pediatric-ai-scribe-v3/public/js/admin.js
ifedan-ed 04252c3015 v3.0.0: Auth, admin panel, security fixes, per-tab model selector
- Add authMiddleware to all AI/transcribe routes (were unauthenticated)
- Add full admin panel: user management, registration toggle, stats
- Fix XSS in email verification (escape user.name in HTML)
- Fix missing APP_URL fallback in password reset email
- Add per-tab model selector (respects OpenRouter/Bedrock/Azure lists)
- Fix transcribeAudio to send Authorization header
- Fix labs input: textarea instead of single-line input
- Add structured logging: audit_log, api_log, access_log tables
- Add admin CLI (admin-cli.js) for Docker exec management
- Fix duplicate var duration declaration in ai.js catch block
- Fix RETURNING check case-sensitivity in database.js
2026-03-21 19:25:51 -04:00

231 lines
9.5 KiB
JavaScript

// ============================================================
// ADMIN.JS — Admin panel: users, settings, stats
// ============================================================
(function() {
var loaded = false;
// Load admin panel when tab is clicked
document.addEventListener('click', function(e) {
var btn = e.target.closest('.tab-btn');
if (btn && btn.getAttribute('data-tab') === 'admin') {
if (!loaded) { loadAdmin(); loaded = true; }
}
// Refresh button
if (e.target.closest('#btn-refresh-users')) {
loadUsers();
}
// Toggle registration
if (e.target.closest('#btn-toggle-reg')) {
toggleRegistration();
}
});
function loadAdmin() {
loadSettings();
loadUsers();
}
// ---- 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 ----
function loadUsers() {
var tbody = document.getElementById('admin-users-body');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">Loading...</td></tr>';
fetch('/api/admin/users', { headers: getAuthHeaders() })
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--red);padding:20px;">Failed to load users</td></tr>'; return; }
renderUsers(data.users || []);
})
.catch(function() { tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--red);padding:20px;">Request failed</td></tr>'; });
}
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 = '<tr><td colspan="5" style="text-align:center;color:var(--g400);padding:20px;">No users found</td></tr>';
return;
}
tbody.innerHTML = users.map(function(u) {
var isSelf = currentUser.id && u.id === currentUser.id;
var roleColor = u.role === 'admin' ? 'var(--blue)' : '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() : '—';
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>');
}
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>';
}).join('');
// Bind action buttons
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 handleUserAction(action, userId, email, name) {
switch (action) {
case 'verify':
if (!confirm('Verify email for ' + email + '?')) return;
adminPost('/api/admin/users/' + userId + '/verify', {}, function() {
showToast(email + ' verified', 'success'); loadUsers();
});
break;
case 'disable':
if (!confirm('Disable account for ' + email + '?')) return;
adminPost('/api/admin/users/' + userId + '/disable', {}, function() {
showToast(email + ' disabled', 'info'); loadUsers();
});
break;
case 'enable':
adminPost('/api/admin/users/' + userId + '/enable', {}, function() {
showToast(email + ' enabled', 'success'); loadUsers();
});
break;
case 'promote':
if (!confirm('Grant admin role to ' + email + '?')) return;
adminPost('/api/admin/users/' + userId + '/role', { role: 'admin' }, function() {
showToast(email + ' is now admin', 'success'); loadUsers();
});
break;
case 'demote':
if (!confirm('Remove admin role from ' + email + '?')) return;
adminPost('/api/admin/users/' + userId + '/role', { role: 'user' }, function() {
showToast(email + ' demoted to user', 'info'); loadUsers();
});
break;
case 'reset-pw':
var newPw = prompt('New password for ' + email + ' (8+ characters):');
if (!newPw) return;
if (newPw.length < 8) { showToast('Password must be 8+ characters', 'error'); return; }
adminPost('/api/admin/users/' + userId + '/reset-password', { newPassword: newPw }, function() {
showToast('Password reset for ' + email, 'success');
});
break;
case 'delete':
if (!confirm('Permanently delete user ' + (name || email) + '?\n\nThis cannot be undone.')) return;
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'); });
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'); });
}
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
})();