Files
Oxicloud/static/js/views/admin/admin.js
T
Bradley Nelson 4427b1613b plugin logging
2026-06-16 23:00:23 -06:00

1910 lines
81 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
/* Plugin detail / log viewer state */
const PLUGIN_LOGS_PAGE_SIZE = 50;
/** @type {Record<string, PluginInfo>} */
let pluginsById = {};
/** @type {string|null} */
let pluginDetailId = null;
let pluginLogsPage = 0;
let pluginLogsTotal = 0;
/** @type {EventSource|null} */
let pluginLogStream = null;
/**
* 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();
// Leaving the plugins tab tears down the live log stream.
if (name !== 'plugins') stopLogStream();
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 = `<tr><td colspan="7" class="table-loading-cell"><i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.loading_users'))}</td></tr>`;
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 =
'<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ' +
escapeHtml(i18n.t('admin.failed_load_users')) +
'</td></tr>';
return;
}
const data = await resp.json();
totalUsers = data.total;
const users = data.users;
if (users.length === 0) {
tbody.innerHTML = `<tr><td colspan="7" class="table-status-empty">${escapeHtml(i18n.t('admin.no_users_found'))}</td></tr>`;
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
? '<span class="badge badge-oidc" title="Authenticated via ' +
escapeHtml(u.auth_provider) +
'"><i class="fas fa-key badge-admin-icon-small"></i> ' +
escapeHtml(u.auth_provider) +
'</span>'
: `<span class="badge badge-local">${escapeHtml(i18n.t('admin.local'))}</span>`;
return (
'<tr>' +
'<td><div class="user-info"><span class="user-name">' +
escapeHtml(u.username || u.email || '—') +
(isSelf ? ` <span class="user-self-badge">${escapeHtml(i18n.t('admin.you_badge'))}</span>` : '') +
'</span><span class="user-email">' +
escapeHtml(u.email) +
'</span></div></td>' +
'<td><span class="badge badge-' +
escapeHtml(u.role) +
'">' +
(u.role === 'admin' ? '<i class="fas fa-shield-alt badge-admin-icon-small"></i> ' : '') +
escapeHtml(u.role) +
'</span></td>' +
'<td>' +
authBadge +
'</td>' +
'<td><span class="badge badge-' +
(u.active ? 'active' : 'inactive') +
'">' +
(u.active ? escapeHtml(i18n.t('admin.active')) : escapeHtml(i18n.t('admin.inactive'))) +
'</span></td>' +
'<td><div class="quota-bar"><div class="progress-bar quota-progress-fixed"><div class="progress-fill ' +
quotaColor +
'" data-width="' +
Math.min(quotaPct, 100) +
'"></div></div><span class="quota-text">' +
quotaText +
'</span></div></td>' +
'<td class="user-last-login-cell">' +
timeAgo(u.last_login_at) +
'</td>' +
'<td><div class="actions-row">' +
'<button class="btn btn-sm btn-secondary admin-action-btn" data-action="quota" data-uid="' +
_escJs(u.id) +
'" data-uname="' +
_escJs(u.username || u.email) +
'" data-quota="' +
u.storage_quota_bytes +
'" title="' +
escapeHtml(i18n.t('admin.edit_quota_title')) +
'"><i class="fas fa-box"></i></button>' +
(isOidc
? ''
: '<button class="btn btn-sm btn-secondary admin-action-btn" data-action="reset-pw" data-uid="' +
_escJs(u.id) +
'" data-uname="' +
_escJs(u.username || u.email) +
'" title="' +
escapeHtml(i18n.t('admin.reset_password_title')) +
'"><i class="fas fa-key"></i></button>') +
'<button class="btn btn-sm btn-secondary admin-action-btn" data-action="toggle-role" data-uid="' +
_escJs(u.id) +
'" data-role="' +
_escJs(u.role) +
'" title="' +
escapeHtml(i18n.t('admin.toggle_role_title')) +
'"' +
(isSelf ? ' disabled' : '') +
'><i class="fas fa-' +
(u.role === 'admin' ? 'user' : 'crown') +
'"></i></button>' +
'<button class="btn btn-sm ' +
(u.active ? 'btn-danger' : 'btn-success') +
' admin-action-btn" data-action="toggle-active" data-uid="' +
_escJs(u.id) +
'" data-active="' +
u.active +
'" title="' +
(u.active ? escapeHtml(i18n.t('admin.deactivate_title')) : escapeHtml(i18n.t('admin.activate_title'))) +
'"' +
(isSelf && u.active ? ' disabled' : '') +
'><i class="fas fa-' +
(u.active ? 'ban' : 'check') +
'"></i></button>' +
'<button class="btn btn-sm btn-danger admin-action-btn" data-action="delete" data-uid="' +
_escJs(u.id) +
'" data-uname="' +
_escJs(u.username || u.email) +
'" title="' +
escapeHtml(i18n.t('admin.delete_title')) +
'"' +
(isSelf ? ' disabled' : '') +
'><i class="fas fa-trash-alt"></i></button>' +
'</div></td></tr>'
);
})
.join('');
// Set dynamic progress bar widths (CSP-safe via JS property)
/** @type {NodeListOf<HTMLDivElement>} */ (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<HTMLButtonElement>} */ (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 =
'<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ' +
escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })) +
'</td></tr>';
}
}
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 = `<i class="fas fa-spinner fa-spin"></i> ${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 = `<i class="fas fa-user-plus"></i> ${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 = `<i class="fas fa-spinner fa-spin"></i> ${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 = `<i class="fas fa-save"></i> ${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 = `<i class="fas fa-spinner fa-spin"></i> ${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 =
'<div class="discovery-result ok"><strong><i class="fas fa-check-circle"></i> ' +
escapeHtml(r.message) +
'</strong><dl><dt>Issuer</dt><dd>' +
escapeHtml(r.issuer || '—') +
'</dd><dt>Auth Endpoint</dt><dd>' +
escapeHtml(r.authorization_endpoint || '—') +
'</dd></dl></div>';
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 = `<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ${escapeHtml(r.message)}</strong></div>`;
}
} catch (e) {
resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(/** @type {Error} */ (e).message)}</div>`;
}
btn.disabled = false;
btn.innerHTML = `<i class="fas fa-search"></i> ${escapeHtml(i18n.t('admin.auto_discover'))}`;
}
async function saveOidcSettings() {
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('save-btn'));
btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${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 = `<i class="fas fa-save"></i> ${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 = '<span class="badge badge-env">ENV</span>';
});
// 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 = `<i class="fas fa-spinner fa-spin"></i> ${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 = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.storage_save') || 'Save')}`;
}
async function testStorageConnection() {
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-test-storage'));
btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${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 = `<i class="fas fa-vial"></i> ${escapeHtml(i18n.t('admin.storage_test_connection') || 'Test Connection')}`;
}
/* ── Migration ── */
/** @type {ReturnType<typeof setInterval> | 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 = `<i class="fas fa-spinner fa-spin"></i> ${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 = `<div class="discovery-result ok"><strong><i class="fas fa-check-circle"></i> ${escapeHtml(i18n.t('admin.migration_verify_passed') || 'Verification passed')}</strong><p>${r.sample_checked} blobs checked, ${r.pg_blob_count} total in database</p></div>`;
} 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 = `<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ${escapeHtml(i18n.t('admin.migration_verify_failed') || 'Verification failed')}</strong><p>${issues.join(', ')}</p></div>`;
}
} catch (e) {
resultDiv.style.display = '';
resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(/** @type {Error} */ (e).message)}</div>`;
}
btn.disabled = false;
btn.innerHTML = `<i class="fas fa-check-double"></i> ${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 = '<span class="badge badge-env">ENV</span>';
});
}
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<void>}
*/
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<void>}
*/
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 =
`<strong>${escapeHtml(i18n.t('admin.smtp_sent') || 'Test email sent.')}</strong><br>` +
`${escapeHtml(codeLabel)}: <code>${data.code ?? ''} ${escapeHtml(data.message ?? '')}</code>`;
} else {
resultEl.className = 'alert alert-error';
const failLabel = i18n.t('admin.smtp_send_failed') || 'Send failed.';
resultEl.innerHTML = `<strong>${escapeHtml(failLabel)}</strong><br>` + `<code>${escapeHtml(data.error ?? 'unknown error')}</code>`;
}
} 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;
// Always return to the list view (and stop any live tail) when (re)loading.
closePluginDetail();
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 = `<tr><td colspan="6" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(`HTTP ${resp.status}`)}</td></tr>`;
return;
}
/** @type {{enabled: boolean, plugins: PluginInfo[]}} */
const data = await resp.json();
renderPluginRows(data.plugins || []);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="6" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }))}</td></tr>`;
}
}
/** @param {PluginInfo[]} plugins */
function renderPluginRows(plugins) {
const tbody = document.getElementById('plugins-tbody');
if (!tbody) return;
pluginsById = {};
plugins.forEach((p) => {
pluginsById[p.id] = p;
});
if (plugins.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" class="table-status-empty">${escapeHtml(i18n.t('admin.plugins_none') || 'No plugins installed.')}</td></tr>`;
return;
}
tbody.innerHTML = plugins
.map((p) => {
const events = (p.subscriptions || []).map((ev) => `<code>${escapeHtml(ev)}</code>`).join(' ') || '—';
const statusLabel = p.enabled ? i18n.t('admin.plugins_enabled') || 'Enabled' : i18n.t('admin.plugins_disabled_badge') || 'Disabled';
const statusBadge = `<span class="badge badge-${p.enabled ? 'active' : 'inactive'}">${escapeHtml(statusLabel)}</span>`;
const toggleTitle = p.enabled ? i18n.t('admin.plugins_disable') || 'Disable' : i18n.t('admin.plugins_enable') || 'Enable';
const toggleBtn =
`<button class="btn btn-sm ${p.enabled ? 'btn-secondary' : 'btn-success'} plugin-action-btn" data-action="toggle" data-pid="${_escJs(p.id)}" data-enabled="${p.enabled}" title="${escapeHtml(toggleTitle)}">` +
`<i class="fas fa-${p.enabled ? 'pause' : 'play'}"></i></button>`;
const deleteBtn =
`<button class="btn btn-sm btn-danger plugin-action-btn" data-action="delete" data-pid="${_escJs(p.id)}" data-pname="${_escJs(p.name)}" title="${escapeHtml(i18n.t('admin.plugins_delete') || 'Delete')}">` +
'<i class="fas fa-trash-alt"></i></button>';
const detailsBtn =
`<button class="btn btn-sm btn-secondary plugin-action-btn" data-action="details" data-pid="${_escJs(p.id)}" title="${escapeHtml(i18n.t('admin.plugins_details') || 'Logs & details')}">` +
'<i class="fas fa-list"></i></button>';
return (
'<tr>' +
`<td>${escapeHtml(p.name)}</td>` +
`<td><code>${escapeHtml(p.id)}</code></td>` +
`<td>${escapeHtml(p.version)}</td>` +
`<td>${events}</td>` +
`<td>${statusBadge}</td>` +
`<td><div class="actions-row">${detailsBtn}${toggleBtn}${deleteBtn}</div></td>` +
'</tr>'
);
})
.join('');
/** @type {NodeListOf<HTMLButtonElement>} */ (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);
else if (action === 'details') openPluginDetail(btn.dataset.pid);
});
});
}
/**
* @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;
}
}
/* ── Plugin detail page (metadata + retention + logs + live tail) ── */
/**
* @typedef {Object} PluginLogEntry
* @property {string} ts
* @property {string} invocation_id
* @property {string} kind
* @property {string} level
* @property {string} [reason]
* @property {string} msg
*/
/**
* @typedef {Object} PluginLogPage
* @property {PluginLogEntry[]} entries
* @property {number} total
* @property {number} limit
* @property {number} offset
*/
/**
* Open the detail page for a plugin: metadata, retention form, and its logs
* (with a live tail). Hides the list view until the user goes back.
* @param {string|undefined} id
*/
function openPluginDetail(id) {
if (!id) return;
const plugin = pluginsById[id];
if (!plugin) return;
pluginDetailId = id;
pluginLogsPage = 0;
hideElement('plugins-main');
showElement('plugin-detail-view');
const nameEl = document.getElementById('plugin-detail-name');
if (nameEl) nameEl.textContent = plugin.name;
renderPluginMeta(plugin);
loadPluginRetention();
loadPluginLogs();
startLogStream(id);
}
/** Return to the installed-plugins list and stop the live stream. */
function closePluginDetail() {
stopLogStream();
pluginDetailId = null;
hideElement('plugin-detail-view');
showElement('plugins-main');
}
/** @param {PluginInfo} p */
function renderPluginMeta(p) {
const meta = document.getElementById('plugin-detail-meta');
if (!meta) return;
const events = (p.subscriptions || []).map((ev) => `<code>${escapeHtml(ev)}</code>`).join(' ') || '—';
const statusLabel = p.enabled ? i18n.t('admin.plugins_enabled') || 'Enabled' : i18n.t('admin.plugins_disabled_badge') || 'Disabled';
const statusBadge = `<span class="badge badge-${p.enabled ? 'active' : 'inactive'}">${escapeHtml(statusLabel)}</span>`;
/** @param {string} label @param {string} value */
const row = (label, value) => `<dt>${escapeHtml(label)}</dt><dd>${value}</dd>`;
meta.innerHTML =
row(i18n.t('admin.plugins_col_id') || 'ID', `<code>${escapeHtml(p.id)}</code>`) +
row(i18n.t('admin.plugins_col_version') || 'Version', escapeHtml(p.version)) +
row('ABI', escapeHtml(String(p.abi))) +
row(i18n.t('admin.plugins_col_events') || 'Events', events) +
row(i18n.t('admin.plugins_col_status') || 'Status', statusBadge);
}
/** Read the current log filter from the toolbar inputs. */
function pluginLogFilter() {
const level = /** @type {HTMLSelectElement|null} */ (document.getElementById('plugin-logs-level'))?.value || '';
const search = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-logs-search'))?.value || '';
return { level, search };
}
/** Load a page of the current plugin's logs into the table. */
async function loadPluginLogs() {
const id = pluginDetailId;
const tbody = document.getElementById('plugin-logs-tbody');
if (!id || !tbody) return;
const { level, search } = pluginLogFilter();
const params = new URLSearchParams();
params.set('limit', String(PLUGIN_LOGS_PAGE_SIZE));
params.set('offset', String(pluginLogsPage * PLUGIN_LOGS_PAGE_SIZE));
if (level) params.set('level', level);
if (search) params.set('search', search);
try {
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/logs?${params.toString()}`, {
headers: headers(),
credentials: 'same-origin'
});
if (!resp.ok) {
tbody.innerHTML = `<tr><td colspan="5" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(`HTTP ${resp.status}`)}</td></tr>`;
return;
}
/** @type {PluginLogPage} */
const page = await resp.json();
pluginLogsTotal = page.total;
renderPluginLogRows(page.entries || []);
updatePluginLogsPagination();
} catch (e) {
tbody.innerHTML = `<tr><td colspan="5" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ${escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }))}</td></tr>`;
}
}
/** @param {PluginLogEntry[]} entries */
function renderPluginLogRows(entries) {
const tbody = document.getElementById('plugin-logs-tbody');
if (!tbody) return;
if (entries.length === 0) {
tbody.innerHTML = `<tr><td colspan="5" class="table-status-empty">${escapeHtml(i18n.t('admin.plugins_logs_none') || 'No log entries.')}</td></tr>`;
return;
}
tbody.innerHTML = entries.map(pluginLogRowHtml).join('');
}
/** @param {PluginLogEntry} e */
function pluginLogRowHtml(e) {
const level = (e.level || 'info').toLowerCase();
const levelBadge = `<span class="plugin-log-level plugin-log-level--${escapeHtml(level)}">${escapeHtml(level)}</span>`;
const kind = e.kind === 'outcome' ? e.reason || 'outcome' : 'log';
return (
'<tr>' +
`<td class="plugin-log-ts">${escapeHtml(timeAgo(e.ts))}</td>` +
`<td>${levelBadge}</td>` +
`<td><code>${escapeHtml(kind)}</code></td>` +
`<td><code class="plugin-log-inv">${escapeHtml(e.invocation_id)}</code></td>` +
`<td class="plugin-log-msg">${escapeHtml(e.msg)}</td>` +
'</tr>'
);
}
function updatePluginLogsPagination() {
const info = document.getElementById('plugin-logs-info');
const prev = /** @type {HTMLButtonElement|null} */ (document.getElementById('plugin-logs-prev'));
const next = /** @type {HTMLButtonElement|null} */ (document.getElementById('plugin-logs-next'));
if (info) {
if (pluginLogsTotal === 0) {
info.textContent = i18n.t('admin.plugins_logs_none') || 'No log entries.';
} else {
const from = pluginLogsPage * PLUGIN_LOGS_PAGE_SIZE + 1;
const to = Math.min((pluginLogsPage + 1) * PLUGIN_LOGS_PAGE_SIZE, pluginLogsTotal);
info.textContent = i18n.t('admin.plugins_logs_showing', { from, to, total: pluginLogsTotal }) || `Showing ${from}–${to} of ${pluginLogsTotal}`;
}
}
if (prev) prev.disabled = pluginLogsPage === 0;
if (next) next.disabled = (pluginLogsPage + 1) * PLUGIN_LOGS_PAGE_SIZE >= pluginLogsTotal;
}
/**
* Open the SSE live tail for the given plugin.
* @param {string} id
*/
function startLogStream(id) {
stopLogStream();
const live = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-logs-live'));
if (live && !live.checked) return;
const es = new EventSource(`${API}/admin/plugins/${encodeURIComponent(id)}/logs/stream`, { withCredentials: true });
es.onmessage = (ev) => {
try {
/** @type {PluginLogEntry} */
const entry = JSON.parse(ev.data);
onLiveLogEntry(entry);
} catch {
/* ignore malformed frames */
}
};
es.addEventListener('lagged', () => {
// Fell behind the broadcast buffer — resync from the server.
loadPluginLogs();
});
pluginLogStream = es;
}
/** Close the live tail if open. */
function stopLogStream() {
if (pluginLogStream) {
pluginLogStream.close();
pluginLogStream = null;
}
}
/**
* Handle a streamed entry: only prepend when viewing the newest page and the
* entry passes the active filter, so the live tail never fights pagination.
* @param {PluginLogEntry} entry
*/
function onLiveLogEntry(entry) {
if (pluginLogsPage !== 0) return;
const { level, search } = pluginLogFilter();
if (level && (entry.level || '').toLowerCase() !== level.toLowerCase()) return;
if (search && !(entry.msg || '').toLowerCase().includes(search.toLowerCase())) return;
const tbody = document.getElementById('plugin-logs-tbody');
if (!tbody) return;
// Drop any "empty" placeholder row before inserting the first live entry.
const placeholder = tbody.querySelector('.table-status-empty');
if (placeholder) tbody.innerHTML = '';
tbody.insertAdjacentHTML('afterbegin', pluginLogRowHtml(entry));
const firstRow = tbody.firstElementChild;
if (firstRow) firstRow.classList.add('plugin-log-row--new');
// Keep the page bounded to one page-worth of rows.
while (tbody.children.length > PLUGIN_LOGS_PAGE_SIZE) {
const last = tbody.lastElementChild;
if (!last) break;
last.remove();
}
pluginLogsTotal += 1;
updatePluginLogsPagination();
}
/** Load the current plugin's retention into the form. */
async function loadPluginRetention() {
const id = pluginDetailId;
if (!id) return;
try {
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/retention`, {
headers: headers(),
credentials: 'same-origin'
});
if (!resp.ok) return;
/** @type {{retention_days: number, max_bytes: number}} */
const r = await resp.json();
const daysEl = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-retention-days'));
const mbEl = /** @type {HTMLInputElement|null} */ (document.getElementById('plugin-retention-max-mb'));
if (daysEl) daysEl.value = String(r.retention_days);
if (mbEl) mbEl.value = String(Math.round(r.max_bytes / (1024 * 1024)));
} catch {
/* leave fields as-is on error */
}
}
/** Persist the retention form for the current plugin. */
async function savePluginRetention() {
const id = pluginDetailId;
const resultEl = document.getElementById('plugin-retention-result');
if (!id || !resultEl) return;
const days = Number(/** @type {HTMLInputElement} */ (document.getElementById('plugin-retention-days')).value);
const mb = Number(/** @type {HTMLInputElement} */ (document.getElementById('plugin-retention-max-mb')).value);
if (!Number.isFinite(days) || days < 0 || !Number.isFinite(mb) || mb < 0) {
resultEl.className = 'alert alert-error';
resultEl.style.display = 'block';
resultEl.textContent = i18n.t('admin.plugins_retention_invalid') || 'Enter non-negative numbers.';
return;
}
try {
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/retention`, {
method: 'PUT',
headers: headers(),
credentials: 'same-origin',
body: JSON.stringify({ retention_days: Math.round(days), max_bytes: Math.round(mb) * 1024 * 1024 })
});
if (resp.ok) {
resultEl.className = 'alert alert-success';
resultEl.style.display = 'block';
resultEl.textContent = i18n.t('admin.plugins_retention_saved') || 'Retention saved.';
} else {
const e = await resp.json().catch(() => ({}));
resultEl.className = 'alert alert-error';
resultEl.style.display = 'block';
resultEl.textContent = e.message || `HTTP ${resp.status}`;
}
} catch (e) {
resultEl.className = 'alert alert-error';
resultEl.style.display = 'block';
resultEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message });
}
}
/** Clear all persisted logs for the current plugin. */
async function clearPluginLogs() {
const id = pluginDetailId;
if (!id) return;
const ok = await showConfirm(i18n.t('admin.plugins_logs_confirm_clear') || 'Clear all logs for this plugin?');
if (!ok) return;
try {
const resp = await fetch(`${API}/admin/plugins/${encodeURIComponent(id)}/logs`, {
method: 'DELETE',
headers: headers(),
credentials: 'same-origin'
});
if (resp.ok) {
pluginLogsPage = 0;
loadPluginLogs();
} 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 }));
}
}
function pluginLogsPrevPage() {
if (pluginLogsPage > 0) {
pluginLogsPage--;
loadPluginLogs();
}
}
function pluginLogsNextPage() {
if ((pluginLogsPage + 1) * PLUGIN_LOGS_PAGE_SIZE < pluginLogsTotal) {
pluginLogsPage++;
loadPluginLogs();
}
}
/* ── 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);
/* Plugin detail page controls */
document.getElementById('plugin-detail-back')?.addEventListener('click', closePluginDetail);
document.getElementById('plugin-retention-save')?.addEventListener('click', savePluginRetention);
document.getElementById('plugin-logs-refresh')?.addEventListener('click', loadPluginLogs);
document.getElementById('plugin-logs-clear')?.addEventListener('click', clearPluginLogs);
document.getElementById('plugin-logs-prev')?.addEventListener('click', pluginLogsPrevPage);
document.getElementById('plugin-logs-next')?.addEventListener('click', pluginLogsNextPage);
document.getElementById('plugin-logs-level')?.addEventListener('change', () => {
pluginLogsPage = 0;
loadPluginLogs();
});
let pluginLogsSearchTimer = 0;
document.getElementById('plugin-logs-search')?.addEventListener('input', () => {
window.clearTimeout(pluginLogsSearchTimer);
pluginLogsSearchTimer = window.setTimeout(() => {
pluginLogsPage = 0;
loadPluginLogs();
}, 250);
});
document.getElementById('plugin-logs-live')?.addEventListener('change', function () {
if (/** @type {HTMLInputElement} */ (this).checked) {
if (pluginDetailId) startLogStream(pluginDetailId);
} else {
stopLogStream();
}
});
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);