pediatric-ai-scribe-v3/public/js/admin.js
Daniel a505244b97
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
refactor: remove the embedding settings, whose only consumer is gone
Embeddings existed here for Learning Hub semantic search — the card said
so itself. Learning Hub was removed, and nothing took its place: the
clinical corpus is embedded by the indexing service, not by this app.
What was left was a settings page that configured a model, tested it,
reported its dimensions, and fed nothing.

src/utils/embeddings.js had exactly one importer, src/routes/adminConfig
.js, which used it for the three routes this deletes. Outside those, the
only mentions of embedding in the server were a comment and a settings
prefix.

Gone: the module, its three admin routes, the dimension probe, the
Discover & test kind and its two panels, the admin.js block behind them,
the embeddings. prefix from both the writable-settings allowlist and the
lockdown list (it can no longer be written at all, so locking it says
nothing), and docs/embeddings-setup.md, which documented Learning Hub
search end to end.

'embedding' stays in NON_CHAT_MODES — that is the filter keeping
embedding models out of the chat-model list, and the gateway still
serves them.

Docs still describe nine /api/learning endpoints that no longer exist,
left from the Learning Hub removal. Not touched here; that is its own
subject.

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

2125 lines
97 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);
loadWebSearch();
})
.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.
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']],
['learning', document.getElementById('cms-learning-prompts'), ['learning-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;">Set</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;">Set</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 Set: 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>';
});
}
}