feat(F.7b): tool access UI + unified keys page (v3.9.0)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -110,6 +110,47 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tool Access Card -->
|
||||
<div id="tool-access-card" class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6 space-y-4">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{% if lang == 'fa' %}دسترسی به ابزارها{% else %}Tool Access{% endif %}
|
||||
</h3>
|
||||
<div id="tool-access-loading" class="text-gray-400 dark:text-gray-500 text-sm">
|
||||
{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}
|
||||
</div>
|
||||
<div id="tool-access-content" class="hidden space-y-4">
|
||||
<!-- Scope selector -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{% if lang == 'fa' %}سطح دسترسی{% else %}Access Scope{% endif %}
|
||||
</label>
|
||||
<select id="tool-scope-select"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent">
|
||||
<option value="read">{% if lang == 'fa' %}فقط خواندن (Read){% else %}Read{% endif %}</option>
|
||||
<option value="read:sensitive">{% if lang == 'fa' %}خواندن + داده حساس{% else %}Read + Sensitive{% endif %}</option>
|
||||
<option value="deploy">{% if lang == 'fa' %}استقرار (start/stop/restart){% else %}Deploy{% endif %}</option>
|
||||
<option value="write">{% if lang == 'fa' %}نوشتن + Lifecycle{% else %}Write{% endif %}</option>
|
||||
<option value="admin">{% if lang == 'fa' %}دسترسی کامل{% else %}Admin (all tools){% endif %}</option>
|
||||
<option value="custom">{% if lang == 'fa' %}سفارشی (per-tool){% else %}Custom{% endif %}</option>
|
||||
</select>
|
||||
<p id="scope-desc" class="text-xs text-gray-500 dark:text-gray-400 mt-1.5"></p>
|
||||
</div>
|
||||
|
||||
<!-- Status message for scope save -->
|
||||
<div id="scope-status" class="hidden text-xs"></div>
|
||||
|
||||
<!-- Advanced: per-tool overrides -->
|
||||
<details id="tool-overrides-section">
|
||||
<summary class="cursor-pointer text-sm font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white select-none py-1">
|
||||
{% if lang == 'fa' %}پیشرفته — انتخاب دستی ابزارها{% else %}Advanced — per-tool overrides{% endif %}
|
||||
</summary>
|
||||
<div id="tool-list" class="mt-3 space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4"></div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
@@ -168,5 +209,162 @@
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Tool Access ──────────────────────────────────────────────
|
||||
const SCOPE_DESCS = {
|
||||
'read': '{% if lang == "fa" %}ابزارهای read-only (list/get) — بدون دادههای حساس{% else %}Read-only list/get tools — no sensitive data{% endif %}',
|
||||
'read:sensitive': '{% if lang == "fa" %}read + لاگها، بکاپها و متغیرهای محیطی{% else %}Read + logs, backups, and env vars{% endif %}',
|
||||
'deploy': '{% if lang == "fa" %}read + start/stop/restart/deploy{% else %}Read + start/stop/restart/deploy{% endif %}',
|
||||
'write': '{% if lang == "fa" %}read + lifecycle + ایجاد/بروزرسانی + env{% else %}Read + lifecycle + create/update + env{% endif %}',
|
||||
'admin': '{% if lang == "fa" %}دسترسی کامل به همه ابزارها{% else %}Full access to all tools{% endif %}',
|
||||
'custom': '{% if lang == "fa" %}بدون فیلتر سطح — فقط toggleهای دستی اعمال میشوند{% else %}No scope filter — only per-tool toggles apply{% endif %}',
|
||||
};
|
||||
const CAT_LABELS = {
|
||||
'read': '{% if lang == "fa" %}خواندن{% else %}Read{% endif %}',
|
||||
'read_sensitive': '{% if lang == "fa" %}خواندن حساس{% else %}Sensitive Read{% endif %}',
|
||||
'lifecycle': 'Lifecycle',
|
||||
'crud': 'CRUD',
|
||||
'env': '{% if lang == "fa" %}محیطی{% else %}Environment{% endif %}',
|
||||
'backup': 'Backup',
|
||||
'system': 'System',
|
||||
};
|
||||
|
||||
function getCsrf() {
|
||||
const m = document.cookie.match(/(?:^|;\s*)dashboard_csrf=([^;]+)/);
|
||||
return m ? decodeURIComponent(m[1]) : '';
|
||||
}
|
||||
|
||||
async function loadToolAccess() {
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/tools');
|
||||
if (!r.ok) { hideToolAccess(); return; }
|
||||
const data = await r.json();
|
||||
renderToolAccess(data);
|
||||
} catch (_) { hideToolAccess(); }
|
||||
}
|
||||
|
||||
function hideToolAccess() {
|
||||
document.getElementById('tool-access-loading').textContent = '';
|
||||
}
|
||||
|
||||
function renderToolAccess(data) {
|
||||
const loading = document.getElementById('tool-access-loading');
|
||||
const content = document.getElementById('tool-access-content');
|
||||
loading.classList.add('hidden');
|
||||
content.classList.remove('hidden');
|
||||
|
||||
// Set scope dropdown
|
||||
const select = document.getElementById('tool-scope-select');
|
||||
select.value = data.tool_scope || 'admin';
|
||||
updateScopeDesc(data.tool_scope || 'admin');
|
||||
|
||||
// Scope change handler
|
||||
select.onchange = async () => {
|
||||
const scope = select.value;
|
||||
updateScopeDesc(scope);
|
||||
const statusEl = document.getElementById('scope-status');
|
||||
statusEl.textContent = '{% if lang == "fa" %}در حال ذخیره...{% else %}Saving...{% endif %}';
|
||||
statusEl.className = 'text-xs text-gray-400';
|
||||
statusEl.classList.remove('hidden');
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/tool-scope', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'x-csrf-token': getCsrf() },
|
||||
body: JSON.stringify({ scope }),
|
||||
});
|
||||
if (r.ok) {
|
||||
statusEl.textContent = '{% if lang == "fa" %}ذخیره شد{% else %}Saved{% endif %}';
|
||||
statusEl.className = 'text-xs text-green-500';
|
||||
} else {
|
||||
statusEl.textContent = '{% if lang == "fa" %}خطا{% else %}Error saving{% endif %}';
|
||||
statusEl.className = 'text-xs text-red-500';
|
||||
}
|
||||
} catch (_) {
|
||||
statusEl.textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
|
||||
statusEl.className = 'text-xs text-red-500';
|
||||
}
|
||||
setTimeout(() => statusEl.classList.add('hidden'), 2000);
|
||||
};
|
||||
|
||||
// Render per-tool list grouped by category
|
||||
const grouped = {};
|
||||
for (const tool of data.tools) {
|
||||
const cat = tool.category || 'read';
|
||||
if (!grouped[cat]) grouped[cat] = [];
|
||||
grouped[cat].push(tool);
|
||||
}
|
||||
const catOrder = ['read', 'read_sensitive', 'lifecycle', 'crud', 'env', 'backup', 'system'];
|
||||
const container = document.getElementById('tool-list');
|
||||
container.innerHTML = '';
|
||||
for (const cat of catOrder) {
|
||||
if (!grouped[cat]) continue;
|
||||
const section = document.createElement('div');
|
||||
section.className = 'space-y-1';
|
||||
const header = document.createElement('p');
|
||||
header.className = 'text-xs font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500 mb-2';
|
||||
header.textContent = CAT_LABELS[cat] || cat;
|
||||
section.appendChild(header);
|
||||
for (const tool of grouped[cat]) {
|
||||
section.appendChild(renderToolRow(tool));
|
||||
}
|
||||
container.appendChild(section);
|
||||
}
|
||||
}
|
||||
|
||||
function updateScopeDesc(scope) {
|
||||
const el = document.getElementById('scope-desc');
|
||||
el.textContent = SCOPE_DESCS[scope] || '';
|
||||
}
|
||||
|
||||
function renderToolRow(tool) {
|
||||
const row = document.createElement('div');
|
||||
row.id = 'tool-row-' + tool.name;
|
||||
row.className = 'flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-50 dark:hover:bg-gray-700/30';
|
||||
|
||||
const left = document.createElement('div');
|
||||
left.className = 'flex items-center gap-2 flex-1 min-w-0';
|
||||
|
||||
const nameEl = document.createElement('span');
|
||||
nameEl.className = 'text-sm text-gray-800 dark:text-gray-200 truncate font-mono';
|
||||
nameEl.textContent = tool.name;
|
||||
nameEl.title = tool.description || '';
|
||||
left.appendChild(nameEl);
|
||||
|
||||
if (tool.sensitivity === 'sensitive') {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'flex-shrink-0 px-1.5 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400';
|
||||
badge.textContent = '{% if lang == "fa" %}حساس{% else %}sensitive{% endif %}';
|
||||
left.appendChild(badge);
|
||||
}
|
||||
|
||||
// Toggle switch
|
||||
const label = document.createElement('label');
|
||||
label.className = 'relative inline-flex items-center cursor-pointer flex-shrink-0';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.className = 'sr-only peer';
|
||||
input.checked = tool.enabled !== false;
|
||||
input.onchange = async () => {
|
||||
const enabled = input.checked;
|
||||
try {
|
||||
const r = await fetch('/api/sites/' + siteId + '/tools/' + tool.name, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'x-csrf-token': getCsrf() },
|
||||
body: JSON.stringify({ enabled }),
|
||||
});
|
||||
if (!r.ok) { input.checked = !enabled; }
|
||||
} catch (_) { input.checked = !enabled; }
|
||||
};
|
||||
const slider = document.createElement('div');
|
||||
slider.className = 'w-9 h-5 bg-gray-300 dark:bg-gray-600 peer-checked:bg-blue-600 rounded-full peer peer-focus:ring-2 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 transition-colors after:content-[""] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:after:translate-x-4';
|
||||
label.appendChild(input);
|
||||
label.appendChild(slider);
|
||||
|
||||
row.appendChild(left);
|
||||
row.appendChild(label);
|
||||
return row;
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadToolAccess);
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -80,6 +80,10 @@
|
||||
id="test-btn-{{ site.id }}">
|
||||
{{ t.test_connection }}
|
||||
</button>
|
||||
<a href="/dashboard/sites/{{ site.id }}{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-sm text-blue-600 dark:text-blue-400 hover:text-blue-500">
|
||||
{{ t.get('connect', 'Connect') }}
|
||||
</a>
|
||||
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||
{{ t.edit }}
|
||||
|
||||
134
core/templates/dashboard/sites/view.html
Normal file
134
core/templates/dashboard/sites/view.html
Normal file
@@ -0,0 +1,134 @@
|
||||
{% extends "dashboard/base.html" %}
|
||||
|
||||
{% block title %}{{ site.alias }} - MCP Hub{% endblock %}
|
||||
{% block page_title %}{{ site.alias }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="max-w-3xl mx-auto space-y-6">
|
||||
<div class="flex items-center gap-4 mb-6">
|
||||
<a href="/dashboard/sites{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</a>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ site.alias }}
|
||||
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-300">
|
||||
{{ plugin_names.get(site.plugin_type, site.plugin_type) }}
|
||||
</span>
|
||||
</h2>
|
||||
<a href="/dashboard/sites/{{ site.id }}/edit{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}"
|
||||
class="ml-auto text-sm text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
|
||||
{% if lang == 'fa' %}ویرایش{% else %}Edit{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- MCP Endpoint URL -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
{% if lang == 'fa' %}آدرس MCP{% else %}MCP Endpoint{% endif %}
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="mcp-url" class="flex-1 bg-gray-100 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-4 py-2 text-sm font-mono text-gray-800 dark:text-gray-200 overflow-x-auto">
|
||||
{{ mcp_url }}
|
||||
</code>
|
||||
<button onclick="copyMcpUrl()" class="flex-shrink-0 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white text-sm transition-colors">
|
||||
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{% if lang == 'fa' %}
|
||||
برای احراز هویت از کلید API (Bearer) یا OAuth استفاده کنید.
|
||||
{% else %}
|
||||
Authenticate with an API key (Bearer token) or OAuth.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Config Snippets -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-6">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{% if lang == 'fa' %}نمونه کدهای پیکربندی{% else %}Configuration Snippets{% endif %}
|
||||
</h3>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{% if lang == 'fa' %}انتخاب کلاینت{% else %}Select Client{% endif %}
|
||||
</label>
|
||||
<select id="config-client" onchange="updateConfig()"
|
||||
class="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2.5 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}">{{ client.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<pre id="config-output" class="bg-gray-100 dark:bg-gray-900 rounded-lg p-4 text-sm text-gray-700 dark:text-gray-300 overflow-x-auto border border-gray-200 dark:border-gray-700 min-h-[100px]">{% if lang == 'fa' %}در حال بارگذاری...{% else %}Loading...{% endif %}</pre>
|
||||
<button onclick="copyConfig()" id="copy-config-btn"
|
||||
class="absolute top-2 right-2 text-xs bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-600 dark:text-gray-300 px-3 py-1 rounded transition-colors">
|
||||
{% if lang == 'fa' %}کپی{% else %}Copy{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="transport-note" class="mt-4 bg-blue-50 dark:bg-blue-500/10 border border-blue-200 dark:border-blue-500/30 rounded-lg p-4">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-400">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>نکته:</strong> از <code>streamableHttp</code> برای Claude Desktop و <code>http</code> برای VS Code/Claude Code استفاده کنید.
|
||||
{% else %}
|
||||
<strong>Note:</strong> Use <code>streamableHttp</code> for Claude Desktop, <code>http</code> for VS Code/Claude Code.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="bearer-hint" class="mt-3 bg-gray-50 dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||
{% if lang == 'fa' %}
|
||||
<strong>API Key:</strong> از صفحه <a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">کلیدها</a> بسازید:
|
||||
{% else %}
|
||||
<strong>API Key:</strong> Create one on the <a href="/dashboard/keys{% if lang and lang != 'en' %}?lang={{ lang }}{% endif %}" class="underline">API Keys</a> page:
|
||||
{% endif %}
|
||||
</p>
|
||||
<code class="block bg-gray-100 dark:bg-gray-900 px-3 py-1.5 rounded text-xs font-mono text-gray-800 dark:text-gray-200">"Authorization": "Bearer mhu_YOUR_API_KEY_HERE"</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const siteAlias = "{{ site.alias }}";
|
||||
const WEB_CLIENTS = ['claude_connectors', 'chatgpt'];
|
||||
|
||||
async function updateConfig() {
|
||||
const client = document.getElementById('config-client')?.value;
|
||||
if (!client) return;
|
||||
const isWeb = WEB_CLIENTS.includes(client);
|
||||
document.getElementById('transport-note').style.display = isWeb ? 'none' : '';
|
||||
document.getElementById('bearer-hint').style.display = isWeb ? 'none' : '';
|
||||
try {
|
||||
const r = await fetch('/api/config/' + siteAlias + '?client=' + client);
|
||||
const data = await r.json();
|
||||
document.getElementById('config-output').textContent = r.ok ? data.config : '{% if lang == "fa" %}خطا در بارگذاری{% else %}Error loading config{% endif %}';
|
||||
} catch (_) {
|
||||
document.getElementById('config-output').textContent = '{% if lang == "fa" %}خطای شبکه{% else %}Network error{% endif %}';
|
||||
}
|
||||
}
|
||||
|
||||
function copyMcpUrl() {
|
||||
navigator.clipboard.writeText(document.getElementById('mcp-url').textContent.trim());
|
||||
}
|
||||
|
||||
function copyConfig() {
|
||||
const text = document.getElementById('config-output').textContent;
|
||||
navigator.clipboard.writeText(text);
|
||||
const btn = document.getElementById('copy-config-btn');
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '{% if lang == "fa" %}کپی شد!{% else %}Copied!{% endif %}';
|
||||
setTimeout(() => btn.textContent = orig, 2000);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', updateConfig);
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user