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

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

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

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

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

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

View File

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

View File

@@ -135,9 +135,6 @@
('services', t.get('services', 'Services'), 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', '/dashboard/services'),
('keys', t.get('keys', 'API Keys'), 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0
01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z', '/dashboard/keys'),
('oauth_clients', t.oauth_clients, 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0
01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622
0-1.042-.133-2.052-.382-3.016z', '/dashboard/oauth-clients'),
] %}
{# ── Admin-only nav items ── #}

View File

@@ -159,10 +159,8 @@
<p class="text-sm text-green-700 dark:text-green-400">
{% if lang == 'fa' %}
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال می‌توانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
ساخت <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
{% else %}
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
Creating an <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
{% endif %}
</p>
</div>

View File

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

View File

@@ -275,10 +275,8 @@
<p class="text-sm text-green-700 dark:text-green-400">
{% if lang == 'fa' %}
<strong>نکته:</strong> فقط آدرس URL کافی است. هنگام اتصال می‌توانید با <strong>API Key</strong> یا <strong>GitHub/Google</strong> احراز هویت کنید.
ساخت <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> اختیاری است.
{% else %}
<strong>Tip:</strong> You only need the URL above. When connecting, you can authenticate with an <strong>API Key</strong> or <strong>GitHub/Google</strong>.
Creating an <a href="/dashboard/oauth-clients{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">OAuth Client</a> is optional.
{% endif %}
</p>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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