// static/js/settings.js — Settings panel module (ES6) // User-facing preferences: AI models, search, appearance import uiModule from './ui.js'; import searchModule from './search.js'; import { makeWindowDraggable } from './windowDrag.js'; import { clearDockSide } from './modalSnap.js'; import { sortModelIds } from './modelSort.js'; import { isAltGrEvent } from './platform.js'; let initialized = false; let modalEl = null; function el(id) { return document.getElementById(id); } function esc(s) { return uiModule.esc(s); } /* ── Tab switching ── */ const ADMIN_TABS = new Set(['services', 'integrations', 'tools', 'users', 'system']); function initTabs() { modalEl.querySelectorAll('[data-settings-tab]').forEach(btn => { btn.addEventListener('click', () => { const tab = btn.dataset.settingsTab; // Lazy-init admin when first clicking an admin tab if (ADMIN_TABS.has(tab) && window.adminModule && typeof window.adminModule.open === 'function') { window.adminModule.open(tab); return; } modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab)); modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab)); // Mark when the Appearance tab is open so the modal can go // semi-transparent — lets the user see the rest of the UI react as // they flip toggles instead of having to close + reopen the modal. document.body.classList.toggle('settings-appearance-open', tab === 'appearance'); syncAppearanceOpacity(tab === 'appearance'); if (tab === 'ai') refreshAiModelEndpoints(); }); }); } /* ── Dragging ── */ function initDrag() { const header = modalEl.querySelector('.modal-header'); const content = modalEl.querySelector('.settings-modal-content'); if (!header || !content) return; // Skip interactive controls in the header (e.g. the opacity slider) so // grabbing them doesn't start a window-drag. makeWindowDraggable(modalEl, { content, header, skipSelector: 'button, input, select, .theme-opacity-wrap', enableDock: false, }); } function resetWindowPlacement() { const content = modalEl && modalEl.querySelector('.settings-modal-content'); if (!content) return; const hadLeft = modalEl.classList.contains('modal-left-docked'); const hadRight = modalEl.classList.contains('modal-right-docked'); modalEl.classList.remove('modal-left-docked', 'modal-right-docked'); if (hadLeft) clearDockSide('left', modalEl); if (hadRight) clearDockSide('right', modalEl); if (content._leftDockNavObs) { try { content._leftDockNavObs.navObs && content._leftDockNavObs.navObs.disconnect(); } catch (_) {} try { window.removeEventListener('resize', content._leftDockNavObs.reanchor); } catch (_) {} delete content._leftDockNavObs; } delete content._preDockSnapshot; delete content._dockSide; delete content._dockSuspended; delete content.dataset._tilePreSnap; delete content.dataset._tileZone; [ 'position', 'left', 'top', 'right', 'bottom', 'margin', 'transform', 'width', 'height', 'max-width', 'max-height', 'border-radius', 'transition', ].forEach(prop => content.style.removeProperty(prop)); } /* ── Close on backdrop / X ── */ function initClose() { modalEl.querySelector('.close-btn').addEventListener('click', close); modalEl.addEventListener('mousedown', e => { if (uiModule.isTouchInsideModal()) return; if (e.target === modalEl) close(); }); document.addEventListener('keydown', e => { if (e.key !== 'Escape' || !modalEl || modalEl.classList.contains('hidden')) return; // If an integration edit/add form is open inside the modal, close // just that — don't dismiss the whole settings modal. (Pressing // ESC mid-edit and losing the modal was a fast-typing footgun.) const innerForm = modalEl.querySelector('#unified-intg-form, #set-email-accounts-form'); if (innerForm && innerForm.style.display !== 'none' && innerForm.children.length > 0) { e.preventDefault(); e.stopPropagation(); innerForm.style.display = 'none'; innerForm.innerHTML = ''; return; } e.preventDefault(); e.stopPropagation(); close(); }); } /* ── Appearance-tab opacity slider ── Mirrors the Theme customizer's slider: fades the settings modal's background (and inner cards) via color-mix so the user can watch the rest of the UI react to toggles, while keeping text/controls crisp (no element opacity). Only shown/active on the Appearance tab. */ const _SETTINGS_PEEK = 55; // % opacity when the Peek toggle is on function _applySettingsOpacity(on) { const content = modalEl && modalEl.querySelector('.settings-modal-content, .modal-content'); if (!content) return; const cards = content.querySelectorAll('.admin-card'); if (on) { const bgMix = `color-mix(in srgb, var(--bg) ${_SETTINGS_PEEK}%, transparent)`; const panelMix = `color-mix(in srgb, var(--panel) ${_SETTINGS_PEEK}%, transparent)`; content.style.setProperty('background', bgMix, 'important'); content.style.setProperty('backdrop-filter', 'none', 'important'); content.style.setProperty('-webkit-backdrop-filter', 'none', 'important'); cards.forEach(c => { c.style.setProperty('background', panelMix, 'important'); c.style.setProperty('backdrop-filter', 'none', 'important'); c.style.setProperty('-webkit-backdrop-filter', 'none', 'important'); }); } else { content.style.removeProperty('background'); content.style.removeProperty('backdrop-filter'); content.style.removeProperty('-webkit-backdrop-filter'); cards.forEach(c => { c.style.removeProperty('background'); c.style.removeProperty('backdrop-filter'); c.style.removeProperty('-webkit-backdrop-filter'); }); } } // Show/hide the Peek toggle for the Appearance tab and apply or clear the fade. function syncAppearanceOpacity(active) { const toggle = el('settings-opacity-wrap'); if (toggle) toggle.classList.toggle('hidden', !active); if (active) { _applySettingsOpacity(toggle ? toggle.classList.contains('active') : false); } else { _applySettingsOpacity(false); // clear the fade off the Appearance tab } } function initOpacityToggle() { const toggle = el('settings-opacity-wrap'); if (!toggle || toggle.dataset.bound === '1') return; toggle.dataset.bound = '1'; toggle.addEventListener('click', () => { const on = !toggle.classList.contains('active'); toggle.classList.toggle('active', on); toggle.setAttribute('aria-pressed', on ? 'true' : 'false'); _applySettingsOpacity(on); }); } /* ═══════════════════════════════════════════ AI TAB ═══════════════════════════════════════════ */ const _aiEndpointRefreshers = new Set(); let _aiEndpointRefreshInFlight = null; async function _fetchModelEndpoints() { const epRes = await fetch('/api/model-endpoints', { credentials: 'same-origin' }); const endpoints = await epRes.json(); return Array.isArray(endpoints) ? endpoints : []; } function _endpointLabel(ep) { return ep.name + (ep.online ? '' : ' (offline)'); } function _fillEndpointSelect(selectEl, endpoints, selected, keepBlank) { if (!selectEl) return; const previous = selected !== undefined ? selected : selectEl.value; const blankText = keepBlank && selectEl.options[0] && selectEl.options[0].value === '' ? selectEl.options[0].textContent : null; while (selectEl.options.length) selectEl.remove(0); if (blankText !== null) { const blank = document.createElement('option'); blank.value = ''; blank.textContent = blankText; selectEl.appendChild(blank); } (endpoints || []).forEach(function(ep) { if (!ep.is_enabled) return; const opt = document.createElement('option'); opt.value = ep.id; opt.textContent = _endpointLabel(ep); selectEl.appendChild(opt); }); if (previous && Array.from(selectEl.options).some(function(o) { return o.value === previous; })) { selectEl.value = previous; } else if (blankText !== null) { selectEl.value = ''; } } function _fillModelSelect(selectEl, models, selected, keepBlank) { if (!selectEl) return; const previous = selected !== undefined ? selected : selectEl.value; const blankText = keepBlank && selectEl.options[0] && selectEl.options[0].value === '' ? selectEl.options[0].textContent : null; while (selectEl.options.length) selectEl.remove(0); if (blankText !== null) { const blank = document.createElement('option'); blank.value = ''; blank.textContent = blankText; selectEl.appendChild(blank); } sortModelIds(models).forEach(function(m) { const opt = document.createElement('option'); opt.value = m; opt.textContent = String(m).split('/').pop(); selectEl.appendChild(opt); }); if (previous && Array.from(selectEl.options).some(function(o) { return o.value === previous; })) { selectEl.value = previous; } else if (blankText !== null) { selectEl.value = ''; } } function _registerAiEndpointRefresh(fn) { _aiEndpointRefreshers.add(fn); } export async function refreshAiModelEndpoints() { if (_aiEndpointRefreshInFlight) return _aiEndpointRefreshInFlight; _aiEndpointRefreshInFlight = (async function() { try { const endpoints = await _fetchModelEndpoints(); _aiEndpointRefreshers.forEach(function(fn) { try { fn(endpoints); } catch (e) { console.warn('[settings] endpoint refresh handler failed', e); } }); } catch (e) { console.warn('[settings] failed to refresh model endpoints', e); } finally { _aiEndpointRefreshInFlight = null; } })(); return _aiEndpointRefreshInFlight; } /* Shared fallback-chain widget — mirrors the Default Chat Model fallback UI * for other model cards (Utility, Vision, …). Pass in the container/button * IDs, the endpoints list, the settings key to persist under, and the * model-filter (for Vision we exclude non-chat-capable models). */ function _bindFallbackWidget(opts) { var fbContainer = el(opts.containerId); var addBtn = el(opts.addBtnId); var endpointsRef = opts.endpoints; // mutable list reference var modelsFilter = opts.modelsFilter || function() { return true; }; var settingKey = opts.settingKey; var current = opts.initial || []; // [{endpoint_id, model}] if (!fbContainer || !addBtn) return { setEndpoints: function() {}, setInitial: function() {} }; function enabledEps() { return (endpointsRef() || []).filter(function(e) { return e.is_enabled; }); } function fillModels(selectEl, epId, selected) { while (selectEl.options.length) selectEl.remove(0); var ep = (endpointsRef() || []).find(function(e) { return e.id === epId; }); if (ep && ep.models) { sortModelIds(ep.models).forEach(function(m) { if (!modelsFilter(m, ep)) return; var o = document.createElement('option'); o.value = m; o.textContent = m.split('/').pop(); selectEl.appendChild(o); }); } if (selected) selectEl.value = selected; } async function save() { var clean = current.filter(function(f) { return f.endpoint_id && f.model; }); var body = {}; body[settingKey] = clean; try { await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } catch (e) { console.warn('[fallback] save failed for ' + settingKey, e); } } function render() { fbContainer.innerHTML = ''; current.forEach(function(fb, idx) { var row = document.createElement('div'); row.className = 'settings-fallback-row'; var num = document.createElement('span'); num.className = 'settings-fallback-num'; num.textContent = (idx + 1) + '.'; var epS = document.createElement('select'); epS.className = 'settings-select'; enabledEps().forEach(function(ep) { var o = document.createElement('option'); o.value = ep.id; o.textContent = ep.name + (ep.online ? '' : ' (offline)'); epS.appendChild(o); }); var first = enabledEps()[0]; epS.value = fb.endpoint_id || (first ? first.id : ''); var mS = document.createElement('select'); mS.className = 'settings-select'; fillModels(mS, epS.value, fb.model); fb.endpoint_id = epS.value; fb.model = mS.value; epS.addEventListener('change', function() { fb.endpoint_id = epS.value; fillModels(mS, epS.value, ''); fb.model = mS.value; save(); }); mS.addEventListener('change', function() { fb.model = mS.value; save(); }); var rm = document.createElement('button'); rm.type = 'button'; rm.className = 'settings-fallback-remove'; rm.title = 'Remove fallback'; rm.innerHTML = '×'; rm.addEventListener('click', function() { current.splice(idx, 1); render(); save(); }); row.appendChild(num); row.appendChild(epS); row.appendChild(mS); row.appendChild(rm); fbContainer.appendChild(row); }); } addBtn.addEventListener('click', function() { var first = enabledEps()[0]; current.push({ endpoint_id: first ? first.id : '', model: '' }); render(); save(); }); render(); return { setInitial: function(list) { current = (list || []).slice(); render(); }, refresh: render, }; } /* ── Default Chat Model ── */ async function initDefaultChat() { var epSel = el('set-defaultEpSelect'); var modelSel = el('set-defaultModelSelect'); var msg = el('set-defaultChatMsg'); var fbContainer = el('set-defaultFallbacks'); var addFbBtn = el('set-defaultAddFallback'); var _endpoints = []; var _fallbacks = []; // [{endpoint_id, model}] — tried in order if primary fails function enabledEndpoints() { return _endpoints.filter(function(e) { return e.is_enabled; }); } // Fill any ) ── var picker = el('search-provider-picker'); var pickerBtn = el('search-provider-btn'); var pickerMenu = el('search-provider-menu'); var pickerCurrent = picker ? picker.querySelector('.adm-provider-current') : null; function _searchProviderLogoSvg(key) { return _SEARCH_PROVIDER_LOGOS[key] || ''; } function _renderSearchPickerMenu() { if (!pickerMenu) return; pickerMenu.innerHTML = Array.from(provSel.options).map(function(o) { var logo = _searchProviderLogoSvg(o.dataset.searchLogo); var active = o.value === provSel.value ? ' active' : ''; return '
' + '' + '' + o.textContent + '' + '
'; }).join(''); } function _syncSearchPicker() { if (!pickerCurrent) return; var opt = provSel.selectedOptions[0] || provSel.options[0]; var logo = _searchProviderLogoSvg(opt.dataset.searchLogo); pickerCurrent.querySelector('.adm-provider-logo').innerHTML = logo; pickerCurrent.querySelector('.adm-provider-name').textContent = opt.textContent; } if (picker && pickerBtn && pickerMenu && pickerCurrent) { _renderSearchPickerMenu(); _syncSearchPicker(); pickerBtn.addEventListener('click', function(e) { e.stopPropagation(); pickerMenu.classList.toggle('hidden'); }); pickerMenu.addEventListener('click', function(e) { var item = e.target.closest('.adm-provider-item'); if (!item) return; provSel.value = item.dataset.value; provSel.dispatchEvent(new Event('change', { bubbles: true })); pickerMenu.classList.add('hidden'); _renderSearchPickerMenu(); }); document.addEventListener('click', function(e) { if (!picker.contains(e.target)) pickerMenu.classList.add('hidden'); }); } // ── Fallback chain ── // Stored as an ordered array of provider IDs (primary not included). // When the primary fails or hits rate-limit, the backend walks this // list in order trying each one. var fbWrap = el('set-searchFallbackChain'); function _availableFallbackOptions() { var primary = provSel.value; var chain = _settings.search_fallback_chain || []; var inChain = new Set(chain.concat([primary, 'disabled'])); return Array.from(provSel.options) .map(function(o) { return { value: o.value, label: o.textContent, logo: o.dataset.searchLogo }; }) .filter(function(o) { return !inChain.has(o.value); }); } function _renderFallbackChain() { if (!fbWrap) return; var chain = (_settings.search_fallback_chain || []).slice(); var esc = function(s) { return String(s || '').replace(/&/g, '&').replace(/' + '⋮⋮' + '' + '' + esc(label) + '' + '' + ''; }).join(''); var addOptions = _availableFallbackOptions(); var addSelect = addOptions.length ? '' : ''; fbWrap.innerHTML = chipsHtml + addSelect; // Wire chip remove + drag-reorder + add fbWrap.querySelectorAll('.search-fb-remove').forEach(function(btn) { btn.addEventListener('click', function() { var next = (_settings.search_fallback_chain || []).filter(function(p) { return p !== btn.dataset.value; }); _saveFallbackChain(next); }); }); var addSel = el('search-fb-add'); if (addSel) { addSel.addEventListener('change', function() { if (!addSel.value) return; var next = (_settings.search_fallback_chain || []).slice(); if (!next.includes(addSel.value)) next.push(addSel.value); _saveFallbackChain(next); }); } // Drag-reorder var dragging = null; fbWrap.querySelectorAll('.search-fb-chip').forEach(function(chip) { chip.addEventListener('dragstart', function() { dragging = chip; chip.classList.add('dragging'); }); chip.addEventListener('dragend', function() { if (dragging) dragging.classList.remove('dragging'); dragging = null; // Persist new order var order = Array.from(fbWrap.querySelectorAll('.search-fb-chip')).map(function(c) { return c.dataset.value; }); _saveFallbackChain(order); }); chip.addEventListener('dragover', function(e) { e.preventDefault(); if (!dragging || dragging === chip) return; var rect = chip.getBoundingClientRect(); var after = (e.clientX - rect.left) > rect.width / 2; chip.parentNode.insertBefore(dragging, after ? chip.nextSibling : chip); }); }); } async function _saveFallbackChain(chain) { _settings.search_fallback_chain = chain; try { await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ search_fallback_chain: chain }), }); msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(refreshStatus, 2000); } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } _renderFallbackChain(); } _renderFallbackChain(); // Re-render whenever the primary changes (it gets filtered out of "Add"). provSel.addEventListener('change', _renderFallbackChain); // ── Test button ── runs a one-off query against the configured provider. var testBtn = el('set-searchTestBtn'); if (testBtn) { testBtn.addEventListener('click', async function() { var prov = provSel.value; if (!prov || prov === 'disabled') { msg.textContent = 'Pick a provider first'; msg.style.color = 'var(--red)'; return; } // Persist current form values first so the test uses what's on screen. await saveSearch(); testBtn.disabled = true; var orig = testBtn.textContent; testBtn.textContent = 'Testing...'; msg.textContent = ''; var t0 = performance.now(); try { var fd = new FormData(); fd.append('query', 'hello world'); fd.append('provider', prov); fd.append('count', '3'); var r = await fetch('/api/search/query', { method: 'POST', body: fd, credentials: 'same-origin' }); var d = await r.json(); var ms = Math.round(performance.now() - t0); if (d.error) { msg.textContent = '✗ ' + d.error + ' (' + ms + 'ms)'; msg.style.color = 'var(--red)'; } else if (!d.results || !d.results.length) { msg.textContent = '⚠ No results returned (' + ms + 'ms)'; msg.style.color = 'var(--red)'; } else { var topTitle = (d.results[0].title || d.results[0].url || '').slice(0, 60); msg.textContent = '✓ ' + d.results.length + ' result' + (d.results.length === 1 ? '' : 's') + ' · ' + ms + 'ms · top: ' + topTitle; msg.style.color = 'var(--fg)'; } } catch (e) { msg.textContent = '✗ Test failed: ' + (e && e.message ? e.message : e); msg.style.color = 'var(--red)'; } finally { testBtn.disabled = false; testBtn.textContent = orig; } }); } } // SVG logos for each search provider (16×16 viewBox normalised to 24×24). var _SEARCH_PROVIDER_LOGOS = { searxng: '', duckduckgo:'', brave: '', google_pse:'', tavily: '', serper: '', disabled: '', }; /* ── Deep Research Model (AI tab) ── */ async function initResearchSettings() { var epSel = el('set-researchEndpoint'); var modelSel = el('set-researchModel'); var tokensInput = el('set-researchMaxTokens'); var extractTimeoutInput = el('set-researchExtractTimeout'); var extractConcurrencyInput = el('set-researchExtractConcurrency'); var msg = el('set-researchMsg'); var endpoints = []; try { endpoints = await _fetchModelEndpoints(); _fillEndpointSelect(epSel, endpoints, epSel.value, true); } catch (e) { console.warn('Failed to load endpoints for research', e); } function refreshModels(selectedModel) { var epId = epSel.value; var ep = endpoints.find(function(e) { return e.id === epId; }); _fillModelSelect(modelSel, ep ? ep.models : [], selectedModel, true); } try { var res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); var settings = await res.json(); if (settings.research_endpoint_id) epSel.value = settings.research_endpoint_id; refreshModels(settings.research_model || ''); if (settings.research_max_tokens) tokensInput.value = settings.research_max_tokens; if (settings.research_extraction_timeout_seconds) extractTimeoutInput.value = settings.research_extraction_timeout_seconds; if (settings.research_extraction_concurrency) extractConcurrencyInput.value = settings.research_extraction_concurrency; } catch (e) { console.warn('Failed to load research settings', e); } function showStatus() { var parts = []; if (epSel.value) { var epName = epSel.options[epSel.selectedIndex].textContent; var mName = modelSel.value ? modelSel.value.split('/').pop() : 'auto'; parts.push(epName + ' / ' + mName); } if (tokensInput.value) { parts.push('Max tokens: ' + tokensInput.value); } if (extractTimeoutInput.value) { parts.push('Extract: ' + extractTimeoutInput.value + 's'); } if (extractConcurrencyInput.value) { parts.push('Parallel: ' + extractConcurrencyInput.value); } if (parts.length) { msg.textContent = parts.join(' · '); msg.style.color = 'var(--fg)'; } else { msg.textContent = 'Using chat defaults'; msg.style.color = 'var(--fg)'; } } showStatus(); async function saveResearch() { var payload = { research_endpoint_id: epSel.value, research_model: modelSel.value, }; var tv = parseInt(tokensInput.value, 10); if (tv && tv >= 1024) payload.research_max_tokens = tv; var et = parseInt(extractTimeoutInput.value, 10); if (et && et >= 15 && et <= 600) payload.research_extraction_timeout_seconds = et; var ec = parseInt(extractConcurrencyInput.value, 10); if (ec && ec >= 1 && ec <= 12) payload.research_extraction_concurrency = ec; try { await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(showStatus, 2000); } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } } epSel.addEventListener('change', async function() { refreshModels(''); saveResearch(); }); modelSel.addEventListener('change', saveResearch); tokensInput.addEventListener('change', saveResearch); extractTimeoutInput.addEventListener('change', saveResearch); extractConcurrencyInput.addEventListener('change', saveResearch); _registerAiEndpointRefresh(function(nextEndpoints) { endpoints = nextEndpoints; _fillEndpointSelect(epSel, endpoints, epSel.value, true); refreshModels(modelSel.value); }); } /* ── Deep Research Search (Search tab) ── */ async function initResearchSearchSettings() { var searchSel = el('set-researchSearch'); var msg = el('set-researchSearchMsg'); function updateSearchOptions(settings) { var options = searchSel.querySelectorAll('option'); options.forEach(function(opt) { var prov = opt.value; if (!prov) return; var kf = _searchKeyFields[prov]; if (!kf) return; var hasKey = ((settings[kf] || '').trim() || (settings.search_api_key || '').trim()); if (!hasKey) { opt.textContent = (_searchLabels[prov] || prov) + ' (no key)'; opt.style.color = 'var(--red)'; } else { opt.textContent = _searchLabels[prov] || prov; opt.style.color = ''; } }); } try { var res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); var settings = await res.json(); if (settings.research_search_provider) searchSel.value = settings.research_search_provider; updateSearchOptions(settings); } catch (e) { console.warn('Failed to load research search settings', e); } async function saveResearchSearch() { try { await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ research_search_provider: searchSel.value }) }); msg.textContent = 'Saved'; msg.style.color = 'var(--fg)'; setTimeout(function() { msg.textContent = ''; }, 2000); } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } } searchSel.addEventListener('change', saveResearchSearch); } /* ── Agent Settings (AI tab) ── */ async function initAgentSettings() { var toolsInput = el('set-agentMaxTools'); var msg = el('set-agentMsg'); if (!toolsInput) return; try { var res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); var settings = await res.json(); if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls; } catch (e) {} async function save() { var val = parseInt(toolsInput.value, 10) || 0; try { await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agent_max_tool_calls: val }) }); msg.textContent = val > 0 ? 'Limit: ' + val + ' tool calls per message' : 'Unlimited'; msg.style.color = 'var(--fg)'; } catch (e) { msg.textContent = 'Failed to save'; msg.style.color = 'var(--red)'; } } toolsInput.addEventListener('change', save); var cur = parseInt(toolsInput.value, 10) || 0; msg.textContent = cur > 0 ? 'Limit: ' + cur + ' tool calls per message' : 'Unlimited'; } /* ═══════════════════════════════════════════ APPEARANCE TAB ═══════════════════════════════════════════ */ function initAppearance() { syncAppearanceCheckboxes(); syncPrivacyCheckboxes(); modalEl.querySelectorAll('[data-ui-key]').forEach(function(chk) { chk.addEventListener('change', async function() { var key = chk.dataset.uiKey; if (window.UI_VIS_ADMIN_ONLY && window.UI_VIS_ADMIN_ONLY.has(key) && !chk.checked && !window._isAdmin) { chk.checked = true; if (uiModule && uiModule.showToast) { uiModule.showToast('Only admins can hide Settings.'); } return; } // Hiding the Settings cog removes the only visible way to re-open this // panel. Warn the user and remind them about the `/settings` slash // command so they don't lock themselves out. if (key === 'sidebar-settings-btn' && !chk.checked) { var ok = true; try { ok = await (uiModule && uiModule.styledConfirm ? uiModule.styledConfirm( 'Hide the Settings cog?\n\nYou can re-open this panel any time by typing /settings in the chat input.', { confirmText: 'Hide', cancelText: 'Cancel' } ) : Promise.resolve(window.confirm('Hide the Settings cog?\n\nYou can re-open this panel any time by typing /settings in the chat input.'))); } catch (_) { ok = false; } if (!ok) { chk.checked = true; return; } if (uiModule && uiModule.showToast) { uiModule.showToast('Settings cog hidden — type /settings to bring it back.', 5000); } } var s = window.loadUIVis(); s[key] = chk.checked; window.saveUIVis(s); window.applyUIVis(s); }); }); modalEl.querySelectorAll('[data-privacy-key]').forEach(function(chk) { chk.addEventListener('change', function() { if (chk.dataset.privacyKey !== 'sensitive-blur') return; localStorage.setItem('odysseus-sensitive-blur', chk.checked ? 'on' : 'off'); window.dispatchEvent(new CustomEvent('odysseus-sensitive-blur-change', { detail: { enabled: chk.checked } })); }); }); var resetBtn = el('set-uiVisResetBtn'); if (resetBtn) { resetBtn.addEventListener('click', function() { localStorage.removeItem('odysseus-ui-visibility'); syncAppearanceCheckboxes(); syncPrivacyCheckboxes(); window.applyUIVis({}); }); } } function syncAppearanceCheckboxes() { var s = window.loadUIVis ? window.loadUIVis() : {}; var defaultOff = window.UI_VIS_DEFAULT_OFF || new Set(); modalEl.querySelectorAll('[data-ui-key]').forEach(function(chk) { var key = chk.dataset.uiKey; chk.checked = key in s ? s[key] !== false : !defaultOff.has(key); }); } function syncPrivacyCheckboxes() { modalEl.querySelectorAll('[data-privacy-key="sensitive-blur"]').forEach(function(chk) { chk.checked = localStorage.getItem('odysseus-sensitive-blur') === 'on'; }); } /* ═══════════════════════════════════════════ SHORTCUTS TAB ═══════════════════════════════════════════ */ const SHORTCUT_DEFAULTS = { search: 'ctrl+k', toggle_sidebar: 'ctrl+b', new_session: 'ctrl+alt+n', fav_session: 'ctrl+alt+f', delete_session: 'ctrl+alt+d', cancel: 'escape', tts: 'alt+shift+t', incognito: 'ctrl+alt+i', settings: 'ctrl+,', focus_input: 'ctrl+/', // Open-tool shortcuts. Calendar is bound by default; the rest are // unbound (empty) so the user can assign their own in the panel. open_calendar: 'ctrl+alt+c', open_compare: '', open_cookbook: '', open_research: '', open_gallery: '', open_library: '', open_memory: '', open_notes: '', open_tasks: '', open_theme: '', }; const SHORTCUT_ICONS = { search: '', toggle_sidebar: '', new_session: '', fav_session: '', delete_session: '', cancel: '', tts: '', incognito: '', settings: '', focus_input: '', open_calendar: '', open_compare: '', open_cookbook: '', open_research: '', open_gallery: '', open_library: '', open_memory: '', open_notes: '', open_tasks: '', open_theme: '', }; const SHORTCUT_LABELS = { search: 'Search conversations', toggle_sidebar: 'Toggle sidebar', new_session: 'New session', fav_session: 'Favorite session', delete_session: 'Delete session', cancel: 'Cancel / close', tts: 'Play/stop TTS', incognito: 'Toggle incognito', settings: 'Toggle Window', focus_input: 'Focus chat input', open_calendar: 'Open Calendar', open_compare: 'Open Compare', open_cookbook: 'Open Cookbook', open_research: 'Open Deep Research', open_gallery: 'Open Gallery', open_library: 'Open Library', open_memory: 'Open Memory', open_notes: 'Open Notes', open_tasks: 'Open Tasks', open_theme: 'Open Theme', }; const SHORTCUT_CATEGORIES = [ { name: 'Navigation', keys: ['search', 'toggle_sidebar', 'focus_input', 'settings'] }, { name: 'Sessions', keys: ['new_session', 'fav_session', 'delete_session'] }, { name: 'Tools', keys: ['incognito', 'tts', 'cancel'] }, { name: 'Open Tools', keys: ['open_calendar', 'open_compare', 'open_cookbook', 'open_research', 'open_gallery', 'open_library', 'open_memory', 'open_notes', 'open_tasks', 'open_theme'] }, ]; function _formatKeyCaps(combo) { return combo.split('+').map(p => { let label; if (p === 'ctrl') label = 'Ctrl'; else if (p === 'alt') label = 'Alt'; else if (p === 'shift') label = 'Shift'; else if (p === 'meta') label = 'Cmd'; else if (p === 'escape') label = 'Esc'; else if (p === ',') label = ','; else if (p === '/') label = '/'; else if (p === 'space') label = 'Space'; else label = p.charAt(0).toUpperCase() + p.slice(1); return `${label}`; }).join(''); } function _comboFromEvent(e) { // Drop a stray AltGr keystroke (e.g. AltGr+E to type €) so it isn't recorded // as a bogus ctrl+alt+ binding — onKey ignores empty combos. See // platform.js for the macOS carve-out and Windows trade-off. if (isAltGrEvent(e)) return ''; const parts = []; if (e.ctrlKey || e.metaKey) parts.push('ctrl'); if (e.altKey) parts.push('alt'); if (e.shiftKey) parts.push('shift'); const key = e.key.toLowerCase(); if (!['control', 'alt', 'shift', 'meta'].includes(key)) { parts.push(key === ' ' ? 'space' : key); } return parts.join('+'); } async function initShortcuts() { const listEl = el('shortcuts-list'); const resetBtn = el('shortcuts-reset-btn'); if (!listEl) return; // Load saved keybinds let keybinds = { ...SHORTCUT_DEFAULTS }; try { const res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); const settings = await res.json(); if (settings.keybinds) keybinds = { ...keybinds, ...settings.keybinds }; } catch (e) {} function _findConflicts() { const comboMap = {}; for (const [action, combo] of Object.entries(keybinds)) { if (!comboMap[combo]) comboMap[combo] = []; comboMap[combo].push(action); } const conflicts = new Set(); for (const actions of Object.values(comboMap)) { if (actions.length > 1) actions.forEach(a => conflicts.add(a)); } return conflicts; } function render() { listEl.innerHTML = ''; const conflicts = _findConflicts(); for (const cat of SHORTCUT_CATEGORIES) { const catHeader = document.createElement('div'); catHeader.className = 'shortcut-category'; catHeader.textContent = cat.name; listEl.appendChild(catHeader); for (const action of cat.keys) { if (!(action in keybinds)) continue; const combo = keybinds[action]; // Unbound shortcuts (empty combo) still render so the user can // assign one \u2014 they show a "Set" affordance instead of keycaps. const label = SHORTCUT_LABELS[action] || action; const icon = SHORTCUT_ICONS[action] || ''; const isCustom = combo !== (SHORTCUT_DEFAULTS[action] || ''); const hasConflict = combo && conflicts.has(action); const row = document.createElement('div'); row.className = 'shortcut-row' + (hasConflict ? ' shortcut-conflict' : ''); row.dataset.action = action; const keyContent = combo ? _formatKeyCaps(combo) : 'Set'; row.innerHTML = ` ${icon}${esc(label)}${hasConflict ? '!' : ''}
`; listEl.appendChild(row); } } listEl.querySelectorAll('.shortcut-key').forEach(btn => { btn.addEventListener('click', () => startRebind(btn)); }); listEl.querySelectorAll('.shortcut-action-btn.is-reset').forEach(btn => { btn.addEventListener('click', () => { const action = btn.dataset.action; keybinds[action] = SHORTCUT_DEFAULTS[action]; saveKeybinds(); render(); }); }); } function startRebind(btn) { const action = btn.dataset.action; const row = btn.closest('.shortcut-row'); const actionBtn = row.querySelector('.shortcut-action-btn'); const hintEl = row.querySelector('.shortcut-hint'); // Remove any other active rebind listEl.querySelectorAll('.shortcut-key.listening').forEach(b => { b.classList.remove('listening'); b.innerHTML = _formatKeyCaps(keybinds[b.dataset.action]); const otherRow = b.closest('.shortcut-row'); const otherAction = otherRow.querySelector('.shortcut-action-btn'); if (otherAction && !otherAction.classList.contains('is-reset')) otherAction.style.visibility = 'hidden'; }); btn.classList.add('listening'); btn.textContent = 'Press keys...'; // Show confirm button actionBtn.textContent = '\u2713'; actionBtn.classList.remove('is-reset'); actionBtn.style.visibility = 'visible'; actionBtn.title = 'Confirm'; // Hint: tell the user how to commit / cancel the rebind. if (hintEl) { hintEl.hidden = false; hintEl.textContent = 'press a key'; } let pendingCombo = null; // Wire confirm button const confirmHandler = () => { if (pendingCombo) { keybinds[action] = pendingCombo; saveKeybinds(); } cleanup(); render(); }; actionBtn.addEventListener('click', confirmHandler, { once: true }); function onKey(e) { e.preventDefault(); e.stopPropagation(); if (e.key === 'Escape') { cleanup(); btn.innerHTML = _formatKeyCaps(keybinds[action]); const isCustom = keybinds[action] !== SHORTCUT_DEFAULTS[action]; if (isCustom) { actionBtn.textContent = '\u21A9'; actionBtn.classList.add('is-reset'); actionBtn.title = 'Reset to default'; } else { actionBtn.style.visibility = 'hidden'; } return; } // Enter commits the previewed combo (same as clicking \u2713). Only acts // as commit once a combo has been captured \u2014 otherwise it would just // try to bind Enter itself. if (e.key === 'Enter' && pendingCombo) { confirmHandler(); return; } const combo = _comboFromEvent(e); if (!combo || combo === 'ctrl' || combo === 'alt' || combo === 'shift' || combo === 'ctrl+alt' || combo === 'ctrl+shift' || combo === 'alt+shift' || combo === 'ctrl+alt+shift') return; // Preview the combo, wait for confirm pendingCombo = combo; btn.innerHTML = _formatKeyCaps(combo); // Now that a combo is captured, prompt to commit with Enter. if (hintEl) hintEl.textContent = '\u21B5 Enter to save'; } function cleanup() { btn.classList.remove('listening'); if (hintEl) { hintEl.hidden = true; hintEl.textContent = ''; } document.removeEventListener('keydown', onKey, true); actionBtn.removeEventListener('click', confirmHandler); } document.addEventListener('keydown', onKey, true); } async function saveKeybinds() { try { await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ keybinds }), }); // Update global keybinds so they take effect immediately window._odysseusKeybinds = keybinds; if (uiModule && uiModule.showToast) uiModule.showToast('Shortcut saved'); } catch (e) { console.error('Failed to save keybinds:', e); } } if (resetBtn) { resetBtn.addEventListener('click', async () => { keybinds = { ...SHORTCUT_DEFAULTS }; render(); await saveKeybinds(); if (uiModule && uiModule.showToast) uiModule.showToast('Shortcuts reset to defaults'); }); } render(); } /* ═══════════════════════════════════════════ INIT & REFRESH ═══════════════════════════════════════════ */ function initAccount() { // Populate user info fetch('/api/auth/status', { credentials: 'same-origin' }) .then(r => r.json()) .then(d => { const nameEl = el('settings-account-username'); const roleEl = el('settings-account-role'); const avatarEl = el('settings-account-avatar'); if (nameEl) nameEl.textContent = d.username || 'Unknown'; if (roleEl) roleEl.textContent = d.is_admin ? 'Admin' : 'User'; if (avatarEl) { const initial = (d.username || '?')[0].toUpperCase(); avatarEl.textContent = initial; } }).catch(() => {}); // Change password const saveBtn = el('settings-pw-save'); const msgEl = el('settings-pw-msg'); if (saveBtn) { saveBtn.addEventListener('click', async () => { const cur = el('settings-pw-current').value; const nw = el('settings-pw-new').value; const conf = el('settings-pw-confirm').value; msgEl.style.color = ''; if (!cur || !nw) { msgEl.textContent = 'Fill in all fields'; msgEl.style.color = 'var(--red)'; return; } if (nw.length < 8) { msgEl.textContent = 'Min 8 characters'; msgEl.style.color = 'var(--red)'; return; } if (nw !== conf) { msgEl.textContent = 'Passwords don\'t match'; msgEl.style.color = 'var(--red)'; return; } saveBtn.disabled = true; try { const res = await fetch('/api/auth/change-password', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ current_password: cur, new_password: nw }) }); if (!res.ok) { const d = await res.json(); throw new Error(d.detail || 'Failed'); } msgEl.style.color = 'var(--green)'; msgEl.textContent = 'Password updated'; el('settings-pw-current').value = ''; el('settings-pw-new').value = ''; el('settings-pw-confirm').value = ''; } catch (e) { msgEl.style.color = 'var(--red)'; msgEl.textContent = e.message; } finally { saveBtn.disabled = false; } }); } // ── Two-Factor Authentication ── const tfaContent = el('settings-2fa-content'); if (tfaContent) { async function render2FA() { try { const res = await fetch('/api/auth/2fa/status', { credentials: 'same-origin' }); const data = await res.json(); if (data.enabled) { // 2FA is ON — show disable option tfaContent.innerHTML = `
✓ Enabled Authenticator app required on login
`; el('tfa-disable-btn').addEventListener('click', async () => { const pw = el('tfa-disable-pw').value; const msg = el('tfa-msg'); if (!pw) { msg.textContent = 'Enter your password'; msg.style.color = 'var(--red)'; return; } try { const r = await fetch('/api/auth/2fa/disable', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: pw }) }); if (!r.ok) { const d = await r.json(); throw new Error(d.detail || 'Failed'); } render2FA(); } catch (e) { msg.textContent = e.message; msg.style.color = 'var(--red)'; } }); } else { // 2FA is OFF — show setup button tfaContent.innerHTML = `
Add an extra layer of security with an authenticator app (Aegis, Google Authenticator, etc.)
`; el('tfa-setup-btn').addEventListener('click', async () => { const msg = el('tfa-msg'); try { const r = await fetch('/api/auth/2fa/setup', { method: 'POST', credentials: 'same-origin' }); if (!r.ok) { const d = await r.json(); throw new Error(d.detail || 'Failed'); } const setup = await r.json(); // Show QR code + manual secret + verify input tfaContent.innerHTML = `
QR Code
Scan with your authenticator app, or enter manually:
${setup.secret}
`; el('tfa-verify-code').focus(); el('tfa-cancel-btn').addEventListener('click', () => render2FA()); el('tfa-verify-btn').addEventListener('click', async () => { const code = el('tfa-verify-code').value.trim(); const vmsg = el('tfa-msg'); if (!code) { vmsg.textContent = 'Enter the code'; vmsg.style.color = 'var(--red)'; return; } try { const vr = await fetch('/api/auth/2fa/confirm', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) }); if (!vr.ok) { const d = await vr.json(); throw new Error(d.detail || 'Invalid code'); } const result = await vr.json(); // Show backup codes const codes = result.backup_codes || []; tfaContent.innerHTML = `
✓ 2FA Enabled!
Save these backup codes somewhere safe. Each can be used once if you lose your authenticator:
${codes.map(c => '
' + c + '
').join('')}
`; el('tfa-done-btn').addEventListener('click', () => render2FA()); } catch (e) { vmsg.textContent = e.message; vmsg.style.color = 'var(--red)'; } }); } catch (e) { msg.textContent = e.message; msg.style.color = 'var(--red)'; } }); } } catch (_) { tfaContent.innerHTML = '
Could not load 2FA status
'; } } render2FA(); } // Logout const logoutBtn = el('settings-logout-btn'); if (logoutBtn) { logoutBtn.addEventListener('mouseenter', () => { logoutBtn.style.opacity = '1'; logoutBtn.style.borderColor = 'var(--red)'; logoutBtn.style.color = 'var(--red)'; }); logoutBtn.addEventListener('mouseleave', () => { logoutBtn.style.opacity = ''; logoutBtn.style.borderColor = ''; logoutBtn.style.color = ''; }); logoutBtn.addEventListener('click', async () => { try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (_) {} // SECURITY: wipe all client-side state on logout so the next user that // signs in on this browser doesn't inherit the previous account's // session id, last-used model, draft chat input, or any cached lists. // Keep "odysseus-last-user" so the login form remembers the username // (if "Remember me" was on). Without this the chat composer pre-loaded // the previous user's last model into a fresh session, which read as // cross-account leakage. try { const _keepKeys = new Set(['odysseus-last-user']); const _toRemove = []; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i); if (k && !_keepKeys.has(k)) _toRemove.push(k); } _toRemove.forEach(k => localStorage.removeItem(k)); sessionStorage.clear(); } catch (_) {} window.location.href = '/login'; }); } } function initAll() { modalEl = el('settings-modal'); initTabs(); initDrag(); initClose(); initOpacityToggle(); initialized = true; initDefaultChat(); initTeacherModel(); initUtilityModel(); initImageSettings(); initVisionSettings(); initTtsSettings(); initSttSettings(); initSearchSettings(); initResearchSettings(); initResearchSearchSettings(); initAgentSettings(); initAppearance(); initShortcuts(); initAccount(); initIntegrations(); initEmailSettings(); initEmailAccountsSettings(); initReminderSettings(); initUnifiedIntegrations(); } function notifyIntegrationsChanged() { try { window.dispatchEvent(new CustomEvent('odysseus-integrations-changed')); } catch (_) {} } async function initReminderSettings() { const root = el('settings-modal'); if (!root || !root.querySelector('[data-settings-panel="reminders"]')) return; // Public URL field (used for deep-links in outgoing alert emails) const pubUrlIn = el('set-app-public-url'); const pubUrlMsg = el('set-app-public-url-msg'); if (pubUrlIn) { try { const r = await fetch('/api/auth/settings', { credentials: 'same-origin' }); const s = await r.json(); pubUrlIn.value = s.app_public_url || ''; } catch (_) {} let pubDebounce; pubUrlIn.addEventListener('input', () => { clearTimeout(pubDebounce); pubDebounce = setTimeout(async () => { try { const val = pubUrlIn.value.trim().replace(/\/+$/, ''); await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app_public_url: val }), }); if (pubUrlMsg) { pubUrlMsg.textContent = val ? 'Saved' : 'Cleared (deep-links disabled)'; pubUrlMsg.style.color = 'var(--green,#50fa7b)'; setTimeout(() => { pubUrlMsg.textContent = ''; }, 2000); } } catch (_) { if (pubUrlMsg) { pubUrlMsg.textContent = 'Save failed'; pubUrlMsg.style.color = 'var(--red)'; } } }, 600); }); } const channelSel = el('set-reminder-channel'); const emailOpt = el('set-reminder-channel-email-opt'); const ntfyOpt = el('set-reminder-channel-ntfy-opt'); const hint = el('set-reminder-channel-hint'); const llmToggle = el('set-reminder-llm-toggle'); // "Integrations" link in the channel-hint copy. Jumps to the // Integrations tab so the user can configure the underlying accounts // (email, ntfy server) the channel dropdown depends on. Idempotent. const openIntgBtn = el('set-reminders-open-integrations'); if (openIntgBtn && !openIntgBtn.dataset.wired) { openIntgBtn.dataset.wired = '1'; openIntgBtn.addEventListener('click', (e) => { e.preventDefault(); const target = modalEl?.querySelector('[data-settings-tab="integrations"]'); if (target) target.click(); }); } if (!channelSel || !llmToggle) return; // Detect configured email accounts. The legacy single-account // `/api/email/config` endpoint was a no-op stub for most installs; // the real per-account list lives at `/api/email/accounts` and is // what the Integrations panel manages. Treat the email channel as // configured if there's at least one account with SMTP set. let emailAccounts = []; try { const res = await fetch('/api/email/accounts', { credentials: 'same-origin' }); if (res.ok) { const d = await res.json(); emailAccounts = (d.accounts || []).filter(a => a.smtp_host && a.smtp_user && a.has_smtp_password); } } catch (_) {} let smtpConfigured = emailAccounts.length > 0; if (!smtpConfigured && emailOpt) { emailOpt.disabled = true; emailOpt.textContent = 'Email (add an account in Integrations)'; } // Detect whether ntfy integration exists — try admin endpoint, fall back to // checking if an ntfy integration was saved in settings (non-admin users). let ntfyConfigured = false; try { const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' }); if (res.ok) { const data = await res.json(); ntfyConfigured = (data.integrations || []).some( i => (i.preset === 'ntfy' || (i.name || '').toLowerCase() === 'ntfy') && i.enabled !== false && i.base_url ); } } catch (_) {} // If admin check failed, check if ntfy was previously selected (trust the saved setting) if (!ntfyConfigured) { try { const res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); const s = await res.json(); if (s.reminder_channel === 'ntfy') ntfyConfigured = true; } catch (_) {} } if (!ntfyConfigured && ntfyOpt) { ntfyOpt.disabled = true; ntfyOpt.textContent = 'ntfy (add in Integrations first)'; } const emailFromRow = el('set-reminder-email-from-row'); const emailAcctSel = el('set-reminder-email-account'); const emailToRow = el('set-reminder-email-to-row'); const emailToIn = el('set-reminder-email-to'); const ntfyTopicRow = el('set-reminder-ntfy-topic-row'); const ntfyTopicIn = el('set-reminder-ntfy-topic'); function populateReminderEmailAccounts(selectedId = '') { if (!emailAcctSel) return; emailAcctSel.innerHTML = emailAccounts.map(a => `` ).join(''); const fallback = (emailAccounts.find(a => a.is_default) || emailAccounts[0] || {}).id || ''; emailAcctSel.value = (selectedId && emailAccounts.some(a => a.id === selectedId)) ? selectedId : fallback; } function applyReminderChannelAvailability() { if (emailOpt) { emailOpt.disabled = !smtpConfigured; emailOpt.textContent = smtpConfigured ? 'Email' : 'Email (add an account in Integrations)'; } if (ntfyOpt) { ntfyOpt.disabled = !ntfyConfigured; ntfyOpt.textContent = ntfyConfigured ? 'ntfy' : 'ntfy (add in Integrations first)'; } } async function refreshReminderChannelAvailability() { const currentChannel = channelSel.value || 'browser'; const currentEmailAccount = emailAcctSel?.value || ''; try { const res = await fetch('/api/email/accounts', { credentials: 'same-origin' }); if (res.ok) { const d = await res.json(); emailAccounts = (d.accounts || []).filter(a => a.smtp_host && a.smtp_user && a.has_smtp_password); } } catch (_) {} smtpConfigured = emailAccounts.length > 0; ntfyConfigured = false; try { const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' }); if (res.ok) { const data = await res.json(); ntfyConfigured = (data.integrations || []).some( i => (i.preset === 'ntfy' || (i.name || '').toLowerCase() === 'ntfy') && i.enabled !== false && i.base_url ); } } catch (_) {} if (!ntfyConfigured) { try { const res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); const s = await res.json(); if (s.reminder_channel === 'ntfy') ntfyConfigured = true; } catch (_) {} } applyReminderChannelAvailability(); populateReminderEmailAccounts(currentEmailAccount); if (currentChannel === 'email' && !smtpConfigured) channelSel.value = 'browser'; else if (currentChannel === 'ntfy' && !ntfyConfigured) channelSel.value = 'browser'; else channelSel.value = currentChannel; if (hint) hint.textContent = CHANNEL_HINTS[channelSel.value] || ''; syncChannelRows(); } // Populate the "Send from" picker with all configured email accounts. populateReminderEmailAccounts(); function syncChannelRows() { const isEmail = channelSel.value === 'email'; if (emailFromRow) emailFromRow.style.display = (isEmail && emailAccounts.length > 1) ? 'flex' : 'none'; if (emailToRow) emailToRow.style.display = isEmail ? 'flex' : 'none'; if (ntfyTopicRow) ntfyTopicRow.style.display = channelSel.value === 'ntfy' ? 'flex' : 'none'; } // Browser notifications fire on EVERY reminder (see // routes/note_routes.py — the in-app notif is always queued // regardless of channel). The hint should make that clear so // users don't think they have to choose between channels. const CHANNEL_HINTS = { browser: 'Reminders appear as browser notifications inside Odysseus.', email: 'Reminders are emailed AND shown as a browser notification.', ntfy: 'Reminders are pushed via ntfy AND shown as a browser notification.', }; applyReminderChannelAvailability(); if (!channelSel.dataset.integrationRefreshWired) { channelSel.dataset.integrationRefreshWired = '1'; window.addEventListener('odysseus-integrations-changed', () => { refreshReminderChannelAvailability().catch(e => console.warn('Failed to refresh reminder channels', e)); }); } try { const res = await fetch('/api/auth/settings', { credentials: 'same-origin' }); const s = await res.json(); let savedChannel = s.reminder_channel || 'browser'; if (savedChannel === 'email' && !smtpConfigured) savedChannel = 'browser'; if (savedChannel === 'ntfy' && !ntfyConfigured) savedChannel = 'browser'; channelSel.value = savedChannel; llmToggle.checked = !!s.reminder_llm_synthesis; if (emailToIn) emailToIn.value = s.reminder_email_to || ''; if (ntfyTopicIn) ntfyTopicIn.value = s.reminder_ntfy_topic || 'Reminders'; // Restore the previously-picked email account (if any), otherwise // default to the account flagged is_default in the integrations // list. Falls through to the first option if neither exists. if (emailAcctSel) { const savedId = s.reminder_email_account_id; populateReminderEmailAccounts(savedId || ''); if (emailAcctSel.value && emailAcctSel.value !== (savedId || '')) { save({ reminder_email_account_id: emailAcctSel.value || null }); } } if (hint) hint.textContent = CHANNEL_HINTS[channelSel.value] || ''; syncChannelRows(); } catch (e) { console.warn('Failed to load reminder settings', e); } async function save(patch) { try { await fetch('/api/auth/settings', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch), }); } catch (e) { console.warn('Failed to save reminder settings', e); } } channelSel.addEventListener('change', () => { if (hint) hint.textContent = CHANNEL_HINTS[channelSel.value] || ''; syncChannelRows(); save({ reminder_channel: channelSel.value }); }); if (emailToIn) { let emailDebounce; emailToIn.addEventListener('input', () => { clearTimeout(emailDebounce); emailDebounce = setTimeout(() => save({ reminder_email_to: emailToIn.value.trim() }), 600); }); } if (emailAcctSel) { emailAcctSel.addEventListener('change', () => { save({ reminder_email_account_id: emailAcctSel.value || null }); }); } if (ntfyTopicIn) { let topicDebounce; ntfyTopicIn.addEventListener('input', () => { clearTimeout(topicDebounce); topicDebounce = setTimeout(() => save({ reminder_ntfy_topic: ntfyTopicIn.value.trim() || 'reminders' }), 600); }); } // Dim the whole AI Synthesis card when off (matches Vision/Utility/etc.). function syncSynthesisDim() { const card = llmToggle.closest('.admin-card'); if (card) card.style.opacity = llmToggle.checked ? '' : '0.45'; } syncSynthesisDim(); llmToggle.addEventListener('change', () => { syncSynthesisDim(); save({ reminder_llm_synthesis: llmToggle.checked }); }); // Test button const testBtn = el('set-reminder-test-btn'); const testMsg = el('set-reminder-test-msg'); if (testBtn) { testBtn.addEventListener('click', async () => { testBtn.disabled = true; if (testMsg) { testMsg.textContent = 'Sending…'; testMsg.style.color = 'var(--fg)'; } // Whirlpool loader right next to the "Sending…" text while it sends. let _testSpin = null; try { const _sp = (await import('./spinner.js')).default; _testSpin = _sp.createWhirlpool(14); _testSpin.element.style.cssText = 'width:14px;height:14px;margin:0 0 0 7px;display:inline-block;vertical-align:middle;'; (testMsg || testBtn).insertAdjacentElement('afterend', _testSpin.element); } catch (_) {} const _stopTestSpin = () => { try { _testSpin && _testSpin.stop(); _testSpin && _testSpin.element.remove(); } catch (_) {} }; try { const res = await fetch('/api/notes/fire-reminder', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ note_id: 'test-' + Date.now(), title: 'Test Reminder', body: 'This is a test reminder to verify your settings are working.', }), }); const data = await res.json(); if (!res.ok) throw new Error(data.detail || 'Server error'); if (channelSel.value === 'email' && !data.email_sent) { throw new Error(data.email_error || 'Email reminder was not sent'); } if (channelSel.value === 'ntfy' && !data.ntfy_sent) { throw new Error(data.ntfy_error || 'ntfy reminder was not sent'); } let status = 'Delivered via ' + channelSel.value; if (data.synthesis) status += ' (AI: "' + data.synthesis.slice(0, 60) + '...")'; if (data.email_sent) status += ' — email sent'; if (data.ntfy_sent) status += ' — ntfy sent'; if (testMsg) { testMsg.textContent = status; testMsg.style.color = 'var(--green, #50fa7b)'; } // Also fire a browser notification so user can see it if ('Notification' in window && Notification.permission === 'granted') { try { new Notification('Test Reminder', { body: data.synthesis || 'This is a test reminder.', tag: 'reminder-test', icon: '/static/favicon.ico', }); } catch {} } } catch (e) { if (testMsg) { testMsg.textContent = 'Failed: ' + e.message; testMsg.style.color = 'var(--red)'; } } finally { _stopTestSpin(); testBtn.disabled = false; } }); } } async function initEmailAccountsSettings() { const root = el('settings-modal'); if (!root || !root.querySelector('[data-settings-panel="email"]')) return; const manageBtn = el('set-email-open-integrations'); if (manageBtn && manageBtn.dataset.bound !== '1') { manageBtn.dataset.bound = '1'; manageBtn.addEventListener('click', () => open('integrations')); } const tasksBtn = el('set-email-open-tasks'); if (tasksBtn && tasksBtn.dataset.bound !== '1') { tasksBtn.dataset.bound = '1'; tasksBtn.addEventListener('click', async () => { try { const mod = await import('./tasks.js'); const openTasks = mod.openTasks || (mod.default && mod.default.openTasks); if (typeof openTasks === 'function') openTasks(); else document.getElementById('tool-tasks-btn')?.click(); } catch (_) { document.getElementById('tool-tasks-btn')?.click(); } }); } const listEl = el('set-email-accounts-list'); const msgEl = el('set-email-accounts-msg'); const formEl = el('set-email-accounts-form'); const addBtn = el('set-email-accounts-add-btn'); if (!listEl || !addBtn || !formEl) return; const esc = s => String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); async function fetchAccounts() { const r = await fetch('/api/email/accounts', { credentials: 'same-origin' }); const d = await r.json(); return d.accounts || []; } function renderRow(a) { const imap = a.imap_host ? `${a.imap_host}:${a.imap_port}` : ''; const badge = a.is_default ? 'Default' : (a.enabled ? '' : 'Disabled'); return ``; } async function renderList() { const accs = await fetchAccounts(); if (!accs.length) { listEl.innerHTML = '
No email accounts configured
'; return; } listEl.innerHTML = accs.map(renderRow).join(''); listEl.querySelectorAll('.email-account-row').forEach(row => { const id = row.dataset.accId; row.querySelector('.email-acc-default-btn')?.addEventListener('click', async (e) => { e.stopPropagation(); await fetch(`/api/email/accounts/${id}/set-default`, { method: 'POST', credentials: 'same-origin' }); renderList(); }); row.querySelector('.email-acc-edit-btn')?.addEventListener('click', (e) => { e.stopPropagation(); showForm(accs.find(a => a.id === id)); }); row.querySelector('.email-acc-del-btn')?.addEventListener('click', async (e) => { e.stopPropagation(); if (!await window.styledConfirm(`Delete account "${accs.find(a => a.id === id)?.name}"?`, { confirmText: 'Delete', danger: true })) return; await fetch(`/api/email/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' }); renderList(); }); }); } function showForm(existing) { const a = existing || {}; const isEdit = !!existing; formEl.style.display = ''; // Small `?` indicator next to each label. Hover/focus to read the // hint via the native `title` tooltip. tabindex makes it // keyboard-focusable too. const _hint = (tip) => `?`; // Provider presets — picking one fills host/port/STARTTLS for both // IMAP and SMTP. Dovecot is IMAP-only here; the host is intentionally // blank because it may live on another machine (DNS, LAN, Tailscale). const PROVIDERS = { gmail: { label: 'Gmail', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } }, migadu: { label: 'Migadu', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } }, icloud: { label: 'iCloud', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } }, outlook: { label: 'Outlook / Office 365', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } }, fastmail: { label: 'Fastmail', imap: { host: 'imap.fastmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.fastmail.com', port: 465 } }, yahoo: { label: 'Yahoo', imap: { host: 'imap.mail.yahoo.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.yahoo.com', port: 465 } }, dovecot: { label: 'Dovecot IMAP (no SMTP)', imap: { host: '', port: 31143, starttls: false }, smtp: { host: '', port: 465 } }, }; const _providerOptions = Object.entries(PROVIDERS) .map(([k, v]) => ``) .join(''); formEl.innerHTML = `

${isEdit ? 'Edit Account' : 'New Account'}

IMAP (Receiving)
SMTP (Sending) — optional, leave blank for read-only
`; // Provider preset → autofill host/port/STARTTLS for both halves. el('eaf-provider').addEventListener('change', (e) => { const p = PROVIDERS[e.target.value]; if (!p) return; el('eaf-imap-host').value = p.imap.host; el('eaf-imap-port').value = p.imap.port; el('eaf-imap-starttls').checked = !!p.imap.starttls; el('eaf-smtp-host').value = p.smtp.host; el('eaf-smtp-port').value = p.smtp.port; }); // "Same as IMAP" toggle — hide the SMTP creds rows when on. The save // handler copies the IMAP user/password into SMTP at submit time. const _syncSmtpSame = () => { const same = el('eaf-smtp-same').checked; formEl.querySelectorAll('.eaf-smtp-creds').forEach(r => { r.style.display = same ? 'none' : ''; }); }; el('eaf-smtp-same').addEventListener('change', _syncSmtpSame); _syncSmtpSame(); el('eaf-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); el('eaf-save').addEventListener('click', async () => { const body = { name: el('eaf-name').value.trim(), from_address: el('eaf-from').value.trim(), imap_host: el('eaf-imap-host').value.trim(), imap_port: parseInt(el('eaf-imap-port').value) || 993, imap_user: el('eaf-imap-user').value.trim(), imap_starttls: el('eaf-imap-starttls').checked, smtp_host: el('eaf-smtp-host').value.trim(), smtp_port: parseInt(el('eaf-smtp-port').value) || 465, smtp_user: el('eaf-smtp-user').value.trim(), }; if (el('eaf-imap-pass').value) body.imap_password = el('eaf-imap-pass').value; if (el('eaf-smtp-pass').value) body.smtp_password = el('eaf-smtp-pass').value; // "Same as IMAP" toggle — copy IMAP username/password into SMTP at // save time, so the hidden SMTP-creds rows don't matter. We only // mirror the password if the user actually typed an IMAP one // (otherwise SMTP keeps whatever it already had on the server). if (el('eaf-smtp-same').checked) { body.smtp_user = body.imap_user; if (body.imap_password) body.smtp_password = body.imap_password; } // Name is optional — fall back to the From address so the list view // still has a label to render. Only refuse if both are blank. if (!body.name) body.name = body.from_address; if (!body.name) { el('eaf-msg').textContent = 'Need at least a Name or Email'; el('eaf-msg').style.color = 'var(--red)'; return; } try { const url = isEdit ? `/api/email/accounts/${a.id}` : '/api/email/accounts'; const method = isEdit ? 'PUT' : 'POST'; const r = await fetch(url, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const d = await r.json(); if (d.ok || d.id) { el('eaf-msg').textContent = 'Saved'; el('eaf-msg').style.color = 'var(--green,#50fa7b)'; setTimeout(() => { formEl.style.display = 'none'; renderList(); }, 400); } else { el('eaf-msg').textContent = d.error || 'Save failed'; el('eaf-msg').style.color = 'var(--red)'; } } catch (e) { el('eaf-msg').textContent = 'Error: ' + e.message; el('eaf-msg').style.color = 'var(--red)'; } }); } addBtn.addEventListener('click', () => showForm(null)); await renderList(); } async function initEmailSettings() { const root = el('settings-modal'); if (!root || !root.querySelector('[data-settings-panel="email"]')) return; // Load current email config try { const res = await fetch('/api/email/config'); const cfg = await res.json(); if (el('set-email-imap-host')) el('set-email-imap-host').value = cfg.imap_host || ''; if (el('set-email-imap-port')) el('set-email-imap-port').value = cfg.imap_port || ''; if (el('set-email-imap-user')) el('set-email-imap-user').value = cfg.imap_user || ''; if (el('set-email-imap-pass')) el('set-email-imap-pass').value = ''; // never prefill if (el('set-email-smtp-host')) el('set-email-smtp-host').value = cfg.smtp_host || ''; if (el('set-email-smtp-port')) el('set-email-smtp-port').value = cfg.smtp_port || ''; if (el('set-email-smtp-user')) el('set-email-smtp-user').value = cfg.smtp_user || ''; if (el('set-email-smtp-pass')) el('set-email-smtp-pass').value = ''; if (el('set-email-from')) el('set-email-from').value = cfg.from_address || ''; } catch (_) {} // Load contacts config try { const res = await fetch('/api/contacts/config'); const cfg = await res.json(); if (el('set-carddav-url')) el('set-carddav-url').value = cfg.url || ''; if (el('set-carddav-user')) el('set-carddav-user').value = cfg.username || ''; if (el('set-carddav-pass')) el('set-carddav-pass').value = ''; } catch (_) {} // Load writing style try { const res = await fetch('/api/email/style'); const data = await res.json(); if (el('set-email-style')) el('set-email-style').value = data.style || ''; } catch (_) {} // Save email config el('set-email-save')?.addEventListener('click', async () => { const msg = el('set-email-msg'); if (msg) msg.textContent = 'Saving...'; const data = { imap_host: el('set-email-imap-host').value, imap_port: parseInt(el('set-email-imap-port').value) || 0, imap_user: el('set-email-imap-user').value, smtp_host: el('set-email-smtp-host').value, smtp_port: parseInt(el('set-email-smtp-port').value) || 0, smtp_user: el('set-email-smtp-user').value, email_from: el('set-email-from').value, }; const imapPass = el('set-email-imap-pass').value; const smtpPass = el('set-email-smtp-pass').value; if (imapPass) data.imap_password = imapPass; if (smtpPass) data.smtp_password = smtpPass; try { const res = await fetch('/api/email/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); const result = await res.json(); if (msg) msg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed'); setTimeout(() => { if (msg) msg.textContent = ''; }, 3000); } catch (e) { if (msg) msg.textContent = 'Failed'; } }); // Save CardDAV config el('set-carddav-save')?.addEventListener('click', async () => { const msg = el('set-carddav-msg'); if (msg) msg.textContent = 'Saving...'; const data = { carddav_url: el('set-carddav-url').value, carddav_username: el('set-carddav-user').value, }; const pass = el('set-carddav-pass').value; if (pass) data.carddav_password = pass; try { const res = await fetch('/api/contacts/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); const result = await res.json(); if (msg) msg.textContent = result.success ? '✓ Saved' : (result.error || 'Failed'); setTimeout(() => { if (msg) msg.textContent = ''; }, 3000); } catch (e) { if (msg) msg.textContent = 'Failed'; } }); // Extract writing style el('set-email-style-extract')?.addEventListener('click', async () => { const btn = el('set-email-style-extract'); const msg = el('set-email-style-msg'); btn.disabled = true; // Render whirlpool + label inside the status area (same pattern as // the "Find" / network-discover button in Add Models). let wp = null; if (msg) { msg.className = ''; msg.innerHTML = ''; try { const sp = window.spinnerModule || (await import('./spinner.js')).default; wp = sp.createWhirlpool(16); wp.element.style.cssText = 'display:inline-block;vertical-align:middle;margin:0 8px 0 0;'; const wrap = document.createElement('span'); wrap.style.cssText = 'display:inline-flex;align-items:center;'; wrap.appendChild(wp.element); const txt = document.createElement('span'); txt.textContent = 'Analyzing your sent emails…'; txt.style.cssText = 'font-size:12px;opacity:0.7;'; wrap.appendChild(txt); msg.appendChild(wrap); } catch (_) { msg.textContent = 'Analyzing your sent emails…'; } } try { const res = await fetch('/api/email/extract-style', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sample_count: 15 }), }); const data = await res.json(); if (data.success && data.style) { if (el('set-email-style')) el('set-email-style').value = data.style; if (msg) msg.textContent = '✓ Style extracted'; } else { if (msg) msg.textContent = data.error || 'Failed'; } } catch (e) { if (msg) msg.textContent = 'Failed to extract'; } finally { if (wp && wp.destroy) { try { wp.destroy(); } catch (_) {} } btn.disabled = false; setTimeout(() => { if (msg) msg.textContent = ''; }, 5000); } }); // Save writing style manually el('set-email-style-save')?.addEventListener('click', async () => { const msg = el('set-email-style-msg'); if (msg) msg.textContent = 'Saving...'; try { const res = await fetch('/api/email/style', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ style: el('set-email-style').value }), }); const result = await res.json(); if (msg) msg.textContent = result.success ? '✓ Saved' : 'Failed'; setTimeout(() => { if (msg) msg.textContent = ''; }, 3000); } catch (e) { if (msg) msg.textContent = 'Failed'; } }); } async function initIntegrations() { const listEl = el('integrations-list'); const formCard = el('integration-form-card'); const addBtn = el('intg-add-btn'); if (!listEl || !formCard) return; const presetSel = el('intg-preset'); const nameIn = el('intg-name'); const urlIn = el('intg-url'); const authTypeSel = el('intg-auth-type'); const authHeaderRow = el('intg-auth-header-row'); const authHeaderIn = el('intg-auth-header'); const keyIn = el('intg-key'); const descIn = el('intg-description'); const saveBtn = el('intg-save-btn'); const cancelBtn = el('intg-cancel-btn'); const testBtn = el('intg-test-btn'); const statusEl = el('intg-status'); const formTitle = el('integration-form-title'); let editingId = null; let presets = {}; // Toggle auth header row visibility function syncAuthRow() { const v = authTypeSel.value; authHeaderRow.style.display = (v === 'header' || v === 'query') ? 'flex' : 'none'; if (v === 'query') authHeaderIn.placeholder = 'api_key'; else authHeaderIn.placeholder = 'X-Auth-Token'; } authTypeSel.addEventListener('change', syncAuthRow); // Load presets try { const res = await fetch('/api/auth/integrations/presets', { credentials: 'same-origin' }); if (res.ok) { const data = await res.json(); presets = data.presets || {}; for (const [key, preset] of Object.entries(presets)) { const opt = document.createElement('option'); opt.value = key; opt.textContent = preset.name || key; presetSel.appendChild(opt); } } } catch (e) {} // Preset auto-fill presetSel.addEventListener('change', () => { const p = presets[presetSel.value]; if (!p) return; nameIn.value = p.name || ''; authTypeSel.value = p.auth_type || 'none'; authHeaderIn.value = p.auth_header || ''; descIn.value = p.description || ''; syncAuthRow(); }); // Render list async function renderList() { try { const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' }); if (!res.ok) { listEl.innerHTML = '
Admin access required
'; return; } const data = await res.json(); const items = data.integrations || []; if (!items.length) { listEl.innerHTML = '
No integrations configured
'; return; } listEl.innerHTML = items.map(i => `
${_esc(i.name || i.id)}
${_esc(i.base_url || '')}
`).join(''); listEl.querySelectorAll('.intg-edit-btn').forEach(b => b.addEventListener('click', () => startEdit(b.dataset.id))); listEl.querySelectorAll('.intg-del-btn').forEach(b => b.addEventListener('click', () => doDelete(b.dataset.id))); } catch (e) { listEl.innerHTML = '
Failed to load
'; } } function _esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; } // Start editing async function startEdit(id) { editingId = id; formTitle.textContent = 'Edit Integration'; // Fetch full data (with unmasked key from a dedicated edit fetch — we'll just load what we have) try { const res = await fetch('/api/auth/integrations', { credentials: 'same-origin' }); const data = await res.json(); const item = (data.integrations || []).find(i => i.id === id); if (!item) return; presetSel.value = item.preset || ''; nameIn.value = item.name || ''; urlIn.value = item.base_url || ''; authTypeSel.value = item.auth_type || 'none'; authHeaderIn.value = item.auth_header || ''; keyIn.value = ''; // masked — user re-enters if changing keyIn.placeholder = item.api_key ? 'Leave blank to keep current' : 'API key or token'; descIn.value = item.description || ''; syncAuthRow(); formCard.style.display = ''; } catch (e) {} } // Show add form addBtn.addEventListener('click', () => { editingId = null; formTitle.textContent = 'Add Integration'; presetSel.value = ''; nameIn.value = ''; urlIn.value = ''; authTypeSel.value = 'header'; authHeaderIn.value = ''; keyIn.value = ''; keyIn.placeholder = 'API key or token'; descIn.value = ''; statusEl.textContent = ''; syncAuthRow(); formCard.style.display = ''; }); cancelBtn.addEventListener('click', () => { formCard.style.display = 'none'; statusEl.textContent = ''; }); // Save saveBtn.addEventListener('click', async () => { const payload = { name: nameIn.value.trim(), base_url: urlIn.value.trim().replace(/\/+$/, ''), auth_type: authTypeSel.value, auth_header: authHeaderIn.value.trim(), description: descIn.value.trim(), }; if (presetSel.value) payload.preset = presetSel.value; if (keyIn.value.trim()) payload.api_key = keyIn.value.trim(); if (!payload.name) { statusEl.textContent = 'Name required'; statusEl.style.color = 'var(--red)'; return; } if (!payload.base_url) { statusEl.textContent = 'URL required'; statusEl.style.color = 'var(--red)'; return; } try { const url = editingId ? `/api/auth/integrations/${editingId}` : '/api/auth/integrations'; const method = editingId ? 'PUT' : 'POST'; const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), credentials: 'same-origin' }); if (res.ok) { statusEl.textContent = 'Saved'; statusEl.style.color = 'var(--green, #98c379)'; formCard.style.display = 'none'; await renderList(); notifyIntegrationsChanged(); } else { const err = await res.json().catch(() => ({})); statusEl.textContent = err.detail || 'Save failed'; statusEl.style.color = 'var(--red)'; } } catch (e) { statusEl.textContent = 'Error saving'; statusEl.style.color = 'var(--red)'; } }); // Test testBtn.addEventListener('click', async () => { if (!editingId) { statusEl.textContent = 'Save first, then test'; statusEl.style.color = 'var(--fg)'; return; } statusEl.textContent = 'Testing...'; statusEl.style.color = 'var(--fg)'; try { const res = await fetch(`/api/auth/integrations/${editingId}/test`, { method: 'POST', credentials: 'same-origin' }); const data = await res.json(); statusEl.textContent = data.message || (data.ok ? 'OK' : 'Failed'); statusEl.style.color = data.ok ? 'var(--green, #98c379)' : 'var(--red)'; } catch (e) { statusEl.textContent = 'Connection failed'; statusEl.style.color = 'var(--red)'; } }); // Delete async function doDelete(id) { if (!await window.styledConfirm('Delete this integration?', { confirmText: 'Delete', danger: true })) return; try { await fetch(`/api/auth/integrations/${id}`, { method: 'DELETE', credentials: 'same-origin' }); if (editingId === id) { formCard.style.display = 'none'; editingId = null; } await renderList(); notifyIntegrationsChanged(); } catch (e) {} } syncAuthRow(); renderList(); } /* ══ Unified Integrations ══ */ const INTG_TYPES = { api: { label: 'API', icon: '' }, caldav: { label: 'CalDAV', icon: '' }, contacts: { label: 'Contacts', icon: '' }, carddav: { label: 'CardDAV', icon: '' }, email: { label: 'Email', icon: '' }, mcp: { label: 'MCP', icon: '' }, vault: { label: 'Vault', icon: '' }, }; let _unifiedInited = false; async function initUnifiedIntegrations() { if (_unifiedInited) return; _unifiedInited = true; const listEl = el('unified-integrations-list'); const formEl = el('unified-intg-form'); const addBtn = el('unified-intg-add-btn'); if (!listEl) return; let integrationNotice = ''; function _openEmailSettings() { open('email'); } async function fetchAll() { const [apiRes, calRes, cardRes, contactsRes, emailAccountsRes, mcpRes, vaultRes] = await Promise.all([ fetch('/api/auth/integrations', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { integrations: [] }).catch(() => ({ integrations: [] })), fetch('/api/calendar/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})), fetch('/api/contacts/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})), fetch('/api/contacts/list', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { contacts: [], count: 0 }).catch(() => ({ contacts: [], count: 0 })), fetch('/api/email/accounts', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { accounts: [] }).catch(() => ({ accounts: [] })), fetch('/api/mcp/servers', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : []).catch(() => []), fetch('/api/vault/config', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : {}).catch(() => ({})), ]); const items = []; // API integrations for (const intg of (apiRes.integrations || [])) { items.push({ type: 'api', id: intg.id, name: intg.name || 'Unnamed', detail: intg.base_url || '', enabled: intg.enabled !== false, data: intg }); } // CalDAV if (calRes.url) { items.push({ type: 'caldav', id: '__caldav__', name: 'Calendar (CalDAV)', detail: calRes.url, enabled: true, data: calRes }); } // Contacts import first, then the optional CardDAV sync account. const contactCount = Number(contactsRes.count || (contactsRes.contacts || []).length || 0); if (contactCount > 0) { items.push({ type: 'contacts', id: '__contacts__', name: 'Contacts Import', detail: `${contactCount} contact${contactCount === 1 ? '' : 's'}`, enabled: true, data: contactsRes, }); } if (cardRes.url) { items.push({ type: 'carddav', id: '__carddav__', name: 'Contacts (CardDAV)', detail: cardRes.url, enabled: true, data: cardRes, }); } // Email — one entry per EmailAccount row for (const acc of (emailAccountsRes.accounts || [])) { const label = acc.name + (acc.is_default ? ' (default)' : ''); const detail = [acc.from_address || acc.imap_user, acc.imap_host].filter(Boolean).join(' — '); items.push({ type: 'email', id: acc.id, name: label, detail, enabled: acc.enabled !== false, data: acc }); } // MCP servers const mcpList = Array.isArray(mcpRes) ? mcpRes : (mcpRes.servers || []); for (const srv of mcpList) { const statusText = srv.needs_oauth ? 'needs auth' : srv.status === 'connected' ? `${srv.enabled_tool_count}/${srv.tool_count} tools` : srv.status === 'error' ? 'error' : 'disconnected'; items.push({ type: 'mcp', id: srv.id || srv.name, name: srv.name || 'MCP Server', detail: statusText, enabled: srv.is_enabled !== false, data: srv }); } // Vaultwarden removed as an integration option. return items; } function renderCard(item) { const t = INTG_TYPES[item.type] || INTG_TYPES.api; // Static enabled/disabled indicator — same dot every integration // type gets. (The clickable glow-on-test variant for email was // removed earlier; this matches the API/CalDAV/MCP pattern.) const statusDot = item.enabled ? '' : ''; return `
${t.icon}
${item.name} ${t.label}
${item.detail || ''}
${statusDot}
`; } async function renderList() { const items = await fetchAll(); const noticeHtml = integrationNotice ? `
${integrationNotice}
` : ''; if (items.length === 0) { listEl.innerHTML = noticeHtml + '
No integrations configured
'; } else { listEl.innerHTML = noticeHtml + items.map(renderCard).join(''); } listEl.querySelector('.intg-open-email-settings')?.addEventListener('click', (e) => { e.stopPropagation(); _openEmailSettings(); }); // Wire edit clicks listEl.querySelectorAll('.intg-card').forEach(card => { card.addEventListener('click', (e) => { if (e.target.closest('.intg-del-btn')) return; const type = card.dataset.intgType; const id = card.dataset.intgId; const items2 = listEl.querySelectorAll('.intg-card'); items2.forEach(c => c.style.borderColor = ''); card.style.borderColor = 'var(--accent)'; showForm(type, id); }); }); // Wire delete listEl.querySelectorAll('.intg-del-btn').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); if (!await window.styledConfirm('Remove this integration?', { confirmText: 'Remove', danger: true })) return; const type = btn.dataset.intgType; const id = btn.dataset.intgId; try { if (type === 'api') await fetch(`/api/auth/integrations/${id}`, { method: 'DELETE', credentials: 'same-origin' }); else if (type === 'caldav') await fetch('/api/calendar/config', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: '', username: '', password: '' }) }); else if (type === 'contacts') { await fetch('/api/contacts/clear', { method: 'DELETE', credentials: 'same-origin' }); } else if (type === 'carddav') { await fetch('/api/contacts/config', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ carddav_url: '', carddav_username: '', carddav_password: '' }) }); } else if (type === 'email') await fetch(`/api/email/accounts/${id}`, { method: 'DELETE', credentials: 'same-origin' }); else if (type === 'mcp') await fetch(`/api/mcp/servers/${id}`, { method: 'DELETE', credentials: 'same-origin' }); else if (type === 'vault') await fetch('/api/vault/logout', { method: 'POST', credentials: 'same-origin' }); } catch (_) {} formEl.style.display = 'none'; await renderList(); notifyIntegrationsChanged(); }); }); } function showForm(type, editId) { formEl.style.display = ''; if (type === 'api') showApiForm(editId); else if (type === 'caldav') showCalDavForm(); else if (type === 'contacts' || type === 'carddav') showCardDavForm(); else if (type === 'email') showEmailForm(editId); else if (type === 'mcp') showMcpForm(editId); else if (type === 'vault') showVaultForm(); } // ── API form ── async function showApiForm(editId) { let presets = {}; try { const r = await fetch('/api/auth/integrations/presets', { credentials: 'same-origin' }); if (r.ok) { const d = await r.json(); presets = d.presets || {}; } } catch (_) {} const presetEntries = Object.entries(presets); // Same `?` hint helper as the email form. Native title tooltip, // tabbable for keyboard users. Inline-styled so it doesn't need // a CSS dependency. const _apiHint = (tip) => `?`; // Real ${selectOpts}
`; const preset = el('uf-api-preset'), name = el('uf-api-name'), url = el('uf-api-url'), auth = el('uf-api-auth'), header = el('uf-api-header'), key = el('uf-api-key'), ntfyHint = el('uf-api-ntfy-hint'); let _editId = editId && editId !== 'new' ? editId : null; // Load existing if (_editId) { try { const r = await fetch('/api/auth/integrations', { credentials: 'same-origin' }); const d = await r.json(); const item = (d.integrations || []).find(i => i.id === _editId); if (item) { name.value = item.name || ''; url.value = item.base_url || ''; auth.value = item.auth_type || 'none'; header.value = item.auth_header || ''; } } catch (_) {} } // Native
`; try { const r = await fetch('/api/calendar/config', { credentials: 'same-origin' }); const d = await r.json(); el('uf-caldav-url').value = d.url || ''; el('uf-caldav-user').value = d.username || ''; } catch (_) {} el('uf-caldav-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); // Run a PROPFIND with the form's current url+user+pass. Used by // both the Test button (visible result only) and by Save (refuse // to persist a broken config). Returns the parsed {ok, error?}. const _runCalDavTest = async () => { const body = { url: el('uf-caldav-url').value.trim(), username: el('uf-caldav-user').value.trim(), password: el('uf-caldav-pass').value, }; try { const r = await fetch('/api/calendar/test', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); return await r.json(); } catch (e) { return { ok: false, error: 'Network error: ' + e.message }; } }; const _setCalDavMsg = (text, ok) => { const msg = el('uf-caldav-msg'); msg.textContent = text; msg.style.color = ok ? 'var(--green, #50fa7b)' : 'var(--red)'; }; el('uf-caldav-save').addEventListener('click', async () => { // Pre-validate by hitting the server with the same PROPFIND the // Test button uses. If the CalDAV server rejects the creds/URL // we won't persist garbage — the user gets the actual error // (HTTP 401, "Not found", "Connection refused", etc.) in red. _setCalDavMsg('Testing…', true); el('uf-caldav-msg').style.color = ''; const d = await _runCalDavTest(); if (!d.ok) { _setCalDavMsg(d.error || 'Connection failed — not saved', false); return; } try { await fetch('/api/calendar/config', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: el('uf-caldav-url').value, username: el('uf-caldav-user').value, password: el('uf-caldav-pass').value, }), }); _setCalDavMsg('Saved', true); formEl.style.display = 'none'; await renderList(); notifyIntegrationsChanged(); } catch (_) { _setCalDavMsg('Save failed', false); } }); el('uf-caldav-test').addEventListener('click', async () => { _setCalDavMsg('Testing…', true); el('uf-caldav-msg').style.color = ''; const d = await _runCalDavTest(); _setCalDavMsg(d.ok ? 'Connected' : (d.error || 'Failed'), d.ok); }); } // ── CardDAV form + contacts manager ── async function showCardDavForm() { formEl.innerHTML = `

Contacts (CardDAV)

Contacts Import

Loading…
`; try { const r = await fetch('/api/contacts/config', { credentials: 'same-origin' }); const d = await r.json(); el('uf-carddav-url').value = d.url || ''; el('uf-carddav-user').value = d.username || ''; } catch (_) {} el('uf-carddav-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); el('uf-carddav-save').addEventListener('click', async () => { const body = { carddav_url: el('uf-carddav-url').value, carddav_username: el('uf-carddav-user').value }; if (el('uf-carddav-pass').value) body.carddav_password = el('uf-carddav-pass').value; try { await fetch('/api/contacts/config', { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); el('uf-carddav-msg').textContent = 'Saved'; el('uf-carddav-msg').style.color = 'var(--green, #50fa7b)'; // Refresh both the sub-panel (contacts manager) AND the // outer integrations list so the CardDAV row appears // immediately instead of waiting for a page reload. await _renderContactsManager(); await renderList(); notifyIntegrationsChanged(); } catch (_) { el('uf-carddav-msg').textContent = 'Failed'; el('uf-carddav-msg').style.color = 'var(--red)'; } }); // Add-row toggle + save el('cm-add-toggle')?.addEventListener('click', () => { const row = el('cm-add-row'); const open = row.style.display !== 'none'; row.style.display = open ? 'none' : 'flex'; if (!open) el('cm-add-name')?.focus(); }); el('cm-add-save')?.addEventListener('click', async () => { const name = el('cm-add-name').value.trim(); const email = el('cm-add-email').value.trim(); if (!email) { el('cm-add-email').focus(); return; } try { await fetch('/api/contacts/add', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email }) }); } catch (_) {} el('cm-add-name').value = ''; el('cm-add-email').value = ''; el('cm-add-row').style.display = 'none'; await _renderContactsManager(); }); const _downloadContacts = async (format) => { const btn = el(format === 'csv' ? 'cm-export-csv-btn' : 'cm-export-vcf-btn'); const orig = btn ? btn.textContent : ''; if (btn) { btn.textContent = 'Exporting...'; btn.disabled = true; } try { const res = await fetch(`/api/contacts/export?format=${encodeURIComponent(format)}`, { credentials: 'same-origin' }); if (!res.ok) throw new Error('Export failed'); const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = format === 'csv' ? 'odysseus-contacts.csv' : 'odysseus-contacts.vcf'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); } catch (_) { uiModule.showError ? uiModule.showError('Export failed') : alert('Export failed'); } finally { if (btn) { btn.textContent = orig; btn.disabled = false; } } }; el('cm-export-vcf-btn')?.addEventListener('click', () => _downloadContacts('vcf')); el('cm-export-csv-btn')?.addEventListener('click', () => _downloadContacts('csv')); // Import .vcf/.csv — read each selected file as text, concatenate by type, // then POST. Imported CardDAV contacts immediately feed email autocomplete // because compose searches /api/contacts/search. el('cm-import-btn')?.addEventListener('click', () => el('cm-import-file')?.click()); el('cm-import-file')?.addEventListener('change', async (e) => { const files = Array.from(e.target.files || []); if (!files.length) return; const btn = el('cm-import-btn'); const orig = btn ? btn.textContent : ''; if (btn) { btn.textContent = 'Importing…'; btn.disabled = true; } try { const texts = await Promise.all(files.map(f => f.text())); const vcfParts = []; const csvParts = []; texts.forEach((text, idx) => { const name = (files[idx]?.name || '').toLowerCase(); if (name.endsWith('.csv') || !String(text || '').toUpperCase().includes('BEGIN:VCARD')) csvParts.push(text); else vcfParts.push(text); }); let imported = 0, total = 0, failed = 0; const _postImport = async (body) => { const r = await fetch('/api/contacts/import', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const d = await r.json(); if (d.error) throw new Error(d.error); imported += Number(d.imported || 0); total += Number(d.total || 0); failed += Number(d.failed || 0); }; if (vcfParts.length) await _postImport({ vcf: vcfParts.join('\n') }); if (csvParts.length) await _postImport({ csv: csvParts.join('\n') }); if (!vcfParts.length && !csvParts.length) throw new Error('No contact data found'); const msg = `Imported ${imported}/${total}` + (failed ? ` (${failed} failed)` : ''); uiModule.showToast ? uiModule.showToast(msg) : null; } catch (err) { uiModule.showError ? uiModule.showError(err?.message || 'Import failed') : alert(err?.message || 'Import failed'); } finally { if (btn) { btn.textContent = orig; btn.disabled = false; } e.target.value = ''; await _renderContactsManager(); } }); await _renderContactsManager(); } // Render the contacts list inside the manager card with inline edit + // delete. Each row: name + emails; pencil flips to editable inputs. async function _renderContactsManager() { const list = el('cm-list'); if (!list) return; let contacts = []; try { const r = await fetch('/api/contacts/list', { credentials: 'same-origin' }); const d = await r.json(); contacts = d.contacts || []; } catch (_) { list.innerHTML = '
Failed to load contacts (check CardDAV config above).
'; return; } const cnt = el('cm-count'); if (cnt) cnt.textContent = contacts.length ? `(${contacts.length})` : ''; if (!contacts.length) { list.innerHTML = '
No contacts yet.
'; return; } // Sort by name for a stable list. contacts.sort((a, b) => (a.name || '').localeCompare(b.name || '')); list.innerHTML = contacts.map(c => { const emails = (c.emails || []).join(', '); const phones = (c.phones || []).join(', '); const sub = [emails, phones].filter(Boolean).join(' · '); return `
${esc(c.name || '(no name)')}
${esc(sub)}
`; }).join(''); // Wire each row's edit / delete / save / cancel. list.querySelectorAll('.contact-row').forEach(row => { const uid = row.dataset.uid; const view = row.querySelector('.contact-row-view'); const editForm = row.querySelector('.contact-row-edit'); row.querySelector('.contact-edit')?.addEventListener('click', () => { view.style.display = 'none'; editForm.style.display = 'flex'; }); row.querySelector('.contact-cancel')?.addEventListener('click', () => { editForm.style.display = 'none'; view.style.display = 'flex'; }); row.querySelector('.contact-save')?.addEventListener('click', async () => { const body = { name: row.querySelector('.contact-edit-name').value.trim(), emails: row.querySelector('.contact-edit-emails').value.split(',').map(s => s.trim()).filter(Boolean), phones: row.querySelector('.contact-edit-phones').value.split(',').map(s => s.trim()).filter(Boolean), }; try { await fetch('/api/contacts/' + encodeURIComponent(uid), { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } catch (_) {} await _renderContactsManager(); }); row.querySelector('.contact-del')?.addEventListener('click', async () => { const ok = uiModule.styledConfirm ? await uiModule.styledConfirm('Delete this contact?', { confirmText: 'Delete', danger: true }) : window.confirm('Delete this contact?'); if (!ok) return; try { await fetch('/api/contacts/' + encodeURIComponent(uid), { method: 'DELETE', credentials: 'same-origin' }); } catch (_) {} await _renderContactsManager(); }); }); } // ── Email form (multi-account) ── // When editId is a real account id, edit that row. When editId is falsy or 'new', // create a fresh account. Posts to /api/email/accounts, never to the legacy // /api/email/config which would overwrite the default. async function showEmailForm(editId) { const isEdit = editId && editId !== 'new' && editId !== '__email__'; let existing = null; if (isEdit) { try { const r = await fetch('/api/email/accounts', { credentials: 'same-origin' }); const d = await r.json(); existing = (d.accounts || []).find(a => a.id === editId) || null; } catch (_) {} } const placeholderPass = (isEdit && existing) ? '(leave blank to keep current)' : ''; // Small `?` indicator next to each label (native title tooltip). const _hint = (tip) => `?`; // Provider presets — picking one auto-fills IMAP + SMTP host/port. // Dovecot is IMAP-only here; the host is intentionally blank because // it may be remote (DNS, LAN, Tailscale), not localhost. const PROVIDERS = { gmail: { label: 'Gmail', emailEx: 'you@gmail.com', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } }, migadu: { label: 'Migadu', emailEx: 'you@yourdomain.com', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } }, icloud: { label: 'iCloud', emailEx: 'you@icloud.com', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } }, outlook: { label: 'Outlook / Office 365', emailEx: 'you@outlook.com', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } }, fastmail: { label: 'Fastmail', emailEx: 'you@fastmail.com', imap: { host: 'imap.fastmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.fastmail.com', port: 465 } }, yahoo: { label: 'Yahoo', emailEx: 'you@yahoo.com', imap: { host: 'imap.mail.yahoo.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.yahoo.com', port: 465 } }, dovecot: { label: 'Dovecot IMAP (no SMTP)', emailEx: 'you@example.com', imap: { host: '', port: 31143, starttls: false }, smtp: { host: '', port: 465 } }, }; const _providerOptions = Object.entries(PROVIDERS) .map(([k, v]) => ``).join(''); formEl.innerHTML = `

${isEdit ? 'Edit' : 'Add'} Email Account

IMAP (Receiving)
SMTP (Sending) — optional, leave blank for read-only
Used when nothing else is selected
`; // Provider-specific helper notes — surfaces for providers that // require an app-specific password (Gmail killed basic IMAP auth // in 2022; iCloud + Yahoo follow the same model). The Generate // button opens the right page in a new tab and copies the URL for // mobile / cross-device flows. const PROVIDER_NOTES = { gmail: { title: 'Gmail needs an App Password', body: 'Your regular Google password won\'t work for IMAP. Generate a 16-character App Password (requires 2-Step Verification enabled) and paste it as the Password.', url: 'https://myaccount.google.com/apppasswords', }, icloud: { title: 'iCloud needs an App-Specific Password', body: 'Sign in to your Apple ID, go to Sign-In and Security → App-Specific Passwords, and generate one (requires 2FA on your Apple ID).', url: 'https://account.apple.com/account/manage', }, yahoo: { title: 'Yahoo needs an App Password', body: 'Generate an App Password from Yahoo Account Security (requires 2-Step Verification enabled) and paste it as the Password.', url: 'https://login.yahoo.com/account/security/app-passwords', }, }; const noteEl = el('uf-email-provider-note'); const _copyProviderUrl = async (text) => { const value = String(text || ''); if (!value) return false; if (navigator.clipboard && window.isSecureContext) { try { await navigator.clipboard.writeText(value); return true; } catch (_) { // Fall through to the textarea path below. } } const ta = document.createElement('textarea'); ta.value = value; ta.setAttribute('readonly', 'readonly'); ta.style.cssText = 'position:fixed;left:0;top:0;width:1px;height:1px;opacity:0;z-index:-1;'; document.body.appendChild(ta); ta.focus(); ta.select(); ta.setSelectionRange(0, value.length); let ok = false; try { ok = document.execCommand('copy'); } catch (_) { ok = false; } ta.remove(); return ok; }; if (noteEl && !noteEl._ufProviderCopyWired) { noteEl._ufProviderCopyWired = true; noteEl.addEventListener('click', async (e) => { const copyBtn = e.target.closest?.('.uf-prov-copy'); if (!copyBtn || !noteEl.contains(copyBtn)) return; e.preventDefault(); e.stopPropagation(); const url = copyBtn.dataset.url || ''; const orig = copyBtn.innerHTML; const ok = await _copyProviderUrl(url); if (!ok) { uiModule.showError?.('Copy failed'); return; } uiModule.showToast?.('Copied'); copyBtn.innerHTML = ' Copied'; setTimeout(() => { if (copyBtn.isConnected) copyBtn.innerHTML = orig; }, 1500); }); } const _renderProviderNote = (key) => { const n = PROVIDER_NOTES[key]; if (!n) { noteEl.style.display = 'none'; noteEl.innerHTML = ''; return; } noteEl.style.display = ''; noteEl.innerHTML = `
${esc(n.title)}
${esc(n.body)}
Generate App Password
`; }; // Provider preset → autofill IMAP + SMTP host/port + STARTTLS, set the // helper note, and update the Email/Username placeholders to a // provider-specific example so users see the right format at a glance. el('uf-email-provider').addEventListener('change', (e) => { const key = e.target.value; _renderProviderNote(key); const p = PROVIDERS[key]; if (!p) return; el('uf-imap-host').value = p.imap.host; el('uf-imap-port').value = p.imap.port; el('uf-imap-starttls').checked = !!p.imap.starttls; el('uf-smtp-host').value = p.smtp.host; el('uf-smtp-port').value = p.smtp.port; if (p.emailEx) { el('uf-email-from').placeholder = p.emailEx; el('uf-imap-user').placeholder = p.emailEx; el('uf-smtp-user').placeholder = p.emailEx; } }); // "Same as IMAP" toggle — hide the SMTP creds rows when on. const _syncSmtpSame = () => { const same = el('uf-smtp-same').checked; formEl.querySelectorAll('.uf-smtp-creds').forEach(r => { r.style.display = same ? 'none' : ''; }); }; el('uf-smtp-same').addEventListener('change', _syncSmtpSame); _syncSmtpSame(); if (existing) { el('uf-email-name').value = existing.name || ''; el('uf-email-from').value = existing.from_address || ''; el('uf-imap-host').value = existing.imap_host || ''; el('uf-imap-port').value = existing.imap_port || 993; el('uf-imap-user').value = existing.imap_user || ''; el('uf-imap-starttls').checked = existing.imap_starttls !== false; el('uf-smtp-host').value = existing.smtp_host || ''; el('uf-smtp-port').value = existing.smtp_port || 465; el('uf-smtp-user').value = existing.smtp_user || ''; el('uf-email-default').checked = !!existing.is_default; // If the saved SMTP user matches the IMAP user, keep the "Same as // IMAP" toggle ON (and stay hidden). Otherwise turn it off so the // separate SMTP credentials are visible for editing. const sameCreds = !!(existing.imap_user && existing.smtp_user && existing.imap_user === existing.smtp_user); el('uf-smtp-same').checked = sameCreds || !existing.smtp_user; _syncSmtpSame(); } else { el('uf-imap-port').value = 993; el('uf-smtp-port').value = 465; } el('uf-email-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); // Reset the Test button to neutral when the user edits any field // after a test — stale green/red would imply the new values were // tested too. const _resetTestBtn = () => { const btn = el('uf-email-test'); if (!btn) return; btn.style.background = ''; btn.style.borderColor = ''; btn.style.color = ''; btn.style.boxShadow = ''; btn.style.animation = ''; const ico = btn.querySelector('.uf-email-test-ico'); if (ico && btn.dataset.origIco) ico.innerHTML = btn.dataset.origIco; }; formEl.querySelectorAll('input, select').forEach(inp => { if (inp.id === 'uf-email-msg') return; inp.addEventListener('input', _resetTestBtn); inp.addEventListener('change', _resetTestBtn); }); // Collect the current form values + apply the "Same as IMAP" mirror — // shared by both Save and Test so they agree on what's being sent. const _collectBody = () => { const body = { name: el('uf-email-name').value.trim(), from_address: el('uf-email-from').value.trim(), imap_host: el('uf-imap-host').value.trim(), imap_port: parseInt(el('uf-imap-port').value) || 993, imap_user: el('uf-imap-user').value.trim(), imap_starttls: el('uf-imap-starttls').checked, smtp_host: el('uf-smtp-host').value.trim(), smtp_port: parseInt(el('uf-smtp-port').value) || 465, smtp_user: el('uf-smtp-user').value.trim(), is_default: el('uf-email-default').checked, }; if (el('uf-imap-pass').value) body.imap_password = el('uf-imap-pass').value; if (el('uf-smtp-pass').value) body.smtp_password = el('uf-smtp-pass').value; if (el('uf-smtp-same').checked) { body.smtp_user = body.imap_user; if (body.imap_password) body.smtp_password = body.imap_password; } return body; }; // Spinner SVG kept inline so we can swap it back to the original // checkmark on completion. ~13px to match the button icon size. const _spinner = ''; const _checkIcon = ''; el('uf-email-test').addEventListener('click', async () => { const body = _collectBody(); // Edit-mode + blank password = use the saved row's stored creds // via the account_id shortcut. Other overrides in the body still // win (server merges). if (isEdit && !body.imap_password) body.account_id = editId; const msg = el('uf-email-msg'); const btn = el('uf-email-test'); const ico = btn.querySelector('.uf-email-test-ico'); btn.dataset.origIco = btn.dataset.origIco || ico.innerHTML; btn.disabled = true; // Clear any prior green/red while testing. btn.style.background = ''; btn.style.borderColor = ''; btn.style.color = ''; btn.style.boxShadow = ''; btn.style.animation = ''; ico.innerHTML = _spinner; msg.textContent = ''; msg.style.color = ''; try { const r = await fetch('/api/email/accounts/test', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const d = await r.json(); if (d.ok) { // Button becomes the indicator — green checkmark with the // cookbook-style halo + breathing animation. No status text; // the glow is the signal. btn.style.background = 'var(--green, #50fa7b)'; btn.style.borderColor = 'var(--green, #50fa7b)'; btn.style.color = '#0b0'; btn.style.boxShadow = '0 0 0 2px color-mix(in srgb, var(--green, #50fa7b) 25%, transparent),' + ' 0 0 10px 2px color-mix(in srgb, var(--green, #50fa7b) 55%, transparent)'; btn.style.animation = 'cookbook-srv-glow-ok 2.4s ease-in-out infinite'; ico.innerHTML = _checkIcon; } else { // Failure — red glow, original icon, error detail in status // text so we can say WHICH half failed (IMAP vs SMTP). btn.style.background = 'var(--red)'; btn.style.borderColor = 'var(--red)'; btn.style.color = '#fff'; btn.style.boxShadow = '0 0 0 2px color-mix(in srgb, var(--red) 25%, transparent),' + ' 0 0 10px 2px color-mix(in srgb, var(--red) 55%, transparent)'; ico.innerHTML = btn.dataset.origIco; const imap = d.imap?.ok ? 'IMAP ok' : `IMAP: ${d.imap?.error || 'fail'}`; const smtp = d.smtp ? (d.smtp.ok ? ' · SMTP ok' : ` · SMTP: ${d.smtp.error || 'fail'}`) : ''; msg.textContent = imap + smtp; msg.style.color = 'var(--red)'; } } catch (e) { btn.style.background = 'var(--red)'; btn.style.borderColor = 'var(--red)'; btn.style.color = '#fff'; ico.innerHTML = btn.dataset.origIco; msg.textContent = 'Test error: ' + e.message; msg.style.color = 'var(--red)'; } finally { btn.disabled = false; } }); el('uf-email-save').addEventListener('click', async () => { const body = _collectBody(); // Name is optional — fall back to Email so the list still has a label. if (!body.name) body.name = body.from_address; if (!body.name) { el('uf-email-msg').textContent = 'Need at least a Name or Email'; el('uf-email-msg').style.color = 'var(--red)'; return; } const saveBtn = el('uf-email-save'); saveBtn.disabled = true; const saveIcoEl = saveBtn.querySelector('.uf-email-save-ico'); const saveLblEl = saveBtn.querySelector('.uf-email-save-label'); const prevIco = saveIcoEl.innerHTML; const prevLbl = saveLblEl.textContent; saveIcoEl.innerHTML = _spinner; saveLblEl.textContent = 'Saving…'; try { const url = isEdit ? `/api/email/accounts/${editId}` : '/api/email/accounts'; const method = isEdit ? 'PUT' : 'POST'; const r = await fetch(url, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); const d = await r.json(); if (!(d.ok || d.id)) { el('uf-email-msg').textContent = d.error || 'Failed'; el('uf-email-msg').style.color = 'var(--red)'; return; } el('uf-email-msg').textContent = 'Saved'; el('uf-email-msg').style.color = 'var(--green,#50fa7b)'; integrationNotice = 'Email account saved. For more settings, go to Settings > Email.'; formEl.style.display = 'none'; await renderList(); notifyIntegrationsChanged(); } catch (e) { el('uf-email-msg').textContent = 'Error: ' + e.message; el('uf-email-msg').style.color = 'var(--red)'; } finally { saveBtn.disabled = false; saveIcoEl.innerHTML = prevIco; saveLblEl.textContent = prevLbl; } }); } // ── Vaultwarden form ── async function showVaultForm() { formEl.innerHTML = `

Vaultwarden (Password Vault)

Loading...
Login registers this device with your Vaultwarden account (once per account).
Unlock decrypts the vault — required after restart or Lock. Session is saved so the assistant can read passwords.
`; const msg = (text, color) => { const m = el('uf-vault-msg'); m.textContent = text || ''; m.style.color = color || ''; }; async function refreshStatus() { try { const r = await fetch('/api/vault/config', { credentials: 'same-origin' }); const d = await r.json(); el('uf-vault-url').value = d.server_url || ''; el('uf-vault-email').value = d.email || ''; const installed = d.bw_installed; const parts = []; parts.push(installed ? 'bw CLI: installed' : 'bw CLI: NOT installed (install nodejs-bitwarden-cli)'); parts.push(d.unlocked ? 'Status: UNLOCKED' : 'Status: locked'); if (d.unlocked_at) parts.push(`Last unlock: ${d.unlocked_at.replace('T',' ').slice(0,19)}`); const statusEl = el('uf-vault-status'); statusEl.textContent = parts.join(' — '); statusEl.style.color = !installed ? 'var(--red)' : d.unlocked ? 'var(--green,#50fa7b)' : ''; } catch (_) { el('uf-vault-status').textContent = 'Failed to load vault status'; } } await refreshStatus(); el('uf-vault-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); el('uf-vault-save').addEventListener('click', async () => { msg('Saving...'); try { const r = await fetch('/api/vault/config', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ server_url: el('uf-vault-url').value, email: el('uf-vault-email').value }), }); const d = await r.json(); if (d.ok) { msg('Saved', 'var(--green,#50fa7b)'); await refreshStatus(); await renderList(); } else msg(d.error || 'Failed', 'var(--red)'); } catch (e) { msg('Error: ' + e.message, 'var(--red)'); } }); el('uf-vault-login').addEventListener('click', async () => { const email = el('uf-vault-email').value.trim(); const pass = el('uf-vault-pass').value; if (!email || !pass) { msg('Email + master password required', 'var(--red)'); return; } msg('Logging in...'); try { const r = await fetch('/api/vault/login', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, master_password: pass }), }); const d = await r.json(); if (d.ok) { msg(d.already ? 'Already logged in — use Unlock' : 'Logged in', 'var(--green,#50fa7b)'); el('uf-vault-pass').value = ''; await refreshStatus(); await renderList(); } else msg(d.error || 'Login failed', 'var(--red)'); } catch (e) { msg('Error: ' + e.message, 'var(--red)'); } }); el('uf-vault-unlock').addEventListener('click', async () => { const pass = el('uf-vault-pass').value; if (!pass) { msg('Master password required', 'var(--red)'); return; } msg('Unlocking...'); try { const r = await fetch('/api/vault/unlock', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ master_password: pass }), }); const d = await r.json(); if (d.ok) { msg('Vault unlocked', 'var(--green,#50fa7b)'); el('uf-vault-pass').value = ''; await refreshStatus(); await renderList(); } else msg(d.error || 'Unlock failed', 'var(--red)'); } catch (e) { msg('Error: ' + e.message, 'var(--red)'); } }); el('uf-vault-lock').addEventListener('click', async () => { msg('Locking...'); try { await fetch('/api/vault/lock', { method: 'POST', credentials: 'same-origin' }); msg('Locked', 'var(--green,#50fa7b)'); await refreshStatus(); await renderList(); } catch (e) { msg('Error: ' + e.message, 'var(--red)'); } }); el('uf-vault-logout').addEventListener('click', async () => { if (!await window.styledConfirm('Log out of Bitwarden CLI? You\'ll need to re-enter your master password to log back in.', { confirmText: 'Log out' })) return; msg('Logging out...'); try { await fetch('/api/vault/logout', { method: 'POST', credentials: 'same-origin' }); msg('Logged out', 'var(--green,#50fa7b)'); await refreshStatus(); await renderList(); } catch (e) { msg('Error: ' + e.message, 'var(--red)'); } }); } // ── MCP form — full management view ── async function showMcpForm(editId) { if (editId && editId !== 'new') { // Show management view for existing server formEl.innerHTML = '
Loading...
'; try { const res = await fetch('/api/mcp/servers', { credentials: 'same-origin' }); const servers = await res.json(); const srv = servers.find(s => (s.id || s.name) === editId); if (!srv) { formEl.innerHTML = '
Server not found
'; return; } const esc = s => String(s||'').replace(/&/g,'&').replace(/

${esc(srv.name)}

${statusText}
${srv.needs_oauth ? `Authorize` : ''}
`; // Reconnect el('uf-mcp-reconnect').addEventListener('click', async () => { const msg = el('uf-mcp-msg'); msg.textContent = 'Reconnecting...'; try { const r = await fetch(`/api/mcp/servers/${srv.id}/reconnect`, { method: 'POST', credentials: 'same-origin' }); const d = await r.json(); msg.textContent = d.connected ? `Connected (${d.tool_count} tools)` : `Failed: ${d.error || 'unknown'}`; await renderList(); showMcpForm(editId); // refresh this view } catch (e) { msg.textContent = 'Failed'; } }); // Toggle enable/disable el('uf-mcp-toggle').addEventListener('click', async () => { const fd = new FormData(); fd.append('is_enabled', String(!srv.is_enabled)); await fetch(`/api/mcp/servers/${srv.id}`, { method: 'PATCH', body: fd, credentials: 'same-origin' }); await renderList(); showMcpForm(editId); }); el('uf-mcp-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); // Load tools list if (srv.status === 'connected' && srv.tool_count > 0) { const panel = el('uf-mcp-tools-panel'); try { const tr = await fetch(`/api/mcp/servers/${srv.id}/tools`, { credentials: 'same-origin' }); const tools = await tr.json(); if (tools.length) { const disabled = new Set(tools.filter(t => t.is_disabled).map(t => t.name)); panel.innerHTML = `
Tools${tools.length - disabled.size}/${tools.length} enabledAll None
${tools.map(t => ``).join('')}
`; const saveFn = async () => { const dis = []; panel.querySelectorAll('input[type=checkbox]').forEach(cb => { if (!cb.checked) dis.push(cb.dataset.mcpToolName); }); await fetch(`/api/mcp/servers/${srv.id}/tools`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ disabled: dis }) }); const cnt = panel.querySelector('.mcp-tools-count'); if (cnt) cnt.textContent = `${tools.length - dis.length}/${tools.length} enabled`; }; panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.addEventListener('change', saveFn)); el('uf-mcp-all')?.addEventListener('click', (e) => { e.preventDefault(); panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = true); saveFn(); }); el('uf-mcp-none')?.addEventListener('click', (e) => { e.preventDefault(); panel.querySelectorAll('input[type=checkbox]').forEach(cb => cb.checked = false); saveFn(); }); } } catch (_) { panel.innerHTML = 'Failed to load tools'; } } } catch (_) { formEl.innerHTML = '
Failed to load server
'; } } else { // Add new MCP server form formEl.innerHTML = `

Add MCP Server

`; el('uf-mcp-transport').addEventListener('change', () => { const sse = el('uf-mcp-transport').value === 'sse'; el('uf-mcp-stdio-fields').style.display = sse ? 'none' : 'flex'; el('uf-mcp-sse-fields').style.display = sse ? 'flex' : 'none'; }); el('uf-mcp-cancel').addEventListener('click', () => { formEl.style.display = 'none'; }); el('uf-mcp-save').addEventListener('click', async () => { const transport = el('uf-mcp-transport').value; // routes/mcp_routes.py uses FastAPI Form(...) — send multipart, not JSON. const fd = new FormData(); fd.append('name', el('uf-mcp-name').value); fd.append('transport', transport); if (transport === 'stdio') { fd.append('command', el('uf-mcp-cmd').value); let args = '[]'; try { args = JSON.stringify(JSON.parse(el('uf-mcp-args').value || '[]')); } catch (_) {} let env = '{}'; try { env = JSON.stringify(JSON.parse(el('uf-mcp-env').value || '{}')); } catch (_) {} fd.append('args', args); fd.append('env', env); } else { fd.append('url', el('uf-mcp-url').value); } try { const r = await fetch('/api/mcp/servers', { method: 'POST', credentials: 'same-origin', body: fd }); if (r.ok) { el('uf-mcp-msg').textContent = 'Saved'; formEl.style.display = 'none'; await renderList(); } else { el('uf-mcp-msg').textContent = `Failed (${r.status})`; } } catch (_) { el('uf-mcp-msg').textContent = 'Failed'; } }); } } // ── Add button with type picker ── if (addBtn) { addBtn.addEventListener('click', () => { formEl.style.display = ''; formEl.innerHTML = `

Add Integration

`; el('uf-type-picker').addEventListener('change', () => { const v = el('uf-type-picker').value; if (v) showForm(v, 'new'); }); }); } await renderList(); } /* ── Admin visibility sync ── */ function syncAdminVisibility() { if (!modalEl) return; const isAdmin = !!window._isAdmin; modalEl.querySelectorAll('.admin-only').forEach(el => { el.style.display = isAdmin ? '' : 'none'; }); } /* ═══════════════════════════════════════════ PUBLIC API ═══════════════════════════════════════════ */ export function open(tab) { if (!initialized) initAll(); syncAppearanceCheckboxes(); resetWindowPlacement(); modalEl.classList.remove('hidden'); syncAdminVisibility(); const content = modalEl.querySelector('.settings-modal-content'); if (tab) { modalEl.querySelectorAll('[data-settings-tab]').forEach(b => b.classList.toggle('active', b.dataset.settingsTab === tab)); modalEl.querySelectorAll('[data-settings-panel]').forEach(p => p.classList.toggle('hidden', p.dataset.settingsPanel !== tab)); } // Auto-init admin data if showing an admin tab const activeTab = tab || (modalEl.querySelector('[data-settings-tab].active') || {}).dataset?.settingsTab || 'services'; document.body.classList.toggle('settings-appearance-open', activeTab === 'appearance'); syncAppearanceOpacity(activeTab === 'appearance'); if (activeTab === 'ai') refreshAiModelEndpoints(); if (ADMIN_TABS.has(activeTab) && window.adminModule && !window.adminModule._initialized) { window.adminModule._initData(); } } export function close() { if (!modalEl) return; // Always clear the appearance-tab body class so the rest of the app // doesn't keep its dimmed state if the modal got closed mid-tab. document.body.classList.remove('settings-appearance-open'); syncAppearanceOpacity(false); // clear any opacity-slider fade const content = modalEl.querySelector('.modal-content, .settings-modal-content'); if (content && !content.classList.contains('modal-closing')) { content.classList.add('modal-closing'); content.addEventListener('animationend', () => { modalEl.classList.add('hidden'); content.classList.remove('modal-closing'); }, { once: true }); setTimeout(() => { if (!modalEl.classList.contains('hidden')) { modalEl.classList.add('hidden'); content.classList.remove('modal-closing'); } }, 250); } else { modalEl.classList.add('hidden'); } } const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints }; export default settingsModule;