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:
airano
2026-02-25 23:27:59 +03:30
parent ea51c10e03
commit fdb050454e
17 changed files with 538 additions and 102 deletions

View File

@@ -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")

View File

@@ -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

View File

@@ -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):

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 #}

View File

@@ -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 %}

View File

@@ -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 %}"

View File

@@ -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 %}

View File

@@ -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', () => {

View File

@@ -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 %}

View File

@@ -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>

View 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 %}

View File

@@ -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)