pediatric-ai-scribe-v3/public/js/admin.js
Daniel 302736af70
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 7s
fix: the Search Sources card loads its saved settings again
PubMed read "Disabled" after every refresh while the database said true,
and the Test button reported the truth — so the card was showing its
defaults, not its settings.

The browser said why, once asked:

    [Admin] Settings load failed:
    ReferenceError: loadWebSearch is not defined

This file is a series of bare { } blocks, and a function declared in one
is not in scope in another. loadWebSearch lives in the block at 322 and
was called from the settings loader in the block at 42. Worse than not
running: the call sat inside that loader's .then(), so it threw and took
the rest of the handler with it.

It now loads itself on tabChanged, which is what every other block in
this file already does.

There is a comment at the top of loadAdmin saying loadOidcConfig cannot
be called from there for exactly this reason. Someone met this hazard
before; the web search call was added later without seeing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 02:12:17 +02:00

2136 lines
98 KiB
JavaScript

import { initImageSettings } from './admin/imageSettings.js';
import { initClinicalAssistantAdmin } from './admin/clinicalAssistant.js';
// ============================================================
// ADMIN.JS — Admin panel: users, settings, stats
// ============================================================
function adminEscapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function adminTableMessage(colspan, color, text) {
return '<tr><td colspan="' + colspan + '" style="text-align:center;color:' + color + ';padding:20px;">' + adminEscapeHtml(text) + '</td></tr>';
}
function adminSetButtonText(btn, text, disabled) {
if (!btn) return;
btn.textContent = text;
btn.disabled = !!disabled;
}
function adminSetButtonHtml(btn, html, disabled) {
if (!btn) return;
btn.innerHTML = html;
btn.disabled = !!disabled;
}
function adminFlashButtonBackground(btn, color) {
if (!btn) return;
btn.style.background = color || '';
setTimeout(function() { if (btn) btn.style.background = ''; }, 2000);
}
// True when the admin tab is already active AND its component markup is loaded,
// i.e. tabChanged for admin has already fired (or will never fire again).
function adminTabActive() {
var tab = document.getElementById('admin-tab');
return !!tab && tab.classList.contains('active') && tab.dataset.loaded === '1';
}
{
let loaded = false;
// Load admin panel when admin tab is activated
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') {
if (!loaded) { loadAdmin(); loaded = true; }
}
});
document.addEventListener('click', function(e) {
// Refresh button
if (e.target.closest('#btn-refresh-users')) {
loadUsers();
}
// Toggle registration
if (e.target.closest('#btn-toggle-reg')) {
toggleRegistration();
}
});
function loadAdmin() {
loadSettings();
loadUsers();
// loadOidcConfig() is registered in the CMS IIFE below — it's not in scope here
}
// ---- SETTINGS + STATS ----
function loadSettings() {
fetch('/api/admin/settings', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var stats = data.stats || {};
var settings = data.settings || {};
var el = document.getElementById('stat-users');
if (el) el.textContent = stats.totalUsers !== undefined ? stats.totalUsers : '—';
el = document.getElementById('stat-api-total');
if (el) el.textContent = stats.totalApiCalls !== undefined ? stats.totalApiCalls : '—';
el = document.getElementById('stat-api-today');
if (el) el.textContent = stats.todayApiCalls !== undefined ? stats.todayApiCalls : '—';
updateRegStatus(settings.registrationEnabled !== false);
})
.catch(function(err) { console.error('[Admin] Settings load failed:', err); });
}
function updateRegStatus(enabled) {
var text = document.getElementById('reg-status-text');
var btn = document.getElementById('btn-toggle-reg');
if (text) {
text.innerHTML = 'Registration is currently <strong style="color:' + (enabled ? 'var(--green)' : 'var(--red)') + '">' + (enabled ? '✅ Enabled' : '❌ Disabled') + '</strong>';
}
if (btn) btn.textContent = enabled ? 'Disable Registration' : 'Enable Registration';
window._adminRegEnabled = enabled;
}
function toggleRegistration() {
var newVal = !window._adminRegEnabled;
fetch('/api/admin/settings/registration', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ enabled: newVal })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
updateRegStatus(newVal);
showToast('Registration ' + (newVal ? 'enabled' : 'disabled'), 'success');
} else {
showToast(data.error || 'Failed', 'error');
}
})
.catch(function() { showToast('Request failed', 'error'); });
}
// ---- USERS ----
let allUsers = [];
function filterUsers() {
var tbody = document.getElementById('admin-users-body');
var input = document.getElementById('admin-users-search');
if (!tbody) return;
var query = String((input && input.value) || '').trim().toLowerCase();
if (!allUsers.length) return;
tbody.innerHTML = allUsers.filter(function(u) {
return !query || (String(u.name || '').toLowerCase().indexOf(query) !== -1) || (String(u.email || '').toLowerCase().indexOf(query) !== -1);
}).map(renderUserRow).join('');
bindUserActions(tbody);
}
function loadUsers() {
var tbody = document.getElementById('admin-users-body');
if (!tbody) return;
tbody.innerHTML = adminTableMessage(5, 'var(--g400)', 'Loading...');
fetch('/api/admin/users', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { allUsers = []; tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Failed to load users'); return; }
allUsers = data.users || [];
filterUsers();
})
.catch(function() { allUsers = []; tbody.innerHTML = adminTableMessage(5, 'var(--red)', 'Request failed'); });
}
document.addEventListener('input', function(e) {
if (e.target && e.target.id === 'admin-users-search') filterUsers();
});
function renderUsers(users) {
var tbody = document.getElementById('admin-users-body');
if (!tbody) return;
var currentUser = JSON.parse(localStorage.getItem('ped_scribe_user') || '{}');
if (users.length === 0) {
tbody.innerHTML = adminTableMessage(5, 'var(--g400)', 'No users found');
return;
}
tbody.innerHTML = users.map(function(u) { return renderUserRow(u, currentUser); }).join('');
bindUserActions(tbody);
}
function bindUserActions(tbody) {
tbody.querySelectorAll('.admin-action').forEach(function(btn) {
btn.addEventListener('click', function() {
handleUserAction(btn.dataset.action, btn.dataset.id, btn.dataset.email, btn.dataset.name);
});
});
}
function renderUserRow(u, currentUser) {
currentUser = currentUser || JSON.parse(localStorage.getItem('ped_scribe_user') || '{}');
var isSelf = currentUser.id && u.id === currentUser.id;
var roleColor = u.role === 'admin' ? 'var(--blue)' : u.role === 'moderator' ? 'var(--purple)' : 'var(--g500)';
var statusColor = u.disabled ? 'var(--red)' : (u.email_verified ? 'var(--green)' : 'var(--amber)');
var statusText = u.disabled ? 'Disabled' : (u.email_verified ? 'Active' : 'Unverified');
var joined = u.created_at ? new Date(u.created_at).toLocaleDateString() : '\u2014';
var actions = [];
if (!isSelf) {
if (!u.email_verified) {
actions.push('<button class="btn-sm btn-primary admin-action" data-action="verify" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Verify</button>');
}
if (u.disabled) {
actions.push('<button class="btn-sm btn-success admin-action" data-action="enable" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Enable</button>');
} else {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="disable" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Disable</button>');
}
if (u.role === 'admin') {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="demote" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Demote</button>');
} else {
actions.push('<button class="btn-sm btn-primary admin-action" data-action="promote" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Make Admin</button>');
}
if (u.role !== 'moderator') {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="set-moderator" data-id="' + u.id + '" data-email="' + esc(u.email) + '" style="color:var(--purple);">Set Moderator</button>');
}
if (u.role === 'moderator') {
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="set-user" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Set User</button>');
}
actions.push('<button class="btn-sm btn-ghost admin-action" data-action="reset-pw" data-id="' + u.id + '" data-email="' + esc(u.email) + '">Reset PW</button>');
actions.push('<button class="btn-sm admin-action" style="background:var(--red-light);color:var(--red);" data-action="delete" data-id="' + u.id + '" data-email="' + esc(u.email) + '" data-name="' + esc(u.name) + '">Delete</button>');
} else {
actions.push('<span style="font-size:12px;color:var(--g400);">(you)</span>');
}
return '<tr>' +
'<td><div style="font-weight:600;font-size:13px;">' + esc(u.name) + '</div><div style="font-size:11px;color:var(--g500);">' + esc(u.email) + '</div></td>' +
'<td><span style="font-size:12px;font-weight:600;color:' + roleColor + ';">' + (u.role || 'user') + '</span></td>' +
'<td><span style="font-size:12px;color:' + statusColor + ';">' + statusText + '</span></td>' +
'<td style="font-size:12px;color:var(--g500);">' + joined + '</td>' +
'<td style="white-space:nowrap;">' + actions.join(' ') + '</td>' +
'</tr>';
}
function handleUserAction(action, userId, email, name) {
switch (action) {
case 'verify':
showConfirm('Verify email for ' + email + '?', function() {
adminPost('/api/admin/users/' + userId + '/verify', {}, function() {
showToast(email + ' verified', 'success'); loadUsers();
});
});
break;
case 'disable':
showConfirm('Disable account for ' + email + '?', function() {
adminPost('/api/admin/users/' + userId + '/disable', {}, function() {
showToast(email + ' disabled', 'info'); loadUsers();
});
}, { danger: true, confirmText: 'Disable' });
break;
case 'enable':
adminPost('/api/admin/users/' + userId + '/enable', {}, function() {
showToast(email + ' enabled', 'success'); loadUsers();
});
break;
case 'promote':
showConfirm('Grant admin role to ' + email + '?', function() {
adminPost('/api/admin/users/' + userId + '/role', { role: 'admin' }, function() {
showToast(email + ' is now admin', 'success'); loadUsers();
});
});
break;
case 'demote':
showConfirm('Remove admin role from ' + email + '?', function() {
adminPost('/api/admin/users/' + userId + '/role', { role: 'user' }, function() {
showToast(email + ' demoted to user', 'info'); loadUsers();
});
});
break;
case 'set-moderator':
showConfirm('Set ' + email + ' as moderator? They will be able to manage Learning Hub content.', function() {
adminPost('/api/admin/users/' + userId + '/role', { role: 'moderator' }, function() {
showToast(email + ' is now a moderator', 'success'); loadUsers();
});
});
break;
case 'set-user':
showConfirm('Set ' + email + ' to regular user role?', function() {
adminPost('/api/admin/users/' + userId + '/role', { role: 'user' }, function() {
showToast(email + ' is now a user', 'success'); loadUsers();
});
});
break;
case 'reset-pw':
showConfirm('New password for ' + email + ' (8+ characters):', function(val) {
if (val.length < 8) { showToast('Password must be 8+ characters', 'error'); return; }
adminPost('/api/admin/users/' + userId + '/reset-password', { newPassword: val }, function() {
showToast('Password reset for ' + email, 'success');
});
}, { input: true, inputType: 'password', placeholder: 'New password', required: true, requiredMsg: 'Enter a password', confirmText: 'Reset Password' });
break;
case 'delete':
showConfirm('Permanently delete user ' + (name || email) + '? This cannot be undone.', function() {
fetch('/api/admin/users/' + userId, { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { showToast(email + ' deleted', 'info'); loadUsers(); }
else showToast(data.error || 'Delete failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
}, { danger: true, confirmText: 'Delete' });
break;
}
}
function adminPost(url, body, onSuccess) {
fetch(url, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(body)
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { onSuccess(data); }
else showToast(data.error || 'Request failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
}
const esc = adminEscapeHtml;
}
// ============================================================
// ADMIN CMS — Announcements, Feature Flags, Email, AI Prompts
// ============================================================
{
let cmsLoaded = false;
let promptsLoaded = false;
let promptsLoading = false;
function startCms() {
if (!cmsLoaded) { loadCms(); loadOidcConfig(); cmsLoaded = true; }
}
// Load CMS when admin tab is opened (via tabChanged event or click)
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') startCms();
});
// Catch-up: when the admin tab is already active and loaded at module init
// (e.g. restored tab before this module evaluated), tabChanged may never fire again.
if (adminTabActive()) startCms();
document.addEventListener('click', function(e) {
// Save buttons
if (e.target.closest('#btn-save-announcement')) saveAnnouncement();
if (e.target.closest('#btn-save-flags')) saveFlags();
if (e.target.closest('#btn-save-websearch')) saveWebSearch();
if (e.target.closest('#btn-test-websearch')) testWebSearch();
if (e.target.closest('#btn-save-email')) saveEmail();
if (e.target.closest('#btn-test-email')) sendTestEmail();
if (e.target.closest('#btn-save-smtp')) saveSmtp();
if (e.target.closest('#btn-clear-smtp')) clearSmtp();
if (e.target.closest('#btn-save-auto-delete')) saveAutoDelete();
if (e.target.closest('#btn-reset-all-defaults')) resetAllDefaults();
});
// When email template selector changes, repopulate fields
document.addEventListener('change', function(e) {
if (e.target.id === 'cms-email-template') loadEmailFields(e.target.value);
});
// ---- LOAD ALL CONFIG ----
function loadCms() {
fetch('/api/admin/config', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var cfg = {};
(data.config || []).forEach(function(row) { cfg[row.key] = row.value; });
// Announcement
var annEnabled = document.getElementById('cms-ann-enabled');
var annType = document.getElementById('cms-ann-type');
var annText = document.getElementById('cms-ann-text');
if (annEnabled) annEnabled.value = cfg['announcement.enabled'] || 'false';
if (annType) annType.value = cfg['announcement.type'] || 'info';
if (annText) annText.value = cfg['announcement.text'] || '';
// Feature flags
var flagReadAloud = document.getElementById('cms-flag-read-aloud');
var flagNextcloud = document.getElementById('cms-flag-nextcloud');
if (flagReadAloud) flagReadAloud.value = cfg['feature.read_aloud'] !== undefined ? cfg['feature.read_aloud'] : 'true';
if (flagNextcloud) flagNextcloud.value = cfg['feature.nextcloud'] !== undefined ? cfg['feature.nextcloud'] : 'true';
// Auto-delete setting
var autoDeleteDays = cfg['site.auto_delete_days'] || '7';
var el = document.getElementById('cms-auto-delete-days');
if (el) el.value = autoDeleteDays;
el = document.getElementById('admin-auto-delete-days');
if (el) el.textContent = autoDeleteDays;
// Email — load verify template by default
window._adminEmailConfig = cfg;
loadEmailFields('verify');
})
.catch(function(err) { console.error('[AdminCMS] Config load failed:', err); });
loadPromptList();
loadSmtp();
}
// ---- ANNOUNCEMENT ----
function saveAnnouncement() {
var enabled = document.getElementById('cms-ann-enabled').value;
var type = document.getElementById('cms-ann-type').value;
var text = document.getElementById('cms-ann-text').value;
Promise.all([
putConfig('announcement.enabled', enabled),
putConfig('announcement.type', type),
putConfig('announcement.text', text)
]).then(function() {
showToast('Announcement saved', 'success');
if (typeof loadAnnouncement === 'function') loadAnnouncement();
}).catch(function() { showToast('Save failed', 'error'); });
}
// ---- WEB SEARCH ----
// Its own endpoints rather than the generic setter: the key is masked on read
// and a blank field means "keep what is there", so changing the provider does
// not silently wipe a working key.
//
// Loads itself on tabChanged, like every other block in this file. It used to
// be called from the settings loader in the block above, which cannot see it:
// this file is a series of bare { } blocks, and a function declared in one is
// not in scope in another. That call threw ReferenceError on every admin
// open, and because it sat inside the settings .then(), it took the rest of
// that handler down with it — so the card showed its defaults and PubMed read
// "Disabled" while the database said true.
document.addEventListener('tabChanged', function (e) {
if (e.detail && e.detail.tab === 'admin') loadWebSearch();
});
if (adminTabActive()) loadWebSearch();
function loadWebSearch() {
fetch('/api/admin/websearch', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data || !data.success) return;
var cfg = data.config || {};
var set = function(id, value) { var el = document.getElementById(id); if (el) el.value = value || ''; };
set('ws-enabled', cfg['websearch.enabled'] === 'true' ? 'true' : 'false');
set('ws-provider', cfg['websearch.provider'] || 'tavily');
set('ws-base-url', cfg['websearch.base_url']);
set('pm-enabled', cfg['pubmed.enabled'] === 'true' ? 'true' : 'false');
set('pm-email', cfg['pubmed.contact_email']);
var key = document.getElementById('ws-api-key');
if (key) key.placeholder = cfg['websearch.api_key'] || 'Leave blank to keep the current key';
var pmKey = document.getElementById('pm-api-key');
if (pmKey) pmKey.placeholder = cfg['pubmed.api_key'] || 'Optional — leave blank to keep the current key';
})
.catch(function() {});
}
function saveWebSearch() {
var status = document.getElementById('ws-status');
if (status) status.textContent = 'Saving...';
fetch('/api/admin/websearch', {
method: 'PUT', headers: getAuthHeaders(),
body: JSON.stringify({
enabled: (document.getElementById('ws-enabled') || {}).value,
provider: (document.getElementById('ws-provider') || {}).value,
apiKey: (document.getElementById('ws-api-key') || {}).value,
baseUrl: (document.getElementById('ws-base-url') || {}).value,
pubmedEnabled: (document.getElementById('pm-enabled') || {}).value,
pubmedApiKey: (document.getElementById('pm-api-key') || {}).value,
pubmedEmail: (document.getElementById('pm-email') || {}).value
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Save failed');
if (status) status.textContent = 'Saved ' + new Date().toLocaleTimeString() + '.';
['ws-api-key', 'pm-api-key'].forEach(function (id) {
var field = document.getElementById(id);
if (field) field.value = '';
});
showToast('Web search settings saved', 'success');
loadWebSearch();
})
.catch(function(err) {
if (status) status.textContent = 'Not saved.';
showToast(err.message, 'error');
});
}
function testWebSearch() {
var status = document.getElementById('ws-status');
if (status) status.textContent = 'Searching...';
fetch('/api/admin/websearch/test', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!status) return;
// Both sources, separately, so one press says which of them works.
var web = data.web || {};
var pubmed = data.pubmed || {};
status.textContent =
'Web: ' + (web.reason ? web.reason : web.count + ' from ' + web.provider) +
' · PubMed: ' + (pubmed.reason ? pubmed.reason : pubmed.count + ' records');
status.style.color = data.success ? 'var(--green)' : 'var(--red)';
})
.catch(function(err) { if (status) { status.textContent = err.message; status.style.color = 'var(--red)'; } });
}
// ---- FEATURE FLAGS ----
function saveFlags() {
var readAloud = document.getElementById('cms-flag-read-aloud').value;
var nextcloud = document.getElementById('cms-flag-nextcloud').value;
var status = document.getElementById('cms-flags-status');
if (status) status.textContent = 'Saving...';
Promise.all([
putConfig('feature.read_aloud', readAloud),
putConfig('feature.nextcloud', nextcloud)
]).then(function() {
if (status) status.textContent = 'Saved.';
showToast('Feature flags saved', 'success');
}).catch(function() {
if (status) status.textContent = 'Not saved.';
showToast('Save failed', 'error');
});
}
// ---- EMAIL TEMPLATES ----
function loadEmailFields(template) {
var cfg = window._adminEmailConfig || {};
var subject = document.getElementById('cms-email-subject');
var body = document.getElementById('cms-email-body');
if (subject) subject.value = cfg['email.' + template + '.subject'] || '';
if (body) body.value = cfg['email.' + template + '.body'] || '';
}
function saveEmail() {
var template = document.getElementById('cms-email-template').value;
var subject = document.getElementById('cms-email-subject').value;
var body = document.getElementById('cms-email-body').value;
Promise.all([
putConfig('email.' + template + '.subject', subject),
putConfig('email.' + template + '.body', body)
]).then(function() {
// Update local cache
if (!window._adminEmailConfig) window._adminEmailConfig = {};
window._adminEmailConfig['email.' + template + '.subject'] = subject;
window._adminEmailConfig['email.' + template + '.body'] = body;
showToast('Email template saved', 'success');
}).catch(function() { showToast('Save failed', 'error'); });
}
function sendTestEmail() {
var template = document.getElementById('cms-email-template').value;
var sendBtn = document.getElementById('btn-test-email');
// Show inline email input instead of prompt()
var existing = document.getElementById('admin-test-email-row');
if (existing) { existing.remove(); return; }
var row = document.createElement('div');
row.id = 'admin-test-email-row';
row.style.cssText = 'margin-top:8px;display:flex;gap:6px;align-items:center;';
row.innerHTML = '<input type="email" id="admin-test-email-input" placeholder="recipient@example.com" style="padding:5px 8px;border:1px solid var(--g300);border-radius:6px;font-size:12px;width:220px;">'
+ '<button class="btn-sm btn-primary" id="admin-test-email-submit" style="font-size:11px;">Send</button>'
+ '<button class="btn-sm btn-ghost" id="admin-test-email-cancel" style="font-size:11px;">Cancel</button>';
if (sendBtn) sendBtn.parentElement.appendChild(row);
document.getElementById('admin-test-email-input').focus();
document.getElementById('admin-test-email-submit').onclick = function() {
var to = document.getElementById('admin-test-email-input').value.trim();
if (!to) { showToast('Enter an email address', 'error'); return; }
fetch('/api/admin/config/test-email', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ to: to, template: template })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { showToast('Test email sent to ' + to, 'success'); row.remove(); }
else showToast(data.error || 'Send failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
};
document.getElementById('admin-test-email-cancel').onclick = function() { row.remove(); };
}
// ---- AI PROMPTS ----
// ---- OIDC / SSO CONFIG ----
function loadOidcConfig() {
// Set callback URL display
var callbackEl = document.getElementById('oidc-callback-url');
if (callbackEl) callbackEl.textContent = window.location.origin + '/api/auth/oidc/callback';
fetch('/api/auth/oidc/config', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var c = data.config || {};
var el;
el = document.getElementById('oidc-enabled'); if (el) el.value = c['oidc.enabled'] || 'false';
el = document.getElementById('oidc-issuer'); if (el) el.value = c['oidc.issuer'] || '';
el = document.getElementById('oidc-client-id'); if (el) el.value = c['oidc.client_id'] || '';
el = document.getElementById('oidc-client-secret'); if (el) el.placeholder = c['oidc.client_secret'] ? c['oidc.client_secret'] : 'Enter client secret';
el = document.getElementById('oidc-button-label'); if (el) el.value = c['oidc.button_label'] || '';
el = document.getElementById('oidc-disable-local'); if (el) el.value = c['oidc.disable_local_auth'] || 'false';
})
.catch(function() {});
}
document.addEventListener('click', function(e) {
if (e.target.id === 'btn-save-oidc' || e.target.closest('#btn-save-oidc')) {
e.preventDefault();
var status = document.getElementById('oidc-save-status');
if (status) { status.textContent = 'Saving...'; status.style.color = 'var(--g500)'; }
var body = {
'oidc.enabled': document.getElementById('oidc-enabled').value,
'oidc.issuer': document.getElementById('oidc-issuer').value.trim(),
'oidc.client_id': document.getElementById('oidc-client-id').value.trim(),
'oidc.button_label': document.getElementById('oidc-button-label').value.trim(),
'oidc.disable_local_auth': document.getElementById('oidc-disable-local').value
};
// Only send secret if user typed a new one
var secretEl = document.getElementById('oidc-client-secret');
if (secretEl && secretEl.value) body['oidc.client_secret'] = secretEl.value;
fetch('/api/auth/oidc/config', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify(body)
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast('OIDC settings saved', 'success');
if (status) status.textContent = '';
if (secretEl) secretEl.value = '';
loadOidcConfig();
} else {
showToast(data.error || 'Save failed', 'error');
if (status) status.textContent = '';
}
})
.catch(function() { showToast('Save failed', 'error'); if (status) status.textContent = ''; });
}
});
async function promptRequest(url, method, body) {
var response = await fetch(url, {
headers: getAuthHeaders(), method: method || 'GET',
body: body === undefined ? undefined : JSON.stringify(body)
});
var data = await response.json();
if (response.status === 409) throw new Error('Conflict: this prompt changed elsewhere. Your draft is unchanged. Open History, view the current revision, then explicitly use it as your save baseline.');
if (!response.ok || !data.success) throw new Error(data.error || 'Prompt request failed');
return data;
}
async function loadPromptList() {
// General settings reloads must not replace any prompt drafts.
if (promptsLoaded || promptsLoading) return;
promptsLoading = true;
var groups = [
['scribe', document.getElementById('cms-scribe-prompts'), ['scribe']],
['clinical', document.getElementById('cms-clinical-prompts'), ['clinical-text', 'clinical-image']]
];
try {
var data = await promptRequest('/api/admin/config/prompts');
groups.forEach(function(group) {
if (!group[1]) return;
group[1].replaceChildren();
var prompts = (data.prompts || []).filter(function(p) { return p.editable === true && group[2].indexOf(p.family) !== -1; });
if (!prompts.length) {
group[1].textContent = 'No editable prompts available in this section.';
return;
}
group[1].appendChild(createPromptFamilyEditor(group[0], prompts));
});
promptsLoaded = true;
} catch (error) {
groups.forEach(function(group) {
if (!group[1]) return;
group[1].textContent = 'Could not load prompts: ' + error.message + ' ';
var retry = document.createElement('button');
retry.type = 'button'; retry.className = 'btn-sm btn-ghost'; retry.textContent = 'Retry loading prompts';
retry.onclick = loadPromptList;
group[1].appendChild(retry);
});
} finally { promptsLoading = false; }
}
// Simple prompt editor: pick a prompt, edit it, save it, or restore the
// shipped default. Revision tracking stays internal only so the server
// optimistic concurrency contract keeps working; no history UI is shown.
function createPromptFamilyEditor(family, prompts) {
var editor = document.createElement('div');
editor.className = 'cms-prompt-family';
editor.dataset.family = family;
editor.style.cssText = 'display:flex;flex-direction:column;gap:10px;';
// Only static markup is parsed; all catalogue content is assigned as text.
editor.innerHTML =
'<label style="font-size:13px;font-weight:600;">Prompt' +
'<select class="prompt-select" style="display:block;margin-top:4px;max-width:100%;font-size:13px;font-family:inherit;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;"></select></label>' +
'<textarea class="prompt-draft" rows="8" style="display:block;width:100%;box-sizing:border-box;font-family:inherit;font-size:13px;padding:8px;border:1px solid var(--g300);border-radius:6px;resize:vertical;"></textarea>' +
'<div style="display:flex;gap:8px;flex-wrap:wrap;">' +
'<button type="button" class="btn-sm btn-primary" data-prompt-action="save">Save prompt</button>' +
'<button type="button" class="btn-sm btn-ghost" data-prompt-action="reset">Restore original</button></div>' +
'<p class="prompt-status" role="status" style="margin:0;font-size:12px;color:var(--g600);"></p>';
var select = editor.querySelector('.prompt-select');
var text = editor.querySelector('.prompt-draft');
var status = editor.querySelector('.prompt-status');
var buttons = {};
editor.querySelectorAll('[data-prompt-action]').forEach(function(button) { buttons[button.dataset.promptAction] = button; });
// Per-key state: server value, revision baseline (internal only) and live draft.
var states = new Map();
prompts.forEach(function(p) {
var option = document.createElement('option');
option.value = p.dbKey;
option.textContent = p.key;
select.appendChild(option);
states.set(p.dbKey, { value: p.value, revision: p.revision, draft: p.value });
});
var key = select.options.length ? select.options[0].value : null;
var busy = false;
if (key) text.value = states.get(key).draft;
function state() { return key ? states.get(key) : null; }
function controls() {
buttons.save.disabled = busy;
buttons.reset.disabled = busy;
select.disabled = busy;
}
async function run(task) {
if (busy) return;
busy = true; controls(); status.textContent = 'Working...';
try { await task(); }
catch (error) { status.textContent = error.message + ' Unsaved edits are preserved.'; }
finally { busy = false; controls(); }
}
async function mutate(action) {
var targetKey = key;
var st = states.get(targetKey);
var submitted = text.value;
st.draft = submitted;
var body = { expectedRevision: st.revision };
if (action === 'save') body.value = submitted;
var data = await promptRequest(action === 'save' ? '/api/admin/config/' + encodeURIComponent(targetKey) : '/api/admin/config/prompts/' + encodeURIComponent(targetKey) + '/reset',
action === 'save' ? 'PUT' : 'POST', body);
st.revision = data.revision;
if (text.value === submitted) text.value = data.value;
status.textContent = action === 'save' ? 'Saved.' : 'Restored shipped default.';
}
select.addEventListener('change', function() {
if (key) states.get(key).draft = text.value;
key = select.value;
text.value = states.get(key).draft;
status.textContent = '';
});
buttons.save.addEventListener('click', function() { run(function() { return mutate('save'); }); });
buttons.reset.addEventListener('click', function() {
showConfirm('Restore only this prompt to its shipped default? This replaces the current value.', function() {
run(function() { return mutate('reset'); });
});
});
controls();
return editor;
}
// ---- SMTP ----
function loadSmtp() {
fetch('/api/admin/config/smtp/status', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var badge = document.getElementById('smtp-source-badge');
if (badge) {
if (data.source === 'env') badge.textContent = 'Configured via env';
else if (data.source === 'database') badge.textContent = 'Configured in DB';
else { badge.textContent = 'Not configured'; badge.style.background = 'var(--red-light)'; badge.style.color = 'var(--red)'; }
}
var h = document.getElementById('cms-smtp-host'); if (h) h.value = data.host || '';
var p = document.getElementById('cms-smtp-port'); if (p) p.value = data.port || '587';
var u = document.getElementById('cms-smtp-user'); if (u) u.value = data.user || '';
var f = document.getElementById('cms-smtp-from'); if (f) f.value = data.from || '';
})
.catch(function() {});
}
function saveSmtp() {
var host = document.getElementById('cms-smtp-host').value.trim();
var port = document.getElementById('cms-smtp-port').value.trim();
var user = document.getElementById('cms-smtp-user').value.trim();
var pass = document.getElementById('cms-smtp-pass').value.trim();
var from = document.getElementById('cms-smtp-from').value.trim();
var secure = document.getElementById('cms-smtp-secure').value;
if (!host) { showToast('SMTP host required', 'error'); return; }
fetch('/api/admin/config/smtp', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ host: host, port: port, user: user, pass: pass, from: from, secure: secure === 'true' })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { showToast('SMTP settings saved', 'success'); loadSmtp(); }
else showToast(data.error || 'Save failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
}
function clearSmtp() {
showConfirm('Clear DB SMTP settings? Env vars will be used if set.', function() {
fetch('/api/admin/config/smtp', { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) { showToast('SMTP DB settings cleared', 'info'); loadSmtp(); }
else showToast(data.error || 'Failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
});
}
// ---- AUTO-DELETE / RESET ----
function saveAutoDelete() {
var days = document.getElementById('cms-auto-delete-days').value;
putConfig('site.auto_delete_days', days)
.then(function() {
var el = document.getElementById('admin-auto-delete-days');
if (el) el.textContent = days;
showToast('Auto-delete set to ' + days + ' days', 'success');
})
.catch(function() { showToast('Save failed', 'error'); });
}
function resetAllDefaults() {
showConfirm('Reset all settings to factory defaults? Announcements, feature flags, email templates will be reset. SMTP and custom models are preserved.', function() {
fetch('/api/admin/config/reset-defaults', { method: 'POST', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
showToast(data.message || 'Reset to defaults', 'success');
cmsLoaded = false;
loadCms();
} else showToast(data.error || 'Reset failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
}, { danger: true, confirmText: 'Reset' });
}
// ---- HELPERS ----
function putConfig(key, value) {
return fetch('/api/admin/config/' + encodeURIComponent(key), {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ value: value })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Failed');
return data;
});
}
}
// ============================================================
// ADMIN CLINICAL ASSISTANT SETTINGS
// ============================================================
initClinicalAssistantAdmin(adminEscapeHtml);
initImageSettings();
// ============================================================
// ADMIN MODEL MANAGEMENT — Discover, search, enable/disable, custom models
// ============================================================
{
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') {
loadAdminModels();
}
});
// Catch-up for a tab that is already active and loaded at module init.
if (adminTabActive()) loadAdminModels();
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-save-default-model')) saveDefaultModel();
if (e.target.closest('#btn-clear-all-models')) clearAllModels();
if (e.target.closest('#btn-test-chat-model')) {
testModel((document.getElementById('admin-chat-test-model') || {}).value || '', e.target.closest('#btn-test-chat-model'));
}
if (e.target.closest('.admin-model-test-btn')) {
var btn = e.target.closest('.admin-model-test-btn');
testModel(btn.dataset.mid, btn);
}
});
// The Discover & test card has one search box for every kind of model; the
// kind switch decides which list is asked. See the discovery block below.
document.addEventListener('admin-discover', function(e) {
if (e.detail && e.detail.kind === 'chat') discoverModels();
});
document.addEventListener('keydown', function(e) {
if (e.target.id === 'admin-chat-test-model' && e.key === 'Enter') {
e.preventDefault();
testModel(e.target.value || '', document.getElementById('btn-test-chat-model'));
}
});
const esc = adminEscapeHtml;
function loadAdminModels() {
fetch('/api/admin/config/models', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
// Provider badge
var badge = document.getElementById('admin-model-provider-badge');
if (badge) badge.textContent = (data.provider || 'unknown').toUpperCase();
// Default model selector — built-ins + custom models
var defaultSel = document.getElementById('admin-default-model');
if (defaultSel) {
defaultSel.innerHTML = '';
data.models.forEach(function(m) {
if (m.enabled === false) return;
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name;
defaultSel.appendChild(opt);
});
(data.custom || []).forEach(function(m) {
if (m.enabled === false) return;
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name;
defaultSel.appendChild(opt);
});
// Pre-select the saved default
if (data.defaultModel) defaultSel.value = data.defaultModel;
}
// Built-in models with toggle
var container = document.getElementById('admin-builtin-models');
if (container) {
if (data.litellmHint) {
container.innerHTML = '<div style="padding:10px 12px;background:var(--g50);border-radius:6px;font-size:13px;color:var(--g600);">' +
'<p style="margin:0 0 8px;"><i class="fas fa-info-circle" style="color:var(--blue);"></i> <strong>LiteLLM mode:</strong> No built-in models. ' +
'Use <strong>Discover &amp; test</strong> above to find models on your gateway, then add them.</p>' +
'<button id="btn-clear-all-models" class="btn-sm" style="background:var(--red-light);color:var(--red);border:none;border-radius:6px;padding:4px 12px;font-size:12px;cursor:pointer;">' +
'<i class="fas fa-trash"></i> Clear all added models</button></div>';
} else if (data.models.length === 0) {
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No built-in models for this provider.</p>';
} else {
container.innerHTML = data.models.map(function(m) {
var checked = m.enabled !== false ? 'checked' : '';
return '<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<label style="display:flex;align-items:center;gap:8px;flex:1;cursor:pointer;margin:0;">' +
'<input type="checkbox" class="admin-model-toggle" data-model-id="' + esc(m.id) + '" ' + checked + ' style="accent-color:var(--blue);">' +
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
'</label>' +
'<button class="btn-sm admin-model-test-btn" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--g100);color:var(--g700);border:none;border-radius:4px;cursor:pointer;white-space:nowrap;">Test</button>' +
'</div>';
}).join('');
container.querySelectorAll('.admin-model-toggle').forEach(function(cb) {
cb.addEventListener('change', function() {
toggleModel(cb.dataset.modelId, cb.checked);
});
});
}
}
// Custom models list
renderCustomModels(data.custom || []);
})
.catch(function(err) { console.error('[AdminModels] Load failed:', err); });
}
function toggleModel(modelId, enabled) {
fetch('/api/admin/config/models/toggle', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ modelId: modelId, enabled: enabled })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
// Disabling removes a model from every picker, not only this list.
announceModelsChanged();
showToast(modelId + ' ' + (enabled ? 'enabled' : 'disabled'), 'success');
loadAdminModels();
} else {
showToast(data.error || 'Failed', 'error');
// Revert checkbox
var cb = document.querySelector('.admin-model-toggle[data-model-id="' + modelId + '"]');
if (cb) cb.checked = !enabled;
}
})
.catch(function() { showToast('Request failed', 'error'); });
}
function discoverModels() {
var search = (document.getElementById('admin-discover-search') || {}).value || '';
var container = document.getElementById('admin-discover-results');
var hint = document.getElementById('admin-discover-hint');
if (!container) return;
container.innerHTML = '<p style="color:var(--g400);font-size:13px;"><i class="fas fa-spinner fa-spin"></i> Querying provider API...</p>';
if (hint) hint.hidden = true;
fetch('/api/admin/config/models/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) {
container.innerHTML = '<p style="color:var(--red);font-size:13px;">Error: ' + esc(data.error || 'Unknown error') + '</p>';
return;
}
if (!data.models || data.models.length === 0) {
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No models found' + (search ? ' matching "' + esc(search) + '"' : '') + '. Try a different search term.</p>';
return;
}
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' chat models. Press + to add one to the roster.</p>' +
data.models.slice(0, 100).map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm btn-primary admin-add-discovered" data-mid="' + esc(m.id) + '" data-mname="' + esc(m.name) + '" style="padding:2px 8px;font-size:11px;min-width:28px;">+</button>' +
'<button class="btn-sm admin-model-test-btn" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--g100);color:var(--g700);border:none;border-radius:4px;cursor:pointer;">Test</button>' +
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
'</div>';
}).join('');
container.querySelectorAll('.admin-add-discovered').forEach(function(btn) {
btn.addEventListener('click', function() {
addDiscoveredModel(btn.dataset.mid, btn.dataset.mname, btn);
});
});
})
.catch(function(err) {
container.innerHTML = '<p style="color:var(--red);font-size:13px;">Request failed: ' + esc(err.message) + '</p>';
});
}
// The model roster changed. Every picker that lists models listens for this,
// because otherwise they keep whatever they were given when the tab loaded:
// adding a model used to refresh the default-model dropdown alone, and the
// Clinical Assistant, review-model and image pickers only caught up on a page
// reload. The detail carries nothing — a listener re-reads the list itself,
// so there is one source of truth rather than a payload to keep in step.
function announceModelsChanged() {
try { document.dispatchEvent(new CustomEvent('models-changed')); } catch (e) {}
}
function addDiscoveredModel(id, name, btn) {
fetch('/api/admin/config/models/add-discovered', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ id: id, name: name })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
announceModelsChanged();
showToast('Added: ' + name + ' — it is now selectable everywhere models are chosen', 'success');
if (btn) { btn.textContent = 'Added'; btn.disabled = true; btn.style.background = 'var(--green)'; }
// Refresh model lists, then auto-select the newly added model
fetch('/api/admin/config/models', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(refreshed) {
if (!refreshed.success) return;
var defaultSel = document.getElementById('admin-default-model');
if (defaultSel) {
defaultSel.innerHTML = '';
refreshed.models.forEach(function(m) {
if (m.enabled === false) return;
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name;
defaultSel.appendChild(opt);
});
(refreshed.custom || []).forEach(function(m) {
if (m.enabled === false) return;
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name;
defaultSel.appendChild(opt);
});
// Auto-select the model just added
defaultSel.value = id;
}
renderCustomModels(refreshed.custom || []);
});
} else {
showToast(data.error || 'Failed', 'error');
}
})
.catch(function() { showToast('Request failed', 'error'); });
}
function renderCustomModels(custom) {
var container = document.getElementById('admin-custom-models-list');
if (!container) return;
if (!custom || custom.length === 0) {
container.innerHTML = '';
return;
}
container.innerHTML = '<div class="admin-subhead" style="margin-bottom:4px;">Added from the gateway</div>' +
custom.map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<span style="flex:1;"><strong>' + esc(m.name) + '</strong> <span style="color:var(--g500);font-size:11px;">(' + esc(m.id) + ')</span></span>' +
'<button class="btn-sm admin-model-test-btn" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--g100);color:var(--g700);border:none;border-radius:4px;cursor:pointer;">Test</button>' +
'<button class="btn-sm admin-delete-custom" data-mid="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;background:var(--red-light);color:var(--red);border:none;border-radius:4px;cursor:pointer;">Remove</button>' +
'</div>';
}).join('');
container.querySelectorAll('.admin-delete-custom').forEach(function(btn) {
btn.addEventListener('click', function() {
deleteCustomModel(btn.dataset.mid);
});
});
}
function deleteCustomModel(modelId) {
fetch('/api/admin/config/models/custom/' + encodeURIComponent(modelId), {
method: 'DELETE',
headers: getAuthHeaders()
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
announceModelsChanged();
showToast('Removed: ' + modelId, 'info');
loadAdminModels();
} else {
showToast(data.error || 'Failed', 'error');
}
})
.catch(function() { showToast('Request failed', 'error'); });
}
function clearAllModels() {
showConfirm('Remove all added models? You will need to find and add them again under Discover & test.', function() {
Promise.all([
fetch('/api/admin/config/models/clear-all', { method: 'POST', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
]).then(function(results) {
if (results[0].success) {
announceModelsChanged();
showToast('All models cleared', 'info');
loadAdminModels();
} else {
showToast(results[0].error || 'Failed', 'error');
}
}).catch(function() { showToast('Request failed', 'error'); });
});
}
function saveDefaultModel() {
var sel = document.getElementById('admin-default-model');
if (!sel || !sel.value) { showToast('Select a model', 'error'); return; }
fetch('/api/admin/config/models/default', {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ modelId: sel.value })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) showToast('Default model set: ' + sel.value, 'success');
else showToast(data.error || 'Failed', 'error');
})
.catch(function() { showToast('Request failed', 'error'); });
}
function testModel(modelId, btn) {
modelId = String(modelId || '').trim();
// The result line outlives the toast, which is gone before an admin has
// scrolled back to read it. Rows on the roster share it with the box.
var result = document.getElementById('admin-chat-test-result');
if (!modelId) {
if (result) result.textContent = 'Enter or pick a model id first.';
return;
}
var origText = btn ? btn.textContent : 'Test';
adminSetButtonText(btn, '...', true);
if (result) result.textContent = 'Testing ' + modelId + '...';
fetch('/api/admin/config/models/test', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ modelId: modelId })
})
.then(function(r) { return r.json(); })
.then(function(data) {
adminSetButtonText(btn, origText, false);
if (data.success) {
if (result) result.textContent = modelId + ' works (' + (data.duration || 0) + ' ms): "' + (data.response || '?') + '"';
showToast('"' + (data.response || '?') + '" — ' + modelId + ' (' + (data.duration || 0) + 'ms)', 'success');
} else {
if (result) result.textContent = modelId + ' failed: ' + (data.error || 'Unknown error');
showToast('Test failed: ' + (data.error || 'Unknown error'), 'error');
}
})
.catch(function() {
adminSetButtonText(btn, origText, false);
if (result) result.textContent = 'Request failed.';
showToast('Request failed', 'error');
});
}
}
// ============================================================
// ADMIN DISCOVERY — one search box for every kind of model
// ============================================================
// Chat, image, speech and transcription models each have their own
// gateway list and their own row buttons, and they used to have a card each,
// scattered down the page. The kind switch keeps the five discovery calls as
// they are and only decides which one the Search button asks. The switch
// dispatches an 'admin-discover' event rather than calling the loaders, which
// live in their own blocks below.
{
const DISCOVER_PLACEHOLDERS = {
chat: 'Filter by name (e.g. gemini, gpt, llama)',
image: 'Filter by name (e.g. dall-e, imagen, flux)',
tts: 'Filter voices or models (e.g. Journey, Neural, alloy)',
stt: 'Filter by name (e.g. gemini, whisper)'
};
function activeDiscoverKind() {
var pressed = document.querySelector('.admin-discover-kind[aria-pressed="true"]');
return pressed ? pressed.dataset.kind : 'chat';
}
function selectDiscoverKind(kind) {
document.querySelectorAll('.admin-discover-kind').forEach(function(btn) {
btn.setAttribute('aria-pressed', btn.dataset.kind === kind ? 'true' : 'false');
});
document.querySelectorAll('.admin-kind-panel').forEach(function(panel) {
panel.hidden = panel.dataset.kind !== kind;
});
// Results from one kind mean nothing under another: the row buttons would
// add an image model to the chat roster.
var results = document.getElementById('admin-discover-results');
if (results) results.innerHTML = '';
var hint = document.getElementById('admin-discover-hint');
if (hint) hint.hidden = false;
var search = document.getElementById('admin-discover-search');
if (search) search.placeholder = DISCOVER_PLACEHOLDERS[kind] || DISCOVER_PLACEHOLDERS.chat;
}
function runDiscover() {
var search = document.getElementById('admin-discover-search');
// Built by the document's own window: an event from any other realm is
// refused by dispatchEvent, and a refused Search does nothing visible.
var win = document.defaultView || window;
document.dispatchEvent(new win.CustomEvent('admin-discover', {
detail: { kind: activeDiscoverKind(), query: search ? search.value : '' }
}));
}
document.addEventListener('click', function(e) {
var kindBtn = e.target.closest('.admin-discover-kind');
if (kindBtn) selectDiscoverKind(kindBtn.dataset.kind);
if (e.target.closest('#btn-discover')) runDiscover();
});
document.addEventListener('keydown', function(e) {
if (e.target.id === 'admin-discover-search' && e.key === 'Enter') { e.preventDefault(); runDiscover(); }
});
}
// ============================================================
// ADMIN TTS MANAGEMENT
// ============================================================
{
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') loadTTSConfig();
});
// Catch-up for a tab that is already active and loaded at module init.
if (adminTabActive()) loadTTSConfig();
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-test-tts')) testTTS();
if (e.target.closest('.admin-tts-set-btn')) {
var btn = e.target.closest('.admin-tts-set-btn');
setTTSDefault(btn.dataset.id, btn.dataset.type, btn);
}
});
document.addEventListener('admin-discover', function(e) {
if (e.detail && e.detail.kind === 'tts') discoverTTS();
});
const esc = adminEscapeHtml;
function loadTTSConfig() {
fetch('/api/admin/config/tts', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var badge = document.getElementById('admin-tts-provider-badge');
if (badge) {
badge.textContent = (data.provider || 'none').toUpperCase();
badge.style.background = data.provider === 'none' ? 'var(--red-light)' : 'var(--g100)';
badge.style.color = data.provider === 'none' ? 'var(--red)' : 'var(--g600)';
}
var info = document.getElementById('admin-tts-info');
if (info) {
var parts = [];
if (data.envProvider !== 'auto') parts.push('TTS_PROVIDER=' + data.envProvider);
if (data.dbVoice) parts.push('DB voice: ' + data.dbVoice);
else if (data.envVoice) parts.push('Env voice: ' + data.envVoice);
if (data.dbModel) parts.push('DB model: ' + data.dbModel);
else if (data.envModel) parts.push('Env model: ' + data.envModel);
var configured = Object.keys(data.configured || {}).filter(function(k) { return data.configured[k]; });
if (configured.length) parts.push('Configured: ' + configured.join(', '));
info.textContent = parts.join(' · ') || 'Auto-detected from env';
}
var voiceSel = document.getElementById('admin-tts-voice');
if (voiceSel) {
voiceSel.innerHTML = '';
var voices = (data.voices && data.voices[data.provider]) || [];
if (data.currentVoice && voices.indexOf(data.currentVoice) === -1) voices = [data.currentVoice].concat(voices);
if (voices.length === 0) voices = ['default'];
voices.forEach(function(v) {
var opt = document.createElement('option');
opt.value = v;
opt.textContent = v + (v === data.currentVoice ? ' (active)' : '');
if (v === data.currentVoice) opt.selected = true;
voiceSel.appendChild(opt);
});
}
})
.catch(function() {});
}
function discoverTTS() {
var search = (document.getElementById('admin-discover-search') || {}).value || '';
var container = document.getElementById('admin-discover-results');
var hint = document.getElementById('admin-discover-hint');
if (!container) return;
container.innerHTML = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
if (hint) hint.hidden = true;
fetch('/api/admin/config/tts/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) {
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>';
return;
}
var items = data.voices || [];
if (items.length === 0) {
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No voices/models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
return;
}
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' voices/models (provider: ' + esc(data.provider) + ')</p>' +
items.slice(0, 100).map(function(v) {
var isModel = v.kind === 'model' || (v.source || '').indexOf('gateway') !== -1 || (v.source || '').indexOf('builtin-model') !== -1 || (v.source || '').indexOf('configured-model') !== -1;
var setType = isModel ? 'model' : 'voice';
var badge = isModel ? '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--blue);color:white;margin-left:4px;">MODEL</span>' : '<span style="font-size:9px;padding:1px 5px;border-radius:4px;background:var(--green);color:white;margin-left:4px;">VOICE</span>';
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm btn-primary admin-tts-set-btn" data-id="' + esc(v.id) + '" data-type="' + setType + '" style="padding:2px 8px;font-size:11px;">Make default</button>' +
'<span style="flex:1;">' + esc(v.name) + badge + '</span>' +
'<span style="font-size:10px;color:var(--g400);">' + esc(v.source || '') + '</span>' +
'</div>';
}).join('');
})
.catch(function(err) {
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Request failed: ' + esc(err.message) + '</p>';
});
}
function setTTSDefault(id, type, btn) {
var key = type === 'model' ? 'tts.model' : 'tts.voice';
var origText = btn ? btn.textContent : '';
adminSetButtonText(btn, '...', true);
fetch('/api/admin/config/' + encodeURIComponent(key), {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ value: id })
})
.then(function(r) { return r.json(); })
.then(function(data) {
adminSetButtonText(btn, 'Set', false);
adminFlashButtonBackground(btn, 'var(--green)');
if (data.success) {
showToast('TTS ' + type + ' set to: ' + id, 'success');
// Update voice selector
var voiceSel = document.getElementById('admin-tts-voice');
if (voiceSel && type === 'voice') {
var found = Array.from(voiceSel.options).find(function(o) { return o.value === id; });
if (!found) {
var opt = document.createElement('option');
opt.value = id; opt.textContent = id + ' (active)';
voiceSel.insertBefore(opt, voiceSel.firstChild);
}
voiceSel.value = id;
}
loadTTSConfig();
} else {
showToast(data.error || 'Failed', 'error');
}
})
.catch(function() {
adminSetButtonText(btn, origText, false);
showToast('Request failed', 'error');
});
}
function testTTS() {
var text = (document.getElementById('admin-tts-test-text') || {}).value || 'Hello.';
var voice = (document.getElementById('admin-tts-voice') || {}).value || '';
var btn = document.getElementById('btn-test-tts');
var resultEl = document.getElementById('admin-tts-result');
var audioEl = document.getElementById('admin-tts-audio');
adminSetButtonHtml(btn, '<i class="fas fa-spinner fa-spin"></i> Synthesizing...', true);
if (resultEl) resultEl.textContent = '';
if (audioEl) audioEl.style.display = 'none';
fetch('/api/admin/config/tts/test', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ text: text, voice: voice })
})
.then(function(r) { return r.json(); })
.then(function(data) {
adminSetButtonHtml(btn, '<i class="fas fa-play"></i> Synthesize &amp; Play', false);
if (!data.success) {
if (resultEl) resultEl.textContent = 'Error: ' + (data.error || 'Unknown error');
return;
}
if (audioEl && data.audio) {
var blob = base64ToBlob(data.audio, 'audio/mpeg');
audioEl.src = URL.createObjectURL(blob);
audioEl.style.display = 'inline-block';
audioEl.play();
}
if (resultEl) resultEl.textContent = 'Provider: ' + (data.provider || '?') + ' · Voice: ' + (data.voice || '?');
})
.catch(function(err) {
adminSetButtonHtml(btn, '<i class="fas fa-play"></i> Synthesize &amp; Play', false);
if (resultEl) resultEl.textContent = 'Request failed: ' + err.message;
});
}
function base64ToBlob(base64, type) {
var binary = atob(base64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return new Blob([bytes], { type: type });
}
}
// ============================================================
// ADMIN STT MANAGEMENT
// ============================================================
{
let mediaRecorder = null;
let audioChunks = [];
let recording = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') loadSTTConfig();
});
// Catch-up for a tab that is already active and loaded at module init.
if (adminTabActive()) loadSTTConfig();
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-stt-record')) toggleRecording();
if (e.target.closest('.admin-stt-set-btn')) {
var btn = e.target.closest('.admin-stt-set-btn');
setSTTDefault(btn.dataset.id, btn);
}
});
document.addEventListener('admin-discover', function(e) {
if (e.detail && e.detail.kind === 'stt') discoverSTT();
});
const esc = adminEscapeHtml;
function loadSTTConfig() {
fetch('/api/admin/config/stt', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var badge = document.getElementById('admin-stt-provider-badge');
if (badge) {
badge.textContent = (data.provider || 'none').toUpperCase();
badge.style.background = data.provider === 'none' ? 'var(--red-light)' : 'var(--g100)';
badge.style.color = data.provider === 'none' ? 'var(--red)' : 'var(--g600)';
}
var info = document.getElementById('admin-stt-info');
if (info) {
var parts = [];
if (data.envProvider !== 'auto') parts.push('TRANSCRIBE_PROVIDER=' + data.envProvider);
if (data.dbModel) parts.push('DB model: ' + data.dbModel);
else if (data.envModel) parts.push('Env model: ' + data.envModel);
var configured = Object.keys(data.configured || {}).filter(function(k) { return data.configured[k]; });
if (configured.length) parts.push('Configured: ' + configured.join(', '));
info.textContent = parts.join(' · ') || 'Auto-detected from env';
}
})
.catch(function() {});
}
function discoverSTT() {
var search = (document.getElementById('admin-discover-search') || {}).value || '';
var container = document.getElementById('admin-discover-results');
var hint = document.getElementById('admin-discover-hint');
if (!container) return;
container.innerHTML = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
if (hint) hint.hidden = true;
fetch('/api/admin/config/stt/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) {
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>';
return;
}
var items = data.models || [];
if (items.length === 0) {
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
return;
}
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' models (provider: ' + esc(data.provider) + ')</p>' +
items.map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm btn-primary admin-stt-set-btn" data-id="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;">Make default</button>' +
'<span style="flex:1;">' + esc(m.name || m.id) + '</span>' +
'<span style="font-size:10px;color:var(--g400);">' + esc(m.source || '') + '</span>' +
'</div>';
}).join('');
})
.catch(function(err) {
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Request failed: ' + esc(err.message) + '</p>';
});
}
function setSTTDefault(modelId, btn) {
var origText = btn ? btn.textContent : '';
adminSetButtonText(btn, '...', true);
fetch('/api/admin/config/' + encodeURIComponent('stt.model'), {
method: 'PUT',
headers: getAuthHeaders(),
body: JSON.stringify({ value: modelId })
})
.then(function(r) { return r.json(); })
.then(function(data) {
adminSetButtonText(btn, 'Set', false);
adminFlashButtonBackground(btn, data.success ? 'var(--green)' : '');
if (data.success) { showToast('STT model set to: ' + modelId, 'success'); loadSTTConfig(); }
else showToast(data.error || 'Failed', 'error');
})
.catch(function() {
adminSetButtonText(btn, origText, false);
showToast('Request failed', 'error');
});
}
function toggleRecording() {
if (recording) {
stopRecording();
} else {
startRecording();
}
}
function startRecording() {
var btn = document.getElementById('btn-stt-record');
var status = document.getElementById('admin-stt-recording-status');
var resultEl = document.getElementById('admin-stt-result');
if (resultEl) resultEl.style.display = 'none';
navigator.mediaDevices.getUserMedia({ audio: true })
.then(function(stream) {
audioChunks = [];
mediaRecorder = new MediaRecorder(stream);
mediaRecorder.addEventListener('dataavailable', function(e) {
if (e.data.size > 0) audioChunks.push(e.data);
});
mediaRecorder.addEventListener('stop', function() {
stream.getTracks().forEach(function(t) { t.stop(); });
transcribeRecording();
});
mediaRecorder.start();
recording = true;
if (btn) { btn.innerHTML = '<i class="fas fa-stop"></i> Stop Recording'; btn.style.background = 'var(--red)'; btn.style.color = 'white'; }
if (status) status.textContent = 'Recording... (click Stop when done)';
})
.catch(function(err) {
if (status) status.textContent = 'Microphone access denied: ' + err.message;
});
}
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
}
recording = false;
var btn = document.getElementById('btn-stt-record');
var status = document.getElementById('admin-stt-recording-status');
if (btn) { btn.innerHTML = '<i class="fas fa-microphone"></i> Start Recording'; btn.style.background = ''; btn.style.color = ''; }
if (status) status.textContent = 'Processing...';
}
function transcribeRecording() {
if (audioChunks.length === 0) return;
var blob = new Blob(audioChunks, { type: 'audio/webm' });
var reader = new FileReader();
reader.onloadend = function() {
var base64 = reader.result.split(',')[1];
fetch('/api/admin/config/stt/test', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ audioBase64: base64, mimeType: 'audio/webm' })
})
.then(function(r) { return r.json(); })
.then(function(data) {
var status = document.getElementById('admin-stt-recording-status');
var resultEl = document.getElementById('admin-stt-result');
var textEl = document.getElementById('admin-stt-text');
var metaEl = document.getElementById('admin-stt-meta');
if (status) status.textContent = '';
if (resultEl) resultEl.style.display = 'block';
if (data.success) {
if (textEl) textEl.textContent = data.text || '(empty result)';
if (metaEl) metaEl.textContent = 'Provider: ' + (data.provider || '?') + ' · ' + (data.duration || 0) + 'ms';
} else {
if (textEl) textEl.textContent = 'Error: ' + (data.error || 'Unknown error');
if (metaEl) metaEl.textContent = '';
}
})
.catch(function(err) {
var status = document.getElementById('admin-stt-recording-status');
if (status) status.textContent = 'Request failed: ' + err.message;
});
};
reader.readAsDataURL(blob);
}
}
// ============================================================
// ADMIN CITATION QUALITY
// A citation that resolves to nothing is never linked, so it is invisible
// unless someone counts it. This is the reading end of that.
// ============================================================
{
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') loadCitationSummary();
});
if (adminTabActive()) loadCitationSummary();
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-view-citation-audit')) openCitationAudit();
if (e.target.closest('#citation-audit-close') || e.target.id === 'citation-audit-modal') closeCitationAudit();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeCitationAudit();
});
const esc = adminEscapeHtml;
function fetchAudit() {
return fetch('/api/admin/citation-audit', { headers: getAuthHeaders() }).then(function(r) { return r.json(); });
}
function loadCitationSummary() {
fetchAudit().then(function(data) {
var badge = document.getElementById('admin-citation-summary');
if (!badge || !data.success) return;
var answers = (data.totals && data.totals.answers) || 0;
badge.textContent = answers ? answers + ' flagged (30 days)' : 'none flagged';
badge.style.background = answers ? 'var(--amber)' : 'var(--green)';
badge.style.color = 'white';
}).catch(function() {});
}
function closeCitationAudit() {
var modal = document.getElementById('citation-audit-modal');
if (modal) modal.remove();
}
function openCitationAudit() {
fetchAudit().then(function(data) {
if (!data.success) throw new Error(data.error || 'Could not load');
closeCitationAudit();
var modal = document.createElement('div');
modal.id = 'citation-audit-modal';
modal.className = 'modal';
modal.innerHTML = '<div class="modal-content" style="width:min(860px,94vw);max-height:84vh;display:flex;flex-direction:column;">' +
'<div class="modal-header"><h2>Citation quality</h2>' +
'<button type="button" class="modal-close" id="citation-audit-close" aria-label="Close"><i class="fas fa-xmark"></i></button></div>' +
'<div class="modal-body" style="overflow:auto;">' + rows(data.rows || []) + '</div></div>';
document.body.appendChild(modal);
}).catch(function(err) { showToast(err.message, 'error'); });
}
function rows(list) {
if (!list.length) {
return '<p style="font-size:13px;color:var(--g500);">Nothing flagged in the last 30 days — every citation pointed at a source that was returned.</p>';
}
return list.map(function(row) {
var missing = (row.unverifiable || []).map(function(n) { return '[' + n + ']'; }).join(' ');
var titles = (row.source_titles || []).map(function(t, i) {
return '<li style="margin:0;">[' + (i + 1) + '] ' + esc(t) + '</li>';
}).join('');
return '<div style="border:1px solid var(--g200);border-radius:8px;padding:10px 12px;margin-bottom:10px;">' +
'<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:6px;">' +
'<span style="font-size:10px;font-weight:700;padding:2px 7px;border-radius:10px;background:var(--amber);color:white;">' + esc(missing) + ' unmatched</span>' +
'<span style="font-size:11px;color:var(--g500);">' + esc(new Date(row.created_at).toLocaleString()) +
' · ' + esc(String(row.cited_count)) + ' citations written, ' + esc(String(row.source_count)) + ' sources returned' +
(row.user_email ? ' · ' + esc(row.user_email) : '') + '</span>' +
'</div>' +
'<div style="font-size:13px;color:var(--g800);margin-bottom:6px;overflow-wrap:anywhere;"><strong>Question:</strong> ' + esc(row.question || '(none)') + '</div>' +
(titles ? '<details><summary style="font-size:12px;color:var(--g600);cursor:pointer;">Sources returned</summary><ul style="font-size:12px;color:var(--g600);margin:6px 0 0;padding-left:18px;">' + titles + '</ul></details>' : '') +
'</div>';
}).join('');
}
}
// ============================================================
// ADMIN LOCKDOWN (display)
// The server refuses locked writes regardless; this only stops an admin
// filling in a field that was never going to save. Settings stay visible, so
// the configuration can still be read.
//
// The state rides along on the invites response rather than a request of its
// own: the admin panel already makes enough calls on open.
// ============================================================
{
window.applyAdminLockdown = function(state) {
if (!state || !state.enabled) return;
var panel = document.getElementById('admin-tab');
if (!panel || !panel.querySelector('.card')) return;
lockdownBanner(panel, state);
lockdownFields(panel, state);
};
function lockdownBanner(panel, state) {
if (document.getElementById('admin-lockdown-banner')) return;
var note = document.createElement('div');
note.id = 'admin-lockdown-banner';
note.style.cssText = 'margin:0 0 12px;padding:10px 14px;border:1px solid var(--amber);background:var(--amber-light);border-radius:8px;font-size:13px;color:var(--g800);';
note.innerHTML = '<i class="fas fa-lock"></i> <strong>Admin lockdown is on.</strong> ' +
adminEscapeHtml(state.reason) + ' Settings are shown but cannot be changed here.';
panel.insertBefore(note, panel.firstChild);
}
// Everything inside the panel, except the controls that stay operational and
// the buttons that only read.
function lockdownFields(panel, state) {
var editable = ['admin-invite', 'btn-create-invite', 'cms-ann', 'announcement',
'admin-users-search', 'registration'];
panel.querySelectorAll('input, select, textarea, button').forEach(function(el) {
var id = el.id || '';
if (editable.some(function(prefix) { return id.indexOf(prefix) === 0; })) return;
var label = (el.textContent || '') + ' ' + id;
if (/search|test|discover|refresh|reload|retry|copy/i.test(label)) return;
el.disabled = true;
el.title = state.reason;
});
}
}
// ============================================================
// ADMIN REGISTRATION INVITES
// The code exists in readable form exactly once: in the response to creating
// it. Everything after works from the id and the last four characters.
// ============================================================
{
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'admin') loadInvites();
});
if (adminTabActive()) loadInvites();
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-create-invite')) createInvite();
var revoke = e.target.closest('.admin-invite-revoke');
if (revoke) inviteAction(revoke.dataset.id, 'revoke');
var del = e.target.closest('.admin-invite-delete');
if (del) inviteAction(del.dataset.id, 'delete');
if (e.target.closest('#btn-clear-used-invites')) clearUsedInvites();
var copy = e.target.closest('.admin-invite-copy');
if (copy && navigator.clipboard) {
navigator.clipboard.writeText(copy.dataset.code)
.then(function() { showToast('Invitation code copied', 'success'); })
.catch(function() { showToast('Could not copy; select it by hand', 'error'); });
}
});
document.addEventListener('change', function(e) {
if (e.target.id === 'admin-invite-only') setInviteOnly(e.target.checked);
});
const esc = adminEscapeHtml;
function loadInvites() {
fetch('/api/admin/invites', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) return;
var toggle = document.getElementById('admin-invite-only');
if (toggle) toggle.checked = !!data.inviteOnly;
renderInvites(data.invites || []);
// Painted after the panel has rendered, from the same response.
if (typeof window.applyAdminLockdown === 'function') window.applyAdminLockdown(data.lockdown);
})
.catch(function() {});
}
function setInviteOnly(on) {
fetch('/api/admin/config/' + encodeURIComponent('registration_invite_only'), {
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: on ? 'true' : 'false' })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Could not save');
showToast(on ? 'Registration now requires an invitation' : 'Registration no longer requires an invitation', 'success');
})
.catch(function(err) { showToast(err.message, 'error'); loadInvites(); });
}
function createInvite() {
var note = (document.getElementById('admin-invite-note') || {}).value || '';
var days = (document.getElementById('admin-invite-days') || {}).value || '7';
var out = document.getElementById('admin-invite-new');
fetch('/api/admin/invites', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ note: note, days: days })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Could not create');
if (out) {
out.innerHTML = '<div style="display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px solid var(--green);background:var(--green-light);border-radius:8px;flex-wrap:wrap;">' +
'<code style="font-size:15px;font-weight:700;letter-spacing:.06em;">' + esc(data.code) + '</code>' +
'<button type="button" class="btn-sm btn-ghost admin-invite-copy" data-code="' + esc(data.code) + '"><i class="fas fa-copy"></i> Copy</button>' +
'<span style="font-size:12px;color:var(--g600);">Valid ' + esc(String(data.days)) + ' days. This is the only time it is shown.</span>' +
'</div>';
}
var noteEl = document.getElementById('admin-invite-note');
if (noteEl) noteEl.value = '';
loadInvites();
})
.catch(function(err) { showToast(err.message, 'error'); });
}
function inviteAction(id, action) {
var request = action === 'delete'
? fetch('/api/admin/invites/' + encodeURIComponent(id), { method: 'DELETE', headers: getAuthHeaders() })
: fetch('/api/admin/invites/' + encodeURIComponent(id) + '/revoke', { method: 'POST', headers: getAuthHeaders() });
request.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Failed');
showToast(action === 'delete' ? 'Invitation deleted' : 'Invitation revoked', 'info');
loadInvites();
})
.catch(function(err) { showToast(err.message, 'error'); });
}
// Used, or expired without being redeemed. A revoked code keeps its row: it
// records a decision somebody took, and it is not cluttering anything the way
// a pile of expired codes does.
var SPENT_STATUS = ['used', 'expired'];
// Spent invitations in one go, which is what a cluttered list actually wants.
// Confirmed first: it is a delete, even if everything it removes is finished.
function clearUsedInvites() {
showConfirm('Delete every used and expired invitation? Live and revoked ones are kept.', function() {
fetch('/api/admin/invites/spent', { method: 'DELETE', headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Could not clear them');
showToast('Removed ' + data.removed + ' spent invitation' + (data.removed === 1 ? '' : 's'), 'success');
loadInvites();
})
.catch(function(err) { showToast(err.message, 'error'); });
}, { danger: true, confirmText: 'Delete' });
}
function renderInvites(rows) {
var container = document.getElementById('admin-invites-list');
if (!container) return;
if (!rows.length) {
container.innerHTML = '<p style="font-size:13px;color:var(--g400);margin:0;">No invitations yet.</p>';
return;
}
var colours = { active: 'var(--green)', used: 'var(--g400)', expired: 'var(--amber)', revoked: 'var(--red)' };
container.innerHTML = rows.map(function(row) {
var when = row.status === 'used' ? 'used ' + new Date(row.used_at).toLocaleDateString()
: row.status === 'revoked' ? 'revoked'
: 'expires ' + new Date(row.expires_at).toLocaleDateString();
var who = row.used_by_email ? ' by ' + esc(row.used_by_email) : '';
return '<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:var(--g50);font-size:13px;flex-wrap:wrap;">' +
'<span style="font-size:10px;font-weight:700;text-transform:uppercase;padding:2px 7px;border-radius:10px;color:white;background:' + (colours[row.status] || 'var(--g400)') + ';">' + esc(row.status) + '</span>' +
// The code itself when it is still recoverable, with a button to copy
// it: an invitation has to be given to somebody, usually later than the
// moment it was made. A row from before codes were kept shows the four
// characters it has.
(row.code
? '<code class="invite-code" style="font-size:12px;">' + esc(row.code) + '</code>' +
'<button type="button" class="btn-sm btn-ghost admin-invite-copy" data-code="' + esc(row.code) + '" title="Copy this code"><i class="fas fa-copy"></i></button>'
: '<code style="font-size:12px;" title="Made before codes were kept">****-' + esc(row.code_hint) + '</code>') +
'<span style="flex:1;min-width:0;overflow-wrap:anywhere;">' + esc(row.note || '') + '</span>' +
'<span style="font-size:11px;color:var(--g500);">' + esc(when) + who + '</span>' +
(row.status === 'active' ? '<button type="button" class="btn-sm btn-ghost admin-invite-revoke" data-id="' + esc(String(row.id)) + '">Revoke</button>' : '') +
// Delete is offered on spent codes only — used, or expired unredeemed.
// One that could still be redeemed may be sitting in somebody's inbox:
// taking it off this list would not take it out of their hands, and
// nothing would then say who held it. Revoking is what stops a live
// code, and it leaves the row behind, marked.
(SPENT_STATUS.indexOf(row.status) !== -1
? '<button type="button" class="btn-sm btn-ghost admin-invite-delete" data-id="' + esc(String(row.id)) + '" style="color:var(--red);" title="Delete this spent invitation"><i class="fas fa-trash"></i></button>'
: '') +
'</div>';
}).join('');
container.querySelectorAll('.admin-invite-copy').forEach(function(btn) {
btn.addEventListener('click', function() {
var code = btn.dataset.code || '';
// The async clipboard API needs a secure context and permission; the
// textarea fallback is what works everywhere else, including plain http
// on a local network.
var done = function() {
var icon = btn.querySelector('i');
if (icon) { icon.className = 'fas fa-check'; setTimeout(function() { icon.className = 'fas fa-copy'; }, 1200); }
showToast('Code copied', 'success');
};
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(code).then(done).catch(fallback);
} else { fallback(); }
function fallback() {
var box = document.createElement('textarea');
box.value = code;
box.setAttribute('readonly', '');
box.style.cssText = 'position:fixed;top:-1000px;opacity:0;';
document.body.appendChild(box);
box.select();
try { document.execCommand('copy'); done(); }
catch (e) { showToast('Could not copy — select the code and copy it', 'error'); }
box.remove();
}
});
});
var spent = rows.filter(function(row) { return SPENT_STATUS.indexOf(row.status) !== -1; }).length;
var clear = document.getElementById('btn-clear-used-invites');
if (clear) {
clear.hidden = spent === 0;
clear.textContent = 'Clear ' + spent + ' spent';
}
}
}
// ============================================================
// ADMIN IMAGE MODEL MANAGEMENT
// ============================================================
// Unlike TTS and STT there is no single default to make: an image model is
// chosen per workflow, so discovery here ends in a Test, and the workflow
// pickers under Availability consume the same discovery call. Nothing loads
// on tab entry: the workflow pickers already make the one discovery call
// opening Admin needs, so this kind asks only when searched.
{
// + Add puts a model on the image roster (clinical_assistant.image_model_roster).
// The Roster card lists it with a Remove; ticking it under Availability
// offers it to users.
document.addEventListener('assistant-image-roster', function() { syncImageRows(); renderImageRoster(); });
document.addEventListener('click', function(e) {
var add = e.target.closest('.admin-image-add-btn, .admin-image-remove-btn');
if (add) { toggleImageRoster(add.dataset.id, add); return; }
if (e.target.closest('#btn-test-image-model')) testImageModel((document.getElementById('admin-image-test-model') || {}).value || '');
var pick = e.target.closest('.admin-image-test-btn');
if (pick) {
var field = document.getElementById('admin-image-test-model');
if (field) field.value = pick.dataset.id;
testImageModel(pick.dataset.id, pick);
}
});
document.addEventListener('admin-discover', function(e) {
if (e.detail && e.detail.kind === 'image') discoverImageModels();
});
document.addEventListener('keydown', function(e) {
if (e.target.id === 'admin-image-test-model' && e.key === 'Enter') { e.preventDefault(); testImageModel(e.target.value || ''); }
});
const esc = adminEscapeHtml;
function discoverImageModels() {
var search = (document.getElementById('admin-discover-search') || {}).value || '';
var container = document.getElementById('admin-discover-results');
var hint = document.getElementById('admin-discover-hint');
if (!container) return;
container.innerHTML = '<p style="font-size:13px;color:var(--g400);"><i class="fas fa-spinner fa-spin"></i> Querying provider...</p>';
if (hint) hint.hidden = true;
fetch('/api/admin/config/image-models/discover?q=' + encodeURIComponent(search), { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) {
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Error: ' + esc(data.error || 'Unknown') + '</p>';
return;
}
var items = data.models || [];
var badge = document.getElementById('admin-image-provider-badge');
if (badge && !search) badge.textContent = data.count + ' available';
if (items.length === 0) {
container.innerHTML = '<p style="font-size:13px;color:var(--g400);">No image models found' + (search ? ' matching "' + esc(search) + '"' : '') + '</p>';
return;
}
container.innerHTML = '<p style="font-size:12px;color:var(--g500);margin:0 0 6px;">Found ' + data.count + ' image model' + (data.count === 1 ? '' : 's') + '</p>' +
items.map(function(m) {
return '<div style="display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;">' +
'<button class="btn-sm btn-ghost admin-image-test-btn" type="button" data-id="' + esc(m.id) + '" style="padding:2px 8px;font-size:11px;">Test</button>' +
'<span style="flex:1;min-width:0;overflow-wrap:anywhere;">' + esc(m.name || m.id) + '</span>' +
'<span style="font-size:10px;color:var(--g400);">' + esc(m.source || '') + '</span>' +
imageAddButton(m.id) +
'</div>';
}).join('');
})
.catch(function(err) {
container.innerHTML = '<p style="font-size:13px;color:var(--red);">Request failed: ' + esc(err.message) + '</p>';
});
}
function currentImageRoster() {
return Array.isArray(window._assistantImageRoster) ? window._assistantImageRoster : [];
}
function imageAddButton(id) {
var added = currentImageRoster().indexOf(id) !== -1;
return added
? '<button class="btn-sm btn-ghost admin-image-add-btn" type="button" data-id="' + esc(id) + '" title="On the image roster. Press to remove." style="padding:2px 8px;font-size:11px;white-space:nowrap;"><i class="fas fa-check"></i> Added</button>'
: '<button class="btn-sm btn-primary admin-image-add-btn" type="button" data-id="' + esc(id) + '" title="Add to the image roster" style="padding:2px 8px;font-size:11px;white-space:nowrap;"><i class="fas fa-plus"></i> Add</button>';
}
// Rows rendered before the roster loaded (or after it changed) catch up here.
function syncImageRows() {
var container = document.getElementById('admin-discover-results');
if (!container) return;
container.querySelectorAll('.admin-image-add-btn').forEach(function(btn) { btn.outerHTML = imageAddButton(btn.dataset.id); });
}
// The roster used to be visible only as ticks under the Clinical Assistant
// and as an "Added" badge on a discovery row that had to be searched for
// again. Everything on the roster is listed here, with the way off it.
function renderImageRoster() {
var container = document.getElementById('admin-image-roster');
if (!container) return;
var roster = currentImageRoster();
container.replaceChildren();
if (!roster.length) {
var empty = document.createElement('p');
empty.className = 'admin-note';
empty.textContent = 'No image models added yet. Search for one under Discover & test and press + Add.';
container.appendChild(empty);
return;
}
roster.forEach(function(id) {
var row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:5px 8px;border-radius:6px;background:var(--g50);font-size:13px;';
var name = document.createElement('span');
name.style.cssText = 'flex:1;min-width:0;overflow-wrap:anywhere;';
name.textContent = id;
var remove = document.createElement('button');
remove.type = 'button';
remove.className = 'btn-sm admin-image-remove-btn';
remove.dataset.id = id;
remove.style.cssText = 'padding:2px 8px;font-size:11px;background:var(--red-light);color:var(--red);border:none;border-radius:4px;cursor:pointer;';
remove.textContent = 'Remove';
row.appendChild(name);
row.appendChild(remove);
container.appendChild(row);
});
}
function toggleImageRoster(id, btn) {
if (!id) return;
var roster = currentImageRoster();
var added = roster.indexOf(id) !== -1;
var next = added ? roster.filter(function(x) { return x !== id; }) : roster.concat([id]);
adminSetButtonText(btn, '...', true);
fetch('/api/admin/config/' + encodeURIComponent('clinical_assistant.image_model_roster'), {
method: 'PUT', headers: getAuthHeaders(), body: JSON.stringify({ value: next.join(',') })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) throw new Error(data.error || 'Could not update the image model list');
window._assistantImageRoster = next;
document.dispatchEvent(new CustomEvent('assistant-image-roster-changed', { detail: { roster: next.slice() } }));
syncImageRows();
renderImageRoster();
showToast(added ? id + ' removed from the image roster'
: id + ' added to the roster. Tick it under Availability to offer it to users.', 'success');
})
.catch(function(err) { syncImageRows(); renderImageRoster(); showToast(err.message || 'Request failed', 'error'); });
}
function testImageModel(modelId, btn) {
var id = String(modelId || '').trim();
var result = document.getElementById('admin-image-test-result');
if (!id) {
if (result) result.innerHTML = '<span style="color:var(--red);">Enter or pick a model id first.</span>';
return;
}
if (btn) adminSetButtonText(btn, '...', true);
if (result) result.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating with ' + esc(id) + '... this can take a minute.';
fetch('/api/admin/config/image-models/test', {
method: 'POST', headers: getAuthHeaders(), body: JSON.stringify({ modelId: id })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (btn) adminSetButtonText(btn, 'Test', false);
if (!data.success) {
if (result) result.innerHTML = '<span style="color:var(--red);">' + esc(data.error || 'Image test failed') + '</span>';
return;
}
var src = data.imageUrl || (data.base64 ? ('data:image/png;base64,' + data.base64) : '');
if (result) {
result.innerHTML = esc(id) + ' works (' + data.duration + ' ms).' +
(src ? '<div style="margin-top:8px;"><img src="' + esc(src) + '" alt="Test image generated by ' + esc(id) + '" style="max-width:180px;border:1px solid var(--g200);border-radius:8px;"></div>' : '');
}
})
.catch(function(err) {
if (btn) adminSetButtonText(btn, 'Test', false);
if (result) result.innerHTML = '<span style="color:var(--red);">Request failed: ' + esc(err.message) + '</span>';
});
}
}