import { getCsrfHeaders } from '../../core/csrf.js'; import { escapeHtml, formatRelativeTime } from '../../core/formatters.js'; import { i18n } from '../../core/i18n.js'; import { oxiIconsInit } from '../../core/icons.js'; /** * @import {RoleEnum} from '../../core/types.js' */ 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. * @param {string} s */ function _escJs(s) { if (typeof s !== 'string') return ''; return s.replace(/[^\w .-]/g, (c) => { return `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`; }); } /** @param {string} id */ function hideElement(id) { const element = document.getElementById(id); if (!element) return; element.classList.remove('show-block', 'show-flex'); element.classList.add('hidden'); } /** * @param {string} id * @param {string} [mode] */ 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() }; } /** @param {number} bytes */ 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 / k ** i).toFixed(1))} ${sizes[i]}`; } /** @param {string|null} dateStr */ function timeAgo(dateStr) { if (!dateStr) return i18n.t('admin.never'); return formatRelativeTime(dateStr); } /* ── Custom confirm modal ── */ /** @param {string} message */ function showConfirm(message) { return new Promise((resolve) => { const overlay = document.getElementById('confirm-modal'); const msgEl = document.getElementById('confirm-message'); const yesBtn = document.getElementById('confirm-yes'); const noBtn = document.getElementById('confirm-cancel'); msgEl.textContent = message; overlay.classList.remove('hidden'); overlay.classList.add('show-flex'); /** @param {any} result */ function cleanup(result) { overlay.classList.remove('show-flex'); overlay.classList.add('hidden'); yesBtn.removeEventListener('click', onYes); noBtn.removeEventListener('click', onNo); overlay.removeEventListener('click', onOverlay); resolve(result); } function onYes() { cleanup(true); } function onNo() { cleanup(false); } /** @param {Event} e */ function onOverlay(e) { if (e.target === overlay) cleanup(false); } yesBtn.addEventListener('click', onYes); noBtn.addEventListener('click', onNo); overlay.addEventListener('click', onOverlay); }); } /* ── Tab switching with fade animation ── */ let activeTabName = 'dashboard'; /** * @param {string} name * @param {Element|undefined} el */ function switchTab(name, el) { if (name === activeTabName) return; var oldTab = document.getElementById(`tab-${activeTabName}`); var newTab = document.getElementById(`tab-${name}`); document.querySelectorAll('.admin-tab').forEach((b) => { b.classList.remove('active'); }); if (el) el.classList.add('active'); // Fade-out old tab if (oldTab) { oldTab.classList.add('tab-fade-out'); oldTab.addEventListener('animationend', function handler() { oldTab.removeEventListener('animationend', handler); oldTab.classList.remove('active', 'tab-fade-out'); // Fade-in new tab if (newTab) { newTab.classList.add('active', 'tab-fade-in'); newTab.addEventListener('animationend', function handler2() { newTab.removeEventListener('animationend', handler2); newTab.classList.remove('tab-fade-in'); }); } }); } else if (newTab) { newTab.classList.add('active', 'tab-fade-in'); newTab.addEventListener('animationend', function handler2() { newTab.removeEventListener('animationend', handler2); newTab.classList.remove('tab-fade-in'); }); } activeTabName = name; // The migration auto-poll only makes sense while the Storage tab is // visible — without this it would keep hitting the API every 2 s // (and updating hidden DOM) for as long as a migration runs. if (name !== 'storage') stopMigrationPolling(); if (name === 'users') loadUsers(); if (name === 'dashboard') loadDashboard(); if (name === 'storage') loadStorage(); if (name === 'smtp') loadSmtp(); if (name === 'plugins') loadPlugins(); } 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 ? i18n.t('admin.enabled') : i18n.t('admin.disabled'); document.getElementById('ds-oidc').textContent = d.oidc_configured ? i18n.t('admin.active') : i18n.t('admin.off'); document.getElementById('ds-quotas-flag').textContent = d.quotas_enabled ? i18n.t('admin.enabled') : i18n.t('admin.disabled'); if (typeof d.registration_enabled !== 'undefined') { /** @type {HTMLInputElement} */ (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 = ` ${escapeHtml(i18n.t('admin.loading_users'))}`; 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 = ' ' + escapeHtml(i18n.t('admin.failed_load_users')) + ''; return; } const data = await resp.json(); totalUsers = data.total; const users = data.users; if (users.length === 0) { tbody.innerHTML = `${escapeHtml(i18n.t('admin.no_users_found'))}`; return; } tbody.innerHTML = users .map((/** @type {any} */ 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) + '' : `${escapeHtml(i18n.t('admin.local'))}`; return ( '' + '
' + escapeHtml(u.username || u.email || '—') + (isSelf ? ` ${escapeHtml(i18n.t('admin.you_badge'))}` : '') + '' + escapeHtml(u.email) + '
' + '' + (u.role === 'admin' ? ' ' : '') + escapeHtml(u.role) + '' + '' + authBadge + '' + '' + (u.active ? escapeHtml(i18n.t('admin.active')) : escapeHtml(i18n.t('admin.inactive'))) + '' + '
' + quotaText + '
' + '' + timeAgo(u.last_login_at) + '' + '
' + '' + (isOidc ? '' : '') + '' + '' + '' + '
' ); }) .join(''); // Set dynamic progress bar widths (CSP-safe via JS property) /** @type {NodeListOf} */ (document.querySelectorAll('.progress-fill[data-width]')).forEach((el) => { el.style.width = `${el.dataset.width}%`; el.removeAttribute('data-width'); }); // Wire up admin action buttons (replaces inline onclick handlers) /** @type {NodeListOf} */ (document.querySelectorAll('.admin-action-btn')).forEach((btn) => { btn.addEventListener('click', () => { const 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, /** @type {RoleEnum} */ (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); }); }); const from = usersPage * PAGE_SIZE + 1; const to = Math.min((usersPage + 1) * PAGE_SIZE, totalUsers); document.getElementById('users-info').textContent = i18n.t('admin.showing_users', { from: from, to: to, total: totalUsers }); /** @type {HTMLButtonElement} */ (document.getElementById('prev-btn')).disabled = usersPage === 0; /** @type {HTMLButtonElement} */ (document.getElementById('next-btn')).disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers; } catch (e) { tbody.innerHTML = ' ' + escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })) + ''; } } function prevPage() { if (usersPage > 0) { usersPage--; loadUsers(); } } function nextPage() { if ((usersPage + 1) * PAGE_SIZE < totalUsers) { usersPage++; loadUsers(); } } /** * @param {string} userId * @param {RoleEnum} currentRole */ async function toggleRole(userId, currentRole) { const newRole = currentRole === 'admin' ? 'user' : 'admin'; const ok = await showConfirm(i18n.t('admin.confirm_role_change', { role: newRole })); if (!ok) 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 || i18n.t('admin.error_generic')); } } catch (e) { alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })); } } /** * @param {string} userId * @param {boolean} currentActive */ async function toggleActive(userId, currentActive) { const msg = currentActive ? i18n.t('admin.confirm_deactivate') : i18n.t('admin.confirm_activate'); const ok = await showConfirm(msg); if (!ok) 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 || i18n.t('admin.error_generic')); } } catch (e) { alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })); } } /** * @param {string} userId * @param {string} username */ async function deleteUser(userId, username) { const ok = await showConfirm(i18n.t('admin.confirm_delete_user', { name: username })); if (!ok) 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 || i18n.t('admin.error_generic')); } } catch (e) { alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })); } } let quotaUserId = ''; /** * @param {string} userId * @param {string} username * @param {number} currentQuota */ function openQuotaModal(userId, username, currentQuota) { quotaUserId = userId; document.getElementById('qm-username').textContent = username; const gb = currentQuota / 1073741824; /** @type {HTMLInputElement} */ (document.getElementById('qm-unit')).value = '1073741824'; /** @type {HTMLInputElement} */ (document.getElementById('qm-value')).value = String(gb > 0 ? Math.round(gb * 10) / 10 : 0); showElement('quota-modal', 'flex'); } function closeQuotaModal() { hideElement('quota-modal'); } async function saveQuota() { const val = parseFloat(/** @type {HTMLInputElement} */ (document.getElementById('qm-value')).value) || 0; const unit = parseInt(/** @type {HTMLInputElement} */ (document.getElementById('qm-unit')).value, 10); 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 || i18n.t('admin.error_generic')); } } catch (e) { alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })); } } function openCreateUserModal() { /** @type {HTMLInputElement} */ (document.getElementById('cu-username')).value = ''; /** @type {HTMLInputElement} */ (document.getElementById('cu-password')).value = ''; /** @type {HTMLInputElement} */ (document.getElementById('cu-email')).value = ''; /** @type {HTMLInputElement} */ (document.getElementById('cu-role')).value = 'user'; /** @type {HTMLInputElement} */ (document.getElementById('cu-quota-value')).value = '1'; /** @type {HTMLInputElement} */ (document.getElementById('cu-quota-unit')).value = '1073741824'; document.getElementById('cu-error').className = 'alert'; document.getElementById('cu-error').textContent = ''; showElement('create-user-modal', 'flex'); setTimeout(() => /** @type {HTMLInputElement} */ (document.getElementById('cu-username')).focus(), 100); } function closeCreateUserModal() { hideElement('create-user-modal'); } async function submitCreateUser() { const username = /** @type {HTMLInputElement} */ (document.getElementById('cu-username')).value.trim(); const password = /** @type {HTMLInputElement} */ (document.getElementById('cu-password')).value; const email = /** @type {HTMLInputElement} */ (document.getElementById('cu-email')).value.trim() || null; const role = /** @type {HTMLInputElement} */ (document.getElementById('cu-role')).value; const quotaVal = parseFloat(/** @type {HTMLInputElement} */ (document.getElementById('cu-quota-value')).value) || 0; const quotaUnit = parseInt(/** @type {HTMLInputElement} */ (document.getElementById('cu-quota-unit')).value, 10); const quotaBytes = Math.round(quotaVal * quotaUnit); const errorEl = document.getElementById('cu-error'); if (username.length < 3) { errorEl.textContent = i18n.t('admin.error_username_short'); errorEl.className = 'alert alert-error'; return; } if (password.length < 8) { errorEl.textContent = i18n.t('admin.error_password_short'); errorEl.className = 'alert alert-error'; return; } const btn = /** @type {HTMLButtonElement} */ (document.getElementById('cu-submit')); btn.disabled = true; btn.innerHTML = ` ${escapeHtml(i18n.t('admin.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 || i18n.t('admin.error_create_user'); errorEl.className = 'alert alert-error'; } } catch (e) { errorEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }); errorEl.className = 'alert alert-error'; } btn.disabled = false; btn.innerHTML = ` ${escapeHtml(i18n.t('admin.create_user'))}`; } let resetPwUserId = ''; /** * @param {string} userId * @param {string} username */ function openResetPasswordModal(userId, username) { resetPwUserId = userId; document.getElementById('rp-username').textContent = username; /** @type {HTMLInputElement} */ (document.getElementById('rp-password')).value = ''; document.getElementById('rp-error').className = 'alert'; document.getElementById('rp-error').textContent = ''; showElement('reset-pw-modal', 'flex'); setTimeout(() => /** @type {HTMLInputElement} */ (document.getElementById('rp-password')).focus(), 100); } function closeResetPasswordModal() { hideElement('reset-pw-modal'); } async function submitResetPassword() { const password = /** @type {HTMLInputElement} */ (document.getElementById('rp-password')).value; const errorEl = document.getElementById('rp-error'); if (password.length < 8) { errorEl.textContent = i18n.t('admin.error_password_short'); errorEl.className = 'alert alert-error'; return; } const btn = /** @type {HTMLButtonElement} */ (document.getElementById('rp-submit')); btn.disabled = true; btn.innerHTML = ` ${escapeHtml(i18n.t('admin.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 || i18n.t('admin.error_generic'); errorEl.className = 'alert alert-error'; } } catch (e) { errorEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }); errorEl.className = 'alert alert-error'; } btn.disabled = false; btn.innerHTML = ` ${escapeHtml(i18n.t('admin.reset_btn'))}`; } /** @param {boolean} enabled */ 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) { /** @type {HTMLInputElement} */ (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 || i18n.t('admin.error_generic')); } } catch (e) { /** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = !enabled; if (!enabled) showElement('registration-warning', 'flex'); else hideElement('registration-warning'); alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })); } } document.getElementById('oidc-enabled').addEventListener('change', function () { if (/** @type {HTMLInputElement} */ (this).checked) showElement('oidc-form'); else hideElement('oidc-form'); }); document.getElementById('disable-password').addEventListener('change', function () { if (/** @type {HTMLInputElement} */ (this).checked) showElement('password-warning', 'flex'); else hideElement('password-warning'); }); /** * @param {string} msg * @param {string} type */ 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 = /** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value.trim(); if (!url) { showOidcStatus('Enter an Issuer URL first', 'error'); return; } const btn = /** @type {HTMLButtonElement} */ (document.getElementById('discover-btn')); btn.disabled = true; btn.innerHTML = ` ${escapeHtml(i18n.t('admin.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 (!(/** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value) && r.provider_name_suggestion) /** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value = r.provider_name_suggestion; } else { resultDiv.innerHTML = `
${escapeHtml(r.message)}
`; } } catch (e) { resultDiv.innerHTML = `
Error: ${escapeHtml(/** @type {Error} */ (e).message)}
`; } btn.disabled = false; btn.innerHTML = ` ${escapeHtml(i18n.t('admin.auto_discover'))}`; } async function saveOidcSettings() { const btn = /** @type {HTMLButtonElement} */ (document.getElementById('save-btn')); btn.disabled = true; btn.innerHTML = ` ${escapeHtml(i18n.t('admin.saving'))}`; const body = { enabled: /** @type {HTMLInputElement} */ (document.getElementById('oidc-enabled')).checked, issuer_url: /** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value.trim(), client_id: /** @type {HTMLInputElement} */ (document.getElementById('client-id')).value.trim(), client_secret: /** @type {HTMLInputElement} */ (document.getElementById('client-secret')).value || null, scopes: /** @type {HTMLInputElement} */ (document.getElementById('scopes')).value.trim() || null, auto_provision: /** @type {HTMLInputElement} */ (document.getElementById('auto-provision')).checked, admin_groups: /** @type {HTMLInputElement} */ (document.getElementById('admin-groups')).value.trim() || null, disable_password_login: /** @type {HTMLInputElement} */ (document.getElementById('disable-password')).checked, provider_name: /** @type {HTMLInputElement} */ (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) { const status = body.enabled ? i18n.t('admin.active').toLowerCase() : i18n.t('admin.disabled').toLowerCase(); showOidcStatus(i18n.t('admin.settings_saved', { status: status }), 'success'); loadDashboard(); } else { const e = await resp.json().catch(() => ({})); showOidcStatus(`Error: ${e.message || resp.statusText}`, 'error'); } } catch (e) { showOidcStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error'); } btn.disabled = false; btn.innerHTML = ` ${escapeHtml(i18n.t('admin.save_btn'))}`; } /* ── Storage tab ── */ // biome-ignore format: keep the following indent const STORAGE_PRESETS = { 'custom': { endpoint: '', region: '', pathStyle: false }, 'aws': { endpoint: '', region: 'us-east-1', pathStyle: false }, 'backblaze': { endpoint: 'https://s3.{region}.backblazeb2.com', region: 'us-west-004', pathStyle: false }, 'cloudflare-r2': { endpoint: 'https://{accountId}.r2.cloudflarestorage.com', region: 'auto', pathStyle: true }, 'minio': { endpoint: 'http://localhost:9000', region: 'us-east-1', pathStyle: true }, 'digitalocean': { endpoint: 'https://{region}.digitaloceanspaces.com', region: 'nyc3', pathStyle: false }, 'wasabi': { endpoint: 'https://s3.{region}.wasabisys.com', region: 'us-east-1', pathStyle: false }, }; /** @param {boolean} visible */ function toggleS3Form(visible) { if (visible) showElement('storage-s3-form'); else hideElement('storage-s3-form'); } function onStoragePresetChange() { const preset = /** @type {HTMLInputElement} */ (document.getElementById('storage-preset')).value; const p = STORAGE_PRESETS[/** @type {keyof typeof STORAGE_PRESETS} */ (preset)]; if (!p) return; if (p.endpoint) /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value = p.endpoint; if (p.region) /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value = p.region; /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked = p.pathStyle; } /** * @param {string} msg * @param {string} type */ function showStorageStatus(msg, type) { const el = document.getElementById('storage-status'); el.textContent = msg; el.className = `alert alert-${type}`; } async function loadStorage() { try { const resp = await fetch(`${API}/admin/settings/storage`, { headers: headers(), credentials: 'same-origin' }); if (!resp.ok) return; const s = await resp.json(); // Backend selector document.querySelectorAll('input[name="storage-backend"]').forEach((r) => { const input = /** @type {HTMLInputElement} */ (r); input.checked = input.value === s.backend; }); toggleS3Form(s.backend === 's3'); // S3 fields /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value = s.s3_endpoint_url || ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value = s.s3_bucket || ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value = s.s3_region || ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value = ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value = ''; /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked = s.s3_force_path_style; // Secret hints if (s.s3_access_key_set) { /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).placeholder = i18n.t('admin.storage_key_placeholder') || 'Leave empty to keep current value'; } if (s.s3_secret_key_set) { showElement('storage-secret-hint'); } else { hideElement('storage-secret-hint'); } // ENV badges /** @type {string[]} */ (s.env_overrides || []).forEach((field) => { const badge = document.getElementById(`badge-${field}`); if (badge) badge.innerHTML = 'ENV'; }); // Status section document.getElementById('storage-current-backend').textContent = s.current_backend || '—'; document.getElementById('storage-total-blobs').textContent = s.total_blobs != null ? s.total_blobs.toLocaleString() : '—'; document.getElementById('storage-total-size').textContent = s.total_bytes_stored != null ? formatBytes(s.total_bytes_stored) : '—'; document.getElementById('storage-dedup-ratio').textContent = s.dedup_ratio != null ? `${s.dedup_ratio.toFixed(2)}x` : '—'; } catch (e) { showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error'); } // Also load migration status loadMigrationStatus(); } async function saveStorageSettings() { const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-save-storage')); btn.disabled = true; btn.innerHTML = ` ${escapeHtml(i18n.t('admin.saving'))}`; const backend = /** @type {HTMLInputElement} */ (document.querySelector('input[name="storage-backend"]:checked')).value; const body = { backend, s3_endpoint_url: /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value.trim() || null, s3_bucket: /** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value.trim() || null, s3_region: /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value.trim() || null, s3_access_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value || null, s3_secret_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value || null, s3_force_path_style: /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked }; try { const resp = await fetch(`${API}/admin/settings/storage`, { method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify(body) }); if (resp.ok) { showStorageStatus(i18n.t('admin.storage_saved') || 'Storage settings saved successfully', 'success'); loadStorage(); } else { const e = await resp.json().catch(() => ({})); showStorageStatus(`Error: ${e.message || resp.statusText}`, 'error'); } } catch (e) { showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error'); } btn.disabled = false; btn.innerHTML = ` ${escapeHtml(i18n.t('admin.storage_save') || 'Save')}`; } async function testStorageConnection() { const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-test-storage')); btn.disabled = true; btn.innerHTML = ` ${escapeHtml(i18n.t('admin.testing') || 'Testing...')}`; const backend = /** @type {HTMLInputElement} */ (document.querySelector('input[name="storage-backend"]:checked')).value; const body = { backend, s3_endpoint_url: /** @type {HTMLInputElement} */ (document.getElementById('storage-endpoint-url')).value.trim() || null, s3_bucket: /** @type {HTMLInputElement} */ (document.getElementById('storage-bucket')).value.trim() || null, s3_region: /** @type {HTMLInputElement} */ (document.getElementById('storage-region')).value.trim() || null, s3_access_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).value || null, s3_secret_key: /** @type {HTMLInputElement} */ (document.getElementById('storage-secret-key')).value || null, s3_force_path_style: /** @type {HTMLInputElement} */ (document.getElementById('storage-path-style')).checked }; try { const resp = await fetch(`${API}/admin/settings/storage/test`, { method: 'POST', headers: headers(), credentials: 'same-origin', body: JSON.stringify(body) }); const r = await resp.json(); if (r.connected) { let msg = `${i18n.t('admin.storage_test_success') || 'Connection successful'} (${escapeHtml(r.backend_type)})`; if (r.available_bytes != null) msg += ` — ${formatBytes(r.available_bytes)} available`; showStorageStatus(msg, 'success'); } else { showStorageStatus(`${i18n.t('admin.storage_test_failure') || 'Connection failed'}: ${escapeHtml(r.message)}`, 'error'); } } catch (e) { showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error'); } btn.disabled = false; btn.innerHTML = ` ${escapeHtml(i18n.t('admin.storage_test_connection') || 'Test Connection')}`; } /* ── Migration ── */ /** @type {ReturnType | null} */ let migrationPollTimer = null; /** * Stop the 2 s migration auto-poll if it is armed. * * Called when the poll observes a non-running status, when a poll request * fails (an expired admin session would otherwise be retried every 2 s * forever), and when the user leaves the Storage tab. Re-entering the tab * re-arms it via `loadStorage()` → `loadMigrationStatus()` while a * migration is running. */ function stopMigrationPolling() { if (migrationPollTimer) { clearInterval(migrationPollTimer); migrationPollTimer = null; } } /** * @param {string} msg * @param {string} type */ function showMigrationMsg(msg, type) { const el = document.getElementById('migration-status-msg'); el.textContent = msg; el.className = `alert alert-${type}`; el.style.display = ''; } /** @param {any} m */ function updateMigrationUI(m) { // Status badge const badge = document.getElementById('migration-status-badge'); badge.textContent = (m.status || 'idle').charAt(0).toUpperCase() + (m.status || 'idle').slice(1); badge.className = `badge badge-migration badge-migration--${m.status || 'idle'}`; const isActive = m.status === 'running' || m.status === 'paused'; const isCompleted = m.status === 'completed'; // Progress section const progressSection = document.getElementById('migration-progress-section'); progressSection.style.display = isActive || isCompleted ? '' : 'none'; if (m.total_blobs > 0) { const pct = Math.round((m.migrated_blobs / m.total_blobs) * 100); document.getElementById('migration-progress-fill').style.width = `${pct}%`; document.getElementById('migration-progress-label').textContent = `${m.migrated_blobs.toLocaleString()} / ${m.total_blobs.toLocaleString()} blobs (${pct}%)`; document.getElementById('migration-bytes-label').textContent = `${formatBytes(m.migrated_bytes)} transferred`; if (m.throughput_bytes_per_sec && m.status === 'running') { document.getElementById('migration-throughput').textContent = `${formatBytes(Math.round(m.throughput_bytes_per_sec))}/s`; const remaining = m.total_blobs - m.migrated_blobs; if (remaining > 0 && m.throughput_bytes_per_sec > 0) { const avgBlobSize = m.migrated_bytes / Math.max(m.migrated_blobs, 1); const etaSecs = Math.round((remaining * avgBlobSize) / m.throughput_bytes_per_sec); const etaMin = Math.ceil(etaSecs / 60); document.getElementById('migration-eta').textContent = `~${etaMin} min remaining`; } } else { document.getElementById('migration-throughput').textContent = ''; document.getElementById('migration-eta').textContent = ''; } } // Failed blobs section const failedSection = document.getElementById('migration-failed-section'); if (m.failed_blobs && m.failed_blobs.length > 0) { failedSection.style.display = ''; document.getElementById('migration-failed-count').textContent = m.failed_blobs.length; document.getElementById('migration-failed-list').textContent = m.failed_blobs.join('\n'); } else { failedSection.style.display = 'none'; } // Button visibility document.getElementById('btn-start-migration').style.display = !isActive && !isCompleted ? '' : 'none'; document.getElementById('btn-pause-migration').style.display = m.status === 'running' ? '' : 'none'; document.getElementById('btn-resume-migration').style.display = m.status === 'paused' ? '' : 'none'; document.getElementById('btn-verify-migration').style.display = isCompleted ? '' : 'none'; document.getElementById('btn-complete-migration').style.display = isCompleted ? '' : 'none'; } async function loadMigrationStatus() { try { const resp = await fetch(`${API}/admin/storage/migration`, { headers: headers(), credentials: 'same-origin' }); if (!resp.ok) { // Don't keep hammering a failing endpoint (e.g. expired session); // any migration button or tab re-entry re-arms the poll. stopMigrationPolling(); return; } const m = await resp.json(); updateMigrationUI(m); // Auto-poll while running if (m.status === 'running') { if (!migrationPollTimer) { migrationPollTimer = setInterval(loadMigrationStatus, 2000); } } else { stopMigrationPolling(); } } catch (_e) { /* ignore */ } } async function startMigration() { const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-start-migration')); btn.disabled = true; try { const resp = await fetch(`${API}/admin/storage/migration/start`, { method: 'POST', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ concurrency: 4 }) }); if (resp.ok) { showMigrationMsg(i18n.t('admin.migration_started') || 'Migration started', 'success'); loadMigrationStatus(); } else { const e = await resp.json().catch(() => ({})); showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error'); } } catch (e) { showMigrationMsg(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error'); } btn.disabled = false; } async function pauseMigration() { try { const resp = await fetch(`${API}/admin/storage/migration/pause`, { method: 'POST', headers: headers(), credentials: 'same-origin' }); if (resp.ok) { showMigrationMsg(i18n.t('admin.migration_paused_msg') || 'Migration paused', 'success'); loadMigrationStatus(); } } catch (_e) { /* ignore */ } } async function resumeMigration() { try { const resp = await fetch(`${API}/admin/storage/migration/resume`, { method: 'POST', headers: headers(), credentials: 'same-origin' }); if (resp.ok) { showMigrationMsg(i18n.t('admin.migration_resumed_msg') || 'Migration resumed', 'success'); loadMigrationStatus(); } } catch (_e) { /* ignore */ } } async function verifyMigration() { const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-verify-migration')); btn.disabled = true; btn.innerHTML = ` ${escapeHtml(i18n.t('admin.migration_verifying') || 'Verifying...')}`; const resultDiv = document.getElementById('migration-verify-result'); try { const resp = await fetch(`${API}/admin/storage/migration/verify`, { method: 'POST', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ sample_size: 100 }) }); const r = await resp.json(); resultDiv.style.display = ''; if (r.passed) { resultDiv.innerHTML = `
${escapeHtml(i18n.t('admin.migration_verify_passed') || 'Verification passed')}

${r.sample_checked} blobs checked, ${r.pg_blob_count} total in database

`; } else { const issues = []; if (r.missing_in_target.length) issues.push(`${r.missing_in_target.length} missing`); if (r.size_mismatches.length) issues.push(`${r.size_mismatches.length} size mismatches`); resultDiv.innerHTML = `
${escapeHtml(i18n.t('admin.migration_verify_failed') || 'Verification failed')}

${issues.join(', ')}

`; } } catch (e) { resultDiv.style.display = ''; resultDiv.innerHTML = `
Error: ${escapeHtml(/** @type {Error} */ (e).message)}
`; } btn.disabled = false; btn.innerHTML = ` ${escapeHtml(i18n.t('admin.migration_verify') || 'Verify Integrity')}`; } async function completeMigration() { try { const resp = await fetch(`${API}/admin/storage/migration/complete`, { method: 'POST', headers: headers(), credentials: 'same-origin' }); if (resp.ok) { showMigrationMsg(i18n.t('admin.migration_completed_msg') || 'Migration finalized. Restart the server to use the new backend.', 'success'); loadMigrationStatus(); } else { const e = await resp.json().catch(() => ({})); showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error'); } } catch (e) { showMigrationMsg(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error'); } } async function init() { try { oxiIconsInit(); 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(); /** @type {HTMLInputElement} */ (document.getElementById('oidc-enabled')).checked = s.enabled; if (s.enabled) showElement('oidc-form'); else hideElement('oidc-form'); /** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value = s.provider_name || ''; /** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value = s.issuer_url || ''; /** @type {HTMLInputElement} */ (document.getElementById('client-id')).value = s.client_id || ''; /** @type {HTMLInputElement} */ (document.getElementById('scopes')).value = s.scopes || 'openid profile email'; /** @type {HTMLInputElement} */ (document.getElementById('auto-provision')).checked = s.auto_provision; /** @type {HTMLInputElement} */ (document.getElementById('admin-groups')).value = s.admin_groups || ''; /** @type {HTMLInputElement} */ (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'); /** @type {string[]} */ (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'); } /* ── SMTP tab ──────────────────────────────────────────────────────────── */ /** * Fetch the runtime SMTP info and render the read-only status grid. * Configuration is sourced exclusively from `OXICLOUD_SMTP_*` env vars; * this view is purely diagnostic — no save path exists. * * @returns {Promise} */ async function loadSmtp() { try { const resp = await fetch(`${API}/admin/smtp/info`, { headers: headers(), credentials: 'same-origin' }); if (!resp.ok) return; /** @type {{enabled: boolean, host: string, port: number, tls: string, from: string, user_state: string}} */ const info = await resp.json(); const enabledEl = document.getElementById('smtp-enabled'); if (enabledEl) { enabledEl.textContent = info.enabled ? i18n.t('admin.smtp_enabled') || 'Enabled' : i18n.t('admin.smtp_disabled') || 'Disabled (host unset)'; enabledEl.style.color = info.enabled ? 'var(--success)' : 'var(--text-muted)'; } const setText = (/** @type {string} */ id, /** @type {string} */ value) => { const el = document.getElementById(id); if (el) el.textContent = value || '—'; }; setText('smtp-host', info.host); setText('smtp-port', String(info.port)); setText('smtp-tls', info.tls); setText('smtp-from', info.from); setText('smtp-user-state', info.user_state); } catch (e) { console.error('Failed to load SMTP info', e); } } /** * Send a diagnostic test email through the configured SMTP relay. * Backend always responds with 200 carrying `{success, code, message, * error}` — SMTP-level failures are operational data, not HTTP errors. * * @returns {Promise} */ async function sendSmtpTest() { const input = /** @type {HTMLInputElement | null} */ (document.getElementById('smtp-test-to')); const resultEl = document.getElementById('smtp-test-result'); const btn = /** @type {HTMLButtonElement | null} */ (document.getElementById('btn-smtp-test')); if (!input || !resultEl) return; const to = input.value.trim(); if (!to) { resultEl.className = 'alert alert-error'; resultEl.style.display = 'block'; resultEl.textContent = i18n.t('admin.smtp_test_missing_to') || 'Enter a recipient address.'; return; } if (btn) btn.disabled = true; resultEl.className = 'alert alert-info'; resultEl.style.display = 'block'; resultEl.textContent = i18n.t('admin.smtp_sending') || 'Sending…'; try { const resp = await fetch(`${API}/admin/smtp/test`, { method: 'POST', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ to }) }); if (resp.status === 503) { resultEl.className = 'alert alert-error'; resultEl.textContent = i18n.t('admin.smtp_not_configured') || 'SMTP is not configured on this server.'; return; } if (!resp.ok) { resultEl.className = 'alert alert-error'; resultEl.textContent = `HTTP ${resp.status}: ${await resp.text()}`; return; } /** @type {{success: boolean, code?: number, message?: string, error?: string}} */ const data = await resp.json(); if (data.success) { resultEl.className = 'alert alert-success'; const codeLabel = i18n.t('admin.smtp_server_code') || 'Server replied'; resultEl.innerHTML = `${escapeHtml(i18n.t('admin.smtp_sent') || 'Test email sent.')}
` + `${escapeHtml(codeLabel)}: ${data.code ?? ''} ${escapeHtml(data.message ?? '')}`; } else { resultEl.className = 'alert alert-error'; const failLabel = i18n.t('admin.smtp_send_failed') || 'Send failed.'; resultEl.innerHTML = `${escapeHtml(failLabel)}
` + `${escapeHtml(data.error ?? 'unknown error')}`; } } catch (e) { resultEl.className = 'alert alert-error'; resultEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }); } finally { if (btn) btn.disabled = false; } } /* ── Plugins ── */ /** * @typedef {Object} PluginInfo * @property {string} id * @property {string} name * @property {string} version * @property {number} abi * @property {string[]} subscriptions * @property {boolean} enabled */ /** * Load installed plugins into the table. A 503 means plugins are disabled on * the server — show the explanatory banner instead of the management UI. */ async function loadPlugins() { const tbody = document.getElementById('plugins-tbody'); const disabledEl = document.getElementById('plugins-disabled'); const mainEl = document.getElementById('plugins-main'); if (!tbody || !disabledEl || !mainEl) return; try { const resp = await fetch(`${API}/admin/plugins`, { headers: headers(), credentials: 'same-origin' }); if (resp.status === 503) { disabledEl.classList.remove('hidden'); mainEl.classList.add('hidden'); return; } disabledEl.classList.add('hidden'); mainEl.classList.remove('hidden'); if (!resp.ok) { tbody.innerHTML = ` ${escapeHtml(`HTTP ${resp.status}`)}`; return; } /** @type {{enabled: boolean, plugins: PluginInfo[]}} */ const data = await resp.json(); renderPluginRows(data.plugins || []); } catch (e) { tbody.innerHTML = ` ${escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }))}`; } } /** @param {PluginInfo[]} plugins */ function renderPluginRows(plugins) { const tbody = document.getElementById('plugins-tbody'); if (!tbody) return; if (plugins.length === 0) { tbody.innerHTML = `${escapeHtml(i18n.t('admin.plugins_none') || 'No plugins installed.')}`; return; } tbody.innerHTML = plugins .map((p) => { const events = (p.subscriptions || []).map((ev) => `${escapeHtml(ev)}`).join(' ') || '—'; const statusLabel = p.enabled ? i18n.t('admin.plugins_enabled') || 'Enabled' : i18n.t('admin.plugins_disabled_badge') || 'Disabled'; const statusBadge = `${escapeHtml(statusLabel)}`; const toggleTitle = p.enabled ? i18n.t('admin.plugins_disable') || 'Disable' : i18n.t('admin.plugins_enable') || 'Enable'; const toggleBtn = ``; const deleteBtn = `'; return ( '' + `${escapeHtml(p.name)}` + `${escapeHtml(p.id)}` + `${escapeHtml(p.version)}` + `${events}` + `${statusBadge}` + `
${toggleBtn}${deleteBtn}
` + '' ); }) .join(''); /** @type {NodeListOf} */ (document.querySelectorAll('#plugins-tbody .plugin-action-btn')).forEach((btn) => { btn.addEventListener('click', () => { const action = btn.dataset.action; if (action === 'toggle') togglePlugin(btn.dataset.pid, btn.dataset.enabled !== 'true'); else if (action === 'delete') deletePlugin(btn.dataset.pid, btn.dataset.pname); }); }); } /** * @param {string|undefined} id * @param {boolean} enable */ async function togglePlugin(id, enable) { if (!id) return; try { const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/enabled`, { method: 'PUT', headers: headers(), credentials: 'same-origin', body: JSON.stringify({ enabled: enable }) }); if (resp.ok) { loadPlugins(); } else { const e = await resp.json().catch(() => ({})); alert(e.message || i18n.t('admin.error_generic')); } } catch (e) { alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })); } } /** * @param {string|undefined} id * @param {string|undefined} name */ async function deletePlugin(id, name) { if (!id) return; const ok = await showConfirm(i18n.t('admin.plugins_confirm_delete', { name: name || id })); if (!ok) return; try { const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}`, { method: 'DELETE', headers: headers(), credentials: 'same-origin' }); if (resp.ok) { loadPlugins(); } else { const e = await resp.json().catch(() => ({})); alert(e.message || i18n.t('admin.error_generic')); } } catch (e) { alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })); } } /** * Install a plugin from the selected .zip bundle. The multipart Content-Type * (with its boundary) is set by the browser — do not override it. */ async function installPlugin() { const bundleInput = /** @type {HTMLInputElement | null} */ (document.getElementById('plugin-bundle-file')); const btn = /** @type {HTMLButtonElement | null} */ (document.getElementById('btn-plugin-install')); const resultEl = document.getElementById('plugin-install-result'); if (!bundleInput || !resultEl) return; const bundleFile = bundleInput.files?.[0]; if (!bundleFile) { resultEl.className = 'alert alert-error'; resultEl.style.display = 'block'; resultEl.textContent = i18n.t('admin.plugins_install_missing_bundle') || 'Select a plugin bundle (.zip).'; return; } const form = new FormData(); form.append('bundle', bundleFile); if (btn) btn.disabled = true; resultEl.className = 'alert alert-info'; resultEl.style.display = 'block'; resultEl.textContent = i18n.t('admin.plugins_installing') || 'Installing…'; try { const resp = await fetch(`${API}/admin/plugins`, { method: 'POST', headers: { ...getCsrfHeaders() }, credentials: 'same-origin', body: form }); if (!resp.ok) { const e = await resp.json().catch(() => ({})); resultEl.className = 'alert alert-error'; resultEl.textContent = e.message || `HTTP ${resp.status}`; return; } /** @type {PluginInfo} */ const info = await resp.json(); resultEl.className = 'alert alert-success'; resultEl.textContent = i18n.t('admin.plugins_installed', { name: info.name }) || `Installed ${info.name}.`; bundleInput.value = ''; loadPlugins(); } catch (e) { resultEl.className = 'alert alert-error'; resultEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }); } finally { if (btn) btn.disabled = false; } } /* ── Apply i18n when translations load / change ── */ document.addEventListener('translationsLoaded', () => { i18n.translatePage(); // Re-render dynamic content that uses i18n.t() loadDashboard(); if (activeTabName === 'users') loadUsers(); }); document.addEventListener('localeChanged', () => { i18n.translatePage(); loadDashboard(); if (activeTabName === 'users') loadUsers(); }); 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('tab-btn-storage').addEventListener('click', function () { switchTab('storage', this); }); document.getElementById('tab-btn-smtp').addEventListener('click', function () { switchTab('smtp', this); }); document.getElementById('tab-btn-plugins').addEventListener('click', function () { switchTab('plugins', this); }); document.getElementById('btn-smtp-test').addEventListener('click', sendSmtpTest); document.getElementById('btn-plugin-install').addEventListener('click', installPlugin); document.getElementById('ds-registration').addEventListener('change', function () { toggleRegistration(/** @type {HTMLInputElement} */ (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); /* ── Storage event listeners ── */ document.querySelectorAll('input[name="storage-backend"]').forEach((r) => { r.addEventListener('change', () => { toggleS3Form(/** @type {HTMLInputElement} */ (r).value === 's3'); }); }); document.getElementById('storage-preset').addEventListener('change', onStoragePresetChange); document.getElementById('btn-test-storage').addEventListener('click', testStorageConnection); document.getElementById('btn-save-storage').addEventListener('click', saveStorageSettings); /* ── Migration event listeners ── */ document.getElementById('btn-start-migration').addEventListener('click', startMigration); document.getElementById('btn-pause-migration').addEventListener('click', pauseMigration); document.getElementById('btn-resume-migration').addEventListener('click', resumeMigration); document.getElementById('btn-verify-migration').addEventListener('click', verifyMigration); document.getElementById('btn-complete-migration').addEventListener('click', completeMigration);