ecbdaee19e
Three targeted frontend fixes: 1. pdf.js smart preload (thumbnail.js, resourceIcon.js). The first PDF thumbnail of a session stalled 1-2s on the lazy import of the ~1.3 MB pdf.js stack. buildResourceIcon() now fires thumbnail.preloadPdf() the moment a PDF row enters the DOM, warming both the module (~300 KB, via the now promise-memoized getPdfjsLib, shared with real users) and the worker script (~1 MB, via a cache-priming fetch — pdf.js only requests it on first getDocument). Only folders that actually contain PDFs pay the download; idempotent after first call, resets on failure so transient offline retries. 2. Admin migration poll cleanup (admin.js). The 2s setInterval kept hitting the API and updating hidden DOM after leaving the Storage tab, and polled a failing endpoint forever after session expiry (!resp.ok returned without clearing). New stopMigrationPolling() helper, invoked on tab switch away from Storage, on non-running status, and on failed polls; tab re-entry re-arms via loadStorage(). 3. resourceList.js render hoisting. Per-row i18n.t() type-cell lookups and the fully item-invariant _renderCustomActions() HTML were recomputed for every row; they now resolve once per batch via _buildItemLabels() (per-category labels memoized, rebuilt each batch so locale switches keep working). _findLaneByKey() swaps the container-wide attribute querySelector for an O(1) _lanes Map kept in sync at the only lane create/wipe sites. https://claude.ai/code/session_0193Hff42gaA962wThxMGSd1
1362 lines
59 KiB
JavaScript
1362 lines
59 KiB
JavaScript
import { getCsrfHeaders } from '../../core/csrf.js';
|
|
import { escapeHtml } 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');
|
|
const d = new Date(dateStr);
|
|
const now = new Date();
|
|
const secs = Math.floor((now.getTime() - d.getTime()) / 1000);
|
|
if (secs < 60) return i18n.t('admin.just_now');
|
|
if (secs < 3600) return i18n.t('admin.minutes_ago', { n: Math.floor(secs / 60) });
|
|
if (secs < 86400) return i18n.t('admin.hours_ago', { n: Math.floor(secs / 3600) });
|
|
if (secs < 2592000) return i18n.t('admin.days_ago', { n: Math.floor(secs / 86400) });
|
|
return d.toLocaleDateString();
|
|
}
|
|
|
|
/* ── 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();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
/* ── 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('btn-smtp-test').addEventListener('click', sendSmtpTest);
|
|
|
|
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);
|