feat: OAuth user fixes + dashboard UI improvements (v3.2.0)
- Bug A: Fix OAuth consent redirect loop (return_url path-relative)
- Bug B: Accept OAuth JWT tokens on /u/{user_id}/{alias}/mcp
- Bug C: Add user OAuth client CRUD (list/create/delete) + new page
- D-1: Sidebar border via Tailwind class, version footer in sidebar
- D-2: Remove broken setInterval targeting #stats-container
- D-3: 404.html purple primary colors + system dark mode support
- D-4: Fix invisible buttons (bg-gray-200 + text-white in light mode)
- D-5: Pin Alpine.js to 3.14.8, remove duplicate CSRF meta tag
- D-6: Fix invisible lang toggle button in settings (dark mode)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2696,6 +2696,7 @@ async def dashboard_connect_page(request: Request) -> Response:
|
||||
"clients": get_supported_clients(),
|
||||
"current_page": "connect",
|
||||
"new_key": new_key,
|
||||
"public_url": os.environ.get("PUBLIC_URL", "http://localhost:8000"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2902,6 +2903,110 @@ async def api_get_config(request: Request) -> Response:
|
||||
return JSONResponse({"error": str(e)}, status_code=400)
|
||||
|
||||
|
||||
async def dashboard_user_oauth_clients_list(request: Request) -> Response:
|
||||
"""GET /dashboard/connect/oauth-clients — OAuth user's own OAuth clients."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return redirect
|
||||
|
||||
accept_language = request.headers.get("accept-language")
|
||||
query_lang = request.query_params.get("lang")
|
||||
lang = detect_language(accept_language, query_lang)
|
||||
t = get_translations(lang)
|
||||
|
||||
from core.oauth import get_client_registry as _get_client_registry
|
||||
|
||||
registry = _get_client_registry()
|
||||
user_id = user_session["user_id"]
|
||||
user_clients = [c for c in registry.list_clients() if c.owner_user_id == user_id]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"dashboard/user-oauth-clients.html",
|
||||
{
|
||||
"request": request,
|
||||
"lang": lang,
|
||||
"t": t,
|
||||
"session": user_session,
|
||||
"clients": user_clients,
|
||||
"current_page": "connect",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def dashboard_user_oauth_clients_create(request: Request) -> Response:
|
||||
"""POST /api/dashboard/user-oauth-clients/create — Create OAuth client for OAuth user."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
|
||||
|
||||
client_name = body.get("client_name", "").strip()
|
||||
redirect_uris_raw = body.get("redirect_uris", "")
|
||||
scopes = body.get("scopes", ["read", "write"])
|
||||
|
||||
if not client_name:
|
||||
return JSONResponse({"error": "Client name required"}, status_code=400)
|
||||
|
||||
if isinstance(redirect_uris_raw, str):
|
||||
redirect_uris = [u.strip() for u in redirect_uris_raw.splitlines() if u.strip()]
|
||||
else:
|
||||
redirect_uris = [u.strip() for u in redirect_uris_raw if u.strip()]
|
||||
|
||||
if not redirect_uris:
|
||||
return JSONResponse({"error": "At least one redirect URI required"}, status_code=400)
|
||||
|
||||
scope_str = " ".join(scopes) if isinstance(scopes, list) else scopes
|
||||
|
||||
from core.oauth import get_client_registry as _get_client_registry
|
||||
|
||||
registry = _get_client_registry()
|
||||
client_id, client_secret = registry.create_client(
|
||||
client_name=client_name,
|
||||
redirect_uris=redirect_uris,
|
||||
allowed_scopes=scope_str.split() if isinstance(scope_str, str) else scopes,
|
||||
owner_user_id=user_session["user_id"],
|
||||
)
|
||||
client = registry.get_client(client_id)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"client_name": client.client_name,
|
||||
"redirect_uris": client.redirect_uris,
|
||||
"scope": client.scope,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def dashboard_user_oauth_clients_delete(request: Request) -> Response:
|
||||
"""DELETE /api/dashboard/user-oauth-clients/{client_id} — Delete user's own OAuth client."""
|
||||
user_session, redirect = _require_user_session(request)
|
||||
if redirect:
|
||||
return JSONResponse({"error": "Unauthorized"}, status_code=401)
|
||||
|
||||
client_id = request.path_params.get("client_id", "")
|
||||
|
||||
from core.oauth import get_client_registry as _get_client_registry
|
||||
|
||||
registry = _get_client_registry()
|
||||
client = registry.get_client(client_id)
|
||||
|
||||
if not client:
|
||||
return JSONResponse({"error": "Client not found"}, status_code=404)
|
||||
|
||||
# Only allow deleting own clients
|
||||
if client.owner_user_id != user_session["user_id"]:
|
||||
return JSONResponse({"error": "Access denied"}, status_code=403)
|
||||
|
||||
registry.delete_client(client_id)
|
||||
return JSONResponse({"success": True})
|
||||
|
||||
|
||||
def register_dashboard_routes(mcp):
|
||||
"""
|
||||
Register dashboard routes with the MCP server.
|
||||
@@ -2911,6 +3016,9 @@ def register_dashboard_routes(mcp):
|
||||
"""
|
||||
logger.info("Registering dashboard routes...")
|
||||
|
||||
# Set template globals (available in all templates without passing explicitly)
|
||||
templates.env.globals["project_version"] = _get_project_version()
|
||||
|
||||
# Auth routes (E.2: OAuth Social Login)
|
||||
mcp.custom_route("/auth/login", methods=["GET"])(auth_login_page)
|
||||
mcp.custom_route("/auth/callback/{provider}", methods=["GET"])(auth_callback)
|
||||
@@ -2998,4 +3106,15 @@ def register_dashboard_routes(mcp):
|
||||
# Config snippet API (E.3)
|
||||
mcp.custom_route("/api/config/{alias}", methods=["GET"])(api_get_config)
|
||||
|
||||
# User OAuth Client routes (Bug C fix)
|
||||
mcp.custom_route("/dashboard/connect/oauth-clients", methods=["GET"])(
|
||||
dashboard_user_oauth_clients_list
|
||||
)
|
||||
mcp.custom_route("/api/dashboard/user-oauth-clients/create", methods=["POST"])(
|
||||
dashboard_user_oauth_clients_create
|
||||
)
|
||||
mcp.custom_route("/api/dashboard/user-oauth-clients/{client_id}", methods=["DELETE"])(
|
||||
dashboard_user_oauth_clients_delete
|
||||
)
|
||||
|
||||
logger.info("Dashboard routes registered successfully")
|
||||
|
||||
@@ -76,6 +76,7 @@ class ClientRegistry:
|
||||
grant_types: list[str] | None = None,
|
||||
allowed_scopes: list[str] | None = None,
|
||||
metadata: dict | None = None,
|
||||
owner_user_id: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Create new OAuth client.
|
||||
@@ -99,6 +100,7 @@ class ClientRegistry:
|
||||
grant_types=grant_types or ["authorization_code", "refresh_token"],
|
||||
allowed_scopes=allowed_scopes or ["read", "write"],
|
||||
metadata=metadata or {},
|
||||
owner_user_id=owner_user_id,
|
||||
)
|
||||
|
||||
# Save
|
||||
|
||||
@@ -27,6 +27,9 @@ class OAuthClient(BaseModel):
|
||||
)
|
||||
created_at: datetime = Field(default_factory=lambda: datetime.now(UTC))
|
||||
metadata: dict = Field(default_factory=dict)
|
||||
owner_user_id: str | None = Field(
|
||||
default=None, description="ID of the OAuth user who created this client (None = admin)"
|
||||
)
|
||||
|
||||
@field_validator("redirect_uris")
|
||||
def validate_redirect_uris(cls, v):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
@@ -11,23 +11,35 @@
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: { 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8' }
|
||||
primary: {
|
||||
50: '#f5f3ff',
|
||||
100: '#ede9fe',
|
||||
400: '#a78bfa',
|
||||
500: '#8b5cf6',
|
||||
600: '#7c3aed',
|
||||
700: '#6d28d9',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100 min-h-screen flex items-center justify-center">
|
||||
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark: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">
|
||||
<svg class="w-10 h-10 text-primary-500 dark: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>
|
||||
<h1 class="text-6xl font-bold text-primary-500 dark:text-primary-400 mb-4">404</h1>
|
||||
<p class="text-xl text-gray-600 dark:text-gray-300 mb-3">Page Not Found</p>
|
||||
<p class="text-sm text-gray-500 dark: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">
|
||||
@@ -37,11 +49,11 @@
|
||||
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">
|
||||
class="inline-flex items-center px-5 py-2.5 text-sm font-medium text-gray-600 dark:text-gray-300 bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg transition-colors">
|
||||
Health Check
|
||||
</a>
|
||||
</div>
|
||||
<p class="mt-12 text-xs text-gray-600">MCP Hub</p>
|
||||
<p class="mt-12 text-xs text-gray-400 dark:text-gray-600">MCP Hub</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
</button>
|
||||
|
||||
{% 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-200 dark:bg-gray-700 hover:bg-gray-300 dark: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-gray-800 dark:text-white text-sm transition-colors">
|
||||
{{ t['clear'] }}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -248,7 +248,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
{% 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 %}"
|
||||
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">
|
||||
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-800 dark:text-white transition-colors">
|
||||
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -258,7 +258,7 @@
|
||||
<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) %}
|
||||
<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-200 dark:bg-gray-700 hover:bg-gray-300 dark: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-800 dark:text-white transition-colors">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||
@@ -268,7 +268,7 @@
|
||||
|
||||
{% 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 %}"
|
||||
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">
|
||||
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-800 dark:text-white transition-colors">
|
||||
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -360,7 +360,7 @@
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onclick="closeCreateModal()"
|
||||
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"
|
||||
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors"
|
||||
>
|
||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||
</button>
|
||||
@@ -448,7 +448,7 @@
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onclick="closeRevokeModal()"
|
||||
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"
|
||||
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors"
|
||||
>
|
||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||
</button>
|
||||
@@ -485,7 +485,7 @@
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button
|
||||
onclick="closeDeleteModal()"
|
||||
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"
|
||||
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors"
|
||||
>
|
||||
{% if lang == 'fa' %}انصراف{% else %}Cancel{% endif %}
|
||||
</button>
|
||||
|
||||
@@ -102,9 +102,8 @@
|
||||
})">
|
||||
<div class="flex h-screen overflow-hidden">
|
||||
<!-- Sidebar -->
|
||||
<aside class="sidebar-transition bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 flex flex-col"
|
||||
:class="sidebarOpen ? 'w-64' : 'w-20'" {% if lang=='fa' %}style="border-left: 1px solid;" {% else
|
||||
%}style="border-right: 1px solid;" {% endif %}>
|
||||
<aside class="sidebar-transition bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 flex flex-col {% if lang=='fa' %}border-l{% else %}border-r{% endif %} border-gray-200 dark:border-gray-700"
|
||||
:class="sidebarOpen ? 'w-64' : 'w-20'">
|
||||
<!-- Logo -->
|
||||
<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">
|
||||
@@ -207,6 +206,11 @@
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<!-- Version footer -->
|
||||
<div x-show="sidebarOpen" class="px-4 py-2 text-xs text-gray-400 dark:text-gray-600">
|
||||
v{{ project_version | default('') }}
|
||||
</div>
|
||||
|
||||
<!-- User Section -->
|
||||
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
{# Profile link for OAuth users #}
|
||||
|
||||
@@ -131,6 +131,58 @@
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ t.no_sites }}. <a href="/dashboard/sites/add{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="text-blue-600 dark:text-blue-400 hover:text-blue-500 dark:hover:text-blue-300">{{ t.add_site }}</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Claude.ai Connection Guide -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{% if lang == 'fa' %}اتصال به Claude.ai{% else %}Connect to Claude.ai{% endif %}
|
||||
</h3>
|
||||
<ol class="space-y-3 text-sm text-gray-700 dark:text-gray-300 {% if lang == 'fa' %}list-decimal list-inside{% else %}list-decimal list-inside{% endif %}" {% if lang == 'fa' %}dir="rtl"{% endif %}>
|
||||
<li>
|
||||
{% if lang == 'fa' %}
|
||||
در صفحه <strong>OAuth Clients</strong> یک کلاینت جدید بسازید
|
||||
{% else %}
|
||||
Create an OAuth Client on the <strong>OAuth Clients</strong> page
|
||||
{% endif %}
|
||||
</li>
|
||||
<li>
|
||||
{% if lang == 'fa' %}
|
||||
در Claude.ai → Settings → Connectors → Add → Custom
|
||||
{% else %}
|
||||
Go to Claude.ai → Settings → Connectors → Add → Custom
|
||||
{% endif %}
|
||||
</li>
|
||||
<li>
|
||||
{% if lang == 'fa' %}
|
||||
آدرس MCP Endpoint خود را وارد کنید:
|
||||
{% else %}
|
||||
Enter your MCP Endpoint URL:
|
||||
{% endif %}
|
||||
{% if sites %}
|
||||
<code class="block mt-1 bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">
|
||||
{{ public_url }}/u/{{ session.user_id }}/{{ sites[0].alias }}/mcp
|
||||
</code>
|
||||
{% else %}
|
||||
<code class="block mt-1 bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">
|
||||
{{ public_url }}/u/{{ session.user_id }}/YOUR-ALIAS/mcp
|
||||
</code>
|
||||
{% endif %}
|
||||
</li>
|
||||
<li>
|
||||
{% if lang == 'fa' %}Client ID و Client Secret را از OAuth Clients وارد کنید{% else %}Enter Client ID and Client Secret from your OAuth Client{% endif %}
|
||||
</li>
|
||||
<li>
|
||||
{% if lang == 'fa' %}با GitHub یا Google وارد شوید{% else %}Log in with GitHub or Google when prompted{% endif %}
|
||||
</li>
|
||||
</ol>
|
||||
<a href="/dashboard/connect/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="mt-4 inline-flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"/>
|
||||
</svg>
|
||||
{% if lang == 'fa' %}مدیریت OAuth Clients{% else %}Manage OAuth Clients{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -316,7 +316,7 @@
|
||||
|
||||
<div class="flex gap-2">
|
||||
<a href="/dashboard/health{% 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">
|
||||
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-gray-800 dark:text-white text-sm transition-colors">
|
||||
{{ t.refresh }}
|
||||
</a>
|
||||
<a href="/dashboard/health?refresh=true{% if lang and lang != 'en' %}&lang={{ lang }}{% endif %}"
|
||||
|
||||
@@ -444,12 +444,4 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Auto-refresh dashboard every 30 seconds
|
||||
htmx.config.defaultSwapStyle = 'outerHTML';
|
||||
|
||||
setInterval(function() {
|
||||
htmx.ajax('GET', '/api/dashboard/stats', {target: '#stats-container', swap: 'none'});
|
||||
}, 30000);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<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>
|
||||
<script defer src="https://unpkg.com/alpinejs@3.14.8/dist/cdn.min.js"></script>
|
||||
|
||||
<!-- Custom Tailwind Config -->
|
||||
<script>
|
||||
@@ -50,8 +50,6 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<meta name="csrf-token" content="{{ request.state.csrf_token }}">
|
||||
|
||||
<!-- Global CSRF Interceptor -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
{% 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-200 dark:bg-gray-700 hover:bg-gray-300 dark: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-gray-800 dark:text-white text-sm transition-colors">
|
||||
{{ t['clear'] }}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -202,7 +202,7 @@
|
||||
<!-- Actions -->
|
||||
<td class="px-6 py-4">
|
||||
<a 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-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 rounded-lg text-sm text-gray-800 dark:text-white transition-colors">
|
||||
{{ 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">
|
||||
@@ -252,7 +252,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
{% 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 %}"
|
||||
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">
|
||||
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-800 dark:text-white transition-colors">
|
||||
{% if lang == 'fa' %}قبلی{% else %}Previous{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -263,7 +263,7 @@
|
||||
{% 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_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">
|
||||
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-800 dark:text-white transition-colors">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% elif page_num == page_number - 3 or page_num == page_number + 3 %}
|
||||
@@ -273,7 +273,7 @@
|
||||
|
||||
{% if page_number < total_pages %} <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-200 dark:bg-gray-700 hover:bg-gray-300 dark: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-800 dark:text-white transition-colors">
|
||||
{% if lang == 'fa' %}بعدی{% else %}Next{% endif %}
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<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-700 dark:text-gray-300 hover:bg-gray-300 dark: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-white dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600{% endif %}">
|
||||
English
|
||||
</a>
|
||||
<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-700 dark:text-gray-300 hover:bg-gray-300 dark: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-white dark:text-gray-300 hover:bg-gray-300 dark:hover:bg-gray-600{% endif %}">
|
||||
فارسی
|
||||
</a>
|
||||
</div>
|
||||
|
||||
241
core/templates/dashboard/user-oauth-clients.html
Normal file
241
core/templates/dashboard/user-oauth-clients.html
Normal file
@@ -0,0 +1,241 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{% if lang == 'fa' %}کلاینتهای OAuth{% else %}My OAuth Clients{% endif %} - MCP Hub{% endblock %}
|
||||
{% block page_title %}{% if lang == 'fa' %}کلاینتهای OAuth من{% else %}My OAuth Clients{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-6">
|
||||
|
||||
<!-- Back link -->
|
||||
<div>
|
||||
<a href="/dashboard/connect{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="inline-flex items-center text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors">
|
||||
<svg class="w-4 h-4 {% if lang == 'fa' %}ml-1 rotate-180{% else %}mr-1{% 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"/>
|
||||
</svg>
|
||||
{% if lang == 'fa' %}بازگشت به اتصال{% else %}Back to Connect{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Info Banner -->
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4">
|
||||
<p class="text-sm text-blue-800 dark:text-blue-300">
|
||||
{% if lang == 'fa' %}
|
||||
برای اتصال Claude.ai Connectors به MCP Hub، یک OAuth Client بسازید. Client ID و Client Secret را در Claude.ai وارد کنید.
|
||||
{% else %}
|
||||
Create an OAuth Client to connect Claude.ai Connectors to your MCP Hub sites. Enter the Client ID and Client Secret in Claude.ai → Settings → Connectors.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Clients List -->
|
||||
<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">
|
||||
{% if lang == 'fa' %}کلاینتهای OAuth{% else %}OAuth Clients{% endif %}
|
||||
</h3>
|
||||
<button onclick="openCreateModal()"
|
||||
class="btn-primary px-4 py-2 rounded-lg text-white text-sm font-medium">
|
||||
+ {% if lang == 'fa' %}کلاینت جدید{% else %}New Client{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if clients %}
|
||||
<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-left text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}نام{% else %}Name{% endif %}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||
Client ID
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}Redirect URIs{% else %}Redirect URIs{% endif %}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-sm font-medium text-gray-500 dark:text-gray-300">
|
||||
{% if lang == 'fa' %}عملیات{% else %}Actions{% endif %}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||
{% for client in clients %}
|
||||
<tr id="client-{{ client.client_id }}">
|
||||
<td class="px-4 py-3 text-gray-900 dark:text-white font-medium">{{ client.client_name }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 font-mono text-xs">{{ client.client_id }}</td>
|
||||
<td class="px-4 py-3 text-gray-500 dark:text-gray-400 text-xs">
|
||||
{% for uri in client.redirect_uris %}
|
||||
<div class="truncate max-w-xs" title="{{ uri }}">{{ uri }}</div>
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<button onclick="deleteClient('{{ client.client_id }}')"
|
||||
class="text-sm text-red-600 dark:text-red-400 hover:text-red-500 dark:hover:text-red-300">
|
||||
{% if lang == 'fa' %}حذف{% else %}Delete{% endif %}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">
|
||||
{% if lang == 'fa' %}هنوز کلاینت OAuth ندارید. یکی بسازید.{% else %}No OAuth clients yet. Create one to connect Claude.ai.{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Client Modal -->
|
||||
<div id="create-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<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-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}کلاینت OAuth جدید{% else %}New OAuth Client{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{% if lang == 'fa' %}نام کلاینت{% else %}Client Name{% endif %}
|
||||
</label>
|
||||
<input type="text" id="client-name"
|
||||
placeholder="{% if lang == 'fa' %}مثال: Claude.ai{% else %}e.g. Claude.ai Desktop{% endif %}"
|
||||
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">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{% if lang == 'fa' %}Redirect URIs (هر URI در یک خط){% else %}Redirect URIs (one per line){% endif %}
|
||||
</label>
|
||||
<textarea id="redirect-uris" rows="3"
|
||||
placeholder="https://claude.ai/oauth/callback"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 text-gray-900 dark:text-white focus:ring-2 focus:ring-primary-500 font-mono text-sm"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button onclick="closeCreateModal()"
|
||||
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 rounded-lg text-sm transition-colors">
|
||||
{% if lang == 'fa' %}لغو{% else %}Cancel{% endif %}
|
||||
</button>
|
||||
<button onclick="createClient()"
|
||||
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
{% if lang == 'fa' %}ایجاد{% else %}Create{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Modal (shown once after create) -->
|
||||
<div id="success-modal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<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-200 dark:border-gray-700">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<svg class="w-5 h-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{% if lang == 'fa' %}کلاینت ساخته شد{% else %}Client Created{% endif %}
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/30 rounded-lg p-3">
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-400">
|
||||
{% if lang == 'fa' %}Client Secret فقط یکبار نمایش داده میشود. الان کپی کنید.{% else %}Client Secret is shown only once. Copy it now.{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Client ID</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="new-client-id" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded px-3 py-1.5 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"></code>
|
||||
<button onclick="copyText('new-client-id')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-white text-xs transition-colors">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Client Secret</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="new-client-secret" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded px-3 py-1.5 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto"></code>
|
||||
<button onclick="copyText('new-client-secret')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-white text-xs transition-colors">{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 flex justify-end">
|
||||
<button onclick="closeSuccessModal()"
|
||||
class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm font-medium transition-colors">
|
||||
{% if lang == 'fa' %}متوجه شدم{% else %}Done{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function openCreateModal() {
|
||||
document.getElementById('create-modal').classList.remove('hidden');
|
||||
document.getElementById('create-modal').classList.add('flex');
|
||||
document.getElementById('client-name').focus();
|
||||
}
|
||||
|
||||
function closeCreateModal() {
|
||||
document.getElementById('create-modal').classList.add('hidden');
|
||||
document.getElementById('create-modal').classList.remove('flex');
|
||||
document.getElementById('client-name').value = '';
|
||||
document.getElementById('redirect-uris').value = '';
|
||||
}
|
||||
|
||||
function closeSuccessModal() {
|
||||
document.getElementById('success-modal').classList.add('hidden');
|
||||
document.getElementById('success-modal').classList.remove('flex');
|
||||
location.reload();
|
||||
}
|
||||
|
||||
function copyText(elementId) {
|
||||
const text = document.getElementById(elementId).textContent;
|
||||
navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
async function createClient() {
|
||||
const name = document.getElementById('client-name').value.trim();
|
||||
const uris = document.getElementById('redirect-uris').value.trim();
|
||||
if (!name || !uris) {
|
||||
alert('{% if lang == "fa" %}نام و Redirect URI الزامی است{% else %}Name and Redirect URI are required{% endif %}');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/api/dashboard/user-oauth-clients/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ client_name: name, redirect_uris: uris }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
closeCreateModal();
|
||||
document.getElementById('new-client-id').textContent = data.client_id;
|
||||
document.getElementById('new-client-secret').textContent = data.client_secret;
|
||||
document.getElementById('success-modal').classList.remove('hidden');
|
||||
document.getElementById('success-modal').classList.add('flex');
|
||||
} else {
|
||||
alert(data.error || '{% if lang == "fa" %}خطا در ایجاد کلاینت{% else %}Failed to create client{% endif %}');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteClient(clientId) {
|
||||
if (!confirm('{% if lang == "fa" %}این کلاینت حذف شود؟{% else %}Delete this OAuth client?{% endif %}')) return;
|
||||
try {
|
||||
const resp = await fetch('/api/dashboard/user-oauth-clients/' + clientId, { method: 'DELETE' });
|
||||
if (resp.ok) {
|
||||
document.getElementById('client-' + clientId).remove();
|
||||
} else {
|
||||
const data = await resp.json();
|
||||
alert(data.error || '{% if lang == "fa" %}خطا در حذف{% else %}Delete failed{% endif %}');
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Error: ' + e.message);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -210,29 +210,64 @@ async def user_mcp_handler(request: Request) -> Response:
|
||||
|
||||
api_key = auth_header[7:] # Strip "Bearer "
|
||||
|
||||
try:
|
||||
from core.user_keys import get_user_key_manager
|
||||
# Try mhu_ API key first, then fall back to OAuth JWT token
|
||||
if api_key.startswith("mhu_"):
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
if key_info["user_id"] != user_id:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, "API key does not match user"),
|
||||
status_code=403,
|
||||
)
|
||||
else:
|
||||
# Try OAuth JWT token (issued after consent flow via GitHub/Google login)
|
||||
try:
|
||||
import jwt as pyjwt
|
||||
|
||||
from core.oauth import get_token_manager
|
||||
|
||||
token_manager = get_token_manager()
|
||||
jwt_payload = token_manager.validate_access_token(api_key)
|
||||
|
||||
# sub = "user:{uuid}" — extract actual user_id
|
||||
sub = jwt_payload.get("sub", "")
|
||||
if not sub.startswith("user:"):
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, "Token not authorized for user endpoint"),
|
||||
status_code=403,
|
||||
)
|
||||
jwt_user_id = sub[len("user:") :]
|
||||
|
||||
if jwt_user_id != user_id:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, "Token user mismatch"),
|
||||
status_code=403,
|
||||
)
|
||||
except pyjwt.ExpiredSignatureError:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, "Token expired"),
|
||||
status_code=401,
|
||||
)
|
||||
except Exception:
|
||||
return JSONResponse(
|
||||
_jsonrpc_error(None, -32600, "Invalid token"),
|
||||
status_code=401,
|
||||
)
|
||||
|
||||
# --- Rate Limiting ---
|
||||
allowed, rate_msg = _check_user_rate_limit(user_id)
|
||||
|
||||
Reference in New Issue
Block a user