const API = '/api'; let currentAdminId = ''; let usersPage = 0; const PAGE_SIZE = 50; let totalUsers = 0; /** Escape a string for safe embedding inside a JS string literal within an HTML attribute. * Converts all non-alphanumeric/space/dot/hyphen/underscore chars to \xHH escapes. */ function _escJs(s) { if (typeof s !== 'string') return ''; return s.replace(/[^\w .\-]/g, function(c) { return '\\x' + c.charCodeAt(0).toString(16).padStart(2, '0'); }); } function hideElement(id) { const element = document.getElementById(id); if (!element) return; element.classList.remove('show-block', 'show-flex'); element.classList.add('hidden'); } function showElement(id, mode = 'block') { const element = document.getElementById(id); if (!element) return; element.classList.remove('hidden', 'show-block', 'show-flex'); if (mode === 'flex') { element.classList.add('show-flex'); } else { element.classList.add('show-block'); } } function headers() { return { 'Content-Type': 'application/json', ...getCsrfHeaders() }; } function formatBytes(bytes) { if (bytes === 0) return '0 B'; const k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } function timeAgo(dateStr) { if (!dateStr) return 'Never'; const d = new Date(dateStr); const now = new Date(); const secs = Math.floor((now - d) / 1000); if (secs < 60) return 'Just now'; if (secs < 3600) return Math.floor(secs/60) + 'm ago'; if (secs < 86400) return Math.floor(secs/3600) + 'h ago'; if (secs < 2592000) return Math.floor(secs/86400) + 'd ago'; return d.toLocaleDateString(); } function switchTab(name, el) { document.querySelectorAll('.admin-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); document.getElementById('tab-' + name).classList.add('active'); if (el) el.classList.add('active'); if (name === 'users') loadUsers(); if (name === 'dashboard') loadDashboard(); } async function loadDashboard() { try { const resp = await fetch(API + '/admin/dashboard', { headers: headers(), credentials: 'same-origin' }); if (!resp.ok) return; const d = await resp.json(); document.getElementById('ds-total-users').textContent = d.total_users; document.getElementById('ds-active-users').textContent = d.active_users; document.getElementById('ds-admin-users').textContent = d.admin_users; document.getElementById('ds-version').textContent = 'v' + d.server_version; document.getElementById('ds-used').textContent = formatBytes(d.total_used_bytes); document.getElementById('ds-quota').textContent = formatBytes(d.total_quota_bytes); document.getElementById('ds-usage-pct').textContent = d.storage_usage_percent.toFixed(1) + '%'; const bar = document.getElementById('ds-bar'); bar.style.width = Math.min(d.storage_usage_percent, 100) + '%'; bar.className = 'progress-fill ' + (d.storage_usage_percent > 90 ? 'red' : d.storage_usage_percent > 70 ? 'orange' : 'green'); document.getElementById('ds-auth').textContent = d.auth_enabled ? 'Enabled' : 'Disabled'; document.getElementById('ds-oidc').textContent = d.oidc_configured ? 'Active' : 'Off'; document.getElementById('ds-quotas-flag').textContent = d.quotas_enabled ? 'Enabled' : 'Disabled'; if (typeof d.registration_enabled !== 'undefined') { document.getElementById('ds-registration').checked = d.registration_enabled; if (d.registration_enabled) hideElement('registration-warning'); else showElement('registration-warning', 'flex'); } if (d.users_over_80_percent > 0) { showElement('ds-warn-card'); document.getElementById('ds-over80').textContent = d.users_over_80_percent; } if (d.users_over_quota > 0) { showElement('ds-danger-card'); document.getElementById('ds-overquota').textContent = d.users_over_quota; } } catch (e) { console.error('Dashboard error', e); } } async function loadUsers() { const tbody = document.getElementById('users-tbody'); tbody.innerHTML = ' Loading…'; try { const resp = await fetch(API + '/admin/users?limit=' + PAGE_SIZE + '&offset=' + (usersPage * PAGE_SIZE), { headers: headers(), credentials: 'same-origin' }); if (!resp.ok) { tbody.innerHTML = ' Failed to load users'; return; } const data = await resp.json(); totalUsers = data.total; const users = data.users; if (users.length === 0) { tbody.innerHTML = 'No users found'; return; } tbody.innerHTML = users.map(u => { const quotaPct = u.storage_quota_bytes > 0 ? ((u.storage_used_bytes / u.storage_quota_bytes) * 100) : 0; const quotaColor = quotaPct > 90 ? 'red' : quotaPct > 70 ? 'orange' : 'green'; const quotaText = u.storage_quota_bytes > 0 ? formatBytes(u.storage_used_bytes) + ' / ' + formatBytes(u.storage_quota_bytes) : formatBytes(u.storage_used_bytes) + ' / ∞'; const isSelf = u.id === currentAdminId; const isOidc = u.auth_provider && u.auth_provider !== 'local'; const authBadge = isOidc ? ' ' + escapeHtml(u.auth_provider) + '' : 'Local'; return '' + '
' + escapeHtml(u.username) + (isSelf ? ' (you)' : '') + '' + escapeHtml(u.email) + '
' + '' + (u.role === 'admin' ? ' ' : '') + escapeHtml(u.role) + '' + '' + authBadge + '' + '' + (u.active ? 'Active' : 'Inactive') + '' + '
' + quotaText + '
' + '' + timeAgo(u.last_login_at) + '' + '
' + '' + (isOidc ? '' : '') + '' + '' + '' + '
'; }).join(''); // Set dynamic progress bar widths (CSP-safe via JS property) document.querySelectorAll('.progress-fill[data-width]').forEach(function(el) { el.style.width = el.dataset.width + '%'; el.removeAttribute('data-width'); }); // Wire up admin action buttons (replaces inline onclick handlers) document.querySelectorAll('.admin-action-btn').forEach(function(btn) { btn.addEventListener('click', function() { var action = btn.dataset.action; if (action === 'quota') openQuotaModal(btn.dataset.uid, btn.dataset.uname, Number(btn.dataset.quota)); else if (action === 'reset-pw') openResetPasswordModal(btn.dataset.uid, btn.dataset.uname); else if (action === 'toggle-role') toggleRole(btn.dataset.uid, btn.dataset.role); else if (action === 'toggle-active') toggleActive(btn.dataset.uid, btn.dataset.active === 'true'); else if (action === 'delete') deleteUser(btn.dataset.uid, btn.dataset.uname); }); }); document.getElementById('users-info').textContent = 'Showing ' + (usersPage * PAGE_SIZE + 1) + '-' + Math.min((usersPage + 1) * PAGE_SIZE, totalUsers) + ' of ' + totalUsers; document.getElementById('prev-btn').disabled = usersPage === 0; document.getElementById('next-btn').disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers; } catch (e) { tbody.innerHTML = ' Error: ' + escapeHtml(e.message) + ''; } } function prevPage() { if (usersPage > 0) { usersPage--; loadUsers(); } } function nextPage() { if ((usersPage + 1) * PAGE_SIZE < totalUsers) { usersPage++; loadUsers(); } } async function toggleRole(userId, currentRole) { const newRole = currentRole === 'admin' ? 'user' : 'admin'; if (!confirm('Change role to ' + newRole + '?')) return; try { const resp = await fetch(API + '/admin/users/' + userId + '/role', { method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ role: newRole }) }); if (resp.ok) loadUsers(); else { const e = await resp.json(); alert(e.message || 'Failed'); } } catch (e) { alert('Error: ' + e.message); } } async function toggleActive(userId, currentActive) { const action = currentActive ? 'deactivate' : 'activate'; if (!confirm('Are you sure you want to ' + action + ' this user?')) return; try { const resp = await fetch(API + '/admin/users/' + userId + '/active', { method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ active: !currentActive }) }); if (resp.ok) loadUsers(); else { const e = await resp.json(); alert(e.message || 'Failed'); } } catch (e) { alert('Error: ' + e.message); } } async function deleteUser(userId, username) { if (!confirm('DELETE user "' + username + '"? This cannot be undone!')) return; try { const resp = await fetch(API + '/admin/users/' + userId, { method: 'DELETE', headers: headers(), credentials: 'same-origin' }); if (resp.ok) { loadUsers(); loadDashboard(); } else { const e = await resp.json(); alert(e.message || 'Failed'); } } catch (e) { alert('Error: ' + e.message); } } let quotaUserId = ''; function openQuotaModal(userId, username, currentQuota) { quotaUserId = userId; document.getElementById('qm-username').textContent = username; const gb = currentQuota / 1073741824; document.getElementById('qm-unit').value = '1073741824'; document.getElementById('qm-value').value = gb > 0 ? Math.round(gb * 10) / 10 : 0; showElement('quota-modal', 'flex'); } function closeQuotaModal() { hideElement('quota-modal'); } async function saveQuota() { const val = parseFloat(document.getElementById('qm-value').value) || 0; const unit = parseInt(document.getElementById('qm-unit').value); const bytes = Math.round(val * unit); try { const resp = await fetch(API + '/admin/users/' + quotaUserId + '/quota', { method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ quota_bytes: bytes }) }); if (resp.ok) { closeQuotaModal(); loadUsers(); loadDashboard(); } else { const e = await resp.json(); alert(e.message || 'Failed'); } } catch (e) { alert('Error: ' + e.message); } } function openCreateUserModal() { document.getElementById('cu-username').value = ''; document.getElementById('cu-password').value = ''; document.getElementById('cu-email').value = ''; document.getElementById('cu-role').value = 'user'; document.getElementById('cu-quota-value').value = '1'; document.getElementById('cu-quota-unit').value = '1073741824'; document.getElementById('cu-error').className = 'alert'; document.getElementById('cu-error').textContent = ''; showElement('create-user-modal', 'flex'); setTimeout(() => document.getElementById('cu-username').focus(), 100); } function closeCreateUserModal() { hideElement('create-user-modal'); } async function submitCreateUser() { const username = document.getElementById('cu-username').value.trim(); const password = document.getElementById('cu-password').value; const email = document.getElementById('cu-email').value.trim() || null; const role = document.getElementById('cu-role').value; const quotaVal = parseFloat(document.getElementById('cu-quota-value').value) || 0; const quotaUnit = parseInt(document.getElementById('cu-quota-unit').value); const quotaBytes = Math.round(quotaVal * quotaUnit); const errorEl = document.getElementById('cu-error'); if (username.length < 3) { errorEl.textContent = 'Username must be at least 3 characters'; errorEl.className = 'alert alert-error'; return; } if (password.length < 8) { errorEl.textContent = 'Password must be at least 8 characters'; errorEl.className = 'alert alert-error'; return; } const btn = document.getElementById('cu-submit'); btn.disabled = true; btn.innerHTML = ' Creating…'; try { const resp = await fetch(API + '/admin/users', { method: 'POST', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ username, password, email, role, quota_bytes: quotaBytes }) }); if (resp.ok) { closeCreateUserModal(); loadUsers(); loadDashboard(); } else { const e = await resp.json().catch(() => ({})); errorEl.textContent = e.message || 'Failed to create user'; errorEl.className = 'alert alert-error'; } } catch (e) { errorEl.textContent = 'Network error: ' + e.message; errorEl.className = 'alert alert-error'; } btn.disabled = false; btn.innerHTML = ' Create'; } let resetPwUserId = ''; function openResetPasswordModal(userId, username) { resetPwUserId = userId; document.getElementById('rp-username').textContent = username; document.getElementById('rp-password').value = ''; document.getElementById('rp-error').className = 'alert'; document.getElementById('rp-error').textContent = ''; showElement('reset-pw-modal', 'flex'); setTimeout(() => document.getElementById('rp-password').focus(), 100); } function closeResetPasswordModal() { hideElement('reset-pw-modal'); } async function submitResetPassword() { const password = document.getElementById('rp-password').value; const errorEl = document.getElementById('rp-error'); if (password.length < 8) { errorEl.textContent = 'Password must be at least 8 characters'; errorEl.className = 'alert alert-error'; return; } const btn = document.getElementById('rp-submit'); btn.disabled = true; btn.innerHTML = ' Resetting…'; try { const resp = await fetch(API + '/admin/users/' + resetPwUserId + '/password', { method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ new_password: password }) }); if (resp.ok) { closeResetPasswordModal(); } else { const e = await resp.json().catch(() => ({})); errorEl.textContent = e.message || 'Failed'; errorEl.className = 'alert alert-error'; } } catch (e) { errorEl.textContent = 'Error: ' + e.message; errorEl.className = 'alert alert-error'; } btn.disabled = false; btn.innerHTML = ' Reset'; } async function toggleRegistration(enabled) { if (enabled) hideElement('registration-warning'); else showElement('registration-warning', 'flex'); try { const resp = await fetch(API + '/admin/settings/registration', { method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ registration_enabled: enabled }) }); if (!resp.ok) { document.getElementById('ds-registration').checked = !enabled; if (!enabled) showElement('registration-warning', 'flex'); else hideElement('registration-warning'); const e = await resp.json().catch(() => ({})); alert(e.message || 'Failed to update registration setting'); } } catch (e) { document.getElementById('ds-registration').checked = !enabled; if (!enabled) showElement('registration-warning', 'flex'); else hideElement('registration-warning'); alert('Error: ' + e.message); } } document.getElementById('oidc-enabled').addEventListener('change', function() { if (this.checked) showElement('oidc-form'); else hideElement('oidc-form'); }); document.getElementById('disable-password').addEventListener('change', function() { if (this.checked) showElement('password-warning', 'flex'); else hideElement('password-warning'); }); function showOidcStatus(msg, type) { const el = document.getElementById('oidc-status'); el.textContent = msg; el.className = 'alert alert-' + type; } function copyCallback() { const text = document.getElementById('callback-url').textContent; navigator.clipboard.writeText(text); } async function testConnection() { const url = document.getElementById('issuer-url').value.trim(); if (!url) { showOidcStatus('Enter an Issuer URL first', 'error'); return; } const btn = document.getElementById('discover-btn'); btn.disabled = true; btn.innerHTML = ' Discovering…'; const resultDiv = document.getElementById('discovery-result'); try { const resp = await fetch(API + '/admin/settings/oidc/test', { method: 'POST', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ issuer_url: url }) }); const r = await resp.json(); if (r.success) { resultDiv.innerHTML = '
' + escapeHtml(r.message) + '
Issuer
' + escapeHtml(r.issuer||'—') + '
Auth Endpoint
' + escapeHtml(r.authorization_endpoint||'—') + '
'; if (!document.getElementById('provider-name').value && r.provider_name_suggestion) document.getElementById('provider-name').value = r.provider_name_suggestion; } else { resultDiv.innerHTML = '
' + escapeHtml(r.message) + '
'; } } catch (e) { resultDiv.innerHTML = '
Error: ' + escapeHtml(e.message) + '
'; } btn.disabled = false; btn.innerHTML = ' Auto-discover'; } async function saveOidcSettings() { const btn = document.getElementById('save-btn'); btn.disabled = true; btn.innerHTML = ' Saving…'; const body = { enabled: document.getElementById('oidc-enabled').checked, issuer_url: document.getElementById('issuer-url').value.trim(), client_id: document.getElementById('client-id').value.trim(), client_secret: document.getElementById('client-secret').value || null, scopes: document.getElementById('scopes').value.trim() || null, auto_provision: document.getElementById('auto-provision').checked, admin_groups: document.getElementById('admin-groups').value.trim() || null, disable_password_login: document.getElementById('disable-password').checked, provider_name: document.getElementById('provider-name').value.trim() || null, }; try { const resp = await fetch(API + '/admin/settings/oidc', { method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify(body) }); if (resp.ok) { showOidcStatus('Settings saved — OIDC is now ' + (body.enabled ? 'active' : 'disabled'), 'success'); loadDashboard(); } else { const e = await resp.json().catch(()=>({})); showOidcStatus('Error: ' + (e.message || resp.statusText), 'error'); } } catch (e) { showOidcStatus('Network error: ' + e.message, 'error'); } btn.disabled = false; btn.innerHTML = ' Save'; } async function init() { try { const me = await fetch(API + '/auth/me', { headers: headers(), credentials: 'same-origin' }); if (!me.ok) { showAccessDenied(); return; } const user = await me.json(); if (user.role !== 'admin') { showAccessDenied(); return; } currentAdminId = user.id; const oidcResp = await fetch(API + '/admin/settings/oidc', { headers: headers(), credentials: 'same-origin' }); if (oidcResp.ok) { const s = await oidcResp.json(); document.getElementById('oidc-enabled').checked = s.enabled; if (s.enabled) showElement('oidc-form'); else hideElement('oidc-form'); document.getElementById('provider-name').value = s.provider_name || ''; document.getElementById('issuer-url').value = s.issuer_url || ''; document.getElementById('client-id').value = s.client_id || ''; document.getElementById('scopes').value = s.scopes || 'openid profile email'; document.getElementById('auto-provision').checked = s.auto_provision; document.getElementById('admin-groups').value = s.admin_groups || ''; document.getElementById('disable-password').checked = s.disable_password_login; if (s.disable_password_login) showElement('password-warning', 'flex'); else hideElement('password-warning'); document.getElementById('callback-url').textContent = s.callback_url; if (s.client_secret_set) showElement('secret-hint'); (s.env_overrides || []).forEach(field => { const badge = document.getElementById('badge-' + field); if (badge) badge.innerHTML = 'ENV'; }); } await loadDashboard(); hideElement('loading'); showElement('main-content'); } catch (e) { console.error(e); showAccessDenied(); } } function showAccessDenied() { hideElement('loading'); showElement('access-denied'); } init(); /* ── Event-listener wiring (replaces inline onclick/onchange) ── */ document.getElementById('tab-btn-dashboard').addEventListener('click', function(){ switchTab('dashboard', this); }); document.getElementById('tab-btn-users').addEventListener('click', function(){ switchTab('users', this); }); document.getElementById('tab-btn-oidc').addEventListener('click', function(){ switchTab('oidc', this); }); document.getElementById('ds-registration').addEventListener('change', function(){ toggleRegistration(this.checked); }); document.getElementById('btn-create-user').addEventListener('click', openCreateUserModal); document.getElementById('prev-btn').addEventListener('click', prevPage); document.getElementById('next-btn').addEventListener('click', nextPage); document.getElementById('discover-btn').addEventListener('click', testConnection); document.getElementById('btn-copy-callback').addEventListener('click', copyCallback); document.getElementById('btn-test-oidc').addEventListener('click', testConnection); document.getElementById('save-btn').addEventListener('click', saveOidcSettings); document.getElementById('btn-close-quota').addEventListener('click', closeQuotaModal); document.getElementById('btn-save-quota').addEventListener('click', saveQuota); document.getElementById('btn-close-create-user').addEventListener('click', closeCreateUserModal); document.getElementById('cu-submit').addEventListener('click', submitCreateUser); document.getElementById('btn-close-reset-pw').addEventListener('click', closeResetPasswordModal); document.getElementById('rp-submit').addEventListener('click', submitResetPassword);