Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
983d93c2a7 | ||
|
|
80377a1a22 | ||
|
|
684c889c27 | ||
|
|
55d30ba876 | ||
|
|
eceba04578 | ||
|
|
1fcc539093 | ||
|
|
9e6f06d933 | ||
|
|
42ea75bcb3 | ||
|
|
647a650629 | ||
|
|
c57fd666c9 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -28,6 +28,7 @@ ENV/
|
|||||||
.coverage.*
|
.coverage.*
|
||||||
htmlcov/
|
htmlcov/
|
||||||
.tox/
|
.tox/
|
||||||
|
pytest-out.txt
|
||||||
.nox/
|
.nox/
|
||||||
coverage.xml
|
coverage.xml
|
||||||
*.cover
|
*.cover
|
||||||
@@ -159,3 +160,6 @@ pytest-cache-files-*/
|
|||||||
# Project specific
|
# Project specific
|
||||||
# ====================================
|
# ====================================
|
||||||
# Add project-specific ignores here
|
# Add project-specific ignores here
|
||||||
|
.worktrees/
|
||||||
|
worktrees/
|
||||||
|
/.agents
|
||||||
|
|||||||
37
CHANGELOG.md
37
CHANGELOG.md
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [3.1.0] - 2026-02-23
|
||||||
|
|
||||||
|
### Live Platform Foundation (Track E.1 - E.3)
|
||||||
|
|
||||||
|
Major release introducing the Live Platform architecture — SQLite database, OAuth social login, site management, and per-user MCP endpoints. All features are included in the Community Edition.
|
||||||
|
|
||||||
|
#### Added
|
||||||
|
- **SQLite Database Backend** (E.1): Async SQLite via aiosqlite, WAL mode, schema versioning with migrations framework, CRUD for users/sites/API keys/connection tokens
|
||||||
|
- **Credential Encryption** (E.1): AES-256-GCM with HKDF per-site key derivation, versioned wire format for future migration support
|
||||||
|
- **OAuth Social Login** (E.2): GitHub + Google OAuth 2.0, CSRF state tokens, JWT session management, dual session types (admin + oauth_user)
|
||||||
|
- **Site Management API** (E.3): Plugin credential definitions for all 9 plugins, connection validation, encrypted site CRUD, MAX_SITES_PER_USER=10
|
||||||
|
- **User API Keys** (E.3): bcrypt-hashed keys with `mhu_` prefix, 8-char prefix for indexed lookup, 5-minute validation cache
|
||||||
|
- **Per-User MCP Endpoints** (E.3): Direct JSON-RPC handler at `/u/{user_id}/{alias}/mcp`, per-user rate limiting (30/min, 500/hr)
|
||||||
|
- **Config Snippets** (E.3): Auto-generated config for Claude Desktop, Claude Code, Cursor, VS Code, and ChatGPT
|
||||||
|
- **Dashboard Pages**: My Sites (list/add/test/delete), Connect (API keys + config snippets), Profile, OAuth Login
|
||||||
|
- **Dark/Light Mode Toggle**: Theme switcher across all dashboard pages
|
||||||
|
- **RBAC**: Role-based access control for dashboard
|
||||||
|
- **Active Health Checks**: Background health monitoring for connected services
|
||||||
|
|
||||||
|
#### Fixed
|
||||||
|
- CSRF middleware body consumption bug
|
||||||
|
- OAuth log noise and DCR crash on startup
|
||||||
|
- WordPress site connection validation (uses aiohttp to match plugin client)
|
||||||
|
- Tenant isolation enforced on all site queries
|
||||||
|
|
||||||
|
#### Security
|
||||||
|
- All bare `except:` replaced with `except Exception:` across 12 files
|
||||||
|
- Network error differentiation (DNS, SSL, timeout, connection refused)
|
||||||
|
- Retry with exponential backoff for transient errors only
|
||||||
|
- Auth/config errors never retried
|
||||||
|
|
||||||
|
#### Tests
|
||||||
|
- 452 total tests (up from 303), all passing
|
||||||
|
- New test suites: database (37), encryption (27), user_auth (32), site_api (17), user_keys (14), user_endpoints (12), config_snippets (10)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.9.0] - 2026-02-14
|
## [2.9.0] - 2026-02-14
|
||||||
|
|
||||||
### Project Revival - Dependency Updates & Documentation Sync
|
### Project Revival - Dependency Updates & Documentation Sync
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
<!-- mcp-name: io.github.airano-ir/mcphub -->
|
||||||
|
|
||||||
# MCP Hub
|
# MCP Hub
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|||||||
@@ -45,12 +45,10 @@ from core.rate_limiter import RateLimitConfig, RateLimiter, get_rate_limiter
|
|||||||
from core.site_manager import SiteConfig, SiteManager, get_site_manager
|
from core.site_manager import SiteConfig, SiteManager, get_site_manager
|
||||||
|
|
||||||
# Legacy (kept for backward compatibility, will be removed in v2.0)
|
# Legacy (kept for backward compatibility, will be removed in v2.0)
|
||||||
from core.site_registry import SiteInfo, SiteRegistry, get_site_registry
|
|
||||||
from core.tool_generator import ToolGenerator
|
from core.tool_generator import ToolGenerator
|
||||||
|
|
||||||
# Tool Management (Option B architecture)
|
# Tool Management (Option B architecture)
|
||||||
from core.tool_registry import ToolDefinition, ToolRegistry, get_tool_registry
|
from core.tool_registry import ToolDefinition, ToolRegistry, get_tool_registry
|
||||||
from core.unified_tools import UnifiedToolGenerator
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Authentication
|
# Authentication
|
||||||
@@ -64,11 +62,6 @@ __all__ = [
|
|||||||
"SiteManager",
|
"SiteManager",
|
||||||
"SiteConfig",
|
"SiteConfig",
|
||||||
"get_site_manager",
|
"get_site_manager",
|
||||||
# Legacy (deprecated)
|
|
||||||
"SiteRegistry",
|
|
||||||
"SiteInfo",
|
|
||||||
"get_site_registry",
|
|
||||||
"UnifiedToolGenerator",
|
|
||||||
# Tool Management
|
# Tool Management
|
||||||
"ToolRegistry",
|
"ToolRegistry",
|
||||||
"ToolDefinition",
|
"ToolDefinition",
|
||||||
|
|||||||
@@ -51,10 +51,13 @@ class AuthManager:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if valid
|
bool: True if valid
|
||||||
"""
|
"""
|
||||||
is_valid = secrets.compare_digest(api_key, self.master_api_key)
|
if not self.master_api_key:
|
||||||
|
return False
|
||||||
|
|
||||||
|
is_valid = secrets.compare_digest(api_key.strip(), self.master_api_key.strip())
|
||||||
|
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
logger.warning("Invalid API key attempt")
|
logger.debug("Master key validation failed for provided token")
|
||||||
|
|
||||||
return is_valid
|
return is_valid
|
||||||
|
|
||||||
|
|||||||
115
core/config_snippets.py
Normal file
115
core/config_snippets.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""MCP client configuration snippet generation (Track E.3).
|
||||||
|
|
||||||
|
Generates copy-paste configuration snippets for connecting AI clients
|
||||||
|
(Claude Desktop, Claude Code, Cursor, VS Code, ChatGPT) to per-user
|
||||||
|
MCP endpoints.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from core.config_snippets import generate_config, get_supported_clients
|
||||||
|
|
||||||
|
snippet = generate_config(
|
||||||
|
base_url="https://mcp.example.com",
|
||||||
|
user_id="abc123",
|
||||||
|
alias="myblog",
|
||||||
|
api_key="mhu_...",
|
||||||
|
client_type="claude_desktop",
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
# Supported MCP client types
|
||||||
|
SUPPORTED_CLIENTS = [
|
||||||
|
{
|
||||||
|
"id": "claude_desktop",
|
||||||
|
"label": "Claude Desktop",
|
||||||
|
"description": "Anthropic's desktop app for Claude",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "claude_code",
|
||||||
|
"label": "Claude Code",
|
||||||
|
"description": "Anthropic's CLI for Claude",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cursor",
|
||||||
|
"label": "Cursor",
|
||||||
|
"description": "AI-first code editor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "vscode",
|
||||||
|
"label": "VS Code",
|
||||||
|
"description": "Visual Studio Code with MCP extension",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "chatgpt",
|
||||||
|
"label": "ChatGPT",
|
||||||
|
"description": "OpenAI ChatGPT (URL-based)",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_supported_clients() -> list[dict[str, str]]:
|
||||||
|
"""Return the list of supported MCP client types."""
|
||||||
|
return SUPPORTED_CLIENTS
|
||||||
|
|
||||||
|
|
||||||
|
def generate_config(
|
||||||
|
base_url: str,
|
||||||
|
user_id: str,
|
||||||
|
alias: str,
|
||||||
|
api_key: str,
|
||||||
|
client_type: str,
|
||||||
|
) -> str:
|
||||||
|
"""Generate a configuration snippet for the given MCP client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Public URL of the MCP Hub instance (no trailing slash).
|
||||||
|
user_id: User UUID.
|
||||||
|
alias: Site alias.
|
||||||
|
api_key: User API key (``mhu_...``).
|
||||||
|
client_type: One of the supported client type IDs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON configuration string ready for copy-paste.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If client_type is not supported.
|
||||||
|
"""
|
||||||
|
base_url = base_url.rstrip("/")
|
||||||
|
endpoint_url = f"{base_url}/u/{user_id}/{alias}/mcp"
|
||||||
|
server_name = f"mcphub-{alias}"
|
||||||
|
|
||||||
|
if client_type in ("claude_desktop", "claude_code"):
|
||||||
|
config = {
|
||||||
|
"mcpServers": {
|
||||||
|
server_name: {
|
||||||
|
"url": endpoint_url,
|
||||||
|
"headers": {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.dumps(config, indent=2)
|
||||||
|
|
||||||
|
elif client_type in ("cursor", "vscode"):
|
||||||
|
config = {
|
||||||
|
"mcp": {
|
||||||
|
"servers": {
|
||||||
|
server_name: {
|
||||||
|
"url": endpoint_url,
|
||||||
|
"headers": {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return json.dumps(config, indent=2)
|
||||||
|
|
||||||
|
elif client_type == "chatgpt":
|
||||||
|
return endpoint_url
|
||||||
|
|
||||||
|
else:
|
||||||
|
valid = [c["id"] for c in SUPPORTED_CLIENTS]
|
||||||
|
raise ValueError(f"Unsupported client type '{client_type}'. Valid: {valid}")
|
||||||
@@ -89,8 +89,11 @@ class DashboardAuth:
|
|||||||
if not api_key:
|
if not api_key:
|
||||||
return False, "", None
|
return False, "", None
|
||||||
|
|
||||||
|
api_key_clean = api_key.strip()
|
||||||
# Check master API key (from env var)
|
# Check master API key (from env var)
|
||||||
if self.master_api_key and secrets.compare_digest(api_key, self.master_api_key):
|
if self.master_api_key and secrets.compare_digest(
|
||||||
|
api_key_clean, self.master_api_key.strip()
|
||||||
|
):
|
||||||
return True, "master", None
|
return True, "master", None
|
||||||
|
|
||||||
# Check AuthManager's master key (covers auto-generated temp keys)
|
# Check AuthManager's master key (covers auto-generated temp keys)
|
||||||
@@ -202,6 +205,9 @@ class DashboardAuth:
|
|||||||
user_type=payload["type"],
|
user_type=payload["type"],
|
||||||
key_id=payload.get("kid"),
|
key_id=payload.get("kid"),
|
||||||
)
|
)
|
||||||
|
except KeyError as e:
|
||||||
|
logger.debug(f"Invalid dashboard session payload (missing key): {e}")
|
||||||
|
return None
|
||||||
except jwt.ExpiredSignatureError:
|
except jwt.ExpiredSignatureError:
|
||||||
logger.debug("Dashboard session expired")
|
logger.debug("Dashboard session expired")
|
||||||
return None
|
return None
|
||||||
@@ -224,6 +230,27 @@ class DashboardAuth:
|
|||||||
return None
|
return None
|
||||||
return self.validate_session(token)
|
return self.validate_session(token)
|
||||||
|
|
||||||
|
def get_user_session_from_request(self, request: Request) -> dict | None:
|
||||||
|
"""Extract and validate an OAuth user session from request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Starlette request object.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User session dict (user_id, email, name, role, type)
|
||||||
|
or None.
|
||||||
|
"""
|
||||||
|
token = request.cookies.get(self.COOKIE_NAME)
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from core.user_auth import get_user_auth
|
||||||
|
|
||||||
|
user_auth = get_user_auth()
|
||||||
|
return user_auth.validate_user_session(token)
|
||||||
|
except (RuntimeError, Exception):
|
||||||
|
return None
|
||||||
|
|
||||||
def set_session_cookie(self, response: Response, token: str) -> Response:
|
def set_session_cookie(self, response: Response, token: str) -> Response:
|
||||||
"""
|
"""
|
||||||
Set session cookie on response.
|
Set session cookie on response.
|
||||||
@@ -273,13 +300,15 @@ class DashboardAuth:
|
|||||||
RedirectResponse to login page if not authenticated, None if OK.
|
RedirectResponse to login page if not authenticated, None if OK.
|
||||||
"""
|
"""
|
||||||
session = self.get_session_from_request(request)
|
session = self.get_session_from_request(request)
|
||||||
if not session:
|
user_session = self.get_user_session_from_request(request)
|
||||||
|
|
||||||
|
if not session and not user_session:
|
||||||
# Store original URL for redirect after login
|
# Store original URL for redirect after login
|
||||||
next_url = str(request.url.path)
|
next_url = str(request.url.path)
|
||||||
if request.url.query:
|
if request.url.query:
|
||||||
next_url += f"?{request.url.query}"
|
next_url += f"?{request.url.query}"
|
||||||
return RedirectResponse(
|
return RedirectResponse(
|
||||||
url=f"/dashboard/login?next={next_url}",
|
url=f"/auth/login?next={next_url}",
|
||||||
status_code=303,
|
status_code=303,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -291,3 +320,57 @@ def get_dashboard_auth() -> DashboardAuth:
|
|||||||
if _dashboard_auth is None:
|
if _dashboard_auth is None:
|
||||||
_dashboard_auth = DashboardAuth()
|
_dashboard_auth = DashboardAuth()
|
||||||
return _dashboard_auth
|
return _dashboard_auth
|
||||||
|
|
||||||
|
|
||||||
|
# ── Role-checking helpers ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def is_admin_session(session) -> bool:
|
||||||
|
"""Check if session is admin (master key or API key with admin scope).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DashboardSession or OAuth user dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if admin session.
|
||||||
|
"""
|
||||||
|
if isinstance(session, DashboardSession):
|
||||||
|
return session.user_type in ("master", "api_key")
|
||||||
|
if isinstance(session, dict):
|
||||||
|
return session.get("type") == "master" or session.get("role") == "admin"
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_display_info(session) -> dict:
|
||||||
|
"""Get display info for header/UI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DashboardSession or OAuth user dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with name, type, email, avatar keys.
|
||||||
|
"""
|
||||||
|
if isinstance(session, DashboardSession):
|
||||||
|
return {"name": "Admin", "type": "admin", "email": None, "avatar": None}
|
||||||
|
if isinstance(session, dict):
|
||||||
|
return {
|
||||||
|
"name": session.get("name") or session.get("email", "User"),
|
||||||
|
"type": "user",
|
||||||
|
"email": session.get("email"),
|
||||||
|
"avatar": None,
|
||||||
|
}
|
||||||
|
return {"name": "Unknown", "type": "unknown", "email": None, "avatar": None}
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_user_id(session) -> str | None:
|
||||||
|
"""Get user_id for OAuth sessions, None for admin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DashboardSession or OAuth user dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User UUID string or None.
|
||||||
|
"""
|
||||||
|
if isinstance(session, dict):
|
||||||
|
return session.get("user_id")
|
||||||
|
return None
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
706
core/database.py
Normal file
706
core/database.py
Normal file
@@ -0,0 +1,706 @@
|
|||||||
|
"""SQLite database backend for the Live Platform (Track E).
|
||||||
|
|
||||||
|
Manages the database connection, schema creation, migrations, and CRUD
|
||||||
|
operations for the user system. Uses aiosqlite for async SQLite access
|
||||||
|
with WAL mode and foreign key enforcement.
|
||||||
|
|
||||||
|
This module is only for user-registered sites on the Live Platform.
|
||||||
|
Admin endpoints continue to use env var sites via SiteManager.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
db = await initialize_database()
|
||||||
|
user = await db.create_user(
|
||||||
|
email="user@example.com",
|
||||||
|
name="Jane",
|
||||||
|
provider="github",
|
||||||
|
provider_id="12345",
|
||||||
|
)
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
# Or as a context manager:
|
||||||
|
async with Database("/tmp/test.db") as db:
|
||||||
|
user = await db.get_user_by_id("some-uuid")
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default data directory: /app/data in Docker, ./data elsewhere
|
||||||
|
_DEFAULT_DATA_DIR = "/app/data" if Path("/app").exists() else "./data"
|
||||||
|
|
||||||
|
# Schema version — increment when adding migrations
|
||||||
|
SCHEMA_VERSION = 3
|
||||||
|
|
||||||
|
# Initial schema DDL
|
||||||
|
_SCHEMA_SQL = """\
|
||||||
|
-- Users (OAuth social login)
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
email TEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
provider TEXT NOT NULL,
|
||||||
|
provider_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'user',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_login TEXT,
|
||||||
|
UNIQUE(provider, provider_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- User's registered sites (any plugin type)
|
||||||
|
CREATE TABLE IF NOT EXISTS sites (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
plugin_type TEXT NOT NULL,
|
||||||
|
alias TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
credentials BLOB NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
status_msg TEXT,
|
||||||
|
last_health TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
UNIQUE(user_id, alias)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Per-user API keys (for MCP client authentication)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_api_keys (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
key_hash TEXT NOT NULL,
|
||||||
|
key_prefix TEXT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
scopes TEXT NOT NULL DEFAULT 'read write',
|
||||||
|
last_used TEXT,
|
||||||
|
use_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
-- WP plugin connection tokens (short-lived, for MCP Connect plugin)
|
||||||
|
CREATE TABLE IF NOT EXISTS connection_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
token_hash TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
used INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Schema version tracking
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_version (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Migration registry: version -> SQL string
|
||||||
|
# Migrations are applied sequentially from the current version + 1 up to SCHEMA_VERSION.
|
||||||
|
_MIGRATIONS: dict[int, str] = {
|
||||||
|
2: (
|
||||||
|
"ALTER TABLE user_api_keys ADD COLUMN key_prefix TEXT;\n"
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_user_api_keys_prefix ON user_api_keys(key_prefix);\n"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""Async SQLite database for the Live Platform.
|
||||||
|
|
||||||
|
Manages connections, schema, migrations, and provides CRUD helpers
|
||||||
|
for users, sites, API keys, and connection tokens.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
db_path: Resolved path to the SQLite database file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str | None = None) -> None:
|
||||||
|
"""Initialize database configuration.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the SQLite database file. If not provided,
|
||||||
|
reads DATABASE_PATH env var, defaulting to ``data/mcphub.db``.
|
||||||
|
"""
|
||||||
|
if db_path is None:
|
||||||
|
db_file = os.getenv("DATABASE_PATH", None)
|
||||||
|
if db_file:
|
||||||
|
self.db_path = Path(db_file)
|
||||||
|
else:
|
||||||
|
self.db_path = Path(_DEFAULT_DATA_DIR) / "mcphub.db"
|
||||||
|
else:
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
|
||||||
|
self._conn: aiosqlite.Connection | None = None
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
"""Create data directory, connect, and set up schema.
|
||||||
|
|
||||||
|
Enables WAL mode and foreign keys, creates tables if they do
|
||||||
|
not exist, and runs any pending migrations.
|
||||||
|
"""
|
||||||
|
# Ensure parent directory exists
|
||||||
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self._conn = await aiosqlite.connect(str(self.db_path))
|
||||||
|
self._conn.row_factory = aiosqlite.Row
|
||||||
|
|
||||||
|
# Enable WAL mode for better concurrent read performance
|
||||||
|
await self._conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
# Enable foreign key enforcement
|
||||||
|
await self._conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
|
||||||
|
await self._create_schema()
|
||||||
|
await self._run_migrations()
|
||||||
|
|
||||||
|
logger.info("Database initialized at %s", self.db_path)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close the database connection."""
|
||||||
|
if self._conn is not None:
|
||||||
|
await self._conn.close()
|
||||||
|
self._conn = None
|
||||||
|
logger.info("Database connection closed")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Context manager
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def __aenter__(self) -> "Database":
|
||||||
|
"""Enter async context — initialize and return self."""
|
||||||
|
await self.initialize()
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
||||||
|
"""Exit async context — close the connection."""
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Low-level query helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _require_conn(self) -> aiosqlite.Connection:
|
||||||
|
"""Return the active connection or raise if not initialized."""
|
||||||
|
if self._conn is None:
|
||||||
|
raise RuntimeError("Database not initialized. Call initialize() first.")
|
||||||
|
return self._conn
|
||||||
|
|
||||||
|
async def execute(self, sql: str, params: tuple[Any, ...] = ()) -> aiosqlite.Cursor:
|
||||||
|
"""Execute a single write SQL statement and commit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sql: SQL statement.
|
||||||
|
params: Bind parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The aiosqlite Cursor.
|
||||||
|
"""
|
||||||
|
conn = self._require_conn()
|
||||||
|
cursor = await conn.execute(sql, params)
|
||||||
|
await conn.commit()
|
||||||
|
return cursor
|
||||||
|
|
||||||
|
async def executemany(self, sql: str, params_list: list[tuple[Any, ...]]) -> aiosqlite.Cursor:
|
||||||
|
"""Execute a SQL statement with multiple parameter sets and commit.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sql: SQL statement.
|
||||||
|
params_list: List of parameter tuples.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The aiosqlite Cursor.
|
||||||
|
"""
|
||||||
|
conn = self._require_conn()
|
||||||
|
cursor = await conn.executemany(sql, params_list)
|
||||||
|
await conn.commit()
|
||||||
|
return cursor
|
||||||
|
|
||||||
|
async def fetchone(self, sql: str, params: tuple[Any, ...] = ()) -> dict[str, Any] | None:
|
||||||
|
"""Fetch a single row as a dictionary (read-only, no commit).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sql: SQL query.
|
||||||
|
params: Bind parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Row as a dict, or None if no result.
|
||||||
|
"""
|
||||||
|
conn = self._require_conn()
|
||||||
|
cursor = await conn.execute(sql, params)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return dict(row)
|
||||||
|
|
||||||
|
async def fetchall(self, sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch all rows as a list of dictionaries (read-only, no commit).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sql: SQL query.
|
||||||
|
params: Bind parameters.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of rows, each as a dict.
|
||||||
|
"""
|
||||||
|
conn = self._require_conn()
|
||||||
|
cursor = await conn.execute(sql, params)
|
||||||
|
rows = await cursor.fetchall()
|
||||||
|
return [dict(row) for row in rows]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Schema management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def _create_schema(self) -> None:
|
||||||
|
"""Create all tables if they do not already exist."""
|
||||||
|
conn = self._require_conn()
|
||||||
|
|
||||||
|
# Check if it's a completely fresh DB (no users table)
|
||||||
|
row = await self.fetchone(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
|
||||||
|
)
|
||||||
|
is_fresh = row is None
|
||||||
|
|
||||||
|
await conn.executescript(_SCHEMA_SQL)
|
||||||
|
|
||||||
|
if is_fresh:
|
||||||
|
# Seed initial schema version
|
||||||
|
row = await self.fetchone("SELECT MAX(version) AS v FROM schema_version")
|
||||||
|
if row is None or row["v"] is None:
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
||||||
|
(SCHEMA_VERSION, _utc_now()),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# For existing v1 databases, if schema_version was just created
|
||||||
|
row = await self.fetchone("SELECT COUNT(*) as c FROM schema_version")
|
||||||
|
if row and row["c"] == 0:
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
||||||
|
(1, _utc_now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _run_migrations(self) -> None:
|
||||||
|
"""Apply any pending migrations sequentially."""
|
||||||
|
conn = self._require_conn()
|
||||||
|
row = await self.fetchone("SELECT MAX(version) AS v FROM schema_version")
|
||||||
|
current = row["v"] if row and row["v"] is not None else 0
|
||||||
|
|
||||||
|
for version in range(current + 1, SCHEMA_VERSION + 1):
|
||||||
|
if version == 3:
|
||||||
|
logger.info("Applying python migration for version 3")
|
||||||
|
# Idempotent repair for v1->v2 upgrade bug (some DBs stamped v2 but missed the column)
|
||||||
|
try:
|
||||||
|
await self.execute("ALTER TABLE user_api_keys ADD COLUMN key_prefix TEXT")
|
||||||
|
except Exception as e:
|
||||||
|
if "duplicate column name" not in str(e).lower():
|
||||||
|
raise
|
||||||
|
await self.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_user_api_keys_prefix ON user_api_keys(key_prefix)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
migration_sql = _MIGRATIONS.get(version)
|
||||||
|
if migration_sql is not None:
|
||||||
|
logger.info("Applying migration to version %d", version)
|
||||||
|
await conn.executescript(migration_sql)
|
||||||
|
logger.info("Migration to version %d applied", version)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"No migration SQL for version %d, recording version only", version
|
||||||
|
)
|
||||||
|
|
||||||
|
# Always record version to avoid infinite retry
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO schema_version (version, applied_at) VALUES (?, ?)",
|
||||||
|
(version, _utc_now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# User CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_user(
|
||||||
|
self,
|
||||||
|
email: str,
|
||||||
|
name: str | None,
|
||||||
|
provider: str,
|
||||||
|
provider_id: str,
|
||||||
|
avatar_url: str | None = None,
|
||||||
|
role: str = "user",
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: User email (unique).
|
||||||
|
name: Display name.
|
||||||
|
provider: OAuth provider ('github' or 'google').
|
||||||
|
provider_id: Provider's unique user ID.
|
||||||
|
avatar_url: URL to user avatar.
|
||||||
|
role: User role ('user' or 'admin').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created user as a dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
aiosqlite.IntegrityError: If email or provider+provider_id already exists.
|
||||||
|
"""
|
||||||
|
user_id = str(uuid.uuid4())
|
||||||
|
now = _utc_now()
|
||||||
|
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO users (id, email, name, avatar_url, provider, provider_id, role, "
|
||||||
|
"created_at, last_login) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(user_id, email, name, avatar_url, provider, provider_id, role, now, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.get_user_by_id(user_id)
|
||||||
|
if result is None:
|
||||||
|
raise RuntimeError(f"Failed to read back created user {user_id}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_user_by_id(self, user_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Get a user by their UUID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User dict, or None if not found.
|
||||||
|
"""
|
||||||
|
return await self.fetchone("SELECT * FROM users WHERE id = ?", (user_id,))
|
||||||
|
|
||||||
|
async def get_user_by_email(self, email: str) -> dict[str, Any] | None:
|
||||||
|
"""Get a user by their email.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
email: User email.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User dict, or None if not found.
|
||||||
|
"""
|
||||||
|
return await self.fetchone("SELECT * FROM users WHERE email = ?", (email,))
|
||||||
|
|
||||||
|
async def get_user_by_provider(self, provider: str, provider_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Get a user by OAuth provider and provider ID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: OAuth provider name.
|
||||||
|
provider_id: Provider's unique user ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User dict, or None if not found.
|
||||||
|
"""
|
||||||
|
return await self.fetchone(
|
||||||
|
"SELECT * FROM users WHERE provider = ? AND provider_id = ?",
|
||||||
|
(provider, provider_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_user_last_login(self, user_id: str) -> None:
|
||||||
|
"""Update a user's last_login timestamp to now.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User UUID.
|
||||||
|
"""
|
||||||
|
await self.execute(
|
||||||
|
"UPDATE users SET last_login = ? WHERE id = ?",
|
||||||
|
(_utc_now(), user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Site CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_site(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
plugin_type: str,
|
||||||
|
alias: str,
|
||||||
|
url: str,
|
||||||
|
credentials: bytes,
|
||||||
|
status: str = "pending",
|
||||||
|
status_msg: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new site for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
plugin_type: Plugin type (e.g. 'wordpress', 'gitea').
|
||||||
|
alias: User-chosen friendly name (unique per user).
|
||||||
|
url: Site URL.
|
||||||
|
credentials: AES-256-GCM encrypted credentials blob.
|
||||||
|
status: Initial status ('pending', 'active', 'error', 'disabled').
|
||||||
|
status_msg: Optional human-readable status message.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created site as a dict.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
aiosqlite.IntegrityError: If alias is duplicated for the same user.
|
||||||
|
"""
|
||||||
|
site_id = str(uuid.uuid4())
|
||||||
|
now = _utc_now()
|
||||||
|
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO sites (id, user_id, plugin_type, alias, url, credentials, "
|
||||||
|
"status, status_msg, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(site_id, user_id, plugin_type, alias, url, credentials, status, status_msg, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.get_site(site_id, user_id)
|
||||||
|
if result is None:
|
||||||
|
raise RuntimeError(f"Failed to read back created site {site_id}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_sites_by_user(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""Get all sites belonging to a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of site dicts.
|
||||||
|
"""
|
||||||
|
return await self.fetchall(
|
||||||
|
"SELECT * FROM sites WHERE user_id = ? ORDER BY created_at",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_site(self, site_id: str, user_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Get a single site, scoped to a specific user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID (for tenant isolation).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Site dict, or None if not found or not owned by user.
|
||||||
|
"""
|
||||||
|
return await self.fetchone(
|
||||||
|
"SELECT * FROM sites WHERE id = ? AND user_id = ?",
|
||||||
|
(site_id, user_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_site(self, site_id: str, user_id: str) -> bool:
|
||||||
|
"""Delete a site, scoped to a specific user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID (for tenant isolation).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a row was deleted, False otherwise.
|
||||||
|
"""
|
||||||
|
cursor = await self.execute(
|
||||||
|
"DELETE FROM sites WHERE id = ? AND user_id = ?",
|
||||||
|
(site_id, user_id),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
async def update_site_status(
|
||||||
|
self,
|
||||||
|
site_id: str,
|
||||||
|
status: str,
|
||||||
|
status_msg: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Update a site's status and optional status message.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
status: New status ('pending', 'active', 'error', 'disabled').
|
||||||
|
status_msg: Optional human-readable status message.
|
||||||
|
user_id: Optional user UUID for tenant-scoped update. When provided,
|
||||||
|
only updates if the site belongs to this user. When None,
|
||||||
|
performs system-level update (e.g., health checks).
|
||||||
|
"""
|
||||||
|
if user_id is not None:
|
||||||
|
await self.execute(
|
||||||
|
"UPDATE sites SET status = ?, status_msg = ? WHERE id = ? AND user_id = ?",
|
||||||
|
(status, status_msg, site_id, user_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.execute(
|
||||||
|
"UPDATE sites SET status = ?, status_msg = ? WHERE id = ?",
|
||||||
|
(status, status_msg, site_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_site_by_alias(self, user_id: str, alias: str) -> dict[str, Any] | None:
|
||||||
|
"""Get a site by user ID and alias.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
alias: Site alias (unique per user).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Site dict, or None if not found.
|
||||||
|
"""
|
||||||
|
return await self.fetchone(
|
||||||
|
"SELECT * FROM sites WHERE user_id = ? AND alias = ?",
|
||||||
|
(user_id, alias),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def count_sites_by_user(self, user_id: str) -> int:
|
||||||
|
"""Count the number of sites belonging to a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of sites.
|
||||||
|
"""
|
||||||
|
row = await self.fetchone(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM sites WHERE user_id = ?",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
return row["cnt"] if row else 0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# User API Key CRUD
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def create_api_key(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
key_hash: str,
|
||||||
|
key_prefix: str,
|
||||||
|
name: str,
|
||||||
|
scopes: str = "read write",
|
||||||
|
expires_at: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new user API key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
key_hash: bcrypt hash of the API key.
|
||||||
|
key_prefix: First 8 chars after ``mhu_`` prefix for fast lookup.
|
||||||
|
name: Human label (e.g. "Claude Desktop").
|
||||||
|
scopes: Space-separated scopes.
|
||||||
|
expires_at: Optional ISO 8601 expiry timestamp.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created API key row as a dict.
|
||||||
|
"""
|
||||||
|
key_id = str(uuid.uuid4())
|
||||||
|
now = _utc_now()
|
||||||
|
|
||||||
|
await self.execute(
|
||||||
|
"INSERT INTO user_api_keys "
|
||||||
|
"(id, user_id, key_hash, key_prefix, name, scopes, created_at, expires_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(key_id, user_id, key_hash, key_prefix, name, scopes, now, expires_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.fetchone(
|
||||||
|
"SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, "
|
||||||
|
"created_at, expires_at FROM user_api_keys WHERE id = ?",
|
||||||
|
(key_id,),
|
||||||
|
)
|
||||||
|
if result is None:
|
||||||
|
raise RuntimeError(f"Failed to read back created API key {key_id}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def get_api_keys_by_user(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""Get all API keys for a user (without key_hash).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of API key dicts (key_hash excluded).
|
||||||
|
"""
|
||||||
|
return await self.fetchall(
|
||||||
|
"SELECT id, user_id, key_prefix, name, scopes, last_used, use_count, "
|
||||||
|
"created_at, expires_at FROM user_api_keys WHERE user_id = ? ORDER BY created_at",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_api_key_by_prefix(self, key_prefix: str) -> dict[str, Any] | None:
|
||||||
|
"""Get an API key by its prefix (includes key_hash for verification).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_prefix: First 8 chars of the key after ``mhu_``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
API key dict including key_hash, or None.
|
||||||
|
"""
|
||||||
|
return await self.fetchone(
|
||||||
|
"SELECT * FROM user_api_keys WHERE key_prefix = ?",
|
||||||
|
(key_prefix,),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete_api_key(self, key_id: str, user_id: str) -> bool:
|
||||||
|
"""Delete an API key, scoped to a specific user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: API key UUID.
|
||||||
|
user_id: Owner's UUID (for tenant isolation).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if a row was deleted, False otherwise.
|
||||||
|
"""
|
||||||
|
cursor = await self.execute(
|
||||||
|
"DELETE FROM user_api_keys WHERE id = ? AND user_id = ?",
|
||||||
|
(key_id, user_id),
|
||||||
|
)
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
|
||||||
|
async def update_api_key_usage(self, key_id: str) -> None:
|
||||||
|
"""Increment use_count and update last_used timestamp for an API key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: API key UUID.
|
||||||
|
"""
|
||||||
|
await self.execute(
|
||||||
|
"UPDATE user_api_keys SET use_count = use_count + 1, last_used = ? WHERE id = ?",
|
||||||
|
(_utc_now(), key_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ======================================================================
|
||||||
|
# Module-level helpers
|
||||||
|
# ======================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
"""Return the current UTC time as an ISO 8601 string."""
|
||||||
|
return datetime.now(UTC).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_database: Database | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_database() -> Database:
|
||||||
|
"""Get the singleton Database instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The Database singleton.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If initialize_database() has not been called.
|
||||||
|
"""
|
||||||
|
if _database is None:
|
||||||
|
raise RuntimeError("Database not initialized. Call initialize_database() first.")
|
||||||
|
return _database
|
||||||
|
|
||||||
|
|
||||||
|
async def initialize_database(db_path: str | None = None) -> Database:
|
||||||
|
"""Create, initialize, and store the singleton Database instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Optional path to the SQLite database file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The initialized Database instance.
|
||||||
|
"""
|
||||||
|
global _database
|
||||||
|
db = Database(db_path)
|
||||||
|
await db.initialize()
|
||||||
|
_database = db
|
||||||
|
return db
|
||||||
212
core/encryption.py
Normal file
212
core/encryption.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
"""Credential encryption for the Live Platform.
|
||||||
|
|
||||||
|
Provides AES-256-GCM encryption with HKDF key derivation for per-site
|
||||||
|
credential storage. Credentials are encrypted as JSON blobs and stored
|
||||||
|
in SQLite. Decryption happens only during tool execution and plaintext
|
||||||
|
is never logged.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
encryption = get_credential_encryption()
|
||||||
|
cipherdata = encryption.encrypt_credentials(
|
||||||
|
{"username": "admin", "app_password": "xxxx xxxx"},
|
||||||
|
site_id="site_abc123",
|
||||||
|
)
|
||||||
|
credentials = encryption.decrypt_credentials(cipherdata, site_id="site_abc123")
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
_NONCE_LENGTH = 12 # 96-bit nonce for AES-GCM
|
||||||
|
_KEY_LENGTH = 32 # 256-bit key
|
||||||
|
_HKDF_SALT = b"mcphub-v1"
|
||||||
|
_FORMAT_VERSION = b"\x01" # Wire format version for future migration support
|
||||||
|
|
||||||
|
|
||||||
|
class CredentialEncryption:
|
||||||
|
"""AES-256-GCM encryption with per-site HKDF-derived keys.
|
||||||
|
|
||||||
|
The master key is read from the ENCRYPTION_KEY environment variable
|
||||||
|
(base64-encoded 32-byte key). Per-site keys are derived via HKDF
|
||||||
|
using the site_id as the info parameter, ensuring each site has a
|
||||||
|
unique encryption key.
|
||||||
|
|
||||||
|
Storage format: version (1 byte) || nonce (12 bytes) || ciphertext || tag (16 bytes)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, encryption_key: str | None = None) -> None:
|
||||||
|
"""Initialize credential encryption.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encryption_key: Base64-encoded 32-byte key. If not provided,
|
||||||
|
reads from the ENCRYPTION_KEY environment variable.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If the encryption key is missing or invalid.
|
||||||
|
"""
|
||||||
|
raw_key = encryption_key or os.getenv("ENCRYPTION_KEY")
|
||||||
|
|
||||||
|
if not raw_key:
|
||||||
|
raise ValueError(
|
||||||
|
"ENCRYPTION_KEY is required. Set it as an environment variable "
|
||||||
|
"or pass it directly. Generate one with: "
|
||||||
|
'python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"'
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._master_key = base64.b64decode(raw_key)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError("ENCRYPTION_KEY must be a valid base64-encoded string.") from exc
|
||||||
|
|
||||||
|
if len(self._master_key) != _KEY_LENGTH:
|
||||||
|
raise ValueError(
|
||||||
|
f"ENCRYPTION_KEY must decode to exactly {_KEY_LENGTH} bytes, "
|
||||||
|
f"got {len(self._master_key)} bytes."
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Credential encryption initialized")
|
||||||
|
|
||||||
|
def _derive_key(self, site_id: str) -> bytes:
|
||||||
|
"""Derive a per-site encryption key using HKDF.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Unique identifier for the site.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
32-byte derived key for the given site.
|
||||||
|
"""
|
||||||
|
hkdf = HKDF(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=_KEY_LENGTH,
|
||||||
|
salt=_HKDF_SALT,
|
||||||
|
info=site_id.encode("utf-8"),
|
||||||
|
)
|
||||||
|
return hkdf.derive(self._master_key)
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: str, site_id: str) -> bytes:
|
||||||
|
"""Encrypt a plaintext string for a specific site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plaintext: The string to encrypt.
|
||||||
|
site_id: Site identifier used for key derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encrypted bytes: version (1) || nonce (12) || ciphertext || tag (16).
|
||||||
|
"""
|
||||||
|
derived_key = self._derive_key(site_id)
|
||||||
|
aesgcm = AESGCM(derived_key)
|
||||||
|
nonce = os.urandom(_NONCE_LENGTH)
|
||||||
|
ciphertext_with_tag = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
|
||||||
|
return _FORMAT_VERSION + nonce + ciphertext_with_tag
|
||||||
|
|
||||||
|
def decrypt(self, cipherdata: bytes, site_id: str) -> str:
|
||||||
|
"""Decrypt cipherdata for a specific site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cipherdata: Encrypted bytes (nonce || ciphertext || tag).
|
||||||
|
site_id: Site identifier used for key derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Original plaintext string.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
cryptography.exceptions.InvalidTag: If decryption fails
|
||||||
|
(wrong key, tampered data, or wrong site_id).
|
||||||
|
ValueError: If cipherdata is too short or has unsupported version.
|
||||||
|
"""
|
||||||
|
# Minimum: 1 (version) + 12 (nonce) + 16 (tag) = 29 bytes
|
||||||
|
min_length = 1 + _NONCE_LENGTH + 16
|
||||||
|
if len(cipherdata) < min_length:
|
||||||
|
raise ValueError(
|
||||||
|
f"Cipherdata too short: expected at least {min_length} bytes, "
|
||||||
|
f"got {len(cipherdata)}."
|
||||||
|
)
|
||||||
|
|
||||||
|
version = cipherdata[:1]
|
||||||
|
if version != _FORMAT_VERSION:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported encryption format version: {version!r}. "
|
||||||
|
f"Expected {_FORMAT_VERSION!r}."
|
||||||
|
)
|
||||||
|
|
||||||
|
nonce = cipherdata[1 : 1 + _NONCE_LENGTH]
|
||||||
|
ciphertext_with_tag = cipherdata[1 + _NONCE_LENGTH :]
|
||||||
|
|
||||||
|
derived_key = self._derive_key(site_id)
|
||||||
|
aesgcm = AESGCM(derived_key)
|
||||||
|
plaintext_bytes = aesgcm.decrypt(nonce, ciphertext_with_tag, None)
|
||||||
|
return plaintext_bytes.decode("utf-8")
|
||||||
|
|
||||||
|
def encrypt_credentials(self, credentials: dict, site_id: str) -> bytes:
|
||||||
|
"""Encrypt a credentials dictionary for a specific site.
|
||||||
|
|
||||||
|
The dictionary is serialized to JSON, then encrypted.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
credentials: Dictionary of credentials (e.g., username, password).
|
||||||
|
site_id: Site identifier used for key derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encrypted bytes.
|
||||||
|
"""
|
||||||
|
json_str = json.dumps(credentials, separators=(",", ":"), sort_keys=True)
|
||||||
|
return self.encrypt(json_str, site_id)
|
||||||
|
|
||||||
|
def decrypt_credentials(self, cipherdata: bytes, site_id: str) -> dict:
|
||||||
|
"""Decrypt cipherdata back to a credentials dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cipherdata: Encrypted bytes from encrypt_credentials.
|
||||||
|
site_id: Site identifier used for key derivation.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Original credentials dictionary.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
cryptography.exceptions.InvalidTag: If decryption fails.
|
||||||
|
json.JSONDecodeError: If decrypted data is not valid JSON.
|
||||||
|
"""
|
||||||
|
json_str = self.decrypt(cipherdata, site_id)
|
||||||
|
return json.loads(json_str)
|
||||||
|
|
||||||
|
|
||||||
|
# Global credential encryption instance
|
||||||
|
_credential_encryption: CredentialEncryption | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_credential_encryption(
|
||||||
|
encryption_key: str | None = None,
|
||||||
|
) -> CredentialEncryption:
|
||||||
|
"""Initialize the global credential encryption instance.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encryption_key: Base64-encoded 32-byte key. If not provided,
|
||||||
|
reads from the ENCRYPTION_KEY environment variable.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The initialized CredentialEncryption instance.
|
||||||
|
"""
|
||||||
|
global _credential_encryption
|
||||||
|
_credential_encryption = CredentialEncryption(encryption_key)
|
||||||
|
return _credential_encryption
|
||||||
|
|
||||||
|
|
||||||
|
def get_credential_encryption() -> CredentialEncryption:
|
||||||
|
"""Get the global credential encryption instance.
|
||||||
|
|
||||||
|
Lazily initializes from the ENCRYPTION_KEY environment variable
|
||||||
|
if not already initialized via initialize_credential_encryption().
|
||||||
|
"""
|
||||||
|
global _credential_encryption
|
||||||
|
if _credential_encryption is None:
|
||||||
|
_credential_encryption = CredentialEncryption()
|
||||||
|
return _credential_encryption
|
||||||
104
core/health.py
104
core/health.py
@@ -12,6 +12,7 @@ This module provides comprehensive health monitoring capabilities including:
|
|||||||
Author: MCP Hub Team
|
Author: MCP Hub Team
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
@@ -175,6 +176,11 @@ class HealthMonitor:
|
|||||||
# Request rate tracking (for requests per minute)
|
# Request rate tracking (for requests per minute)
|
||||||
self.request_timestamps: deque = deque(maxlen=1000)
|
self.request_timestamps: deque = deque(maxlen=1000)
|
||||||
|
|
||||||
|
# Active background checks
|
||||||
|
self.latest_health_status: dict[str, ProjectHealthStatus] = {}
|
||||||
|
self._bg_task: asyncio.Task | None = None
|
||||||
|
self._is_running = False
|
||||||
|
|
||||||
logger.info("HealthMonitor initialized")
|
logger.info("HealthMonitor initialized")
|
||||||
|
|
||||||
def _setup_default_thresholds(self):
|
def _setup_default_thresholds(self):
|
||||||
@@ -470,7 +476,7 @@ class HealthMonitor:
|
|||||||
}
|
}
|
||||||
alerts = self._check_alerts(project_id, alert_check_data)
|
alerts = self._check_alerts(project_id, alert_check_data)
|
||||||
|
|
||||||
return ProjectHealthStatus(
|
status = ProjectHealthStatus(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
healthy=is_healthy,
|
healthy=is_healthy,
|
||||||
last_check=datetime.now(UTC),
|
last_check=datetime.now(UTC),
|
||||||
@@ -480,6 +486,8 @@ class HealthMonitor:
|
|||||||
alerts=alerts,
|
alerts=alerts,
|
||||||
details=health_result,
|
details=health_result,
|
||||||
)
|
)
|
||||||
|
self.latest_health_status[project_id] = status
|
||||||
|
return status
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
response_time_ms = (time.time() - start_time) * 1000
|
response_time_ms = (time.time() - start_time) * 1000
|
||||||
@@ -493,7 +501,7 @@ class HealthMonitor:
|
|||||||
error_message=error_msg,
|
error_message=error_msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ProjectHealthStatus(
|
status = ProjectHealthStatus(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
healthy=False,
|
healthy=False,
|
||||||
last_check=datetime.now(UTC),
|
last_check=datetime.now(UTC),
|
||||||
@@ -502,6 +510,8 @@ class HealthMonitor:
|
|||||||
recent_errors=[error_msg],
|
recent_errors=[error_msg],
|
||||||
alerts=[f"CRITICAL: Health check failed - {error_msg}"],
|
alerts=[f"CRITICAL: Health check failed - {error_msg}"],
|
||||||
)
|
)
|
||||||
|
self.latest_health_status[project_id] = status
|
||||||
|
return status
|
||||||
|
|
||||||
def _find_site_info(self, project_id: str) -> dict[str, Any] | None:
|
def _find_site_info(self, project_id: str) -> dict[str, Any] | None:
|
||||||
"""Find site info from SiteManager by full_id."""
|
"""Find site info from SiteManager by full_id."""
|
||||||
@@ -543,16 +553,41 @@ class HealthMonitor:
|
|||||||
plugin_instance = plugin_registry.create_instance(plugin_type, site_id, config_dict)
|
plugin_instance = plugin_registry.create_instance(plugin_type, site_id, config_dict)
|
||||||
return await plugin_instance.health_check()
|
return await plugin_instance.health_check()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(
|
logger.warning(
|
||||||
f"Could not create plugin instance for {project_id}, "
|
f"Could not create plugin instance for {project_id}, "
|
||||||
f"falling back to basic HTTP check: {e}"
|
f"falling back to authenticated HTTP check: {e}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fallback: basic HTTP check if plugin instantiation fails
|
# Fallback: authenticated health check for WordPress-based plugins
|
||||||
|
if plugin_type in ("wordpress", "wordpress_advanced", "woocommerce"):
|
||||||
|
try:
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
auth = aiohttp.BasicAuth(
|
||||||
|
config.username or "",
|
||||||
|
config.app_password or "",
|
||||||
|
)
|
||||||
|
async with aiohttp.ClientSession(auth=auth) as session:
|
||||||
|
async with session.get(
|
||||||
|
f"{config.url}/wp-json/wp/v2/users/me",
|
||||||
|
timeout=aiohttp.ClientTimeout(total=10),
|
||||||
|
ssl=False,
|
||||||
|
) as resp:
|
||||||
|
auth_valid = resp.status == 200
|
||||||
|
basic_result = await self._basic_http_health_check(config.url, project_id)
|
||||||
|
basic_result["auth_valid"] = auth_valid
|
||||||
|
if not auth_valid:
|
||||||
|
basic_result["auth_warning"] = "Site accessible but credentials may be invalid"
|
||||||
|
return basic_result
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Last resort: basic HTTP check
|
||||||
return await self._basic_http_health_check(config.url, project_id)
|
return await self._basic_http_health_check(config.url, project_id)
|
||||||
|
|
||||||
async def _basic_http_health_check(self, url: str | None, project_id: str) -> dict[str, Any]:
|
async def _basic_http_health_check(self, url: str | None, project_id: str) -> dict[str, Any]:
|
||||||
"""Basic HTTP health check as a last-resort fallback."""
|
"""Basic HTTP health check as a last-resort fallback."""
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
if not url:
|
if not url:
|
||||||
@@ -568,8 +603,27 @@ class HealthMonitor:
|
|||||||
"status_code": resp.status,
|
"status_code": resp.status,
|
||||||
"message": f"HTTP {resp.status} from {url}",
|
"message": f"HTTP {resp.status} from {url}",
|
||||||
}
|
}
|
||||||
|
except TimeoutError:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": "timeout",
|
||||||
|
"message": f"Site at {url} did not respond within 10 seconds.",
|
||||||
|
}
|
||||||
|
except aiohttp.ClientConnectorDNSError:
|
||||||
|
host = url.split("://")[-1].split("/")[0]
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": "dns_failure",
|
||||||
|
"message": f"DNS resolution failed for '{host}'. Check the URL.",
|
||||||
|
}
|
||||||
|
except aiohttp.ClientConnectorError:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"error_type": "connection_refused",
|
||||||
|
"message": f"Cannot connect to {url}. Server unreachable.",
|
||||||
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"healthy": False, "message": f"Connection failed: {e}"}
|
return {"healthy": False, "error_type": "unknown", "message": f"Connection failed: {e}"}
|
||||||
|
|
||||||
async def check_all_projects_health(self, include_metrics: bool = True) -> dict[str, Any]:
|
async def check_all_projects_health(self, include_metrics: bool = True) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
@@ -742,8 +796,46 @@ class HealthMonitor:
|
|||||||
self.failed_requests = 0
|
self.failed_requests = 0
|
||||||
self.response_times.clear()
|
self.response_times.clear()
|
||||||
self.request_timestamps.clear()
|
self.request_timestamps.clear()
|
||||||
|
self.latest_health_status.clear()
|
||||||
logger.warning("All metrics have been reset")
|
logger.warning("All metrics have been reset")
|
||||||
|
|
||||||
|
async def start_background_checks(self, interval_seconds: int = 60):
|
||||||
|
"""Start background health checks for all projects."""
|
||||||
|
if self._is_running:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._is_running = True
|
||||||
|
logger.info(f"Starting background health checks every {interval_seconds} seconds")
|
||||||
|
|
||||||
|
async def _loop():
|
||||||
|
# Initial wait to let server start up fully
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
while self._is_running:
|
||||||
|
try:
|
||||||
|
await self.check_all_projects_health(include_metrics=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in background health check loop: {e}")
|
||||||
|
|
||||||
|
# Sleep interval, check _is_running periodically
|
||||||
|
for _ in range(interval_seconds):
|
||||||
|
if not self._is_running:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
self._bg_task = asyncio.create_task(_loop())
|
||||||
|
|
||||||
|
async def stop_background_checks(self):
|
||||||
|
"""Stop background health checks."""
|
||||||
|
self._is_running = False
|
||||||
|
if self._bg_task:
|
||||||
|
self._bg_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._bg_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
self._bg_task = None
|
||||||
|
logger.info("Background health checks stopped")
|
||||||
|
|
||||||
|
|
||||||
# Singleton instance
|
# Singleton instance
|
||||||
_health_monitor: HealthMonitor | None = None
|
_health_monitor: HealthMonitor | None = None
|
||||||
|
|||||||
@@ -241,8 +241,8 @@ class TokenManager:
|
|||||||
try:
|
try:
|
||||||
old_payload = self.validate_access_token(token_data.access_token)
|
old_payload = self.validate_access_token(token_data.access_token)
|
||||||
scope = old_payload.get("scope", "read")
|
scope = old_payload.get("scope", "read")
|
||||||
except:
|
except Exception:
|
||||||
pass # Old token might be expired, use default
|
pass # Old token might be expired, use default scope
|
||||||
|
|
||||||
# Generate new tokens
|
# Generate new tokens
|
||||||
new_access_token = self.generate_access_token(
|
new_access_token = self.generate_access_token(
|
||||||
|
|||||||
545
core/site_api.py
Normal file
545
core/site_api.py
Normal file
@@ -0,0 +1,545 @@
|
|||||||
|
"""Site management logic for the Live Platform (Track E.3).
|
||||||
|
|
||||||
|
Provides site CRUD operations, connection validation, and credential
|
||||||
|
field definitions for all 9 plugin types. Coordinates between the
|
||||||
|
database, encryption, and plugin health check layers.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from core.site_api import create_user_site, get_user_sites, validate_site_connection
|
||||||
|
|
||||||
|
ok, msg = await validate_site_connection("wordpress", "https://example.com", creds)
|
||||||
|
site = await create_user_site(user_id, "wordpress", "myblog", url, creds)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
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"))
|
||||||
|
|
||||||
|
# Plugin credential field definitions — drives the dynamic "Add Site" form
|
||||||
|
# and server-side validation. Each field has:
|
||||||
|
# name: form input name (matches credential JSON key)
|
||||||
|
# label: display label
|
||||||
|
# type: "text" or "password"
|
||||||
|
# required: whether the field is mandatory
|
||||||
|
PLUGIN_CREDENTIAL_FIELDS: dict[str, list[dict[str, Any]]] = {
|
||||||
|
"wordpress": [
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"label": "Username",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Your WordPress admin username",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "app_password",
|
||||||
|
"label": "Application Password",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "WordPress Admin → Users → Profile → Application Passwords",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"woocommerce": [
|
||||||
|
{
|
||||||
|
"name": "consumer_key",
|
||||||
|
"label": "Consumer Key",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": "WooCommerce → Settings → Advanced → REST API",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "consumer_secret",
|
||||||
|
"label": "Consumer Secret",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Shown once when creating the REST API key",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"wordpress_advanced": [
|
||||||
|
{
|
||||||
|
"name": "username",
|
||||||
|
"label": "Username",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Your WordPress admin username",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "app_password",
|
||||||
|
"label": "Application Password",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "WordPress Admin → Users → Profile → Application Passwords",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "container",
|
||||||
|
"label": "Docker Container Name",
|
||||||
|
"type": "text",
|
||||||
|
"required": False,
|
||||||
|
"hint": "Docker container running WordPress (for WP-CLI access)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"gitea": [
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"label": "Access Token",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Gitea → Settings → Applications → Generate Token",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"n8n": [
|
||||||
|
{
|
||||||
|
"name": "api_key",
|
||||||
|
"label": "API Key",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "n8n → Settings → API → Create API Key",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"supabase": [
|
||||||
|
{
|
||||||
|
"name": "service_role_key",
|
||||||
|
"label": "Service Role Key",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Supabase Dashboard → Settings → API → service_role key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "anon_key",
|
||||||
|
"label": "Anon Key",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Supabase Dashboard → Settings → API → anon key",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"openpanel": [
|
||||||
|
{
|
||||||
|
"name": "client_id",
|
||||||
|
"label": "Client ID",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": "OpenPanel admin panel → API section",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "client_secret",
|
||||||
|
"label": "Client Secret",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Generated with your Client ID",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"appwrite": [
|
||||||
|
{
|
||||||
|
"name": "project_id",
|
||||||
|
"label": "Project ID",
|
||||||
|
"type": "text",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Appwrite Console → Project Settings → Project ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "api_key",
|
||||||
|
"label": "API Key",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Appwrite Console → Project Settings → API Keys → Create",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"directus": [
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"label": "Static Token",
|
||||||
|
"type": "password",
|
||||||
|
"required": True,
|
||||||
|
"hint": "Directus → Settings → User → Static Token",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Plugin display names for UI
|
||||||
|
PLUGIN_DISPLAY_NAMES: dict[str, str] = {
|
||||||
|
"wordpress": "WordPress",
|
||||||
|
"woocommerce": "WooCommerce",
|
||||||
|
"wordpress_advanced": "WordPress Advanced",
|
||||||
|
"gitea": "Gitea",
|
||||||
|
"n8n": "n8n",
|
||||||
|
"supabase": "Supabase",
|
||||||
|
"openpanel": "OpenPanel",
|
||||||
|
"appwrite": "Appwrite",
|
||||||
|
"directus": "Directus",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoints per plugin type
|
||||||
|
_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"},
|
||||||
|
"gitea": {"path": "/api/v1/user", "method": "GET"},
|
||||||
|
"n8n": {"path": "/healthz", "method": "GET"},
|
||||||
|
"supabase": {"path": "/rest/v1/", "method": "GET"},
|
||||||
|
"openpanel": {"path": "/api/v1/oauth/token", "method": "POST"},
|
||||||
|
"appwrite": {"path": "/v1/health", "method": "GET"},
|
||||||
|
"directus": {"path": "/server/health", "method": "GET"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_credential_fields(plugin_type: str) -> list[dict[str, Any]]:
|
||||||
|
"""Get credential field definitions for a plugin type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_type: Plugin type name.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of field definition dicts.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If plugin_type is unknown.
|
||||||
|
"""
|
||||||
|
fields = PLUGIN_CREDENTIAL_FIELDS.get(plugin_type)
|
||||||
|
if fields is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown plugin type '{plugin_type}'. "
|
||||||
|
f"Valid: {list(PLUGIN_CREDENTIAL_FIELDS.keys())}"
|
||||||
|
)
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_credential_fields() -> dict[str, list[dict[str, Any]]]:
|
||||||
|
"""Get credential fields for regular (non-admin) users.
|
||||||
|
|
||||||
|
Excludes plugin types that require admin-level infrastructure
|
||||||
|
(e.g., wordpress_advanced needs Docker access).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered dict of plugin_type -> field definitions.
|
||||||
|
"""
|
||||||
|
excluded = {"wordpress_advanced"}
|
||||||
|
return {k: v for k, v in PLUGIN_CREDENTIAL_FIELDS.items() if k not in excluded}
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_plugin_names() -> dict[str, str]:
|
||||||
|
"""Get plugin display names for regular (non-admin) users.
|
||||||
|
|
||||||
|
Excludes plugins that require admin-level infrastructure.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Filtered dict of plugin_type -> display name.
|
||||||
|
"""
|
||||||
|
excluded = {"wordpress_advanced"}
|
||||||
|
return {k: v for k, v in PLUGIN_DISPLAY_NAMES.items() if k not in excluded}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_credentials(plugin_type: str, credentials: dict[str, str]) -> tuple[bool, list[str]]:
|
||||||
|
"""Validate that all required credential fields are present and non-empty.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_type: Plugin type name.
|
||||||
|
credentials: Dict of credential key→value.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (is_valid, list_of_error_messages).
|
||||||
|
"""
|
||||||
|
fields = get_credential_fields(plugin_type)
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
for field in fields:
|
||||||
|
if field["required"]:
|
||||||
|
value = credentials.get(field["name"], "").strip()
|
||||||
|
if not value:
|
||||||
|
errors.append(f"'{field['label']}' is required")
|
||||||
|
|
||||||
|
return (len(errors) == 0, errors)
|
||||||
|
|
||||||
|
|
||||||
|
async def validate_site_connection(
|
||||||
|
plugin_type: str, url: str, credentials: dict[str, str]
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Test connectivity to a site using HTTP health check.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plugin_type: Plugin type name.
|
||||||
|
url: Site URL.
|
||||||
|
credentials: Plaintext credential dict.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message). Message is "OK" on success or
|
||||||
|
a human-readable error description.
|
||||||
|
"""
|
||||||
|
endpoint_info = _HEALTH_ENDPOINTS.get(plugin_type)
|
||||||
|
if endpoint_info is None:
|
||||||
|
return False, f"Unknown plugin type '{plugin_type}'"
|
||||||
|
|
||||||
|
check_url = url.rstrip("/") + endpoint_info["path"]
|
||||||
|
method = endpoint_info["method"]
|
||||||
|
|
||||||
|
# Build auth headers per plugin type
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if plugin_type in ("wordpress", "wordpress_advanced"):
|
||||||
|
import base64
|
||||||
|
|
||||||
|
username = credentials.get("username", "")
|
||||||
|
app_password = credentials.get("app_password", "")
|
||||||
|
token = base64.b64encode(f"{username}:{app_password}".encode()).decode()
|
||||||
|
headers["Authorization"] = f"Basic {token}"
|
||||||
|
elif plugin_type == "woocommerce":
|
||||||
|
import base64
|
||||||
|
|
||||||
|
ck = credentials.get("consumer_key", "")
|
||||||
|
cs = credentials.get("consumer_secret", "")
|
||||||
|
token = base64.b64encode(f"{ck}:{cs}".encode()).decode()
|
||||||
|
headers["Authorization"] = f"Basic {token}"
|
||||||
|
elif plugin_type == "gitea":
|
||||||
|
headers["Authorization"] = f"token {credentials.get('token', '')}"
|
||||||
|
elif plugin_type == "n8n":
|
||||||
|
headers["X-N8N-API-KEY"] = credentials.get("api_key", "")
|
||||||
|
elif plugin_type == "supabase":
|
||||||
|
headers["apikey"] = credentials.get("service_role_key", "")
|
||||||
|
headers["Authorization"] = f"Bearer {credentials.get('service_role_key', '')}"
|
||||||
|
elif plugin_type == "appwrite":
|
||||||
|
headers["X-Appwrite-Project"] = credentials.get("project_id", "")
|
||||||
|
headers["X-Appwrite-Key"] = credentials.get("api_key", "")
|
||||||
|
elif plugin_type == "directus":
|
||||||
|
headers["Authorization"] = f"Bearer {credentials.get('token', '')}"
|
||||||
|
elif plugin_type == "openpanel":
|
||||||
|
# OpenPanel uses token exchange — just check that the URL is reachable
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
timeout = aiohttp.ClientTimeout(total=15)
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
if method == "POST" and plugin_type == "openpanel":
|
||||||
|
async with session.post(
|
||||||
|
check_url,
|
||||||
|
json={
|
||||||
|
"clientId": credentials.get("client_id", ""),
|
||||||
|
"clientSecret": credentials.get("client_secret", ""),
|
||||||
|
},
|
||||||
|
) as resp:
|
||||||
|
status_code = resp.status
|
||||||
|
resp_text = await resp.text()
|
||||||
|
elif method == "POST":
|
||||||
|
async with session.post(check_url, headers=headers) as resp:
|
||||||
|
status_code = resp.status
|
||||||
|
resp_text = await resp.text()
|
||||||
|
else:
|
||||||
|
async with session.get(check_url, headers=headers) as resp:
|
||||||
|
status_code = resp.status
|
||||||
|
resp_text = await resp.text()
|
||||||
|
|
||||||
|
if status_code < 400:
|
||||||
|
return True, "OK"
|
||||||
|
elif status_code == 401:
|
||||||
|
return False, "Authentication failed — check credentials"
|
||||||
|
elif status_code == 403:
|
||||||
|
return False, "Access forbidden — check permissions or API may be disabled"
|
||||||
|
elif status_code == 404:
|
||||||
|
return False, f"Endpoint not found at {check_url} — check URL"
|
||||||
|
else:
|
||||||
|
return False, f"HTTP {status_code}: {resp_text[:200]}"
|
||||||
|
|
||||||
|
except aiohttp.ClientConnectorError:
|
||||||
|
return False, "Connection failed — check URL and ensure the site is reachable"
|
||||||
|
except TimeoutError:
|
||||||
|
return False, "Connection timed out (15s) — site may be slow or unreachable"
|
||||||
|
except aiohttp.InvalidURL:
|
||||||
|
return False, "Invalid URL protocol — use https:// or http://"
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"Connection error: {type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user_site(
|
||||||
|
user_id: str,
|
||||||
|
plugin_type: str,
|
||||||
|
alias: str,
|
||||||
|
url: str,
|
||||||
|
credentials: dict[str, str],
|
||||||
|
skip_validation: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new site for a user.
|
||||||
|
|
||||||
|
Validates credentials, tests the connection, encrypts credentials,
|
||||||
|
and stores in the database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
plugin_type: Plugin type name.
|
||||||
|
alias: User-chosen friendly name.
|
||||||
|
url: Site URL.
|
||||||
|
credentials: Plaintext credential dict.
|
||||||
|
skip_validation: If True, skip connection test (for testing).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created site dict (without decrypted credentials).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: On validation errors (bad plugin type, missing fields,
|
||||||
|
alias taken, site limit reached, connection failed).
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.encryption import get_credential_encryption
|
||||||
|
|
||||||
|
# Validate plugin type
|
||||||
|
if plugin_type not in PLUGIN_CREDENTIAL_FIELDS:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown plugin type '{plugin_type}'. "
|
||||||
|
f"Valid: {list(PLUGIN_CREDENTIAL_FIELDS.keys())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate alias format
|
||||||
|
alias = alias.strip().lower()
|
||||||
|
if not alias or len(alias) < 2 or len(alias) > 50:
|
||||||
|
raise ValueError("Alias must be 2-50 characters")
|
||||||
|
if not alias.replace("-", "").replace("_", "").isalnum():
|
||||||
|
raise ValueError("Alias may only contain letters, numbers, hyphens, and underscores")
|
||||||
|
|
||||||
|
# Validate required credential fields
|
||||||
|
valid, errors = validate_credentials(plugin_type, credentials)
|
||||||
|
if not valid:
|
||||||
|
raise ValueError(f"Missing credentials: {', '.join(errors)}")
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
|
||||||
|
# Check site limit
|
||||||
|
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)")
|
||||||
|
|
||||||
|
# Check alias uniqueness (DB constraint will also catch this)
|
||||||
|
existing = await db.get_site_by_alias(user_id, alias)
|
||||||
|
if existing is not None:
|
||||||
|
raise ValueError(f"Alias '{alias}' is already in use")
|
||||||
|
|
||||||
|
# Test connection
|
||||||
|
status = "active"
|
||||||
|
status_msg = "Connection verified"
|
||||||
|
if not skip_validation:
|
||||||
|
ok, msg = await validate_site_connection(plugin_type, url, credentials)
|
||||||
|
if not ok:
|
||||||
|
raise ValueError(f"Connection test failed: {msg}")
|
||||||
|
|
||||||
|
# Encrypt credentials
|
||||||
|
encryptor = get_credential_encryption()
|
||||||
|
# We need the site_id for encryption, but we don't have it yet.
|
||||||
|
# Use a pre-generated UUID as the site_id.
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
site_id = str(uuid.uuid4())
|
||||||
|
encrypted = encryptor.encrypt_credentials(credentials, site_id)
|
||||||
|
|
||||||
|
# Store in database — we bypass db.create_site() to use our pre-generated ID
|
||||||
|
from core.database import _utc_now
|
||||||
|
|
||||||
|
now = _utc_now()
|
||||||
|
await db.execute(
|
||||||
|
"INSERT INTO sites (id, user_id, plugin_type, alias, url, credentials, "
|
||||||
|
"status, status_msg, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(site_id, user_id, plugin_type, alias, url, encrypted, status, status_msg, now),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return the created site (without credentials blob)
|
||||||
|
site = await db.get_site(site_id, user_id)
|
||||||
|
if site is None:
|
||||||
|
raise RuntimeError(f"Failed to read back created site {site_id}")
|
||||||
|
|
||||||
|
result = dict(site)
|
||||||
|
result.pop("credentials", None)
|
||||||
|
logger.info("Created site %s (%s) for user %s", alias, plugin_type, user_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_sites(user_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""Get all sites for a user (without credentials).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of site dicts.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
sites = await db.get_sites_by_user(user_id)
|
||||||
|
# Strip credentials blob from response
|
||||||
|
return [{k: v for k, v in site.items() if k != "credentials"} for site in sites]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_site(site_id: str, user_id: str) -> dict[str, Any] | None:
|
||||||
|
"""Get a single site (without credentials).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Site dict or None.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site(site_id, user_id)
|
||||||
|
if site is None:
|
||||||
|
return None
|
||||||
|
result = dict(site)
|
||||||
|
result.pop("credentials", None)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_user_site(site_id: str, user_id: str) -> bool:
|
||||||
|
"""Delete a user's site.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
deleted = await db.delete_site(site_id, user_id)
|
||||||
|
if deleted:
|
||||||
|
logger.info("Deleted site %s for user %s", site_id, user_id)
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
async def test_site_connection(site_id: str, user_id: str) -> tuple[bool, str]:
|
||||||
|
"""Test connectivity to an existing site.
|
||||||
|
|
||||||
|
Decrypts credentials from the database, runs the health check,
|
||||||
|
and updates the site status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
site_id: Site UUID.
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (success, message).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If site not found.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
from core.encryption import get_credential_encryption
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site(site_id, user_id)
|
||||||
|
if site is None:
|
||||||
|
raise ValueError("Site not found")
|
||||||
|
|
||||||
|
encryptor = get_credential_encryption()
|
||||||
|
credentials = encryptor.decrypt_credentials(site["credentials"], site_id)
|
||||||
|
|
||||||
|
ok, msg = await validate_site_connection(site["plugin_type"], site["url"], credentials)
|
||||||
|
|
||||||
|
# Update status
|
||||||
|
new_status = "active" if ok else "error"
|
||||||
|
await db.update_site_status(site_id, new_status, msg, user_id=user_id)
|
||||||
|
|
||||||
|
return ok, msg
|
||||||
@@ -50,6 +50,7 @@ class SiteConfig(BaseModel):
|
|||||||
site_id: str = Field(..., description="Unique site identifier")
|
site_id: str = Field(..., description="Unique site identifier")
|
||||||
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
plugin_type: str = Field(..., description="Plugin type (wordpress, gitea, etc)")
|
||||||
alias: str | None = Field(None, description="Friendly alias for the site")
|
alias: str | None = Field(None, description="Friendly alias for the site")
|
||||||
|
user_id: str | None = Field(None, description="Owner user ID for the site")
|
||||||
|
|
||||||
# Common config fields (plugins may require additional fields)
|
# Common config fields (plugins may require additional fields)
|
||||||
url: str | None = Field(None, description="Site URL")
|
url: str | None = Field(None, description="Site URL")
|
||||||
@@ -452,6 +453,7 @@ class SiteManager:
|
|||||||
"site_id": config.site_id,
|
"site_id": config.site_id,
|
||||||
"alias": config.alias,
|
"alias": config.alias,
|
||||||
"full_id": config.get_full_id(),
|
"full_id": config.get_full_id(),
|
||||||
|
"user_id": config.user_id,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,371 +0,0 @@
|
|||||||
"""
|
|
||||||
Site Registry
|
|
||||||
|
|
||||||
Manages site configurations discovered from environment variables.
|
|
||||||
Supports multi-site setups with aliases and dynamic discovery.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class SiteInfo:
|
|
||||||
"""Information about a single site."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, plugin_type: str, site_id: str, config: dict[str, Any], alias: str | None = None
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Initialize site information.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
|
||||||
site_id: Site identifier (e.g., 'site1')
|
|
||||||
config: Site configuration from environment
|
|
||||||
alias: Optional friendly alias for the site
|
|
||||||
"""
|
|
||||||
self.plugin_type = plugin_type
|
|
||||||
self.site_id = site_id
|
|
||||||
self.config = config
|
|
||||||
self.alias = alias or site_id
|
|
||||||
|
|
||||||
def get_full_id(self) -> str:
|
|
||||||
"""Get full site identifier: plugin_type_site_id"""
|
|
||||||
return f"{self.plugin_type}_{self.site_id}"
|
|
||||||
|
|
||||||
def get_display_name(self) -> str:
|
|
||||||
"""Get display name (alias if available, otherwise site_id)"""
|
|
||||||
return self.alias
|
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
|
||||||
"""Convert to dictionary for serialization."""
|
|
||||||
return {
|
|
||||||
"plugin_type": self.plugin_type,
|
|
||||||
"site_id": self.site_id,
|
|
||||||
"alias": self.alias,
|
|
||||||
"full_id": self.get_full_id(),
|
|
||||||
"config_keys": list(self.config.keys()),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SiteRegistry:
|
|
||||||
"""
|
|
||||||
Registry for managing site configurations across plugin types.
|
|
||||||
|
|
||||||
Discovers sites from environment variables:
|
|
||||||
- {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
|
|
||||||
- {PLUGIN_TYPE}_{SITE_ID}_ALIAS (optional)
|
|
||||||
|
|
||||||
Example:
|
|
||||||
WORDPRESS_SITE1_URL=https://example.com
|
|
||||||
WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
WORDPRESS_SITE1_APP_PASSWORD=xxxx
|
|
||||||
WORDPRESS_SITE2_URL=https://myblog.com
|
|
||||||
WORDPRESS_SITE2_ALIAS=myblog
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
"""Initialize site registry."""
|
|
||||||
self.sites: dict[str, SiteInfo] = {} # full_id -> SiteInfo
|
|
||||||
self.aliases: dict[str, str] = {} # alias -> full_id
|
|
||||||
self.alias_conflicts: dict[str, list[str]] = {} # alias -> [full_ids that wanted it]
|
|
||||||
self.logger = logging.getLogger("SiteRegistry")
|
|
||||||
|
|
||||||
def discover_sites(self, plugin_types: list[str]) -> None:
|
|
||||||
"""
|
|
||||||
Discover sites from environment variables.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_types: List of plugin types to discover (e.g., ['wordpress'])
|
|
||||||
"""
|
|
||||||
self.logger.info("Starting site discovery...")
|
|
||||||
|
|
||||||
for plugin_type in plugin_types:
|
|
||||||
self._discover_plugin_sites(plugin_type)
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
f"Discovery complete. Found {len(self.sites)} sites "
|
|
||||||
f"with {len(self.aliases)} aliases."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log alias conflicts if any
|
|
||||||
if self.alias_conflicts:
|
|
||||||
self.logger.info("Duplicate alias conflicts detected:")
|
|
||||||
for alias, full_ids in self.alias_conflicts.items():
|
|
||||||
winner = self.aliases.get(alias)
|
|
||||||
losers = [fid for fid in full_ids if fid != winner]
|
|
||||||
self.logger.info(f" Alias '{alias}': {winner} (winner), {losers} (using full_id)")
|
|
||||||
|
|
||||||
# Reserved words that should NOT be interpreted as site IDs
|
|
||||||
RESERVED_SITE_WORDS = {
|
|
||||||
"limit",
|
|
||||||
"rate",
|
|
||||||
"config",
|
|
||||||
"debug",
|
|
||||||
"log",
|
|
||||||
"level",
|
|
||||||
"mode",
|
|
||||||
"timeout",
|
|
||||||
"retry",
|
|
||||||
"max",
|
|
||||||
"min",
|
|
||||||
"default",
|
|
||||||
"global",
|
|
||||||
"enabled",
|
|
||||||
"disabled",
|
|
||||||
"host",
|
|
||||||
"port",
|
|
||||||
"path",
|
|
||||||
"key",
|
|
||||||
"secret",
|
|
||||||
"token",
|
|
||||||
"advanced",
|
|
||||||
"basic",
|
|
||||||
"simple",
|
|
||||||
"pro",
|
|
||||||
"premium",
|
|
||||||
"standard",
|
|
||||||
}
|
|
||||||
|
|
||||||
def _discover_plugin_sites(self, plugin_type: str) -> None:
|
|
||||||
"""
|
|
||||||
Discover all sites for a specific plugin type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
|
||||||
"""
|
|
||||||
prefix = plugin_type.upper() + "_"
|
|
||||||
|
|
||||||
# Find all site IDs for this plugin type
|
|
||||||
site_ids = set()
|
|
||||||
env_pattern = re.compile(f"^{prefix}([A-Z0-9_]+?)_(.+)$")
|
|
||||||
|
|
||||||
for env_key in os.environ.keys():
|
|
||||||
match = env_pattern.match(env_key)
|
|
||||||
if match:
|
|
||||||
site_id = match.group(1).lower()
|
|
||||||
# Skip reserved words that are not real site IDs
|
|
||||||
if site_id not in self.RESERVED_SITE_WORDS:
|
|
||||||
site_ids.add(site_id)
|
|
||||||
|
|
||||||
# Create SiteInfo for each discovered site
|
|
||||||
for site_id in site_ids:
|
|
||||||
try:
|
|
||||||
config = self._load_site_config(plugin_type, site_id)
|
|
||||||
if config:
|
|
||||||
alias = config.pop("alias", None) # Extract alias if present
|
|
||||||
site_info = SiteInfo(plugin_type, site_id, config, alias)
|
|
||||||
|
|
||||||
full_id = site_info.get_full_id()
|
|
||||||
self.sites[full_id] = site_info
|
|
||||||
|
|
||||||
# Register alias with duplicate detection
|
|
||||||
if alias:
|
|
||||||
self._register_alias_safe(alias, full_id)
|
|
||||||
# Also register with prefix
|
|
||||||
prefixed_alias = f"{plugin_type}_{alias}"
|
|
||||||
self._register_alias_safe(prefixed_alias, full_id)
|
|
||||||
|
|
||||||
# Always register site_id as alias too (with safe check)
|
|
||||||
self._register_alias_safe(site_id, full_id)
|
|
||||||
self.aliases[full_id] = (
|
|
||||||
full_id # full_id can reference itself (no conflict possible)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log with alias status
|
|
||||||
effective_alias = self._get_effective_alias(alias, full_id)
|
|
||||||
self.logger.info(f"Discovered site: {full_id} " f"(path: {effective_alias})")
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Failed to load {plugin_type} site '{site_id}': {e}", exc_info=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def _register_alias_safe(self, alias: str, full_id: str) -> bool:
|
|
||||||
"""
|
|
||||||
Register an alias with duplicate detection.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
alias: The alias to register
|
|
||||||
full_id: The full_id to map the alias to
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if alias was registered, False if it was a duplicate
|
|
||||||
"""
|
|
||||||
if alias in self.aliases:
|
|
||||||
existing_full_id = self.aliases[alias]
|
|
||||||
if existing_full_id != full_id:
|
|
||||||
# Duplicate alias detected
|
|
||||||
if alias not in self.alias_conflicts:
|
|
||||||
self.alias_conflicts[alias] = [existing_full_id]
|
|
||||||
self.alias_conflicts[alias].append(full_id)
|
|
||||||
self.logger.info(
|
|
||||||
f"Duplicate alias '{alias}': {full_id} conflicts with {existing_full_id}. "
|
|
||||||
f"{full_id} will use full_id for endpoint path."
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
else:
|
|
||||||
self.aliases[alias] = full_id
|
|
||||||
return True
|
|
||||||
|
|
||||||
def _get_effective_alias(self, alias: str | None, full_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Get the effective path suffix for a site.
|
|
||||||
|
|
||||||
If the alias was taken by another site, returns full_id.
|
|
||||||
Otherwise returns the alias (or full_id if no alias).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
alias: The desired alias
|
|
||||||
full_id: The full site ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The effective path suffix
|
|
||||||
"""
|
|
||||||
if alias:
|
|
||||||
# Check if this site owns the alias
|
|
||||||
if self.aliases.get(alias) == full_id:
|
|
||||||
return alias
|
|
||||||
else:
|
|
||||||
# Alias was taken, use full_id
|
|
||||||
return full_id
|
|
||||||
return full_id
|
|
||||||
|
|
||||||
def get_alias_conflicts(self) -> dict[str, list[str]]:
|
|
||||||
"""
|
|
||||||
Get all alias conflicts.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict mapping conflicted aliases to list of full_ids that wanted them
|
|
||||||
"""
|
|
||||||
return self.alias_conflicts.copy()
|
|
||||||
|
|
||||||
def get_effective_path_suffix(self, full_id: str) -> str:
|
|
||||||
"""
|
|
||||||
Get the effective path suffix for a site's endpoint.
|
|
||||||
|
|
||||||
Uses alias if available and not conflicted, otherwise full_id.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
full_id: The full site ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path suffix to use in endpoint URL
|
|
||||||
"""
|
|
||||||
site_info = self.sites.get(full_id)
|
|
||||||
if not site_info:
|
|
||||||
return full_id
|
|
||||||
|
|
||||||
alias = site_info.alias
|
|
||||||
return self._get_effective_alias(alias, full_id)
|
|
||||||
|
|
||||||
def _load_site_config(self, plugin_type: str, site_id: str) -> dict[str, Any] | None:
|
|
||||||
"""
|
|
||||||
Load configuration for a site from environment.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type
|
|
||||||
site_id: Site ID
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Dict with configuration or None if incomplete
|
|
||||||
"""
|
|
||||||
prefix = f"{plugin_type.upper()}_{site_id.upper()}_"
|
|
||||||
config = {}
|
|
||||||
|
|
||||||
# Collect all config keys for this site
|
|
||||||
for env_key, env_value in os.environ.items():
|
|
||||||
if env_key.startswith(prefix):
|
|
||||||
# Extract config key (everything after prefix)
|
|
||||||
config_key = env_key[len(prefix) :].lower()
|
|
||||||
config[config_key] = env_value
|
|
||||||
|
|
||||||
if not config:
|
|
||||||
return None
|
|
||||||
|
|
||||||
self.logger.debug(f"Loaded config for {plugin_type}/{site_id}: {list(config.keys())}")
|
|
||||||
return config
|
|
||||||
|
|
||||||
def get_site(self, plugin_type: str, site_identifier: str) -> SiteInfo | None:
|
|
||||||
"""
|
|
||||||
Get site information by ID or alias.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type (e.g., 'wordpress')
|
|
||||||
site_identifier: Site ID, alias, or full_id
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
SiteInfo if found, None otherwise
|
|
||||||
"""
|
|
||||||
# Try direct lookup first
|
|
||||||
full_id = f"{plugin_type}_{site_identifier}"
|
|
||||||
if full_id in self.sites:
|
|
||||||
return self.sites[full_id]
|
|
||||||
|
|
||||||
# Try alias lookup
|
|
||||||
if site_identifier in self.aliases:
|
|
||||||
resolved_full_id = self.aliases[site_identifier]
|
|
||||||
return self.sites.get(resolved_full_id)
|
|
||||||
|
|
||||||
# Try prefixed alias
|
|
||||||
prefixed = f"{plugin_type}_{site_identifier}"
|
|
||||||
if prefixed in self.aliases:
|
|
||||||
resolved_full_id = self.aliases[prefixed]
|
|
||||||
return self.sites.get(resolved_full_id)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_sites_by_type(self, plugin_type: str) -> list[SiteInfo]:
|
|
||||||
"""
|
|
||||||
Get all sites of a specific plugin type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type to filter by
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of SiteInfo objects
|
|
||||||
"""
|
|
||||||
return [
|
|
||||||
site_info for site_info in self.sites.values() if site_info.plugin_type == plugin_type
|
|
||||||
]
|
|
||||||
|
|
||||||
def list_all_sites(self) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
List all discovered sites.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of site info dictionaries
|
|
||||||
"""
|
|
||||||
return [site_info.to_dict() for site_info in self.sites.values()]
|
|
||||||
|
|
||||||
def get_site_options(self, plugin_type: str) -> list[str]:
|
|
||||||
"""
|
|
||||||
Get available site options for a plugin type (for schema enum).
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of valid site identifiers (IDs and aliases)
|
|
||||||
"""
|
|
||||||
options = set()
|
|
||||||
for site_info in self.get_sites_by_type(plugin_type):
|
|
||||||
options.add(site_info.site_id)
|
|
||||||
if site_info.alias and site_info.alias != site_info.site_id:
|
|
||||||
options.add(site_info.alias)
|
|
||||||
return sorted(options)
|
|
||||||
|
|
||||||
|
|
||||||
# Global site registry instance
|
|
||||||
_site_registry: SiteRegistry | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def get_site_registry() -> SiteRegistry:
|
|
||||||
"""Get the global site registry instance."""
|
|
||||||
global _site_registry
|
|
||||||
if _site_registry is None:
|
|
||||||
_site_registry = SiteRegistry()
|
|
||||||
return _site_registry
|
|
||||||
47
core/templates/dashboard/404.html
Normal file
47
core/templates/dashboard/404.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>404 — Page Not Found | MCP Hub</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: { 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
|
||||||
|
<div class="text-center px-6 py-16 max-w-lg">
|
||||||
|
<div class="w-20 h-20 bg-primary-500/20 rounded-2xl flex items-center justify-center mx-auto mb-8">
|
||||||
|
<svg class="w-10 h-10 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-6xl font-bold text-white mb-4">404</h1>
|
||||||
|
<p class="text-xl text-gray-400 mb-8">Page not found</p>
|
||||||
|
<p class="text-sm text-gray-500 mb-8">The page you're looking for doesn't exist or has been moved.</p>
|
||||||
|
<div class="flex items-center justify-center gap-4">
|
||||||
|
<a href="/dashboard"
|
||||||
|
class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"/>
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/health"
|
||||||
|
class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-gray-300 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg transition-colors">
|
||||||
|
Health Check
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p class="mt-12 text-xs text-gray-600">MCP Hub</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<!-- Header with Create Button -->
|
<!-- Header with Create Button -->
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400 text-sm">
|
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||||
{% if lang == 'fa' %}مدیریت کلیدهای API برای دسترسی به ابزارها{% else %}Manage API keys for tool access{% endif %}
|
{% if lang == 'fa' %}مدیریت کلیدهای API برای دسترسی به ابزارها{% else %}Manage API keys for tool access{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,16 +24,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<form method="GET" class="flex flex-wrap items-center gap-4">
|
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||||
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||||
|
|
||||||
<!-- Project Filter -->
|
<!-- Project Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-gray-400">{{ t.filter }}:</label>
|
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
|
||||||
<select
|
<select
|
||||||
name="project"
|
name="project"
|
||||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
||||||
@@ -48,10 +48,10 @@
|
|||||||
|
|
||||||
<!-- Status Filter -->
|
<!-- Status Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-gray-400">{% if lang == 'fa' %}وضعیت:{% else %}Status:{% endif %}</label>
|
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت:{% else %}Status:{% endif %}</label>
|
||||||
<select
|
<select
|
||||||
name="status"
|
name="status"
|
||||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</option>
|
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% if lang == 'fa' %}فعال{% else %}Active{% endif %}</option>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
name="search"
|
name="search"
|
||||||
value="{{ search_query }}"
|
value="{{ search_query }}"
|
||||||
placeholder="{% if lang == 'fa' %}جستجو در توضیحات...{% else %}Search description...{% endif %}"
|
placeholder="{% if lang == 'fa' %}جستجو در توضیحات...{% else %}Search description...{% endif %}"
|
||||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% if search_query or selected_project or selected_status != 'active' %}
|
{% if search_query or selected_project or selected_status != 'active' %}
|
||||||
<a href="/dashboard/api-keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
<a href="/dashboard/api-keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
||||||
{{ t['clear'] }}
|
{{ t['clear'] }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -84,42 +84,42 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Keys Table -->
|
<!-- API Keys Table -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}شناسه کلید{% else %}Key ID{% endif %}
|
{% if lang == 'fa' %}شناسه کلید{% else %}Key ID{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}
|
{% if lang == 'fa' %}دسترسی{% else %}Scope{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}توضیحات{% else %}Description{% endif %}
|
{% if lang == 'fa' %}توضیحات{% else %}Description{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}استفاده{% else %}Usage{% endif %}
|
{% if lang == 'fa' %}استفاده{% else %}Usage{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if api_keys %}
|
{% if api_keys %}
|
||||||
{% for key in api_keys %}
|
{% for key in api_keys %}
|
||||||
<tr class="hover:bg-gray-700/30 transition-colors {% if key.revoked %}opacity-50{% endif %}">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors {% if key.revoked %}opacity-50{% endif %}">
|
||||||
<!-- Key ID -->
|
<!-- Key ID -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="text-sm text-gray-300 font-mono">{{ key.key_id[:12] }}...</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ key.key_id[:12] }}...</span>
|
||||||
<button
|
<button
|
||||||
onclick="copyToClipboard('{{ key.key_id }}')"
|
onclick="copyToClipboard('{{ key.key_id }}')"
|
||||||
class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-gray-400 hover:text-white transition-colors"
|
class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-gray-400 hover:text-white transition-colors"
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}
|
{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}
|
||||||
</span>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-sm text-gray-300">{{ key.project_id }}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ key.project_id }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -156,7 +156,7 @@
|
|||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-400">{{ key.description or '-' }}</span>
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ key.description or '-' }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
|
|
||||||
<!-- Usage -->
|
<!-- Usage -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-300">{{ key.usage_count }}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ key.usage_count }}</span>
|
||||||
<span class="text-xs text-gray-500">{% if lang == 'fa' %}بار{% else %}calls{% endif %}</span>
|
<span class="text-xs text-gray-500">{% if lang == 'fa' %}بار{% else %}calls{% endif %}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<button
|
<button
|
||||||
onclick="openDeleteModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
|
onclick="openDeleteModal('{{ key.key_id }}', '{{ key.description or key.key_id[:12] }}')"
|
||||||
class="p-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-400 hover:text-white transition-colors"
|
class="p-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-400 hover:text-white transition-colors"
|
||||||
title="{% if lang == 'fa' %}حذف کلید{% else %}Delete Key{% endif %}"
|
title="{% if lang == 'fa' %}حذف کلید{% else %}Delete Key{% endif %}"
|
||||||
>
|
>
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}کلید API یافت نشد{% else %}No API keys found{% endif %}
|
{% if lang == 'fa' %}کلید API یافت نشد{% else %}No API keys found{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -236,8 +236,8 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} کلید
|
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} کلید
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -248,7 +248,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if page_number > 1 %}
|
{% if page_number > 1 %}
|
||||||
<a href="?page={{ page_number - 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="?page={{ page_number - 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||||
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -258,7 +258,7 @@
|
|||||||
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
|
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
|
||||||
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %}
|
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %}
|
||||||
<a href="?page={{ page_num }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="?page={{ page_num }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||||
{{ page_num }}
|
{{ page_num }}
|
||||||
</a>
|
</a>
|
||||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||||
@@ -268,7 +268,7 @@
|
|||||||
|
|
||||||
{% if page_number < total_pages %}
|
{% if page_number < total_pages %}
|
||||||
<a href="?page={{ page_number + 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="?page={{ page_number + 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_status %}&status={{ selected_status }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||||
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -280,9 +280,9 @@
|
|||||||
|
|
||||||
<!-- Create Key Modal -->
|
<!-- Create Key Modal -->
|
||||||
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-md mx-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if lang == 'fa' %}ایجاد کلید API جدید{% else %}Create New API Key{% endif %}
|
{% if lang == 'fa' %}ایجاد کلید API جدید{% else %}Create New API Key{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
@@ -291,13 +291,13 @@
|
|||||||
|
|
||||||
<!-- Project -->
|
<!-- Project -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
name="project_id"
|
name="project_id"
|
||||||
required
|
required
|
||||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
>
|
>
|
||||||
<option value="*">{% if lang == 'fa' %}همه پروژهها (*){% else %}All Projects (*){% endif %}</option>
|
<option value="*">{% if lang == 'fa' %}همه پروژهها (*){% else %}All Projects (*){% endif %}</option>
|
||||||
{% for project in available_projects %}
|
{% for project in available_projects %}
|
||||||
@@ -308,46 +308,46 @@
|
|||||||
|
|
||||||
<!-- Scope -->
|
<!-- Scope -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}
|
{% if lang == 'fa' %}سطح دسترسی{% else %}Scope{% endif %}
|
||||||
</label>
|
</label>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox" name="scope_read" value="read" checked class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
<input type="checkbox" name="scope_read" value="read" checked class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
||||||
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-300">read - {% if lang == 'fa' %}خواندن دادهها{% else %}Read data{% endif %}</span>
|
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-700 dark:text-gray-300">read - {% if lang == 'fa' %}خواندن دادهها{% else %}Read data{% endif %}</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox" name="scope_write" value="write" class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
<input type="checkbox" name="scope_write" value="write" class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
||||||
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-300">write - {% if lang == 'fa' %}نوشتن دادهها{% else %}Write data{% endif %}</span>
|
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-700 dark:text-gray-300">write - {% if lang == 'fa' %}نوشتن دادهها{% else %}Write data{% endif %}</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center">
|
<label class="flex items-center">
|
||||||
<input type="checkbox" name="scope_admin" value="admin" class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
<input type="checkbox" name="scope_admin" value="admin" class="bg-gray-700 border-gray-600 rounded text-primary-600 focus:ring-primary-500">
|
||||||
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-300">admin - {% if lang == 'fa' %}مدیریت کامل{% else %}Full admin{% endif %}</span>
|
<span class="{% if lang == 'fa' %}mr-2{% else %}ml-2{% endif %} text-sm text-gray-700 dark:text-gray-300">admin - {% if lang == 'fa' %}مدیریت کامل{% else %}Full admin{% endif %}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Description -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
{% if lang == 'fa' %}توضیحات (اختیاری){% else %}Description (optional){% endif %}
|
{% if lang == 'fa' %}توضیحات (اختیاری){% else %}Description (optional){% endif %}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="description"
|
name="description"
|
||||||
placeholder="{% if lang == 'fa' %}مثال: کلید برای CI/CD{% else %}e.g., Key for CI/CD{% endif %}"
|
placeholder="{% if lang == 'fa' %}مثال: کلید برای CI/CD{% else %}e.g., Key for CI/CD{% endif %}"
|
||||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Expiration -->
|
<!-- Expiration -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-300 mb-2">
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
{% if lang == 'fa' %}انقضا (اختیاری){% else %}Expiration (optional){% endif %}
|
{% if lang == 'fa' %}انقضا (اختیاری){% else %}Expiration (optional){% endif %}
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
name="expires_in_days"
|
name="expires_in_days"
|
||||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
>
|
>
|
||||||
<option value="">{% if lang == 'fa' %}بدون انقضا{% else %}Never expires{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}بدون انقضا{% else %}Never expires{% endif %}</option>
|
||||||
<option value="7">{% if lang == 'fa' %}7 روز{% else %}7 days{% endif %}</option>
|
<option value="7">{% if lang == 'fa' %}7 روز{% else %}7 days{% endif %}</option>
|
||||||
@@ -357,10 +357,10 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<div class="p-6 border-t border-gray-700 flex justify-end gap-3">
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onclick="closeCreateModal()"
|
onclick="closeCreateModal()"
|
||||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||||
</button>
|
</button>
|
||||||
@@ -376,9 +376,9 @@
|
|||||||
|
|
||||||
<!-- New Key Modal (shows the actual key) -->
|
<!-- New Key Modal (shows the actual key) -->
|
||||||
<div id="newKeyModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<div id="newKeyModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-lg mx-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-lg mx-4">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-white flex items-center">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
|
||||||
<svg class="w-6 h-6 text-green-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-green-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
<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>
|
</svg>
|
||||||
@@ -403,7 +403,7 @@
|
|||||||
type="text"
|
type="text"
|
||||||
id="newKeyValue"
|
id="newKeyValue"
|
||||||
readonly
|
readonly
|
||||||
class="flex-1 bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white font-mono text-sm"
|
class="flex-1 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white font-mono text-sm"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onclick="copyNewKey()"
|
onclick="copyNewKey()"
|
||||||
@@ -414,7 +414,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 border-t border-gray-700 flex justify-end">
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||||
<button
|
<button
|
||||||
onclick="closeNewKeyModal()"
|
onclick="closeNewKeyModal()"
|
||||||
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors"
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors"
|
||||||
@@ -427,14 +427,14 @@
|
|||||||
|
|
||||||
<!-- Revoke Modal -->
|
<!-- Revoke Modal -->
|
||||||
<div id="revokeModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<div id="revokeModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-md mx-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if lang == 'fa' %}لغو کلید API{% else %}Revoke API Key{% endif %}
|
{% if lang == 'fa' %}لغو کلید API{% else %}Revoke API Key{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<p class="text-gray-300">
|
<p class="text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
آیا مطمئن هستید که میخواهید کلید <span id="revokeKeyName" class="font-mono text-white"></span> را لغو کنید؟
|
آیا مطمئن هستید که میخواهید کلید <span id="revokeKeyName" class="font-mono text-white"></span> را لغو کنید؟
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -445,10 +445,10 @@
|
|||||||
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و کلید دیگر کار نخواهد کرد.{% else %}This action cannot be undone and the key will stop working immediately.{% endif %}
|
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و کلید دیگر کار نخواهد کرد.{% else %}This action cannot be undone and the key will stop working immediately.{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 border-t border-gray-700 flex justify-end gap-3">
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onclick="closeRevokeModal()"
|
onclick="closeRevokeModal()"
|
||||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||||
</button>
|
</button>
|
||||||
@@ -464,14 +464,14 @@
|
|||||||
|
|
||||||
<!-- Delete Modal -->
|
<!-- Delete Modal -->
|
||||||
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 w-full max-w-md mx-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if lang == 'fa' %}حذف کلید API{% else %}Delete API Key{% endif %}
|
{% if lang == 'fa' %}حذف کلید API{% else %}Delete API Key{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<p class="text-gray-300">
|
<p class="text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
آیا مطمئن هستید که میخواهید کلید <span id="deleteKeyName" class="font-mono text-white"></span> را حذف کنید؟
|
آیا مطمئن هستید که میخواهید کلید <span id="deleteKeyName" class="font-mono text-white"></span> را حذف کنید؟
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -482,10 +482,10 @@
|
|||||||
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و تمام سوابق استفاده از این کلید حذف خواهد شد.{% else %}This action cannot be undone and all usage records will be deleted.{% endif %}
|
{% if lang == 'fa' %}این عمل غیرقابل برگشت است و تمام سوابق استفاده از این کلید حذف خواهد شد.{% else %}This action cannot be undone and all usage records will be deleted.{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 border-t border-gray-700 flex justify-end gap-3">
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
onclick="closeDeleteModal()"
|
onclick="closeDeleteModal()"
|
||||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -6,16 +6,16 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<form method="GET" class="flex flex-wrap items-center gap-4">
|
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||||
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||||
|
|
||||||
<!-- Project Filter -->
|
<!-- Project Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-gray-400">{% if lang == 'fa' %}پروژه:{% else %}Project:{% endif %}</label>
|
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}پروژه:{% else %}Project:{% endif %}</label>
|
||||||
<select
|
<select
|
||||||
name="project"
|
name="project"
|
||||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}همه پروژهها{% else %}All Projects{% endif %}</option>
|
||||||
@@ -29,10 +29,10 @@
|
|||||||
|
|
||||||
<!-- Event Type Filter -->
|
<!-- Event Type Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-gray-400">{{ t.filter }}:</label>
|
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
|
||||||
<select
|
<select
|
||||||
name="event_type"
|
name="event_type"
|
||||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="">{% if lang == 'fa' %}همه رویدادها{% else %}All Events{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}همه رویدادها{% else %}All Events{% endif %}</option>
|
||||||
@@ -45,10 +45,10 @@
|
|||||||
|
|
||||||
<!-- Level Filter -->
|
<!-- Level Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-gray-400">{% if lang == 'fa' %}سطح:{% else %}Level:{% endif %}</label>
|
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}سطح:{% else %}Level:{% endif %}</label>
|
||||||
<select
|
<select
|
||||||
name="level"
|
name="level"
|
||||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
<option value="">{% if lang == 'fa' %}همه{% else %}All{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}همه{% else %}All{% endif %}</option>
|
||||||
@@ -60,12 +60,12 @@
|
|||||||
|
|
||||||
<!-- Date Filter -->
|
<!-- Date Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-gray-400">{% if lang == 'fa' %}تاریخ:{% else %}Date:{% endif %}</label>
|
<label class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}تاریخ:{% else %}Date:{% endif %}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
name="date"
|
name="date"
|
||||||
value="{{ selected_date }}"
|
value="{{ selected_date }}"
|
||||||
class="bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
onchange="this.form.submit()"
|
onchange="this.form.submit()"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +77,7 @@
|
|||||||
name="search"
|
name="search"
|
||||||
value="{{ search_query }}"
|
value="{{ search_query }}"
|
||||||
placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search...{% endif %}"
|
placeholder="{% if lang == 'fa' %}جستجو...{% else %}Search...{% endif %}"
|
||||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white text-sm placeholder-gray-400 focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% if search_query or selected_event_type or selected_level or selected_date or selected_project %}
|
{% if search_query or selected_event_type or selected_level or selected_date or selected_project %}
|
||||||
<a href="/dashboard/audit-logs{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
<a href="/dashboard/audit-logs{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
||||||
{{ t['clear'] }}
|
{{ t['clear'] }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -95,11 +95,11 @@
|
|||||||
|
|
||||||
<!-- Stats Summary -->
|
<!-- Stats Summary -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل رویدادها{% else %}Total Events{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کل رویدادها{% else %}Total Events{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ stats.total }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.total }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -108,11 +108,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}فراخوانی ابزار{% else %}Tool Calls{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}فراخوانی ابزار{% else %}Tool Calls{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ stats.tool_calls }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.tool_calls }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -121,11 +121,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}احراز هویت{% else %}Auth Events{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}احراز هویت{% else %}Auth Events{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ stats.auth_events }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.auth_events }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -134,11 +134,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}خطاها{% else %}Errors{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}خطاها{% else %}Errors{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ stats.errors }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.errors }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-10 h-10 bg-red-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -150,35 +150,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Logs Table -->
|
<!-- Logs Table -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}زمان{% else %}Timestamp{% endif %}
|
{% if lang == 'fa' %}زمان{% else %}Timestamp{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}نوع{% else %}Type{% endif %}
|
{% if lang == 'fa' %}نوع{% else %}Type{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}سطح{% else %}Level{% endif %}
|
{% if lang == 'fa' %}سطح{% else %}Level{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}رویداد{% else %}Event{% endif %}
|
{% if lang == 'fa' %}رویداد{% else %}Event{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}جزئیات{% else %}Details{% endif %}
|
{% if lang == 'fa' %}جزئیات{% else %}Details{% endif %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if logs %}
|
{% if logs %}
|
||||||
{% for log in logs %}
|
{% for log in logs %}
|
||||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||||
<!-- Timestamp -->
|
<!-- Timestamp -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-300 font-mono">{{ log.timestamp[:19] }}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ log.timestamp[:19] if log.timestamp else '-' }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Event Type -->
|
<!-- Event Type -->
|
||||||
@@ -207,7 +207,7 @@
|
|||||||
|
|
||||||
<!-- Event/Message -->
|
<!-- Event/Message -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-white">{{ log.event or log.message or log.tool_name or '-' }}</span>
|
<span class="text-sm text-gray-900 dark:text-white">{{ log.event or log.message or log.tool_name or '-' }}</span>
|
||||||
{% if log.project_id %}
|
{% if log.project_id %}
|
||||||
<span class="text-xs text-gray-500 block">{{ log.project_id }}</span>
|
<span class="text-xs text-gray-500 block">{{ log.project_id }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}لاگی یافت نشد{% else %}No logs found{% endif %}
|
{% if lang == 'fa' %}لاگی یافت نشد{% else %}No logs found{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
@@ -249,8 +249,8 @@
|
|||||||
|
|
||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} لاگ
|
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} لاگ
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -261,17 +261,17 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if page_number > 1 %}
|
{% if page_number > 1 %}
|
||||||
<a href="?page={{ page_number - 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="?page={{ page_number - 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-900 dark:text-white transition-colors">
|
||||||
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for page_num in range(1, total_pages + 1) %}
|
{% for page_num in range(1, total_pages + 1) %}
|
||||||
{% if page_num == page_number %}
|
{% if page_num == page_number %}
|
||||||
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
|
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-gray-900 dark:text-white">{{ page_num }}</span>
|
||||||
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %}
|
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %}
|
||||||
<a href="?page={{ page_num }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="?page={{ page_num }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-900 dark:text-white transition-colors">
|
||||||
{{ page_num }}
|
{{ page_num }}
|
||||||
</a>
|
</a>
|
||||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||||
@@ -281,7 +281,7 @@
|
|||||||
|
|
||||||
{% if page_number < total_pages %}
|
{% if page_number < total_pages %}
|
||||||
<a href="?page={{ page_number + 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="?page={{ page_number + 1 }}{% if selected_project %}&project={{ selected_project }}{% endif %}{% if selected_event_type %}&event_type={{ selected_event_type }}{% endif %}{% if selected_level %}&level={{ selected_level }}{% endif %}{% if selected_date %}&date={{ selected_date }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-900 dark:text-white transition-colors">
|
||||||
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
208
core/templates/dashboard/auth-login.html
Normal file
208
core/templates/dashboard/auth-login.html
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ lang|default('en') }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Login - MCP Hub</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
||||||
|
{% include "dashboard/partials/head_assets.html" %}
|
||||||
|
<style>
|
||||||
|
.login-gradient {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-shadow {
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-github {
|
||||||
|
background: #24292e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-github:hover {
|
||||||
|
background: #2f363d;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 20px rgba(36, 41, 46, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-google {
|
||||||
|
background: #4285f4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-google:hover {
|
||||||
|
background: #3367d6;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 20px rgba(66, 133, 244, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-admin {
|
||||||
|
border: 1px solid #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-admin:hover {
|
||||||
|
background: #374151;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-float {
|
||||||
|
animation: float 6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.transition-all {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="bg-gray-900 min-h-screen flex items-center justify-center p-4">
|
||||||
|
<!-- Background decoration -->
|
||||||
|
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
|
<div class="absolute -top-40 -right-40 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl animate-float"></div>
|
||||||
|
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-float"
|
||||||
|
style="animation-delay: -3s;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative w-full max-w-md">
|
||||||
|
<div class="card-shadow bg-gray-800 rounded-2xl p-8">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="mx-auto w-16 h-16 login-gradient rounded-2xl flex items-center justify-center mb-4">
|
||||||
|
<img src="/static/logo.svg" alt="MCP Hub Logo"
|
||||||
|
class="w-10 h-10 object-contain drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]">
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">MCP Hub</h1>
|
||||||
|
<p class="text-gray-400 mt-2" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||||
|
{% if lang == 'fa' %}برای مدیریت سایتهایتان وارد شوید{% else %}Sign in to manage your sites{% endif
|
||||||
|
%}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error Message -->
|
||||||
|
{% if error %}
|
||||||
|
<div class="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-lg">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-5 h-5 text-red-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %} flex-shrink-0"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-red-400 text-sm">
|
||||||
|
{% if error == 'rate_limit' %}
|
||||||
|
{% if lang == 'fa' %}تلاشهای ثبتنام زیاد. لطفاً بعداً تلاش کنید.{% else %}Too many
|
||||||
|
registration attempts. Please try again later.{% endif %}
|
||||||
|
{% elif error == 'oauth_denied' %}
|
||||||
|
{% if lang == 'fa' %}احراز هویت لغو یا رد شد.{% else %}Authentication was cancelled or denied.{%
|
||||||
|
endif %}
|
||||||
|
{% elif error == 'no_email' %}
|
||||||
|
{% if lang == 'fa' %}آدرس ایمیل شما قابل دریافت نبود.{% else %}Could not retrieve your email
|
||||||
|
address. Please check your provider settings.{% endif %}
|
||||||
|
{% elif error == 'invalid_state' %}
|
||||||
|
{% if lang == 'fa' %}نشست منقضی شده. لطفاً دوباره تلاش کنید.{% else %}Session expired. Please
|
||||||
|
try again.{% endif %}
|
||||||
|
{% elif error == 'exchange_failed' %}
|
||||||
|
{% if lang == 'fa' %}احراز هویت ناموفق. لطفاً دوباره تلاش کنید.{% else %}Authentication failed.
|
||||||
|
Please try again.{% endif %}
|
||||||
|
{% elif error == 'provider_unavailable' %}
|
||||||
|
{% if lang == 'fa' %}این ارائهدهنده ورود در دسترس نیست.{% else %}This login provider is not
|
||||||
|
available.{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if lang == 'fa' %}خطایی رخ داد. لطفاً دوباره تلاش کنید.{% else %}An error occurred. Please
|
||||||
|
try again.{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- OAuth Buttons -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% if "github" in providers %}
|
||||||
|
<a href="/auth/github"
|
||||||
|
class="btn-github transition-all flex items-center justify-center w-full py-3 px-4 text-white font-semibold rounded-lg">
|
||||||
|
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}" fill="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12 0c-6.626 0-12 5.373-12 12 0 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.23.957-.266 1.983-.399 3.003-.404 1.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.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}ورود با GitHub{% else %}Continue with GitHub{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if "google" in providers %}
|
||||||
|
<a href="/auth/google"
|
||||||
|
class="btn-google transition-all flex items-center justify-center w-full py-3 px-4 text-white font-semibold rounded-lg">
|
||||||
|
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
|
fill="#fff" />
|
||||||
|
<path
|
||||||
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
|
fill="#fff" fill-opacity="0.8" />
|
||||||
|
<path
|
||||||
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
|
fill="#fff" fill-opacity="0.6" />
|
||||||
|
<path
|
||||||
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#fff" fill-opacity="0.9" />
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}ورود با Google{% else %}Continue with Google{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not providers %}
|
||||||
|
<div class="text-center p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||||
|
<p class="text-yellow-400 text-sm">
|
||||||
|
{% if lang == 'fa' %}هیچ ارائهدهنده OAuth پیکربندی نشده.{% else %}No OAuth providers
|
||||||
|
configured. Set GITHUB_CLIENT_ID/SECRET or GOOGLE_CLIENT_ID/SECRET in your environment.{% endif
|
||||||
|
%}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="my-6 flex items-center">
|
||||||
|
<div class="flex-1 border-t border-gray-600"></div>
|
||||||
|
<span class="px-4 text-sm text-gray-400">
|
||||||
|
{% if lang == 'fa' %}یا{% else %}or{% endif %}
|
||||||
|
</span>
|
||||||
|
<div class="flex-1 border-t border-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Admin Login Link -->
|
||||||
|
<a href="/dashboard/login{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="btn-admin transition-all flex items-center justify-center w-full py-3 px-4 text-gray-300 font-medium rounded-lg text-sm">
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="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" />
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}ورود مدیر با کلید API{% else %}Admin Login with API Key{% endif %}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Language Toggle -->
|
||||||
|
<div class="mt-6 text-center">
|
||||||
|
<a href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}"
|
||||||
|
class="text-sm text-gray-400 hover:text-white transition-colors">
|
||||||
|
{% if lang == 'fa' %}English{% else %}فارسی{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center text-gray-500 text-sm mt-6">MCP Hub v{{ version }}</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,69 +1,44 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ lang|default('en') }}" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<html lang="{{ lang|default('en') }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}{{ t.dashboard }}{% endblock %} - MCP Hub</title>
|
<title>{% block title %}{{ t.dashboard }}{% endblock %} - MCP Hub</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
||||||
|
|
||||||
<!-- Vazirmatn Font for Persian -->
|
{% include "dashboard/partials/head_assets.html" %}
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;200;300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
|
||||||
|
|
||||||
<!-- Tailwind CSS -->
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
|
|
||||||
<!-- HTMX for dynamic updates -->
|
|
||||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
|
||||||
|
|
||||||
<!-- Alpine.js for simple interactions -->
|
|
||||||
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
|
||||||
|
|
||||||
<!-- Custom Tailwind Config -->
|
|
||||||
<script>
|
|
||||||
tailwind.config = {
|
|
||||||
darkMode: 'class',
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
fontFamily: {
|
|
||||||
'vazirmatn': ['Vazirmatn', 'system-ui', 'sans-serif'],
|
|
||||||
},
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
50: '#f5f3ff',
|
|
||||||
100: '#ede9fe',
|
|
||||||
200: '#ddd6fe',
|
|
||||||
300: '#c4b5fd',
|
|
||||||
400: '#a78bfa',
|
|
||||||
500: '#8b5cf6',
|
|
||||||
600: '#7c3aed',
|
|
||||||
700: '#6d28d9',
|
|
||||||
800: '#5b21b6',
|
|
||||||
900: '#4c1d95',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
[x-cloak] { display: none !important; }
|
/* Custom scrollbar — light mode */
|
||||||
|
|
||||||
/* Custom scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #1f2937;
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #4b5563;
|
background: #d1d5db;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar — dark mode */
|
||||||
|
.dark ::-webkit-scrollbar-track {
|
||||||
|
background: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: #4b5563;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
background: #6b7280;
|
background: #6b7280;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +51,7 @@
|
|||||||
.card-hover {
|
.card-hover {
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-hover:hover {
|
.card-hover:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3);
|
||||||
@@ -85,9 +61,17 @@
|
|||||||
.status-pulse {
|
.status-pulse {
|
||||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse {
|
@keyframes pulse {
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0.5; }
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* RTL adjustments */
|
/* RTL adjustments */
|
||||||
@@ -95,80 +79,155 @@
|
|||||||
margin-left: 0.75rem;
|
margin-left: 0.75rem;
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
[dir="ltr"] .sidebar-icon {
|
[dir="ltr"] .sidebar-icon {
|
||||||
margin-right: 0.75rem;
|
margin-right: 0.75rem;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Vazirmatn font for Persian */
|
|
||||||
[dir="rtl"],
|
|
||||||
[dir="rtl"] * {
|
|
||||||
font-family: 'Vazirmatn', system-ui, sans-serif !important;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<meta name="csrf-token" content="{{ request.state.csrf_token }}">
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-900 text-gray-100 min-h-screen" x-data="{ sidebarOpen: true, darkMode: true }">
|
|
||||||
|
{# ── Derive RBAC info from session ── #}
|
||||||
|
{% set _is_admin = is_admin_session(session) %}
|
||||||
|
{% set _display = get_session_display_info(session) %}
|
||||||
|
|
||||||
|
<body class="bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100 min-h-screen transition-colors duration-200"
|
||||||
|
x-data="{ sidebarOpen: true, darkMode: localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches) }"
|
||||||
|
x-init="$watch('darkMode', val => {
|
||||||
|
if(val) { document.documentElement.classList.add('dark'); localStorage.theme = 'dark'; }
|
||||||
|
else { document.documentElement.classList.remove('dark'); localStorage.theme = 'light'; }
|
||||||
|
})">
|
||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<aside
|
<aside class="sidebar-transition bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 flex flex-col"
|
||||||
class="sidebar-transition bg-gray-800 border-gray-700 flex flex-col"
|
:class="sidebarOpen ? 'w-64' : 'w-20'" {% if lang=='fa' %}style="border-left: 1px solid;" {% else
|
||||||
:class="sidebarOpen ? 'w-64' : 'w-20'"
|
%}style="border-right: 1px solid;" {% endif %}>
|
||||||
{% if lang == 'fa' %}style="border-left: 1px solid;"{% else %}style="border-right: 1px solid;"{% endif %}
|
|
||||||
>
|
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="flex items-center justify-between h-16 px-4 border-b border-gray-700">
|
<div class="flex items-center justify-between h-16 px-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center" x-show="sidebarOpen">
|
<div class="flex items-center" x-show="sidebarOpen">
|
||||||
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
|
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
|
||||||
<span class="{% if lang == 'fa' %}mr-3{% else %}ml-3{% endif %} font-bold text-lg">MCP Hub</span>
|
<span class="{% if lang == 'fa' %}mr-3{% else %}ml-3{% endif %} font-bold text-lg text-gray-900 dark:text-white">MCP Hub</span>
|
||||||
</div>
|
</div>
|
||||||
<div x-show="!sidebarOpen" class="flex items-center justify-center">
|
<div x-show="!sidebarOpen" class="flex items-center justify-center">
|
||||||
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
|
<img src="/static/logo.svg" alt="MCP Hub" class="w-8 h-8">
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button @click="sidebarOpen = !sidebarOpen" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-300">
|
||||||
@click="sidebarOpen = !sidebarOpen"
|
|
||||||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="M4 6h16M4 12h16M4 18h16"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 6h16M4 12h16M4 18h16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<nav class="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
|
<nav class="flex-1 px-2 py-4 space-y-1 overflow-y-auto">
|
||||||
{% set nav_items = [
|
{# ── Common nav items (all users) ── #}
|
||||||
('dashboard', t.dashboard, 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', '/dashboard'),
|
{% set common_nav = [
|
||||||
('projects', t.projects, 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z', '/dashboard/projects'),
|
('dashboard', t.dashboard, 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1
|
||||||
('api_keys', t.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/api-keys'),
|
1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', '/dashboard'),
|
||||||
('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'),
|
|
||||||
('audit_logs', t.audit_logs, 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01', '/dashboard/audit-logs'),
|
|
||||||
('health', t.health, 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z', '/dashboard/health'),
|
|
||||||
('settings', t.settings, 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z', '/dashboard/settings'),
|
|
||||||
] %}
|
] %}
|
||||||
|
|
||||||
{% for item_id, label, icon_path, url in nav_items %}
|
{# ── User-only nav items ── #}
|
||||||
<a
|
{% set user_nav = [
|
||||||
href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
('my_sites', t.get('my_sites', 'My Sites'), 'M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0
|
||||||
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-300 hover:bg-gray-700 hover:text-white{% endif %}"
|
01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9',
|
||||||
>
|
'/dashboard/sites'),
|
||||||
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
('connect', t.get('connect', 'Connect'), 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}"/>
|
5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', '/dashboard/connect'),
|
||||||
|
] %}
|
||||||
|
|
||||||
|
{# ── Admin-only nav items ── #}
|
||||||
|
{% set admin_nav = [
|
||||||
|
('projects', t.projects, 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
|
||||||
|
'/dashboard/projects'),
|
||||||
|
('api_keys', t.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/api-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'),
|
||||||
|
('audit_logs', t.audit_logs, 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2
|
||||||
|
2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01',
|
||||||
|
'/dashboard/audit-logs'),
|
||||||
|
('health', t.health, 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12
|
||||||
|
7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z', '/dashboard/health'),
|
||||||
|
('settings', t.settings, 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573
|
||||||
|
1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724
|
||||||
|
0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35
|
||||||
|
0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0
|
||||||
|
00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31
|
||||||
|
2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z', '/dashboard/settings'),
|
||||||
|
] %}
|
||||||
|
|
||||||
|
{# Render common nav #}
|
||||||
|
{% for item_id, label, icon_path, url in common_nav %}
|
||||||
|
<a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}">
|
||||||
|
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}" />
|
||||||
</svg>
|
</svg>
|
||||||
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
|
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{# Render user nav (non-admin only) #}
|
||||||
|
{% if not _is_admin %}
|
||||||
|
{% for item_id, label, icon_path, url in user_nav %}
|
||||||
|
<a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}">
|
||||||
|
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}" />
|
||||||
|
</svg>
|
||||||
|
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Render admin nav (admin only) #}
|
||||||
|
{% if _is_admin %}
|
||||||
|
<div class="pt-3 mt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p x-show="sidebarOpen" class="px-3 mb-2 text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500">
|
||||||
|
{% if lang == 'fa' %}مدیریت{% else %}Administration{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% for item_id, label, icon_path, url in admin_nav %}
|
||||||
|
<a href="{{ url }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="flex items-center px-3 py-2 rounded-lg transition-colors {% if current_page == item_id %}bg-primary-600 text-white{% else %}text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white{% endif %}">
|
||||||
|
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="{{ icon_path }}" />
|
||||||
|
</svg>
|
||||||
|
<span x-show="sidebarOpen" class="truncate">{{ label }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- User Section -->
|
<!-- User Section -->
|
||||||
<div class="p-4 border-t border-gray-700">
|
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<a
|
{# Profile link for OAuth users #}
|
||||||
href="/dashboard/logout"
|
{% if not _is_admin %}
|
||||||
class="flex items-center px-3 py-2 text-gray-300 hover:bg-gray-700 hover:text-white rounded-lg transition-colors"
|
<a href="/dashboard/profile{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
>
|
class="flex items-center px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white rounded-lg transition-colors mb-1 {% if current_page == 'profile' %}bg-primary-600 text-white{% endif %}">
|
||||||
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
<span x-show="sidebarOpen">{{ t.get('profile', 'Profile') }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="/dashboard/logout"
|
||||||
|
class="flex items-center px-3 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white rounded-lg transition-colors">
|
||||||
|
<svg class="w-5 h-5 sidebar-icon flex-shrink-0" fill="none" stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
</svg>
|
</svg>
|
||||||
<span x-show="sidebarOpen">{{ t.logout }}</span>
|
<span x-show="sidebarOpen">{{ t.logout }}</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -176,39 +235,57 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="flex-1 overflow-y-auto bg-gray-900">
|
<main class="flex-1 overflow-y-auto bg-gray-100 dark:bg-gray-900">
|
||||||
<!-- Top Header -->
|
<!-- Top Header -->
|
||||||
<header class="sticky top-0 z-10 bg-gray-800/95 backdrop-blur border-b border-gray-700">
|
<header class="sticky top-0 z-10 bg-white/95 dark:bg-gray-800/95 backdrop-blur border-b border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between h-16 px-6">
|
<div class="flex items-center justify-between h-16 px-6">
|
||||||
<h1 class="text-xl font-semibold">{% block page_title %}{{ t.dashboard }}{% endblock %}</h1>
|
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">{% block page_title %}{{ t.dashboard }}{% endblock %}</h1>
|
||||||
|
|
||||||
<div class="flex items-center space-x-4 {% if lang == 'fa' %}space-x-reverse{% endif %}">
|
<div class="flex items-center space-x-4 {% if lang == 'fa' %}space-x-reverse{% endif %}">
|
||||||
|
<!-- Dark Mode Toggle -->
|
||||||
|
<button @click="darkMode = !darkMode"
|
||||||
|
class="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
title="Toggle Dark Mode">
|
||||||
|
<svg x-show="darkMode" 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="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
||||||
|
</svg>
|
||||||
|
<svg x-show="!darkMode" 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="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
<!-- Language Toggle -->
|
<!-- Language Toggle -->
|
||||||
<a
|
<a href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}"
|
||||||
href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}"
|
class="px-3 py-1 text-sm bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg transition-colors text-gray-800 dark:text-gray-200">
|
||||||
class="px-3 py-1 text-sm bg-gray-700 hover:bg-gray-600 rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
{% if lang == 'fa' %}EN{% else %}FA{% endif %}
|
{% if lang == 'fa' %}EN{% else %}FA{% endif %}
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Refresh Button -->
|
<!-- Refresh Button -->
|
||||||
<button
|
<button hx-get="{{ request.url.path }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
hx-get="{{ request.url.path }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
hx-target="body" hx-swap="outerHTML"
|
||||||
hx-target="body"
|
class="p-2 text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
hx-swap="outerHTML"
|
title="{{ t.refresh }}">
|
||||||
class="p-2 text-gray-400 hover:text-white hover:bg-gray-700 rounded-lg transition-colors"
|
|
||||||
title="{{ t.refresh }}"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- User Info -->
|
<!-- User Info -->
|
||||||
{% if session %}
|
{% if session %}
|
||||||
<div class="flex items-center text-sm text-gray-400">
|
<div class="flex items-center text-sm">
|
||||||
|
{% if _is_admin %}
|
||||||
|
<span class="px-2 py-1 bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 rounded text-xs font-medium">
|
||||||
|
{{ t.get('admin_badge', 'Admin') }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
<span class="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
<span class="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
{{ session.user_type }}
|
<span class="text-gray-600 dark:text-gray-400">{{ _display.name }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -224,4 +301,5 @@
|
|||||||
|
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
196
core/templates/dashboard/connect.html
Normal file
196
core/templates/dashboard/connect.html
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.connect }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ t.connect }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-8">
|
||||||
|
|
||||||
|
<!-- API Keys Section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.api_keys }}</h3>
|
||||||
|
<button onclick="createKey()" id="create-key-btn"
|
||||||
|
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium">
|
||||||
|
+ {{ t.generate_key }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New key alert -->
|
||||||
|
{% if new_key %}
|
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-500/20 border border-yellow-200 dark:border-yellow-500/50 text-yellow-800 dark:text-yellow-300 px-4 py-3 rounded-lg mb-4">
|
||||||
|
<p class="font-medium mb-1">{{ t.your_api_key }}</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="bg-gray-100 dark:bg-gray-900 px-3 py-1 rounded text-sm flex-1 overflow-x-auto text-gray-800 dark:text-gray-200">{{ new_key }}</code>
|
||||||
|
<button onclick="copyText('{{ new_key }}')" class="text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-600 dark:hover:text-yellow-200">{{ t['copy'] }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">{{ t.key_shown_once }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- New key display (JS-created) -->
|
||||||
|
<div id="new-key-display" class="hidden bg-yellow-50 dark:bg-yellow-500/20 border border-yellow-200 dark:border-yellow-500/50 text-yellow-800 dark:text-yellow-300 px-4 py-3 rounded-lg mb-4">
|
||||||
|
<p class="font-medium mb-1">{{ t.your_api_key }}</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code id="new-key-value" class="bg-gray-100 dark:bg-gray-900 px-3 py-1 rounded text-sm flex-1 overflow-x-auto text-gray-800 dark:text-gray-200"></code>
|
||||||
|
<button onclick="copyNewKey()" class="text-sm text-yellow-700 dark:text-yellow-300 hover:text-yellow-600 dark:hover:text-yellow-200">{{ t['copy'] }}</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-yellow-600 dark:text-yellow-400 mt-1">{{ t.key_shown_once }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if api_keys %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.api_key_name }}</th>
|
||||||
|
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Prefix</th>
|
||||||
|
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||||
|
{% if lang == 'fa' %}دسترسی{% else %}Access{% endif %}
|
||||||
|
</th>
|
||||||
|
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">Uses</th>
|
||||||
|
<th class="px-4 py-3 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.actions }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% for key in api_keys %}
|
||||||
|
<tr id="key-{{ key.id }}">
|
||||||
|
<td class="px-4 py-3 text-gray-900 dark:text-white">{{ key.name }}</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 font-mono text-sm">mhu_{{ key.key_prefix }}...</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400">
|
||||||
|
{% if lang == 'fa' %}دسترسی کامل{% else %}Full Access{% endif %}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-sm">{{ key.use_count }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<button onclick="deleteKey('{{ key.id }}')" class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">{{ t.delete }}</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_api_keys }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Config Snippets Section -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ t.config_snippets }}</h3>
|
||||||
|
|
||||||
|
{% if sites %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.select_site }}</label>
|
||||||
|
<select id="config-site" onchange="updateConfig()"
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||||
|
{% for site in sites %}
|
||||||
|
<option value="{{ site.alias }}">{{ site.alias }} ({{ site.plugin_type }})</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.select_client }}</label>
|
||||||
|
<select id="config-client" onchange="updateConfig()"
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||||
|
{% for client in clients %}
|
||||||
|
<option value="{{ client.id }}">{{ client.label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[120px]">Select a site and client to generate configuration...</pre>
|
||||||
|
<button onclick="copyConfig()" class="absolute top-2 {{ 'left-2' if lang == 'fa' else 'right-2' }} text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors" id="copy-config-btn">{{ t['copy'] }}</button>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_sites }}. <a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">{{ t.add_site }}</a></p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
async function createKey() {
|
||||||
|
const name = prompt('Key name (e.g., "Claude Desktop"):');
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('create-key-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/keys', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: name }),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok && data.key) {
|
||||||
|
document.getElementById('new-key-value').textContent = data.key.key;
|
||||||
|
document.getElementById('new-key-display').classList.remove('hidden');
|
||||||
|
// Reload to show in table
|
||||||
|
setTimeout(() => location.reload(), 500);
|
||||||
|
} else {
|
||||||
|
alert(data.error || 'Failed to create key');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Error: ' + e.message);
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteKey(keyId) {
|
||||||
|
if (!confirm('Delete this API key?')) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/keys/' + keyId, { method: 'DELETE' });
|
||||||
|
if (resp.ok) {
|
||||||
|
document.getElementById('key-' + keyId).remove();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to delete key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateConfig() {
|
||||||
|
const alias = document.getElementById('config-site')?.value;
|
||||||
|
const client = document.getElementById('config-client')?.value;
|
||||||
|
if (!alias || !client) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/config/' + alias + '?client=' + client);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok) {
|
||||||
|
document.getElementById('config-output').textContent = data.config;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('config-output').textContent = 'Error loading config';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text) {
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyNewKey() {
|
||||||
|
const key = document.getElementById('new-key-value').textContent;
|
||||||
|
navigator.clipboard.writeText(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyConfig() {
|
||||||
|
const text = document.getElementById('config-output').textContent;
|
||||||
|
navigator.clipboard.writeText(text);
|
||||||
|
const btn = document.getElementById('copy-config-btn');
|
||||||
|
btn.textContent = '{{ t.copied }}';
|
||||||
|
setTimeout(() => btn.textContent = '{{ t['copy'] }}', 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load config on page load
|
||||||
|
{% if sites %}
|
||||||
|
document.addEventListener('DOMContentLoaded', updateConfig);
|
||||||
|
{% endif %}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -8,10 +8,10 @@
|
|||||||
<!-- System Status Overview -->
|
<!-- System Status Overview -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<!-- Overall Status -->
|
<!-- Overall Status -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}وضعیت کلی{% else %}Overall Status{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت کلی{% else %}Overall Status{% endif %}</p>
|
||||||
{% if async_load %}
|
{% if async_load %}
|
||||||
<p id="system-status-badge" class="text-2xl font-bold text-blue-400">
|
<p id="system-status-badge" class="text-2xl font-bold text-blue-400">
|
||||||
{% if lang == 'fa' %}در حال بررسی...{% else %}Checking...{% endif %}
|
{% if lang == 'fa' %}در حال بررسی...{% else %}Checking...{% endif %}
|
||||||
@@ -44,11 +44,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uptime -->
|
<!-- Uptime -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{{ t.system_uptime }}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.system_uptime }}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ uptime.formatted }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ uptime.formatted }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -59,11 +59,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Total Requests -->
|
<!-- Total Requests -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل درخواستها{% else %}Total Requests{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کل درخواستها{% else %}Total Requests{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ metrics.total_requests|default(0) }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ metrics.total_requests|default(0) }}</p>
|
||||||
<p class="text-xs text-gray-500">{{ metrics.requests_per_minute|default(0)|round(1) }} req/min</p>
|
<p class="text-xs text-gray-500">{{ metrics.requests_per_minute|default(0)|round(1) }} req/min</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||||
@@ -75,10 +75,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Rate -->
|
<!-- Error Rate -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}</p>
|
||||||
<p class="text-2xl font-bold {% if (metrics.error_rate_percent|default(0)) > 10 %}text-red-400{% elif (metrics.error_rate_percent|default(0)) > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
<p class="text-2xl font-bold {% if (metrics.error_rate_percent|default(0)) > 10 %}text-red-400{% elif (metrics.error_rate_percent|default(0)) > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
||||||
{{ metrics.error_rate_percent|default(0)|round(2) }}%
|
{{ metrics.error_rate_percent|default(0)|round(2) }}%
|
||||||
</p>
|
</p>
|
||||||
@@ -113,8 +113,8 @@
|
|||||||
<!-- Response Time & Throughput -->
|
<!-- Response Time & Throughput -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Response Time -->
|
<!-- Response Time -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<h3 class="text-lg font-semibold text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}زمان پاسخ{% else %}Response Time{% endif %}
|
{% if lang == 'fa' %}زمان پاسخ{% else %}Response Time{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
@@ -134,22 +134,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Request Summary -->
|
<!-- Request Summary -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<h3 class="text-lg font-semibold text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}خلاصه درخواستها{% else %}Request Summary{% endif %}
|
{% if lang == 'fa' %}خلاصه درخواستها{% else %}Request Summary{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}موفق{% else %}Successful{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}موفق{% else %}Successful{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-green-400">{{ metrics.successful_requests|default(0) }}</p>
|
<p class="text-2xl font-bold text-green-400">{{ metrics.successful_requests|default(0) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}ناموفق{% else %}Failed{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ناموفق{% else %}Failed{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-red-400">{{ metrics.failed_requests|default(0) }}</p>
|
<p class="text-2xl font-bold text-red-400">{{ metrics.failed_requests|default(0) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<div class="w-full bg-gray-700 rounded-full h-2">
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
{% set total = metrics.total_requests|default(0) %}
|
{% set total = metrics.total_requests|default(0) %}
|
||||||
{% set successful = metrics.successful_requests|default(0) %}
|
{% set successful = metrics.successful_requests|default(0) %}
|
||||||
{% set success_rate = ((successful / total) * 100) if total > 0 else 100 %}
|
{% set success_rate = ((successful / total) * 100) if total > 0 else 100 %}
|
||||||
@@ -164,12 +164,12 @@
|
|||||||
<div id="alerts-container"></div>
|
<div id="alerts-container"></div>
|
||||||
|
|
||||||
<!-- Projects Health -->
|
<!-- Projects Health -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div class="px-6 py-4 border-b border-gray-700 flex items-center justify-between">
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if lang == 'fa' %}سلامت پروژهها{% else %}Project Health{% endif %}
|
{% if lang == 'fa' %}سلامت پروژهها{% else %}Project Health{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<span id="projects-summary" class="text-sm text-gray-400">
|
<span id="projects-summary" class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if async_load %}
|
{% if async_load %}
|
||||||
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
|
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -180,21 +180,21 @@
|
|||||||
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
{% if lang == 'fa' %}پروژه{% else %}Project{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}زمان پاسخ{% else %}Response Time{% endif %}
|
{% if lang == 'fa' %}زمان پاسخ{% else %}Response Time{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}
|
{% if lang == 'fa' %}نرخ خطا{% else %}Error Rate{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}آخرین بررسی{% else %}Last Check{% endif %}
|
{% if lang == 'fa' %}آخرین بررسی{% else %}Last Check{% endif %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
<!-- HTMX async loading - wraps tbody for proper swap -->
|
<!-- HTMX async loading - wraps tbody for proper swap -->
|
||||||
<tbody
|
<tbody
|
||||||
id="projects-health-body"
|
id="projects-health-body"
|
||||||
class="divide-y divide-gray-700"
|
class="divide-y divide-gray-200 dark:divide-gray-700"
|
||||||
hx-get="/api/dashboard/health/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
hx-get="/api/dashboard/health/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-swap="innerHTML"
|
hx-swap="innerHTML"
|
||||||
@@ -215,7 +215,7 @@
|
|||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}در حال بررسی سلامت پروژهها...{% else %}Checking projects health...{% endif %}
|
{% if lang == 'fa' %}در حال بررسی سلامت پروژهها...{% else %}Checking projects health...{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -223,12 +223,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if projects_health %}
|
{% if projects_health %}
|
||||||
{% for project_id, project in projects_health.items() %}
|
{% for project_id, project in projects_health.items() %}
|
||||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-white font-medium">{{ project_id }}</span>
|
<span class="text-gray-900 dark:text-white font-medium">{{ project_id }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
{% if project.healthy or project.status == 'healthy' %}
|
{% if project.healthy or project.status == 'healthy' %}
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
|
<span class="text-gray-700 dark:text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="{% if project.error_rate_percent > 10 %}text-red-400{% elif project.error_rate_percent > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
<span class="{% if project.error_rate_percent > 10 %}text-red-400{% elif project.error_rate_percent > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
||||||
@@ -252,14 +252,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="px-6 py-12 text-center">
|
<td colspan="5" class="px-6 py-12 text-center">
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}هیچ پروژهای برای بررسی یافت نشد{% else %}No projects found for health check{% endif %}
|
{% if lang == 'fa' %}هیچ پروژهای برای بررسی یافت نشد{% else %}No projects found for health check{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
@@ -272,26 +272,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System Information -->
|
<!-- System Information -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<h3 class="text-lg font-semibold text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}اطلاعات سیستم{% else %}System Information{% endif %}
|
{% if lang == 'fa' %}اطلاعات سیستم{% else %}System Information{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}زمان شروع{% else %}Start Time{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}زمان شروع{% else %}Start Time{% endif %}</p>
|
||||||
<p class="text-white font-mono text-sm">{{ uptime.start_time[:19] if uptime.start_time else '-' }}</p>
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.start_time[:19] if uptime.start_time else '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}آپتایم (روز){% else %}Uptime (Days){% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آپتایم (روز){% else %}Uptime (Days){% endif %}</p>
|
||||||
<p class="text-white font-mono text-sm">{{ uptime.days|default(0)|round(2) }}</p>
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.days|default(0)|round(2) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}آپتایم (ساعت){% else %}Uptime (Hours){% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آپتایم (ساعت){% else %}Uptime (Hours){% endif %}</p>
|
||||||
<p class="text-white font-mono text-sm">{{ uptime.hours|default(0)|round(2) }}</p>
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.hours|default(0)|round(2) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}زمان فعلی{% else %}Current Time{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}زمان فعلی{% else %}Current Time{% endif %}</p>
|
||||||
<p class="text-white font-mono text-sm">{{ uptime.current_time[:19] if uptime.current_time else '-' }}</p>
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ uptime.current_time[:19] if uptime.current_time else '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -299,7 +299,7 @@
|
|||||||
<!-- Refresh Buttons -->
|
<!-- Refresh Buttons -->
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
{% if is_cached %}
|
{% if is_cached %}
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
<svg class="w-4 h-4 inline-block {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 inline-block {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -316,7 +316,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
||||||
{{ t.refresh }}
|
{{ t.refresh }}
|
||||||
</a>
|
</a>
|
||||||
<a href="/dashboard/health?refresh=true{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="/dashboard/health?refresh=true{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<!-- Projects Table Rows -->
|
<!-- Projects Table Rows -->
|
||||||
{% if projects_health %}
|
{% if projects_health %}
|
||||||
{% for project_id, project in projects_health.items() %}
|
{% for project_id, project in projects_health.items() %}
|
||||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-white font-medium">{{ project_id }}</span>
|
<span class="text-gray-900 dark:text-white font-medium">{{ project_id }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
{% if project.healthy or project.status == 'healthy' %}
|
{% if project.healthy or project.status == 'healthy' %}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
|
<span class="text-gray-700 dark:text-gray-300">{{ project.response_time_ms|round(2) }} ms</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="{% if project.error_rate_percent > 10 %}text-red-400{% elif project.error_rate_percent > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
<span class="{% if project.error_rate_percent > 10 %}text-red-400{% elif project.error_rate_percent > 5 %}text-yellow-400{% else %}text-green-400{% endif %}">
|
||||||
@@ -25,14 +25,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ project.last_check[:19] if project.last_check else '-' }}</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="px-6 py-12 text-center">
|
<td colspan="5" class="px-6 py-12 text-center">
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}هیچ پروژهای برای بررسی یافت نشد{% else %}No projects found for health check{% endif %}
|
{% if lang == 'fa' %}هیچ پروژهای برای بررسی یافت نشد{% else %}No projects found for health check{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -5,14 +5,20 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Stats Cards -->
|
|
||||||
|
{% if is_admin is defined and is_admin %}
|
||||||
|
{# ══════════════════════════════════════════════════════ #}
|
||||||
|
{# ADMIN DASHBOARD #}
|
||||||
|
{# ══════════════════════════════════════════════════════ #}
|
||||||
|
|
||||||
|
<!-- Admin Stats Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<!-- Projects Card -->
|
<!-- Projects Card -->
|
||||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{{ t.total_projects }}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.total_projects }}</p>
|
||||||
<p class="text-3xl font-bold text-white mt-1">{{ stats.projects_count }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.projects_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -29,11 +35,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Keys Card -->
|
<!-- API Keys Card -->
|
||||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{{ t.active_api_keys }}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.active_api_keys }}</p>
|
||||||
<p class="text-3xl font-bold text-white mt-1">{{ stats.api_keys_count }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.api_keys_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -50,11 +56,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tools Card -->
|
<!-- Tools Card -->
|
||||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{{ t.total_tools }}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.total_tools }}</p>
|
||||||
<p class="text-3xl font-bold text-white mt-1">{{ stats.tools_count }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.tools_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -63,17 +69,17 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-4 text-sm text-gray-400">
|
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}ابزارهای موجود{% else %}Available tools{% endif %}
|
{% if lang == 'fa' %}ابزارهای موجود{% else %}Available tools{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uptime Card -->
|
<!-- Uptime Card -->
|
||||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{{ t.system_uptime }}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.system_uptime }}</p>
|
||||||
<p class="text-3xl font-bold text-white mt-1">{{ "%.1f"|format(stats.uptime_days) }}d</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ "%.1f"|format(stats.uptime_days) }}d</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -90,20 +96,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Grid -->
|
<!-- Admin Main Content Grid -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<!-- Recent Activity -->
|
<!-- Recent Activity -->
|
||||||
<div class="lg:col-span-2 bg-gray-800 rounded-xl border border-gray-700">
|
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{{ t.recent_activity }}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.recent_activity }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-700">
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if recent_activity %}
|
{% if recent_activity %}
|
||||||
{% for activity in recent_activity %}
|
{% for activity in recent_activity %}
|
||||||
<div class="p-4 hover:bg-gray-700/50 transition-colors">
|
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<!-- Icon based on type -->
|
|
||||||
{% if activity.type == 'tool_call' %}
|
{% if activity.type == 'tool_call' %}
|
||||||
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
<div class="w-8 h-8 bg-blue-500/20 rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||||
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -131,16 +136,16 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-white">{{ activity.message }}</p>
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ activity.message }}</p>
|
||||||
<p class="text-xs text-gray-400">{{ activity.project }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ activity.project if activity.project and activity.project != 'None' else '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-gray-500">{{ activity.timestamp[:19] }}</span>
|
<span class="text-xs text-gray-400 dark:text-gray-500">{{ activity.timestamp[:19] if activity.timestamp else '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="p-8 text-center text-gray-400">
|
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||||
<svg class="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -149,9 +154,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% if recent_activity %}
|
{% if recent_activity %}
|
||||||
<div class="p-4 border-t border-gray-700">
|
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<a href="/dashboard/audit-logs{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-sm text-primary-400 hover:text-primary-300">
|
<a href="/dashboard/audit-logs{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-sm text-primary-400 hover:text-primary-300">
|
||||||
{{ t.view_all }} →
|
{{ t.view_all }} →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -160,9 +165,9 @@
|
|||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Projects by Type -->
|
<!-- Projects by Type -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{{ t.projects_by_type }}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.projects_by_type }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-6 space-y-4">
|
||||||
{% set plugin_colors = {
|
{% set plugin_colors = {
|
||||||
@@ -196,13 +201,13 @@
|
|||||||
<div class="w-8 h-8 {{ plugin_colors.get(plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
<div class="w-8 h-8 {{ plugin_colors.get(plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||||
<span class="text-xs font-bold text-white">{{ plugin_icons.get(plugin_type, plugin_type[:2]|upper) }}</span>
|
<span class="text-xs font-bold text-white">{{ plugin_icons.get(plugin_type, plugin_type[:2]|upper) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-gray-300">{{ plugin_type|plugin_name }}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">{{ plugin_type|plugin_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm font-medium text-white">{{ count }}</span>
|
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ count }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-center text-gray-400 text-sm">
|
<p class="text-center text-gray-500 dark:text-gray-400 text-sm">
|
||||||
{% if lang == 'fa' %}پروژهای یافت نشد{% else %}No projects found{% endif %}
|
{% if lang == 'fa' %}پروژهای یافت نشد{% else %}No projects found{% endif %}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -210,39 +215,39 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Health Status -->
|
<!-- Health Status -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{{ t.health_status }}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.health_status }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-6 space-y-4">
|
||||||
{% for component, status in health_summary.components.items() %}
|
{% for component, status in health_summary.components.items() %}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-gray-300 capitalize">{{ component }}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300 capitalize">{{ component }}</span>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
{% if status == 'healthy' %}
|
{% if status == 'healthy' %}
|
||||||
<span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
<span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
<span class="text-sm text-green-400">{{ t.healthy }}</span>
|
<span class="text-sm text-green-600 dark:text-green-400">{{ t.healthy }}</span>
|
||||||
{% elif status == 'warning' %}
|
{% elif status == 'warning' %}
|
||||||
<span class="w-2 h-2 bg-yellow-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
<span class="w-2 h-2 bg-yellow-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
<span class="text-sm text-yellow-400">{{ t.warning }}</span>
|
<span class="text-sm text-yellow-600 dark:text-yellow-400">{{ t.warning }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="w-2 h-2 bg-red-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
<span class="w-2 h-2 bg-red-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
<span class="text-sm text-red-400">{{ t.error }}</span>
|
<span class="text-sm text-red-600 dark:text-red-400">{{ t.error }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="pt-4 border-t border-gray-700">
|
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<p class="text-xs text-gray-500">
|
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %}
|
{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %}
|
||||||
{{ health_summary.last_check[:19] }}
|
{{ health_summary.last_check[:19] }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 border-t border-gray-700">
|
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-sm text-primary-400 hover:text-primary-300">
|
<a href="/dashboard/health{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-sm text-primary-400 hover:text-primary-300">
|
||||||
{{ t.view_all }} →
|
{{ t.view_all }} →
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -276,6 +281,165 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# ══════════════════════════════════════════════════════ #}
|
||||||
|
{# USER DASHBOARD #}
|
||||||
|
{# ══════════════════════════════════════════════════════ #}
|
||||||
|
|
||||||
|
<!-- User Stats Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<!-- My Sites Card -->
|
||||||
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('my_services', 'My Services') }}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.sites_count|default(0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-blue-400 hover:text-blue-300 flex items-center">
|
||||||
|
{{ t.view_all }}
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Connections Card -->
|
||||||
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('active_connections', 'Active Connections') }}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.active_sites_count|default(0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}اتصالات فعال{% else %}Active connections{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- API Keys Card -->
|
||||||
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('user_api_keys', 'API Keys') }}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.api_keys_count|default(0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/dashboard/connect{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="mt-4 text-sm text-purple-400 hover:text-purple-300 flex items-center">
|
||||||
|
{{ t.view_all }}
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Available Tools Card -->
|
||||||
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{{ t.get('available_tools', 'Available Tools') }}</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ stats.tools_count|default(0) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-12 bg-orange-500/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{% if lang == 'fa' %}ابزارهای MCP قابل استفاده{% else %}MCP tools available{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User Site Status -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.get('site_status', 'Site Status') }}</h2>
|
||||||
|
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors">
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||||
|
</svg>
|
||||||
|
{{ t.get('add_site', 'Add Service') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% if user_sites %}
|
||||||
|
{% set plugin_colors = {
|
||||||
|
'wordpress': 'bg-blue-500',
|
||||||
|
'woocommerce': 'bg-purple-500',
|
||||||
|
'wordpress_advanced': 'bg-indigo-500',
|
||||||
|
'gitea': 'bg-green-500',
|
||||||
|
'n8n': 'bg-orange-500',
|
||||||
|
'supabase': 'bg-emerald-500',
|
||||||
|
'openpanel': 'bg-cyan-500',
|
||||||
|
'appwrite': 'bg-pink-500',
|
||||||
|
'directus': 'bg-violet-500',
|
||||||
|
} %}
|
||||||
|
|
||||||
|
{% for site in user_sites %}
|
||||||
|
<div class="flex items-center justify-between p-4 border-b border-gray-100 dark:border-gray-700 last:border-0 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-8 h-8 {{ plugin_colors.get(site.plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||||
|
<span class="text-xs font-bold text-white">{{ site.plugin_type[:2]|upper }}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ site.alias }}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">{{ site.plugin_type|plugin_name }} · {{ site.url[:40] }}{% if site.url|length > 40 %}...{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
{% if site.status == 'active' %}
|
||||||
|
<span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-green-600 dark:text-green-400">{{ t.get('active', 'Active') }}</span>
|
||||||
|
{% elif site.status == 'error' %}
|
||||||
|
<span class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-red-600 dark:text-red-400">{{ t.get('error', 'Error') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-2 h-2 bg-yellow-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-yellow-600 dark:text-yellow-400">Pending</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="p-12 text-center">
|
||||||
|
<svg class="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-2">{{ t.get('no_services_yet', "You haven't connected any services yet.") }}</p>
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500 mb-6">{{ t.get('add_first_service', 'Add your first service to start using MCP tools.') }}</p>
|
||||||
|
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-primary-600 hover:bg-primary-500 rounded-lg transition-colors">
|
||||||
|
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||||
|
</svg>
|
||||||
|
{{ t.get('add_site', 'Add Service') }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -284,7 +448,6 @@
|
|||||||
// Auto-refresh dashboard every 30 seconds
|
// Auto-refresh dashboard every 30 seconds
|
||||||
htmx.config.defaultSwapStyle = 'outerHTML';
|
htmx.config.defaultSwapStyle = 'outerHTML';
|
||||||
|
|
||||||
// Refresh stats periodically
|
|
||||||
setInterval(function() {
|
setInterval(function() {
|
||||||
htmx.ajax('GET', '/api/dashboard/stats', {target: '#stats-container', swap: 'none'});
|
htmx.ajax('GET', '/api/dashboard/stats', {target: '#stats-container', swap: 'none'});
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|||||||
@@ -1,46 +1,61 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ lang|default('en') }}" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<html lang="{{ lang|default('en') }}" {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ t.login_title }} - MCP Hub</title>
|
<title>{{ t.login_title }} - MCP Hub</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
<link rel="icon" type="image/svg+xml" href="/static/logo.svg">
|
||||||
|
|
||||||
<!-- Tailwind CSS -->
|
{% include "dashboard/partials/head_assets.html" %}
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.login-gradient {
|
.login-gradient {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-shadow {
|
.card-shadow {
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-focus:focus {
|
.input-focus:focus {
|
||||||
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
|
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.animate-float {
|
.animate-float {
|
||||||
animation: float 6s ease-in-out infinite;
|
animation: float 6s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes float {
|
@keyframes float {
|
||||||
0%, 100% { transform: translateY(0px); }
|
|
||||||
50% { transform: translateY(-20px); }
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-gray-900 min-h-screen flex items-center justify-center p-4">
|
<body class="bg-gray-900 min-h-screen flex items-center justify-center p-4">
|
||||||
<!-- Background decoration -->
|
<!-- Background decoration -->
|
||||||
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
<div class="absolute -top-40 -right-40 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl animate-float"></div>
|
<div class="absolute -top-40 -right-40 w-80 h-80 bg-purple-500/20 rounded-full blur-3xl animate-float"></div>
|
||||||
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-float" style="animation-delay: -3s;"></div>
|
<div class="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500/20 rounded-full blur-3xl animate-float"
|
||||||
|
style="animation-delay: -3s;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Login Card -->
|
<!-- Login Card -->
|
||||||
@@ -49,20 +64,22 @@
|
|||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<div class="mx-auto w-16 h-16 login-gradient rounded-2xl flex items-center justify-center mb-4">
|
<div class="mx-auto w-16 h-16 login-gradient rounded-2xl flex items-center justify-center mb-4">
|
||||||
<svg class="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<img src="/static/logo.svg" alt="MCP Hub Logo"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
|
class="w-10 h-10 object-contain drop-shadow-[0_0_8px_rgba(255,255,255,0.8)]">
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<h1 class="text-2xl font-bold text-white">MCP Hub</h1>
|
<h1 class="text-2xl font-bold text-white">MCP Hub</h1>
|
||||||
<p class="text-gray-400 mt-2" {% if lang == 'fa' %}dir="rtl"{% endif %}>{{ t.login_subtitle }}</p>
|
<p class="text-gray-400 mt-2" {% if lang=='fa' %}dir="rtl" {% endif %}>{{ t.login_subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Message -->
|
<!-- Error Message -->
|
||||||
{% if error %}
|
{% if error %}
|
||||||
<div class="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<div class="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-lg" {% if lang=='fa' %}dir="rtl" {%
|
||||||
|
endif %}>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<svg class="w-5 h-5 text-red-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-red-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-red-400 text-sm">
|
<span class="text-red-400 text-sm">
|
||||||
{% if error == 'rate_limit' %}
|
{% if error == 'rate_limit' %}
|
||||||
@@ -77,48 +94,49 @@
|
|||||||
|
|
||||||
<!-- Login Form -->
|
<!-- Login Form -->
|
||||||
<form method="POST" action="/dashboard/login" class="space-y-6">
|
<form method="POST" action="/dashboard/login" class="space-y-6">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ request.state.csrf_token }}">
|
||||||
<input type="hidden" name="next" value="{{ next_url }}">
|
<input type="hidden" name="next" value="{{ next_url }}">
|
||||||
|
|
||||||
<div {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<div {% if lang=='fa' %}dir="rtl" {% endif %}>
|
||||||
<label for="api_key" class="block text-sm font-medium text-gray-300 mb-2">
|
<label for="api_key" class="block text-sm font-medium text-gray-300 mb-2">
|
||||||
{{ t.api_key_label }}
|
{{ t.api_key_label }}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input type="password" id="api_key" name="api_key" required autocomplete="off"
|
||||||
type="password"
|
|
||||||
id="api_key"
|
|
||||||
name="api_key"
|
|
||||||
required
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="{{ t.api_key_placeholder }}"
|
placeholder="{{ t.api_key_placeholder }}"
|
||||||
class="input-focus w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-purple-500 transition-colors"
|
class="input-focus w-full px-4 py-3 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:border-purple-500 transition-colors"
|
||||||
{% if lang == 'fa' %}dir="ltr" style="text-align: right;"{% endif %}
|
{% if lang=='fa' %}dir="ltr" style="text-align: right;" {% endif %}>
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button type="submit"
|
||||||
type="submit"
|
class="btn-primary w-full py-3 px-4 text-white font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-gray-800">
|
||||||
class="btn-primary w-full py-3 px-4 text-white font-semibold rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 focus:ring-offset-gray-800"
|
|
||||||
>
|
|
||||||
{{ t.login_button }}
|
{{ t.login_button }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Security Note -->
|
<!-- Security Note -->
|
||||||
<div class="mt-6 p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
<div class="mt-6 p-4 bg-blue-500/10 border border-blue-500/30 rounded-lg" {% if lang=='fa' %}dir="rtl" {%
|
||||||
|
endif %}>
|
||||||
<div class="flex items-start">
|
<div class="flex items-start">
|
||||||
<svg class="w-5 h-5 text-blue-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %} mt-0.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-blue-400 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %} mt-0.5 flex-shrink-0"
|
||||||
<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"/>
|
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>
|
</svg>
|
||||||
<div class="text-sm text-blue-300">
|
<div class="text-sm text-blue-300">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
<p>برای دسترسی به داشبورد به Master API Key نیاز دارید.</p>
|
<p>برای دسترسی به داشبورد به Master API Key نیاز دارید.</p>
|
||||||
<p class="mt-2 text-blue-400">کلید API خود را در متغیر محیطی <code class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> تنظیم کنید.</p>
|
<p class="mt-2 text-blue-400">کلید API خود را در متغیر محیطی <code
|
||||||
|
class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> تنظیم کنید.</p>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p>Enter your <code class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> to access the dashboard.</p>
|
<p>Enter your <code class="bg-gray-700 px-1 rounded">MASTER_API_KEY</code> to access the
|
||||||
<p class="mt-2 text-blue-400">Don't have one? Set it in your <code class="bg-gray-700 px-1 rounded">.env</code> file, or check the server logs for the temporary key.</p>
|
dashboard.</p>
|
||||||
|
<p class="mt-2 text-blue-400">Don't have one? Set it in your <code
|
||||||
|
class="bg-gray-700 px-1 rounded">.env</code> file, or check the server logs for the
|
||||||
|
temporary key.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p class="mt-2">
|
<p class="mt-2">
|
||||||
<a href="https://github.com/airano-ir/mcphub#quick-start" target="_blank" class="text-purple-400 hover:text-purple-300 underline">
|
<a href="https://github.com/airano-ir/mcphub#quick-start" target="_blank"
|
||||||
|
class="text-purple-400 hover:text-purple-300 underline">
|
||||||
{% if lang == 'fa' %}راهنمای شروع{% else %}Setup Guide{% endif %}
|
{% if lang == 'fa' %}راهنمای شروع{% else %}Setup Guide{% endif %}
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
@@ -128,11 +146,9 @@
|
|||||||
|
|
||||||
<!-- Language Toggle -->
|
<!-- Language Toggle -->
|
||||||
<div class="mt-6 text-center">
|
<div class="mt-6 text-center">
|
||||||
<a
|
<a href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}{% if next_url %}&next={{ next_url }}{% endif %}"
|
||||||
href="?lang={% if lang == 'fa' %}en{% else %}fa{% endif %}{% if next_url %}&next={{ next_url }}{% endif %}"
|
class="text-sm text-gray-400 hover:text-white transition-colors">
|
||||||
class="text-sm text-gray-400 hover:text-white transition-colors"
|
{% if lang == 'fa' %}English{% else %}<span lang="fa" dir="rtl">فارسی</span>{% endif %}
|
||||||
>
|
|
||||||
{% if lang == 'fa' %}English{% else %}فارسی{% endif %}
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,4 +159,5 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<!-- Header with Create Button -->
|
<!-- Header with Create Button -->
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
مدیریت کلاینتهای OAuth برای دسترسی امن به API
|
مدیریت کلاینتهای OAuth برای دسترسی امن به API
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -29,11 +29,11 @@
|
|||||||
|
|
||||||
<!-- Stats -->
|
<!-- Stats -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کل کلاینتها{% else %}Total Clients{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کل کلاینتها{% else %}Total Clients{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-white">{{ total_count }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ total_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-10 h-10 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -42,10 +42,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کلاینتهای فعال{% else %}Active Clients{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کلاینتهای فعال{% else %}Active Clients{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-green-400">{{ total_count }}</p>
|
<p class="text-2xl font-bold text-green-400">{{ total_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-10 h-10 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||||
@@ -55,11 +55,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}پروتکل{% else %}Protocol{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}پروتکل{% else %}Protocol{% endif %}</p>
|
||||||
<p class="text-2xl font-bold text-white">OAuth 2.1</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">OAuth 2.1</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-10 h-10 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -71,37 +71,37 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Clients Table -->
|
<!-- Clients Table -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}
|
{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}شناسه کلاینت{% else %}Client ID{% endif %}
|
{% if lang == 'fa' %}شناسه کلاینت{% else %}Client ID{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}
|
{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}تاریخ ایجاد{% else %}Created{% endif %}
|
{% if lang == 'fa' %}تاریخ ایجاد{% else %}Created{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if clients %}
|
{% if clients %}
|
||||||
{% for client in clients %}
|
{% for client in clients %}
|
||||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-white font-medium">{{ client.client_name }}</span>
|
<span class="text-gray-900 dark:text-white font-medium">{{ client.client_name }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<code class="text-sm text-gray-400 bg-gray-900 px-2 py-1 rounded">{{ client.client_id[:30] }}...</code>
|
<code class="text-sm text-gray-500 dark:text-gray-400 bg-gray-900 px-2 py-1 rounded">{{ client.client_id[:30] }}...</code>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-400">{{ client.created_at[:10] if client.created_at else '-' }}</span>
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ client.created_at[:10] if client.created_at else '-' }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -143,15 +143,15 @@
|
|||||||
<td colspan="5" class="px-6 py-4">
|
<td colspan="5" class="px-6 py-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400 mb-1">{% if lang == 'fa' %}آدرسهای Redirect{% else %}Redirect URIs{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}آدرسهای Redirect{% else %}Redirect URIs{% endif %}</p>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{% for uri in client.redirect_uris %}
|
{% for uri in client.redirect_uris %}
|
||||||
<code class="block text-xs text-gray-300 bg-gray-800 px-2 py-1 rounded">{{ uri }}</code>
|
<code class="block text-xs text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded">{{ uri }}</code>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400 mb-1">{% if lang == 'fa' %}انواع Grant{% else %}Grant Types{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}انواع Grant{% else %}Grant Types{% endif %}</p>
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-wrap gap-1">
|
||||||
{% for grant in client.grant_types %}
|
{% for grant in client.grant_types %}
|
||||||
<span class="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">{{ grant }}</span>
|
<span class="px-2 py-0.5 bg-purple-500/20 text-purple-400 text-xs rounded">{{ grant }}</span>
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-400 mb-4">
|
<p class="text-gray-500 dark:text-gray-400 mb-4">
|
||||||
{% if lang == 'fa' %}هیچ کلاینت OAuth ثبت نشده{% else %}No OAuth clients registered{% endif %}
|
{% if lang == 'fa' %}هیچ کلاینت OAuth ثبت نشده{% else %}No OAuth clients registered{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
@@ -188,30 +188,30 @@
|
|||||||
|
|
||||||
<!-- Create Client Modal -->
|
<!-- Create Client Modal -->
|
||||||
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<div id="createModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6 w-full max-w-lg mx-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-lg mx-4">
|
||||||
<h3 class="text-xl font-semibold text-white mb-4">
|
<h3 class="text-xl font-semibold text-white mb-4">
|
||||||
{% if lang == 'fa' %}ایجاد کلاینت OAuth{% else %}Create OAuth Client{% endif %}
|
{% if lang == 'fa' %}ایجاد کلاینت OAuth{% else %}Create OAuth Client{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form id="createForm" class="space-y-4">
|
<form id="createForm" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1">{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}</label>
|
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
name="client_name"
|
name="client_name"
|
||||||
required
|
required
|
||||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
placeholder="{% if lang == 'fa' %}مثال: My MCP Client{% else %}e.g., My MCP Client{% endif %}"
|
placeholder="{% if lang == 'fa' %}مثال: My MCP Client{% else %}e.g., My MCP Client{% endif %}"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1">{% if lang == 'fa' %}آدرسهای Redirect{% else %}Redirect URIs{% endif %}</label>
|
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}آدرسهای Redirect{% else %}Redirect URIs{% endif %}</label>
|
||||||
<textarea
|
<textarea
|
||||||
name="redirect_uris"
|
name="redirect_uris"
|
||||||
required
|
required
|
||||||
rows="3"
|
rows="3"
|
||||||
class="w-full bg-gray-700 border border-gray-600 rounded-lg px-4 py-2 text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
placeholder="https://example.com/callback https://app.example.com/oauth/callback"
|
placeholder="https://example.com/callback https://app.example.com/oauth/callback"
|
||||||
></textarea>
|
></textarea>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
@@ -220,19 +220,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1">{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}</label>
|
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">{% if lang == 'fa' %}دامنهها{% else %}Scopes{% endif %}</label>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input type="checkbox" name="scopes" value="read" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
<input type="checkbox" name="scopes" value="read" checked class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||||
<span class="text-sm text-gray-300">read</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">read</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input type="checkbox" name="scopes" value="write" class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
<input type="checkbox" name="scopes" value="write" class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||||
<span class="text-sm text-gray-300">write</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">write</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input type="checkbox" name="scopes" value="admin" class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
<input type="checkbox" name="scopes" value="admin" class="rounded bg-gray-700 border-gray-600 text-primary-500">
|
||||||
<span class="text-sm text-gray-300">admin</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">admin</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick="closeCreateModal()"
|
onclick="closeCreateModal()"
|
||||||
class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{{ t.cancel }}
|
{{ t.cancel }}
|
||||||
</button>
|
</button>
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
|
|
||||||
<!-- Success Modal (shows client secret) -->
|
<!-- Success Modal (shows client secret) -->
|
||||||
<div id="successModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<div id="successModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6 w-full max-w-lg mx-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-lg mx-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div class="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -271,16 +271,16 @@
|
|||||||
<h3 class="text-xl font-semibold text-white mb-2">
|
<h3 class="text-xl font-semibold text-white mb-2">
|
||||||
{% if lang == 'fa' %}کلاینت ایجاد شد{% else %}Client Created{% endif %}
|
{% if lang == 'fa' %}کلاینت ایجاد شد{% else %}Client Created{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-sm text-gray-400 mb-4">
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
{% if lang == 'fa' %}این رمز فقط یکبار نمایش داده میشود. آن را در جای امنی ذخیره کنید.{% else %}This secret is only shown once. Save it in a secure place.{% endif %}
|
{% if lang == 'fa' %}این رمز فقط یکبار نمایش داده میشود. آن را در جای امنی ذخیره کنید.{% else %}This secret is only shown once. Save it in a secure place.{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 mb-6">
|
<div class="space-y-3 mb-6">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1">Client ID</label>
|
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">Client ID</label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<code id="newClientId" class="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-gray-300 overflow-x-auto"></code>
|
<code id="newClientId" class="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-gray-700 dark:text-gray-300 overflow-x-auto"></code>
|
||||||
<button onclick="copyToClipboard('newClientId')" class="p-2 text-gray-400 hover:text-white">
|
<button onclick="copyToClipboard('newClientId')" class="p-2 text-gray-400 hover:text-white">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
@@ -289,7 +289,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-400 mb-1">Client Secret</label>
|
<label class="block text-sm text-gray-500 dark:text-gray-400 mb-1">Client Secret</label>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<code id="newClientSecret" class="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-green-400 overflow-x-auto"></code>
|
<code id="newClientSecret" class="flex-1 bg-gray-900 px-3 py-2 rounded text-sm text-green-400 overflow-x-auto"></code>
|
||||||
<button onclick="copyToClipboard('newClientSecret')" class="p-2 text-gray-400 hover:text-white">
|
<button onclick="copyToClipboard('newClientSecret')" class="p-2 text-gray-400 hover:text-white">
|
||||||
@@ -312,7 +312,7 @@
|
|||||||
|
|
||||||
<!-- Delete Confirmation Modal -->
|
<!-- Delete Confirmation Modal -->
|
||||||
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
<div id="deleteModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6 w-full max-w-md mx-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 w-full max-w-md mx-4">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div class="w-16 h-16 bg-red-500/20 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-8 h-8 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -322,7 +322,7 @@
|
|||||||
<h3 class="text-xl font-semibold text-white mb-2">
|
<h3 class="text-xl font-semibold text-white mb-2">
|
||||||
{% if lang == 'fa' %}حذف کلاینت{% else %}Delete Client{% endif %}
|
{% if lang == 'fa' %}حذف کلاینت{% else %}Delete Client{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-gray-400 mb-1">
|
<p class="text-gray-500 dark:text-gray-400 mb-1">
|
||||||
{% if lang == 'fa' %}آیا مطمئن هستید که میخواهید این کلاینت را حذف کنید؟{% else %}Are you sure you want to delete this client?{% endif %}
|
{% if lang == 'fa' %}آیا مطمئن هستید که میخواهید این کلاینت را حذف کنید؟{% else %}Are you sure you want to delete this client?{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p id="deleteClientName" class="text-white font-medium mb-4"></p>
|
<p id="deleteClientName" class="text-white font-medium mb-4"></p>
|
||||||
@@ -331,7 +331,7 @@
|
|||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<button
|
||||||
onclick="closeDeleteModal()"
|
onclick="closeDeleteModal()"
|
||||||
class="flex-1 px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
class="flex-1 px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{{ t.cancel }}
|
{{ t.cancel }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
107
core/templates/dashboard/partials/head_assets.html
Normal file
107
core/templates/dashboard/partials/head_assets.html
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<!-- Vazirmatn Font for Persian -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@100;200;300;400;500;600;700;800;900&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- HTMX for dynamic updates -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
|
||||||
|
<!-- Alpine.js for simple interactions -->
|
||||||
|
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Custom Tailwind Config -->
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
'vazirmatn': ['Vazirmatn', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#f5f3ff',
|
||||||
|
100: '#ede9fe',
|
||||||
|
200: '#ddd6fe',
|
||||||
|
300: '#c4b5fd',
|
||||||
|
400: '#a78bfa',
|
||||||
|
500: '#8b5cf6',
|
||||||
|
600: '#7c3aed',
|
||||||
|
700: '#6d28d9',
|
||||||
|
800: '#5b21b6',
|
||||||
|
900: '#4c1d95',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Theme Initialization -->
|
||||||
|
<script>
|
||||||
|
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<meta name="csrf-token" content="{{ request.state.csrf_token }}">
|
||||||
|
|
||||||
|
<!-- Global CSRF Interceptor -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const getCsrfToken = () => document.querySelector('meta[name="csrf-token"]')?.content;
|
||||||
|
|
||||||
|
// Intercept HTMX requests
|
||||||
|
document.body.addEventListener('htmx:configRequest', (event) => {
|
||||||
|
const token = getCsrfToken();
|
||||||
|
if (token && event.detail.verb !== 'get') {
|
||||||
|
event.detail.headers['X-CSRF-Token'] = token;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Intercept standard fetch requests
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
window.fetch = async function (resource, init) {
|
||||||
|
const token = getCsrfToken();
|
||||||
|
if (token) {
|
||||||
|
const url = typeof resource === 'string' ? new URL(resource, window.location.origin) : resource;
|
||||||
|
if (url.origin === window.location.origin) {
|
||||||
|
init = init || {};
|
||||||
|
init.headers = init.headers || {};
|
||||||
|
|
||||||
|
const method = (init.method || 'GET').toUpperCase();
|
||||||
|
if (method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
|
||||||
|
if (init.headers instanceof Headers) {
|
||||||
|
init.headers.append('X-CSRF-Token', token);
|
||||||
|
} else if (Array.isArray(init.headers)) {
|
||||||
|
init.headers.push(['X-CSRF-Token', token]);
|
||||||
|
} else {
|
||||||
|
init.headers['X-CSRF-Token'] = token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalFetch.call(this, resource, init);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vazirmatn font for Persian */
|
||||||
|
html[lang="fa"],
|
||||||
|
html[lang="fa"] *,
|
||||||
|
[lang="fa"],
|
||||||
|
[lang="fa"] * {
|
||||||
|
font-family: 'Vazirmatn', system-ui, sans-serif !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
90
core/templates/dashboard/profile.html
Normal file
90
core/templates/dashboard/profile.html
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{% if lang == 'fa' %}پروفایل{% else %}Profile{% endif %}{% endblock %}
|
||||||
|
{% block page_title %}{% if lang == 'fa' %}پروفایل{% else %}Profile{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-2xl mx-auto space-y-6">
|
||||||
|
<!-- User Info Card -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center space-x-4 {% if lang == 'fa' %}space-x-reverse{% endif %}">
|
||||||
|
{% if user and user.avatar_url %}
|
||||||
|
<img src="{{ user.avatar_url }}" alt="{{ user.name or user.email }}" class="w-16 h-16 rounded-full border-2 border-purple-500">
|
||||||
|
{% else %}
|
||||||
|
<div class="w-16 h-16 rounded-full bg-purple-600 flex items-center justify-center text-white text-xl font-bold">
|
||||||
|
{{ (session.name or session.email or "?")[0]|upper }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ user.name or session.name or "User" }}</h2>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">{{ user.email or session.email }}</p>
|
||||||
|
{% if user %}
|
||||||
|
<div class="flex items-center mt-1 space-x-2 {% if lang == 'fa' %}space-x-reverse{% endif %}">
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||||
|
{% if user.provider == 'github' %}bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300{% else %}bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300{% endif %}">
|
||||||
|
{% if user.provider == 'github' %}
|
||||||
|
<svg class="w-3 h-3 {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0c-6.626 0-12 5.373-12 12 0 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.23.957-.266 1.983-.399 3.003-.404 1.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.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/></svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-3 h-3 {% if lang == 'fa' %}ml-1{% else %}mr-1{% endif %}" fill="currentColor" viewBox="0 0 24 24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/></svg>
|
||||||
|
{% endif %}
|
||||||
|
{{ user.provider|title }}
|
||||||
|
</span>
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 dark:bg-purple-900 text-purple-700 dark:text-purple-300">
|
||||||
|
{{ user.role|title }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Account Details -->
|
||||||
|
{% if user %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
{% if lang == 'fa' %}جزئیات حساب{% else %}Account Details{% endif %}
|
||||||
|
</h3>
|
||||||
|
<dl class="space-y-3">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}شناسه کاربر{% else %}User ID{% endif %}</dt>
|
||||||
|
<dd class="text-gray-700 dark:text-gray-200 font-mono text-sm">{{ user.id[:8] }}...</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ارائهدهنده ورود{% else %}Login Provider{% endif %}</dt>
|
||||||
|
<dd class="text-gray-700 dark:text-gray-200">{{ user.provider|title }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}تاریخ عضویت{% else %}Joined{% endif %}</dt>
|
||||||
|
<dd class="text-gray-700 dark:text-gray-200">{{ user.created_at[:10] if user.created_at else "Unknown" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آخرین ورود{% else %}Last Login{% endif %}</dt>
|
||||||
|
<dd class="text-gray-700 dark:text-gray-200">{{ user.last_login[:10] if user.last_login else "Never" }}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<!-- Account linking note -->
|
||||||
|
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg">
|
||||||
|
<p class="text-xs text-blue-700 dark:text-blue-300">
|
||||||
|
{% if lang == 'fa' %}
|
||||||
|
حساب شما به آدرس ایمیل {{ user.email }} متصل است. میتوانید با هر ارائهدهندهای که از این ایمیل استفاده میکند وارد شوید.
|
||||||
|
{% else %}
|
||||||
|
Your account is linked to {{ user.email }}. You can sign in with any provider that uses this email address.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Logout -->
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="/auth/logout" class="inline-flex items-center px-4 py-2 text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300 border border-red-200 dark:border-red-500/30 rounded-lg hover:bg-red-50 dark:hover:bg-red-500/10 transition-colors">
|
||||||
|
<svg class="w-5 h-5 {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
|
||||||
|
</svg>
|
||||||
|
{% if lang == 'fa' %}خروج{% else %}Sign Out{% endif %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
{% block title %}{{ project.alias or project.site_id }} - {{ t.projects }}{% endblock %}
|
{% block title %}{{ project.alias or project.site_id }} - {{ t.projects }}{% endblock %}
|
||||||
{% block page_title %}
|
{% block page_title %}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-gray-400 hover:text-white {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %} transition-colors">
|
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %} transition-colors">
|
||||||
<svg class="w-5 h-5 {% if lang == 'fa' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 {% if lang == 'fa' %}rotate-180{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
<!-- Stats Cards -->
|
<!-- Stats Cards -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<!-- Status Card -->
|
<!-- Status Card -->
|
||||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}</p>
|
||||||
<div class="flex items-center mt-2">
|
<div class="flex items-center mt-2">
|
||||||
{% if project.health.status == 'healthy' %}
|
{% if project.health.status == 'healthy' %}
|
||||||
<span class="w-3 h-3 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
<span class="w-3 h-3 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
@@ -50,18 +50,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% if project.health.error_rate and project.health.error_rate > 0 %}
|
{% if project.health.error_rate and project.health.error_rate > 0 %}
|
||||||
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}نرخ خطا:{% else %}Error rate:{% endif %} {{ project.health.error_rate|round(1) }}%</p>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نرخ خطا:{% else %}Error rate:{% endif %} {{ project.health.error_rate|round(1) }}%</p>
|
||||||
{% elif project.health.last_check %}
|
{% elif project.health.last_check %}
|
||||||
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %} {{ project.health.last_check[:19] }}</p>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}آخرین بررسی:{% else %}Last check:{% endif %} {{ project.health.last_check[:19] }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tools Card -->
|
<!-- Tools Card -->
|
||||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}ابزارها{% else %}Tools{% endif %}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ابزارها{% else %}Tools{% endif %}</p>
|
||||||
<p class="text-3xl font-bold text-white mt-1">{{ project.tools_count }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.tools_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-purple-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -69,15 +69,15 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}ابزار موجود{% else %}available{% endif %}</p>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ابزار موجود{% else %}available{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- API Keys Card -->
|
<!-- API Keys Card -->
|
||||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کلیدهای API{% else %}API Keys{% endif %}</p>
|
||||||
<p class="text-3xl font-bold text-white mt-1">{{ project.api_keys_count }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.api_keys_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-green-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -91,11 +91,11 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Requests Card -->
|
<!-- Requests Card -->
|
||||||
<div class="card-hover bg-gray-800 rounded-xl p-6 border border-gray-700">
|
<div class="card-hover bg-white dark:bg-gray-800 rounded-xl p-6 border border-gray-200 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-medium text-gray-400">{% if lang == 'fa' %}درخواستها{% else %}Requests{% endif %}</p>
|
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}درخواستها{% else %}Requests{% endif %}</p>
|
||||||
<p class="text-3xl font-bold text-white mt-1">{{ project.requests_24h }}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ project.requests_24h }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
<div class="w-12 h-12 bg-blue-500/20 rounded-lg flex items-center justify-center">
|
||||||
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 text-sm text-gray-400">{% if lang == 'fa' %}در 24 ساعت{% else %}/24h{% endif %}</p>
|
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}در 24 ساعت{% else %}/24h{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -112,33 +112,33 @@
|
|||||||
<!-- Configuration -->
|
<!-- Configuration -->
|
||||||
<div class="lg:col-span-2 space-y-6">
|
<div class="lg:col-span-2 space-y-6">
|
||||||
<!-- Config Section -->
|
<!-- Config Section -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{% if lang == 'fa' %}تنظیمات{% else %}Configuration{% endif %}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}تنظیمات{% else %}Configuration{% endif %}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<dl class="space-y-4">
|
<dl class="space-y-4">
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}شناسه کامل{% else %}Full ID{% endif %}</dt>
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}شناسه کامل{% else %}Full ID{% endif %}</dt>
|
||||||
<dd class="text-white font-mono">{{ project.full_id }}</dd>
|
<dd class="text-gray-900 dark:text-white font-mono">{{ project.full_id }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}نوع پلاگین{% else %}Plugin Type{% endif %}</dt>
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نوع پلاگین{% else %}Plugin Type{% endif %}</dt>
|
||||||
<dd class="text-white">{{ project.plugin_type|plugin_name }}</dd>
|
<dd class="text-gray-900 dark:text-white">{{ project.plugin_type|plugin_name }}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}شناسه سایت{% else %}Site ID{% endif %}</dt>
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}شناسه سایت{% else %}Site ID{% endif %}</dt>
|
||||||
<dd class="text-white font-mono">{{ project.site_id }}</dd>
|
<dd class="text-gray-900 dark:text-white font-mono">{{ project.site_id }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% if project.alias %}
|
{% if project.alias %}
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}</dt>
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}</dt>
|
||||||
<dd class="text-primary-400 font-medium">{{ project.alias }}</dd>
|
<dd class="text-primary-400 font-medium">{{ project.alias }}</dd>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if project.url %}
|
{% if project.url %}
|
||||||
<div class="flex justify-between py-2 border-b border-gray-700">
|
<div class="flex justify-between py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
<dt class="text-gray-400">URL</dt>
|
<dt class="text-gray-500 dark:text-gray-400">URL</dt>
|
||||||
<dd>
|
<dd>
|
||||||
<a href="{{ project.url }}" target="_blank" class="text-blue-400 hover:text-blue-300">
|
<a href="{{ project.url }}" target="_blank" class="text-blue-400 hover:text-blue-300">
|
||||||
{{ project.url }}
|
{{ project.url }}
|
||||||
@@ -150,22 +150,22 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="flex justify-between py-2">
|
<div class="flex justify-between py-2">
|
||||||
<dt class="text-gray-400">{% if lang == 'fa' %}اندپوینت MCP{% else %}MCP Endpoint{% endif %}</dt>
|
<dt class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}اندپوینت MCP{% else %}MCP Endpoint{% endif %}</dt>
|
||||||
<dd class="text-white font-mono text-sm">/project/{{ project.alias or project.full_id }}/mcp</dd>
|
<dd class="text-gray-900 dark:text-white font-mono text-sm">/project/{{ project.alias or project.full_id }}/mcp</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Available Tools -->
|
<!-- Available Tools -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
<div class="p-6 border-b border-gray-700 flex items-center justify-between">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
<h2 class="text-lg font-semibold text-white">{% if lang == 'fa' %}ابزارهای موجود{% else %}Available Tools{% endif %}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}ابزارهای موجود{% else %}Available Tools{% endif %}</h2>
|
||||||
<span class="px-2 py-1 bg-gray-700 rounded-lg text-sm text-gray-300">{{ project.tools|length }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
|
<span class="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded-lg text-sm text-gray-700 dark:text-gray-300">{{ project.tools|length }} {% if lang == 'fa' %}ابزار{% else %}tools{% endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="max-h-96 overflow-y-auto">
|
<div class="max-h-96 overflow-y-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50 sticky top-0">
|
<thead class="bg-gray-50 dark:bg-gray-700/50 sticky top-0">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-4 py-2 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-xs font-medium text-gray-400 uppercase">
|
<th class="px-4 py-2 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-xs font-medium text-gray-400 uppercase">
|
||||||
{% if lang == 'fa' %}نام{% else %}Name{% endif %}
|
{% if lang == 'fa' %}نام{% else %}Name{% endif %}
|
||||||
@@ -178,14 +178,14 @@
|
|||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% for tool in project.tools[:50] %}
|
{% for tool in project.tools[:50] %}
|
||||||
<tr class="hover:bg-gray-700/30">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||||
<td class="px-4 py-2">
|
<td class="px-4 py-2">
|
||||||
<span class="text-sm text-white font-mono">{{ tool.name }}</span>
|
<span class="text-sm text-gray-900 dark:text-white font-mono">{{ tool.name }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-2">
|
<td class="px-4 py-2">
|
||||||
<span class="text-sm text-gray-400 truncate block max-w-xs" title="{{ tool.description }}">
|
<span class="text-sm text-gray-500 dark:text-gray-400 truncate block max-w-xs" title="{{ tool.description }}">
|
||||||
{{ tool.description[:60] }}{% if tool.description|length > 60 %}...{% endif %}
|
{{ tool.description[:60] }}{% if tool.description|length > 60 %}...{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -213,9 +213,9 @@
|
|||||||
<!-- Right Column -->
|
<!-- Right Column -->
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Quick Actions -->
|
<!-- Quick Actions -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{% if lang == 'fa' %}عملیات سریع{% else %}Quick Actions{% endif %}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{% if lang == 'fa' %}عملیات سریع{% else %}Quick Actions{% endif %}</h2>
|
||||||
</div>
|
</div>
|
||||||
<!-- Health Check Result Display -->
|
<!-- Health Check Result Display -->
|
||||||
<div id="health-check-result" class="hidden p-4 rounded-lg mb-2"></div>
|
<div id="health-check-result" class="hidden p-4 rounded-lg mb-2"></div>
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on::before-request="showHealthCheckLoading()"
|
hx-on::before-request="showHealthCheckLoading()"
|
||||||
hx-on::after-request="handleHealthCheckResponse(event)"
|
hx-on::after-request="handleHealthCheckResponse(event)"
|
||||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
|
class="w-full flex items-center justify-between px-4 py-3 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||||||
>
|
>
|
||||||
<span id="health-check-text">{% if lang == 'fa' %}بررسی سلامت{% else %}Check Health{% endif %}</span>
|
<span id="health-check-text">{% if lang == 'fa' %}بررسی سلامت{% else %}Check Health{% endif %}</span>
|
||||||
<svg id="health-check-icon" class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg id="health-check-icon" class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -241,7 +241,7 @@
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
href="/dashboard/api-keys?project={{ project.full_id }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
href="/dashboard/api-keys?project={{ project.full_id }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
|
class="w-full flex items-center justify-between px-4 py-3 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||||||
>
|
>
|
||||||
<span>{% if lang == 'fa' %}مدیریت کلیدها{% else %}Manage API Keys{% endif %}</span>
|
<span>{% if lang == 'fa' %}مدیریت کلیدها{% else %}Manage API Keys{% endif %}</span>
|
||||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
|
|
||||||
<a
|
<a
|
||||||
href="/dashboard/audit-logs?project={{ project.full_id }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
href="/dashboard/audit-logs?project={{ project.full_id }}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="w-full flex items-center justify-between px-4 py-3 bg-gray-700 hover:bg-gray-600 rounded-lg text-white transition-colors"
|
class="w-full flex items-center justify-between px-4 py-3 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white transition-colors"
|
||||||
>
|
>
|
||||||
<span>{% if lang == 'fa' %}مشاهده لاگها{% else %}View Logs{% endif %}</span>
|
<span>{% if lang == 'fa' %}مشاهده لاگها{% else %}View Logs{% endif %}</span>
|
||||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -262,14 +262,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Activity -->
|
<!-- Recent Activity -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
|
||||||
<div class="p-6 border-b border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h2 class="text-lg font-semibold text-white">{{ t.recent_activity }}</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t.recent_activity }}</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="divide-y divide-gray-700">
|
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if project.recent_activity %}
|
{% if project.recent_activity %}
|
||||||
{% for activity in project.recent_activity[:5] %}
|
{% for activity in project.recent_activity[:5] %}
|
||||||
<div class="p-4 hover:bg-gray-700/30">
|
<div class="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/30">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
{% if activity.level == 'ERROR' %}
|
{% if activity.level == 'ERROR' %}
|
||||||
@@ -277,7 +277,7 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
<span class="w-2 h-2 bg-green-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="text-sm text-white">{{ activity.message }}</span>
|
<span class="text-sm text-gray-900 dark:text-white">{{ activity.message }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs text-gray-500">{{ activity.timestamp[:16] }}</span>
|
<span class="text-xs text-gray-500">{{ activity.timestamp[:16] }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,19 +343,19 @@
|
|||||||
resultDiv.classList.add('bg-green-500/20');
|
resultDiv.classList.add('bg-green-500/20');
|
||||||
resultDiv.innerHTML = '<p class="text-green-400 font-medium">' +
|
resultDiv.innerHTML = '<p class="text-green-400 font-medium">' +
|
||||||
(lang === 'fa' ? '✓ سالم' : '✓ Healthy') +
|
(lang === 'fa' ? '✓ سالم' : '✓ Healthy') +
|
||||||
'</p><p class="text-sm text-gray-400">' +
|
'</p><p class="text-sm text-gray-500 dark:text-gray-400">' +
|
||||||
(lang === 'fa' ? 'زمان پاسخ: ' : 'Response time: ') +
|
(lang === 'fa' ? 'زمان پاسخ: ' : 'Response time: ') +
|
||||||
(response.response_time_ms || 0).toFixed(2) + ' ms</p>';
|
(response.response_time_ms || 0).toFixed(2) + ' ms</p>';
|
||||||
} else if (response.status === 'unhealthy') {
|
} else if (response.status === 'unhealthy') {
|
||||||
resultDiv.classList.add('bg-red-500/20');
|
resultDiv.classList.add('bg-red-500/20');
|
||||||
resultDiv.innerHTML = '<p class="text-red-400 font-medium">' +
|
resultDiv.innerHTML = '<p class="text-red-400 font-medium">' +
|
||||||
(lang === 'fa' ? '✗ ناسالم' : '✗ Unhealthy') +
|
(lang === 'fa' ? '✗ ناسالم' : '✗ Unhealthy') +
|
||||||
'</p><p class="text-sm text-gray-400">' + (response.message || '') + '</p>';
|
'</p><p class="text-sm text-gray-500 dark:text-gray-400">' + (response.message || '') + '</p>';
|
||||||
} else {
|
} else {
|
||||||
resultDiv.classList.add('bg-yellow-500/20');
|
resultDiv.classList.add('bg-yellow-500/20');
|
||||||
resultDiv.innerHTML = '<p class="text-yellow-400 font-medium">' +
|
resultDiv.innerHTML = '<p class="text-yellow-400 font-medium">' +
|
||||||
(lang === 'fa' ? '⚠ خطا' : '⚠ Error') +
|
(lang === 'fa' ? '⚠ خطا' : '⚠ Error') +
|
||||||
'</p><p class="text-sm text-gray-400">' + (response.message || 'Unknown error') + '</p>';
|
'</p><p class="text-sm text-gray-500 dark:text-gray-400">' + (response.message || 'Unknown error') + '</p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-hide after 5 seconds
|
// Auto-hide after 5 seconds
|
||||||
|
|||||||
@@ -6,20 +6,17 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-4">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<form method="GET" class="flex flex-wrap items-center gap-4">
|
<form method="GET" class="flex flex-wrap items-center gap-4">
|
||||||
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
{% if lang and lang != 'en' %}<input type="hidden" name="lang" value="{{ lang }}">{% endif %}
|
||||||
<!-- Plugin Type Filter -->
|
<!-- Plugin Type Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<label class="text-sm text-gray-400">{{ t.filter }}:</label>
|
<label class="text-sm text-gray-500 dark:text-gray-400">{{ t.filter }}:</label>
|
||||||
<select
|
<select name="plugin_type" onchange="this.form.submit()"
|
||||||
name="plugin_type"
|
class="px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm focus:outline-none focus:border-primary-500">
|
||||||
onchange="this.form.submit()"
|
|
||||||
class="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500"
|
|
||||||
>
|
|
||||||
<option value="">{{ t.all }} Types</option>
|
<option value="">{{ t.all }} Types</option>
|
||||||
{% for plugin_type in available_plugin_types %}
|
{% for plugin_type in available_plugin_types %}
|
||||||
<option value="{{ plugin_type }}" {% if selected_plugin_type == plugin_type %}selected{% endif %}>
|
<option value="{{ plugin_type }}" {% if selected_plugin_type==plugin_type %}selected{% endif %}>
|
||||||
{{ plugin_type|plugin_name }}
|
{{ plugin_type|plugin_name }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -28,36 +25,34 @@
|
|||||||
|
|
||||||
<!-- Status Filter -->
|
<!-- Status Filter -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<select
|
<select name="status" onchange="this.form.submit()"
|
||||||
name="status"
|
class="px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm focus:outline-none focus:border-primary-500">
|
||||||
onchange="this.form.submit()"
|
|
||||||
class="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500"
|
|
||||||
>
|
|
||||||
<option value="">{% if lang == 'fa' %}همه وضعیتها{% else %}All Status{% endif %}</option>
|
<option value="">{% if lang == 'fa' %}همه وضعیتها{% else %}All Status{% endif %}</option>
|
||||||
<option value="healthy" {% if selected_status == 'healthy' %}selected{% endif %}>{{ t.healthy }}</option>
|
<option value="healthy" {% if selected_status=='healthy' %}selected{% endif %}>{{ t.healthy }}
|
||||||
<option value="warning" {% if selected_status == 'warning' %}selected{% endif %}>{% if lang == 'fa' %}هشدار{% else %}Warning{% endif %}</option>
|
</option>
|
||||||
<option value="unhealthy" {% if selected_status == 'unhealthy' %}selected{% endif %}>{% if lang == 'fa' %}ناسالم{% else %}Unhealthy{% endif %}</option>
|
<option value="warning" {% if selected_status=='warning' %}selected{% endif %}>{% if lang == 'fa'
|
||||||
<option value="unknown" {% if selected_status == 'unknown' %}selected{% endif %}>{% if lang == 'fa' %}نامشخص{% else %}Unknown{% endif %}</option>
|
%}هشدار{% else %}Warning{% endif %}</option>
|
||||||
|
<option value="unhealthy" {% if selected_status=='unhealthy' %}selected{% endif %}>{% if lang ==
|
||||||
|
'fa' %}ناسالم{% else %}Unhealthy{% endif %}</option>
|
||||||
|
<option value="unknown" {% if selected_status=='unknown' %}selected{% endif %}>{% if lang == 'fa'
|
||||||
|
%}نامشخص{% else %}Unknown{% endif %}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="flex-1 min-w-[200px]">
|
<div class="flex-1 min-w-[200px]">
|
||||||
<input
|
<input type="text" name="search" value="{{ search_query }}" placeholder="{{ t.search }}..."
|
||||||
type="text"
|
class="w-full px-3 py-2 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white text-sm placeholder-gray-400 focus:outline-none focus:border-primary-500">
|
||||||
name="search"
|
|
||||||
value="{{ search_query }}"
|
|
||||||
placeholder="{{ t.search }}..."
|
|
||||||
class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white text-sm placeholder-gray-400 focus:outline-none focus:border-primary-500"
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">
|
<button type="submit"
|
||||||
|
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg text-white text-sm transition-colors">
|
||||||
{{ t.search }}
|
{{ t.search }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{% if search_query or selected_plugin_type or selected_status %}
|
{% if search_query or selected_plugin_type or selected_status %}
|
||||||
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="px-4 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
<a href="/dashboard/projects{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-white text-sm transition-colors">
|
||||||
{{ t['clear'] }}
|
{{ t['clear'] }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -65,35 +60,41 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Projects Table -->
|
<!-- Projects Table -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-700/50">
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th
|
||||||
|
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}نوع{% else %}Plugin{% endif %}
|
{% if lang == 'fa' %}نوع{% else %}Plugin{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th
|
||||||
|
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}شناسه{% else %}Site ID{% endif %}
|
{% if lang == 'fa' %}شناسه{% else %}Site ID{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th
|
||||||
|
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}
|
{% if lang == 'fa' %}نام مستعار{% else %}Alias{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th
|
||||||
|
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
URL
|
URL
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th
|
||||||
|
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
{% if lang == 'fa' %}وضعیت{% else %}Status{% endif %}
|
||||||
</th>
|
</th>
|
||||||
<th class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-300">
|
<th
|
||||||
|
class="px-6 py-4 text-{% if lang == 'fa' %}right{% else %}left{% endif %} text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-gray-700">
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{% if projects %}
|
{% if projects %}
|
||||||
{% for project in projects %}
|
{% for project in projects %}
|
||||||
<tr class="hover:bg-gray-700/30 transition-colors">
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors">
|
||||||
<!-- Plugin Type -->
|
<!-- Plugin Type -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -119,16 +120,18 @@
|
|||||||
'appwrite': 'AW',
|
'appwrite': 'AW',
|
||||||
'directus': 'DI',
|
'directus': 'DI',
|
||||||
} %}
|
} %}
|
||||||
<div class="w-8 h-8 {{ plugin_colors.get(project.plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
<div
|
||||||
<span class="text-xs font-bold text-white">{{ plugin_icons.get(project.plugin_type, project.plugin_type[:2]|upper) }}</span>
|
class="w-8 h-8 {{ plugin_colors.get(project.plugin_type, 'bg-gray-500') }} rounded-lg flex items-center justify-center {% if lang == 'fa' %}ml-3{% else %}mr-3{% endif %}">
|
||||||
|
<span class="text-xs font-bold text-white">{{ plugin_icons.get(project.plugin_type,
|
||||||
|
project.plugin_type[:2]|upper) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-sm text-white">{{ project.plugin_type|plugin_name }}</span>
|
<span class="text-sm text-gray-900 dark:text-white">{{ project.plugin_type|plugin_name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Site ID -->
|
<!-- Site ID -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<span class="text-sm text-gray-300 font-mono">{{ project.site_id }}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300 font-mono">{{ project.site_id }}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Alias -->
|
<!-- Alias -->
|
||||||
@@ -145,7 +148,9 @@
|
|||||||
<!-- URL -->
|
<!-- URL -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
{% if project.url %}
|
{% if project.url %}
|
||||||
<a href="{{ project.url }}" target="_blank" class="text-sm text-blue-400 hover:text-blue-300 truncate max-w-[200px] block" title="{{ project.url }}">
|
<a href="{{ project.url }}" target="_blank"
|
||||||
|
class="text-sm text-blue-400 hover:text-blue-300 truncate max-w-[200px] block"
|
||||||
|
title="{{ project.url }}">
|
||||||
{{ project.url[:40] }}{% if project.url|length > 40 %}...{% endif %}
|
{{ project.url[:40] }}{% if project.url|length > 40 %}...{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -157,36 +162,52 @@
|
|||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
{% if project.health_status == 'healthy' %}
|
{% if project.health_status == 'healthy' %}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
<span
|
||||||
|
class="w-2 h-2 bg-green-500 rounded-full status-pulse {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
<span class="text-sm text-green-400">{{ t.healthy }}</span>
|
<span class="text-sm text-green-400">{{ t.healthy }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% elif project.health_status == 'warning' %}
|
{% elif project.health_status == 'warning' %}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="w-2 h-2 bg-yellow-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
<span
|
||||||
<span class="text-sm text-yellow-400">{% if lang == 'fa' %}هشدار{% else %}Warning{% endif %}</span>
|
class="w-2 h-2 bg-yellow-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-yellow-400">{% if lang == 'fa' %}هشدار{% else %}Warning{%
|
||||||
|
endif %}</span>
|
||||||
</div>
|
</div>
|
||||||
{% elif project.health_status == 'unhealthy' %}
|
{% elif project.health_status == 'unhealthy' %}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center" {% if project.reason %}title="{{ project.reason }}" {% endif
|
||||||
<span class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
%}>
|
||||||
<span class="text-sm text-red-400">{% if lang == 'fa' %}ناسالم{% else %}Unhealthy{% endif %}</span>
|
<span
|
||||||
|
class="w-2 h-2 bg-red-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-red-400">
|
||||||
|
{% if lang == 'fa' %}ناسالم{% else %}Unhealthy{% endif %}
|
||||||
|
{% if project.reason %}
|
||||||
|
<svg class="w-4 h-4 inline-block ml-1 opacity-70" 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"></path>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="w-2 h-2 bg-gray-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
<span
|
||||||
<span class="text-sm text-gray-400">{% if lang == 'fa' %}نامشخص{% else %}Unknown{% endif %}</span>
|
class="w-2 h-2 bg-gray-500 rounded-full {% if lang == 'fa' %}ml-2{% else %}mr-2{% endif %}"></span>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نامشخص{% else %}Unknown{% endif
|
||||||
|
%}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
<td class="px-6 py-4">
|
<td class="px-6 py-4">
|
||||||
<a
|
<a href="/dashboard/projects/{{ project.full_id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
href="/dashboard/projects/{{ project.full_id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
class="inline-flex items-center px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||||
class="inline-flex items-center px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors"
|
|
||||||
>
|
|
||||||
{{ t.view }}
|
{{ t.view }}
|
||||||
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 {% if lang == 'fa' %}mr-1 rotate-180{% else %}ml-1{% endif %}"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 5l7 7-7 7" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
@@ -195,10 +216,12 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="px-6 py-12 text-center">
|
<td colspan="6" class="px-6 py-12 text-center">
|
||||||
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-12 h-12 mx-auto mb-4 text-gray-500 opacity-50" fill="none"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
|
stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p class="text-gray-400">
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}پروژهای یافت نشد{% else %}No projects found{% endif %}
|
{% if lang == 'fa' %}پروژهای یافت نشد{% else %}No projects found{% endif %}
|
||||||
</p>
|
</p>
|
||||||
{% if search_query or selected_plugin_type %}
|
{% if search_query or selected_plugin_type %}
|
||||||
@@ -216,18 +239,20 @@
|
|||||||
<!-- Pagination -->
|
<!-- Pagination -->
|
||||||
{% if total_pages > 1 %}
|
{% if total_pages > 1 %}
|
||||||
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
<div class="px-6 py-4 border-t border-gray-700 flex items-center justify-between">
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{ total_count }} پروژه
|
نمایش {{ ((page_number - 1) * per_page) + 1 }} تا {{ [page_number * per_page, total_count]|min }} از {{
|
||||||
|
total_count }} پروژه
|
||||||
{% else %}
|
{% else %}
|
||||||
Showing {{ ((page_number - 1) * per_page) + 1 }} to {{ [page_number * per_page, total_count]|min }} of {{ total_count }} projects
|
Showing {{ ((page_number - 1) * per_page) + 1 }} to {{ [page_number * per_page, total_count]|min }} of
|
||||||
|
{{ total_count }} projects
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
{% if page_number > 1 %}
|
{% if page_number > 1 %}
|
||||||
<a href="?page={{ page_number - 1 }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
<a href="?page={{ page_number - 1 }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||||
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -235,9 +260,10 @@
|
|||||||
{% for page_num in range(1, total_pages + 1) %}
|
{% for page_num in range(1, total_pages + 1) %}
|
||||||
{% if page_num == page_number %}
|
{% if page_num == page_number %}
|
||||||
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
|
<span class="px-3 py-1.5 bg-primary-600 rounded-lg text-sm text-white">{{ page_num }}</span>
|
||||||
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <= page_number + 2) %}
|
{% elif page_num == 1 or page_num == total_pages or (page_num >= page_number - 2 and page_num <=
|
||||||
<a href="?page={{ page_num }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
page_number + 2) %} <a
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
href="?page={{ page_num }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||||
{{ page_num }}
|
{{ page_num }}
|
||||||
</a>
|
</a>
|
||||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||||
@@ -245,9 +271,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% if page_number < total_pages %}
|
{% if page_number < total_pages %} <a
|
||||||
<a href="?page={{ page_number + 1 }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
href="?page={{ page_number + 1 }}{% if selected_plugin_type %}&plugin_type={{ selected_plugin_type }}{% endif %}{% if search_query %}&search={{ search_query }}{% endif %}{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||||
class="px-3 py-1.5 bg-gray-700 hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
class="px-3 py-1.5 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-white transition-colors">
|
||||||
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -6,22 +6,22 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Language Settings -->
|
<!-- Language Settings -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<h3 class="text-lg font-semibold text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}تنظیمات زبان{% else %}Language Settings{% endif %}
|
{% if lang == 'fa' %}تنظیمات زبان{% else %}Language Settings{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<a href="/dashboard/settings?lang=en"
|
<a href="/dashboard/settings?lang=en"
|
||||||
class="px-4 py-2 rounded-lg transition-colors {% if lang != 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-gray-300 hover:bg-gray-600{% endif %}">
|
class="px-4 py-2 rounded-lg transition-colors {% if lang != 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600{% endif %}">
|
||||||
English
|
English
|
||||||
</a>
|
</a>
|
||||||
<a href="/dashboard/settings?lang=fa"
|
<a href="/dashboard/settings?lang=fa"
|
||||||
class="px-4 py-2 rounded-lg transition-colors {% if lang == 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-gray-300 hover:bg-gray-600{% endif %}">
|
class="px-4 py-2 rounded-lg transition-colors {% if lang == 'fa' %}bg-primary-600 text-white{% else %}bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600{% endif %}">
|
||||||
فارسی
|
فارسی
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
زبان فعلی: فارسی
|
زبان فعلی: فارسی
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -32,71 +32,71 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- System Configuration -->
|
<!-- System Configuration -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<h3 class="text-lg font-semibold text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}پیکربندی سیستم{% else %}System Configuration{% endif %}
|
{% if lang == 'fa' %}پیکربندی سیستم{% else %}System Configuration{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حالت سرور{% else %}Server Mode{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حالت سرور{% else %}Server Mode{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ config.server_mode }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.server_mode }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}پورت{% else %}Port{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}پورت{% else %}Port{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ config.port }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.port }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}سطح لاگ{% else %}Log Level{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}سطح لاگ{% else %}Log Level{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ config.log_level }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.log_level }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حالت احراز هویت OAuth{% else %}OAuth Auth Mode{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حالت احراز هویت OAuth{% else %}OAuth Auth Mode{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ config.oauth_auth_mode }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.oauth_auth_mode }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حد نرخ روزانه{% else %}Daily Rate Limit{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حد نرخ روزانه{% else %}Daily Rate Limit{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ config.rate_limit_per_day }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.rate_limit_per_day }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}حد نرخ هر دقیقه{% else %}Per Minute Rate Limit{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}حد نرخ هر دقیقه{% else %}Per Minute Rate Limit{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ config.rate_limit_per_minute }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.rate_limit_per_minute }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Security Settings -->
|
<!-- Security Settings -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<h3 class="text-lg font-semibold text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}تنظیمات امنیتی{% else %}Security Settings{% endif %}
|
{% if lang == 'fa' %}تنظیمات امنیتی{% else %}Security Settings{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}احراز هویت API فعال{% else %}API Auth Enabled{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}احراز هویت API فعال{% else %}API Auth Enabled{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ 'Yes' if config.api_auth_enabled else 'No' }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ 'Yes' if config.api_auth_enabled else 'No' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}کوکی امن داشبورد{% else %}Dashboard Secure Cookie{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}کوکی امن داشبورد{% else %}Dashboard Secure Cookie{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ 'Yes' if config.dashboard_secure_cookie else 'No' }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ 'Yes' if config.dashboard_secure_cookie else 'No' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}دامنههای مجاز OAuth{% else %}OAuth Trusted Domains{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}دامنههای مجاز OAuth{% else %}OAuth Trusted Domains{% endif %}</p>
|
||||||
<p class="text-white font-mono text-sm">{{ config.oauth_trusted_domains or 'localhost' }}</p>
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ config.oauth_trusted_domains or 'localhost' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}مدت انقضا سشن داشبورد{% else %}Dashboard Session Expiry{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}مدت انقضا سشن داشبورد{% else %}Dashboard Session Expiry{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ config.dashboard_session_expiry }} {% if lang == 'fa' %}ساعت{% else %}hours{% endif %}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ config.dashboard_session_expiry }} {% if lang == 'fa' %}ساعت{% else %}hours{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Registered Plugins -->
|
<!-- Registered Plugins -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h3 class="text-lg font-semibold text-white">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
{% if lang == 'fa' %}پلاگینهای ثبت شده{% else %}Registered Plugins{% endif %}
|
{% if lang == 'fa' %}پلاگینهای ثبت شده{% else %}Registered Plugins{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<span class="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg">
|
<span class="px-2 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg">
|
||||||
@@ -106,10 +106,10 @@
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
{% if plugins %}
|
{% if plugins %}
|
||||||
{% for plugin in plugins %}
|
{% for plugin in plugins %}
|
||||||
<div class="flex items-center justify-between py-2 border-b border-gray-700 last:border-0">
|
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-white font-medium">{{ plugin.name }}</p>
|
<p class="text-gray-900 dark:text-white font-medium">{{ plugin.name }}</p>
|
||||||
<p class="text-sm text-gray-400">{{ plugin.description }}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ plugin.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="px-2 py-1 bg-green-500/20 text-green-400 text-xs rounded-lg">
|
<span class="px-2 py-1 bg-green-500/20 text-green-400 text-xs rounded-lg">
|
||||||
{{ t.active }}
|
{{ t.active }}
|
||||||
@@ -117,8 +117,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="bg-gray-700/30 rounded-lg p-4">
|
<div class="bg-gray-100 dark:bg-gray-700/30 rounded-lg p-4">
|
||||||
<p class="text-gray-400 mb-2">
|
<p class="text-gray-500 dark:text-gray-400 mb-2">
|
||||||
{% if lang == 'fa' %}هیچ پلاگینی ثبت نشده{% else %}No plugins registered{% endif %}
|
{% if lang == 'fa' %}هیچ پلاگینی ثبت نشده{% else %}No plugins registered{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
@@ -134,8 +134,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- About Section -->
|
<!-- About Section -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<h3 class="text-lg font-semibold text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}درباره{% else %}About{% endif %}
|
{% if lang == 'fa' %}درباره{% else %}About{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -146,30 +146,30 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h4 class="text-xl font-bold text-white">MCP Hub</h4>
|
<h4 class="text-xl font-bold text-gray-900 dark:text-white">MCP Hub</h4>
|
||||||
<p class="text-gray-400">{% if lang == 'fa' %}هاب پروتکل کانتکست مدل{% else %}Model Context Protocol Hub{% endif %}</p>
|
<p class="text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}هاب پروتکل کانتکست مدل{% else %}Model Context Protocol Hub{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نسخه{% else %}Version{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه{% else %}Version{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ about.version }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ about.version }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نسخه MCP{% else %}MCP Version{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه MCP{% else %}MCP Version{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ about.mcp_version }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ about.mcp_version }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نسخه Python{% else %}Python Version{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نسخه Python{% else %}Python Version{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ about.python_version }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ about.python_version }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}تعداد ابزار{% else %}Tools Count{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}تعداد ابزار{% else %}Tools Count{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ about.tools_count }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ about.tools_count }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 pt-4 border-t border-gray-700">
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<p class="text-sm text-gray-400">
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{% if lang == 'fa' %}
|
{% if lang == 'fa' %}
|
||||||
MCP Hub یک سرور MCP چند منظوره است که امکان اتصال به سرویسهای مختلف از جمله WordPress، Gitea، n8n و دیگر سرویسها را فراهم میکند.
|
MCP Hub یک سرور MCP چند منظوره است که امکان اتصال به سرویسهای مختلف از جمله WordPress، Gitea، n8n و دیگر سرویسها را فراهم میکند.
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -181,25 +181,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Session Information -->
|
<!-- Session Information -->
|
||||||
<div class="bg-gray-800 rounded-xl border border-gray-700 p-6">
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||||
<h3 class="text-lg font-semibold text-white mb-4">
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
{% if lang == 'fa' %}اطلاعات سشن{% else %}Session Information{% endif %}
|
{% if lang == 'fa' %}اطلاعات سشن{% else %}Session Information{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}نوع کاربر{% else %}User Type{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}نوع کاربر{% else %}User Type{% endif %}</p>
|
||||||
<p class="text-white font-mono">{{ session.user_type }}</p>
|
<p class="text-gray-900 dark:text-white font-mono">{{ session_display.user_type }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}ایجاد شده در{% else %}Created At{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}ایجاد شده در{% else %}Created At{% endif %}</p>
|
||||||
<p class="text-white font-mono text-sm">{{ session.created_at[:19] if session.created_at else '-' }}</p>
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ session_display.created_at[:19] if session_display.created_at else '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-400">{% if lang == 'fa' %}انقضا در{% else %}Expires At{% endif %}</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{% if lang == 'fa' %}انقضا در{% else %}Expires At{% endif %}</p>
|
||||||
<p class="text-white font-mono text-sm">{{ session.expires_at[:19] if session.expires_at else '-' }}</p>
|
<p class="text-gray-900 dark:text-white font-mono text-sm">{{ session_display.expires_at[:19] if session_display.expires_at else '-' }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 pt-4 border-t border-gray-700">
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
<a href="/dashboard/logout"
|
<a href="/dashboard/logout"
|
||||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm transition-colors inline-block">
|
class="px-4 py-2 bg-red-600 hover:bg-red-700 rounded-lg text-white text-sm transition-colors inline-block">
|
||||||
{{ t.logout }}
|
{{ t.logout }}
|
||||||
|
|||||||
151
core/templates/dashboard/sites/add.html
Normal file
151
core/templates/dashboard/sites/add.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.add_site }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ t.add_site }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-2xl mx-auto space-y-6">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% 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" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t.add_site }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error display -->
|
||||||
|
<div id="error-msg" class="hidden bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg"></div>
|
||||||
|
|
||||||
|
<form id="add-site-form" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-6">
|
||||||
|
<!-- Plugin Type -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.plugin_type }}</label>
|
||||||
|
<select id="plugin_type" name="plugin_type" required
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
onchange="updateFields()">
|
||||||
|
<option value="">{{ t.select_plugin }}</option>
|
||||||
|
{% for ptype, pname in plugin_names.items() %}
|
||||||
|
<option value="{{ ptype }}">{{ pname }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_url }}</label>
|
||||||
|
<input type="url" id="url" name="url" required placeholder="https://example.com"
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Alias -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.site_alias }}</label>
|
||||||
|
<input type="text" id="alias" name="alias" required placeholder="myblog" pattern="[a-zA-Z0-9_-]+"
|
||||||
|
minlength="2" maxlength="50"
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t.site_alias_hint }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dynamic Credential Fields -->
|
||||||
|
<div id="credential-fields" class="space-y-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit -->
|
||||||
|
<div class="flex items-center gap-4 pt-4">
|
||||||
|
<button type="submit" id="submit-btn"
|
||||||
|
class="btn-primary px-6 py-2.5 rounded-lg text-white font-medium disabled:opacity-50">
|
||||||
|
{{ t.add_site }}
|
||||||
|
</button>
|
||||||
|
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||||
|
{{ t.cancel }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
const pluginFields = {{ plugin_fields_json| safe }};
|
||||||
|
|
||||||
|
function updateFields() {
|
||||||
|
const ptype = document.getElementById('plugin_type').value;
|
||||||
|
const container = document.getElementById('credential-fields');
|
||||||
|
|
||||||
|
if (!ptype || !pluginFields[ptype]) {
|
||||||
|
container.innerHTML = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ t.credentials }}</label><p class="text-sm text-gray-500 dark:text-gray-400">{{ t.select_plugin }}</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.credentials }}</label>';
|
||||||
|
pluginFields[ptype].forEach(field => {
|
||||||
|
const req = field.required ? 'required' : '';
|
||||||
|
const hintHtml = field.hint ? `<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">${field.hint}</p>` : '';
|
||||||
|
html += `
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">${field.label}${field.required ? ' *' : ''}</label>
|
||||||
|
<input type="${field.type}" name="cred_${field.name}" ${req}
|
||||||
|
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
placeholder="${field.label}">
|
||||||
|
${hintHtml}
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
container.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('add-site-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const btn = document.getElementById('submit-btn');
|
||||||
|
const errorEl = document.getElementById('error-msg');
|
||||||
|
errorEl.classList.add('hidden');
|
||||||
|
|
||||||
|
btn.textContent = '{{ t.adding_site }}';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
const ptype = document.getElementById('plugin_type').value;
|
||||||
|
const url = document.getElementById('url').value;
|
||||||
|
const alias = document.getElementById('alias').value;
|
||||||
|
|
||||||
|
// Collect credentials
|
||||||
|
const creds = {};
|
||||||
|
if (pluginFields[ptype]) {
|
||||||
|
pluginFields[ptype].forEach(field => {
|
||||||
|
const input = document.querySelector(`[name="cred_${field.name}"]`);
|
||||||
|
if (input) creds[field.name] = input.value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
plugin_type: ptype,
|
||||||
|
url: url,
|
||||||
|
alias: alias,
|
||||||
|
credentials: creds,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok) {
|
||||||
|
window.location.href = '/dashboard/sites?msg={{ t.site_added }}{% if lang and lang != "en" %}&lang={{ lang }}{% endif %}';
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = data.error || 'Failed to add site';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
btn.textContent = '{{ t.add_site }}';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = 'Network error: ' + err.message;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
btn.textContent = '{{ t.add_site }}';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
143
core/templates/dashboard/sites/list.html
Normal file
143
core/templates/dashboard/sites/list.html
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
{% extends "dashboard/base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.my_sites }} - MCP Hub{% endblock %}
|
||||||
|
{% block page_title %}{{ t.my_sites }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header with Add Site button -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">{{ t.my_sites }}</h2>
|
||||||
|
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium"
|
||||||
|
title="{{ t.add_site }}">
|
||||||
|
+ {{ t.add_site }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flash messages -->
|
||||||
|
{% if msg %}
|
||||||
|
<div class="bg-green-50 dark:bg-green-500/20 border border-green-200 dark:border-green-500/50 text-green-700 dark:text-green-300 px-4 py-3 rounded-lg">
|
||||||
|
{{ msg }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if error %}
|
||||||
|
<div class="bg-red-50 dark:bg-red-500/20 border border-red-200 dark:border-red-500/50 text-red-700 dark:text-red-300 px-4 py-3 rounded-lg">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if sites %}
|
||||||
|
<!-- Sites table -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.site_alias }}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.plugin_type }}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.site_url }}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.status }}</th>
|
||||||
|
<th class="px-6 py-4 text-{{ 'right' if lang == 'fa' else 'left' }} text-sm font-medium text-gray-500 dark:text-gray-300">{{ t.actions }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
{% for site in sites %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors" id="site-{{ site.id }}">
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium">{{ site.alias }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300">
|
||||||
|
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-gray-500 dark:text-gray-400 text-sm max-w-xs truncate">{{ site.url }}</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
{% if site.status == 'active' %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300">{{ t.active }}</span>
|
||||||
|
{% elif site.status == 'error' %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-300">{{ t.error }}</span>
|
||||||
|
{% elif site.status == 'pending' %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-300">Pending</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-500/20 text-gray-600 dark:text-gray-400">{{ site.status }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if site.status_msg %}
|
||||||
|
<p class="text-xs text-gray-500 mt-1">{{ site.status_msg[:60] }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button onclick="testSite('{{ site.id }}')"
|
||||||
|
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300"
|
||||||
|
id="test-btn-{{ site.id }}">
|
||||||
|
{{ t.test_connection }}
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteSite('{{ site.id }}')"
|
||||||
|
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
|
||||||
|
{{ t.delete }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<!-- Empty state -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||||
|
<svg class="w-16 h-16 mx-auto text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 12h14M12 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">{{ t.no_sites }}</h3>
|
||||||
|
<p class="text-gray-500 mb-6">{{ t.add_first_site }}</p>
|
||||||
|
<a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||||
|
class="btn-primary px-6 py-2 rounded-lg text-white text-sm font-medium">
|
||||||
|
+ {{ t.add_site }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
<script>
|
||||||
|
async function testSite(siteId) {
|
||||||
|
const btn = document.getElementById('test-btn-' + siteId);
|
||||||
|
btn.textContent = '{{ t.testing }}';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sites/' + siteId + '/test', { method: 'POST' });
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.ok) {
|
||||||
|
btn.textContent = '{{ t.connection_ok }}';
|
||||||
|
btn.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
|
} else {
|
||||||
|
btn.textContent = data.message || '{{ t.connection_failed }}';
|
||||||
|
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
btn.textContent = '{{ t.error }}';
|
||||||
|
btn.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = '{{ t.test_connection }}';
|
||||||
|
btn.className = 'text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300';
|
||||||
|
btn.disabled = false;
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSite(siteId) {
|
||||||
|
if (!confirm('Are you sure you want to delete this site?')) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/sites/' + siteId, { method: 'DELETE' });
|
||||||
|
if (resp.ok) {
|
||||||
|
document.getElementById('site-' + siteId).remove();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to delete site');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -448,12 +448,15 @@ class ToolGenerator:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Import custom exceptions for better error handling
|
# Import custom exceptions for better error handling
|
||||||
from plugins.wordpress.client import AuthenticationError, ConfigurationError
|
from plugins.wordpress.client import (
|
||||||
|
AuthenticationError,
|
||||||
|
ConfigurationError,
|
||||||
|
ConnectionError,
|
||||||
|
)
|
||||||
|
|
||||||
error_type = type(e).__name__
|
error_type = type(e).__name__
|
||||||
|
|
||||||
if isinstance(e, ConfigurationError):
|
if isinstance(e, ConfigurationError):
|
||||||
# Configuration error - likely missing env vars
|
|
||||||
logger.error(f"Configuration error in {plugin_type}_{method_name}: {e}")
|
logger.error(f"Configuration error in {plugin_type}_{method_name}: {e}")
|
||||||
return (
|
return (
|
||||||
f"Configuration Error: {str(e)}\n\n"
|
f"Configuration Error: {str(e)}\n\n"
|
||||||
@@ -464,12 +467,14 @@ class ToolGenerator:
|
|||||||
)
|
)
|
||||||
|
|
||||||
elif isinstance(e, AuthenticationError):
|
elif isinstance(e, AuthenticationError):
|
||||||
# Authentication error - 401/403
|
|
||||||
logger.warning(f"Authentication error in {plugin_type}_{method_name}: {e}")
|
logger.warning(f"Authentication error in {plugin_type}_{method_name}: {e}")
|
||||||
return f"Authentication Error: {str(e)}"
|
return f"Authentication Error: {str(e)}"
|
||||||
|
|
||||||
|
elif isinstance(e, ConnectionError):
|
||||||
|
logger.warning(f"Connection error in {plugin_type}_{method_name}: {e}")
|
||||||
|
return f"Connection Error: {str(e)}"
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Unexpected error
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Error in unified handler for {plugin_type}_{method_name}: {e}",
|
f"Error in unified handler for {plugin_type}_{method_name}: {e}",
|
||||||
exc_info=True,
|
exc_info=True,
|
||||||
|
|||||||
@@ -1,363 +0,0 @@
|
|||||||
"""
|
|
||||||
Unified Tool Generator
|
|
||||||
|
|
||||||
Generates context-based tools that work across multiple sites.
|
|
||||||
Maintains backward compatibility by keeping per-site tools.
|
|
||||||
|
|
||||||
Architecture:
|
|
||||||
- Old: wordpress_site1_get_post(post_id)
|
|
||||||
- New: wordpress_get_post(site, post_id)
|
|
||||||
- Both work simultaneously!
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from core.site_registry import get_site_registry
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class UnifiedToolGenerator:
|
|
||||||
"""
|
|
||||||
Generates unified tools from per-site tool definitions.
|
|
||||||
|
|
||||||
Takes existing plugin tools and creates context-based versions
|
|
||||||
that accept a 'site' parameter for dynamic routing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, project_manager):
|
|
||||||
"""
|
|
||||||
Initialize unified tool generator.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
project_manager: ProjectManager instance with discovered projects
|
|
||||||
"""
|
|
||||||
self.project_manager = project_manager
|
|
||||||
self.site_registry = get_site_registry()
|
|
||||||
self.logger = logging.getLogger("UnifiedToolGenerator")
|
|
||||||
|
|
||||||
def generate_unified_tools(self, plugin_type: str) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Generate unified tools for a specific plugin type.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Type of plugin (e.g., 'wordpress')
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of unified tool definitions
|
|
||||||
"""
|
|
||||||
# Get all projects of this type
|
|
||||||
projects = self.project_manager.get_projects_by_type(plugin_type)
|
|
||||||
|
|
||||||
if not projects:
|
|
||||||
self.logger.warning(f"No projects found for plugin type: {plugin_type}")
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Use the first project as a template to get tool definitions
|
|
||||||
first_project_id = list(projects.keys())[0]
|
|
||||||
template_plugin = projects[first_project_id]
|
|
||||||
template_tools = template_plugin.get_tools()
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
f"Generating unified tools for {plugin_type} "
|
|
||||||
f"from {len(template_tools)} template tools"
|
|
||||||
)
|
|
||||||
|
|
||||||
unified_tools = []
|
|
||||||
seen_actions = set()
|
|
||||||
|
|
||||||
for tool in template_tools:
|
|
||||||
# Extract action name from per-site tool name
|
|
||||||
# e.g., "wordpress_site1_get_post" -> "get_post"
|
|
||||||
tool_name = tool["name"]
|
|
||||||
parts = tool_name.split("_")
|
|
||||||
|
|
||||||
# Skip if not in expected format
|
|
||||||
if len(parts) < 3:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Extract action (everything after plugin_type_site_id_)
|
|
||||||
# e.g., wordpress_site1_get_post -> get_post
|
|
||||||
action = "_".join(parts[2:])
|
|
||||||
|
|
||||||
# Skip duplicates (we only need one unified tool per action)
|
|
||||||
if action in seen_actions:
|
|
||||||
continue
|
|
||||||
seen_actions.add(action)
|
|
||||||
|
|
||||||
# Create unified tool
|
|
||||||
unified_tool = self._create_unified_tool(
|
|
||||||
plugin_type=plugin_type, action=action, template_tool=tool
|
|
||||||
)
|
|
||||||
|
|
||||||
if unified_tool:
|
|
||||||
unified_tools.append(unified_tool)
|
|
||||||
|
|
||||||
self.logger.info(f"Generated {len(unified_tools)} unified tools for {plugin_type}")
|
|
||||||
return unified_tools
|
|
||||||
|
|
||||||
def _create_unified_tool(
|
|
||||||
self, plugin_type: str, action: str, template_tool: dict[str, Any]
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""
|
|
||||||
Create a unified tool from a template.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type (e.g., 'wordpress')
|
|
||||||
action: Action name (e.g., 'get_post')
|
|
||||||
template_tool: Original per-site tool definition
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Unified tool definition
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Create unified tool name
|
|
||||||
unified_name = f"{plugin_type}_{action}"
|
|
||||||
|
|
||||||
# Get available sites for this plugin type
|
|
||||||
site_options = self.site_registry.get_site_options(plugin_type)
|
|
||||||
|
|
||||||
if not site_options:
|
|
||||||
self.logger.warning(f"No sites available for {plugin_type}, skipping {action}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Modify input schema to add 'site' parameter
|
|
||||||
original_schema = template_tool.get("inputSchema", {})
|
|
||||||
unified_schema = self._add_site_parameter(original_schema, plugin_type, site_options)
|
|
||||||
|
|
||||||
# Update description to mention site parameter
|
|
||||||
original_description = template_tool.get("description", "")
|
|
||||||
unified_description = self._update_description(original_description, plugin_type)
|
|
||||||
|
|
||||||
# Create wrapper handler
|
|
||||||
original_handler = template_tool.get("handler")
|
|
||||||
unified_handler = self._create_unified_handler(plugin_type, action, original_handler)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"name": unified_name,
|
|
||||||
"description": unified_description,
|
|
||||||
"inputSchema": unified_schema,
|
|
||||||
"handler": unified_handler,
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(
|
|
||||||
f"Error creating unified tool for {plugin_type}_{action}: {e}", exc_info=True
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _add_site_parameter(
|
|
||||||
self, original_schema: dict[str, Any], plugin_type: str, site_options: list[str]
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Add 'site' parameter to input schema.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
original_schema: Original input schema
|
|
||||||
plugin_type: Plugin type
|
|
||||||
site_options: Available site IDs/aliases
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Modified schema with site parameter
|
|
||||||
"""
|
|
||||||
# Deep copy to avoid modifying original
|
|
||||||
import copy
|
|
||||||
|
|
||||||
schema = copy.deepcopy(original_schema)
|
|
||||||
|
|
||||||
# Ensure schema has required structure
|
|
||||||
if "properties" not in schema:
|
|
||||||
schema["properties"] = {}
|
|
||||||
if "required" not in schema:
|
|
||||||
schema["required"] = []
|
|
||||||
|
|
||||||
# Add 'site' as first parameter
|
|
||||||
schema["properties"] = {
|
|
||||||
"site": {
|
|
||||||
"type": "string",
|
|
||||||
"description": (
|
|
||||||
f"Site ID or alias. Available options: {', '.join(site_options)}. "
|
|
||||||
f"Use list_projects() to see all configured sites."
|
|
||||||
),
|
|
||||||
"enum": site_options,
|
|
||||||
},
|
|
||||||
**schema["properties"],
|
|
||||||
}
|
|
||||||
|
|
||||||
# Make 'site' required
|
|
||||||
if "site" not in schema["required"]:
|
|
||||||
schema["required"].insert(0, "site")
|
|
||||||
|
|
||||||
return schema
|
|
||||||
|
|
||||||
def _update_description(self, original_description: str, plugin_type: str) -> str:
|
|
||||||
"""
|
|
||||||
Update tool description to mention unified context.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
original_description: Original description
|
|
||||||
plugin_type: Plugin type
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Updated description
|
|
||||||
"""
|
|
||||||
# Remove site-specific mentions (e.g., "from site1", "in site2")
|
|
||||||
import re
|
|
||||||
|
|
||||||
cleaned = re.sub(
|
|
||||||
r"\b(?:from|in|for)\s+site\d+\b", "", original_description, flags=re.IGNORECASE
|
|
||||||
)
|
|
||||||
cleaned = re.sub(
|
|
||||||
r"\bsite\d+\b", f"the specified {plugin_type} site", cleaned, flags=re.IGNORECASE
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add unified context note
|
|
||||||
prefix = "[UNIFIED] "
|
|
||||||
if not cleaned.startswith(prefix):
|
|
||||||
cleaned = prefix + cleaned
|
|
||||||
|
|
||||||
return cleaned.strip()
|
|
||||||
|
|
||||||
def _create_unified_handler(
|
|
||||||
self, plugin_type: str, action: str, original_handler: Callable | None
|
|
||||||
) -> Callable:
|
|
||||||
"""
|
|
||||||
Create a unified handler that routes to the correct site.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
plugin_type: Plugin type
|
|
||||||
action: Action name
|
|
||||||
original_handler: Original handler (not used, we call plugin method directly)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Async handler function
|
|
||||||
"""
|
|
||||||
|
|
||||||
async def unified_handler(site: str, **kwargs):
|
|
||||||
"""
|
|
||||||
Unified handler that routes to the correct site plugin.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
site: Site ID or alias
|
|
||||||
**kwargs: Other parameters for the tool
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Get site info from registry
|
|
||||||
site_info = self.site_registry.get_site(plugin_type, site)
|
|
||||||
|
|
||||||
if not site_info:
|
|
||||||
available = self.site_registry.get_site_options(plugin_type)
|
|
||||||
return (
|
|
||||||
f"Error: Site '{site}' not found for {plugin_type}. "
|
|
||||||
f"Available sites: {', '.join(available)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get the plugin instance
|
|
||||||
full_id = site_info.get_full_id()
|
|
||||||
|
|
||||||
# SECURITY: Check if API key has access to this project
|
|
||||||
from core.context import get_api_key_context
|
|
||||||
|
|
||||||
api_key_info = get_api_key_context()
|
|
||||||
|
|
||||||
if api_key_info and not api_key_info.get("is_global"):
|
|
||||||
# Per-project key - must match the project
|
|
||||||
allowed_project = api_key_info.get("project_id")
|
|
||||||
|
|
||||||
# Resolve allowed_project to normalize alias vs site_id
|
|
||||||
# API key might have been created with alias (wordpress_myblog)
|
|
||||||
# or site_id (wordpress_site1)
|
|
||||||
allowed_project_normalized = allowed_project
|
|
||||||
if allowed_project and "_" in allowed_project:
|
|
||||||
# Extract plugin type and site identifier from allowed_project
|
|
||||||
allowed_parts = allowed_project.split("_", 1)
|
|
||||||
if len(allowed_parts) == 2:
|
|
||||||
allowed_plugin_type, allowed_site_identifier = allowed_parts
|
|
||||||
# Try to resolve the site identifier to site_id
|
|
||||||
try:
|
|
||||||
allowed_site_info = self.site_registry.get_site(
|
|
||||||
allowed_plugin_type, allowed_site_identifier
|
|
||||||
)
|
|
||||||
if allowed_site_info:
|
|
||||||
# Normalize to plugin_type_site_id format
|
|
||||||
allowed_project_normalized = allowed_site_info.get_full_id()
|
|
||||||
except (ValueError, Exception):
|
|
||||||
# Site not found, keep original for error message
|
|
||||||
pass
|
|
||||||
|
|
||||||
if allowed_project_normalized != full_id:
|
|
||||||
logger.warning(
|
|
||||||
f"Access denied: API key for project '{allowed_project}' "
|
|
||||||
f"attempted to access '{full_id}'"
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
f"Error: Access denied. This API key is restricted to project '{allowed_project}'. "
|
|
||||||
f"Use a global API key or create a key for '{full_id}'."
|
|
||||||
)
|
|
||||||
|
|
||||||
plugin = self.project_manager.get_project(full_id)
|
|
||||||
|
|
||||||
if not plugin:
|
|
||||||
return f"Error: Plugin instance not found for {full_id}"
|
|
||||||
|
|
||||||
# Find the original handler method in the plugin
|
|
||||||
# The original per-site tool name was: {plugin_type}_{site_id}_{action}
|
|
||||||
original_tool_name = f"{plugin_type}_{site_info.site_id}_{action}"
|
|
||||||
|
|
||||||
# Get all tools from plugin and find the matching handler
|
|
||||||
tools = plugin.get_tools()
|
|
||||||
handler = None
|
|
||||||
|
|
||||||
for tool in tools:
|
|
||||||
if tool["name"] == original_tool_name:
|
|
||||||
handler = tool.get("handler")
|
|
||||||
break
|
|
||||||
|
|
||||||
if not handler:
|
|
||||||
return (
|
|
||||||
f"Error: Handler not found for {original_tool_name}. "
|
|
||||||
f"This may be a plugin implementation issue."
|
|
||||||
)
|
|
||||||
|
|
||||||
# Filter out None values from kwargs to avoid validation errors
|
|
||||||
# WordPress API doesn't accept None values in query parameters
|
|
||||||
filtered_kwargs = {key: value for key, value in kwargs.items() if value is not None}
|
|
||||||
|
|
||||||
# Call the handler with filtered kwargs
|
|
||||||
result = await handler(**filtered_kwargs)
|
|
||||||
return result
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"Error in unified handler for {plugin_type}_{action}: {e}", exc_info=True
|
|
||||||
)
|
|
||||||
return f"Error: {str(e)}"
|
|
||||||
|
|
||||||
return unified_handler
|
|
||||||
|
|
||||||
def generate_all_unified_tools(self) -> list[dict[str, Any]]:
|
|
||||||
"""
|
|
||||||
Generate unified tools for all registered plugin types.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
List of all unified tool definitions
|
|
||||||
"""
|
|
||||||
all_tools = []
|
|
||||||
|
|
||||||
# Get all plugin types from registry
|
|
||||||
from plugins import registry
|
|
||||||
|
|
||||||
plugin_types = registry.get_registered_types()
|
|
||||||
|
|
||||||
for plugin_type in plugin_types:
|
|
||||||
tools = self.generate_unified_tools(plugin_type)
|
|
||||||
all_tools.extend(tools)
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
f"Generated {len(all_tools)} total unified tools "
|
|
||||||
f"across {len(plugin_types)} plugin types"
|
|
||||||
)
|
|
||||||
|
|
||||||
return all_tools
|
|
||||||
483
core/user_auth.py
Normal file
483
core/user_auth.py
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
"""User authentication system with OAuth Social Login (GitHub + Google).
|
||||||
|
|
||||||
|
Handles OAuth 2.0 authorization flows, token exchange, user info fetching,
|
||||||
|
session creation (JWT cookies), and registration rate limiting.
|
||||||
|
|
||||||
|
This module is for user-facing authentication (Track E.2). The existing
|
||||||
|
admin authentication (core/auth.py, core/dashboard/auth.py) is unchanged.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
auth = initialize_user_auth(
|
||||||
|
github_client_id="...",
|
||||||
|
github_client_secret="...",
|
||||||
|
public_url="https://mcp.example.com",
|
||||||
|
)
|
||||||
|
url, state = auth.get_authorization_url("github")
|
||||||
|
# ... user redirected, callback received ...
|
||||||
|
user_info = await auth.exchange_code("github", code)
|
||||||
|
token = auth.create_user_session(user_id="...", email="...", name="...", role="user")
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# OAuth provider endpoints
|
||||||
|
_GITHUB_AUTH_URL = "https://github.com/login/oauth/authorize"
|
||||||
|
_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"
|
||||||
|
|
||||||
|
_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"
|
||||||
|
|
||||||
|
# State expiry: 10 minutes
|
||||||
|
_STATE_EXPIRY_SECONDS = 600
|
||||||
|
|
||||||
|
# Registration rate limit: 3 per IP per hour
|
||||||
|
_REG_RATE_LIMIT = 3
|
||||||
|
_REG_RATE_WINDOW = 3600 # 1 hour
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthProvider:
|
||||||
|
"""OAuth provider constants (for type hints / constants)."""
|
||||||
|
|
||||||
|
GITHUB = "github"
|
||||||
|
GOOGLE = "google"
|
||||||
|
|
||||||
|
|
||||||
|
class UserAuth:
|
||||||
|
"""OAuth Social Login and user session management.
|
||||||
|
|
||||||
|
Manages OAuth authorization URL generation, code-for-token exchange,
|
||||||
|
user info fetching, JWT session creation/validation, and registration
|
||||||
|
rate limiting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
github_client_id: str | None = None,
|
||||||
|
github_client_secret: str | None = None,
|
||||||
|
google_client_id: str | None = None,
|
||||||
|
google_client_secret: str | None = None,
|
||||||
|
public_url: str | None = None,
|
||||||
|
session_secret: str | None = None,
|
||||||
|
session_expiry_hours: int = 168, # 7 days
|
||||||
|
) -> None:
|
||||||
|
"""Initialize user authentication.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
github_client_id: GitHub OAuth App client ID.
|
||||||
|
github_client_secret: GitHub OAuth App client secret.
|
||||||
|
google_client_id: Google OAuth client ID.
|
||||||
|
google_client_secret: Google OAuth client secret.
|
||||||
|
public_url: Public-facing URL of the server (for callbacks).
|
||||||
|
session_secret: Secret key for JWT session signing.
|
||||||
|
session_expiry_hours: Session token lifetime in hours.
|
||||||
|
"""
|
||||||
|
self._github_client_id = github_client_id
|
||||||
|
self._github_client_secret = github_client_secret
|
||||||
|
self._google_client_id = google_client_id
|
||||||
|
self._google_client_secret = google_client_secret
|
||||||
|
self._public_url = (public_url or "").rstrip("/")
|
||||||
|
|
||||||
|
self._session_secret = session_secret or os.getenv(
|
||||||
|
"DASHBOARD_SESSION_SECRET",
|
||||||
|
os.getenv("OAUTH_JWT_SECRET_KEY", secrets.token_hex(32)),
|
||||||
|
)
|
||||||
|
self._session_expiry_hours = int(
|
||||||
|
os.getenv("SESSION_EXPIRY_HOURS", str(session_expiry_hours))
|
||||||
|
)
|
||||||
|
|
||||||
|
# CSRF state tokens: state -> timestamp
|
||||||
|
self._pending_states: dict[str, float] = {}
|
||||||
|
|
||||||
|
# Registration rate limiting: IP -> [timestamps]
|
||||||
|
self._registration_records: dict[str, list[float]] = {}
|
||||||
|
|
||||||
|
providers = self.available_providers()
|
||||||
|
if providers and not self._public_url:
|
||||||
|
logger.warning(
|
||||||
|
"OAuth providers configured (%s) but PUBLIC_URL is not set. "
|
||||||
|
"OAuth login will fail until PUBLIC_URL is configured.",
|
||||||
|
providers,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"UserAuth initialized: providers=%s, session_expiry=%dh",
|
||||||
|
providers,
|
||||||
|
self._session_expiry_hours,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Provider availability ─────────────────────────────────
|
||||||
|
|
||||||
|
def available_providers(self) -> list[str]:
|
||||||
|
"""Return list of configured OAuth providers.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of provider name strings (e.g. ["github", "google"]).
|
||||||
|
"""
|
||||||
|
providers: list[str] = []
|
||||||
|
if self._github_client_id and self._github_client_secret:
|
||||||
|
providers.append("github")
|
||||||
|
if self._google_client_id and self._google_client_secret:
|
||||||
|
providers.append("google")
|
||||||
|
return providers
|
||||||
|
|
||||||
|
# ── OAuth URL generation ──────────────────────────────────
|
||||||
|
|
||||||
|
def get_authorization_url(self, provider: str) -> tuple[str, str]:
|
||||||
|
"""Generate OAuth authorization URL with CSRF state.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: "github" or "google".
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (authorization_url, state_token).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If provider is unsupported or not configured.
|
||||||
|
"""
|
||||||
|
if not self._public_url:
|
||||||
|
raise ValueError(
|
||||||
|
"PUBLIC_URL environment variable must be set for OAuth login to work. "
|
||||||
|
"Example: PUBLIC_URL=https://mcp.example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
state = secrets.token_hex(32)
|
||||||
|
self._pending_states[state] = time.time()
|
||||||
|
self._cleanup_expired_states()
|
||||||
|
|
||||||
|
callback_url = f"{self._public_url}/auth/callback/{provider}"
|
||||||
|
|
||||||
|
if provider == "github":
|
||||||
|
if not self._github_client_id:
|
||||||
|
raise ValueError("GitHub OAuth not configured")
|
||||||
|
params = {
|
||||||
|
"client_id": self._github_client_id,
|
||||||
|
"redirect_uri": callback_url,
|
||||||
|
"scope": "read:user user:email",
|
||||||
|
"state": state,
|
||||||
|
}
|
||||||
|
return f"{_GITHUB_AUTH_URL}?{urlencode(params)}", state
|
||||||
|
|
||||||
|
if provider == "google":
|
||||||
|
if not self._google_client_id:
|
||||||
|
raise ValueError("Google OAuth not configured")
|
||||||
|
params = {
|
||||||
|
"client_id": self._google_client_id,
|
||||||
|
"redirect_uri": callback_url,
|
||||||
|
"scope": "openid email profile",
|
||||||
|
"response_type": "code",
|
||||||
|
"state": state,
|
||||||
|
"access_type": "online",
|
||||||
|
}
|
||||||
|
return f"{_GOOGLE_AUTH_URL}?{urlencode(params)}", state
|
||||||
|
|
||||||
|
raise ValueError(f"Unsupported provider: {provider}")
|
||||||
|
|
||||||
|
# ── State validation ──────────────────────────────────────
|
||||||
|
|
||||||
|
def validate_state(self, state: str) -> bool:
|
||||||
|
"""Validate and consume an OAuth state token (one-time use).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
state: The state parameter from the callback.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if valid and not expired, False otherwise.
|
||||||
|
"""
|
||||||
|
timestamp = self._pending_states.pop(state, None)
|
||||||
|
if timestamp is None:
|
||||||
|
return False
|
||||||
|
return not (time.time() - timestamp > _STATE_EXPIRY_SECONDS)
|
||||||
|
|
||||||
|
def _cleanup_expired_states(self) -> None:
|
||||||
|
"""Remove expired state tokens."""
|
||||||
|
now = time.time()
|
||||||
|
expired = [s for s, t in self._pending_states.items() if now - t > _STATE_EXPIRY_SECONDS]
|
||||||
|
for s in expired:
|
||||||
|
del self._pending_states[s]
|
||||||
|
|
||||||
|
# ── Token exchange ────────────────────────────────────────
|
||||||
|
|
||||||
|
async def exchange_code(self, provider: str, code: str) -> dict:
|
||||||
|
"""Exchange authorization code for user info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
provider: "github" or "google".
|
||||||
|
code: Authorization code from callback.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: provider, provider_id, email, name, avatar_url.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If token exchange or user info fetch fails.
|
||||||
|
"""
|
||||||
|
if provider == "github":
|
||||||
|
return await self._exchange_github(code)
|
||||||
|
if provider == "google":
|
||||||
|
return await self._exchange_google(code)
|
||||||
|
raise ValueError(f"Unsupported provider: {provider}")
|
||||||
|
|
||||||
|
async def _exchange_github(self, code: str) -> dict:
|
||||||
|
"""Exchange GitHub authorization code for user info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: GitHub authorization code.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with provider, provider_id, email, name, avatar_url.
|
||||||
|
"""
|
||||||
|
callback_url = f"{self._public_url}/auth/callback/github"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Exchange code for access token
|
||||||
|
token_resp = await client.post(
|
||||||
|
_GITHUB_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"client_id": self._github_client_id,
|
||||||
|
"client_secret": self._github_client_secret,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": callback_url,
|
||||||
|
},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
if token_resp.status_code != 200:
|
||||||
|
raise ValueError(f"Failed to exchange GitHub code: {token_resp.text}")
|
||||||
|
|
||||||
|
token_data = token_resp.json()
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
raise ValueError(f"Failed to exchange GitHub code: {token_data}")
|
||||||
|
|
||||||
|
# Fetch user info
|
||||||
|
user_resp = await client.get(
|
||||||
|
_GITHUB_USER_URL,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
user_data = user_resp.json()
|
||||||
|
|
||||||
|
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}"},
|
||||||
|
)
|
||||||
|
if emails_resp.status_code == 200:
|
||||||
|
emails = emails_resp.json()
|
||||||
|
primary = next(
|
||||||
|
(e for e in emails if e.get("primary") and e.get("verified")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if primary:
|
||||||
|
email = primary["email"]
|
||||||
|
elif emails:
|
||||||
|
email = emails[0]["email"]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"provider": "github",
|
||||||
|
"provider_id": str(user_data["id"]),
|
||||||
|
"email": email,
|
||||||
|
"name": user_data.get("name") or user_data.get("login"),
|
||||||
|
"avatar_url": user_data.get("avatar_url"),
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _exchange_google(self, code: str) -> dict:
|
||||||
|
"""Exchange Google authorization code for user info.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
code: Google authorization code.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with provider, provider_id, email, name, avatar_url.
|
||||||
|
"""
|
||||||
|
callback_url = f"{self._public_url}/auth/callback/google"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
# Exchange code for access token
|
||||||
|
token_resp = await client.post(
|
||||||
|
_GOOGLE_TOKEN_URL,
|
||||||
|
data={
|
||||||
|
"client_id": self._google_client_id,
|
||||||
|
"client_secret": self._google_client_secret,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": callback_url,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if token_resp.status_code != 200:
|
||||||
|
raise ValueError(f"Failed to exchange Google code: {token_resp.text}")
|
||||||
|
|
||||||
|
token_data = token_resp.json()
|
||||||
|
access_token = token_data.get("access_token")
|
||||||
|
if not access_token:
|
||||||
|
raise ValueError(f"Failed to exchange Google code: {token_data}")
|
||||||
|
|
||||||
|
# Fetch user info
|
||||||
|
user_resp = await client.get(
|
||||||
|
_GOOGLE_USERINFO_URL,
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
)
|
||||||
|
user_data = user_resp.json()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"provider": "google",
|
||||||
|
"provider_id": str(user_data["sub"]),
|
||||||
|
"email": user_data.get("email"),
|
||||||
|
"name": user_data.get("name"),
|
||||||
|
"avatar_url": user_data.get("picture"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Registration rate limiting ────────────────────────────
|
||||||
|
|
||||||
|
def check_registration_rate(self, client_ip: str) -> bool:
|
||||||
|
"""Check if IP is within registration rate limit.
|
||||||
|
|
||||||
|
Allows up to 3 registrations per IP per hour. Expired records
|
||||||
|
are cleaned up automatically.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_ip: Client IP address.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if registration is allowed, False if rate limited.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
records = self._registration_records.get(client_ip, [])
|
||||||
|
# Keep only records within the window
|
||||||
|
records = [t for t in records if now - t < _REG_RATE_WINDOW]
|
||||||
|
self._registration_records[client_ip] = records
|
||||||
|
return len(records) < _REG_RATE_LIMIT
|
||||||
|
|
||||||
|
def record_registration(self, client_ip: str) -> None:
|
||||||
|
"""Record a registration event for rate limiting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client_ip: Client IP address.
|
||||||
|
"""
|
||||||
|
if client_ip not in self._registration_records:
|
||||||
|
self._registration_records[client_ip] = []
|
||||||
|
self._registration_records[client_ip].append(time.time())
|
||||||
|
|
||||||
|
# ── Session management ────────────────────────────────────
|
||||||
|
|
||||||
|
def create_user_session(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
email: str,
|
||||||
|
name: str | None,
|
||||||
|
role: str,
|
||||||
|
) -> str:
|
||||||
|
"""Create a JWT session token for an OAuth user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User UUID from database.
|
||||||
|
email: User email.
|
||||||
|
name: User display name.
|
||||||
|
role: User role ('user' or 'admin').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JWT token string.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
payload = {
|
||||||
|
"uid": user_id,
|
||||||
|
"email": email,
|
||||||
|
"name": name or "",
|
||||||
|
"role": role,
|
||||||
|
"type": "oauth_user",
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + self._session_expiry_hours * 3600,
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, self._session_secret, algorithm="HS256")
|
||||||
|
|
||||||
|
def validate_user_session(self, token: str) -> dict | None:
|
||||||
|
"""Validate a user session JWT token.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: JWT token string.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with user_id, email, name, role, type -- or None if invalid.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, self._session_secret, algorithms=["HS256"])
|
||||||
|
return {
|
||||||
|
"user_id": payload["uid"],
|
||||||
|
"email": payload["email"],
|
||||||
|
"name": payload.get("name", ""),
|
||||||
|
"role": payload.get("role", "user"),
|
||||||
|
"type": payload.get("type", "oauth_user"),
|
||||||
|
}
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
logger.debug("User session expired")
|
||||||
|
return None
|
||||||
|
except jwt.InvalidTokenError as e:
|
||||||
|
logger.debug("Invalid user session token: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Singleton ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_user_auth: UserAuth | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_user_auth(
|
||||||
|
github_client_id: str | None = None,
|
||||||
|
github_client_secret: str | None = None,
|
||||||
|
google_client_id: str | None = None,
|
||||||
|
google_client_secret: str | None = None,
|
||||||
|
public_url: str | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> UserAuth:
|
||||||
|
"""Initialize the global UserAuth singleton.
|
||||||
|
|
||||||
|
Reads from env vars if arguments are not provided:
|
||||||
|
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET,
|
||||||
|
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET,
|
||||||
|
PUBLIC_URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
github_client_id: GitHub OAuth App client ID.
|
||||||
|
github_client_secret: GitHub OAuth App client secret.
|
||||||
|
google_client_id: Google OAuth client ID.
|
||||||
|
google_client_secret: Google OAuth client secret.
|
||||||
|
public_url: Public-facing URL of the server.
|
||||||
|
**kwargs: Additional keyword args passed to UserAuth.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The initialized UserAuth instance.
|
||||||
|
"""
|
||||||
|
global _user_auth
|
||||||
|
_user_auth = UserAuth(
|
||||||
|
github_client_id=github_client_id or os.getenv("GITHUB_CLIENT_ID"),
|
||||||
|
github_client_secret=github_client_secret or os.getenv("GITHUB_CLIENT_SECRET"),
|
||||||
|
google_client_id=google_client_id or os.getenv("GOOGLE_CLIENT_ID"),
|
||||||
|
google_client_secret=google_client_secret or os.getenv("GOOGLE_CLIENT_SECRET"),
|
||||||
|
public_url=public_url or os.getenv("PUBLIC_URL", ""),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
return _user_auth
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_auth() -> UserAuth:
|
||||||
|
"""Get the global UserAuth singleton.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The UserAuth singleton instance.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If initialize_user_auth() has not been called.
|
||||||
|
"""
|
||||||
|
if _user_auth is None:
|
||||||
|
raise RuntimeError("UserAuth not initialized. Call initialize_user_auth() first.")
|
||||||
|
return _user_auth
|
||||||
378
core/user_endpoints.py
Normal file
378
core/user_endpoints.py
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
"""Per-user MCP endpoint handler (Track E.3).
|
||||||
|
|
||||||
|
Handles MCP JSON-RPC requests for user-owned sites at
|
||||||
|
``/u/{user_id}/{alias}/mcp``. Implements the Streamable HTTP transport
|
||||||
|
protocol directly (no per-user FastMCP instances).
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Validate user API key (Bearer token)
|
||||||
|
2. Look up user's site from SQLite
|
||||||
|
3. Decrypt credentials
|
||||||
|
4. For tools/list: return plugin tools (without ``site`` param)
|
||||||
|
5. For tools/call: create plugin instance, call method, return result
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# In server.py route registration:
|
||||||
|
from core.user_endpoints import user_mcp_handler
|
||||||
|
Route("/u/{user_id}/{alias}/mcp", endpoint=user_mcp_handler, methods=["POST"])
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import JSONResponse, Response
|
||||||
|
|
||||||
|
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]] = {}
|
||||||
|
|
||||||
|
# Cache for tool schemas per plugin type (computed once)
|
||||||
|
_tool_schema_cache: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _check_user_rate_limit(user_id: str) -> tuple[bool, str]:
|
||||||
|
"""Check per-user rate limits.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: User UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (allowed, error_message). error_message is empty if allowed.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
timestamps = _rate_limits.setdefault(user_id, [])
|
||||||
|
|
||||||
|
# Prune old entries (older than 1 hour)
|
||||||
|
cutoff_hr = now - 3600
|
||||||
|
_rate_limits[user_id] = [t for t in timestamps if t > cutoff_hr]
|
||||||
|
timestamps = _rate_limits[user_id]
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
timestamps.append(now)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tools_for_plugin(plugin_type: str) -> list[dict[str, Any]]:
|
||||||
|
"""Get MCP tool definitions for a plugin type (cached).
|
||||||
|
|
||||||
|
Returns tool schemas with the ``site`` parameter removed
|
||||||
|
(auto-injected for user endpoints).
|
||||||
|
"""
|
||||||
|
if plugin_type in _tool_schema_cache:
|
||||||
|
return _tool_schema_cache[plugin_type]
|
||||||
|
|
||||||
|
from core.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
tools = registry.get_by_plugin_type(plugin_type)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for tool_def in tools:
|
||||||
|
schema = deepcopy(tool_def.input_schema)
|
||||||
|
# Remove 'site' parameter (auto-injected)
|
||||||
|
if "properties" in schema:
|
||||||
|
schema["properties"].pop("site", None)
|
||||||
|
if "required" in schema and "site" in schema["required"]:
|
||||||
|
schema["required"] = [r for r in schema["required"] if r != "site"]
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"name": tool_def.name,
|
||||||
|
"description": tool_def.description,
|
||||||
|
"inputSchema": schema,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
_tool_schema_cache[plugin_type] = result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def _execute_tool(
|
||||||
|
tool_name: str,
|
||||||
|
arguments: dict[str, Any],
|
||||||
|
plugin_type: str,
|
||||||
|
config_dict: dict[str, Any],
|
||||||
|
) -> Any:
|
||||||
|
"""Execute a tool by creating a plugin instance and calling the method.
|
||||||
|
|
||||||
|
Uses the same pattern as unified_handler in tool_generator.py.
|
||||||
|
"""
|
||||||
|
from plugins import registry as plugin_registry
|
||||||
|
|
||||||
|
if not plugin_registry.is_registered(plugin_type):
|
||||||
|
return {"type": "text", "text": f"Error: Unknown plugin type '{plugin_type}'"}
|
||||||
|
|
||||||
|
method_name = tool_name
|
||||||
|
# Strip plugin_type prefix: "wordpress_list_posts" → "list_posts"
|
||||||
|
prefix = f"{plugin_type}_"
|
||||||
|
if method_name.startswith(prefix):
|
||||||
|
method_name = method_name[len(prefix) :]
|
||||||
|
|
||||||
|
try:
|
||||||
|
plugin_instance = plugin_registry.create_instance(
|
||||||
|
plugin_type,
|
||||||
|
project_id=f"user_{config_dict.get('alias', 'unknown')}",
|
||||||
|
config=config_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not hasattr(plugin_instance, method_name):
|
||||||
|
return {"type": "text", "text": f"Error: Method '{method_name}' not found"}
|
||||||
|
|
||||||
|
method = getattr(plugin_instance, method_name)
|
||||||
|
|
||||||
|
# Process arguments (parse JSON strings, filter None/empty)
|
||||||
|
filtered_args = {}
|
||||||
|
for key, value in arguments.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, str):
|
||||||
|
stripped = value.strip()
|
||||||
|
if stripped == "":
|
||||||
|
continue
|
||||||
|
if (stripped.startswith("{") and stripped.endswith("}")) or (
|
||||||
|
stripped.startswith("[") and stripped.endswith("]")
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
value = json.loads(value)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
filtered_args[key] = value
|
||||||
|
|
||||||
|
result = await method(**filtered_args)
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Tool execution error %s: %s", tool_name, e, exc_info=True)
|
||||||
|
return {"type": "text", "text": f"Error: {type(e).__name__}: {e}"}
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonrpc_error(req_id: Any, code: int, message: str) -> dict[str, Any]:
|
||||||
|
"""Build a JSON-RPC error response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"error": {"code": code, "message": message},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonrpc_result(req_id: Any, result: Any) -> dict[str, Any]:
|
||||||
|
"""Build a JSON-RPC success response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": result,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def user_mcp_handler(request: Request) -> Response:
|
||||||
|
"""Handle MCP JSON-RPC requests for user endpoints.
|
||||||
|
|
||||||
|
Route: POST /u/{user_id}/{alias}/mcp
|
||||||
|
|
||||||
|
Validates user API key, looks up the site, and handles MCP protocol
|
||||||
|
methods (initialize, tools/list, tools/call).
|
||||||
|
"""
|
||||||
|
user_id = request.path_params.get("user_id", "")
|
||||||
|
alias = request.path_params.get("alias", "")
|
||||||
|
|
||||||
|
if not user_id or not alias:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Invalid endpoint path"),
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Authentication ---
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
if not auth_header.startswith("Bearer "):
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Missing Authorization header"),
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
api_key = auth_header[7:] # Strip "Bearer "
|
||||||
|
|
||||||
|
try:
|
||||||
|
from core.user_keys import get_user_key_manager
|
||||||
|
|
||||||
|
key_mgr = get_user_key_manager()
|
||||||
|
key_info = await key_mgr.validate_key(api_key)
|
||||||
|
except RuntimeError:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Authentication service unavailable"),
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
if key_info is None:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Invalid API key"),
|
||||||
|
status_code=401,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure the API key belongs to the user in the URL
|
||||||
|
if key_info["user_id"] != user_id:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "API key does not match user"),
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Look up site ---
|
||||||
|
try:
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
site = await db.get_site_by_alias(user_id, alias)
|
||||||
|
except RuntimeError:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Database unavailable"),
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
if site is None:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, f"Site '{alias}' not found"),
|
||||||
|
status_code=404,
|
||||||
|
)
|
||||||
|
|
||||||
|
if site["status"] == "disabled":
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32600, "Site is disabled"),
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Parse JSON-RPC body ---
|
||||||
|
try:
|
||||||
|
body = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(None, -32700, "Parse error"),
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
req_id = body.get("id")
|
||||||
|
method = body.get("method", "")
|
||||||
|
params = body.get("params", {})
|
||||||
|
|
||||||
|
# --- Handle MCP methods ---
|
||||||
|
|
||||||
|
if method == "initialize":
|
||||||
|
version = "3.1.0"
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_result(
|
||||||
|
req_id,
|
||||||
|
{
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {"tools": {}},
|
||||||
|
"serverInfo": {
|
||||||
|
"name": f"mcphub-{alias}",
|
||||||
|
"version": version,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
elif method == "notifications/initialized":
|
||||||
|
# Notification — no response needed
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
elif method == "tools/list":
|
||||||
|
tools = _get_tools_for_plugin(site["plugin_type"])
|
||||||
|
return JSONResponse(_jsonrpc_result(req_id, {"tools": tools}))
|
||||||
|
|
||||||
|
elif method == "tools/call":
|
||||||
|
tool_name = params.get("name", "")
|
||||||
|
arguments = params.get("arguments", {})
|
||||||
|
|
||||||
|
# Verify tool belongs to this plugin type
|
||||||
|
plugin_prefix = f"{site['plugin_type']}_"
|
||||||
|
if not tool_name.startswith(plugin_prefix):
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(req_id, -32601, f"Tool '{tool_name}' not available for this site")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check required scope
|
||||||
|
from core.tool_registry import get_tool_registry
|
||||||
|
|
||||||
|
registry = get_tool_registry()
|
||||||
|
tool_def = registry.get_by_name(tool_name)
|
||||||
|
if not tool_def:
|
||||||
|
return JSONResponse(_jsonrpc_error(req_id, -32601, f"Tool '{tool_name}' not found"))
|
||||||
|
|
||||||
|
required_scope = tool_def.required_scope
|
||||||
|
key_scopes = key_info.get("scopes", "").split()
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
if key_level < required_level:
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(
|
||||||
|
req_id,
|
||||||
|
-32600,
|
||||||
|
f"Insufficient scope. Tool '{tool_name}' requires '{required_scope}' scope.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Decrypt credentials
|
||||||
|
try:
|
||||||
|
from core.encryption import get_credential_encryption
|
||||||
|
|
||||||
|
encryptor = get_credential_encryption()
|
||||||
|
credentials = encryptor.decrypt_credentials(site["credentials"], site["id"])
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Credential decryption failed for site %s: %s", site["id"], e)
|
||||||
|
return JSONResponse(
|
||||||
|
_jsonrpc_error(req_id, -32603, "Failed to decrypt site credentials")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build config dict for plugin instantiation
|
||||||
|
config_dict = {
|
||||||
|
"site_url": site["url"],
|
||||||
|
"url": site["url"],
|
||||||
|
"alias": alias,
|
||||||
|
**credentials,
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await _execute_tool(tool_name, arguments, site["plugin_type"], config_dict)
|
||||||
|
|
||||||
|
# Format result as MCP content
|
||||||
|
if isinstance(result, str):
|
||||||
|
content = [{"type": "text", "text": result}]
|
||||||
|
elif isinstance(result, dict) and "type" in result:
|
||||||
|
content = [result]
|
||||||
|
elif isinstance(result, list):
|
||||||
|
content = result
|
||||||
|
else:
|
||||||
|
content = [{"type": "text", "text": json.dumps(result, default=str)}]
|
||||||
|
|
||||||
|
return JSONResponse(_jsonrpc_result(req_id, {"content": content}))
|
||||||
|
|
||||||
|
else:
|
||||||
|
return JSONResponse(_jsonrpc_error(req_id, -32601, f"Method '{method}' not supported"))
|
||||||
249
core/user_keys.py
Normal file
249
core/user_keys.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"""User API Key management for the Live Platform (Track E.3).
|
||||||
|
|
||||||
|
Provides bcrypt-hashed API keys for authenticating MCP client connections
|
||||||
|
to per-user endpoints (``/u/{user_id}/{alias}/mcp``).
|
||||||
|
|
||||||
|
Key format: ``mhu_<32 random urlsafe chars>``
|
||||||
|
Lookup: ``key_prefix`` column (first 8 chars after ``mhu_``) for indexed DB lookup,
|
||||||
|
then bcrypt verification on the matching row.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
from core.user_keys import initialize_user_key_manager, get_user_key_manager
|
||||||
|
|
||||||
|
initialize_user_key_manager()
|
||||||
|
mgr = get_user_key_manager()
|
||||||
|
result = await mgr.create_key(user_id, "Claude Desktop")
|
||||||
|
# result["key"] is shown once to the user
|
||||||
|
info = await mgr.validate_key("mhu_...")
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Key format constants
|
||||||
|
KEY_PREFIX_TAG = "mhu_"
|
||||||
|
KEY_RANDOM_BYTES = 32 # urlsafe_b64 of 32 bytes = 43 chars
|
||||||
|
KEY_PREFIX_LEN = 8 # chars after "mhu_" stored for fast DB lookup
|
||||||
|
|
||||||
|
# Validation cache TTL
|
||||||
|
_CACHE_TTL_SECONDS = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
|
class UserKeyManager:
|
||||||
|
"""Manages per-user API keys with bcrypt hashing.
|
||||||
|
|
||||||
|
Keys are stored in the ``user_api_keys`` SQLite table via
|
||||||
|
:class:`core.database.Database`. Each key is bcrypt-hashed;
|
||||||
|
a plaintext ``key_prefix`` column enables indexed lookup without
|
||||||
|
scanning all rows.
|
||||||
|
|
||||||
|
An in-memory cache avoids repeated bcrypt verification for the
|
||||||
|
same key within a 5-minute window.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
# Cache: raw_key -> (key_id, user_id, scopes, cached_at)
|
||||||
|
self._cache: dict[str, tuple[str, str, str, float]] = {}
|
||||||
|
|
||||||
|
async def create_key(
|
||||||
|
self,
|
||||||
|
user_id: str,
|
||||||
|
name: str,
|
||||||
|
scopes: str = "read write",
|
||||||
|
expires_in_days: int | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a new API key for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
name: Human label (e.g. "Claude Desktop").
|
||||||
|
scopes: Ignored — always "read write" (full access).
|
||||||
|
expires_in_days: Optional expiry in days from now. None = never.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``key`` (plaintext, shown once), ``key_id``, ``name``,
|
||||||
|
``scopes``, ``created_at``, ``expires_at``.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
# All user API keys get full access — scopes param is ignored
|
||||||
|
scopes = "read write"
|
||||||
|
|
||||||
|
raw_key = KEY_PREFIX_TAG + secrets.token_urlsafe(KEY_RANDOM_BYTES)
|
||||||
|
key_prefix = raw_key[len(KEY_PREFIX_TAG) : len(KEY_PREFIX_TAG) + KEY_PREFIX_LEN]
|
||||||
|
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
expires_at = None
|
||||||
|
if expires_in_days is not None:
|
||||||
|
expires_at = (datetime.now(UTC) + timedelta(days=expires_in_days)).isoformat()
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
row = await db.create_api_key(
|
||||||
|
user_id=user_id,
|
||||||
|
key_hash=key_hash,
|
||||||
|
key_prefix=key_prefix,
|
||||||
|
name=name,
|
||||||
|
scopes=scopes,
|
||||||
|
expires_at=expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Created user API key %s for user %s", row["id"], user_id)
|
||||||
|
return {
|
||||||
|
"key": raw_key, # shown once
|
||||||
|
"key_id": row["id"],
|
||||||
|
"name": row["name"],
|
||||||
|
"scopes": row["scopes"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"expires_at": row["expires_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def validate_key(self, api_key: str) -> dict[str, Any] | None:
|
||||||
|
"""Validate an API key and return its metadata.
|
||||||
|
|
||||||
|
Uses an in-memory cache to avoid repeated bcrypt verification.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_key: The raw API key string (e.g. ``mhu_...``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``key_id``, ``user_id``, ``scopes`` if valid, else None.
|
||||||
|
"""
|
||||||
|
if not api_key or not api_key.startswith(KEY_PREFIX_TAG):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check cache first
|
||||||
|
cached = self._cache.get(api_key)
|
||||||
|
if cached is not None:
|
||||||
|
key_id, user_id, scopes, cached_at = cached
|
||||||
|
if time.time() - cached_at < _CACHE_TTL_SECONDS:
|
||||||
|
# Update usage in background (fire-and-forget via DB)
|
||||||
|
try:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
asyncio.create_task(db.update_api_key_usage(key_id))
|
||||||
|
except Exception:
|
||||||
|
pass # Non-critical
|
||||||
|
return {"key_id": key_id, "user_id": user_id, "scopes": scopes}
|
||||||
|
else:
|
||||||
|
del self._cache[api_key]
|
||||||
|
|
||||||
|
# Extract prefix for DB lookup
|
||||||
|
key_prefix = api_key[len(KEY_PREFIX_TAG) : len(KEY_PREFIX_TAG) + KEY_PREFIX_LEN]
|
||||||
|
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
row = await db.get_api_key_by_prefix(key_prefix)
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify bcrypt hash
|
||||||
|
if not bcrypt.checkpw(api_key.encode(), row["key_hash"].encode()):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check expiry
|
||||||
|
if row["expires_at"] is not None:
|
||||||
|
expires = datetime.fromisoformat(row["expires_at"])
|
||||||
|
if expires < datetime.now(UTC):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Update usage
|
||||||
|
await db.update_api_key_usage(row["id"])
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
self._cache[api_key] = (
|
||||||
|
row["id"],
|
||||||
|
row["user_id"],
|
||||||
|
row["scopes"],
|
||||||
|
time.time(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"key_id": row["id"],
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
"scopes": row["scopes"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def list_keys(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""List all API keys for a user (without hashes).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of key metadata dicts.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
return await db.get_api_keys_by_user(user_id)
|
||||||
|
|
||||||
|
async def delete_key(self, key_id: str, user_id: str) -> bool:
|
||||||
|
"""Delete an API key.
|
||||||
|
|
||||||
|
Also invalidates the validation cache for any cached key matching
|
||||||
|
this key_id.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
key_id: API key UUID.
|
||||||
|
user_id: Owner's UUID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found.
|
||||||
|
"""
|
||||||
|
from core.database import get_database
|
||||||
|
|
||||||
|
db = get_database()
|
||||||
|
deleted = await db.delete_api_key(key_id, user_id)
|
||||||
|
|
||||||
|
if deleted:
|
||||||
|
# Purge from cache
|
||||||
|
to_remove = [k for k, v in self._cache.items() if v[0] == key_id]
|
||||||
|
for k in to_remove:
|
||||||
|
del self._cache[k]
|
||||||
|
logger.info("Deleted user API key %s for user %s", key_id, user_id)
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Clear the entire validation cache."""
|
||||||
|
self._cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton
|
||||||
|
_manager: UserKeyManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_user_key_manager() -> UserKeyManager:
|
||||||
|
"""Create and store the singleton UserKeyManager."""
|
||||||
|
global _manager
|
||||||
|
_manager = UserKeyManager()
|
||||||
|
logger.info("UserKeyManager initialized")
|
||||||
|
return _manager
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_key_manager() -> UserKeyManager:
|
||||||
|
"""Get the singleton UserKeyManager.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The UserKeyManager singleton.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: If not initialized.
|
||||||
|
"""
|
||||||
|
if _manager is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"UserKeyManager not initialized. Call initialize_user_key_manager() first."
|
||||||
|
)
|
||||||
|
return _manager
|
||||||
79
docker-compose.coolify.yaml
Normal file
79
docker-compose.coolify.yaml
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# ===================================
|
||||||
|
# MCP Hub — Docker Compose (Coolify)
|
||||||
|
# ===================================
|
||||||
|
#
|
||||||
|
# Coolify auto-reads the 'environment' block below and shows
|
||||||
|
# each variable in the UI for editing. Just deploy and fill values.
|
||||||
|
#
|
||||||
|
# Setup:
|
||||||
|
# 1. New Resource → Docker Compose → point to this file
|
||||||
|
# 2. Fill environment variables in Coolify UI
|
||||||
|
# 3. Set domain (e.g., mcp.yourdomain.com)
|
||||||
|
# 4. Deploy
|
||||||
|
# ===================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
mcp-server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: mcphub
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# No 'ports' — Coolify reverse proxy routes traffic via EXPOSE 8000
|
||||||
|
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# === REQUIRED ===
|
||||||
|
- MASTER_API_KEY=${MASTER_API_KEY}
|
||||||
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
|
- DASHBOARD_SESSION_SECRET=${DASHBOARD_SESSION_SECRET}
|
||||||
|
|
||||||
|
# === OAUTH SOCIAL LOGIN (for user registration) ===
|
||||||
|
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
|
||||||
|
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
|
||||||
|
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||||
|
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
|
||||||
|
- PUBLIC_URL=${PUBLIC_URL:-}
|
||||||
|
- SESSION_EXPIRY_HOURS=${SESSION_EXPIRY_HOURS:-168}
|
||||||
|
|
||||||
|
# === LOGGING ===
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
||||||
|
|
||||||
|
# === SITE MANAGEMENT (E.3) ===
|
||||||
|
- MAX_SITES_PER_USER=${MAX_SITES_PER_USER:-10}
|
||||||
|
- USER_RATE_LIMIT_PER_MIN=${USER_RATE_LIMIT_PER_MIN:-30}
|
||||||
|
- USER_RATE_LIMIT_PER_HR=${USER_RATE_LIMIT_PER_HR:-500}
|
||||||
|
|
||||||
|
# === ADMIN-MANAGED SITES (optional) ===
|
||||||
|
# Add WordPress/Gitea/n8n/etc. sites here if you want admin-level access.
|
||||||
|
# Users can add their own sites via the dashboard without these.
|
||||||
|
# Format: {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
|
||||||
|
#
|
||||||
|
# - WORDPRESS_SITE1_URL=${WORDPRESS_SITE1_URL:-}
|
||||||
|
# - WORDPRESS_SITE1_USERNAME=${WORDPRESS_SITE1_USERNAME:-}
|
||||||
|
# - WORDPRESS_SITE1_APP_PASSWORD=${WORDPRESS_SITE1_APP_PASSWORD:-}
|
||||||
|
# - WORDPRESS_SITE1_ALIAS=${WORDPRESS_SITE1_ALIAS:-}
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- mcp-data:/app/data # SQLite DB, API keys, OAuth data
|
||||||
|
- mcp-logs:/app/logs # Audit logs, health reports
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
|
||||||
|
# Coolify uses GID 988 for Docker socket
|
||||||
|
group_add:
|
||||||
|
- "988"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mcp-data:
|
||||||
|
driver: local
|
||||||
|
mcp-logs:
|
||||||
|
driver: local
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
# ===================================
|
# ===================================
|
||||||
# MCP Hub — Docker Compose Configuration
|
# MCP Hub — Docker Compose (Standalone)
|
||||||
# ===================================
|
# ===================================
|
||||||
#
|
#
|
||||||
# After starting:
|
# Usage:
|
||||||
|
# cp env.example .env # edit with your values
|
||||||
# docker compose up -d
|
# docker compose up -d
|
||||||
# curl http://localhost:8000/health # verify server is running
|
# curl http://localhost:8000/health
|
||||||
# open http://localhost:8000/dashboard # web dashboard
|
# open http://localhost:8000/dashboard
|
||||||
#
|
#
|
||||||
# Port mapping:
|
# For Coolify deployment, use docker-compose.coolify.yaml instead.
|
||||||
# - Standalone Docker: ports "8000:8000" works out-of-the-box.
|
|
||||||
# - Coolify: Comment out or remove the 'ports' section below.
|
|
||||||
# Coolify's reverse proxy handles routing — 'expose' is sufficient.
|
|
||||||
# ===================================
|
# ===================================
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -18,58 +16,19 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
image: airano/mcphub:latest
|
||||||
container_name: mcphub
|
container_name: mcphub
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
|
|
||||||
# Coolify users: Remove the 'ports' section above.
|
env_file:
|
||||||
# Coolify's reverse proxy routes traffic to the container's exposed port.
|
- .env
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
environment:
|
environment:
|
||||||
# Master API key
|
|
||||||
- MASTER_API_KEY=${MASTER_API_KEY}
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
- LOG_LEVEL=${LOG_LEVEL:-INFO}
|
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# === OAuth 2.1 (OPTIONAL) ===
|
|
||||||
# Only needed for ChatGPT auto-registration or third-party OAuth clients.
|
|
||||||
# For Claude Desktop/Code/Cursor with Bearer token auth, skip these entirely.
|
|
||||||
# - OAUTH_JWT_SECRET_KEY=${OAUTH_JWT_SECRET_KEY}
|
|
||||||
# - OAUTH_BASE_URL=${OAUTH_BASE_URL}
|
|
||||||
# - OAUTH_JWT_ALGORITHM=${OAUTH_JWT_ALGORITHM:-HS256}
|
|
||||||
# - OAUTH_ACCESS_TOKEN_TTL=${OAUTH_ACCESS_TOKEN_TTL:-3600}
|
|
||||||
# - OAUTH_REFRESH_TOKEN_TTL=${OAUTH_REFRESH_TOKEN_TTL:-604800}
|
|
||||||
- OAUTH_STORAGE_TYPE=${OAUTH_STORAGE_TYPE:-json}
|
|
||||||
- OAUTH_STORAGE_PATH=${OAUTH_STORAGE_PATH:-/app/data}
|
|
||||||
|
|
||||||
# === WordPress Projects ===
|
|
||||||
# Configure WordPress sites in Coolify environment variables
|
|
||||||
# Format: WORDPRESS_{SITE_ID}_{CONFIG_KEY}
|
|
||||||
#
|
|
||||||
# Required variables for each site:
|
|
||||||
# WORDPRESS_SITE1_URL=https://your-site.com
|
|
||||||
# WORDPRESS_SITE1_USERNAME=admin
|
|
||||||
# WORDPRESS_SITE1_APP_PASSWORD=xxxx xxxx xxxx xxxx
|
|
||||||
#
|
|
||||||
# Optional (for WP-CLI tools):
|
|
||||||
# WORDPRESS_SITE1_CONTAINER=wordpress_container_name
|
|
||||||
# WORDPRESS_SITE1_ALIAS=myblog
|
|
||||||
#
|
|
||||||
# ⚠️ DO NOT add example values here - configure in Coolify!
|
|
||||||
# The server will auto-discover all WORDPRESS_* variables at startup.
|
|
||||||
|
|
||||||
# === Other Plugins ===
|
|
||||||
# Gitea, n8n, Supabase, OpenPanel, Appwrite, and Directus plugins
|
|
||||||
# auto-discover their environment variables at startup.
|
|
||||||
# Format: {PLUGIN_TYPE}_{SITE_ID}_{CONFIG_KEY}
|
|
||||||
# No need to pre-define them here.
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
test: ["CMD", "wget", "-qO-", "http://localhost:8000/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -77,31 +36,20 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# Docker socket access for WP-CLI tools
|
|
||||||
# Required for: wp_cache_flush, wp_cache_type, wp_transient_*, etc.
|
|
||||||
volumes:
|
volumes:
|
||||||
|
- mcp-data:/app/data # SQLite DB, API keys, OAuth data
|
||||||
|
- mcp-logs:/app/logs # Audit logs, health reports
|
||||||
|
# Docker socket — only needed for WP-CLI tools (WordPress Advanced plugin)
|
||||||
|
# Remove if you don't use wp_cache_flush, wp_transient_*, etc.
|
||||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
- mcp-data:/app/data # Persistent storage for API keys
|
|
||||||
- mcp-logs:/app/logs # Persistent logs (audit, health)
|
|
||||||
|
|
||||||
# Grant access to Docker socket by adding user to docker group
|
# Docker socket GID — adjust to match your host's docker group
|
||||||
# The GID varies by host (988, 999, 133 are common)
|
# Check with: getent group docker | cut -d: -f3
|
||||||
# Coolify typically uses 988
|
|
||||||
group_add:
|
group_add:
|
||||||
- "988" # Docker group GID (adjust if needed)
|
- "999"
|
||||||
|
|
||||||
# Network - Coolify will handle this
|
|
||||||
# networks:
|
|
||||||
# - coolify
|
|
||||||
|
|
||||||
# Named volumes for persistent data
|
|
||||||
volumes:
|
volumes:
|
||||||
mcp-data:
|
mcp-data:
|
||||||
driver: local
|
driver: local
|
||||||
mcp-logs:
|
mcp-logs:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
# Networks managed by Coolify
|
|
||||||
# networks:
|
|
||||||
# coolify:
|
|
||||||
# external: true
|
|
||||||
|
|||||||
33
env.example
33
env.example
@@ -19,6 +19,14 @@
|
|||||||
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
# Generate with: python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
MASTER_API_KEY=your-secure-key-here
|
MASTER_API_KEY=your-secure-key-here
|
||||||
|
|
||||||
|
# Encryption key for user credentials (REQUIRED for Live Platform / Track E)
|
||||||
|
# Generate with: python -c "import os, base64; print(base64.b64encode(os.urandom(32)).decode())"
|
||||||
|
ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# Dashboard session secret (REQUIRED for production)
|
||||||
|
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
DASHBOARD_SESSION_SECRET=
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# WORDPRESS SITES
|
# WORDPRESS SITES
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -122,6 +130,28 @@ WORDPRESS_SITE1_ALIAS=mysite
|
|||||||
# OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
# OAUTH_JWT_SECRET_KEY=your-jwt-secret
|
||||||
# OAUTH_BASE_URL=https://your-public-domain.com
|
# OAUTH_BASE_URL=https://your-public-domain.com
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# OAUTH SOCIAL LOGIN (optional — for user registration)
|
||||||
|
# ============================================
|
||||||
|
# Enable GitHub and/or Google login for the Live Platform.
|
||||||
|
# Create OAuth apps:
|
||||||
|
# GitHub: https://github.com/settings/developers → New OAuth App
|
||||||
|
# - Callback URL: https://your-domain.com/auth/callback/github
|
||||||
|
# Google: https://console.cloud.google.com/apis/credentials → Create OAuth Client
|
||||||
|
# - Callback URL: https://your-domain.com/auth/callback/google
|
||||||
|
|
||||||
|
# GITHUB_CLIENT_ID=
|
||||||
|
# GITHUB_CLIENT_SECRET=
|
||||||
|
# GOOGLE_CLIENT_ID=
|
||||||
|
# GOOGLE_CLIENT_SECRET=
|
||||||
|
# PUBLIC_URL=https://mcp.example.com
|
||||||
|
# SESSION_EXPIRY_HOURS=168
|
||||||
|
|
||||||
|
# === SITE MANAGEMENT (E.3) ===
|
||||||
|
# MAX_SITES_PER_USER=10
|
||||||
|
# USER_RATE_LIMIT_PER_MIN=30
|
||||||
|
# USER_RATE_LIMIT_PER_HR=500
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# ADVANCED (optional — defaults are fine)
|
# ADVANCED (optional — defaults are fine)
|
||||||
# ============================================
|
# ============================================
|
||||||
@@ -129,6 +159,9 @@ WORDPRESS_SITE1_ALIAS=mysite
|
|||||||
# Logging
|
# Logging
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Database path (default: data/mcphub.db)
|
||||||
|
# DATABASE_PATH=/app/data/mcphub.db
|
||||||
|
|
||||||
# Rate limits (per client)
|
# Rate limits (per client)
|
||||||
# RATE_LIMIT_PER_MINUTE=60
|
# RATE_LIMIT_PER_MINUTE=60
|
||||||
# RATE_LIMIT_PER_HOUR=1000
|
# RATE_LIMIT_PER_HOUR=1000
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ async def get_instance_info(client: N8nClient) -> str:
|
|||||||
try:
|
try:
|
||||||
health = await client.health_check()
|
health = await client.health_check()
|
||||||
info["health"] = health
|
info["health"] = health
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get current user to verify connectivity
|
# Get current user to verify connectivity
|
||||||
@@ -123,7 +123,7 @@ async def get_instance_info(client: N8nClient) -> str:
|
|||||||
"email": user.get("email"),
|
"email": user.get("email"),
|
||||||
"role": user.get("role"),
|
"role": user.get("role"),
|
||||||
}
|
}
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
info["instance_url"] = client.site_url
|
info["instance_url"] = client.site_url
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ async def set_variables(client: N8nClient, variables: dict[str, str]) -> str:
|
|||||||
# Variable exists, update it
|
# Variable exists, update it
|
||||||
await client.update_variable(key, value)
|
await client.update_variable(key, value)
|
||||||
updated.append(key)
|
updated.append(key)
|
||||||
except:
|
except Exception:
|
||||||
# Variable doesn't exist, create it
|
# Variable doesn't exist, create it
|
||||||
await client.create_variable(key, value)
|
await client.create_variable(key, value)
|
||||||
created.append(key)
|
created.append(key)
|
||||||
|
|||||||
@@ -236,8 +236,8 @@ async def get_profile(client: OpenPanelClient, project_id: str, profile_id: str)
|
|||||||
indent=2,
|
indent=2,
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass # Profile event fetch is optional; fall through to generic response
|
||||||
|
|
||||||
return json.dumps(
|
return json.dumps(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -161,21 +161,21 @@ async def get_database_stats(client: SupabaseClient) -> str:
|
|||||||
try:
|
try:
|
||||||
tables = await client.list_tables()
|
tables = await client.list_tables()
|
||||||
stats["tables"] = len(tables) if isinstance(tables, list) else 0
|
stats["tables"] = len(tables) if isinstance(tables, list) else 0
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get schema count
|
# Get schema count
|
||||||
try:
|
try:
|
||||||
schemas = await client.list_schemas()
|
schemas = await client.list_schemas()
|
||||||
stats["schemas"] = len(schemas) if isinstance(schemas, list) else 0
|
stats["schemas"] = len(schemas) if isinstance(schemas, list) else 0
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get extension count
|
# Get extension count
|
||||||
try:
|
try:
|
||||||
extensions = await client.list_extensions()
|
extensions = await client.list_extensions()
|
||||||
stats["extensions"] = len(extensions) if isinstance(extensions, list) else 0
|
stats["extensions"] = len(extensions) if isinstance(extensions, list) else 0
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get database size and version
|
# Get database size and version
|
||||||
@@ -186,7 +186,7 @@ async def get_database_stats(client: SupabaseClient) -> str:
|
|||||||
if isinstance(size_result, list) and len(size_result) > 0:
|
if isinstance(size_result, list) and len(size_result) > 0:
|
||||||
stats["size"] = size_result[0].get("size", "unknown")
|
stats["size"] = size_result[0].get("size", "unknown")
|
||||||
stats["version"] = size_result[0].get("version", "unknown")
|
stats["version"] = size_result[0].get("version", "unknown")
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Get connection info
|
# Get connection info
|
||||||
@@ -196,7 +196,7 @@ async def get_database_stats(client: SupabaseClient) -> str:
|
|||||||
)
|
)
|
||||||
if isinstance(conn_result, list) and len(conn_result) > 0:
|
if isinstance(conn_result, list) and len(conn_result) > 0:
|
||||||
stats["active_connections"] = conn_result[0].get("connections", 0)
|
stats["active_connections"] = conn_result[0].get("connections", 0)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return json.dumps({"success": True, "database_stats": stats}, indent=2, ensure_ascii=False)
|
return json.dumps({"success": True, "database_stats": stats}, indent=2, ensure_ascii=False)
|
||||||
@@ -237,7 +237,7 @@ async def get_storage_stats(client: SupabaseClient) -> str:
|
|||||||
if isinstance(files, list):
|
if isinstance(files, list):
|
||||||
bucket_info["file_count"] = len(files)
|
bucket_info["file_count"] = len(files)
|
||||||
stats["total_files"] += len(files)
|
stats["total_files"] += len(files)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
stats["bucket_details"].append(bucket_info)
|
stats["bucket_details"].append(bucket_info)
|
||||||
@@ -333,7 +333,7 @@ async def get_instance_info(client: SupabaseClient) -> str:
|
|||||||
await client.list_schemas()
|
await client.list_schemas()
|
||||||
|
|
||||||
info["services_available"].append(service)
|
info["services_available"].append(service)
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Try to get PostgreSQL version
|
# Try to get PostgreSQL version
|
||||||
@@ -341,7 +341,7 @@ async def get_instance_info(client: SupabaseClient) -> str:
|
|||||||
version_result = await client.execute_sql("SELECT version()")
|
version_result = await client.execute_sql("SELECT version()")
|
||||||
if isinstance(version_result, list) and len(version_result) > 0:
|
if isinstance(version_result, list) and len(version_result) > 0:
|
||||||
info["postgres_version"] = version_result[0].get("version", "unknown")
|
info["postgres_version"] = version_result[0].get("version", "unknown")
|
||||||
except:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return json.dumps({"success": True, "instance_info": info}, indent=2, ensure_ascii=False)
|
return json.dumps({"success": True, "instance_info": info}, indent=2, ensure_ascii=False)
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ Handles all HTTP communication with WordPress REST API.
|
|||||||
Separates API communication from business logic.
|
Separates API communication from business logic.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -25,6 +27,23 @@ class AuthenticationError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionError(Exception):
|
||||||
|
"""Raised when a network connection to the site fails."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# Transient HTTP status codes that are worth retrying
|
||||||
|
_RETRYABLE_STATUS_CODES = {502, 503, 504, 429}
|
||||||
|
|
||||||
|
# Default request timeout in seconds
|
||||||
|
_REQUEST_TIMEOUT = 30
|
||||||
|
|
||||||
|
# Retry configuration
|
||||||
|
_MAX_RETRIES = 2
|
||||||
|
_RETRY_BACKOFF_BASE = 1.0 # seconds
|
||||||
|
|
||||||
|
|
||||||
class WordPressClient:
|
class WordPressClient:
|
||||||
"""
|
"""
|
||||||
WordPress REST API client for HTTP communication.
|
WordPress REST API client for HTTP communication.
|
||||||
@@ -140,9 +159,14 @@ class WordPressClient:
|
|||||||
if json_data:
|
if json_data:
|
||||||
json_data = {k: v for k, v in json_data.items() if should_include(v)}
|
json_data = {k: v for k, v in json_data.items() if should_include(v)}
|
||||||
|
|
||||||
# Make request
|
# Make request with retry for transient errors
|
||||||
|
timeout = aiohttp.ClientTimeout(total=_REQUEST_TIMEOUT)
|
||||||
|
last_exception = None
|
||||||
|
|
||||||
|
for attempt in range(_MAX_RETRIES + 1):
|
||||||
|
try:
|
||||||
async with (
|
async with (
|
||||||
aiohttp.ClientSession() as session,
|
aiohttp.ClientSession(timeout=timeout) as session,
|
||||||
session.request(
|
session.request(
|
||||||
method, url, params=params, json=json_data, data=data, headers=headers
|
method, url, params=params, json=json_data, data=data, headers=headers
|
||||||
) as response,
|
) as response,
|
||||||
@@ -151,6 +175,16 @@ class WordPressClient:
|
|||||||
if response.status >= 400:
|
if response.status >= 400:
|
||||||
error_text = await response.text()
|
error_text = await response.text()
|
||||||
|
|
||||||
|
# Retry on transient server errors (502, 503, 504, 429)
|
||||||
|
if response.status in _RETRYABLE_STATUS_CODES and attempt < _MAX_RETRIES:
|
||||||
|
wait = _RETRY_BACKOFF_BASE * (2**attempt)
|
||||||
|
self.logger.warning(
|
||||||
|
f"Transient error {response.status} from {url}, "
|
||||||
|
f"retrying in {wait:.1f}s (attempt {attempt + 1}/{_MAX_RETRIES})"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
continue
|
||||||
|
|
||||||
# Parse structured error response
|
# Parse structured error response
|
||||||
error_info = self._parse_error_response(
|
error_info = self._parse_error_response(
|
||||||
response.status, error_text, use_woocommerce
|
response.status, error_text, use_woocommerce
|
||||||
@@ -172,6 +206,78 @@ class WordPressClient:
|
|||||||
# Return JSON response
|
# Return JSON response
|
||||||
return await response.json()
|
return await response.json()
|
||||||
|
|
||||||
|
except (AuthenticationError, ConfigurationError):
|
||||||
|
raise # Never retry auth/config errors
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
last_exception = ConnectionError(
|
||||||
|
f"Request timed out after {_REQUEST_TIMEOUT}s. "
|
||||||
|
f"The site at {self.site_url} is not responding. "
|
||||||
|
"Possible causes: site is overloaded, network is slow, "
|
||||||
|
"or the server is down."
|
||||||
|
)
|
||||||
|
if attempt < _MAX_RETRIES:
|
||||||
|
wait = _RETRY_BACKOFF_BASE * (2**attempt)
|
||||||
|
self.logger.warning(
|
||||||
|
f"Timeout connecting to {url}, "
|
||||||
|
f"retrying in {wait:.1f}s (attempt {attempt + 1}/{_MAX_RETRIES})"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
continue
|
||||||
|
|
||||||
|
except aiohttp.ClientConnectorCertificateError as e:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"SSL certificate error for {self.site_url}. "
|
||||||
|
"The site's SSL certificate is invalid or expired. "
|
||||||
|
f"Details: {e}"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
except aiohttp.ClientConnectorDNSError as e:
|
||||||
|
host = self.site_url.split("://")[-1].split("/")[0]
|
||||||
|
raise ConnectionError(
|
||||||
|
f"DNS resolution failed for '{host}'. "
|
||||||
|
"The domain name could not be found. "
|
||||||
|
"Please check that the URL is correct."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
except aiohttp.ClientConnectorError as e:
|
||||||
|
os_error = getattr(e, "os_error", None)
|
||||||
|
if isinstance(os_error, socket.gaierror):
|
||||||
|
host = self.site_url.split("://")[-1].split("/")[0]
|
||||||
|
raise ConnectionError(
|
||||||
|
f"DNS resolution failed for '{host}'. "
|
||||||
|
"The domain name could not be found. "
|
||||||
|
"Please check that the URL is correct."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
raise ConnectionError(
|
||||||
|
f"Cannot connect to {self.site_url}. "
|
||||||
|
"The server is unreachable. Possible causes: "
|
||||||
|
"wrong URL, server is down, firewall blocking, or wrong port."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
except aiohttp.InvalidURL:
|
||||||
|
raise ConnectionError(
|
||||||
|
f"Invalid URL: {self.site_url}. "
|
||||||
|
"Please provide a valid URL starting with https:// or http://."
|
||||||
|
)
|
||||||
|
|
||||||
|
except (aiohttp.ClientError, OSError) as e:
|
||||||
|
last_exception = ConnectionError(
|
||||||
|
f"Network error connecting to {self.site_url}: {e}"
|
||||||
|
)
|
||||||
|
if attempt < _MAX_RETRIES:
|
||||||
|
wait = _RETRY_BACKOFF_BASE * (2**attempt)
|
||||||
|
self.logger.warning(
|
||||||
|
f"Network error for {url}: {e}, "
|
||||||
|
f"retrying in {wait:.1f}s (attempt {attempt + 1}/{_MAX_RETRIES})"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(wait)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# All retries exhausted
|
||||||
|
raise last_exception # type: ignore[misc]
|
||||||
|
|
||||||
def _parse_error_response(
|
def _parse_error_response(
|
||||||
self, status_code: int, error_text: str, use_woocommerce: bool = False
|
self, status_code: int, error_text: str, use_woocommerce: bool = False
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
@@ -408,28 +514,33 @@ class WordPressClient:
|
|||||||
Dict with 'available' bool and version info
|
Dict with 'available' bool and version info
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Try to access WooCommerce system status endpoint
|
|
||||||
response = await self.get("system_status", use_woocommerce=True)
|
response = await self.get("system_status", use_woocommerce=True)
|
||||||
return {
|
return {
|
||||||
"available": True,
|
"available": True,
|
||||||
"version": response.get("environment", {}).get("version", "unknown"),
|
"version": response.get("environment", {}).get("version", "unknown"),
|
||||||
}
|
}
|
||||||
except Exception:
|
except AuthenticationError:
|
||||||
return {"available": False, "version": None}
|
return {"available": False, "version": None, "reason": "authentication_failed"}
|
||||||
|
except ConnectionError as e:
|
||||||
|
return {"available": False, "version": None, "reason": str(e)}
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug(f"WooCommerce check failed: {e}")
|
||||||
|
return {"available": False, "version": None, "reason": str(e)}
|
||||||
|
|
||||||
async def check_site_health(self) -> dict[str, Any]:
|
async def check_site_health(self) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Check WordPress site health and accessibility.
|
Check WordPress site health and accessibility.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with health status information
|
Dict with health status information including specific error diagnosis.
|
||||||
"""
|
"""
|
||||||
|
timeout = aiohttp.ClientTimeout(total=10)
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
async with session.get(f"{self.site_url}/wp-json") as response:
|
async with session.get(f"{self.site_url}/wp-json") as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
data = await response.json()
|
data = await response.json()
|
||||||
return {
|
result = {
|
||||||
"healthy": True,
|
"healthy": True,
|
||||||
"accessible": True,
|
"accessible": True,
|
||||||
"name": data.get("name", "Unknown"),
|
"name": data.get("name", "Unknown"),
|
||||||
@@ -437,15 +548,100 @@ class WordPressClient:
|
|||||||
"url": data.get("url", self.site_url),
|
"url": data.get("url", self.site_url),
|
||||||
"routes": len(data.get("routes", {})),
|
"routes": len(data.get("routes", {})),
|
||||||
}
|
}
|
||||||
else:
|
|
||||||
|
# Test authentication with an authenticated request
|
||||||
|
try:
|
||||||
|
await self.get("users/me")
|
||||||
|
result["auth_valid"] = True
|
||||||
|
except Exception:
|
||||||
|
result["auth_valid"] = False
|
||||||
|
result["auth_warning"] = (
|
||||||
|
"Site accessible but credentials may be invalid"
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Detect REST API disabled (common with security plugins)
|
||||||
|
if response.status in (403, 404):
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"accessible": True,
|
||||||
|
"error_type": "rest_api_disabled",
|
||||||
|
"message": (
|
||||||
|
f"WordPress REST API returned {response.status}. "
|
||||||
|
"The REST API may be disabled by a security plugin "
|
||||||
|
"(e.g., Wordfence, iThemes Security, Disable REST API). "
|
||||||
|
"Please ensure the REST API is enabled for MCP Hub to work."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if response.status == 401:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"accessible": True,
|
||||||
|
"error_type": "auth_required",
|
||||||
|
"message": (
|
||||||
|
"WordPress REST API requires authentication even for discovery. "
|
||||||
|
"This may be caused by a security plugin restricting public access."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"accessible": True,
|
||||||
|
"error_type": "unexpected_status",
|
||||||
|
"message": f"Site returned HTTP {response.status}.",
|
||||||
|
}
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
return {
|
return {
|
||||||
"healthy": False,
|
"healthy": False,
|
||||||
"accessible": False,
|
"accessible": False,
|
||||||
"message": f"Site returned status {response.status}",
|
"error_type": "timeout",
|
||||||
|
"message": (
|
||||||
|
f"Site at {self.site_url} did not respond within 10 seconds. "
|
||||||
|
"The server may be overloaded or down."
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
except aiohttp.ClientConnectorDNSError:
|
||||||
|
host = self.site_url.split("://")[-1].split("/")[0]
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"accessible": False,
|
||||||
|
"error_type": "dns_failure",
|
||||||
|
"message": (
|
||||||
|
f"DNS resolution failed for '{host}'. " "Please check that the URL is correct."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
except aiohttp.ClientConnectorCertificateError:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"accessible": False,
|
||||||
|
"error_type": "ssl_error",
|
||||||
|
"message": (
|
||||||
|
f"SSL certificate error for {self.site_url}. "
|
||||||
|
"The certificate may be expired or invalid."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
except aiohttp.ClientConnectorError:
|
||||||
|
return {
|
||||||
|
"healthy": False,
|
||||||
|
"accessible": False,
|
||||||
|
"error_type": "connection_refused",
|
||||||
|
"message": (
|
||||||
|
f"Cannot connect to {self.site_url}. "
|
||||||
|
"The server is unreachable — check URL, firewall, or server status."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self.logger.debug(f"Health check failed with unexpected error: {e}")
|
||||||
return {
|
return {
|
||||||
"healthy": False,
|
"healthy": False,
|
||||||
"accessible": False,
|
"accessible": False,
|
||||||
"message": f"Health check failed: {str(e)}",
|
"error_type": "unknown",
|
||||||
|
"message": f"Health check failed: {e}",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ class MenusHandler:
|
|||||||
# Try custom endpoint first, fallback to standard if not available
|
# Try custom endpoint first, fallback to standard if not available
|
||||||
try:
|
try:
|
||||||
menus = await self.client.get("menus", use_custom_namespace=True)
|
menus = await self.client.get("menus", use_custom_namespace=True)
|
||||||
except:
|
except Exception:
|
||||||
# Fallback: use wp/v2/navigation endpoint (WP 5.9+)
|
# Fallback: use wp/v2/navigation endpoint (WP 5.9+)
|
||||||
menus = await self.client.get("navigation")
|
menus = await self.client.get("navigation")
|
||||||
|
|
||||||
@@ -216,7 +216,7 @@ class MenusHandler:
|
|||||||
# Get menu details
|
# Get menu details
|
||||||
try:
|
try:
|
||||||
menu = await self.client.get(f"menus/{menu_id}", use_custom_namespace=True)
|
menu = await self.client.get(f"menus/{menu_id}", use_custom_namespace=True)
|
||||||
except:
|
except Exception:
|
||||||
menu = await self.client.get(f"navigation/{menu_id}")
|
menu = await self.client.get(f"navigation/{menu_id}")
|
||||||
|
|
||||||
# Get menu items
|
# Get menu items
|
||||||
@@ -256,7 +256,7 @@ class MenusHandler:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
menu = await self.client.post("menus", json_data=data, use_custom_namespace=True)
|
menu = await self.client.post("menus", json_data=data, use_custom_namespace=True)
|
||||||
except:
|
except Exception:
|
||||||
# Try navigation endpoint
|
# Try navigation endpoint
|
||||||
menu = await self.client.post("navigation", json_data=data)
|
menu = await self.client.post("navigation", json_data=data)
|
||||||
|
|
||||||
|
|||||||
@@ -599,7 +599,7 @@ class WPCLIManager:
|
|||||||
size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
|
size_str = f"{size_bytes / (1024 * 1024):.1f} MB"
|
||||||
else:
|
else:
|
||||||
size_str = "unknown"
|
size_str = "unknown"
|
||||||
except:
|
except (ValueError, IndexError, OSError):
|
||||||
size_str = "unknown"
|
size_str = "unknown"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1198,7 +1198,7 @@ class WPCLIManager:
|
|||||||
site_cmd = "core version"
|
site_cmd = "core version"
|
||||||
version_result = await self._execute_wp_cli(site_cmd)
|
version_result = await self._execute_wp_cli(site_cmd)
|
||||||
current_version = version_result.get("message", "unknown").strip()
|
current_version = version_result.get("message", "unknown").strip()
|
||||||
except:
|
except Exception:
|
||||||
current_version = "unknown"
|
current_version = "unknown"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1220,7 +1220,7 @@ class WPCLIManager:
|
|||||||
try:
|
try:
|
||||||
version_result = await self._execute_wp_cli("core version")
|
version_result = await self._execute_wp_cli("core version")
|
||||||
old_version = version_result.get("message", "unknown").strip()
|
old_version = version_result.get("message", "unknown").strip()
|
||||||
except:
|
except Exception:
|
||||||
old_version = "unknown"
|
old_version = "unknown"
|
||||||
|
|
||||||
# Execute update (long timeout for downloads)
|
# Execute update (long timeout for downloads)
|
||||||
@@ -1230,7 +1230,7 @@ class WPCLIManager:
|
|||||||
try:
|
try:
|
||||||
version_result = await self._execute_wp_cli("core version")
|
version_result = await self._execute_wp_cli("core version")
|
||||||
new_version = version_result.get("message", "unknown").strip()
|
new_version = version_result.get("message", "unknown").strip()
|
||||||
except:
|
except Exception:
|
||||||
new_version = "unknown"
|
new_version = "unknown"
|
||||||
|
|
||||||
# Determine update type
|
# Determine update type
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ class SystemHandler:
|
|||||||
try:
|
try:
|
||||||
db_result = await self.wp_cli.execute_command(db_cmd)
|
db_result = await self.wp_cli.execute_command(db_cmd)
|
||||||
sizes["database_size_mb"] = float(db_result.get("output", "0").strip())
|
sizes["database_size_mb"] = float(db_result.get("output", "0").strip())
|
||||||
except:
|
except (ValueError, KeyError, Exception):
|
||||||
sizes["database_size_mb"] = 0.0
|
sizes["database_size_mb"] = 0.0
|
||||||
|
|
||||||
# Calculate total
|
# Calculate total
|
||||||
@@ -456,7 +456,7 @@ class SystemHandler:
|
|||||||
schedules_result = await self.wp_cli.execute_command(schedules_cmd)
|
schedules_result = await self.wp_cli.execute_command(schedules_cmd)
|
||||||
schedules_data = json.loads(schedules_result.get("output", "[]"))
|
schedules_data = json.loads(schedules_result.get("output", "[]"))
|
||||||
schedules = {s.get("name"): s for s in schedules_data}
|
schedules = {s.get("name"): s for s in schedules_data}
|
||||||
except:
|
except (json.JSONDecodeError, KeyError, Exception):
|
||||||
schedules = {}
|
schedules = {}
|
||||||
|
|
||||||
return {"success": True, "events": events, "total": len(events), "schedules": schedules}
|
return {"success": True, "events": events, "total": len(events), "schedules": schedules}
|
||||||
@@ -514,7 +514,7 @@ class SystemHandler:
|
|||||||
size_result = await self.wp_cli.execute_command(size_cmd)
|
size_result = await self.wp_cli.execute_command(size_cmd)
|
||||||
log_size_bytes = int(size_result.get("output", "0").strip())
|
log_size_bytes = int(size_result.get("output", "0").strip())
|
||||||
log_size_mb = round(log_size_bytes / 1024 / 1024, 2)
|
log_size_mb = round(log_size_bytes / 1024 / 1024, 2)
|
||||||
except:
|
except (ValueError, KeyError, Exception):
|
||||||
log_size_mb = 0.0
|
log_size_mb = 0.0
|
||||||
|
|
||||||
# Read log file (last N lines)
|
# Read log file (last N lines)
|
||||||
|
|||||||
@@ -132,6 +132,15 @@ class WordPressAdvancedPlugin(BasePlugin):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"REST API check failed: {e}")
|
self.logger.warning(f"REST API check failed: {e}")
|
||||||
|
|
||||||
|
# Test authentication with an authenticated request
|
||||||
|
auth_valid = False
|
||||||
|
if rest_api_available:
|
||||||
|
try:
|
||||||
|
await self.client.get("users/me")
|
||||||
|
auth_valid = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Test WP-CLI access (optional — only needed for database/system tools)
|
# Test WP-CLI access (optional — only needed for database/system tools)
|
||||||
wp_cli_available = False
|
wp_cli_available = False
|
||||||
try:
|
try:
|
||||||
@@ -140,16 +149,20 @@ class WordPressAdvancedPlugin(BasePlugin):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return {
|
result = {
|
||||||
"healthy": rest_api_available,
|
"healthy": rest_api_available,
|
||||||
"wp_cli_available": wp_cli_available,
|
"wp_cli_available": wp_cli_available,
|
||||||
"rest_api_available": rest_api_available,
|
"rest_api_available": rest_api_available,
|
||||||
|
"auth_valid": auth_valid,
|
||||||
"features": {
|
"features": {
|
||||||
"database_operations": wp_cli_available,
|
"database_operations": wp_cli_available,
|
||||||
"bulk_operations": rest_api_available,
|
"bulk_operations": rest_api_available,
|
||||||
"system_operations": wp_cli_available,
|
"system_operations": wp_cli_available,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
if not auth_valid and rest_api_available:
|
||||||
|
result["auth_warning"] = "Site accessible but credentials may be invalid"
|
||||||
|
return result
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return {
|
||||||
"healthy": False,
|
"healthy": False,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "mcphub-server"
|
name = "mcphub-server"
|
||||||
version = "3.0.4"
|
version = "3.1.0"
|
||||||
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
|
description = "AI-native management hub for WordPress, WooCommerce, and self-hosted services via Model Context Protocol (MCP)"
|
||||||
authors = [
|
authors = [
|
||||||
{name = "MCP Hub", email = "contact@mcphub.dev"}
|
{name = "MCP Hub", email = "contact@mcphub.dev"}
|
||||||
@@ -37,7 +37,9 @@ dependencies = [
|
|||||||
"authlib>=1.5.0",
|
"authlib>=1.5.0",
|
||||||
"PyJWT>=2.8.0",
|
"PyJWT>=2.8.0",
|
||||||
"cryptography>=46.0.0",
|
"cryptography>=46.0.0",
|
||||||
|
"aiosqlite>=0.20.0",
|
||||||
"jinja2>=3.1.2",
|
"jinja2>=3.1.2",
|
||||||
|
"bcrypt>=4.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -17,7 +17,13 @@ python-dotenv>=1.0.0
|
|||||||
# OAuth 2.1 Infrastructure
|
# OAuth 2.1 Infrastructure
|
||||||
authlib>=1.5.0 # OAuth 2.1 server/client implementation
|
authlib>=1.5.0 # OAuth 2.1 server/client implementation
|
||||||
PyJWT>=2.8.0 # JWT encoding/decoding
|
PyJWT>=2.8.0 # JWT encoding/decoding
|
||||||
cryptography>=46.0.0 # For RSA/ECDSA JWT signing
|
cryptography>=46.0.0 # For RSA/ECDSA JWT signing + AES-256-GCM encryption
|
||||||
|
|
||||||
# Template engine for OAuth Authorization Page (Phase E)
|
# Template engine for dashboard and OAuth pages
|
||||||
jinja2>=3.1.2 # HTML template rendering
|
jinja2>=3.1.2 # HTML template rendering
|
||||||
|
|
||||||
|
# Database (Track E — Live Platform)
|
||||||
|
aiosqlite>=0.20.0 # Async SQLite for user data, sites, API keys
|
||||||
|
|
||||||
|
# User API key hashing (Track E.3)
|
||||||
|
bcrypt>=4.0.0 # Secure password hashing for user API keys
|
||||||
|
|||||||
72
server.json
Normal file
72
server.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||||
|
"name": "io.github.airano-ir/mcphub",
|
||||||
|
"description": "Unified MCP server for managing WordPress, WooCommerce, Gitea, n8n, Supabase, OpenPanel, Appwrite, and Directus with 596 tools, multi-site support, and OAuth 2.1 authentication.",
|
||||||
|
"title": "MCP Hub",
|
||||||
|
"websiteUrl": "https://github.com/airano-ir/mcphub",
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/airano-ir/mcphub",
|
||||||
|
"source": "github"
|
||||||
|
},
|
||||||
|
"version": "3.1.0",
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"registryType": "pypi",
|
||||||
|
"registryBaseUrl": "https://pypi.org",
|
||||||
|
"identifier": "mcphub-server",
|
||||||
|
"version": "3.1.0",
|
||||||
|
"runtimeHint": "uvx",
|
||||||
|
"transport": {
|
||||||
|
"type": "stdio"
|
||||||
|
},
|
||||||
|
"environmentVariables": [
|
||||||
|
{
|
||||||
|
"name": "MASTER_API_KEY",
|
||||||
|
"description": "Master API key for authentication (also used for dashboard login)",
|
||||||
|
"isRequired": true,
|
||||||
|
"isSecret": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WORDPRESS_SITE1_URL",
|
||||||
|
"description": "WordPress site URL (e.g., https://example.com)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WORDPRESS_SITE1_USERNAME",
|
||||||
|
"description": "WordPress admin username"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "WORDPRESS_SITE1_APP_PASSWORD",
|
||||||
|
"description": "WordPress Application Password",
|
||||||
|
"isSecret": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GITEA_SITE1_URL",
|
||||||
|
"description": "Gitea instance URL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "GITEA_SITE1_TOKEN",
|
||||||
|
"description": "Gitea API token",
|
||||||
|
"isSecret": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "N8N_SITE1_URL",
|
||||||
|
"description": "n8n instance URL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "N8N_SITE1_API_KEY",
|
||||||
|
"description": "n8n API key",
|
||||||
|
"isSecret": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SUPABASE_SITE1_URL",
|
||||||
|
"description": "Supabase project URL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "SUPABASE_SITE1_SERVICE_ROLE_KEY",
|
||||||
|
"description": "Supabase service role key",
|
||||||
|
"isSecret": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
355
server.py
355
server.py
@@ -44,10 +44,8 @@ from starlette.templating import Jinja2Templates
|
|||||||
|
|
||||||
# Import core modules
|
# Import core modules
|
||||||
from core import (
|
from core import (
|
||||||
EventType,
|
|
||||||
LogLevel,
|
LogLevel,
|
||||||
ToolGenerator,
|
ToolGenerator,
|
||||||
UnifiedToolGenerator,
|
|
||||||
get_api_key_manager,
|
get_api_key_manager,
|
||||||
get_audit_logger,
|
get_audit_logger,
|
||||||
get_auth_manager,
|
get_auth_manager,
|
||||||
@@ -55,11 +53,24 @@ from core import (
|
|||||||
get_rate_limiter,
|
get_rate_limiter,
|
||||||
# Core architecture modules
|
# Core architecture modules
|
||||||
get_site_manager,
|
get_site_manager,
|
||||||
get_site_registry,
|
|
||||||
get_tool_registry,
|
get_tool_registry,
|
||||||
set_api_key_context,
|
set_api_key_context,
|
||||||
)
|
)
|
||||||
from core.dashboard.routes import (
|
from core.dashboard.routes import (
|
||||||
|
# E.3: Site Management routes
|
||||||
|
api_create_key,
|
||||||
|
api_create_site,
|
||||||
|
api_delete_key,
|
||||||
|
api_delete_site,
|
||||||
|
api_get_config,
|
||||||
|
api_list_keys,
|
||||||
|
api_list_sites,
|
||||||
|
api_test_site,
|
||||||
|
# E.2: OAuth Social Login routes
|
||||||
|
auth_callback,
|
||||||
|
auth_login_page,
|
||||||
|
auth_logout,
|
||||||
|
auth_provider_redirect,
|
||||||
dashboard_api_audit_logs,
|
dashboard_api_audit_logs,
|
||||||
dashboard_api_health,
|
dashboard_api_health,
|
||||||
dashboard_api_keys_create,
|
dashboard_api_keys_create,
|
||||||
@@ -72,6 +83,8 @@ from core.dashboard.routes import (
|
|||||||
dashboard_api_stats,
|
dashboard_api_stats,
|
||||||
# K.4: Audit Logs routes
|
# K.4: Audit Logs routes
|
||||||
dashboard_audit_logs_list,
|
dashboard_audit_logs_list,
|
||||||
|
# E.3: Dashboard pages
|
||||||
|
dashboard_connect_page,
|
||||||
# K.5: Health Monitoring routes
|
# K.5: Health Monitoring routes
|
||||||
dashboard_health_page,
|
dashboard_health_page,
|
||||||
dashboard_health_projects_partial,
|
dashboard_health_projects_partial,
|
||||||
@@ -83,18 +96,23 @@ from core.dashboard.routes import (
|
|||||||
dashboard_oauth_clients_delete,
|
dashboard_oauth_clients_delete,
|
||||||
# K.4: OAuth Clients routes
|
# K.4: OAuth Clients routes
|
||||||
dashboard_oauth_clients_list,
|
dashboard_oauth_clients_list,
|
||||||
|
# E.2: Profile page
|
||||||
|
dashboard_profile_page,
|
||||||
dashboard_project_detail,
|
dashboard_project_detail,
|
||||||
dashboard_project_health_check,
|
dashboard_project_health_check,
|
||||||
# K.2: Projects routes
|
# K.2: Projects routes
|
||||||
dashboard_projects_list,
|
dashboard_projects_list,
|
||||||
# K.5: Settings routes
|
# K.5: Settings routes
|
||||||
dashboard_settings_page,
|
dashboard_settings_page,
|
||||||
|
# E.3: Site Management pages
|
||||||
|
dashboard_sites_add,
|
||||||
|
dashboard_sites_list,
|
||||||
)
|
)
|
||||||
|
from core.database import get_database, initialize_database
|
||||||
from core.i18n import detect_language, get_all_translations
|
from core.i18n import detect_language, get_all_translations
|
||||||
|
|
||||||
# OAuth and CSRF (Phase E)
|
# OAuth and CSRF (Phase E)
|
||||||
from core.oauth import get_csrf_manager
|
from core.oauth import get_csrf_manager
|
||||||
from plugins import registry as plugin_registry
|
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
@@ -106,6 +124,23 @@ logging.basicConfig(
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Server version — read from pyproject.toml, fallback to importlib.metadata
|
||||||
|
SERVER_VERSION = "3.1.0"
|
||||||
|
try:
|
||||||
|
_pyproject = os.path.join(os.path.dirname(__file__), "pyproject.toml")
|
||||||
|
with open(_pyproject) as _f:
|
||||||
|
for _line in _f:
|
||||||
|
if _line.strip().startswith("version"):
|
||||||
|
SERVER_VERSION = _line.split("=")[1].strip().strip('"').strip("'")
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
from importlib.metadata import version as _pkg_version
|
||||||
|
|
||||||
|
SERVER_VERSION = _pkg_version("mcphub-server")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# OAuth Authorization Configuration
|
# OAuth Authorization Configuration
|
||||||
OAUTH_AUTH_MODE = os.getenv("OAUTH_AUTH_MODE", "trusted_domains").lower()
|
OAUTH_AUTH_MODE = os.getenv("OAUTH_AUTH_MODE", "trusted_domains").lower()
|
||||||
OAUTH_TRUSTED_DOMAINS = os.getenv(
|
OAUTH_TRUSTED_DOMAINS = os.getenv(
|
||||||
@@ -256,18 +291,13 @@ project_manager = get_project_manager()
|
|||||||
audit_logger = get_audit_logger()
|
audit_logger = get_audit_logger()
|
||||||
csrf_manager = get_csrf_manager()
|
csrf_manager = get_csrf_manager()
|
||||||
|
|
||||||
# Initialize site registry (legacy - kept for backward compatibility)
|
|
||||||
site_registry = get_site_registry()
|
|
||||||
plugin_types = plugin_registry.get_registered_types()
|
|
||||||
site_registry.discover_sites(plugin_types)
|
|
||||||
|
|
||||||
# Initialize unified tool generator (legacy - kept for backward compatibility)
|
|
||||||
unified_tool_generator = UnifiedToolGenerator(project_manager)
|
|
||||||
|
|
||||||
# Initialize site manager
|
# Initialize site manager
|
||||||
site_manager = get_site_manager()
|
from plugins import registry as plugin_registry
|
||||||
site_manager.discover_sites(plugin_types)
|
|
||||||
|
|
||||||
|
site_manager = get_site_manager()
|
||||||
|
plugin_types = plugin_registry.get_registered_types()
|
||||||
|
site_manager.discover_sites(plugin_types)
|
||||||
# Initialize tool registry (central tool management)
|
# Initialize tool registry (central tool management)
|
||||||
tool_registry = get_tool_registry()
|
tool_registry = get_tool_registry()
|
||||||
|
|
||||||
@@ -306,6 +336,22 @@ logger.info("=" * 60)
|
|||||||
# === MCP INSTRUCTIONS HELPER ===
|
# === MCP INSTRUCTIONS HELPER ===
|
||||||
# Phase K.2: Auto-discovery of available sites for AI assistants
|
# Phase K.2: Auto-discovery of available sites for AI assistants
|
||||||
|
|
||||||
|
# Example tool names per plugin type for instructions
|
||||||
|
_PLUGIN_EXAMPLE_TOOLS = {
|
||||||
|
"wordpress": ("wordpress_list_posts(per_page=10)", "wordpress_get_post(post_id=123)"),
|
||||||
|
"woocommerce": ("woocommerce_list_products(per_page=10)", "woocommerce_get_order(order_id=1)"),
|
||||||
|
"wordpress_advanced": (
|
||||||
|
"wordpress_advanced_system_info()",
|
||||||
|
"wordpress_advanced_wp_db_check()",
|
||||||
|
),
|
||||||
|
"gitea": ("gitea_list_repositories()", "gitea_get_repository(owner='user', repo='name')"),
|
||||||
|
"n8n": ("n8n_list_workflows()", "n8n_get_workflow(workflow_id=1)"),
|
||||||
|
"supabase": ("supabase_list_tables()", "supabase_query_table(table='users')"),
|
||||||
|
"openpanel": ("openpanel_get_event_count()", "openpanel_list_events()"),
|
||||||
|
"appwrite": ("appwrite_list_databases()", "appwrite_list_collections(database_id='db')"),
|
||||||
|
"directus": ("directus_list_collections()", "directus_get_items(collection='posts')"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def generate_mcp_instructions(plugin_type: str = None, site_locked: str = None) -> str:
|
def generate_mcp_instructions(plugin_type: str = None, site_locked: str = None) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -374,15 +420,18 @@ For export/read operations (get_event_count, export_events, etc.), you need to a
|
|||||||
The user can find it in OpenPanel Dashboard → Project Settings.
|
The user can find it in OpenPanel Dashboard → Project Settings.
|
||||||
Track API operations (identify_user, track_event, etc.) work without project_id."""
|
Track API operations (identify_user, track_event, etc.) work without project_id."""
|
||||||
|
|
||||||
|
ex1, _ = _PLUGIN_EXAMPLE_TOOLS.get(
|
||||||
|
plugin_type, ("wordpress_list_posts(per_page=10)", "")
|
||||||
|
)
|
||||||
return f"""🔗 SINGLE SITE MODE - Connected to: {site_name}
|
return f"""🔗 SINGLE SITE MODE - Connected to: {site_name}
|
||||||
URL: {site_url}
|
URL: {site_url}
|
||||||
|
|
||||||
You are connected to exactly ONE site. The 'site' parameter is OPTIONAL - you can omit it or use any value.
|
You are connected to exactly ONE site. The 'site' parameter is OPTIONAL - you can omit it or use any value.
|
||||||
|
|
||||||
Examples (all equivalent):
|
Examples (all equivalent):
|
||||||
- wordpress_list_posts(per_page=10)
|
- {ex1}
|
||||||
- wordpress_list_posts(site="{site_name}", per_page=10)
|
- {plugin_type}_list_posts(site="{site_name}", per_page=10)
|
||||||
- wordpress_list_posts(site="default", per_page=10)
|
- {plugin_type}_list_posts(site="default", per_page=10)
|
||||||
|
|
||||||
Just use the tools directly without asking which site to use.{openpanel_note}"""
|
Just use the tools directly without asking which site to use.{openpanel_note}"""
|
||||||
|
|
||||||
@@ -398,12 +447,18 @@ Just use the tools directly without asking which site to use.{openpanel_note}"""
|
|||||||
|
|
||||||
sites_text = "\n".join(site_list)
|
sites_text = "\n".join(site_list)
|
||||||
|
|
||||||
|
ex1, _ = _PLUGIN_EXAMPLE_TOOLS.get(
|
||||||
|
plugin_type, ("wordpress_list_posts(per_page=10)", "")
|
||||||
|
)
|
||||||
|
# Replace per_page with site param for multi-site example
|
||||||
|
multi_ex = ex1.replace("(", '(site="site1", ', 1) if "(" in ex1 else ex1
|
||||||
|
|
||||||
return f"""📋 MULTI-SITE MODE - {len(sites)} sites available:
|
return f"""📋 MULTI-SITE MODE - {len(sites)} sites available:
|
||||||
|
|
||||||
{sites_text}
|
{sites_text}
|
||||||
|
|
||||||
When using tools, pass the 'site' parameter with either the site_id or alias.
|
When using tools, pass the 'site' parameter with either the site_id or alias.
|
||||||
Example: wordpress_list_posts(site="site1", per_page=10)
|
Example: {multi_ex}
|
||||||
|
|
||||||
Use list_sites() to see all available sites."""
|
Use list_sites() to see all available sites."""
|
||||||
|
|
||||||
@@ -1025,16 +1080,15 @@ class RateLimitMiddleware(Middleware):
|
|||||||
if not allowed:
|
if not allowed:
|
||||||
# Log rejection via audit logger
|
# Log rejection via audit logger
|
||||||
try:
|
try:
|
||||||
audit_logger.log_event(
|
audit_logger.log_system_event(
|
||||||
event_type=EventType.SECURITY,
|
event=f"Rate limit exceeded for client {client_id[:8]}...",
|
||||||
level=LogLevel.WARNING,
|
details={
|
||||||
message=f"Rate limit exceeded for client {client_id[:8]}...",
|
|
||||||
metadata={
|
|
||||||
"tool_name": tool_name,
|
"tool_name": tool_name,
|
||||||
"plugin_type": plugin_type,
|
"plugin_type": plugin_type,
|
||||||
"reason": message,
|
"reason": message,
|
||||||
"retry_after_seconds": retry_after,
|
"retry_after_seconds": retry_after,
|
||||||
},
|
},
|
||||||
|
level=LogLevel.WARNING,
|
||||||
)
|
)
|
||||||
except Exception as log_error:
|
except Exception as log_error:
|
||||||
logger.error(f"Failed to log rate limit rejection: {log_error}")
|
logger.error(f"Failed to log rate limit rejection: {log_error}")
|
||||||
@@ -1160,6 +1214,61 @@ mcp.add_middleware(HealthMetricsMiddleware())
|
|||||||
logger.info("Health metrics middleware enabled")
|
logger.info("Health metrics middleware enabled")
|
||||||
|
|
||||||
|
|
||||||
|
# === USER AUTHENTICATION (E.2: OAuth Social Login) ===
|
||||||
|
|
||||||
|
try:
|
||||||
|
from core.user_auth import initialize_user_auth
|
||||||
|
|
||||||
|
initialize_user_auth()
|
||||||
|
logger.info("User authentication (OAuth Social Login) initialized")
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("User auth not initialized (OAuth not configured): %s", e)
|
||||||
|
|
||||||
|
|
||||||
|
# === ENCRYPTION KEY VALIDATION (E.1: Credential Encryption) ===
|
||||||
|
|
||||||
|
_encryption_key_raw = os.getenv("ENCRYPTION_KEY", "")
|
||||||
|
if _encryption_key_raw:
|
||||||
|
try:
|
||||||
|
import base64 as _b64
|
||||||
|
|
||||||
|
_key_bytes = _b64.b64decode(_encryption_key_raw)
|
||||||
|
if len(_key_bytes) != 32:
|
||||||
|
logger.critical(
|
||||||
|
"FATAL: ENCRYPTION_KEY must decode to exactly 32 bytes, got %d. "
|
||||||
|
'Generate a valid key with: python -c "import os,base64; '
|
||||||
|
'print(base64.b64encode(os.urandom(32)).decode())"',
|
||||||
|
len(_key_bytes),
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
from core.encryption import initialize_credential_encryption
|
||||||
|
|
||||||
|
initialize_credential_encryption(_encryption_key_raw)
|
||||||
|
logger.info("Credential encryption initialized")
|
||||||
|
except Exception as e:
|
||||||
|
logger.critical(
|
||||||
|
"FATAL: Invalid ENCRYPTION_KEY: %s. "
|
||||||
|
'Generate a valid key with: python -c "import os,base64; '
|
||||||
|
'print(base64.b64encode(os.urandom(32)).decode())"',
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
logger.info("ENCRYPTION_KEY not set — user site management disabled")
|
||||||
|
|
||||||
|
|
||||||
|
# === USER API KEY MANAGER (E.3: Site Management) ===
|
||||||
|
|
||||||
|
try:
|
||||||
|
from core.user_keys import initialize_user_key_manager
|
||||||
|
|
||||||
|
initialize_user_key_manager()
|
||||||
|
logger.info("User API Key Manager initialized")
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("User API Key Manager not initialized: %s", e)
|
||||||
|
|
||||||
|
|
||||||
# === ENDPOINT MIDDLEWARE HELPER ===
|
# === ENDPOINT MIDDLEWARE HELPER ===
|
||||||
|
|
||||||
|
|
||||||
@@ -2116,8 +2225,8 @@ async def oauth_register(request: Request) -> JSONResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Audit log for open DCR
|
# Audit log for open DCR
|
||||||
audit_logger.log_event(
|
audit_logger.log_system_event(
|
||||||
event_type="oauth_dcr_open",
|
event="oauth_dcr_open",
|
||||||
details={
|
details={
|
||||||
"client_ip": client_ip,
|
"client_ip": client_ip,
|
||||||
"redirect_uris": redirect_uris,
|
"redirect_uris": redirect_uris,
|
||||||
@@ -2133,8 +2242,8 @@ async def oauth_register(request: Request) -> JSONResponse:
|
|||||||
logger.info(f"Protected DCR registration from {client_ip} with Master API Key")
|
logger.info(f"Protected DCR registration from {client_ip} with Master API Key")
|
||||||
|
|
||||||
# Audit log for protected DCR
|
# Audit log for protected DCR
|
||||||
audit_logger.log_event(
|
audit_logger.log_system_event(
|
||||||
event_type="oauth_dcr_protected",
|
event="oauth_dcr_protected",
|
||||||
details={
|
details={
|
||||||
"client_ip": client_ip,
|
"client_ip": client_ip,
|
||||||
"redirect_uris": redirect_uris,
|
"redirect_uris": redirect_uris,
|
||||||
@@ -2980,8 +3089,8 @@ async def _get_system_info_impl() -> dict:
|
|||||||
"success": True,
|
"success": True,
|
||||||
"server": {
|
"server": {
|
||||||
"name": "MCP Hub",
|
"name": "MCP Hub",
|
||||||
"version": "v2.6.0",
|
"version": SERVER_VERSION,
|
||||||
"phase": "X.3 (System Endpoint)",
|
"endpoint_type": "system",
|
||||||
},
|
},
|
||||||
"uptime": {
|
"uptime": {
|
||||||
"seconds": uptime_seconds,
|
"seconds": uptime_seconds,
|
||||||
@@ -4200,11 +4309,7 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def _is_valid_token(token: str) -> bool:
|
def _is_valid_token(token: str) -> bool:
|
||||||
"""Check if a Bearer token is recognized by any auth backend."""
|
"""Check if a Bearer token is recognized by any auth backend."""
|
||||||
# 1. Master API key
|
# 1. Per-project API key (cheap prefix check)
|
||||||
if auth_manager.validate_master_key(token):
|
|
||||||
return True
|
|
||||||
|
|
||||||
# 2. Per-project API key (any valid key, skip project/scope check)
|
|
||||||
if token.startswith("cmp_"):
|
if token.startswith("cmp_"):
|
||||||
key_hash = api_key_manager._hash_key(token)
|
key_hash = api_key_manager._hash_key(token)
|
||||||
for key in api_key_manager.keys.values():
|
for key in api_key_manager.keys.values():
|
||||||
@@ -4212,6 +4317,10 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# 2. User API key (prefix-matched, full validation in endpoint)
|
||||||
|
if token.startswith("mhu_"):
|
||||||
|
return True
|
||||||
|
|
||||||
# 3. OAuth JWT token
|
# 3. OAuth JWT token
|
||||||
try:
|
try:
|
||||||
from core.oauth import get_token_manager
|
from core.oauth import get_token_manager
|
||||||
@@ -4222,7 +4331,84 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return False
|
# 4. Master API key (fallback — no distinguishing prefix)
|
||||||
|
return bool(auth_manager.validate_master_key(token))
|
||||||
|
|
||||||
|
class DashboardCSRFMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""
|
||||||
|
Double-Submit Cookie CSRF protection for Dashboard APIs.
|
||||||
|
Generates a stateless CSRF cookie and validates against the X-CSRF-Token header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next):
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from starlette.responses import JSONResponse
|
||||||
|
|
||||||
|
csrf_cookie = request.cookies.get("dashboard_csrf")
|
||||||
|
is_new_cookie = False
|
||||||
|
if not csrf_cookie:
|
||||||
|
csrf_cookie = secrets.token_urlsafe(32)
|
||||||
|
is_new_cookie = True
|
||||||
|
|
||||||
|
request.state.csrf_token = csrf_cookie
|
||||||
|
|
||||||
|
if request.method in ["POST", "PUT", "DELETE", "PATCH"]:
|
||||||
|
path = request.url.path
|
||||||
|
|
||||||
|
# Protect dashboard mutating endpoints
|
||||||
|
is_protected = (
|
||||||
|
path.startswith("/api/dashboard/")
|
||||||
|
or path.startswith("/api/sites")
|
||||||
|
or path.startswith("/api/keys")
|
||||||
|
or path == "/dashboard/login"
|
||||||
|
)
|
||||||
|
|
||||||
|
# API Requests using Bearer tokens are exempt from CSRF
|
||||||
|
auth_header = request.headers.get("authorization", "")
|
||||||
|
is_bearer = auth_header.startswith("Bearer ")
|
||||||
|
|
||||||
|
if is_protected and not is_bearer:
|
||||||
|
provided_token = request.headers.get("x-csrf-token")
|
||||||
|
|
||||||
|
# For form submissions — use raw body parsing to avoid
|
||||||
|
# consuming the body stream (BaseHTTPMiddleware bug:
|
||||||
|
# request.form() in middleware makes body empty for route handler)
|
||||||
|
if not provided_token and request.headers.get("content-type", "").startswith(
|
||||||
|
"application/x-www-form-urlencoded"
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
|
||||||
|
body = await request.body()
|
||||||
|
params = parse_qs(body.decode("utf-8", errors="replace"))
|
||||||
|
csrf_values = params.get("csrf_token", [])
|
||||||
|
if csrf_values:
|
||||||
|
provided_token = csrf_values[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not provided_token or provided_token != csrf_cookie:
|
||||||
|
logger.warning(f"CSRF validation failed for {path}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"error": "CSRF validation failed. Please refresh the page."},
|
||||||
|
status_code=403,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await call_next(request)
|
||||||
|
|
||||||
|
if is_new_cookie:
|
||||||
|
response.set_cookie(
|
||||||
|
"dashboard_csrf",
|
||||||
|
csrf_cookie,
|
||||||
|
max_age=86400,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=os.environ.get("DASHBOARD_SECURE_COOKIE", "true").lower() == "true",
|
||||||
|
)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
# Create MCP instances
|
# Create MCP instances
|
||||||
system_mcp = create_system_mcp() # Phase X.3 - System endpoint
|
system_mcp = create_system_mcp() # Phase X.3 - System endpoint
|
||||||
@@ -4320,7 +4506,16 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
# This is REQUIRED for FastMCP's StreamableHTTPSessionManager task group
|
# This is REQUIRED for FastMCP's StreamableHTTPSessionManager task group
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def combined_lifespan(app):
|
async def combined_lifespan(app):
|
||||||
"""Combine lifespans from all FastMCP http apps"""
|
"""Combine lifespans from all FastMCP http apps and initialize database"""
|
||||||
|
|
||||||
|
# Initialize database first
|
||||||
|
try:
|
||||||
|
await initialize_database()
|
||||||
|
logger.info("Database initialized successfully")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize database: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
active_contexts = []
|
active_contexts = []
|
||||||
|
|
||||||
# Enter each sub-app's lifespan context
|
# Enter each sub-app's lifespan context
|
||||||
@@ -4343,9 +4538,20 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to start router lifespan for {name}: {e}")
|
logger.warning(f"Failed to start router lifespan for {name}: {e}")
|
||||||
|
|
||||||
|
# Start health monitor background checks
|
||||||
|
from core.health import get_health_monitor
|
||||||
|
|
||||||
|
hm = get_health_monitor()
|
||||||
|
if hm:
|
||||||
|
await hm.start_background_checks(interval_seconds=60)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
# Stop health monitor background checks
|
||||||
|
if hm:
|
||||||
|
await hm.stop_background_checks()
|
||||||
|
|
||||||
# Exit all contexts in reverse order
|
# Exit all contexts in reverse order
|
||||||
for name, ctx in reversed(active_contexts):
|
for name, ctx in reversed(active_contexts):
|
||||||
try:
|
try:
|
||||||
@@ -4354,9 +4560,17 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Error during lifespan cleanup for {name}: {e}")
|
logger.warning(f"Error during lifespan cleanup for {name}: {e}")
|
||||||
|
|
||||||
# Root redirect: / → /dashboard (so users don't see 404)
|
# Close database connection
|
||||||
|
try:
|
||||||
|
db = get_database()
|
||||||
|
await db.close()
|
||||||
|
logger.info("Database connection closed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Error closing database: {e}")
|
||||||
|
|
||||||
|
# Root redirect: / → /auth/login (Live Platform landing)
|
||||||
async def root_redirect(request):
|
async def root_redirect(request):
|
||||||
return RedirectResponse(url="/dashboard", status_code=302)
|
return RedirectResponse(url="/auth/login", status_code=302)
|
||||||
|
|
||||||
# Build routes
|
# Build routes
|
||||||
# Note: Order matters! More specific routes first
|
# Note: Order matters! More specific routes first
|
||||||
@@ -4365,10 +4579,19 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
Route("/health", health_check, methods=["GET"]),
|
Route("/health", health_check, methods=["GET"]),
|
||||||
# Root redirect
|
# Root redirect
|
||||||
Route("/", root_redirect, methods=["GET"]),
|
Route("/", root_redirect, methods=["GET"]),
|
||||||
|
# Auth routes (E.2: OAuth Social Login)
|
||||||
|
Route("/auth/login", auth_login_page, methods=["GET"]),
|
||||||
|
Route("/auth/callback/{provider}", auth_callback, methods=["GET"]),
|
||||||
|
Route("/auth/logout", auth_logout, methods=["GET", "POST"]),
|
||||||
|
Route("/auth/{provider}", auth_provider_redirect, methods=["GET"]),
|
||||||
# Dashboard routes
|
# Dashboard routes
|
||||||
Route("/dashboard/login", dashboard_login_page, methods=["GET"]),
|
Route("/dashboard/login", dashboard_login_page, methods=["GET"]),
|
||||||
Route("/dashboard/login", dashboard_login_submit, methods=["POST"]),
|
Route("/dashboard/login", dashboard_login_submit, methods=["POST"]),
|
||||||
Route("/dashboard/logout", dashboard_logout, methods=["GET", "POST"]),
|
Route("/dashboard/logout", dashboard_logout, methods=["GET", "POST"]),
|
||||||
|
Route("/dashboard/profile", dashboard_profile_page, methods=["GET"]),
|
||||||
|
Route("/dashboard/sites/add", dashboard_sites_add, methods=["GET"]),
|
||||||
|
Route("/dashboard/sites", dashboard_sites_list, methods=["GET"]),
|
||||||
|
Route("/dashboard/connect", dashboard_connect_page, methods=["GET"]),
|
||||||
Route("/dashboard", dashboard_home, methods=["GET"]),
|
Route("/dashboard", dashboard_home, methods=["GET"]),
|
||||||
Route("/dashboard/", dashboard_home, methods=["GET"]),
|
Route("/dashboard/", dashboard_home, methods=["GET"]),
|
||||||
Route("/api/dashboard/stats", dashboard_api_stats, methods=["GET"]),
|
Route("/api/dashboard/stats", dashboard_api_stats, methods=["GET"]),
|
||||||
@@ -4413,6 +4636,17 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
Route("/api/dashboard/health/projects", dashboard_health_projects_partial, methods=["GET"]),
|
Route("/api/dashboard/health/projects", dashboard_health_projects_partial, methods=["GET"]),
|
||||||
# Dashboard Settings routes (Phase K.5)
|
# Dashboard Settings routes (Phase K.5)
|
||||||
Route("/dashboard/settings", dashboard_settings_page, methods=["GET"]),
|
Route("/dashboard/settings", dashboard_settings_page, methods=["GET"]),
|
||||||
|
# Site Management API (E.3)
|
||||||
|
Route("/api/sites", api_list_sites, methods=["GET"]),
|
||||||
|
Route("/api/sites", api_create_site, methods=["POST"]),
|
||||||
|
Route("/api/sites/{id}/test", api_test_site, methods=["POST"]),
|
||||||
|
Route("/api/sites/{id}", api_delete_site, methods=["DELETE"]),
|
||||||
|
# User API Key routes (E.3)
|
||||||
|
Route("/api/keys", api_list_keys, methods=["GET"]),
|
||||||
|
Route("/api/keys", api_create_key, methods=["POST"]),
|
||||||
|
Route("/api/keys/{id}", api_delete_key, methods=["DELETE"]),
|
||||||
|
# Config snippet API (E.3)
|
||||||
|
Route("/api/config/{alias}", api_get_config, methods=["GET"]),
|
||||||
# OAuth endpoints
|
# OAuth endpoints
|
||||||
Route("/.well-known/oauth-authorization-server", oauth_metadata, methods=["GET"]),
|
Route("/.well-known/oauth-authorization-server", oauth_metadata, methods=["GET"]),
|
||||||
# Path-specific OAuth protected resource metadata (must come before root)
|
# Path-specific OAuth protected resource metadata (must come before root)
|
||||||
@@ -4446,6 +4680,24 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
safe_name = project_id.replace("-", "_").replace(".", "_")
|
safe_name = project_id.replace("-", "_").replace(".", "_")
|
||||||
routes.append(Mount(mount_path, app=project_app, name=f"mcp_project_{safe_name}"))
|
routes.append(Mount(mount_path, app=project_app, name=f"mcp_project_{safe_name}"))
|
||||||
|
|
||||||
|
# Per-user MCP endpoints (E.3: Site Management)
|
||||||
|
try:
|
||||||
|
from starlette.routing import Route
|
||||||
|
|
||||||
|
from core.user_endpoints import user_mcp_handler
|
||||||
|
|
||||||
|
routes.append(
|
||||||
|
Route(
|
||||||
|
"/u/{user_id}/{alias}/mcp",
|
||||||
|
endpoint=user_mcp_handler,
|
||||||
|
methods=["POST"],
|
||||||
|
name="user_mcp_endpoint",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info("Per-user MCP endpoint registered at /u/{user_id}/{alias}/mcp")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Per-user MCP endpoint not registered: %s", e)
|
||||||
|
|
||||||
# Static files (favicon, logo)
|
# Static files (favicon, logo)
|
||||||
_static_dir = os.path.join(os.path.dirname(__file__), "core", "templates", "static")
|
_static_dir = os.path.join(os.path.dirname(__file__), "core", "templates", "static")
|
||||||
if os.path.isdir(_static_dir):
|
if os.path.isdir(_static_dir):
|
||||||
@@ -4453,15 +4705,38 @@ def create_multi_endpoint_app(transport: str = "streamable-http"):
|
|||||||
|
|
||||||
routes.append(Mount("/static", app=StaticFiles(directory=_static_dir), name="static"))
|
routes.append(Mount("/static", app=StaticFiles(directory=_static_dir), name="static"))
|
||||||
|
|
||||||
# Main admin endpoint (must be last - catches all remaining routes)
|
# Catch-all for unmatched GET requests — serves styled 404 page
|
||||||
|
async def catch_all_404(request):
|
||||||
|
_404_path = os.path.join(
|
||||||
|
os.path.dirname(__file__), "core", "templates", "dashboard", "404.html"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with open(_404_path, encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
from starlette.responses import HTMLResponse
|
||||||
|
|
||||||
|
return HTMLResponse(content, status_code=404)
|
||||||
|
except Exception:
|
||||||
|
from starlette.responses import PlainTextResponse
|
||||||
|
|
||||||
|
return PlainTextResponse("404 Not Found", status_code=404)
|
||||||
|
|
||||||
|
routes.append(Route("/{path:path}", catch_all_404, methods=["GET"]))
|
||||||
|
|
||||||
|
# Main admin endpoint (must be last - catches all remaining non-GET routes)
|
||||||
routes.append(Mount("/", app=main_app, name="mcp_admin"))
|
routes.append(Mount("/", app=main_app, name="mcp_admin"))
|
||||||
|
|
||||||
# Add OAuth middleware that returns 401 + WWW-Authenticate for MCP endpoints
|
# Add middlewares
|
||||||
middleware = [
|
middleware = [
|
||||||
StarletteMiddleware(OAuthRequiredMiddleware),
|
StarletteMiddleware(OAuthRequiredMiddleware),
|
||||||
|
StarletteMiddleware(DashboardCSRFMiddleware),
|
||||||
]
|
]
|
||||||
|
|
||||||
app = Starlette(routes=routes, lifespan=combined_lifespan, middleware=middleware)
|
app = Starlette(
|
||||||
|
routes=routes,
|
||||||
|
lifespan=combined_lifespan,
|
||||||
|
middleware=middleware,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("=" * 60)
|
logger.info("=" * 60)
|
||||||
logger.info("Multi-Endpoint Architecture Active")
|
logger.info("Multi-Endpoint Architecture Active")
|
||||||
|
|||||||
64
smithery.yaml
Normal file
64
smithery.yaml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
# Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml
|
||||||
|
|
||||||
|
startCommand:
|
||||||
|
type: stdio
|
||||||
|
configSchema:
|
||||||
|
# JSON Schema defining the configuration options for the MCP.
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- masterApiKey
|
||||||
|
properties:
|
||||||
|
masterApiKey:
|
||||||
|
type: string
|
||||||
|
description: Master API key for authentication. Generate with python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||||
|
wordpressSite1Url:
|
||||||
|
type: string
|
||||||
|
description: WordPress site URL (e.g., https://example.com)
|
||||||
|
wordpressSite1Username:
|
||||||
|
type: string
|
||||||
|
description: WordPress admin username
|
||||||
|
wordpressSite1AppPassword:
|
||||||
|
type: string
|
||||||
|
description: WordPress Application Password (generate in WP admin > Users > Profile > Application Passwords)
|
||||||
|
giteaSite1Url:
|
||||||
|
type: string
|
||||||
|
description: Gitea instance URL (e.g., https://gitea.example.com)
|
||||||
|
giteaSite1Token:
|
||||||
|
type: string
|
||||||
|
description: Gitea API token
|
||||||
|
n8nSite1Url:
|
||||||
|
type: string
|
||||||
|
description: n8n instance URL (e.g., https://n8n.example.com)
|
||||||
|
n8nSite1ApiKey:
|
||||||
|
type: string
|
||||||
|
description: n8n API key
|
||||||
|
supabaseSite1Url:
|
||||||
|
type: string
|
||||||
|
description: Supabase project URL
|
||||||
|
supabaseSite1ServiceRoleKey:
|
||||||
|
type: string
|
||||||
|
description: Supabase service role key
|
||||||
|
commandFunction:
|
||||||
|
# A function that produces the CLI command to start the MCP on stdio.
|
||||||
|
|-
|
||||||
|
(config) => {
|
||||||
|
const env = { MASTER_API_KEY: config.masterApiKey };
|
||||||
|
if (config.wordpressSite1Url) {
|
||||||
|
env.WORDPRESS_SITE1_URL = config.wordpressSite1Url;
|
||||||
|
env.WORDPRESS_SITE1_USERNAME = config.wordpressSite1Username || '';
|
||||||
|
env.WORDPRESS_SITE1_APP_PASSWORD = config.wordpressSite1AppPassword || '';
|
||||||
|
}
|
||||||
|
if (config.giteaSite1Url) {
|
||||||
|
env.GITEA_SITE1_URL = config.giteaSite1Url;
|
||||||
|
env.GITEA_SITE1_TOKEN = config.giteaSite1Token || '';
|
||||||
|
}
|
||||||
|
if (config.n8nSite1Url) {
|
||||||
|
env.N8N_SITE1_URL = config.n8nSite1Url;
|
||||||
|
env.N8N_SITE1_API_KEY = config.n8nSite1ApiKey || '';
|
||||||
|
}
|
||||||
|
if (config.supabaseSite1Url) {
|
||||||
|
env.SUPABASE_SITE1_URL = config.supabaseSite1Url;
|
||||||
|
env.SUPABASE_SITE1_SERVICE_ROLE_KEY = config.supabaseSite1ServiceRoleKey || '';
|
||||||
|
}
|
||||||
|
return { command: 'uvx', args: ['mcphub-server'], env };
|
||||||
|
}
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Quick test to verify per-project API key isolation.
|
|
||||||
|
|
||||||
This test checks that:
|
|
||||||
1. Per-project API key can access its own project
|
|
||||||
2. Per-project API key CANNOT access other projects
|
|
||||||
3. Global API key can access all projects
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Mock setup
|
|
||||||
os.environ["MASTER_API_KEY"] = "test_master_key_123"
|
|
||||||
os.environ["WORDPRESS_SITE1_URL"] = "https://site1.example.com"
|
|
||||||
os.environ["WORDPRESS_SITE1_USERNAME"] = "admin"
|
|
||||||
os.environ["WORDPRESS_SITE1_APP_PASSWORD"] = "password1"
|
|
||||||
os.environ["WORDPRESS_SITE4_URL"] = "https://site4.example.com"
|
|
||||||
os.environ["WORDPRESS_SITE4_USERNAME"] = "admin"
|
|
||||||
os.environ["WORDPRESS_SITE4_APP_PASSWORD"] = "password4"
|
|
||||||
|
|
||||||
# Import after env setup
|
|
||||||
from core.api_keys import get_api_key_manager
|
|
||||||
from core.project_manager import get_project_manager
|
|
||||||
from core.site_registry import get_site_registry
|
|
||||||
from core.unified_tools import UnifiedToolGenerator
|
|
||||||
|
|
||||||
print("=" * 60)
|
|
||||||
print("Testing Per-Project API Key Isolation")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Initialize
|
|
||||||
api_key_manager = get_api_key_manager()
|
|
||||||
project_manager = get_project_manager()
|
|
||||||
site_registry = get_site_registry()
|
|
||||||
|
|
||||||
# Discover sites
|
|
||||||
from plugins import registry as plugin_registry
|
|
||||||
|
|
||||||
plugin_types = plugin_registry.get_registered_types()
|
|
||||||
site_registry.discover_sites(plugin_types)
|
|
||||||
|
|
||||||
print(f"\nDiscovered sites: {list(site_registry.sites.keys())}")
|
|
||||||
|
|
||||||
# Create API keys
|
|
||||||
print("\n1. Creating API keys...")
|
|
||||||
|
|
||||||
# Global key
|
|
||||||
global_key = api_key_manager.create_key(
|
|
||||||
project_id="*", scope="admin", description="Global test key"
|
|
||||||
)
|
|
||||||
print(f" ✓ Global key created: {global_key}")
|
|
||||||
|
|
||||||
# Per-project key for wordpress_site4
|
|
||||||
site4_key = api_key_manager.create_key(
|
|
||||||
project_id="wordpress_site4", scope="admin", description="Site4 only key"
|
|
||||||
)
|
|
||||||
print(f" ✓ Site4 key created: {site4_key}")
|
|
||||||
|
|
||||||
# Create unified tool generator
|
|
||||||
unified_gen = UnifiedToolGenerator(project_manager)
|
|
||||||
unified_tools = unified_gen.generate_all_unified_tools()
|
|
||||||
print(f"\n2. Generated {len(unified_tools)} unified tools")
|
|
||||||
|
|
||||||
# Find wordpress_list_posts tool
|
|
||||||
list_posts_tool = None
|
|
||||||
for tool in unified_tools:
|
|
||||||
if tool["name"] == "wordpress_list_posts":
|
|
||||||
list_posts_tool = tool
|
|
||||||
break
|
|
||||||
|
|
||||||
if not list_posts_tool:
|
|
||||||
print(" ✗ wordpress_list_posts tool not found!")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
print(" ✓ Found wordpress_list_posts tool")
|
|
||||||
|
|
||||||
|
|
||||||
async def test_access(key_token, site_id, expected_result):
|
|
||||||
"""Test if a key can access a site"""
|
|
||||||
from server import _api_key_context
|
|
||||||
|
|
||||||
# Validate key and set context (simulating middleware)
|
|
||||||
key_id = api_key_manager.validate_key(
|
|
||||||
key_token, project_id="*", required_scope="read", skip_project_check=True
|
|
||||||
)
|
|
||||||
|
|
||||||
if key_id:
|
|
||||||
key = api_key_manager.keys.get(key_id)
|
|
||||||
_api_key_context.set(
|
|
||||||
{
|
|
||||||
"key_id": key_id,
|
|
||||||
"project_id": key.project_id,
|
|
||||||
"scope": key.scope,
|
|
||||||
"is_global": key.project_id == "*",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Try to call the handler
|
|
||||||
handler = list_posts_tool["handler"]
|
|
||||||
result = await handler(site=site_id, per_page=1)
|
|
||||||
|
|
||||||
# Check result
|
|
||||||
is_error = isinstance(result, str) and result.startswith("Error: Access denied")
|
|
||||||
|
|
||||||
if expected_result == "allowed":
|
|
||||||
if not is_error:
|
|
||||||
print(" ✓ Access allowed as expected")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(" ✗ FAIL: Access denied but should be allowed!")
|
|
||||||
print(f" Result: {result[:100]}")
|
|
||||||
return False
|
|
||||||
else: # expected_result == "denied"
|
|
||||||
if is_error:
|
|
||||||
print(" ✓ Access denied as expected")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
print(" ✗ FAIL: Access allowed but should be denied!")
|
|
||||||
print(f" Result: {result[:100] if isinstance(result, str) else str(result)[:100]}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
async def run_tests():
|
|
||||||
"""Run all test cases"""
|
|
||||||
print("\n3. Testing access control...")
|
|
||||||
|
|
||||||
all_pass = True
|
|
||||||
|
|
||||||
# Test 1: Per-project key accessing its own project
|
|
||||||
print("\n Test 1: Per-project key (site4) → site4")
|
|
||||||
all_pass &= await test_access(site4_key, "site4", "allowed")
|
|
||||||
|
|
||||||
# Test 2: Per-project key accessing different project
|
|
||||||
print("\n Test 2: Per-project key (site4) → site1")
|
|
||||||
all_pass &= await test_access(site4_key, "site1", "denied")
|
|
||||||
|
|
||||||
# Test 3: Global key accessing any project
|
|
||||||
print("\n Test 3: Global key → site1")
|
|
||||||
all_pass &= await test_access(global_key, "site1", "allowed")
|
|
||||||
|
|
||||||
print("\n Test 4: Global key → site4")
|
|
||||||
all_pass &= await test_access(global_key, "site4", "allowed")
|
|
||||||
|
|
||||||
return all_pass
|
|
||||||
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
result = asyncio.run(run_tests())
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
if result:
|
|
||||||
print("✅ All tests PASSED!")
|
|
||||||
print("Per-project API key isolation is working correctly!")
|
|
||||||
else:
|
|
||||||
print("❌ Some tests FAILED!")
|
|
||||||
print("Per-project API key isolation has issues!")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
exit(0 if result else 1)
|
|
||||||
@@ -100,8 +100,6 @@ class IntegrationTester:
|
|||||||
# Check handlers initialized
|
# Check handlers initialized
|
||||||
assert hasattr(plugin, "posts"), "Missing posts handler"
|
assert hasattr(plugin, "posts"), "Missing posts handler"
|
||||||
assert hasattr(plugin, "media"), "Missing media handler"
|
assert hasattr(plugin, "media"), "Missing media handler"
|
||||||
assert hasattr(plugin, "products"), "Missing products handler"
|
|
||||||
assert hasattr(plugin, "orders"), "Missing orders handler"
|
|
||||||
|
|
||||||
self.add_result(
|
self.add_result(
|
||||||
"wordpress_plugin_init", "passed", "WordPress plugin initializes correctly"
|
"wordpress_plugin_init", "passed", "WordPress plugin initializes correctly"
|
||||||
@@ -158,11 +156,6 @@ class IntegrationTester:
|
|||||||
"CommentsHandler",
|
"CommentsHandler",
|
||||||
"UsersHandler",
|
"UsersHandler",
|
||||||
"SiteHandler",
|
"SiteHandler",
|
||||||
"ProductsHandler",
|
|
||||||
"OrdersHandler",
|
|
||||||
"CustomersHandler",
|
|
||||||
"ReportsHandler",
|
|
||||||
"CouponsHandler",
|
|
||||||
"SEOHandler",
|
"SEOHandler",
|
||||||
"WPCLIHandler",
|
"WPCLIHandler",
|
||||||
"MenusHandler",
|
"MenusHandler",
|
||||||
|
|||||||
154
tests/test_config_snippets.py
Normal file
154
tests/test_config_snippets.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""Tests for MCP client configuration snippet generation (core/config_snippets.py)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.config_snippets import generate_config, get_supported_clients
|
||||||
|
|
||||||
|
# ── Test Data ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
BASE_URL = "https://mcp.example.com"
|
||||||
|
USER_ID = "abc123-uuid"
|
||||||
|
ALIAS = "myblog"
|
||||||
|
API_KEY = "mhu_testapikey1234567890abcdefghijklmnopqr"
|
||||||
|
EXPECTED_ENDPOINT = f"{BASE_URL}/u/{USER_ID}/{ALIAS}/mcp"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Supported Clients ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSupportedClients:
|
||||||
|
"""Test get_supported_clients function."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_get_supported_clients(self):
|
||||||
|
"""Should return exactly 5 supported client types."""
|
||||||
|
clients = get_supported_clients()
|
||||||
|
assert len(clients) == 5
|
||||||
|
client_ids = [c["id"] for c in clients]
|
||||||
|
assert "claude_desktop" in client_ids
|
||||||
|
assert "claude_code" in client_ids
|
||||||
|
assert "cursor" in client_ids
|
||||||
|
assert "vscode" in client_ids
|
||||||
|
assert "chatgpt" in client_ids
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_supported_clients_have_labels(self):
|
||||||
|
"""Each client should have id, label, and description."""
|
||||||
|
for client in get_supported_clients():
|
||||||
|
assert "id" in client
|
||||||
|
assert "label" in client
|
||||||
|
assert "description" in client
|
||||||
|
assert len(client["label"]) > 0
|
||||||
|
assert len(client["description"]) > 0
|
||||||
|
|
||||||
|
|
||||||
|
# ── Claude Desktop / Claude Code Format ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestClaudeFormat:
|
||||||
|
"""Test Claude Desktop and Claude Code config generation."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_claude_desktop_format(self):
|
||||||
|
"""Claude Desktop config should be valid JSON with mcpServers."""
|
||||||
|
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "claude_desktop")
|
||||||
|
config = json.loads(snippet)
|
||||||
|
assert "mcpServers" in config
|
||||||
|
server_name = f"mcphub-{ALIAS}"
|
||||||
|
assert server_name in config["mcpServers"]
|
||||||
|
server = config["mcpServers"][server_name]
|
||||||
|
assert server["url"] == EXPECTED_ENDPOINT
|
||||||
|
assert f"Bearer {API_KEY}" in server["headers"]["Authorization"]
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_claude_code_format(self):
|
||||||
|
"""Claude Code config should use the same mcpServers format."""
|
||||||
|
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "claude_code")
|
||||||
|
config = json.loads(snippet)
|
||||||
|
assert "mcpServers" in config
|
||||||
|
server_name = f"mcphub-{ALIAS}"
|
||||||
|
assert server_name in config["mcpServers"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cursor / VS Code Format ─────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestCursorVSCodeFormat:
|
||||||
|
"""Test Cursor and VS Code config generation."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_cursor_format(self):
|
||||||
|
"""Cursor config should be valid JSON with mcp.servers."""
|
||||||
|
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "cursor")
|
||||||
|
config = json.loads(snippet)
|
||||||
|
assert "mcp" in config
|
||||||
|
assert "servers" in config["mcp"]
|
||||||
|
server_name = f"mcphub-{ALIAS}"
|
||||||
|
assert server_name in config["mcp"]["servers"]
|
||||||
|
server = config["mcp"]["servers"][server_name]
|
||||||
|
assert server["url"] == EXPECTED_ENDPOINT
|
||||||
|
assert f"Bearer {API_KEY}" in server["headers"]["Authorization"]
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_vscode_format(self):
|
||||||
|
"""VS Code config should use the same mcp.servers format as Cursor."""
|
||||||
|
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "vscode")
|
||||||
|
config = json.loads(snippet)
|
||||||
|
assert "mcp" in config
|
||||||
|
assert "servers" in config["mcp"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── ChatGPT Format ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestChatGPTFormat:
|
||||||
|
"""Test ChatGPT config generation."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_chatgpt_format(self):
|
||||||
|
"""ChatGPT config should return the raw endpoint URL only."""
|
||||||
|
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "chatgpt")
|
||||||
|
assert snippet == EXPECTED_ENDPOINT
|
||||||
|
# Should NOT be JSON
|
||||||
|
with pytest.raises(json.JSONDecodeError):
|
||||||
|
json.loads(snippet)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Error Handling ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorHandling:
|
||||||
|
"""Test error conditions."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_invalid_client_type(self):
|
||||||
|
"""Unsupported client type should raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Unsupported client type"):
|
||||||
|
generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, "unknown_client")
|
||||||
|
|
||||||
|
|
||||||
|
# ── URL Correctness ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestURLCorrectness:
|
||||||
|
"""Test that generated configs contain the correct endpoint URL."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_config_contains_correct_url(self):
|
||||||
|
"""All config formats should contain /u/{user_id}/{alias}/mcp."""
|
||||||
|
for client_type in ("claude_desktop", "claude_code", "cursor", "vscode", "chatgpt"):
|
||||||
|
snippet = generate_config(BASE_URL, USER_ID, ALIAS, API_KEY, client_type)
|
||||||
|
assert (
|
||||||
|
f"/u/{USER_ID}/{ALIAS}/mcp" in snippet
|
||||||
|
), f"{client_type} config missing expected URL path"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_trailing_slash_stripped(self):
|
||||||
|
"""Base URL with trailing slash should not produce double slashes."""
|
||||||
|
snippet = generate_config(
|
||||||
|
"https://mcp.example.com/", USER_ID, ALIAS, API_KEY, "claude_desktop"
|
||||||
|
)
|
||||||
|
assert "//u/" not in snippet
|
||||||
|
assert f"/u/{USER_ID}/{ALIAS}/mcp" in snippet
|
||||||
@@ -371,3 +371,40 @@ class TestDashboardCookieManagement:
|
|||||||
break
|
break
|
||||||
assert cookie_header is not None
|
assert cookie_header is not None
|
||||||
assert "mcp_dashboard_session=" in cookie_header
|
assert "mcp_dashboard_session=" in cookie_header
|
||||||
|
|
||||||
|
|
||||||
|
def test_dashboard_connect_page(monkeypatch):
|
||||||
|
"""Test that the /dashboard/connect page renders successfully without 500 errors."""
|
||||||
|
from server import create_multi_endpoint_app
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import core.dashboard.routes
|
||||||
|
import core.site_api
|
||||||
|
import core.user_keys
|
||||||
|
|
||||||
|
app = create_multi_endpoint_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
def mock_req(*args):
|
||||||
|
return {"user_id": "abc", "type": "user"}, None
|
||||||
|
|
||||||
|
monkeypatch.setattr(core.dashboard.routes, "_require_user_session", mock_req)
|
||||||
|
|
||||||
|
async def mock_sites(*args):
|
||||||
|
return [{"alias": "Test", "plugin_type": "dummy"}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(core.site_api, "get_user_sites", mock_sites)
|
||||||
|
|
||||||
|
class MockKeyMgr:
|
||||||
|
async def list_keys(self, *a):
|
||||||
|
return [
|
||||||
|
{"id": "1", "name": "Key", "key_prefix": "prefix", "scopes": "all", "use_count": 0}
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(core.user_keys, "get_user_key_manager", lambda: MockKeyMgr())
|
||||||
|
|
||||||
|
resp = client.get("/dashboard/connect")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "Test" in resp.text
|
||||||
|
assert "Key" in resp.text
|
||||||
|
|||||||
577
tests/test_database.py
Normal file
577
tests/test_database.py
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
"""Tests for SQLite database backend (core/database.py)."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import aiosqlite
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.database import SCHEMA_VERSION, Database, get_database, initialize_database
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def db(tmp_path):
|
||||||
|
"""Provide an initialized Database using a temp directory."""
|
||||||
|
db_path = str(tmp_path / "test.db")
|
||||||
|
database = Database(db_path)
|
||||||
|
await database.initialize()
|
||||||
|
yield database
|
||||||
|
await database.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def user_row(db):
|
||||||
|
"""Create and return a sample user dict."""
|
||||||
|
return await db.create_user(
|
||||||
|
email="alice@example.com",
|
||||||
|
name="Alice",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-111",
|
||||||
|
avatar_url="https://example.com/alice.png",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def second_user(db):
|
||||||
|
"""Create and return a second sample user dict."""
|
||||||
|
return await db.create_user(
|
||||||
|
email="bob@example.com",
|
||||||
|
name="Bob",
|
||||||
|
provider="google",
|
||||||
|
provider_id="gg-222",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def site_row(db, user_row):
|
||||||
|
"""Create and return a sample site dict."""
|
||||||
|
return await db.create_site(
|
||||||
|
user_id=user_row["id"],
|
||||||
|
plugin_type="wordpress",
|
||||||
|
alias="myblog",
|
||||||
|
url="https://myblog.example.com",
|
||||||
|
credentials=b"encrypted-blob-data",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema creation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaCreation:
|
||||||
|
"""Test that the database schema is created correctly."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_tables_exist(self, db):
|
||||||
|
"""All expected tables should exist after initialization."""
|
||||||
|
rows = await db.fetchall("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||||
|
table_names = {row["name"] for row in rows}
|
||||||
|
expected = {"users", "sites", "user_api_keys", "connection_tokens", "schema_version"}
|
||||||
|
assert expected.issubset(table_names)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_schema_version_set(self, db):
|
||||||
|
"""schema_version table should contain version 1 after init."""
|
||||||
|
row = await db.fetchone("SELECT MAX(version) AS v FROM schema_version")
|
||||||
|
assert row is not None
|
||||||
|
assert row["v"] == SCHEMA_VERSION
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# User CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserCRUD:
|
||||||
|
"""Test user create, read, and update operations."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_user(self, db):
|
||||||
|
"""Should create a user and return a dict with all fields."""
|
||||||
|
user = await db.create_user(
|
||||||
|
email="test@example.com",
|
||||||
|
name="Test User",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-999",
|
||||||
|
avatar_url="https://example.com/avatar.png",
|
||||||
|
)
|
||||||
|
assert user["email"] == "test@example.com"
|
||||||
|
assert user["name"] == "Test User"
|
||||||
|
assert user["provider"] == "github"
|
||||||
|
assert user["provider_id"] == "gh-999"
|
||||||
|
assert user["avatar_url"] == "https://example.com/avatar.png"
|
||||||
|
assert user["role"] == "user"
|
||||||
|
assert user["id"] is not None
|
||||||
|
assert user["created_at"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_user_admin_role(self, db):
|
||||||
|
"""Should allow creating a user with admin role."""
|
||||||
|
user = await db.create_user(
|
||||||
|
email="admin@example.com",
|
||||||
|
name="Admin",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-admin",
|
||||||
|
role="admin",
|
||||||
|
)
|
||||||
|
assert user["role"] == "admin"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_user_by_id(self, db, user_row):
|
||||||
|
"""Should retrieve a user by their UUID."""
|
||||||
|
fetched = await db.get_user_by_id(user_row["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched["email"] == "alice@example.com"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_user_by_id_not_found(self, db):
|
||||||
|
"""Should return None for a non-existent user ID."""
|
||||||
|
fetched = await db.get_user_by_id("non-existent-uuid")
|
||||||
|
assert fetched is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_user_by_provider(self, db, user_row):
|
||||||
|
"""Should retrieve a user by provider and provider_id."""
|
||||||
|
fetched = await db.get_user_by_provider("github", "gh-111")
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched["id"] == user_row["id"]
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_user_by_provider_not_found(self, db):
|
||||||
|
"""Should return None for a non-existent provider combo."""
|
||||||
|
fetched = await db.get_user_by_provider("github", "does-not-exist")
|
||||||
|
assert fetched is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_update_last_login(self, db, user_row):
|
||||||
|
"""Should update the last_login timestamp."""
|
||||||
|
original_login = user_row["last_login"]
|
||||||
|
# Small delay so the timestamps differ
|
||||||
|
await asyncio.sleep(0.01)
|
||||||
|
await db.update_user_last_login(user_row["id"])
|
||||||
|
|
||||||
|
fetched = await db.get_user_by_id(user_row["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched["last_login"] != original_login
|
||||||
|
assert fetched["last_login"] > original_login
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# User uniqueness constraints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserConstraints:
|
||||||
|
"""Test UNIQUE constraints on the users table."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_duplicate_email_raises(self, db, user_row):
|
||||||
|
"""Inserting a user with a duplicate email should raise IntegrityError."""
|
||||||
|
with pytest.raises(aiosqlite.IntegrityError):
|
||||||
|
await db.create_user(
|
||||||
|
email="alice@example.com", # duplicate
|
||||||
|
name="Alice Clone",
|
||||||
|
provider="google",
|
||||||
|
provider_id="gg-unique",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_duplicate_provider_raises(self, db, user_row):
|
||||||
|
"""Inserting a user with duplicate provider+provider_id should raise."""
|
||||||
|
with pytest.raises(aiosqlite.IntegrityError):
|
||||||
|
await db.create_user(
|
||||||
|
email="other@example.com",
|
||||||
|
name="Other",
|
||||||
|
provider="github", # same provider
|
||||||
|
provider_id="gh-111", # same provider_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Site CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteCRUD:
|
||||||
|
"""Test site create, read, update, and delete operations."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_site(self, db, user_row):
|
||||||
|
"""Should create a site and return a dict with all fields."""
|
||||||
|
site = await db.create_site(
|
||||||
|
user_id=user_row["id"],
|
||||||
|
plugin_type="wordpress",
|
||||||
|
alias="testsite",
|
||||||
|
url="https://test.example.com",
|
||||||
|
credentials=b"encrypted-data",
|
||||||
|
)
|
||||||
|
assert site["plugin_type"] == "wordpress"
|
||||||
|
assert site["alias"] == "testsite"
|
||||||
|
assert site["url"] == "https://test.example.com"
|
||||||
|
assert site["credentials"] == b"encrypted-data"
|
||||||
|
assert site["status"] == "pending"
|
||||||
|
assert site["user_id"] == user_row["id"]
|
||||||
|
assert site["id"] is not None
|
||||||
|
assert site["created_at"] is not None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_sites_by_user(self, db, user_row):
|
||||||
|
"""Should return all sites for a user."""
|
||||||
|
await db.create_site(
|
||||||
|
user_id=user_row["id"],
|
||||||
|
plugin_type="wordpress",
|
||||||
|
alias="site-a",
|
||||||
|
url="https://a.example.com",
|
||||||
|
credentials=b"cred-a",
|
||||||
|
)
|
||||||
|
await db.create_site(
|
||||||
|
user_id=user_row["id"],
|
||||||
|
plugin_type="gitea",
|
||||||
|
alias="site-b",
|
||||||
|
url="https://b.example.com",
|
||||||
|
credentials=b"cred-b",
|
||||||
|
)
|
||||||
|
|
||||||
|
sites = await db.get_sites_by_user(user_row["id"])
|
||||||
|
assert len(sites) == 2
|
||||||
|
aliases = {s["alias"] for s in sites}
|
||||||
|
assert aliases == {"site-a", "site-b"}
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_sites_by_user_empty(self, db, user_row):
|
||||||
|
"""Should return empty list when user has no sites."""
|
||||||
|
sites = await db.get_sites_by_user(user_row["id"])
|
||||||
|
assert sites == []
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_site(self, db, user_row, site_row):
|
||||||
|
"""Should retrieve a site by ID with user scoping."""
|
||||||
|
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched["alias"] == "myblog"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_delete_site(self, db, user_row, site_row):
|
||||||
|
"""Should delete a site and return True."""
|
||||||
|
deleted = await db.delete_site(site_row["id"], user_row["id"])
|
||||||
|
assert deleted is True
|
||||||
|
|
||||||
|
# Verify it's gone
|
||||||
|
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||||
|
assert fetched is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_delete_site_not_found(self, db, user_row):
|
||||||
|
"""Should return False when deleting a non-existent site."""
|
||||||
|
deleted = await db.delete_site("no-such-id", user_row["id"])
|
||||||
|
assert deleted is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Site isolation
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteIsolation:
|
||||||
|
"""Test that site access is properly scoped to the owning user."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_site_wrong_user_returns_none(self, db, user_row, second_user, site_row):
|
||||||
|
"""get_site with wrong user_id should return None."""
|
||||||
|
fetched = await db.get_site(site_row["id"], second_user["id"])
|
||||||
|
assert fetched is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_delete_site_wrong_user_returns_false(self, db, user_row, second_user, site_row):
|
||||||
|
"""delete_site with wrong user_id should return False and not delete."""
|
||||||
|
deleted = await db.delete_site(site_row["id"], second_user["id"])
|
||||||
|
assert deleted is False
|
||||||
|
|
||||||
|
# Verify it's still there for the real owner
|
||||||
|
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Site alias constraints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteAliasConstraints:
|
||||||
|
"""Test UNIQUE(user_id, alias) constraint on the sites table."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_duplicate_alias_same_user_raises(self, db, user_row, site_row):
|
||||||
|
"""Duplicate alias for the same user should raise IntegrityError."""
|
||||||
|
with pytest.raises(aiosqlite.IntegrityError):
|
||||||
|
await db.create_site(
|
||||||
|
user_id=user_row["id"],
|
||||||
|
plugin_type="gitea",
|
||||||
|
alias="myblog", # duplicate alias for same user
|
||||||
|
url="https://other.example.com",
|
||||||
|
credentials=b"cred",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_same_alias_different_users_succeeds(self, db, user_row, second_user):
|
||||||
|
"""Same alias for different users should succeed."""
|
||||||
|
site1 = await db.create_site(
|
||||||
|
user_id=user_row["id"],
|
||||||
|
plugin_type="wordpress",
|
||||||
|
alias="shared-alias",
|
||||||
|
url="https://a.example.com",
|
||||||
|
credentials=b"cred-a",
|
||||||
|
)
|
||||||
|
site2 = await db.create_site(
|
||||||
|
user_id=second_user["id"],
|
||||||
|
plugin_type="wordpress",
|
||||||
|
alias="shared-alias",
|
||||||
|
url="https://b.example.com",
|
||||||
|
credentials=b"cred-b",
|
||||||
|
)
|
||||||
|
assert site1["id"] != site2["id"]
|
||||||
|
assert site1["alias"] == site2["alias"] == "shared-alias"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cascade delete
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCascadeDelete:
|
||||||
|
"""Test that deleting a user cascades to their sites."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_delete_user_cascades_sites(self, db, user_row, site_row):
|
||||||
|
"""Deleting a user should also delete their sites."""
|
||||||
|
# Verify site exists
|
||||||
|
site = await db.get_site(site_row["id"], user_row["id"])
|
||||||
|
assert site is not None
|
||||||
|
|
||||||
|
# Delete the user directly
|
||||||
|
await db.execute("DELETE FROM users WHERE id = ?", (user_row["id"],))
|
||||||
|
|
||||||
|
# Site should be gone
|
||||||
|
row = await db.fetchone("SELECT * FROM sites WHERE id = ?", (site_row["id"],))
|
||||||
|
assert row is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# update_site_status
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateSiteStatus:
|
||||||
|
"""Test site status updates."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_update_site_status(self, db, user_row, site_row):
|
||||||
|
"""Should update status and status_msg."""
|
||||||
|
await db.update_site_status(site_row["id"], "active", "Connected successfully")
|
||||||
|
|
||||||
|
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched["status"] == "active"
|
||||||
|
assert fetched["status_msg"] == "Connected successfully"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_update_site_status_to_error(self, db, user_row, site_row):
|
||||||
|
"""Should allow setting error status with a message."""
|
||||||
|
await db.update_site_status(site_row["id"], "error", "Connection refused")
|
||||||
|
|
||||||
|
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched["status"] == "error"
|
||||||
|
assert fetched["status_msg"] == "Connection refused"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_update_site_status_clears_message(self, db, user_row, site_row):
|
||||||
|
"""Should clear status_msg when set to None."""
|
||||||
|
await db.update_site_status(site_row["id"], "active", "All good")
|
||||||
|
await db.update_site_status(site_row["id"], "disabled")
|
||||||
|
|
||||||
|
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched["status"] == "disabled"
|
||||||
|
assert fetched["status_msg"] is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_update_site_status_with_user_id(self, db, user_row, site_row):
|
||||||
|
"""Should update when user_id matches the site owner."""
|
||||||
|
await db.update_site_status(site_row["id"], "active", "OK", user_id=user_row["id"])
|
||||||
|
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched["status"] == "active"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_update_site_status_wrong_user_id_no_effect(self, db, user_row, site_row):
|
||||||
|
"""Should not update when user_id doesn't match."""
|
||||||
|
await db.update_site_status(site_row["id"], "active", "OK", user_id="wrong-user-id")
|
||||||
|
fetched = await db.get_site(site_row["id"], user_row["id"])
|
||||||
|
assert fetched is not None
|
||||||
|
assert fetched["status"] == "pending" # unchanged
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Context manager
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestContextManager:
|
||||||
|
"""Test async context manager support."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_context_manager(self, tmp_path):
|
||||||
|
"""async with Database(...) as db: should work."""
|
||||||
|
db_path = str(tmp_path / "ctx_test.db")
|
||||||
|
async with Database(db_path) as db:
|
||||||
|
user = await db.create_user(
|
||||||
|
email="ctx@example.com",
|
||||||
|
name="Context",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-ctx",
|
||||||
|
)
|
||||||
|
assert user["email"] == "ctx@example.com"
|
||||||
|
|
||||||
|
# After exit, connection should be closed
|
||||||
|
assert db._conn is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PRAGMA checks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPragmas:
|
||||||
|
"""Test that WAL mode and foreign keys are enabled."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_wal_mode_enabled(self, db):
|
||||||
|
"""PRAGMA journal_mode should be WAL."""
|
||||||
|
row = await db.fetchone("PRAGMA journal_mode")
|
||||||
|
assert row is not None
|
||||||
|
assert row["journal_mode"].lower() == "wal"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_foreign_keys_enabled(self, db):
|
||||||
|
"""PRAGMA foreign_keys should be ON (1)."""
|
||||||
|
row = await db.fetchone("PRAGMA foreign_keys")
|
||||||
|
assert row is not None
|
||||||
|
assert row["foreign_keys"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Concurrent access
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestConcurrentAccess:
|
||||||
|
"""Test that two Database instances on the same file don't deadlock."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_concurrent_instances(self, tmp_path):
|
||||||
|
"""Two Database instances on the same file should not deadlock."""
|
||||||
|
db_path = str(tmp_path / "concurrent.db")
|
||||||
|
|
||||||
|
async with Database(db_path) as db1, Database(db_path) as db2:
|
||||||
|
user1 = await db1.create_user(
|
||||||
|
email="user1@example.com",
|
||||||
|
name="User 1",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-1",
|
||||||
|
)
|
||||||
|
user2 = await db2.create_user(
|
||||||
|
email="user2@example.com",
|
||||||
|
name="User 2",
|
||||||
|
provider="google",
|
||||||
|
provider_id="gg-2",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Both users should be visible from either connection
|
||||||
|
fetched1 = await db2.get_user_by_id(user1["id"])
|
||||||
|
fetched2 = await db1.get_user_by_id(user2["id"])
|
||||||
|
assert fetched1 is not None
|
||||||
|
assert fetched2 is not None
|
||||||
|
assert fetched1["email"] == "user1@example.com"
|
||||||
|
assert fetched2["email"] == "user2@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Empty results
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestEmptyResults:
|
||||||
|
"""Test behavior with empty or non-existent data."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_user_by_id_nonexistent(self, db):
|
||||||
|
"""get_user_by_id with a fake UUID should return None."""
|
||||||
|
result = await db.get_user_by_id("00000000-0000-0000-0000-000000000000")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_user_by_provider_nonexistent(self, db):
|
||||||
|
"""get_user_by_provider with a fake combo should return None."""
|
||||||
|
result = await db.get_user_by_provider("unknown_provider", "fake_id")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_site_nonexistent(self, db):
|
||||||
|
"""get_site with fake IDs should return None."""
|
||||||
|
result = await db.get_site("fake-site-id", "fake-user-id")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_sites_by_user_nonexistent(self, db):
|
||||||
|
"""get_sites_by_user with a fake user ID should return empty list."""
|
||||||
|
result = await db.get_sites_by_user("fake-user-id")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestModuleHelpers:
|
||||||
|
"""Test get_database() and initialize_database() helpers."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_database_before_init_raises(self, monkeypatch):
|
||||||
|
"""get_database() should raise RuntimeError if not initialized."""
|
||||||
|
# Reset the module-level singleton
|
||||||
|
import core.database as db_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(db_module, "_database", None)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="Database not initialized"):
|
||||||
|
get_database()
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_initialize_and_get_database(self, tmp_path, monkeypatch):
|
||||||
|
"""initialize_database() should set the singleton, get_database() returns it."""
|
||||||
|
import core.database as db_module
|
||||||
|
|
||||||
|
monkeypatch.setattr(db_module, "_database", None)
|
||||||
|
|
||||||
|
db_path = str(tmp_path / "singleton_test.db")
|
||||||
|
db = await initialize_database(db_path)
|
||||||
|
try:
|
||||||
|
assert db is get_database()
|
||||||
|
|
||||||
|
# Should be functional
|
||||||
|
user = await db.create_user(
|
||||||
|
email="singleton@example.com",
|
||||||
|
name="Singleton",
|
||||||
|
provider="github",
|
||||||
|
provider_id="gh-singleton",
|
||||||
|
)
|
||||||
|
assert user["email"] == "singleton@example.com"
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
monkeypatch.setattr(db_module, "_database", None)
|
||||||
115
tests/test_dynamic_endpoints_integration.py
Normal file
115
tests/test_dynamic_endpoints_integration.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for the per-user dynamic MCP endpoints.
|
||||||
|
Uses Starlette TestClient to simulate real HTTP requests through the server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from server import create_multi_endpoint_app
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
app = create_multi_endpoint_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_managers():
|
||||||
|
"""Mock the core managers to avoid needing a real database or keys."""
|
||||||
|
|
||||||
|
# Mock Site Manager
|
||||||
|
mock_site_manager = MagicMock()
|
||||||
|
mock_site_manager.list_all_sites.return_value = []
|
||||||
|
|
||||||
|
# Mock Key Manager
|
||||||
|
mock_key_manager = AsyncMock()
|
||||||
|
mock_key_manager.validate_key.return_value = {
|
||||||
|
"key_id": "test-key-123",
|
||||||
|
"user_id": "user-123",
|
||||||
|
"scopes": "read write",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mock Database
|
||||||
|
mock_db = AsyncMock()
|
||||||
|
mock_db.get_site_by_alias.return_value = {
|
||||||
|
"id": "site-123",
|
||||||
|
"user_id": "user-123",
|
||||||
|
"plugin_type": "wordpress",
|
||||||
|
"alias": "myblog",
|
||||||
|
"url": "https://example.com",
|
||||||
|
"credentials": b"encrypted-blob",
|
||||||
|
"status": "active",
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("server.get_site_manager", return_value=mock_site_manager),
|
||||||
|
patch("core.user_keys.get_user_key_manager", return_value=mock_key_manager),
|
||||||
|
patch("core.database.get_database", return_value=mock_db),
|
||||||
|
):
|
||||||
|
yield {"key_manager": mock_key_manager, "db": mock_db}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_dynamic_endpoint_unauthorized():
|
||||||
|
"""Test that requests without API key are rejected."""
|
||||||
|
response = client.post(
|
||||||
|
"/u/user-123/myblog/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "initialize"}
|
||||||
|
)
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_dynamic_endpoint_invalid_method(mock_managers):
|
||||||
|
"""Test that unauthorized methods or valid requests with bad structure return JSON-RPC errors."""
|
||||||
|
response = client.post(
|
||||||
|
"/u/user-123/myblog/mcp",
|
||||||
|
headers={"Authorization": "Bearer mhu_test-key"},
|
||||||
|
json={"jsonrpc": "2.0", "id": 1, "method": "invalid_method"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200 # JSON-RPC errors are 200 OK
|
||||||
|
data = response.json()
|
||||||
|
assert "error" in data
|
||||||
|
assert "not supported" in data["error"]["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_dynamic_endpoint_initialize(mock_managers):
|
||||||
|
"""Test standard MCP initialize on the dynamic endpoint."""
|
||||||
|
response = client.post(
|
||||||
|
"/u/user-123/myblog/mcp",
|
||||||
|
headers={"Authorization": "Bearer mhu_test-key"},
|
||||||
|
json={
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"method": "initialize",
|
||||||
|
"params": {
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "test-client", "version": "1.0.0"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert "result" in data
|
||||||
|
assert "capabilities" in data["result"]
|
||||||
|
assert "serverInfo" in data["result"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_dynamic_endpoint_mismatched_user(mock_managers):
|
||||||
|
"""Test that if the key belongs to a different user, it's rejected."""
|
||||||
|
mock_managers["key_manager"].validate_key.return_value = {
|
||||||
|
"key_id": "test-key-123",
|
||||||
|
"user_id": "different-user-456",
|
||||||
|
"scopes": "read write",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/u/user-123/myblog/mcp",
|
||||||
|
headers={"Authorization": "Bearer mhu_test-key"},
|
||||||
|
json={"jsonrpc": "2.0", "id": 1, "method": "initialize"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
data = response.json()
|
||||||
|
assert "does not match" in data["error"]["message"]
|
||||||
315
tests/test_encryption.py
Normal file
315
tests/test_encryption.py
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
"""Tests for Credential Encryption (core/encryption.py)."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.exceptions import InvalidTag
|
||||||
|
|
||||||
|
from core.encryption import (
|
||||||
|
CredentialEncryption,
|
||||||
|
get_credential_encryption,
|
||||||
|
initialize_credential_encryption,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A valid base64-encoded 32-byte key for testing
|
||||||
|
TEST_KEY = base64.b64encode(os.urandom(32)).decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def encryption():
|
||||||
|
"""Create a CredentialEncryption instance with a test key."""
|
||||||
|
return CredentialEncryption(encryption_key=TEST_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def _clear_singleton():
|
||||||
|
"""Reset the global singleton before and after each test that uses it."""
|
||||||
|
import core.encryption as mod
|
||||||
|
|
||||||
|
original = mod._credential_encryption
|
||||||
|
mod._credential_encryption = None
|
||||||
|
yield
|
||||||
|
mod._credential_encryption = original
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestEncryptDecrypt:
|
||||||
|
"""Test basic encrypt/decrypt round-trips."""
|
||||||
|
|
||||||
|
def test_round_trip(self, encryption):
|
||||||
|
"""Encrypt then decrypt should return the original plaintext."""
|
||||||
|
plaintext = "Hello, World!"
|
||||||
|
site_id = "site_001"
|
||||||
|
|
||||||
|
cipherdata = encryption.encrypt(plaintext, site_id)
|
||||||
|
result = encryption.decrypt(cipherdata, site_id)
|
||||||
|
|
||||||
|
assert result == plaintext
|
||||||
|
|
||||||
|
def test_credentials_round_trip(self, encryption):
|
||||||
|
"""encrypt_credentials then decrypt_credentials should return the same dict."""
|
||||||
|
credentials = {
|
||||||
|
"username": "admin",
|
||||||
|
"app_password": "xxxx xxxx xxxx xxxx",
|
||||||
|
"api_key": "sk-1234567890",
|
||||||
|
}
|
||||||
|
site_id = "site_002"
|
||||||
|
|
||||||
|
cipherdata = encryption.encrypt_credentials(credentials, site_id)
|
||||||
|
result = encryption.decrypt_credentials(cipherdata, site_id)
|
||||||
|
|
||||||
|
assert result == credentials
|
||||||
|
|
||||||
|
def test_empty_credentials(self, encryption):
|
||||||
|
"""Empty dict should encrypt and decrypt correctly."""
|
||||||
|
credentials = {}
|
||||||
|
site_id = "site_empty"
|
||||||
|
|
||||||
|
cipherdata = encryption.encrypt_credentials(credentials, site_id)
|
||||||
|
result = encryption.decrypt_credentials(cipherdata, site_id)
|
||||||
|
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
def test_unicode_credentials(self, encryption):
|
||||||
|
"""Non-ASCII characters in credentials should survive round-trip."""
|
||||||
|
credentials = {
|
||||||
|
"username": "\u06a9\u0627\u0631\u0628\u0631",
|
||||||
|
"password": "\u0631\u0645\u0632\u0639\u0628\u0648\u0631-\u0627\u06cc\u0645\u0646",
|
||||||
|
"display_name": "\u5f20\u4e09\u7684\u535a\u5ba2",
|
||||||
|
"notes": "Emoji \u2764\ufe0f test \U0001f680",
|
||||||
|
}
|
||||||
|
site_id = "site_unicode"
|
||||||
|
|
||||||
|
cipherdata = encryption.encrypt_credentials(credentials, site_id)
|
||||||
|
result = encryption.decrypt_credentials(cipherdata, site_id)
|
||||||
|
|
||||||
|
assert result == credentials
|
||||||
|
|
||||||
|
def test_large_payload(self, encryption):
|
||||||
|
"""A large JSON payload (~10KB) should encrypt and decrypt correctly."""
|
||||||
|
credentials = {f"key_{i}": f"value_{i}_{'x' * 100}" for i in range(85)}
|
||||||
|
json_size = len(json.dumps(credentials))
|
||||||
|
assert json_size > 10000, f"Payload should be >10KB, got {json_size}"
|
||||||
|
|
||||||
|
site_id = "site_large"
|
||||||
|
cipherdata = encryption.encrypt_credentials(credentials, site_id)
|
||||||
|
result = encryption.decrypt_credentials(cipherdata, site_id)
|
||||||
|
|
||||||
|
assert result == credentials
|
||||||
|
|
||||||
|
def test_empty_string_encrypt_decrypt(self, encryption):
|
||||||
|
"""Empty string should encrypt and decrypt correctly."""
|
||||||
|
cipherdata = encryption.encrypt("", "site_empty_str")
|
||||||
|
result = encryption.decrypt(cipherdata, "site_empty_str")
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestSiteIsolation:
|
||||||
|
"""Test that different site_ids produce different ciphertext and keys."""
|
||||||
|
|
||||||
|
def test_different_site_ids_produce_different_ciphertext(self, encryption):
|
||||||
|
"""Same plaintext encrypted with different site_ids should differ."""
|
||||||
|
plaintext = "same-secret-value"
|
||||||
|
cipher_a = encryption.encrypt(plaintext, "site_alpha")
|
||||||
|
cipher_b = encryption.encrypt(plaintext, "site_beta")
|
||||||
|
|
||||||
|
# Ciphertext should differ (different derived keys + different nonces)
|
||||||
|
assert cipher_a != cipher_b
|
||||||
|
|
||||||
|
def test_wrong_site_id_fails_to_decrypt(self, encryption):
|
||||||
|
"""Decrypting with the wrong site_id should raise InvalidTag."""
|
||||||
|
plaintext = "secret-data"
|
||||||
|
cipherdata = encryption.encrypt(plaintext, "correct_site")
|
||||||
|
|
||||||
|
with pytest.raises(InvalidTag):
|
||||||
|
encryption.decrypt(cipherdata, "wrong_site")
|
||||||
|
|
||||||
|
def test_wrong_site_id_fails_credentials(self, encryption):
|
||||||
|
"""decrypt_credentials with wrong site_id should raise InvalidTag."""
|
||||||
|
credentials = {"username": "admin", "password": "secret"}
|
||||||
|
cipherdata = encryption.encrypt_credentials(credentials, "correct_site")
|
||||||
|
|
||||||
|
with pytest.raises(InvalidTag):
|
||||||
|
encryption.decrypt_credentials(cipherdata, "wrong_site")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestTampering:
|
||||||
|
"""Test that tampered ciphertext is detected."""
|
||||||
|
|
||||||
|
def test_tampered_ciphertext_fails(self, encryption):
|
||||||
|
"""Modifying a byte in the ciphertext should cause decryption to fail."""
|
||||||
|
plaintext = "sensitive-data"
|
||||||
|
site_id = "site_tamper"
|
||||||
|
|
||||||
|
cipherdata = encryption.encrypt(plaintext, site_id)
|
||||||
|
|
||||||
|
# Tamper with a byte in the ciphertext portion (after version + nonce)
|
||||||
|
tampered = bytearray(cipherdata)
|
||||||
|
tampered[16] ^= 0xFF # Flip bits in a ciphertext byte
|
||||||
|
tampered = bytes(tampered)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidTag):
|
||||||
|
encryption.decrypt(tampered, site_id)
|
||||||
|
|
||||||
|
def test_tampered_nonce_fails(self, encryption):
|
||||||
|
"""Modifying the nonce should cause decryption to fail."""
|
||||||
|
plaintext = "sensitive-data"
|
||||||
|
site_id = "site_nonce_tamper"
|
||||||
|
|
||||||
|
cipherdata = encryption.encrypt(plaintext, site_id)
|
||||||
|
|
||||||
|
tampered = bytearray(cipherdata)
|
||||||
|
tampered[1] ^= 0xFF # Flip bits in the nonce (byte 1, after version byte)
|
||||||
|
tampered = bytes(tampered)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidTag):
|
||||||
|
encryption.decrypt(tampered, site_id)
|
||||||
|
|
||||||
|
def test_truncated_cipherdata_fails(self, encryption):
|
||||||
|
"""Truncated cipherdata should raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="too short"):
|
||||||
|
encryption.decrypt(b"short", "site_trunc")
|
||||||
|
|
||||||
|
def test_unsupported_version_fails(self, encryption):
|
||||||
|
"""Cipherdata with wrong version byte should raise ValueError."""
|
||||||
|
cipherdata = encryption.encrypt("test", "site_ver")
|
||||||
|
# Replace version byte with 0x99
|
||||||
|
bad_version = b"\x99" + cipherdata[1:]
|
||||||
|
with pytest.raises(ValueError, match="Unsupported encryption format version"):
|
||||||
|
encryption.decrypt(bad_version, "site_ver")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestKeyValidation:
|
||||||
|
"""Test encryption key validation."""
|
||||||
|
|
||||||
|
def test_missing_key_raises_valueerror(self, monkeypatch):
|
||||||
|
"""Missing ENCRYPTION_KEY should raise ValueError."""
|
||||||
|
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="ENCRYPTION_KEY is required"):
|
||||||
|
CredentialEncryption()
|
||||||
|
|
||||||
|
def test_invalid_base64_raises_valueerror(self):
|
||||||
|
"""Non-base64 key should raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="valid base64"):
|
||||||
|
CredentialEncryption(encryption_key="not-valid-base64!!!")
|
||||||
|
|
||||||
|
def test_wrong_length_key_raises_valueerror(self):
|
||||||
|
"""Key that decodes to wrong number of bytes should raise ValueError."""
|
||||||
|
short_key = base64.b64encode(b"too-short").decode()
|
||||||
|
with pytest.raises(ValueError, match="exactly 32 bytes"):
|
||||||
|
CredentialEncryption(encryption_key=short_key)
|
||||||
|
|
||||||
|
def test_16_byte_key_raises_valueerror(self):
|
||||||
|
"""A 16-byte key (AES-128) should be rejected; we require 32 bytes."""
|
||||||
|
key_16 = base64.b64encode(os.urandom(16)).decode()
|
||||||
|
with pytest.raises(ValueError, match="exactly 32 bytes"):
|
||||||
|
CredentialEncryption(encryption_key=key_16)
|
||||||
|
|
||||||
|
def test_valid_key_from_env(self, monkeypatch):
|
||||||
|
"""ENCRYPTION_KEY from env should be accepted when valid."""
|
||||||
|
key = base64.b64encode(os.urandom(32)).decode()
|
||||||
|
monkeypatch.setenv("ENCRYPTION_KEY", key)
|
||||||
|
|
||||||
|
enc = CredentialEncryption()
|
||||||
|
# Should work without error
|
||||||
|
cipherdata = enc.encrypt("test", "site")
|
||||||
|
assert enc.decrypt(cipherdata, "site") == "test"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestKeyDerivation:
|
||||||
|
"""Test HKDF key derivation properties."""
|
||||||
|
|
||||||
|
def test_deterministic(self, encryption):
|
||||||
|
"""Same site_id should always produce the same derived key."""
|
||||||
|
key_a = encryption._derive_key("site_deterministic")
|
||||||
|
key_b = encryption._derive_key("site_deterministic")
|
||||||
|
assert key_a == key_b
|
||||||
|
|
||||||
|
def test_different_sites_different_keys(self, encryption):
|
||||||
|
"""Different site_ids should produce different derived keys."""
|
||||||
|
key_a = encryption._derive_key("site_one")
|
||||||
|
key_b = encryption._derive_key("site_two")
|
||||||
|
assert key_a != key_b
|
||||||
|
|
||||||
|
def test_derived_key_length(self, encryption):
|
||||||
|
"""Derived key should be 32 bytes."""
|
||||||
|
key = encryption._derive_key("any_site")
|
||||||
|
assert len(key) == 32
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestNonceUniqueness:
|
||||||
|
"""Test that random nonces ensure ciphertext uniqueness."""
|
||||||
|
|
||||||
|
def test_same_plaintext_different_ciphertext(self, encryption):
|
||||||
|
"""Two encryptions of the same plaintext should produce different ciphertext."""
|
||||||
|
plaintext = "identical-value"
|
||||||
|
site_id = "site_nonce"
|
||||||
|
|
||||||
|
cipher_a = encryption.encrypt(plaintext, site_id)
|
||||||
|
cipher_b = encryption.encrypt(plaintext, site_id)
|
||||||
|
|
||||||
|
# Both should decrypt to the same value
|
||||||
|
assert encryption.decrypt(cipher_a, site_id) == plaintext
|
||||||
|
assert encryption.decrypt(cipher_b, site_id) == plaintext
|
||||||
|
|
||||||
|
# But the ciphertext should differ (different random nonces)
|
||||||
|
assert cipher_a != cipher_b
|
||||||
|
|
||||||
|
def test_nonce_is_12_bytes(self, encryption):
|
||||||
|
"""The nonce prefix should be exactly 12 bytes (after version byte)."""
|
||||||
|
cipherdata = encryption.encrypt("test", "site_nonce_len")
|
||||||
|
# Minimum size: 1 (version) + 12 (nonce) + 0 (empty plaintext encrypted) + 16 (tag)
|
||||||
|
assert len(cipherdata) >= 29
|
||||||
|
# Version byte is 0x01
|
||||||
|
assert cipherdata[0:1] == b"\x01"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestSingleton:
|
||||||
|
"""Test the module-level singleton getter."""
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_clear_singleton")
|
||||||
|
def test_get_credential_encryption_returns_instance(self, monkeypatch):
|
||||||
|
"""get_credential_encryption should return a CredentialEncryption instance."""
|
||||||
|
key = base64.b64encode(os.urandom(32)).decode()
|
||||||
|
monkeypatch.setenv("ENCRYPTION_KEY", key)
|
||||||
|
|
||||||
|
enc = get_credential_encryption()
|
||||||
|
assert isinstance(enc, CredentialEncryption)
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_clear_singleton")
|
||||||
|
def test_singleton_returns_same_instance(self, monkeypatch):
|
||||||
|
"""Calling get_credential_encryption twice should return the same object."""
|
||||||
|
key = base64.b64encode(os.urandom(32)).decode()
|
||||||
|
monkeypatch.setenv("ENCRYPTION_KEY", key)
|
||||||
|
|
||||||
|
enc_a = get_credential_encryption()
|
||||||
|
enc_b = get_credential_encryption()
|
||||||
|
assert enc_a is enc_b
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_clear_singleton")
|
||||||
|
def test_singleton_raises_without_key(self, monkeypatch):
|
||||||
|
"""get_credential_encryption should raise ValueError if ENCRYPTION_KEY is missing."""
|
||||||
|
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="ENCRYPTION_KEY is required"):
|
||||||
|
get_credential_encryption()
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("_clear_singleton")
|
||||||
|
def test_initialize_with_explicit_key(self, monkeypatch):
|
||||||
|
"""initialize_credential_encryption should accept an explicit key."""
|
||||||
|
monkeypatch.delenv("ENCRYPTION_KEY", raising=False)
|
||||||
|
key = base64.b64encode(os.urandom(32)).decode()
|
||||||
|
|
||||||
|
enc = initialize_credential_encryption(key)
|
||||||
|
assert isinstance(enc, CredentialEncryption)
|
||||||
|
# Should be the same as get_credential_encryption now
|
||||||
|
assert get_credential_encryption() is enc
|
||||||
353
tests/test_site_api.py
Normal file
353
tests/test_site_api.py
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
"""Tests for Site Management API (core/site_api.py)."""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.site_api import (
|
||||||
|
MAX_SITES_PER_USER,
|
||||||
|
create_user_site,
|
||||||
|
delete_user_site,
|
||||||
|
get_credential_fields,
|
||||||
|
get_user_sites,
|
||||||
|
validate_credentials,
|
||||||
|
validate_site_connection,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Fixtures ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_db():
|
||||||
|
"""Create and patch a mock database instance."""
|
||||||
|
db = AsyncMock()
|
||||||
|
db.count_sites_by_user = AsyncMock(return_value=0)
|
||||||
|
db.get_site_by_alias = AsyncMock(return_value=None)
|
||||||
|
db.get_site = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"id": "site-uuid-001",
|
||||||
|
"user_id": "user-uuid-001",
|
||||||
|
"plugin_type": "wordpress",
|
||||||
|
"alias": "myblog",
|
||||||
|
"url": "https://myblog.example.com",
|
||||||
|
"credentials": b"encrypted-blob",
|
||||||
|
"status": "active",
|
||||||
|
"status_msg": "Connection verified",
|
||||||
|
"created_at": "2026-02-19T12:00:00Z",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.get_sites_by_user = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"id": "site-uuid-001",
|
||||||
|
"user_id": "user-uuid-001",
|
||||||
|
"plugin_type": "wordpress",
|
||||||
|
"alias": "myblog",
|
||||||
|
"url": "https://myblog.example.com",
|
||||||
|
"credentials": b"encrypted-blob",
|
||||||
|
"status": "active",
|
||||||
|
"created_at": "2026-02-19T12:00:00Z",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.delete_site = AsyncMock(return_value=True)
|
||||||
|
db.execute = AsyncMock()
|
||||||
|
db.update_site_status = AsyncMock()
|
||||||
|
with patch("core.database.get_database", return_value=db):
|
||||||
|
yield db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_encryption():
|
||||||
|
"""Create and patch a mock encryption instance."""
|
||||||
|
enc = MagicMock()
|
||||||
|
enc.encrypt_credentials = MagicMock(return_value=b"encrypted-blob")
|
||||||
|
enc.decrypt_credentials = MagicMock(
|
||||||
|
return_value={
|
||||||
|
"username": "admin",
|
||||||
|
"app_password": "xxxx xxxx xxxx xxxx",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch("core.encryption.get_credential_encryption", return_value=enc):
|
||||||
|
yield enc
|
||||||
|
|
||||||
|
|
||||||
|
# ── Credential Field Definitions ─────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestCredentialFields:
|
||||||
|
"""Test credential field definitions and retrieval."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_get_credential_fields_all_plugins(self):
|
||||||
|
"""All 9 plugin types should return non-empty credential field lists."""
|
||||||
|
expected_plugins = [
|
||||||
|
"wordpress",
|
||||||
|
"woocommerce",
|
||||||
|
"wordpress_advanced",
|
||||||
|
"gitea",
|
||||||
|
"n8n",
|
||||||
|
"supabase",
|
||||||
|
"openpanel",
|
||||||
|
"appwrite",
|
||||||
|
"directus",
|
||||||
|
]
|
||||||
|
for plugin_type in expected_plugins:
|
||||||
|
fields = get_credential_fields(plugin_type)
|
||||||
|
assert len(fields) > 0, f"{plugin_type} returned empty fields"
|
||||||
|
for field in fields:
|
||||||
|
assert "name" in field
|
||||||
|
assert "label" in field
|
||||||
|
assert "type" in field
|
||||||
|
assert "required" in field
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_get_credential_fields_invalid(self):
|
||||||
|
"""Unknown plugin type should raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Unknown plugin type"):
|
||||||
|
get_credential_fields("nonexistent_plugin")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Credential Validation ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestCredentialValidation:
|
||||||
|
"""Test credential validation logic."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_validate_credentials_valid(self):
|
||||||
|
"""Valid WordPress credentials should pass validation."""
|
||||||
|
valid, errors = validate_credentials(
|
||||||
|
"wordpress",
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"app_password": "xxxx xxxx xxxx xxxx",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert valid is True
|
||||||
|
assert errors == []
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_validate_credentials_missing_required(self):
|
||||||
|
"""Missing required fields should return errors."""
|
||||||
|
valid, errors = validate_credentials(
|
||||||
|
"wordpress",
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
# app_password is missing
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert valid is False
|
||||||
|
assert len(errors) == 1
|
||||||
|
assert "Application Password" in errors[0]
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_validate_credentials_optional_field(self):
|
||||||
|
"""Optional fields (like container) should not cause errors when missing."""
|
||||||
|
valid, errors = validate_credentials(
|
||||||
|
"wordpress_advanced",
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"app_password": "xxxx xxxx xxxx xxxx",
|
||||||
|
# container is optional — should not cause error
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert valid is True
|
||||||
|
assert errors == []
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_validate_credentials_empty_string_required(self):
|
||||||
|
"""Empty string for required field should fail validation."""
|
||||||
|
valid, errors = validate_credentials(
|
||||||
|
"wordpress",
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"app_password": " ", # whitespace only
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert valid is False
|
||||||
|
assert len(errors) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── Site Creation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteCreation:
|
||||||
|
"""Test site creation flow."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_site_success(self, mock_db, mock_encryption):
|
||||||
|
"""Creating a site with skip_validation should succeed."""
|
||||||
|
result = await create_user_site(
|
||||||
|
user_id="user-uuid-001",
|
||||||
|
plugin_type="wordpress",
|
||||||
|
alias="myblog",
|
||||||
|
url="https://myblog.example.com",
|
||||||
|
credentials={"username": "admin", "app_password": "xxxx xxxx xxxx xxxx"},
|
||||||
|
skip_validation=True,
|
||||||
|
)
|
||||||
|
assert result["alias"] == "myblog"
|
||||||
|
assert result["plugin_type"] == "wordpress"
|
||||||
|
# Credentials blob should be stripped from the response
|
||||||
|
assert "credentials" not in result
|
||||||
|
# DB execute should have been called to insert
|
||||||
|
mock_db.execute.assert_called_once()
|
||||||
|
# Encryption should have been used
|
||||||
|
mock_encryption.encrypt_credentials.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_site_alias_too_short(self, mock_db, mock_encryption):
|
||||||
|
"""Alias shorter than 2 characters should raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="2-50 characters"):
|
||||||
|
await create_user_site(
|
||||||
|
user_id="user-uuid-001",
|
||||||
|
plugin_type="wordpress",
|
||||||
|
alias="a",
|
||||||
|
url="https://example.com",
|
||||||
|
credentials={"username": "admin", "app_password": "xxxx"},
|
||||||
|
skip_validation=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_site_alias_invalid_chars(self, mock_db, mock_encryption):
|
||||||
|
"""Alias with invalid characters should raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="letters, numbers, hyphens, and underscores"):
|
||||||
|
await create_user_site(
|
||||||
|
user_id="user-uuid-001",
|
||||||
|
plugin_type="wordpress",
|
||||||
|
alias="my blog!",
|
||||||
|
url="https://example.com",
|
||||||
|
credentials={"username": "admin", "app_password": "xxxx"},
|
||||||
|
skip_validation=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_site_max_limit(self, mock_db, mock_encryption):
|
||||||
|
"""Exceeding MAX_SITES_PER_USER should raise ValueError."""
|
||||||
|
mock_db.count_sites_by_user.return_value = MAX_SITES_PER_USER
|
||||||
|
with pytest.raises(ValueError, match="Site limit reached"):
|
||||||
|
await create_user_site(
|
||||||
|
user_id="user-uuid-001",
|
||||||
|
plugin_type="wordpress",
|
||||||
|
alias="toomany",
|
||||||
|
url="https://example.com",
|
||||||
|
credentials={"username": "admin", "app_password": "xxxx"},
|
||||||
|
skip_validation=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_site_duplicate_alias(self, mock_db, mock_encryption):
|
||||||
|
"""Duplicate alias for the same user should raise ValueError."""
|
||||||
|
mock_db.get_site_by_alias.return_value = {"id": "existing-site", "alias": "myblog"}
|
||||||
|
with pytest.raises(ValueError, match="already in use"):
|
||||||
|
await create_user_site(
|
||||||
|
user_id="user-uuid-001",
|
||||||
|
plugin_type="wordpress",
|
||||||
|
alias="myblog",
|
||||||
|
url="https://example.com",
|
||||||
|
credentials={"username": "admin", "app_password": "xxxx"},
|
||||||
|
skip_validation=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Site Retrieval and Deletion ──────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteRetrievalDeletion:
|
||||||
|
"""Test site list and delete operations."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_get_user_sites(self, mock_db):
|
||||||
|
"""get_user_sites should return sites without credential blobs."""
|
||||||
|
sites = await get_user_sites("user-uuid-001")
|
||||||
|
assert len(sites) == 1
|
||||||
|
assert sites[0]["alias"] == "myblog"
|
||||||
|
# Credentials should be stripped
|
||||||
|
assert "credentials" not in sites[0]
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_delete_user_site(self, mock_db):
|
||||||
|
"""delete_user_site should return True when site exists."""
|
||||||
|
deleted = await delete_user_site("site-uuid-001", "user-uuid-001")
|
||||||
|
assert deleted is True
|
||||||
|
mock_db.delete_site.assert_called_once_with("site-uuid-001", "user-uuid-001")
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_delete_user_site_not_found(self, mock_db):
|
||||||
|
"""delete_user_site should return False when site doesn't exist."""
|
||||||
|
mock_db.delete_site.return_value = False
|
||||||
|
deleted = await delete_user_site("nonexistent", "user-uuid-001")
|
||||||
|
assert deleted is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Connection Validation ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestConnectionValidation:
|
||||||
|
"""Test live connection validation with mocked HTTP."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_validate_connection_success(self):
|
||||||
|
"""Successful HTTP response should return (True, 'OK')."""
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.text = AsyncMock(return_value="OK")
|
||||||
|
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
||||||
|
mock_response.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(return_value=mock_response)
|
||||||
|
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("core.site_api.aiohttp.ClientSession", return_value=mock_session):
|
||||||
|
ok, msg = await validate_site_connection(
|
||||||
|
"wordpress",
|
||||||
|
"https://myblog.example.com",
|
||||||
|
{"username": "admin", "app_password": "xxxx"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert msg == "OK"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_validate_connection_timeout(self):
|
||||||
|
"""Timeout should return (False, message about timeout)."""
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(side_effect=TimeoutError("timed out"))
|
||||||
|
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("core.site_api.aiohttp.ClientSession", return_value=mock_session):
|
||||||
|
ok, msg = await validate_site_connection(
|
||||||
|
"wordpress",
|
||||||
|
"https://slow-site.example.com",
|
||||||
|
{"username": "admin", "app_password": "xxxx"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ok is False
|
||||||
|
assert "timed out" in msg.lower()
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_validate_connection_auth_failure(self):
|
||||||
|
"""HTTP 401 should return (False, message about authentication)."""
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 401
|
||||||
|
mock_response.text = AsyncMock(return_value="Unauthorized")
|
||||||
|
mock_response.__aenter__ = AsyncMock(return_value=mock_response)
|
||||||
|
mock_response.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(return_value=mock_response)
|
||||||
|
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||||
|
mock_session.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("core.site_api.aiohttp.ClientSession", return_value=mock_session):
|
||||||
|
ok, msg = await validate_site_connection(
|
||||||
|
"wordpress",
|
||||||
|
"https://myblog.example.com",
|
||||||
|
{"username": "admin", "app_password": "wrong"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert ok is False
|
||||||
|
assert "authentication" in msg.lower()
|
||||||
40
tests/test_tenant_isolation.py
Normal file
40
tests/test_tenant_isolation.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Tests for tenant isolation in Dashboard routes."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.dashboard.routes import get_all_projects
|
||||||
|
from core.site_manager import SiteConfig, SiteManager
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_all_projects_tenant_isolation(monkeypatch):
|
||||||
|
"""Normal user should only see their own sites, ignoring global ones."""
|
||||||
|
|
||||||
|
# Mock SiteManager with some global and user sites
|
||||||
|
mgr = SiteManager()
|
||||||
|
|
||||||
|
# Global site (no user_id)
|
||||||
|
mgr.register_site(SiteConfig(site_id="global1", plugin_type="wordpress"))
|
||||||
|
|
||||||
|
# User 123's site
|
||||||
|
mgr.register_site(SiteConfig(site_id="user1site", plugin_type="wordpress", user_id="user-123"))
|
||||||
|
|
||||||
|
monkeypatch.setattr("core.site_manager.get_site_manager", lambda: mgr)
|
||||||
|
|
||||||
|
# Normal user 456 (has no sites)
|
||||||
|
session_456 = {"user_id": "user-456", "type": "user"}
|
||||||
|
res = await get_all_projects(user_session=session_456)
|
||||||
|
assert len(res["projects"]) == 0
|
||||||
|
|
||||||
|
# Normal user 123 (has 1 site)
|
||||||
|
session_123 = {"user_id": "user-123", "type": "user"}
|
||||||
|
res = await get_all_projects(user_session=session_123)
|
||||||
|
assert len(res["projects"]) == 1
|
||||||
|
assert res["projects"][0]["site_id"] == "user1site"
|
||||||
|
|
||||||
|
# Master user (sees all)
|
||||||
|
class DummyMaster:
|
||||||
|
user_type = "master"
|
||||||
|
|
||||||
|
res = await get_all_projects(user_session=DummyMaster())
|
||||||
|
assert len(res["projects"]) == 2
|
||||||
444
tests/test_user_auth.py
Normal file
444
tests/test_user_auth.py
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
"""Tests for the user authentication system (OAuth Social Login)."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.user_auth import (
|
||||||
|
OAuthProvider,
|
||||||
|
UserAuth,
|
||||||
|
get_user_auth,
|
||||||
|
initialize_user_auth,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Fixtures ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_singleton():
|
||||||
|
"""Reset the global UserAuth singleton between tests."""
|
||||||
|
import core.user_auth as mod
|
||||||
|
|
||||||
|
mod._user_auth = None
|
||||||
|
yield
|
||||||
|
mod._user_auth = None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def user_auth():
|
||||||
|
"""Create a UserAuth instance with test credentials."""
|
||||||
|
return UserAuth(
|
||||||
|
github_client_id="gh_test_id",
|
||||||
|
github_client_secret="gh_test_secret",
|
||||||
|
google_client_id="google_test_id",
|
||||||
|
google_client_secret="google_test_secret",
|
||||||
|
public_url="https://mcp.example.com",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── OAuth URL Generation ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestOAuthURLGeneration:
|
||||||
|
"""Test OAuth authorization URL generation."""
|
||||||
|
|
||||||
|
def test_github_auth_url(self, user_auth):
|
||||||
|
"""GitHub auth URL should point to correct endpoint."""
|
||||||
|
url, state = user_auth.get_authorization_url("github")
|
||||||
|
assert "github.com/login/oauth/authorize" in url
|
||||||
|
assert "client_id=gh_test_id" in url
|
||||||
|
assert "state=" in url
|
||||||
|
assert len(state) == 64 # 32 bytes hex
|
||||||
|
|
||||||
|
def test_google_auth_url(self, user_auth):
|
||||||
|
"""Google auth URL should point to correct endpoint."""
|
||||||
|
url, state = user_auth.get_authorization_url("google")
|
||||||
|
assert "accounts.google.com/o/oauth2/v2/auth" in url
|
||||||
|
assert "client_id=google_test_id" in url
|
||||||
|
assert "state=" in url
|
||||||
|
|
||||||
|
def test_github_callback_url(self, user_auth):
|
||||||
|
"""GitHub auth URL should include correct callback URL."""
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
url, _ = user_auth.get_authorization_url("github")
|
||||||
|
decoded = unquote(url)
|
||||||
|
assert "redirect_uri=" in url
|
||||||
|
assert "mcp.example.com" in decoded
|
||||||
|
assert "/auth/callback/github" in decoded
|
||||||
|
|
||||||
|
def test_google_callback_url(self, user_auth):
|
||||||
|
"""Google auth URL should include correct callback URL."""
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
url, _ = user_auth.get_authorization_url("google")
|
||||||
|
decoded = unquote(url)
|
||||||
|
assert "redirect_uri=" in url
|
||||||
|
assert "/auth/callback/google" in decoded
|
||||||
|
|
||||||
|
def test_google_scopes(self, user_auth):
|
||||||
|
"""Google auth URL should request openid, email, and profile scopes."""
|
||||||
|
url, _ = user_auth.get_authorization_url("google")
|
||||||
|
assert "openid" in url
|
||||||
|
assert "email" in url
|
||||||
|
assert "profile" in url
|
||||||
|
|
||||||
|
def test_invalid_provider(self, user_auth):
|
||||||
|
"""Unsupported provider should raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Unsupported provider"):
|
||||||
|
user_auth.get_authorization_url("twitter")
|
||||||
|
|
||||||
|
|
||||||
|
# ── State Validation ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestStateValidation:
|
||||||
|
"""Test OAuth state parameter validation (CSRF protection)."""
|
||||||
|
|
||||||
|
def test_valid_state(self, user_auth):
|
||||||
|
"""A freshly generated state should validate successfully."""
|
||||||
|
_, state = user_auth.get_authorization_url("github")
|
||||||
|
assert user_auth.validate_state(state) is True
|
||||||
|
|
||||||
|
def test_invalid_state(self, user_auth):
|
||||||
|
"""An unknown state string should not validate."""
|
||||||
|
assert user_auth.validate_state("invalid_state_value") is False
|
||||||
|
|
||||||
|
def test_state_consumed_after_use(self, user_auth):
|
||||||
|
"""State token should be one-time use (consumed after validation)."""
|
||||||
|
_, state = user_auth.get_authorization_url("github")
|
||||||
|
assert user_auth.validate_state(state) is True
|
||||||
|
assert user_auth.validate_state(state) is False # Second use fails
|
||||||
|
|
||||||
|
def test_state_expiry(self, user_auth):
|
||||||
|
"""State token should be rejected after expiry (10 minutes)."""
|
||||||
|
_, state = user_auth.get_authorization_url("github")
|
||||||
|
# Manually expire the state (11+ minutes ago)
|
||||||
|
user_auth._pending_states[state] = time.time() - 700
|
||||||
|
assert user_auth.validate_state(state) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Token Exchange (Mocked) ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestTokenExchange:
|
||||||
|
"""Test OAuth token exchange with mocked HTTP responses."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_github_token_exchange(self, user_auth):
|
||||||
|
"""GitHub code exchange should return correct user info."""
|
||||||
|
mock_token_response = MagicMock()
|
||||||
|
mock_token_response.status_code = 200
|
||||||
|
mock_token_response.json.return_value = {
|
||||||
|
"access_token": "gho_test_token",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_user_response = MagicMock()
|
||||||
|
mock_user_response.status_code = 200
|
||||||
|
mock_user_response.json.return_value = {
|
||||||
|
"id": 12345,
|
||||||
|
"login": "testuser",
|
||||||
|
"name": "Test User",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/12345",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_token_response)
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_user_response)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
user_info = await user_auth.exchange_code("github", "test_code")
|
||||||
|
|
||||||
|
assert user_info["provider"] == "github"
|
||||||
|
assert user_info["provider_id"] == "12345"
|
||||||
|
assert user_info["email"] == "test@example.com"
|
||||||
|
assert user_info["name"] == "Test User"
|
||||||
|
assert user_info["avatar_url"] == ("https://avatars.githubusercontent.com/u/12345")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_github_email_fallback(self, user_auth):
|
||||||
|
"""When primary email is None, fetch from /user/emails endpoint."""
|
||||||
|
mock_token_response = MagicMock()
|
||||||
|
mock_token_response.status_code = 200
|
||||||
|
mock_token_response.json.return_value = {
|
||||||
|
"access_token": "gho_test_token",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_user_response = MagicMock()
|
||||||
|
mock_user_response.status_code = 200
|
||||||
|
mock_user_response.json.return_value = {
|
||||||
|
"id": 12345,
|
||||||
|
"login": "testuser",
|
||||||
|
"name": "Test User",
|
||||||
|
"email": None,
|
||||||
|
"avatar_url": "https://avatars.githubusercontent.com/u/12345",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_emails_response = MagicMock()
|
||||||
|
mock_emails_response.status_code = 200
|
||||||
|
mock_emails_response.json.return_value = [
|
||||||
|
{
|
||||||
|
"email": "secondary@example.com",
|
||||||
|
"primary": False,
|
||||||
|
"verified": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"email": "primary@example.com",
|
||||||
|
"primary": True,
|
||||||
|
"verified": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_token_response)
|
||||||
|
mock_client.get = AsyncMock(side_effect=[mock_user_response, mock_emails_response])
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
user_info = await user_auth.exchange_code("github", "test_code")
|
||||||
|
|
||||||
|
assert user_info["email"] == "primary@example.com"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_google_token_exchange(self, user_auth):
|
||||||
|
"""Google code exchange should return correct user info."""
|
||||||
|
mock_token_response = MagicMock()
|
||||||
|
mock_token_response.status_code = 200
|
||||||
|
mock_token_response.json.return_value = {
|
||||||
|
"access_token": "ya29.test_token",
|
||||||
|
}
|
||||||
|
|
||||||
|
mock_user_response = MagicMock()
|
||||||
|
mock_user_response.status_code = 200
|
||||||
|
mock_user_response.json.return_value = {
|
||||||
|
"sub": "google_67890",
|
||||||
|
"email": "test@gmail.com",
|
||||||
|
"name": "Test Google User",
|
||||||
|
"picture": "https://lh3.googleusercontent.com/photo.jpg",
|
||||||
|
"email_verified": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_token_response)
|
||||||
|
mock_client.get = AsyncMock(return_value=mock_user_response)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
user_info = await user_auth.exchange_code("google", "test_code")
|
||||||
|
|
||||||
|
assert user_info["provider"] == "google"
|
||||||
|
assert user_info["provider_id"] == "google_67890"
|
||||||
|
assert user_info["email"] == "test@gmail.com"
|
||||||
|
assert user_info["name"] == "Test Google User"
|
||||||
|
assert user_info["avatar_url"] == ("https://lh3.googleusercontent.com/photo.jpg")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_token_exchange_failure(self, user_auth):
|
||||||
|
"""Failed token exchange should raise ValueError."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 400
|
||||||
|
mock_response.text = "Bad Request"
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"error": "bad_verification_code",
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient") as mock_client_cls:
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||||
|
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||||
|
mock_client.post = AsyncMock(return_value=mock_response)
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="Failed to exchange"):
|
||||||
|
await user_auth.exchange_code("github", "bad_code")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exchange_unsupported_provider(self, user_auth):
|
||||||
|
"""Unsupported provider in exchange_code should raise ValueError."""
|
||||||
|
with pytest.raises(ValueError, match="Unsupported provider"):
|
||||||
|
await user_auth.exchange_code("twitter", "code")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Registration Rate Limiting ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistrationRateLimit:
|
||||||
|
"""Test registration rate limiting (3 per IP per hour)."""
|
||||||
|
|
||||||
|
def test_under_limit(self, user_auth):
|
||||||
|
"""IP under the limit should be allowed."""
|
||||||
|
assert user_auth.check_registration_rate("1.2.3.4") is True
|
||||||
|
user_auth.record_registration("1.2.3.4")
|
||||||
|
assert user_auth.check_registration_rate("1.2.3.4") is True
|
||||||
|
|
||||||
|
def test_at_limit(self, user_auth):
|
||||||
|
"""IP at the limit (3 registrations) should be blocked."""
|
||||||
|
for _ in range(3):
|
||||||
|
user_auth.record_registration("1.2.3.4")
|
||||||
|
assert user_auth.check_registration_rate("1.2.3.4") is False
|
||||||
|
|
||||||
|
def test_different_ips(self, user_auth):
|
||||||
|
"""Rate limiting should be per-IP."""
|
||||||
|
for _ in range(3):
|
||||||
|
user_auth.record_registration("1.2.3.4")
|
||||||
|
assert user_auth.check_registration_rate("5.6.7.8") is True
|
||||||
|
|
||||||
|
def test_expired_records_cleaned(self, user_auth):
|
||||||
|
"""Expired records should be cleaned up, allowing new registrations."""
|
||||||
|
user_auth.record_registration("1.2.3.4")
|
||||||
|
# Manually expire the record (over 1 hour ago)
|
||||||
|
user_auth._registration_records["1.2.3.4"] = [time.time() - 3700]
|
||||||
|
assert user_auth.check_registration_rate("1.2.3.4") is True
|
||||||
|
|
||||||
|
|
||||||
|
# ── Session Creation ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestUserSession:
|
||||||
|
"""Test user session JWT creation and validation."""
|
||||||
|
|
||||||
|
def test_create_user_session(self, user_auth):
|
||||||
|
"""create_user_session should return a non-empty JWT string."""
|
||||||
|
token = user_auth.create_user_session(
|
||||||
|
user_id="uuid-123",
|
||||||
|
email="test@example.com",
|
||||||
|
name="Test",
|
||||||
|
role="user",
|
||||||
|
)
|
||||||
|
assert isinstance(token, str)
|
||||||
|
assert len(token) > 0
|
||||||
|
|
||||||
|
def test_validate_user_session(self, user_auth):
|
||||||
|
"""validate_user_session should decode a valid token correctly."""
|
||||||
|
token = user_auth.create_user_session(
|
||||||
|
user_id="uuid-123",
|
||||||
|
email="test@example.com",
|
||||||
|
name="Test User",
|
||||||
|
role="user",
|
||||||
|
)
|
||||||
|
session = user_auth.validate_user_session(token)
|
||||||
|
assert session is not None
|
||||||
|
assert session["user_id"] == "uuid-123"
|
||||||
|
assert session["email"] == "test@example.com"
|
||||||
|
assert session["name"] == "Test User"
|
||||||
|
assert session["role"] == "user"
|
||||||
|
assert session["type"] == "oauth_user"
|
||||||
|
|
||||||
|
def test_invalid_token(self, user_auth):
|
||||||
|
"""validate_user_session should return None for garbage tokens."""
|
||||||
|
assert user_auth.validate_user_session("garbage.token.here") is None
|
||||||
|
|
||||||
|
def test_expired_token(self, user_auth):
|
||||||
|
"""validate_user_session should return None for expired tokens."""
|
||||||
|
import jwt as pyjwt
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"uid": "uuid-123",
|
||||||
|
"email": "test@example.com",
|
||||||
|
"name": "Test",
|
||||||
|
"role": "user",
|
||||||
|
"type": "oauth_user",
|
||||||
|
"iat": time.time() - 7200,
|
||||||
|
"exp": time.time() - 3600, # Expired 1 hour ago
|
||||||
|
}
|
||||||
|
token = pyjwt.encode(payload, user_auth._session_secret, algorithm="HS256")
|
||||||
|
assert user_auth.validate_user_session(token) is None
|
||||||
|
|
||||||
|
def test_session_with_none_name(self, user_auth):
|
||||||
|
"""Session with None name should store empty string."""
|
||||||
|
token = user_auth.create_user_session(
|
||||||
|
user_id="uuid-456",
|
||||||
|
email="noname@example.com",
|
||||||
|
name=None,
|
||||||
|
role="user",
|
||||||
|
)
|
||||||
|
session = user_auth.validate_user_session(token)
|
||||||
|
assert session is not None
|
||||||
|
assert session["name"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Singleton Pattern ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleton:
|
||||||
|
"""Test module-level singleton pattern."""
|
||||||
|
|
||||||
|
def test_initialize_and_get(self):
|
||||||
|
"""initialize_user_auth should set the singleton retrievable by get."""
|
||||||
|
auth = initialize_user_auth(
|
||||||
|
github_client_id="gh_id",
|
||||||
|
github_client_secret="gh_secret",
|
||||||
|
google_client_id="g_id",
|
||||||
|
google_client_secret="g_secret",
|
||||||
|
public_url="https://example.com",
|
||||||
|
)
|
||||||
|
assert get_user_auth() is auth
|
||||||
|
|
||||||
|
def test_get_without_init_raises(self):
|
||||||
|
"""get_user_auth before init should raise RuntimeError."""
|
||||||
|
with pytest.raises(RuntimeError, match="not initialized"):
|
||||||
|
get_user_auth()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Provider Config ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestProviderConfig:
|
||||||
|
"""Test provider availability detection."""
|
||||||
|
|
||||||
|
def test_available_providers_both(self, user_auth):
|
||||||
|
"""Both providers configured should both appear."""
|
||||||
|
providers = user_auth.available_providers()
|
||||||
|
assert "github" in providers
|
||||||
|
assert "google" in providers
|
||||||
|
|
||||||
|
def test_no_github_if_missing_creds(self):
|
||||||
|
"""Missing GitHub creds should exclude github from providers."""
|
||||||
|
auth = UserAuth(
|
||||||
|
google_client_id="g_id",
|
||||||
|
google_client_secret="g_secret",
|
||||||
|
public_url="https://example.com",
|
||||||
|
)
|
||||||
|
providers = auth.available_providers()
|
||||||
|
assert "github" not in providers
|
||||||
|
assert "google" in providers
|
||||||
|
|
||||||
|
def test_no_google_if_missing_creds(self):
|
||||||
|
"""Missing Google creds should exclude google from providers."""
|
||||||
|
auth = UserAuth(
|
||||||
|
github_client_id="gh_id",
|
||||||
|
github_client_secret="gh_secret",
|
||||||
|
public_url="https://example.com",
|
||||||
|
)
|
||||||
|
providers = auth.available_providers()
|
||||||
|
assert "github" in providers
|
||||||
|
assert "google" not in providers
|
||||||
|
|
||||||
|
def test_no_providers_if_none_configured(self):
|
||||||
|
"""No credentials at all should return empty providers list."""
|
||||||
|
auth = UserAuth(public_url="https://example.com")
|
||||||
|
assert auth.available_providers() == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── OAuthProvider Constants ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestOAuthProviderConstants:
|
||||||
|
"""Test OAuthProvider class constants."""
|
||||||
|
|
||||||
|
def test_github_constant(self):
|
||||||
|
"""OAuthProvider.GITHUB should be 'github'."""
|
||||||
|
assert OAuthProvider.GITHUB == "github"
|
||||||
|
|
||||||
|
def test_google_constant(self):
|
||||||
|
"""OAuthProvider.GOOGLE should be 'google'."""
|
||||||
|
assert OAuthProvider.GOOGLE == "google"
|
||||||
366
tests/test_user_endpoints.py
Normal file
366
tests/test_user_endpoints.py
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
"""Tests for per-user MCP endpoint handler (core/user_endpoints.py)."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from core.user_endpoints import (
|
||||||
|
USER_RATE_LIMIT_PER_MIN,
|
||||||
|
_rate_limits,
|
||||||
|
user_mcp_handler,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request(
|
||||||
|
user_id: str = "user-uuid-001",
|
||||||
|
alias: str = "myblog",
|
||||||
|
method_name: str = "initialize",
|
||||||
|
params: dict | None = None,
|
||||||
|
api_key: str = "mhu_validkey1234567890abcdefghijklmnopqrst",
|
||||||
|
req_id: int = 1,
|
||||||
|
) -> Request:
|
||||||
|
"""Build a mock Starlette Request for the user MCP endpoint."""
|
||||||
|
body = {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"method": method_name,
|
||||||
|
"params": params or {},
|
||||||
|
}
|
||||||
|
body_bytes = json.dumps(body).encode()
|
||||||
|
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "POST",
|
||||||
|
"path": f"/u/{user_id}/{alias}/mcp",
|
||||||
|
"path_params": {"user_id": user_id, "alias": alias},
|
||||||
|
"headers": [],
|
||||||
|
"query_string": b"",
|
||||||
|
}
|
||||||
|
|
||||||
|
if api_key:
|
||||||
|
scope["headers"].append((b"authorization", f"Bearer {api_key}".encode()))
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {"type": "http.request", "body": body_bytes}
|
||||||
|
|
||||||
|
return Request(scope, receive)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_request_no_auth(
|
||||||
|
user_id: str = "user-uuid-001",
|
||||||
|
alias: str = "myblog",
|
||||||
|
) -> Request:
|
||||||
|
"""Build a mock Request without an Authorization header."""
|
||||||
|
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize"}).encode()
|
||||||
|
scope = {
|
||||||
|
"type": "http",
|
||||||
|
"method": "POST",
|
||||||
|
"path": f"/u/{user_id}/{alias}/mcp",
|
||||||
|
"path_params": {"user_id": user_id, "alias": alias},
|
||||||
|
"headers": [],
|
||||||
|
"query_string": b"",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def receive():
|
||||||
|
return {"type": "http.request", "body": body}
|
||||||
|
|
||||||
|
return Request(scope, receive)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fixtures ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_rate_limits():
|
||||||
|
"""Clear the global rate limit tracking between tests."""
|
||||||
|
_rate_limits.clear()
|
||||||
|
yield
|
||||||
|
_rate_limits.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_tool_cache():
|
||||||
|
"""Clear the tool schema cache between tests."""
|
||||||
|
import core.user_endpoints as mod
|
||||||
|
|
||||||
|
mod._tool_schema_cache.clear()
|
||||||
|
yield
|
||||||
|
mod._tool_schema_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_key_mgr():
|
||||||
|
"""Patch get_user_key_manager to return a mock."""
|
||||||
|
mgr = AsyncMock()
|
||||||
|
mgr.validate_key = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"key_id": "key-uuid-001",
|
||||||
|
"user_id": "user-uuid-001",
|
||||||
|
"scopes": "read write",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch("core.user_keys.get_user_key_manager", return_value=mgr):
|
||||||
|
yield mgr
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_db():
|
||||||
|
"""Patch get_database to return a mock."""
|
||||||
|
db = AsyncMock()
|
||||||
|
db.get_site_by_alias = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"id": "site-uuid-001",
|
||||||
|
"user_id": "user-uuid-001",
|
||||||
|
"plugin_type": "wordpress",
|
||||||
|
"alias": "myblog",
|
||||||
|
"url": "https://myblog.example.com",
|
||||||
|
"credentials": b"encrypted-blob",
|
||||||
|
"status": "active",
|
||||||
|
"status_msg": "OK",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch("core.database.get_database", return_value=db):
|
||||||
|
yield db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_encryption():
|
||||||
|
"""Patch get_credential_encryption to return a mock."""
|
||||||
|
enc = MagicMock()
|
||||||
|
enc.decrypt_credentials = MagicMock(
|
||||||
|
return_value={
|
||||||
|
"username": "admin",
|
||||||
|
"app_password": "xxxx xxxx xxxx xxxx",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
with patch("core.encryption.get_credential_encryption", return_value=enc):
|
||||||
|
yield enc
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_tool_registry():
|
||||||
|
"""Patch get_tool_registry to return a registry with a sample tool."""
|
||||||
|
tool_def = MagicMock()
|
||||||
|
tool_def.name = "wordpress_list_posts"
|
||||||
|
tool_def.description = "List WordPress posts"
|
||||||
|
tool_def.input_schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"site": {"type": "string", "description": "Site identifier"},
|
||||||
|
"status": {"type": "string", "description": "Post status"},
|
||||||
|
},
|
||||||
|
"required": ["site"],
|
||||||
|
}
|
||||||
|
|
||||||
|
registry = MagicMock()
|
||||||
|
registry.get_by_plugin_type = MagicMock(return_value=[tool_def])
|
||||||
|
|
||||||
|
with patch("core.tool_registry.get_tool_registry", return_value=registry):
|
||||||
|
yield registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_plugin_registry():
|
||||||
|
"""Patch plugins.plugin_registry to return a mock."""
|
||||||
|
mock_reg = MagicMock()
|
||||||
|
mock_reg.is_registered = MagicMock(return_value=True)
|
||||||
|
|
||||||
|
mock_instance = MagicMock()
|
||||||
|
mock_instance.list_posts = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{"id": 1, "title": "Hello World"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
mock_reg.create_instance = MagicMock(return_value=mock_instance)
|
||||||
|
|
||||||
|
with patch("plugins.plugin_registry", mock_reg, create=True):
|
||||||
|
yield mock_reg
|
||||||
|
|
||||||
|
|
||||||
|
# ── Authentication Tests ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestAuthentication:
|
||||||
|
"""Test authentication checks in user_mcp_handler."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_missing_auth_header(self, mock_key_mgr, mock_db):
|
||||||
|
"""Request without Authorization header should return 401."""
|
||||||
|
request = _make_request_no_auth()
|
||||||
|
response = await user_mcp_handler(request)
|
||||||
|
assert response.status_code == 401
|
||||||
|
body = json.loads(response.body)
|
||||||
|
assert "error" in body
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_invalid_api_key(self, mock_key_mgr, mock_db):
|
||||||
|
"""Invalid API key should return 401."""
|
||||||
|
mock_key_mgr.validate_key.return_value = None
|
||||||
|
request = _make_request(api_key="mhu_invalidkeyvalue")
|
||||||
|
response = await user_mcp_handler(request)
|
||||||
|
assert response.status_code == 401
|
||||||
|
body = json.loads(response.body)
|
||||||
|
assert "Invalid API key" in body["error"]["message"]
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_user_id_mismatch(self, mock_key_mgr, mock_db):
|
||||||
|
"""API key user_id not matching URL user_id should return 403."""
|
||||||
|
mock_key_mgr.validate_key.return_value = {
|
||||||
|
"key_id": "key-uuid-001",
|
||||||
|
"user_id": "different-user-id",
|
||||||
|
"scopes": "read write",
|
||||||
|
}
|
||||||
|
request = _make_request(user_id="user-uuid-001")
|
||||||
|
response = await user_mcp_handler(request)
|
||||||
|
assert response.status_code == 403
|
||||||
|
body = json.loads(response.body)
|
||||||
|
assert "does not match" in body["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Site Lookup Tests ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSiteLookup:
|
||||||
|
"""Test site lookup behavior."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_site_not_found(self, mock_key_mgr, mock_db):
|
||||||
|
"""Non-existent alias should return 404."""
|
||||||
|
mock_db.get_site_by_alias.return_value = None
|
||||||
|
request = _make_request(alias="nonexistent")
|
||||||
|
response = await user_mcp_handler(request)
|
||||||
|
assert response.status_code == 404
|
||||||
|
body = json.loads(response.body)
|
||||||
|
assert "not found" in body["error"]["message"]
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_disabled_site(self, mock_key_mgr, mock_db):
|
||||||
|
"""Disabled site should return 403."""
|
||||||
|
mock_db.get_site_by_alias.return_value = {
|
||||||
|
"id": "site-uuid-001",
|
||||||
|
"user_id": "user-uuid-001",
|
||||||
|
"plugin_type": "wordpress",
|
||||||
|
"alias": "myblog",
|
||||||
|
"url": "https://myblog.example.com",
|
||||||
|
"credentials": b"encrypted-blob",
|
||||||
|
"status": "disabled",
|
||||||
|
"status_msg": "Disabled by admin",
|
||||||
|
}
|
||||||
|
request = _make_request()
|
||||||
|
response = await user_mcp_handler(request)
|
||||||
|
assert response.status_code == 403
|
||||||
|
body = json.loads(response.body)
|
||||||
|
assert "disabled" in body["error"]["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ── MCP Protocol Methods ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestMCPMethods:
|
||||||
|
"""Test MCP JSON-RPC method handling."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_initialize_method(self, mock_key_mgr, mock_db):
|
||||||
|
"""initialize should return protocolVersion and capabilities."""
|
||||||
|
request = _make_request(method_name="initialize")
|
||||||
|
response = await user_mcp_handler(request)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = json.loads(response.body)
|
||||||
|
result = body["result"]
|
||||||
|
assert "protocolVersion" in result
|
||||||
|
assert "capabilities" in result
|
||||||
|
assert "tools" in result["capabilities"]
|
||||||
|
assert "serverInfo" in result
|
||||||
|
assert "myblog" in result["serverInfo"]["name"]
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_notifications_initialized(self, mock_key_mgr, mock_db):
|
||||||
|
"""notifications/initialized should return 204 with no body."""
|
||||||
|
request = _make_request(method_name="notifications/initialized")
|
||||||
|
response = await user_mcp_handler(request)
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_tools_list(self, mock_key_mgr, mock_db, mock_tool_registry):
|
||||||
|
"""tools/list should return tools with site param removed."""
|
||||||
|
request = _make_request(method_name="tools/list")
|
||||||
|
response = await user_mcp_handler(request)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = json.loads(response.body)
|
||||||
|
tools = body["result"]["tools"]
|
||||||
|
assert len(tools) == 1
|
||||||
|
assert tools[0]["name"] == "wordpress_list_posts"
|
||||||
|
# 'site' should be removed from properties and required
|
||||||
|
schema = tools[0]["inputSchema"]
|
||||||
|
assert "site" not in schema.get("properties", {})
|
||||||
|
if "required" in schema:
|
||||||
|
assert "site" not in schema["required"]
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_tools_call_invalid_tool(self, mock_key_mgr, mock_db):
|
||||||
|
"""Calling a tool with wrong plugin prefix should return error."""
|
||||||
|
request = _make_request(
|
||||||
|
method_name="tools/call",
|
||||||
|
params={"name": "gitea_list_repos", "arguments": {}},
|
||||||
|
)
|
||||||
|
response = await user_mcp_handler(request)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = json.loads(response.body)
|
||||||
|
assert "error" in body
|
||||||
|
assert "not available" in body["error"]["message"]
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_tools_call_success(
|
||||||
|
self,
|
||||||
|
mock_key_mgr,
|
||||||
|
mock_db,
|
||||||
|
mock_encryption,
|
||||||
|
mock_tool_registry,
|
||||||
|
mock_plugin_registry,
|
||||||
|
):
|
||||||
|
"""Calling a valid tool should return content result."""
|
||||||
|
request = _make_request(
|
||||||
|
method_name="tools/call",
|
||||||
|
params={"name": "wordpress_list_posts", "arguments": {"status": "publish"}},
|
||||||
|
)
|
||||||
|
response = await user_mcp_handler(request)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = json.loads(response.body)
|
||||||
|
assert "result" in body
|
||||||
|
assert "content" in body["result"]
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_unsupported_method(self, mock_key_mgr, mock_db):
|
||||||
|
"""Unknown MCP method should return -32601 error."""
|
||||||
|
request = _make_request(method_name="resources/list")
|
||||||
|
response = await user_mcp_handler(request)
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = json.loads(response.body)
|
||||||
|
assert "error" in body
|
||||||
|
assert body["error"]["code"] == -32601
|
||||||
|
assert "not supported" in body["error"]["message"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Rate Limiting ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestRateLimiting:
|
||||||
|
"""Test per-user rate limiting."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_rate_limit_exceeded(self, mock_key_mgr, mock_db):
|
||||||
|
"""Exceeding per-minute rate limit should return 429."""
|
||||||
|
# Fill the rate limit bucket
|
||||||
|
now = time.time()
|
||||||
|
_rate_limits["user-uuid-001"] = [now - i for i in range(USER_RATE_LIMIT_PER_MIN)]
|
||||||
|
|
||||||
|
request = _make_request(method_name="initialize")
|
||||||
|
response = await user_mcp_handler(request)
|
||||||
|
assert response.status_code == 429
|
||||||
|
body = json.loads(response.body)
|
||||||
|
assert "Rate limit" in body["error"]["message"]
|
||||||
294
tests/test_user_keys.py
Normal file
294
tests/test_user_keys.py
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
"""Tests for User API Key management (core/user_keys.py)."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.user_keys import (
|
||||||
|
KEY_PREFIX_TAG,
|
||||||
|
UserKeyManager,
|
||||||
|
get_user_key_manager,
|
||||||
|
initialize_user_key_manager,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Fixtures ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_singleton():
|
||||||
|
"""Reset the global UserKeyManager singleton between tests."""
|
||||||
|
import core.user_keys as mod
|
||||||
|
|
||||||
|
original = mod._manager
|
||||||
|
mod._manager = None
|
||||||
|
yield
|
||||||
|
mod._manager = original
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_db():
|
||||||
|
"""Create and patch a mock database instance."""
|
||||||
|
db = AsyncMock()
|
||||||
|
# Default return values for DB methods
|
||||||
|
db.create_api_key = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"id": "key-uuid-001",
|
||||||
|
"user_id": "user-uuid-001",
|
||||||
|
"key_hash": "$2b$12$fakehashvalue",
|
||||||
|
"key_prefix": "abcdefgh",
|
||||||
|
"name": "Claude Desktop",
|
||||||
|
"scopes": "read write",
|
||||||
|
"created_at": "2026-02-19T12:00:00Z",
|
||||||
|
"expires_at": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.get_api_key_by_prefix = AsyncMock(return_value=None)
|
||||||
|
db.get_api_keys_by_user = AsyncMock(
|
||||||
|
return_value=[
|
||||||
|
{
|
||||||
|
"id": "key-uuid-001",
|
||||||
|
"name": "Claude Desktop",
|
||||||
|
"scopes": "read write",
|
||||||
|
"created_at": "2026-02-19T12:00:00Z",
|
||||||
|
"expires_at": None,
|
||||||
|
"last_used_at": None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
db.delete_api_key = AsyncMock(return_value=True)
|
||||||
|
db.update_api_key_usage = AsyncMock()
|
||||||
|
with patch("core.database.get_database", return_value=db):
|
||||||
|
yield db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def key_mgr():
|
||||||
|
"""Create a fresh UserKeyManager instance."""
|
||||||
|
return UserKeyManager()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Key Creation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyCreation:
|
||||||
|
"""Test API key creation."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_key_format(self, key_mgr, mock_db):
|
||||||
|
"""Created key should start with 'mhu_' and have substantial length."""
|
||||||
|
result = await key_mgr.create_key("user-uuid-001", "Claude Desktop")
|
||||||
|
raw_key = result["key"]
|
||||||
|
assert raw_key.startswith(KEY_PREFIX_TAG)
|
||||||
|
# mhu_ (4) + urlsafe_b64 of 32 bytes (43 chars) = 47 chars total
|
||||||
|
assert len(raw_key) == 47
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_key_stores_bcrypt_hash(self, key_mgr, mock_db):
|
||||||
|
"""The key_hash passed to DB should be a bcrypt hash ($2b$ prefix)."""
|
||||||
|
await key_mgr.create_key("user-uuid-001", "Test Key")
|
||||||
|
call_kwargs = mock_db.create_api_key.call_args
|
||||||
|
key_hash = call_kwargs.kwargs.get("key_hash") or call_kwargs[1].get("key_hash")
|
||||||
|
assert key_hash.startswith("$2b$")
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_create_key_returns_metadata(self, key_mgr, mock_db):
|
||||||
|
"""Create key should return key_id, name, scopes, and timestamps."""
|
||||||
|
result = await key_mgr.create_key("user-uuid-001", "Claude Desktop")
|
||||||
|
assert "key_id" in result
|
||||||
|
assert result["name"] == "Claude Desktop"
|
||||||
|
assert result["scopes"] == "read write"
|
||||||
|
assert "created_at" in result
|
||||||
|
|
||||||
|
|
||||||
|
# ── Key Validation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyValidation:
|
||||||
|
"""Test API key validation logic."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_validate_key_success(self, key_mgr, mock_db):
|
||||||
|
"""Creating then validating a key should return valid info."""
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
# Create a key and capture the raw key
|
||||||
|
result = await key_mgr.create_key("user-uuid-001", "Test Key")
|
||||||
|
raw_key = result["key"]
|
||||||
|
|
||||||
|
# Set up DB to return a matching row with correct bcrypt hash
|
||||||
|
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
||||||
|
mock_db.get_api_key_by_prefix.return_value = {
|
||||||
|
"id": "key-uuid-001",
|
||||||
|
"user_id": "user-uuid-001",
|
||||||
|
"key_hash": key_hash,
|
||||||
|
"scopes": "read write",
|
||||||
|
"expires_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
info = await key_mgr.validate_key(raw_key)
|
||||||
|
assert info is not None
|
||||||
|
assert info["user_id"] == "user-uuid-001"
|
||||||
|
assert info["key_id"] == "key-uuid-001"
|
||||||
|
assert info["scopes"] == "read write"
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_validate_key_wrong_key(self, key_mgr, mock_db):
|
||||||
|
"""A key that doesn't match the bcrypt hash should return None."""
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
correct_key = "mhu_correctkeyvalue1234567890abcdefghijklmno"
|
||||||
|
key_hash = bcrypt.hashpw(correct_key.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
mock_db.get_api_key_by_prefix.return_value = {
|
||||||
|
"id": "key-uuid-001",
|
||||||
|
"user_id": "user-uuid-001",
|
||||||
|
"key_hash": key_hash,
|
||||||
|
"scopes": "read write",
|
||||||
|
"expires_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
wrong_key = "mhu_wrongkeyvalue1234567890abcdefghijklmnopq"
|
||||||
|
info = await key_mgr.validate_key(wrong_key)
|
||||||
|
assert info is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_validate_key_no_prefix(self, key_mgr, mock_db):
|
||||||
|
"""A key without the 'mhu_' prefix should immediately return None."""
|
||||||
|
info = await key_mgr.validate_key("bad_prefix_key")
|
||||||
|
assert info is None
|
||||||
|
# DB should not even be queried
|
||||||
|
mock_db.get_api_key_by_prefix.assert_not_called()
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_validate_key_expired(self, key_mgr, mock_db):
|
||||||
|
"""A key with expires_at in the past should return None."""
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
raw_key = "mhu_expiredkeyvalue1234567890abcdefghijklmno"
|
||||||
|
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
||||||
|
|
||||||
|
expired_at = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
|
||||||
|
mock_db.get_api_key_by_prefix.return_value = {
|
||||||
|
"id": "key-uuid-002",
|
||||||
|
"user_id": "user-uuid-001",
|
||||||
|
"key_hash": key_hash,
|
||||||
|
"scopes": "read",
|
||||||
|
"expires_at": expired_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
info = await key_mgr.validate_key(raw_key)
|
||||||
|
assert info is None
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_validate_key_cache(self, key_mgr, mock_db):
|
||||||
|
"""Second validation of the same key should use cache (no DB prefix lookup)."""
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
result = await key_mgr.create_key("user-uuid-001", "Cache Test")
|
||||||
|
raw_key = result["key"]
|
||||||
|
|
||||||
|
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
||||||
|
mock_db.get_api_key_by_prefix.return_value = {
|
||||||
|
"id": "key-uuid-001",
|
||||||
|
"user_id": "user-uuid-001",
|
||||||
|
"key_hash": key_hash,
|
||||||
|
"scopes": "read write",
|
||||||
|
"expires_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
# First validation — hits DB
|
||||||
|
info1 = await key_mgr.validate_key(raw_key)
|
||||||
|
assert info1 is not None
|
||||||
|
assert mock_db.get_api_key_by_prefix.call_count == 1
|
||||||
|
|
||||||
|
# Second validation — should use cache
|
||||||
|
info2 = await key_mgr.validate_key(raw_key)
|
||||||
|
assert info2 is not None
|
||||||
|
# get_api_key_by_prefix should NOT be called again
|
||||||
|
assert mock_db.get_api_key_by_prefix.call_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_validate_key_updates_usage(self, key_mgr, mock_db):
|
||||||
|
"""Successful validation should call update_api_key_usage."""
|
||||||
|
import bcrypt
|
||||||
|
|
||||||
|
result = await key_mgr.create_key("user-uuid-001", "Usage Test")
|
||||||
|
raw_key = result["key"]
|
||||||
|
|
||||||
|
key_hash = bcrypt.hashpw(raw_key.encode(), bcrypt.gensalt()).decode()
|
||||||
|
mock_db.get_api_key_by_prefix.return_value = {
|
||||||
|
"id": "key-uuid-001",
|
||||||
|
"user_id": "user-uuid-001",
|
||||||
|
"key_hash": key_hash,
|
||||||
|
"scopes": "read write",
|
||||||
|
"expires_at": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
await key_mgr.validate_key(raw_key)
|
||||||
|
mock_db.update_api_key_usage.assert_called_with("key-uuid-001")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Key Listing ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyListing:
|
||||||
|
"""Test key listing functionality."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_list_keys_no_hash(self, key_mgr, mock_db):
|
||||||
|
"""Listed keys should not contain key_hash field."""
|
||||||
|
keys = await key_mgr.list_keys("user-uuid-001")
|
||||||
|
assert len(keys) == 1
|
||||||
|
assert "key_hash" not in keys[0]
|
||||||
|
assert keys[0]["name"] == "Claude Desktop"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Key Deletion ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestKeyDeletion:
|
||||||
|
"""Test key deletion and cache invalidation."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_delete_key_clears_cache(self, key_mgr, mock_db):
|
||||||
|
"""Deleting a key should remove it from the validation cache."""
|
||||||
|
# Populate cache manually
|
||||||
|
key_mgr._cache["mhu_test_cached_key"] = (
|
||||||
|
"key-uuid-001",
|
||||||
|
"user-uuid-001",
|
||||||
|
"read write",
|
||||||
|
time.time(),
|
||||||
|
)
|
||||||
|
assert "mhu_test_cached_key" in key_mgr._cache
|
||||||
|
|
||||||
|
deleted = await key_mgr.delete_key("key-uuid-001", "user-uuid-001")
|
||||||
|
assert deleted is True
|
||||||
|
assert "mhu_test_cached_key" not in key_mgr._cache
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
async def test_delete_key_not_found(self, key_mgr, mock_db):
|
||||||
|
"""Deleting a non-existent key should return False."""
|
||||||
|
mock_db.delete_api_key.return_value = False
|
||||||
|
deleted = await key_mgr.delete_key("nonexistent-key", "user-uuid-001")
|
||||||
|
assert deleted is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Singleton Pattern ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleton:
|
||||||
|
"""Test module-level singleton pattern."""
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_singleton_pattern(self):
|
||||||
|
"""initialize_user_key_manager should set the singleton retrievable by get."""
|
||||||
|
mgr = initialize_user_key_manager()
|
||||||
|
assert get_user_key_manager() is mgr
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_get_without_init_raises(self):
|
||||||
|
"""get_user_key_manager before init should raise RuntimeError."""
|
||||||
|
with pytest.raises(RuntimeError, match="not initialized"):
|
||||||
|
get_user_key_manager()
|
||||||
@@ -8,10 +8,16 @@ import base64
|
|||||||
import json
|
import json
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from plugins.base import BasePlugin, PluginRegistry
|
from plugins.base import BasePlugin, PluginRegistry
|
||||||
from plugins.wordpress.client import AuthenticationError, ConfigurationError, WordPressClient
|
from plugins.wordpress.client import (
|
||||||
|
AuthenticationError,
|
||||||
|
ConfigurationError,
|
||||||
|
ConnectionError,
|
||||||
|
WordPressClient,
|
||||||
|
)
|
||||||
from plugins.wordpress.plugin import WordPressPlugin
|
from plugins.wordpress.plugin import WordPressPlugin
|
||||||
|
|
||||||
# --- WordPressClient Tests ---
|
# --- WordPressClient Tests ---
|
||||||
@@ -282,6 +288,282 @@ class TestWordPressClientRequest:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Connection Error Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
class TestWordPressClientConnectionErrors:
|
||||||
|
"""Test network error differentiation and retry logic."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self):
|
||||||
|
return WordPressClient(
|
||||||
|
site_url="https://example.com",
|
||||||
|
username="admin",
|
||||||
|
app_password="xxxx",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dns_error_raises_connection_error(self, client):
|
||||||
|
"""DNS failure should raise ConnectionError with helpful message."""
|
||||||
|
dns_error = aiohttp.ClientConnectorDNSError(
|
||||||
|
connection_key=MagicMock(), os_error=OSError("Name resolution failed")
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.request = MagicMock(side_effect=dns_error)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
with pytest.raises(ConnectionError, match="DNS resolution failed"):
|
||||||
|
await client.request("GET", "posts")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ssl_error_raises_connection_error(self, client):
|
||||||
|
"""SSL certificate error should raise ConnectionError with helpful message."""
|
||||||
|
ssl_error = aiohttp.ClientConnectorCertificateError(
|
||||||
|
connection_key=MagicMock(), certificate_error=Exception("cert expired")
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.request = MagicMock(side_effect=ssl_error)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
with pytest.raises(ConnectionError, match="SSL certificate error"):
|
||||||
|
await client.request("GET", "posts")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connection_refused_raises_connection_error(self, client):
|
||||||
|
"""Connection refused should raise ConnectionError with helpful message."""
|
||||||
|
conn_error = aiohttp.ClientConnectorError(
|
||||||
|
connection_key=MagicMock(), os_error=OSError("Connection refused")
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.request = MagicMock(side_effect=conn_error)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
with pytest.raises(ConnectionError, match="Cannot connect"):
|
||||||
|
await client.request("GET", "posts")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_timeout_retries_then_raises(self, client):
|
||||||
|
"""Timeout should retry then raise ConnectionError."""
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.request = MagicMock(side_effect=TimeoutError())
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
with pytest.raises(ConnectionError, match="timed out"):
|
||||||
|
await client.request("GET", "posts")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_invalid_url_raises_connection_error(self, client):
|
||||||
|
"""Invalid URL should raise ConnectionError."""
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.request = MagicMock(side_effect=aiohttp.InvalidURL("bad url"))
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
with pytest.raises(ConnectionError, match="Invalid URL"):
|
||||||
|
await client.request("GET", "posts")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_error_not_retried(self, client):
|
||||||
|
"""Auth errors should NOT be retried."""
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 401
|
||||||
|
mock_response.text = AsyncMock(
|
||||||
|
return_value=json.dumps({"code": "invalid_auth", "message": "Bad"})
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.request = MagicMock(
|
||||||
|
return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
with pytest.raises(AuthenticationError):
|
||||||
|
await client.request("GET", "posts")
|
||||||
|
# Should be called only once (no retry)
|
||||||
|
assert mock_session.request.call_count == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_502_retried_then_raises(self, client):
|
||||||
|
"""502 errors should be retried before raising."""
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 502
|
||||||
|
mock_response.text = AsyncMock(return_value="Bad Gateway")
|
||||||
|
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.request = MagicMock(
|
||||||
|
return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
with patch("asyncio.sleep", new_callable=AsyncMock):
|
||||||
|
with pytest.raises(Exception, match="BAD_GATEWAY"):
|
||||||
|
await client.request("GET", "posts")
|
||||||
|
# Should be called 3 times (1 + 2 retries)
|
||||||
|
assert mock_session.request.call_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestWordPressClientHealthCheck:
|
||||||
|
"""Test site health check with error differentiation."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(self):
|
||||||
|
return WordPressClient(
|
||||||
|
site_url="https://example.com",
|
||||||
|
username="admin",
|
||||||
|
app_password="xxxx",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_healthy_site(self, client):
|
||||||
|
"""Healthy site should return proper status."""
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = 200
|
||||||
|
mock_response.json = AsyncMock(
|
||||||
|
return_value={
|
||||||
|
"name": "My Blog",
|
||||||
|
"description": "A blog",
|
||||||
|
"url": "https://example.com",
|
||||||
|
"routes": {"a": 1},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(
|
||||||
|
return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
result = await client.check_site_health()
|
||||||
|
assert result["healthy"] is True
|
||||||
|
assert result["name"] == "My Blog"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rest_api_disabled_detected(self, client):
|
||||||
|
"""403/404 on /wp-json should detect REST API disabled."""
|
||||||
|
for status in (403, 404):
|
||||||
|
mock_response = AsyncMock()
|
||||||
|
mock_response.status = status
|
||||||
|
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(
|
||||||
|
return_value=AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_response),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
result = await client.check_site_health()
|
||||||
|
assert result["healthy"] is False
|
||||||
|
assert result["accessible"] is True
|
||||||
|
assert result["error_type"] == "rest_api_disabled"
|
||||||
|
assert "REST API" in result["message"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dns_failure_in_health_check(self, client):
|
||||||
|
"""DNS failure in health check should be detected."""
|
||||||
|
dns_error = aiohttp.ClientConnectorDNSError(
|
||||||
|
connection_key=MagicMock(), os_error=OSError("DNS failed")
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(side_effect=dns_error)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
result = await client.check_site_health()
|
||||||
|
assert result["healthy"] is False
|
||||||
|
assert result["error_type"] == "dns_failure"
|
||||||
|
assert "DNS" in result["message"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_timeout_in_health_check(self, client):
|
||||||
|
"""Timeout in health check should be detected."""
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(side_effect=TimeoutError())
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
result = await client.check_site_health()
|
||||||
|
assert result["healthy"] is False
|
||||||
|
assert result["error_type"] == "timeout"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ssl_error_in_health_check(self, client):
|
||||||
|
"""SSL error in health check should be detected."""
|
||||||
|
ssl_error = aiohttp.ClientConnectorCertificateError(
|
||||||
|
connection_key=MagicMock(), certificate_error=Exception("expired")
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(side_effect=ssl_error)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
result = await client.check_site_health()
|
||||||
|
assert result["healthy"] is False
|
||||||
|
assert result["error_type"] == "ssl_error"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connection_refused_in_health_check(self, client):
|
||||||
|
"""Connection refused in health check should be detected."""
|
||||||
|
conn_error = aiohttp.ClientConnectorError(
|
||||||
|
connection_key=MagicMock(), os_error=OSError("Connection refused")
|
||||||
|
)
|
||||||
|
with patch("aiohttp.ClientSession") as mock_cls:
|
||||||
|
mock_session = AsyncMock()
|
||||||
|
mock_session.get = MagicMock(side_effect=conn_error)
|
||||||
|
mock_cls.return_value = AsyncMock(
|
||||||
|
__aenter__=AsyncMock(return_value=mock_session),
|
||||||
|
__aexit__=AsyncMock(return_value=False),
|
||||||
|
)
|
||||||
|
result = await client.check_site_health()
|
||||||
|
assert result["healthy"] is False
|
||||||
|
assert result["error_type"] == "connection_refused"
|
||||||
|
|
||||||
|
|
||||||
# --- WordPressPlugin Tests ---
|
# --- WordPressPlugin Tests ---
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user