// static/js/admin.js — Admin panel module (ES6) // Admin-only: users, endpoints, MCP, RAG, embeddings, tokens, webhooks, features import uiModule from './ui.js'; import settingsModule from './settings.js'; import { providerLogo } from './providers.js'; import { sortModelObjects } from './modelSort.js'; let initialized = false; let modalEl = null; // When the user adds an endpoint, store its id so the next render of // the endpoints list can flash a glow on that row. Cleared once the // animation fires. let _recentlyAddedEpId = null; function el(id) { return document.getElementById(id); } function esc(s) { return uiModule.esc(s); } /* ═══════════════════════════════════════════ USERS TAB ═══════════════════════════════════════════ */ const PRIV_LABELS = { can_use_agent: 'Agent mode', can_use_browser: 'Browser automation', can_use_bash: 'Shell / Python / Files', can_use_documents: 'Document editor', can_use_research: 'Deep research', can_generate_images: 'Image generation', can_manage_memory: 'Memory & skills', }; async function loadUsers() { const list = el('adm-userList'); try { const res = await fetch('/api/auth/users', { credentials: 'same-origin' }); if (res.status === 401 || res.status === 403) { list.innerHTML = '
Access denied
'; return; } const data = await res.json(); if (!data.users || data.users.length === 0) { list.innerHTML = '
No users found
'; return; } list.innerHTML = ''; data.users.forEach(u => { const row = document.createElement('div'); row.className = 'admin-user-row'; // Header: name + badges + delete const header = document.createElement('div'); header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;cursor:pointer;padding:4px 0;'; const initial = u.username.charAt(0).toUpperCase(); header.innerHTML = `
${esc(initial)}
${esc(u.username)} ${u.is_admin ? 'ADMIN' : 'Click to manage privileges'}
${u.is_admin ? '' : ``} ${u.is_admin ? '' : ''}
`; row.appendChild(header); // Privileges panel (hidden by default, not for admins) if (!u.is_admin) { const privPanel = document.createElement('div'); privPanel.className = 'admin-priv-panel hidden'; privPanel.style.cssText = 'padding:8px 0 4px;border-top:1px solid var(--border);margin-top:8px;'; // Boolean toggles let html = '
Features
'; for (const [key, label] of Object.entries(PRIV_LABELS)) { const checked = u.privileges && u.privileges[key] ? 'checked' : ''; html += `
${label}
`; } // Rate limit html += '
Limits
'; const maxMsg = (u.privileges && u.privileges.max_messages_per_day) || 0; html += `
Daily message limit
0 = no limit
`; // Allowed models — checkbox list const allowedSet = new Set((u.privileges && u.privileges.allowed_models) || []); const allEmpty = allowedSet.size === 0; html += `
Allowed models
All None
${allEmpty ? 'All models allowed (no restrictions)' : allowedSet.size + ' model(s) allowed'}
Loading models...
`; privPanel.innerHTML = html; row.appendChild(privPanel); // Toggle panel visibility + rotate chevron + load models let _modelsLoaded = false; header.addEventListener('click', (e) => { if (e.target.closest('.admin-btn-delete, [data-adm-rename-user]')) return; privPanel.classList.toggle('hidden'); const chevron = header.querySelector('.admin-user-chevron'); if (chevron) { const isOpen = !privPanel.classList.contains('hidden'); chevron.style.transform = isOpen ? 'rotate(180deg)' : ''; chevron.style.opacity = isOpen ? '0.7' : '0.3'; } // Load models list on first expand if (!_modelsLoaded && !privPanel.classList.contains('hidden')) { _modelsLoaded = true; _loadModelsForUser(u.username, allowedSet, privPanel); } }); // Wire privilege changes (boolean + number inputs, not model checkboxes) privPanel.querySelectorAll('[data-priv]').forEach(input => { const handler = async () => { const username = input.dataset.user; const key = input.dataset.priv; let value; if (input.type === 'checkbox') value = input.checked; else if (input.type === 'number') value = parseInt(input.value) || 0; else value = input.value; try { await fetch(`/api/auth/users/${encodeURIComponent(username)}/privileges`, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ [key]: value }), }); } catch (e) { uiModule.showError('Failed to update privilege'); } }; if (input.type === 'checkbox') input.addEventListener('change', handler); else input.addEventListener('change', handler); }); } // Rename button const renameBtn = row.querySelector('[data-adm-rename-user]'); if (renameBtn) { renameBtn.addEventListener('click', async (e) => { e.stopPropagation(); const oldUsername = renameBtn.dataset.admRenameUser; const next = await uiModule.styledPrompt(`Rename "${oldUsername}"`, { defaultValue: oldUsername, placeholder: 'New username', confirmText: 'Rename', }); const username = (next || '').trim(); if (!username || username === oldUsername) return; try { const res = await fetch(`/api/auth/users/${encodeURIComponent(oldUsername)}/rename`, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }), }); const data = await res.json().catch(() => ({})); if (!res.ok) { uiModule.showError(data.detail || 'Failed to rename user'); return; } if (data.renamed_self) { window.location.reload(); return; } loadUsers(); } catch (err) { uiModule.showError('Failed to rename user'); } }); } // Delete button const delBtn = row.querySelector('[data-adm-del-user]'); if (delBtn) { delBtn.addEventListener('click', async (e) => { e.stopPropagation(); const username = delBtn.dataset.admDelUser; if (!await uiModule.styledConfirm(`Remove user "${username}"?`, { confirmText: 'Remove', danger: true })) return; const res = await fetch('/api/auth/users', { method: 'DELETE', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }); if (res.ok) loadUsers(); else uiModule.showError('Failed to delete user'); }); } list.appendChild(row); }); } catch (e) { list.innerHTML = '
Failed to load users
'; } } async function _loadModelsForUser(username, allowedSet, privPanel) { const listEl = privPanel.querySelector(`.priv-models-list[data-user="${username}"]`); if (!listEl) return; try { const res = await fetch('/api/models', { credentials: 'same-origin' }); const data = await res.json(); const allModels = []; (data.items || []).forEach(item => { if (item.offline) return; (item.models || []).forEach(mid => { allModels.push({ mid, epName: item.endpoint_name || '', display: mid.split('/').pop() }); }); }); if (!allModels.length) { listEl.innerHTML = 'No models available'; return; } const allEmpty = allowedSet.size === 0; listEl.innerHTML = sortModelObjects(allModels).map(m => { const checked = allEmpty || allowedSet.has(m.mid) ? 'checked' : ''; return ``; }).join(''); // Save on change function _saveModels() { const checked = []; listEl.querySelectorAll('.priv-model-cb').forEach(cb => { if (cb.checked) checked.push(cb.dataset.mid); }); // If all are checked, send empty array (= no restrictions) const value = checked.length === allModels.length ? [] : checked; const hint = privPanel.querySelector('.priv-models-list[data-user]')?.previousElementSibling?.querySelector('div[style*="opacity"]'); if (hint) hint.textContent = value.length === 0 ? 'All models allowed (no restrictions)' : value.length + ' model(s) allowed'; fetch(`/api/auth/users/${encodeURIComponent(username)}/privileges`, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ allowed_models: value }), }).catch(() => {}); } listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.addEventListener('change', _saveModels)); // All / None buttons privPanel.querySelector(`.priv-models-all[data-user="${username}"]`)?.addEventListener('click', (e) => { e.preventDefault(); listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.checked = true); _saveModels(); }); privPanel.querySelector(`.priv-models-none[data-user="${username}"]`)?.addEventListener('click', (e) => { e.preventDefault(); listEl.querySelectorAll('.priv-model-cb').forEach(cb => cb.checked = false); _saveModels(); }); } catch (e) { listEl.innerHTML = 'Failed to load models'; } } function initSignupToggle() { const toggle = el('adm-signupToggle'); fetch('/api/auth/status', { credentials: 'same-origin' }) .then(r => r.json()) .then(d => { toggle.checked = !!d.signup_enabled; }) .catch(e => console.warn('Auth status fetch failed:', e)); toggle.addEventListener('change', async () => { try { const res = await fetch('/api/auth/signup-toggle', { method: 'POST', credentials: 'same-origin' }); const data = await res.json(); toggle.checked = data.signup_enabled; } catch (e) { toggle.checked = !toggle.checked; } }); } function initAddUser() { el('adm-addBtn').addEventListener('click', async () => { const msg = el('adm-addMsg'); msg.textContent = ''; msg.className = ''; const username = el('adm-newUsername').value.trim(); const password = el('adm-newPassword').value; const is_admin = el('adm-newIsAdmin').checked; if (!username) { msg.textContent = 'Username required'; msg.className = 'admin-error'; return; } if (password.length < 8) { msg.textContent = 'Password must be at least 8 characters'; msg.className = 'admin-error'; return; } el('adm-addBtn').disabled = true; try { const res = await fetch('/api/auth/users', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, is_admin }) }); const data = await res.json(); if (res.ok) { msg.textContent = 'User created'; msg.className = 'admin-success'; el('adm-newUsername').value = ''; el('adm-newPassword').value = ''; el('adm-newIsAdmin').checked = false; loadUsers(); } else { msg.textContent = data.detail || 'Failed'; msg.className = 'admin-error'; } } catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; } el('adm-addBtn').disabled = false; }); } /* ═══════════════════════════════════════════ SERVICES TAB — Endpoints ═══════════════════════════════════════════ */ function _isLocalEndpoint(url) { if (!url) return false; try { const u = new URL(url); const h = u.hostname.toLowerCase(); if (h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0') return true; if (h.endsWith('.local')) return true; if (/^10\./.test(h)) return true; if (/^192\.168\./.test(h)) return true; if (/^172\.(1[6-9]|2[0-9]|3[01])\./.test(h)) return true; // Tailscale CGNAT range (100.64.0.0/10 → 100.64.x–100.127.x). Servers // found via "Scan for Servers" come back as tailnet IPs, which are still // your own machines, so group them under Local rather than API. if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true; // Single-label hostnames are LAN by convention. if (!h.includes('.')) return true; return false; } catch { return false; } } async function _refreshAfterEndpointChange(deletedEndpointId) { try { const sm = window.sessionModule; const pending = sm && sm.getPendingChat ? sm.getPendingChat() : null; if (deletedEndpointId && pending && String(pending.endpointId || '') === String(deletedEndpointId)) { if (sm.setPendingChat) sm.setPendingChat(null); } } catch (_) {} try { if (window.modelsModule && window.modelsModule.refreshModels) { await window.modelsModule.refreshModels(true); } } catch (_) {} try { window.dispatchEvent(new CustomEvent('ge:model-endpoints-updated', { detail: { deletedEndpointId: deletedEndpointId || null } })); } catch (_) {} try { if (window.sessionModule && window.sessionModule.updateModelPicker) { window.sessionModule.updateModelPicker(); } } catch (_) {} } async function _selectAddedModelInChat(endpoint) { const modelId = endpoint && Array.isArray(endpoint.models) ? endpoint.models[0] : ''; if (!modelId) return; try { if (window.modelsModule && window.modelsModule.refreshModels) { await window.modelsModule.refreshModels(true); } } catch (_) {} try { document.dispatchEvent(new CustomEvent('odysseus:auto-select-model', { detail: { endpointId: endpoint.id || '', endpointName: endpoint.name || '', modelId, url: endpoint.base_url || '', } })); } catch (_) {} } async function loadEndpoints() { const listLocal = el('adm-epList-local'); const listApi = el('adm-epList-api'); // Fallback to the legacy single list if the split containers don't exist // (older HTML or third-party embedding). const listLegacy = el('adm-epList'); // Refresh model picker so new endpoints show up in chat if (window.modelsModule && window.modelsModule.refreshModels) { window.modelsModule.refreshModels(true); setTimeout(() => { if (window.sessionModule && window.sessionModule.updateModelPicker) { window.sessionModule.updateModelPicker(); } }, 1500); } if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') { settingsModule.refreshAiModelEndpoints(); } try { const res = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); // Treat a non-OK response (e.g. 401/403 for non-admins, or backend // returning an error envelope) the same as "no endpoints yet": show the // empty state, not "Failed to load". The user just installed the app — // there's literally nothing to load, so the error read as broken UI. let data = []; if (res.ok) { try { data = await res.json(); } catch { data = []; } } if (!Array.isArray(data) || data.length === 0) { const empty = '
None
'; if (listLocal) listLocal.innerHTML = empty; if (listApi) listApi.innerHTML = '
None
'; if (listLegacy) listLegacy.innerHTML = empty; return; } const rowHtml = data.map(ep => { const visibleCount = ep.models.length; const totalCount = visibleCount + (ep.hidden_count || 0); // `ep.models` is the *visible* set — when every model is hidden it's // empty, but we still need to render the expand panel so the user can // un-hide them. Gate on the total instead. const hasModels = ep.online && totalCount > 0; const statusBadge = ep.status === 'empty' ? 'no models' : ep.online ? `${visibleCount}/${totalCount} models enabled` : 'offline'; const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : ''; return `
${hasModels ? '' : ''}
${esc(ep.base_url)}${_isLocalEndpoint(ep.base_url) ? `` : ''}${ep.has_key ? ' (key set)' : ''}
${hasModels ? `` : ''}
`; }); // Partition rows into Local vs API for the split sections. // Subsections without any rows are hidden entirely (heading + all) // so empty groups don't take up vertical real estate. const _renderInto = (container, indices) => { if (!container) return; const section = container.closest('.adm-ep-section'); if (!indices.length) { if (section) section.style.display = 'none'; container.innerHTML = ''; return; } if (section) section.style.display = ''; container.innerHTML = indices.map(i => rowHtml[i]).join(''); }; const localIdx = [], apiIdx = []; data.forEach((ep, i) => (_isLocalEndpoint(ep.base_url) ? localIdx : apiIdx).push(i)); // Sort each section: enabled endpoints first, disabled at the bottom. // Preserve original order within each group via stable sort. const _sortByEnabled = (a, b) => Number(!!data[b].is_enabled) - Number(!!data[a].is_enabled); localIdx.sort(_sortByEnabled); apiIdx.sort(_sortByEnabled); _renderInto(listLocal, localIdx); _renderInto(listApi, apiIdx); if (listLegacy) listLegacy.innerHTML = rowHtml.join(''); // Iterate matching nodes across both containers. const queryAll = (sel) => { const out = []; [listLocal, listApi, listLegacy].forEach(c => { if (c) c.querySelectorAll(sel).forEach(n => out.push(n)); }); return out; }; queryAll('[data-adm-toggle-ep]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); await fetch(`/api/model-endpoints/${btn.dataset.admToggleEp}`, { method: 'PATCH' }); loadEndpoints(); }); }); queryAll('[data-adm-copy-url]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const url = btn.dataset.admCopyUrl || ''; if (!url) return; uiModule.copyToClipboard(url).then(() => { // Brief icon swap to a checkmark so the user gets feedback that // the copy actually happened. Reverts after ~1.4s. const prev = btn.innerHTML; btn.innerHTML = ''; btn.style.opacity = '1'; setTimeout(() => { btn.innerHTML = prev; btn.style.opacity = ''; }, 1400); }).catch(() => {}); }); }); queryAll('[data-adm-del-ep]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); var epId = btn.dataset.admDelEp; var isOffline = btn.dataset.admEpOnline === '0'; // Offline endpoints are already broken — skip the confirm dialog // entirely and delete immediately. The optimistic UI removal makes // the action feel instant. if (!isOffline) { var deps = []; try { var depRes = await fetch('/api/model-endpoints/' + epId + '/dependents', { credentials: 'same-origin' }); var depData = await depRes.json(); deps = depData.dependents || []; } catch (e) { /* proceed without warning */ } var msg = 'Delete this endpoint?'; if (deps.length) { msg += '\n\nThe following settings use this endpoint and will be reset:\n— ' + deps.join('\n— '); } if (!await uiModule.styledConfirm(msg, { confirmText: 'Delete', danger: true })) return; } // Optimistic: remove from UI immediately const row = btn.closest('[data-adm-ep-id]'); if (row) row.remove(); fetch('/api/model-endpoints/' + epId, { method: 'DELETE' }) .then(() => _refreshAfterEndpointChange(epId)) .then(() => loadEndpoints()) .catch(() => loadEndpoints()); }); }); // Clear the just-added marker now that the row has been rendered // with the animation class — keeps the glow from re-firing on every // subsequent loadEndpoints() call (e.g. when toggling a model). if (_recentlyAddedEpId) _recentlyAddedEpId = null; // Models expand/collapse (click anywhere on card) queryAll('[data-adm-ep-id]').forEach(row => { const header = row.querySelector('[data-adm-ep-header]'); if (!header) return; let _modelsLoaded = false; row.style.cursor = 'pointer'; row.addEventListener('click', async (e) => { // Don't let interactions inside the expanded panel re-fire the // expand/collapse handler — the search box was getting closed // because clicking it bubbled up to here. if (e.target.closest('.admin-btn-sm, .admin-btn-delete, .mcp-tools-list, .mcp-tools-header, .mcp-tools-search, input, label')) return; const epId = header.dataset.admEpHeader; const panel = row.querySelector(`[data-adm-ep-models-panel="${epId}"]`); if (!panel) return; panel.classList.toggle('hidden'); const chevron = row.querySelector('.admin-user-chevron'); const isOpen = !panel.classList.contains('hidden'); if (chevron) { chevron.style.transform = isOpen ? 'rotate(180deg)' : ''; chevron.style.opacity = isOpen ? '0.7' : '0.3'; } if (!_modelsLoaded && isOpen) { _modelsLoaded = true; // Our shared whirlpool spinner (consistent with the rest of the app). panel.innerHTML = ''; let _modelsSpin = null; const _ld = document.createElement('span'); _ld.style.cssText = 'opacity:0.55;font-size:11px;display:inline-flex;align-items:center;gap:8px;'; _ld.appendChild(document.createTextNode('Loading models…')); try { const _sp = (await import('./spinner.js')).default; _modelsSpin = _sp.createWhirlpool(14); _modelsSpin.element.style.cssText = 'width:14px;height:14px;margin:0;display:inline-block;'; _ld.appendChild(_modelsSpin.element); } catch (_) {} panel.appendChild(_ld); const _stopSpin = () => { try { _modelsSpin && _modelsSpin.stop(); } catch (_) {} }; try { const res = await fetch(`/api/model-endpoints/${epId}/models`, { credentials: 'same-origin' }); const models = await res.json(); _stopSpin(); const sortedModels = sortModelObjects(models); if (!sortedModels.length) { panel.innerHTML = 'No models'; return; } const hiddenSet = new Set(sortedModels.filter(m => m.is_hidden).map(m => m.id)); const showSearch = sortedModels.length >= 8; panel.innerHTML = `
Models ${sortedModels.length - hiddenSet.size}/${sortedModels.length} enabled All None
${showSearch ? `` : ''}
` + sortedModels.map(m => `` ).join('') + '
'; const filterRows = (q) => { const needle = q.trim().toLowerCase(); panel.querySelectorAll('[data-ep-model-row]').forEach(row => { row.style.display = (!needle || row.dataset.search.includes(needle)) ? '' : 'none'; }); }; panel.querySelector(`[data-ep-search="${epId}"]`)?.addEventListener('input', (e) => filterRows(e.target.value)); panel.querySelector(`[data-ep-select-all="${epId}"]`)?.addEventListener('click', (e) => { e.preventDefault(); panel.querySelectorAll('[data-ep-model-row]').forEach(row => { if (row.style.display !== 'none') row.querySelector('input[type=checkbox]').checked = true; }); _saveEpModelState(epId, panel); }); panel.querySelector(`[data-ep-select-none="${epId}"]`)?.addEventListener('click', (e) => { e.preventDefault(); panel.querySelectorAll('[data-ep-model-row]').forEach(row => { if (row.style.display !== 'none') row.querySelector('input[type=checkbox]').checked = false; }); _saveEpModelState(epId, panel); }); panel.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.addEventListener('change', () => _saveEpModelState(epId, panel)); }); } catch (e) { _stopSpin(); panel.innerHTML = 'Failed to load models'; } } }); }); } catch (e) { const err = '
Failed to load
'; [listLocal, listApi, listLegacy].forEach(c => { if (c) c.innerHTML = err; }); } } async function _saveEpModelState(epId, panel) { const hidden = []; panel.querySelectorAll('input[type=checkbox]').forEach(cb => { if (!cb.checked) hidden.push(cb.dataset.epModelId); }); const total = panel.querySelectorAll('input[type=checkbox]').length; try { await fetch(`/api/model-endpoints/${epId}/models`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ hidden }), }); const countLabel = panel.querySelector('.mcp-tools-count'); if (countLabel) countLabel.textContent = `${total - hidden.length}/${total} enabled`; const row = panel.closest('[data-adm-ep-id]'); if (row) { const badge = row.querySelector('.admin-badge'); if (badge && !badge.classList.contains('admin-badge-off')) badge.textContent = `${total - hidden.length}/${total} models enabled`; } if (settingsModule && typeof settingsModule.refreshAiModelEndpoints === 'function') { settingsModule.refreshAiModelEndpoints(); } } catch (e) { /* silent */ } } function initEndpointForm() { const provider = el('adm-epProvider'); const urlInput = el('adm-epUrl'); // Custom provider picker — mirrors the (now hidden) '; } list.innerHTML = html; // Prevent toggle clicks from expanding/collapsing list.querySelectorAll('.admin-tool-cat-right').forEach(span => { span.addEventListener('click', e => e.stopPropagation()); }); // Wire category expand/collapse list.querySelectorAll('[data-tool-cat]').forEach(header => { header.addEventListener('click', () => { const body = el(header.dataset.toolCat); if (!body) return; body.classList.toggle('hidden'); const chevron = header.querySelector('.admin-tool-cat-chevron'); const isOpen = !body.classList.contains('hidden'); if (chevron) { chevron.style.transform = isOpen ? 'rotate(180deg)' : ''; chevron.style.opacity = isOpen ? '0.7' : '0.3'; } }); }); // Helper: save disabled tools + update counters async function _saveToolState() { const allChecks = list.querySelectorAll('input[data-tool-id]'); const disabled = []; allChecks.forEach(c => { if (!c.checked) disabled.push(c.dataset.toolId); }); await fetch('/api/tools', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ disabled }), credentials: 'same-origin', }); } function _updateCatCounter(catEl) { if (!catEl) return; const catChecks = catEl.querySelectorAll('input[data-tool-id]'); const catEnabled = Array.from(catChecks).filter(c => c.checked).length; const counter = catEl.querySelector('.admin-tool-cat-count'); if (counter) counter.textContent = catEnabled + '/' + catChecks.length; const catToggle = catEl.querySelector('input[data-tool-cat-toggle]'); if (catToggle) catToggle.checked = (catEnabled === catChecks.length); } // Wire individual tool toggles list.querySelectorAll('input[data-tool-id]').forEach(chk => { chk.addEventListener('change', async () => { await _saveToolState(); _updateCatCounter(chk.closest('.admin-tool-category')); }); }); // Wire category-level toggle (enable/disable all in category) list.querySelectorAll('input[data-tool-cat-toggle]').forEach(chk => { chk.addEventListener('change', async () => { const catEl = chk.closest('.admin-tool-category'); if (!catEl) return; const checked = chk.checked; catEl.querySelectorAll('input[data-tool-id]').forEach(c => { c.checked = checked; }); await _saveToolState(); _updateCatCounter(catEl); }); }); } catch (e) { console.error('Failed to load tools:', e); list.innerHTML = '
Failed to load tools
'; } } async function loadMcpServers() { const list = el('adm-mcpList'); if (!list) return; // MCP section not visible / not yet rendered try { const res = await fetch('/api/mcp/servers', { credentials: 'same-origin' }); const servers = await res.json(); if (!servers.length) { list.innerHTML = '
No MCP servers configured
'; return; } list.innerHTML = servers.map(s => { const statusColor = s.needs_oauth ? '#e5a33a' : s.status === 'connected' ? 'var(--fg)' : s.status === 'error' ? 'var(--red)' : 'color-mix(in srgb, var(--fg) 50%, transparent)'; const toolInfo = s.status === 'connected' ? `${s.enabled_tool_count}/${s.tool_count} tools enabled` : ''; const statusText = s.needs_oauth ? 'Needs authorization' : s.status === 'connected' ? `Connected (${toolInfo})` : s.status === 'error' ? `Error: ${s.error || 'unknown'}` : 'Disconnected'; const hasTools = s.status === 'connected' && s.tool_count > 0; return `
${s.needs_oauth ? `Authorize` : ''} ${hasTools ? '' : ''}
${hasTools ? `` : ''}
`; }).join(''); list.querySelectorAll('[data-adm-mcp-reconnect]').forEach(btn => { btn.addEventListener('click', async () => { const msg = el('adm-mcpMsg'); msg.textContent = 'Reconnecting...'; msg.className = ''; try { const res = await fetch(`/api/mcp/servers/${btn.dataset.admMcpReconnect}/reconnect`, { method: 'POST', credentials: 'same-origin' }); const data = await res.json(); msg.textContent = data.connected ? `Reconnected (${data.tool_count} tools)` : `Failed: ${data.error || 'unknown'}`; msg.className = data.connected ? 'admin-success' : 'admin-error'; loadMcpServers(); } catch (e) { msg.textContent = 'Failed: ' + e.message; msg.className = 'admin-error'; } }); }); list.querySelectorAll('[data-adm-mcp-toggle]').forEach(btn => { btn.addEventListener('click', async () => { const fd = new FormData(); fd.append('is_enabled', btn.dataset.admMcpEnable); await fetch(`/api/mcp/servers/${btn.dataset.admMcpToggle}`, { method: 'PATCH', body: fd, credentials: 'same-origin' }); loadMcpServers(); }); }); list.querySelectorAll('[data-adm-mcp-delete]').forEach(btn => { btn.addEventListener('click', async () => { if (!await uiModule.styledConfirm('Delete this MCP server?', { confirmText: 'Delete', danger: true })) return; await fetch(`/api/mcp/servers/${btn.dataset.admMcpDelete}`, { method: 'DELETE', credentials: 'same-origin' }); loadMcpServers(); }); }); // Tools expand/collapse (click anywhere on card) list.querySelectorAll('[data-adm-mcp-id]').forEach(row => { const header = row.querySelector('[data-adm-mcp-header]'); if (!header) return; let _toolsLoaded = false; row.style.cursor = 'pointer'; row.addEventListener('click', async (e) => { if (e.target.closest('.admin-btn-sm, .admin-btn-delete, a, .mcp-tools-list, .mcp-tools-header')) return; const sid = header.dataset.admMcpHeader; const panel = row.querySelector(`[data-adm-mcp-tools-panel="${sid}"]`); if (!panel) return; panel.classList.toggle('hidden'); const chevron = row.querySelector('.admin-user-chevron'); const isOpen = !panel.classList.contains('hidden'); if (chevron) { chevron.style.transform = isOpen ? 'rotate(180deg)' : ''; chevron.style.opacity = isOpen ? '0.7' : '0.3'; } if (!_toolsLoaded && isOpen) { _toolsLoaded = true; panel.innerHTML = 'Loading tools...'; try { const res = await fetch(`/api/mcp/servers/${sid}/tools`, { credentials: 'same-origin' }); const tools = await res.json(); if (!tools.length) { panel.innerHTML = 'No tools'; return; } const disabled = new Set(tools.filter(t => t.is_disabled).map(t => t.name)); panel.innerHTML = `
Tools ${tools.length - disabled.size}/${tools.length} enabled All None
` + tools.map(t => `` ).join('') + '
'; panel.querySelector(`[data-mcp-select-all="${sid}"]`)?.addEventListener('click', (e) => { e.preventDefault(); panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = true); _saveMcpToolState(sid, panel); }); panel.querySelector(`[data-mcp-select-none="${sid}"]`)?.addEventListener('click', (e) => { e.preventDefault(); panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = false); _saveMcpToolState(sid, panel); }); panel.querySelectorAll('input[type=checkbox]').forEach(cb => { cb.addEventListener('change', () => _saveMcpToolState(sid, panel)); }); } catch (e) { panel.innerHTML = 'Failed to load tools'; } } }); }); } catch (e) { if (list) list.innerHTML = '
Failed to load MCP servers
'; } } async function _saveMcpToolState(serverId, panel) { const disabled = []; panel.querySelectorAll('input[type=checkbox]').forEach(cb => { if (!cb.checked) disabled.push(cb.dataset.mcpToolName); }); const total = panel.querySelectorAll('input[type=checkbox]').length; try { await fetch(`/api/mcp/servers/${serverId}/tools`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ disabled }), }); // Update the count label in the panel const countLabel = panel.querySelector('.mcp-tools-count'); if (countLabel) countLabel.textContent = `${total - disabled.length}/${total} enabled`; // Update badge in the server row const row = panel.closest('[data-adm-mcp-id]'); if (row) { const badge = row.querySelector('.admin-badge'); if (badge) badge.textContent = `Connected (${total - disabled.length}/${total} tools enabled)`; } } catch (e) { /* silent */ } } function initMcpForm() { const cmdEl = el('adm-mcpCommand'); if (!cmdEl) return; // MCP form not present in this build — nothing to wire const transportSel = el('adm-mcpTransport'); const sseRow = el('adm-mcpSseRow'); const envRow = el('adm-mcpEnvRow'); const envFieldsWrap = el('adm-mcpEnvFields'); const helpBox = el('adm-mcpHelp'); const cmdRow = cmdEl.parentElement; let _activeHelp = null; let _envKeys = []; // track which env keys have dedicated fields let _activeOauthFile = null; // preset oauthFile config (for Google servers) let _activeOauth = null; // preset OAuth flow config (provider, scopes, etc.) function _clearEnvFields() { envFieldsWrap.innerHTML = ''; _envKeys = []; envRow.style.display = 'none'; el('adm-mcpEnv').value = ''; _activeOauth = null; } function _buildEnvFields(envObj, help, preset) { _clearEnvFields(); const keys = Object.keys(envObj); if (!keys.length) return; _envKeys = keys; // Provider dropdown (e.g. for Email IMAP/SMTP) if (preset?.providerDropdown) { const pd = preset.providerDropdown; const row = document.createElement('div'); row.className = 'admin-model-form-row'; row.style.cssText = 'gap:6px;align-items:center;'; const label = document.createElement('span'); label.style.cssText = 'font-size:11px;opacity:0.55;min-width:0;white-space:nowrap;'; label.textContent = pd.label || 'Provider'; const select = document.createElement('select'); select.style.cssText = 'flex:1;padding:6px 8px;border-radius:6px;border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-primary);font-size:12px;'; pd.options.forEach((opt, i) => { const o = document.createElement('option'); o.value = i; o.textContent = opt.name; select.appendChild(o); }); select.addEventListener('change', () => { const opt = pd.options[parseInt(select.value)]; for (const [envKey, field] of Object.entries(pd.targets)) { const inp = envFieldsWrap.querySelector(`.mcp-env-input[data-env-key="${envKey}"]`); if (inp) inp.value = opt[field] || ''; } }); row.appendChild(label); row.appendChild(select); envFieldsWrap.appendChild(row); // Auto-fill with first provider after inputs are created setTimeout(() => { const first = pd.options[0]; for (const [envKey, field] of Object.entries(pd.targets)) { const inp = envFieldsWrap.querySelector(`.mcp-env-input[data-env-key="${envKey}"]`); if (inp && !inp.value) inp.value = first[field] || ''; } }, 0); } for (const key of keys) { const row = document.createElement('div'); row.className = 'admin-model-form-row'; row.style.cssText = 'gap:6px;align-items:center;'; const label = document.createElement('span'); label.style.cssText = 'font-size:11px;opacity:0.55;min-width:0;white-space:nowrap;'; label.textContent = key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); const input = document.createElement('input'); input.type = key.toLowerCase().includes('secret') || key.toLowerCase().includes('token') || key.toLowerCase().includes('key') || key.toLowerCase().includes('password') ? 'password' : 'text'; input.placeholder = key; input.dataset.envKey = key; input.className = 'mcp-env-input'; input.style.cssText = 'flex:1;'; if (envObj[key]) input.value = envObj[key]; row.appendChild(label); row.appendChild(input); envFieldsWrap.appendChild(row); } // Help toggle link if (help) { _activeHelp = help; const helpLink = document.createElement('a'); helpLink.textContent = 'How do I get these?'; helpLink.href = '#'; helpLink.style.cssText = 'font-size:10.5px;opacity:0.5;margin-top:2px;display:inline-block;'; helpLink.addEventListener('click', (e) => { e.preventDefault(); helpBox.style.display = helpBox.style.display === 'none' ? '' : 'none'; }); envFieldsWrap.appendChild(helpLink); helpBox.textContent = help; helpBox.style.display = 'none'; } else { _activeHelp = null; helpBox.style.display = 'none'; } } // Collect env from either dedicated fields or raw JSON fallback function _collectEnv() { if (_envKeys.length) { const obj = {}; envFieldsWrap.querySelectorAll('.mcp-env-input').forEach(inp => { if (inp.value.trim()) obj[inp.dataset.envKey] = inp.value.trim(); }); return JSON.stringify(obj); } return el('adm-mcpEnv').value.trim() || '{}'; } transportSel.addEventListener('change', () => { const isSse = transportSel.value === 'sse'; sseRow.style.display = isSse ? '' : 'none'; cmdRow.style.display = isSse ? 'none' : ''; if (isSse) { _clearEnvFields(); helpBox.style.display = 'none'; } }); // Preset catalog const presetSel = el('adm-mcpPreset'); if (presetSel) { MCP_PRESETS.forEach((p, i) => { const opt = document.createElement('option'); opt.value = i; opt.textContent = p.name + (Object.keys(p.env).length ? ' (requires keys)' : ''); presetSel.appendChild(opt); }); presetSel.addEventListener('change', () => { if (presetSel.value === '') return; const p = MCP_PRESETS[parseInt(presetSel.value)]; el('adm-mcpName').value = p.name.toLowerCase().replace(/\s+/g, '-'); transportSel.value = 'stdio'; el('adm-mcpCommand').value = p.command; el('adm-mcpArgs').value = JSON.stringify(p.args); sseRow.style.display = 'none'; cmdRow.style.display = ''; _buildEnvFields(p.env, p.help || null, p); _activeOauthFile = p.oauthFile || null; _activeOauth = p.oauth || null; presetSel.value = ''; // Focus first env field if keys are needed const firstInput = envFieldsWrap.querySelector('.mcp-env-input'); if (firstInput) firstInput.focus(); else el('adm-mcpAddBtn').focus(); }); } el('adm-mcpAddBtn').addEventListener('click', async () => { const name = el('adm-mcpName').value.trim(); const transport = transportSel.value; const command = el('adm-mcpCommand').value.trim(); const args = el('adm-mcpArgs').value.trim() || '[]'; const env = _collectEnv(); const url = el('adm-mcpUrl').value.trim(); const msg = el('adm-mcpMsg'); if (!name) { msg.textContent = 'Name is required'; msg.className = 'admin-error'; return; } if (transport === 'stdio' && !command) { msg.textContent = 'Command is required for stdio'; msg.className = 'admin-error'; return; } if (transport === 'sse' && !url) { msg.textContent = 'URL is required for SSE'; msg.className = 'admin-error'; return; } try { JSON.parse(env); } catch { msg.textContent = 'Env must be valid JSON'; msg.className = 'admin-error'; return; } const fd = new FormData(); fd.append('name', name); fd.append('transport', transport); fd.append('command', command); fd.append('args', args); fd.append('env', env); fd.append('url', url); // If preset has oauthFile config, send credentials for file generation if (_activeOauthFile) { const envObj = JSON.parse(env); fd.append('oauth_file', JSON.stringify({ dir: _activeOauthFile.dir, filename: _activeOauthFile.filename, client_id: envObj.GOOGLE_CLIENT_ID || '', client_secret: envObj.GOOGLE_CLIENT_SECRET || '', })); } // If preset has OAuth flow config, send it so the server can handle authorization if (_activeOauth) { fd.append('oauth_config', JSON.stringify(_activeOauth)); } msg.textContent = 'Adding...'; msg.className = ''; try { const res = await fetch('/api/mcp/servers', { method: 'POST', body: fd, credentials: 'same-origin' }); const data = await res.json(); if (data.needs_oauth) { msg.innerHTML = `Added ${esc(name)} — Authorize with Google to connect`; msg.className = 'admin-success'; } else if (data.connected) { msg.textContent = `Added ${name} (${data.tool_count} tools discovered)`; msg.className = 'admin-success'; } else { msg.textContent = `Added but connection failed: ${data.error || 'unknown'}`; msg.className = 'admin-error'; } el('adm-mcpName').value = ''; el('adm-mcpCommand').value = ''; el('adm-mcpArgs').value = ''; el('adm-mcpUrl').value = ''; _clearEnvFields(); helpBox.style.display = 'none'; _activeHelp = null; _activeOauthFile = null; _activeOauth = null; loadMcpServers(); } catch (e) { msg.textContent = 'Failed: ' + e.message; msg.className = 'admin-error'; } }); } /* ── Embedding model ── No settings UI: the embedding model (RAG, semantic memory, tool selection) is fixed infrastructure that ships with the app, and swapping it would invalidate every existing vector. Configure via the FASTEMBED_MODEL / EMBEDDING_URL env vars if you really need to override it. */ /* ── RAG ── */ async function loadRag() { try { const res = await fetch('/api/personal'); const data = await res.json(); const dirList = el('adm-ragDirList'); const dirs = data.directories || []; if (dirs.length === 0) { dirList.innerHTML = '
No directories indexed
'; } else { dirList.innerHTML = dirs.map(d => `
${esc(d)}
`).join(''); dirList.querySelectorAll('[data-adm-rag-dir]').forEach(btn => { btn.addEventListener('click', async () => { if (!await uiModule.styledConfirm(`Remove directory "${btn.dataset.admRagDir}" from RAG?`, { confirmText: 'Remove', danger: true })) return; btn.disabled = true; btn.textContent = '...'; try { const res = await fetch('/api/personal/remove_directory?directory=' + encodeURIComponent(btn.dataset.admRagDir), { method: 'DELETE' }); if (res.ok) { ragMsg('Directory removed'); loadRag(); } else { const e = await res.json(); ragMsg(e.detail || 'Failed', true); } } catch (e) { ragMsg('Error: ' + e.message, true); } }); }); } const fileList = el('adm-ragFileList'); const files = data.files || []; if (files.length === 0) { fileList.innerHTML = '
No files indexed
'; } else { fileList.innerHTML = files.map(f => { const size = f.size ? (f.size > 1024 ? (f.size / 1024).toFixed(1) + ' KB' : f.size + ' B') : ''; return `
${esc(f.name)}${size}
`; }).join(''); fileList.querySelectorAll('[data-adm-rag-file]').forEach(btn => { btn.addEventListener('click', async () => { if (!await uiModule.styledConfirm(`Delete "${btn.dataset.admRagFile}" from RAG?`, { confirmText: 'Delete', danger: true })) return; btn.disabled = true; btn.textContent = '...'; try { const res = await fetch('/api/personal/file?filepath=' + encodeURIComponent(btn.dataset.admRagFile), { method: 'DELETE' }); if (res.ok) { ragMsg('File removed'); loadRag(); } else { const e = await res.json(); ragMsg(e.detail || 'Failed', true); } } catch (e) { ragMsg('Error: ' + e.message, true); } }); }); } } catch (e) { el('adm-ragDirList').innerHTML = '
Failed to load
'; el('adm-ragFileList').innerHTML = ''; } } let _ragMsgTimer = null; function ragMsg(text, isError, persist) { const s = el('adm-ragStatus'); s.textContent = text; s.style.color = isError ? 'var(--red)' : 'var(--fg)'; if (_ragMsgTimer) { clearTimeout(_ragMsgTimer); _ragMsgTimer = null; } if (text && !persist) _ragMsgTimer = setTimeout(() => { s.textContent = ''; }, 5000); } async function ragUpload(files) { if (!files || files.length === 0) return; ragMsg('Uploading ' + files.length + ' file(s)...', false, true); const fd = new FormData(); for (const f of files) fd.append('files', f); try { const res = await fetch('/api/personal/upload', { method: 'POST', body: fd }); const data = await res.json(); if (data.success) { ragMsg(`Uploaded ${data.uploaded.length} file(s), ${data.indexed_count} chunks indexed`); loadRag(); } else ragMsg(data.detail || 'Upload failed', true); } catch (e) { ragMsg('Upload error: ' + e.message, true); } } function initRag() { const dropZone = el('adm-ragDropZone'); const fileInput = el('adm-ragFileInput'); dropZone.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', () => ragUpload(fileInput.files)); dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('dragover'); }); dropZone.addEventListener('dragleave', () => dropZone.classList.remove('dragover')); dropZone.addEventListener('drop', e => { e.preventDefault(); dropZone.classList.remove('dragover'); ragUpload(e.dataTransfer.files); }); el('adm-ragAddDirBtn').addEventListener('click', async () => { const dir = el('adm-ragDirInput').value.trim(); if (!dir) return; const btn = el('adm-ragAddDirBtn'); btn.disabled = true; btn.textContent = 'Indexing...'; try { const res = await fetch('/api/personal/add_directory', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ directory: dir }) }); const data = await res.json(); if (data.success) { ragMsg(`Indexed ${data.indexed_count} chunks from directory`); el('adm-ragDirInput').value = ''; loadRag(); } else ragMsg(data.detail || data.message || 'Failed', true); } catch (e) { ragMsg('Error: ' + e.message, true); } btn.disabled = false; btn.textContent = 'Add Directory'; }); el('adm-ragReloadBtn').addEventListener('click', async () => { const btn = el('adm-ragReloadBtn'); btn.disabled = true; btn.textContent = 'Reloading...'; try { const res = await fetch('/api/personal/reload', { method: 'POST' }); const data = await res.json(); ragMsg(`Index reloaded: ${data.count} documents`); loadRag(); } catch (e) { ragMsg('Reload failed: ' + e.message, true); } btn.disabled = false; btn.textContent = 'Reload Index'; }); } /* ═══════════════════════════════════════════ SYSTEM TAB — Tokens ═══════════════════════════════════════════ */ async function loadTokens() { const list = el('adm-tokenList'); try { const res = await fetch('/api/tokens', { credentials: 'same-origin' }); const tokens = await res.json(); if (!tokens.length) { list.innerHTML = '
No API tokens
'; return; } list.innerHTML = tokens.map(t => `
${esc(t.name)} ${esc(t.token_prefix)}... ${esc((t.scopes || ['chat']).join(', '))} ${t.owner ? `Owner: ${esc(t.owner)}` : ''} ${t.last_used_at ? `Last used: ${new Date(t.last_used_at).toLocaleDateString()}` : 'Never used'}
`).join(''); list.querySelectorAll('[data-adm-del-token]').forEach(btn => { btn.addEventListener('click', async () => { if (!await uiModule.styledConfirm('Revoke this API token? External integrations using it will stop working.', { confirmText: 'Revoke', danger: true })) return; await fetch(`/api/tokens/${btn.dataset.admDelToken}`, { method: 'DELETE', credentials: 'same-origin' }); loadTokens(); }); }); } catch (e) { list.innerHTML = '
Failed to load tokens
'; } } function initTokenForm() { el('adm-tokenAddBtn').addEventListener('click', async () => { const msg = el('adm-tokenMsg'); const reveal = el('adm-tokenReveal'); msg.textContent = ''; msg.className = ''; reveal.style.display = 'none'; const name = el('adm-tokenName').value.trim(); if (!name) { msg.textContent = 'Token name is required'; msg.className = 'admin-error'; return; } const fd = new FormData(); fd.append('name', name); try { const res = await fetch('/api/tokens', { method: 'POST', body: fd, credentials: 'same-origin' }); const data = await res.json(); if (res.ok) { el('adm-tokenValue').textContent = data.token; reveal.style.display = ''; el('adm-tokenName').value = ''; loadTokens(); } else { msg.textContent = data.detail || 'Failed'; msg.className = 'admin-error'; } } catch (e) { msg.textContent = 'Request failed'; msg.className = 'admin-error'; } }); el('adm-tokenCopyBtn').addEventListener('click', () => { const val = el('adm-tokenValue').textContent; navigator.clipboard.writeText(val).then(() => { el('adm-tokenCopyBtn').textContent = 'Copied!'; setTimeout(() => { el('adm-tokenCopyBtn').textContent = 'Copy'; }, 2000); }); }); } /* ── Webhooks ── */ async function loadWebhooks() { const list = el('adm-whList'); try { const res = await fetch('/api/webhooks', { credentials: 'same-origin' }); const hooks = await res.json(); if (!hooks.length) { list.innerHTML = '
No webhooks configured
'; return; } list.innerHTML = hooks.map(w => { const events = (w.events || []).map(e => `${esc(e)}`).join(' '); const statusBadge = w.last_status_code ? `${w.last_status_code}` : ''; const lastTriggered = w.last_triggered_at ? new Date(w.last_triggered_at).toLocaleString() : 'Never'; const errorText = w.last_error ? `
Error: ${esc(w.last_error.substring(0, 80))}
` : ''; return `
${esc(w.name)} ${w.is_active ? '' : 'disabled'} ${w.has_secret ? 'signed' : ''}
${esc(w.url)}
${events}
Last: ${lastTriggered} ${statusBadge}
${errorText}
`; }).join(''); list.querySelectorAll('[data-adm-wh-test]').forEach(btn => { btn.addEventListener('click', async () => { const msg = el('adm-whMsg'); msg.textContent = 'Sending test...'; msg.className = ''; try { const res = await fetch(`/api/webhooks/${btn.dataset.admWhTest}/test`, { method: 'POST', credentials: 'same-origin' }); msg.textContent = res.ok ? 'Test sent!' : 'Test failed'; msg.className = res.ok ? 'admin-success' : 'admin-error'; setTimeout(() => loadWebhooks(), 1000); } catch (e) { msg.textContent = 'Failed: ' + e.message; msg.className = 'admin-error'; } }); }); list.querySelectorAll('[data-adm-wh-toggle]').forEach(btn => { btn.addEventListener('click', async () => { await fetch(`/api/webhooks/${btn.dataset.admWhToggle}`, { method: 'PATCH', credentials: 'same-origin' }); loadWebhooks(); }); }); list.querySelectorAll('[data-adm-wh-delete]').forEach(btn => { btn.addEventListener('click', async () => { if (!await uiModule.styledConfirm('Delete this webhook?', { confirmText: 'Delete', danger: true })) return; await fetch(`/api/webhooks/${btn.dataset.admWhDelete}`, { method: 'DELETE', credentials: 'same-origin' }); loadWebhooks(); }); }); } catch (e) { list.innerHTML = '
Failed to load webhooks
'; } } function initWebhookForm() { el('adm-whAddBtn').addEventListener('click', async () => { const msg = el('adm-whMsg'); msg.textContent = ''; msg.className = ''; const name = el('adm-whName').value.trim(); const url = el('adm-whUrl').value.trim(); const secret = el('adm-whSecret').value.trim(); const events = Array.from(modalEl.querySelectorAll('.adm-wh-event:checked')).map(e => e.value).join(','); if (!name) { msg.textContent = 'Name is required'; msg.className = 'admin-error'; return; } if (!url) { msg.textContent = 'URL is required'; msg.className = 'admin-error'; return; } if (!events) { msg.textContent = 'Select at least one event'; msg.className = 'admin-error'; return; } const fd = new FormData(); fd.append('name', name); fd.append('url', url); fd.append('secret', secret); fd.append('events', events); try { const res = await fetch('/api/webhooks', { method: 'POST', body: fd, credentials: 'same-origin' }); if (res.ok) { msg.textContent = 'Webhook added'; msg.className = 'admin-success'; el('adm-whName').value = ''; el('adm-whUrl').value = ''; el('adm-whSecret').value = ''; loadWebhooks(); } else { const d = await res.json(); msg.textContent = d.detail || 'Failed'; msg.className = 'admin-error'; } } catch (e) { msg.textContent = 'Failed: ' + e.message; msg.className = 'admin-error'; } }); } /* ── Features ── */ const featureLabels = { web_search: 'Web Search', deep_research: 'Deep Research', memory: 'Memory', document_editor: 'Document Editor', rag: 'RAG Knowledge Base', sensitive_filter: 'Sensitive Info Filter', gallery: 'Gallery' }; async function loadFeatures() { const container = el('adm-featureToggles'); try { const res = await fetch('/api/auth/features', { credentials: 'same-origin' }); const features = await res.json(); container.innerHTML = Object.entries(featureLabels).map(([key, label]) => `
${label}
`).join(''); container.querySelectorAll('input[data-adm-feature]').forEach(toggle => { toggle.addEventListener('change', async () => { const body = {}; body[toggle.dataset.admFeature] = toggle.checked; await fetch('/api/auth/features', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); }); }); } catch (e) { container.innerHTML = '
Failed to load features
'; } } /* ── CalDAV Config ── */ function initCalDAV() { const urlIn = el('caldav-url'); const userIn = el('caldav-user'); const passIn = el('caldav-pass'); const saveBtn = el('caldav-save-btn'); const testBtn = el('caldav-test-btn'); const status = el('caldav-status'); if (!urlIn || !saveBtn) return; // Load current config fetch(`${API_BASE}/api/calendar/config`, { credentials: 'same-origin' }) .then(r => r.json()).then(d => { urlIn.value = d.caldav_url || ''; userIn.value = d.caldav_username || ''; passIn.value = d.caldav_password || ''; }).catch(() => {}); saveBtn.addEventListener('click', async () => { status.textContent = 'Saving...'; try { const res = await fetch(`${API_BASE}/api/calendar/config`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ caldav_url: urlIn.value, caldav_username: userIn.value, caldav_password: passIn.value }), }); const d = await res.json(); status.textContent = d.ok ? 'Saved' : 'Error'; status.style.color = d.ok ? 'var(--green)' : 'var(--red)'; } catch (e) { status.textContent = 'Error'; status.style.color = 'var(--red)'; } setTimeout(() => { status.textContent = ''; status.style.color = ''; }, 3000); }); testBtn.addEventListener('click', async () => { status.textContent = 'Testing...'; try { // Save first await fetch(`${API_BASE}/api/calendar/config`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ caldav_url: urlIn.value, caldav_username: userIn.value, caldav_password: passIn.value }), }); const res = await fetch(`${API_BASE}/api/calendar/test`, { method: 'POST', credentials: 'same-origin' }); const d = await res.json(); status.textContent = d.ok ? `Connected (${d.calendars} calendars)` : `Failed: ${d.error}`; status.style.color = d.ok ? 'var(--green)' : 'var(--red)'; } catch (e) { status.textContent = 'Error'; status.style.color = 'var(--red)'; } setTimeout(() => { status.textContent = ''; status.style.color = ''; }, 5000); }); } /* ── Data Backup (export/import) ── */ function initBackup() { el('adm-exportDataBtn').addEventListener('click', async () => { const btn = el('adm-exportDataBtn'); const msg = el('adm-backupMsg'); btn.disabled = true; btn.textContent = 'Exporting...'; msg.textContent = ''; try { const res = await fetch('/api/export', { credentials: 'same-origin' }); if (!res.ok) throw new Error('Export failed'); const blob = await res.blob(); const disposition = res.headers.get('Content-Disposition') || ''; const match = disposition.match(/filename=(.+)/); const filename = match ? match[1] : 'odysseus_backup.json'; const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; a.click(); URL.revokeObjectURL(a.href); msg.textContent = 'Export downloaded.'; msg.className = 'admin-success'; } catch (e) { msg.textContent = 'Export failed: ' + e.message; msg.className = 'admin-error'; } btn.disabled = false; btn.textContent = 'Export Data'; }); const fileInput = el('adm-importFile'); el('adm-importDataBtn').addEventListener('click', () => { fileInput.value = ''; fileInput.click(); }); fileInput.addEventListener('change', async () => { const file = fileInput.files[0]; if (!file) return; const msg = el('adm-backupMsg'); const btn = el('adm-importDataBtn'); btn.disabled = true; btn.textContent = 'Importing...'; msg.textContent = ''; try { const text = await file.text(); const data = JSON.parse(text); const res = await fetch('/api/import', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); const result = await res.json(); if (res.ok && result.ok) { msg.textContent = result.message || 'Import successful.'; msg.className = 'admin-success'; } else { msg.textContent = result.message || result.detail || 'Import failed'; msg.className = 'admin-error'; } } catch (e) { msg.textContent = 'Import failed: ' + e.message; msg.className = 'admin-error'; } btn.disabled = false; btn.textContent = 'Import Data'; }); } /* ── Danger Zone ── */ function initDangerZone() { // Per-category Danger Zone wipes. Each button declares its target // via data-wipe-kind; one delegated handler handles double-confirm, // POSTs to /api/admin/wipe/{kind}, and writes the result. const _LABELS = { chats: 'chats', memory: 'memory entries', skills: 'skills', notes: 'notes', tasks: 'tasks', documents: 'documents', gallery: 'gallery images', calendar: 'calendar items', }; const _wipeMsg = el('adm-wipeMsg'); modalEl.querySelectorAll('[data-wipe-kind]').forEach(btn => { btn.addEventListener('click', async () => { const kind = btn.dataset.wipeKind; const label = _LABELS[kind] || kind; if (!await uiModule.styledConfirm(`Wipe ALL ${label}? This cannot be undone.`, { confirmText: 'Wipe', danger: true })) return; if (!await uiModule.styledConfirm(`Really wipe every one of your ${label}?`, { confirmText: 'Yes, wipe everything', danger: true })) return; btn.disabled = true; const prev = btn.textContent; btn.textContent = 'Wiping…'; if (_wipeMsg) { _wipeMsg.textContent = ''; _wipeMsg.className = ''; } try { const res = await fetch(`/api/admin/wipe/${kind}`, { method: 'DELETE', credentials: 'same-origin' }); const data = await res.json().catch(() => ({})); if (res.ok) { if (_wipeMsg) { _wipeMsg.textContent = `Wiped ${data.count ?? 0} ${label}.`; _wipeMsg.className = 'admin-success'; } } else { if (_wipeMsg) { _wipeMsg.textContent = data.detail || 'Failed'; _wipeMsg.className = 'admin-error'; } } } catch (e) { if (_wipeMsg) { _wipeMsg.textContent = 'Request failed: ' + e.message; _wipeMsg.className = 'admin-error'; } } btn.disabled = false; btn.textContent = prev; }); }); } /* ═══════════════════════════════════════════ INIT & REFRESH ═══════════════════════════════════════════ */ function initAll() { modalEl = el('settings-modal'); const inits = [initSignupToggle, initAddUser, initEndpointForm, initMcpForm, initCalDAV, initBackup, initDangerZone, () => settingsModule.initIntegrations()]; for (const fn of inits) { try { fn(); } catch (e) { console.error('Admin init error in', fn.name || 'anonymous', e); } } initialized = true; refreshAll(); } function refreshAll() { loadUsers(); loadEndpoints(); loadBuiltinTools(); loadMcpServers(); } /* ═══════════════════════════════════════════ PUBLIC API ═══════════════════════════════════════════ */ export function _initData() { if (!initialized) initAll(); else refreshAll(); } export function open(tab) { _initData(); settingsModule.open(tab || 'services'); } export function close() { settingsModule.close(); } const adminModule = { open, close, _initData, get _initialized() { return initialized; } }; export default adminModule;