2026-04-13 15:09:10 +02:00
|
|
|
import { getCsrfHeaders } from '../../core/csrf.js';
|
2026-06-15 00:17:17 +02:00
|
|
|
import { escapeHtml, formatRelativeTime } from '../../core/formatters.js';
|
2026-04-13 15:09:10 +02:00
|
|
|
import { i18n } from '../../core/i18n.js';
|
2026-05-07 13:54:59 +02:00
|
|
|
import { oxiIconsInit } from '../../core/icons.js';
|
2026-04-13 15:09:10 +02:00
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/**
|
|
|
|
|
* @import {RoleEnum} from '../../core/types.js'
|
|
|
|
|
*/
|
|
|
|
|
|
2026-02-20 12:27:52 +01:00
|
|
|
const API = '/api';
|
|
|
|
|
let currentAdminId = '';
|
|
|
|
|
let usersPage = 0;
|
|
|
|
|
const PAGE_SIZE = 50;
|
|
|
|
|
let totalUsers = 0;
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/**
|
|
|
|
|
* Escape a string for safe embedding inside a JS string literal within an HTML attribute.
|
|
|
|
|
* @param {string} s
|
|
|
|
|
*/
|
2026-03-03 01:44:39 +01:00
|
|
|
function _escJs(s) {
|
2026-04-07 22:48:59 +02:00
|
|
|
if (typeof s !== 'string') return '';
|
2026-04-07 22:50:42 +02:00
|
|
|
return s.replace(/[^\w .-]/g, (c) => {
|
|
|
|
|
return `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`;
|
2026-04-07 22:48:59 +02:00
|
|
|
});
|
2026-03-03 01:44:39 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @param {string} id */
|
2026-02-20 12:27:52 +01:00
|
|
|
function hideElement(id) {
|
2026-04-07 22:48:59 +02:00
|
|
|
const element = document.getElementById(id);
|
|
|
|
|
if (!element) return;
|
|
|
|
|
element.classList.remove('show-block', 'show-flex');
|
|
|
|
|
element.classList.add('hidden');
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/**
|
|
|
|
|
* @param {string} id
|
|
|
|
|
* @param {string} [mode]
|
|
|
|
|
*/
|
2026-02-20 12:27:52 +01:00
|
|
|
function showElement(id, mode = 'block') {
|
2026-04-07 22:48:59 +02:00
|
|
|
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');
|
|
|
|
|
}
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function headers() {
|
2026-04-07 22:48:59 +02:00
|
|
|
return { 'Content-Type': 'application/json', ...getCsrfHeaders() };
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @param {number} bytes */
|
2026-02-20 12:27:52 +01:00
|
|
|
function formatBytes(bytes) {
|
2026-04-07 22:48:59 +02:00
|
|
|
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));
|
2026-04-07 22:50:42 +02:00
|
|
|
return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @param {string|null} dateStr */
|
2026-02-20 12:27:52 +01:00
|
|
|
function timeAgo(dateStr) {
|
2026-04-25 23:19:36 +02:00
|
|
|
if (!dateStr) return i18n.t('admin.never');
|
2026-06-15 00:17:17 +02:00
|
|
|
return formatRelativeTime(dateStr);
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-03-09 00:08:34 +01:00
|
|
|
/* ── Custom confirm modal ── */
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @param {string} message */
|
2026-03-09 00:08:34 +01:00
|
|
|
function showConfirm(message) {
|
2026-04-07 22:50:42 +02:00
|
|
|
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');
|
2026-04-07 22:48:59 +02:00
|
|
|
msgEl.textContent = message;
|
|
|
|
|
overlay.classList.remove('hidden');
|
|
|
|
|
overlay.classList.add('show-flex');
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @param {any} result */
|
2026-04-07 22:48:59 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @param {Event} e */
|
2026-04-07 22:48:59 +02:00
|
|
|
function onOverlay(e) {
|
|
|
|
|
if (e.target === overlay) cleanup(false);
|
|
|
|
|
}
|
|
|
|
|
yesBtn.addEventListener('click', onYes);
|
|
|
|
|
noBtn.addEventListener('click', onNo);
|
|
|
|
|
overlay.addEventListener('click', onOverlay);
|
|
|
|
|
});
|
2026-03-09 00:08:34 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Tab switching with fade animation ── */
|
|
|
|
|
let activeTabName = 'dashboard';
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/**
|
|
|
|
|
* @param {string} name
|
|
|
|
|
* @param {Element|undefined} el
|
|
|
|
|
*/
|
2026-02-20 12:27:52 +01:00
|
|
|
function switchTab(name, el) {
|
2026-04-07 22:48:59 +02:00
|
|
|
if (name === activeTabName) return;
|
2026-04-07 22:50:42 +02:00
|
|
|
var oldTab = document.getElementById(`tab-${activeTabName}`);
|
|
|
|
|
var newTab = document.getElementById(`tab-${name}`);
|
2026-04-07 22:48:59 +02:00
|
|
|
|
2026-04-07 22:50:42 +02:00
|
|
|
document.querySelectorAll('.admin-tab').forEach((b) => {
|
2026-04-07 22:48:59 +02:00
|
|
|
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) {
|
2026-03-09 00:08:34 +01:00
|
|
|
newTab.classList.add('active', 'tab-fade-in');
|
|
|
|
|
newTab.addEventListener('animationend', function handler2() {
|
2026-04-07 22:48:59 +02:00
|
|
|
newTab.removeEventListener('animationend', handler2);
|
|
|
|
|
newTab.classList.remove('tab-fade-in');
|
2026-03-09 00:08:34 +01:00
|
|
|
});
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
2026-03-09 00:08:34 +01:00
|
|
|
|
2026-04-07 22:48:59 +02:00
|
|
|
activeTabName = name;
|
2026-06-11 09:58:46 +00:00
|
|
|
// 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();
|
2026-04-07 22:48:59 +02:00
|
|
|
if (name === 'users') loadUsers();
|
|
|
|
|
if (name === 'dashboard') loadDashboard();
|
2026-04-14 21:33:38 +02:00
|
|
|
if (name === 'storage') loadStorage();
|
2026-06-02 00:35:45 +02:00
|
|
|
if (name === 'smtp') loadSmtp();
|
2026-06-16 21:26:36 -06:00
|
|
|
if (name === 'plugins') loadPlugins();
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadDashboard() {
|
2026-04-07 22:48:59 +02:00
|
|
|
try {
|
2026-04-07 22:50:42 +02:00
|
|
|
const resp = await fetch(`${API}/admin/dashboard`, {
|
2026-04-07 22:48:59 +02:00
|
|
|
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;
|
2026-04-07 22:50:42 +02:00
|
|
|
document.getElementById('ds-version').textContent = `v${d.server_version}`;
|
2026-04-07 22:48:59 +02:00
|
|
|
document.getElementById('ds-used').textContent = formatBytes(d.total_used_bytes);
|
|
|
|
|
document.getElementById('ds-quota').textContent = formatBytes(d.total_quota_bytes);
|
2026-04-07 22:50:42 +02:00
|
|
|
document.getElementById('ds-usage-pct').textContent = `${d.storage_usage_percent.toFixed(1)}%`;
|
2026-04-07 22:48:59 +02:00
|
|
|
const bar = document.getElementById('ds-bar');
|
2026-04-07 22:50:42 +02:00
|
|
|
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'}`;
|
2026-04-25 23:19:36 +02:00
|
|
|
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');
|
2026-04-07 22:48:59 +02:00
|
|
|
|
|
|
|
|
if (typeof d.registration_enabled !== 'undefined') {
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = d.registration_enabled;
|
2026-04-07 22:48:59 +02:00
|
|
|
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);
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadUsers() {
|
2026-04-07 22:48:59 +02:00
|
|
|
const tbody = document.getElementById('users-tbody');
|
2026-04-25 23:19:36 +02:00
|
|
|
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>`;
|
2026-04-07 22:48:59 +02:00
|
|
|
try {
|
2026-04-07 22:50:42 +02:00
|
|
|
const resp = await fetch(`${API}/admin/users?limit=${PAGE_SIZE}&offset=${usersPage * PAGE_SIZE}`, {
|
2026-04-07 22:48:59 +02:00
|
|
|
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> ' +
|
2026-04-25 23:19:36 +02:00
|
|
|
escapeHtml(i18n.t('admin.failed_load_users')) +
|
2026-04-07 22:48:59 +02:00
|
|
|
'</td></tr>';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const data = await resp.json();
|
|
|
|
|
totalUsers = data.total;
|
|
|
|
|
const users = data.users;
|
|
|
|
|
if (users.length === 0) {
|
2026-04-25 23:19:36 +02:00
|
|
|
tbody.innerHTML = `<tr><td colspan="7" class="table-status-empty">${escapeHtml(i18n.t('admin.no_users_found'))}</td></tr>`;
|
2026-04-07 22:48:59 +02:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tbody.innerHTML = users
|
2026-05-07 23:40:02 +02:00
|
|
|
.map((/** @type {any} */ u) => {
|
2026-04-07 22:48:59 +02:00
|
|
|
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
|
2026-04-07 22:50:42 +02:00
|
|
|
? `${formatBytes(u.storage_used_bytes)} / ${formatBytes(u.storage_quota_bytes)}`
|
|
|
|
|
: `${formatBytes(u.storage_used_bytes)} / ∞`;
|
2026-04-07 22:48:59 +02:00
|
|
|
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>'
|
2026-04-25 23:19:36 +02:00
|
|
|
: `<span class="badge badge-local">${escapeHtml(i18n.t('admin.local'))}</span>`;
|
2026-04-07 22:48:59 +02:00
|
|
|
return (
|
|
|
|
|
'<tr>' +
|
|
|
|
|
'<td><div class="user-info"><span class="user-name">' +
|
2026-06-03 11:26:45 +02:00
|
|
|
escapeHtml(u.username || u.email || '—') +
|
2026-04-25 23:19:36 +02:00
|
|
|
(isSelf ? ` <span class="user-self-badge">${escapeHtml(i18n.t('admin.you_badge'))}</span>` : '') +
|
2026-04-07 22:48:59 +02:00
|
|
|
'</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') +
|
|
|
|
|
'">' +
|
2026-04-25 23:19:36 +02:00
|
|
|
(u.active ? escapeHtml(i18n.t('admin.active')) : escapeHtml(i18n.t('admin.inactive'))) +
|
2026-04-07 22:48:59 +02:00
|
|
|
'</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="' +
|
2026-06-03 11:26:45 +02:00
|
|
|
_escJs(u.username || u.email) +
|
2026-04-07 22:48:59 +02:00
|
|
|
'" data-quota="' +
|
|
|
|
|
u.storage_quota_bytes +
|
|
|
|
|
'" title="' +
|
2026-04-25 23:19:36 +02:00
|
|
|
escapeHtml(i18n.t('admin.edit_quota_title')) +
|
2026-04-07 22:48:59 +02:00
|
|
|
'"><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="' +
|
2026-06-03 11:26:45 +02:00
|
|
|
_escJs(u.username || u.email) +
|
2026-04-07 22:48:59 +02:00
|
|
|
'" title="' +
|
2026-04-25 23:19:36 +02:00
|
|
|
escapeHtml(i18n.t('admin.reset_password_title')) +
|
2026-04-07 22:48:59 +02:00
|
|
|
'"><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="' +
|
2026-04-25 23:19:36 +02:00
|
|
|
escapeHtml(i18n.t('admin.toggle_role_title')) +
|
2026-04-07 22:48:59 +02:00
|
|
|
'"' +
|
|
|
|
|
(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="' +
|
2026-04-25 23:19:36 +02:00
|
|
|
(u.active ? escapeHtml(i18n.t('admin.deactivate_title')) : escapeHtml(i18n.t('admin.activate_title'))) +
|
2026-04-07 22:48:59 +02:00
|
|
|
'"' +
|
|
|
|
|
(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="' +
|
2026-06-03 11:26:45 +02:00
|
|
|
_escJs(u.username || u.email) +
|
2026-04-07 22:48:59 +02:00
|
|
|
'" title="' +
|
2026-04-25 23:19:36 +02:00
|
|
|
escapeHtml(i18n.t('admin.delete_title')) +
|
2026-04-07 22:48:59 +02:00
|
|
|
'"' +
|
|
|
|
|
(isSelf ? ' disabled' : '') +
|
|
|
|
|
'><i class="fas fa-trash-alt"></i></button>' +
|
|
|
|
|
'</div></td></tr>'
|
|
|
|
|
);
|
|
|
|
|
})
|
|
|
|
|
.join('');
|
|
|
|
|
|
|
|
|
|
// Set dynamic progress bar widths (CSP-safe via JS property)
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @type {NodeListOf<HTMLDivElement>} */ (document.querySelectorAll('.progress-fill[data-width]')).forEach((el) => {
|
2026-04-07 22:50:42 +02:00
|
|
|
el.style.width = `${el.dataset.width}%`;
|
2026-04-07 22:48:59 +02:00
|
|
|
el.removeAttribute('data-width');
|
|
|
|
|
});
|
2026-03-05 16:18:30 -05:00
|
|
|
|
2026-04-07 22:48:59 +02:00
|
|
|
// Wire up admin action buttons (replaces inline onclick handlers)
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @type {NodeListOf<HTMLButtonElement>} */ (document.querySelectorAll('.admin-action-btn')).forEach((btn) => {
|
2026-04-07 22:50:42 +02:00
|
|
|
btn.addEventListener('click', () => {
|
|
|
|
|
const action = btn.dataset.action;
|
2026-04-07 22:48:59 +02:00
|
|
|
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);
|
2026-05-07 23:40:02 +02:00
|
|
|
else if (action === 'toggle-role') toggleRole(btn.dataset.uid, /** @type {RoleEnum} */ (btn.dataset.role));
|
2026-04-07 22:48:59 +02:00
|
|
|
else if (action === 'toggle-active') toggleActive(btn.dataset.uid, btn.dataset.active === 'true');
|
|
|
|
|
else if (action === 'delete') deleteUser(btn.dataset.uid, btn.dataset.uname);
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-03-05 16:18:30 -05:00
|
|
|
|
2026-04-07 22:48:59 +02:00
|
|
|
const from = usersPage * PAGE_SIZE + 1;
|
|
|
|
|
const to = Math.min((usersPage + 1) * PAGE_SIZE, totalUsers);
|
2026-04-25 23:19:36 +02:00
|
|
|
document.getElementById('users-info').textContent = i18n.t('admin.showing_users', { from: from, to: to, total: totalUsers });
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @type {HTMLButtonElement} */ (document.getElementById('prev-btn')).disabled = usersPage === 0;
|
|
|
|
|
/** @type {HTMLButtonElement} */ (document.getElementById('next-btn')).disabled = (usersPage + 1) * PAGE_SIZE >= totalUsers;
|
2026-04-07 22:48:59 +02:00
|
|
|
} catch (e) {
|
|
|
|
|
tbody.innerHTML =
|
|
|
|
|
'<tr><td colspan="7" class="table-status-error"><i class="fas fa-exclamation-circle"></i> ' +
|
2026-05-07 23:40:02 +02:00
|
|
|
escapeHtml(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message })) +
|
2026-04-07 22:48:59 +02:00
|
|
|
'</td></tr>';
|
|
|
|
|
}
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-07 22:48:59 +02:00
|
|
|
function prevPage() {
|
|
|
|
|
if (usersPage > 0) {
|
|
|
|
|
usersPage--;
|
|
|
|
|
loadUsers();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
function nextPage() {
|
|
|
|
|
if ((usersPage + 1) * PAGE_SIZE < totalUsers) {
|
|
|
|
|
usersPage++;
|
|
|
|
|
loadUsers();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-02-20 12:27:52 +01:00
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/**
|
|
|
|
|
* @param {string} userId
|
|
|
|
|
* @param {RoleEnum} currentRole
|
|
|
|
|
*/
|
2026-02-20 12:27:52 +01:00
|
|
|
async function toggleRole(userId, currentRole) {
|
2026-04-07 22:48:59 +02:00
|
|
|
const newRole = currentRole === 'admin' ? 'user' : 'admin';
|
2026-04-25 23:19:36 +02:00
|
|
|
const ok = await showConfirm(i18n.t('admin.confirm_role_change', { role: newRole }));
|
2026-04-07 22:48:59 +02:00
|
|
|
if (!ok) return;
|
|
|
|
|
try {
|
2026-04-07 22:50:42 +02:00
|
|
|
const resp = await fetch(`${API}/admin/users/${userId}/role`, {
|
2026-04-07 22:48:59 +02:00
|
|
|
method: 'PUT',
|
|
|
|
|
headers: headers(),
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
body: JSON.stringify({ role: newRole })
|
|
|
|
|
});
|
|
|
|
|
if (resp.ok) loadUsers();
|
|
|
|
|
else {
|
|
|
|
|
const e = await resp.json();
|
2026-04-25 23:19:36 +02:00
|
|
|
alert(e.message || i18n.t('admin.error_generic'));
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-05-07 23:40:02 +02:00
|
|
|
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/**
|
|
|
|
|
* @param {string} userId
|
|
|
|
|
* @param {boolean} currentActive
|
|
|
|
|
*/
|
2026-02-20 12:27:52 +01:00
|
|
|
async function toggleActive(userId, currentActive) {
|
2026-04-25 23:19:36 +02:00
|
|
|
const msg = currentActive ? i18n.t('admin.confirm_deactivate') : i18n.t('admin.confirm_activate');
|
2026-04-07 22:48:59 +02:00
|
|
|
const ok = await showConfirm(msg);
|
|
|
|
|
if (!ok) return;
|
|
|
|
|
try {
|
2026-04-07 22:50:42 +02:00
|
|
|
const resp = await fetch(`${API}/admin/users/${userId}/active`, {
|
2026-04-07 22:48:59 +02:00
|
|
|
method: 'PUT',
|
|
|
|
|
headers: headers(),
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
body: JSON.stringify({ active: !currentActive })
|
|
|
|
|
});
|
|
|
|
|
if (resp.ok) loadUsers();
|
|
|
|
|
else {
|
|
|
|
|
const e = await resp.json();
|
2026-04-25 23:19:36 +02:00
|
|
|
alert(e.message || i18n.t('admin.error_generic'));
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-05-07 23:40:02 +02:00
|
|
|
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/**
|
|
|
|
|
* @param {string} userId
|
|
|
|
|
* @param {string} username
|
|
|
|
|
*/
|
2026-02-20 12:27:52 +01:00
|
|
|
async function deleteUser(userId, username) {
|
2026-04-25 23:19:36 +02:00
|
|
|
const ok = await showConfirm(i18n.t('admin.confirm_delete_user', { name: username }));
|
2026-04-07 22:48:59 +02:00
|
|
|
if (!ok) return;
|
|
|
|
|
try {
|
2026-04-07 22:50:42 +02:00
|
|
|
const resp = await fetch(`${API}/admin/users/${userId}`, {
|
2026-04-07 22:48:59 +02:00
|
|
|
method: 'DELETE',
|
|
|
|
|
headers: headers(),
|
|
|
|
|
credentials: 'same-origin'
|
|
|
|
|
});
|
|
|
|
|
if (resp.ok) {
|
|
|
|
|
loadUsers();
|
|
|
|
|
loadDashboard();
|
|
|
|
|
} else {
|
|
|
|
|
const e = await resp.json();
|
2026-04-25 23:19:36 +02:00
|
|
|
alert(e.message || i18n.t('admin.error_generic'));
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-05-07 23:40:02 +02:00
|
|
|
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let quotaUserId = '';
|
2026-05-07 23:40:02 +02:00
|
|
|
/**
|
|
|
|
|
* @param {string} userId
|
|
|
|
|
* @param {string} username
|
|
|
|
|
* @param {number} currentQuota
|
|
|
|
|
*/
|
2026-02-20 12:27:52 +01:00
|
|
|
function openQuotaModal(userId, username, currentQuota) {
|
2026-04-07 22:48:59 +02:00
|
|
|
quotaUserId = userId;
|
|
|
|
|
document.getElementById('qm-username').textContent = username;
|
|
|
|
|
const gb = currentQuota / 1073741824;
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @type {HTMLInputElement} */ (document.getElementById('qm-unit')).value = '1073741824';
|
|
|
|
|
/** @type {HTMLInputElement} */ (document.getElementById('qm-value')).value = String(gb > 0 ? Math.round(gb * 10) / 10 : 0);
|
2026-04-07 22:48:59 +02:00
|
|
|
showElement('quota-modal', 'flex');
|
|
|
|
|
}
|
|
|
|
|
function closeQuotaModal() {
|
|
|
|
|
hideElement('quota-modal');
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveQuota() {
|
2026-05-07 23:40:02 +02:00
|
|
|
const val = parseFloat(/** @type {HTMLInputElement} */ (document.getElementById('qm-value')).value) || 0;
|
|
|
|
|
const unit = parseInt(/** @type {HTMLInputElement} */ (document.getElementById('qm-unit')).value, 10);
|
2026-04-07 22:48:59 +02:00
|
|
|
const bytes = Math.round(val * unit);
|
|
|
|
|
try {
|
2026-04-07 22:50:42 +02:00
|
|
|
const resp = await fetch(`${API}/admin/users/${quotaUserId}/quota`, {
|
2026-04-07 22:48:59 +02:00
|
|
|
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();
|
2026-04-25 23:19:36 +02:00
|
|
|
alert(e.message || i18n.t('admin.error_generic'));
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-05-07 23:40:02 +02:00
|
|
|
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openCreateUserModal() {
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @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';
|
2026-04-07 22:48:59 +02:00
|
|
|
document.getElementById('cu-error').className = 'alert';
|
|
|
|
|
document.getElementById('cu-error').textContent = '';
|
|
|
|
|
showElement('create-user-modal', 'flex');
|
2026-05-07 23:40:02 +02:00
|
|
|
setTimeout(() => /** @type {HTMLInputElement} */ (document.getElementById('cu-username')).focus(), 100);
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
|
|
|
|
function closeCreateUserModal() {
|
|
|
|
|
hideElement('create-user-modal');
|
|
|
|
|
}
|
2026-02-20 12:27:52 +01:00
|
|
|
|
|
|
|
|
async function submitCreateUser() {
|
2026-05-07 23:40:02 +02:00
|
|
|
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);
|
2026-04-07 22:48:59 +02:00
|
|
|
const quotaBytes = Math.round(quotaVal * quotaUnit);
|
|
|
|
|
|
|
|
|
|
const errorEl = document.getElementById('cu-error');
|
|
|
|
|
if (username.length < 3) {
|
2026-04-25 23:19:36 +02:00
|
|
|
errorEl.textContent = i18n.t('admin.error_username_short');
|
2026-04-07 22:48:59 +02:00
|
|
|
errorEl.className = 'alert alert-error';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (password.length < 8) {
|
2026-04-25 23:19:36 +02:00
|
|
|
errorEl.textContent = i18n.t('admin.error_password_short');
|
2026-04-07 22:48:59 +02:00
|
|
|
errorEl.className = 'alert alert-error';
|
|
|
|
|
return;
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
2026-04-07 22:48:59 +02:00
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('cu-submit'));
|
2026-04-07 22:48:59 +02:00
|
|
|
btn.disabled = true;
|
2026-04-25 23:19:36 +02:00
|
|
|
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.creating'))}`;
|
2026-04-07 22:48:59 +02:00
|
|
|
try {
|
2026-04-07 22:50:42 +02:00
|
|
|
const resp = await fetch(`${API}/admin/users`, {
|
2026-04-07 22:48:59 +02:00
|
|
|
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(() => ({}));
|
2026-04-25 23:19:36 +02:00
|
|
|
errorEl.textContent = e.message || i18n.t('admin.error_create_user');
|
2026-04-07 22:48:59 +02:00
|
|
|
errorEl.className = 'alert alert-error';
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-05-07 23:40:02 +02:00
|
|
|
errorEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message });
|
2026-04-07 22:48:59 +02:00
|
|
|
errorEl.className = 'alert alert-error';
|
|
|
|
|
}
|
|
|
|
|
btn.disabled = false;
|
2026-04-25 23:19:36 +02:00
|
|
|
btn.innerHTML = `<i class="fas fa-user-plus"></i> ${escapeHtml(i18n.t('admin.create_user'))}`;
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let resetPwUserId = '';
|
2026-05-07 23:40:02 +02:00
|
|
|
/**
|
|
|
|
|
* @param {string} userId
|
|
|
|
|
* @param {string} username
|
|
|
|
|
*/
|
2026-02-20 12:27:52 +01:00
|
|
|
function openResetPasswordModal(userId, username) {
|
2026-04-07 22:48:59 +02:00
|
|
|
resetPwUserId = userId;
|
|
|
|
|
document.getElementById('rp-username').textContent = username;
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @type {HTMLInputElement} */ (document.getElementById('rp-password')).value = '';
|
2026-04-07 22:48:59 +02:00
|
|
|
document.getElementById('rp-error').className = 'alert';
|
|
|
|
|
document.getElementById('rp-error').textContent = '';
|
|
|
|
|
showElement('reset-pw-modal', 'flex');
|
2026-05-07 23:40:02 +02:00
|
|
|
setTimeout(() => /** @type {HTMLInputElement} */ (document.getElementById('rp-password')).focus(), 100);
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
|
|
|
|
function closeResetPasswordModal() {
|
|
|
|
|
hideElement('reset-pw-modal');
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function submitResetPassword() {
|
2026-05-07 23:40:02 +02:00
|
|
|
const password = /** @type {HTMLInputElement} */ (document.getElementById('rp-password')).value;
|
2026-04-07 22:48:59 +02:00
|
|
|
const errorEl = document.getElementById('rp-error');
|
|
|
|
|
if (password.length < 8) {
|
2026-04-25 23:19:36 +02:00
|
|
|
errorEl.textContent = i18n.t('admin.error_password_short');
|
2026-04-07 22:48:59 +02:00
|
|
|
errorEl.className = 'alert alert-error';
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('rp-submit'));
|
2026-04-07 22:48:59 +02:00
|
|
|
btn.disabled = true;
|
2026-04-25 23:19:36 +02:00
|
|
|
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.resetting'))}`;
|
2026-04-07 22:48:59 +02:00
|
|
|
try {
|
2026-04-07 22:50:42 +02:00
|
|
|
const resp = await fetch(`${API}/admin/users/${resetPwUserId}/password`, {
|
2026-04-07 22:48:59 +02:00
|
|
|
method: 'PUT',
|
|
|
|
|
headers: headers(),
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
body: JSON.stringify({ new_password: password })
|
|
|
|
|
});
|
|
|
|
|
if (resp.ok) {
|
|
|
|
|
closeResetPasswordModal();
|
|
|
|
|
} else {
|
|
|
|
|
const e = await resp.json().catch(() => ({}));
|
2026-04-25 23:19:36 +02:00
|
|
|
errorEl.textContent = e.message || i18n.t('admin.error_generic');
|
2026-04-07 22:48:59 +02:00
|
|
|
errorEl.className = 'alert alert-error';
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-05-07 23:40:02 +02:00
|
|
|
errorEl.textContent = i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message });
|
2026-04-07 22:48:59 +02:00
|
|
|
errorEl.className = 'alert alert-error';
|
|
|
|
|
}
|
|
|
|
|
btn.disabled = false;
|
2026-04-25 23:19:36 +02:00
|
|
|
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.reset_btn'))}`;
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @param {boolean} enabled */
|
2026-02-20 12:27:52 +01:00
|
|
|
async function toggleRegistration(enabled) {
|
2026-04-07 22:48:59 +02:00
|
|
|
if (enabled) hideElement('registration-warning');
|
|
|
|
|
else showElement('registration-warning', 'flex');
|
|
|
|
|
try {
|
2026-04-07 22:50:42 +02:00
|
|
|
const resp = await fetch(`${API}/admin/settings/registration`, {
|
2026-04-07 22:48:59 +02:00
|
|
|
method: 'PUT',
|
|
|
|
|
headers: headers(),
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
body: JSON.stringify({ registration_enabled: enabled })
|
|
|
|
|
});
|
|
|
|
|
if (!resp.ok) {
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = !enabled;
|
2026-04-07 22:48:59 +02:00
|
|
|
if (!enabled) showElement('registration-warning', 'flex');
|
|
|
|
|
else hideElement('registration-warning');
|
|
|
|
|
const e = await resp.json().catch(() => ({}));
|
2026-04-25 23:19:36 +02:00
|
|
|
alert(e.message || i18n.t('admin.error_generic'));
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @type {HTMLInputElement} */ (document.getElementById('ds-registration')).checked = !enabled;
|
2026-04-07 22:48:59 +02:00
|
|
|
if (!enabled) showElement('registration-warning', 'flex');
|
|
|
|
|
else hideElement('registration-warning');
|
2026-05-07 23:40:02 +02:00
|
|
|
alert(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }));
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 22:48:59 +02:00
|
|
|
document.getElementById('oidc-enabled').addEventListener('change', function () {
|
2026-05-07 23:40:02 +02:00
|
|
|
if (/** @type {HTMLInputElement} */ (this).checked) showElement('oidc-form');
|
2026-04-07 22:48:59 +02:00
|
|
|
else hideElement('oidc-form');
|
2026-02-20 12:27:52 +01:00
|
|
|
});
|
2026-04-07 22:48:59 +02:00
|
|
|
document.getElementById('disable-password').addEventListener('change', function () {
|
2026-05-07 23:40:02 +02:00
|
|
|
if (/** @type {HTMLInputElement} */ (this).checked) showElement('password-warning', 'flex');
|
2026-04-07 22:48:59 +02:00
|
|
|
else hideElement('password-warning');
|
2026-02-20 12:27:52 +01:00
|
|
|
});
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/**
|
|
|
|
|
* @param {string} msg
|
|
|
|
|
* @param {string} type
|
|
|
|
|
*/
|
2026-02-20 12:27:52 +01:00
|
|
|
function showOidcStatus(msg, type) {
|
2026-04-07 22:48:59 +02:00
|
|
|
const el = document.getElementById('oidc-status');
|
|
|
|
|
el.textContent = msg;
|
2026-04-07 22:50:42 +02:00
|
|
|
el.className = `alert alert-${type}`;
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function copyCallback() {
|
2026-04-07 22:48:59 +02:00
|
|
|
const text = document.getElementById('callback-url').textContent;
|
|
|
|
|
navigator.clipboard.writeText(text);
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function testConnection() {
|
2026-05-07 23:40:02 +02:00
|
|
|
const url = /** @type {HTMLInputElement} */ (document.getElementById('issuer-url')).value.trim();
|
2026-04-07 22:48:59 +02:00
|
|
|
if (!url) {
|
|
|
|
|
showOidcStatus('Enter an Issuer URL first', 'error');
|
|
|
|
|
return;
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
2026-05-07 23:40:02 +02:00
|
|
|
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('discover-btn'));
|
2026-04-07 22:48:59 +02:00
|
|
|
btn.disabled = true;
|
2026-04-25 23:19:36 +02:00
|
|
|
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.discovering'))}`;
|
2026-04-07 22:48:59 +02:00
|
|
|
const resultDiv = document.getElementById('discovery-result');
|
|
|
|
|
try {
|
2026-04-07 22:50:42 +02:00
|
|
|
const resp = await fetch(`${API}/admin/settings/oidc/test`, {
|
2026-04-07 22:48:59 +02:00
|
|
|
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>';
|
2026-05-07 23:40:02 +02:00
|
|
|
if (!(/** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value) && r.provider_name_suggestion)
|
|
|
|
|
/** @type {HTMLInputElement} */ (document.getElementById('provider-name')).value = r.provider_name_suggestion;
|
2026-04-07 22:48:59 +02:00
|
|
|
} else {
|
2026-04-07 22:50:42 +02:00
|
|
|
resultDiv.innerHTML = `<div class="discovery-result fail"><strong><i class="fas fa-times-circle"></i> ${escapeHtml(r.message)}</strong></div>`;
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-05-07 23:40:02 +02:00
|
|
|
resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(/** @type {Error} */ (e).message)}</div>`;
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
|
|
|
|
btn.disabled = false;
|
2026-04-25 23:19:36 +02:00
|
|
|
btn.innerHTML = `<i class="fas fa-search"></i> ${escapeHtml(i18n.t('admin.auto_discover'))}`;
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveOidcSettings() {
|
2026-05-07 23:40:02 +02:00
|
|
|
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('save-btn'));
|
2026-04-07 22:48:59 +02:00
|
|
|
btn.disabled = true;
|
2026-04-25 23:19:36 +02:00
|
|
|
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.saving'))}`;
|
2026-04-07 22:48:59 +02:00
|
|
|
const body = {
|
2026-05-07 23:40:02 +02:00
|
|
|
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
|
2026-04-07 22:48:59 +02:00
|
|
|
};
|
|
|
|
|
try {
|
2026-04-07 22:50:42 +02:00
|
|
|
const resp = await fetch(`${API}/admin/settings/oidc`, {
|
2026-04-07 22:48:59 +02:00
|
|
|
method: 'PUT',
|
|
|
|
|
headers: headers(),
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
body: JSON.stringify(body)
|
|
|
|
|
});
|
|
|
|
|
if (resp.ok) {
|
2026-04-25 23:19:36 +02:00
|
|
|
const status = body.enabled ? i18n.t('admin.active').toLowerCase() : i18n.t('admin.disabled').toLowerCase();
|
|
|
|
|
showOidcStatus(i18n.t('admin.settings_saved', { status: status }), 'success');
|
2026-04-07 22:48:59 +02:00
|
|
|
loadDashboard();
|
|
|
|
|
} else {
|
|
|
|
|
const e = await resp.json().catch(() => ({}));
|
2026-04-07 22:50:42 +02:00
|
|
|
showOidcStatus(`Error: ${e.message || resp.statusText}`, 'error');
|
2026-04-07 22:48:59 +02:00
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-05-07 23:40:02 +02:00
|
|
|
showOidcStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
|
2026-03-09 00:08:34 +01:00
|
|
|
}
|
2026-04-07 22:48:59 +02:00
|
|
|
btn.disabled = false;
|
2026-04-25 23:19:36 +02:00
|
|
|
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.save_btn'))}`;
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-04-14 21:33:38 +02:00
|
|
|
/* ── Storage tab ── */
|
|
|
|
|
|
2026-04-15 00:15:53 +02:00
|
|
|
// biome-ignore format: keep the following indent
|
2026-04-14 21:33:38 +02:00
|
|
|
const STORAGE_PRESETS = {
|
2026-04-15 00:15:53 +02:00
|
|
|
'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 },
|
2026-04-14 21:33:38 +02:00
|
|
|
};
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @param {boolean} visible */
|
2026-04-14 21:33:38 +02:00
|
|
|
function toggleS3Form(visible) {
|
|
|
|
|
if (visible) showElement('storage-s3-form');
|
|
|
|
|
else hideElement('storage-s3-form');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onStoragePresetChange() {
|
2026-05-07 23:40:02 +02:00
|
|
|
const preset = /** @type {HTMLInputElement} */ (document.getElementById('storage-preset')).value;
|
|
|
|
|
const p = STORAGE_PRESETS[/** @type {keyof typeof STORAGE_PRESETS} */ (preset)];
|
2026-04-14 21:33:38 +02:00
|
|
|
if (!p) return;
|
2026-05-07 23:40:02 +02:00
|
|
|
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;
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/**
|
|
|
|
|
* @param {string} msg
|
|
|
|
|
* @param {string} type
|
|
|
|
|
*/
|
2026-04-14 21:33:38 +02:00
|
|
|
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) => {
|
2026-05-07 23:40:02 +02:00
|
|
|
const input = /** @type {HTMLInputElement} */ (r);
|
|
|
|
|
input.checked = input.value === s.backend;
|
2026-04-14 21:33:38 +02:00
|
|
|
});
|
|
|
|
|
toggleS3Form(s.backend === 's3');
|
|
|
|
|
|
|
|
|
|
// S3 fields
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @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;
|
2026-04-14 21:33:38 +02:00
|
|
|
|
|
|
|
|
// Secret hints
|
|
|
|
|
if (s.s3_access_key_set) {
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @type {HTMLInputElement} */ (document.getElementById('storage-access-key')).placeholder =
|
|
|
|
|
i18n.t('admin.storage_key_placeholder') || 'Leave empty to keep current value';
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
if (s.s3_secret_key_set) {
|
|
|
|
|
showElement('storage-secret-hint');
|
|
|
|
|
} else {
|
|
|
|
|
hideElement('storage-secret-hint');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ENV badges
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @type {string[]} */ (s.env_overrides || []).forEach((field) => {
|
2026-04-14 21:33:38 +02:00
|
|
|
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) {
|
2026-05-07 23:40:02 +02:00
|
|
|
showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Also load migration status
|
|
|
|
|
loadMigrationStatus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveStorageSettings() {
|
2026-05-07 23:40:02 +02:00
|
|
|
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-save-storage'));
|
2026-04-14 21:33:38 +02:00
|
|
|
btn.disabled = true;
|
2026-04-25 23:19:36 +02:00
|
|
|
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.saving'))}`;
|
2026-04-14 21:33:38 +02:00
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
const backend = /** @type {HTMLInputElement} */ (document.querySelector('input[name="storage-backend"]:checked')).value;
|
2026-04-14 21:33:38 +02:00
|
|
|
const body = {
|
|
|
|
|
backend,
|
2026-05-07 23:40:02 +02:00
|
|
|
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
|
2026-04-14 21:33:38 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(`${API}/admin/settings/storage`, {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
headers: headers(),
|
|
|
|
|
credentials: 'same-origin',
|
|
|
|
|
body: JSON.stringify(body)
|
|
|
|
|
});
|
|
|
|
|
if (resp.ok) {
|
2026-04-25 23:19:36 +02:00
|
|
|
showStorageStatus(i18n.t('admin.storage_saved') || 'Storage settings saved successfully', 'success');
|
2026-04-14 21:33:38 +02:00
|
|
|
loadStorage();
|
|
|
|
|
} else {
|
|
|
|
|
const e = await resp.json().catch(() => ({}));
|
|
|
|
|
showStorageStatus(`Error: ${e.message || resp.statusText}`, 'error');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-05-07 23:40:02 +02:00
|
|
|
showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
btn.disabled = false;
|
2026-04-25 23:19:36 +02:00
|
|
|
btn.innerHTML = `<i class="fas fa-save"></i> ${escapeHtml(i18n.t('admin.storage_save') || 'Save')}`;
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function testStorageConnection() {
|
2026-05-07 23:40:02 +02:00
|
|
|
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-test-storage'));
|
2026-04-14 21:33:38 +02:00
|
|
|
btn.disabled = true;
|
2026-04-25 23:19:36 +02:00
|
|
|
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.testing') || 'Testing...')}`;
|
2026-04-14 21:33:38 +02:00
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
const backend = /** @type {HTMLInputElement} */ (document.querySelector('input[name="storage-backend"]:checked')).value;
|
2026-04-14 21:33:38 +02:00
|
|
|
const body = {
|
|
|
|
|
backend,
|
2026-05-07 23:40:02 +02:00
|
|
|
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
|
2026-04-14 21:33:38 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-04-25 23:19:36 +02:00
|
|
|
let msg = `${i18n.t('admin.storage_test_success') || 'Connection successful'} (${escapeHtml(r.backend_type)})`;
|
2026-04-14 21:33:38 +02:00
|
|
|
if (r.available_bytes != null) msg += ` — ${formatBytes(r.available_bytes)} available`;
|
|
|
|
|
showStorageStatus(msg, 'success');
|
|
|
|
|
} else {
|
2026-04-25 23:19:36 +02:00
|
|
|
showStorageStatus(`${i18n.t('admin.storage_test_failure') || 'Connection failed'}: ${escapeHtml(r.message)}`, 'error');
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-05-07 23:40:02 +02:00
|
|
|
showStorageStatus(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
btn.disabled = false;
|
2026-04-25 23:19:36 +02:00
|
|
|
btn.innerHTML = `<i class="fas fa-vial"></i> ${escapeHtml(i18n.t('admin.storage_test_connection') || 'Test Connection')}`;
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ── Migration ── */
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @type {ReturnType<typeof setInterval> | null} */
|
2026-04-14 21:33:38 +02:00
|
|
|
let migrationPollTimer = null;
|
|
|
|
|
|
2026-06-11 09:58:46 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/**
|
|
|
|
|
* @param {string} msg
|
|
|
|
|
* @param {string} type
|
|
|
|
|
*/
|
2026-04-14 21:33:38 +02:00
|
|
|
function showMigrationMsg(msg, type) {
|
|
|
|
|
const el = document.getElementById('migration-status-msg');
|
|
|
|
|
el.textContent = msg;
|
|
|
|
|
el.className = `alert alert-${type}`;
|
|
|
|
|
el.style.display = '';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @param {any} m */
|
2026-04-14 21:33:38 +02:00
|
|
|
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');
|
2026-04-15 00:15:53 +02:00
|
|
|
progressSection.style.display = isActive || isCompleted ? '' : 'none';
|
2026-04-14 21:33:38 +02:00
|
|
|
|
|
|
|
|
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}%)`;
|
2026-04-15 00:15:53 +02:00
|
|
|
document.getElementById('migration-bytes-label').textContent = `${formatBytes(m.migrated_bytes)} transferred`;
|
2026-04-14 21:33:38 +02:00
|
|
|
|
|
|
|
|
if (m.throughput_bytes_per_sec && m.status === 'running') {
|
2026-04-15 00:15:53 +02:00
|
|
|
document.getElementById('migration-throughput').textContent = `${formatBytes(Math.round(m.throughput_bytes_per_sec))}/s`;
|
2026-04-14 21:33:38 +02:00
|
|
|
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);
|
2026-04-15 00:15:53 +02:00
|
|
|
document.getElementById('migration-eta').textContent = `~${etaMin} min remaining`;
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
} 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
|
2026-04-15 00:15:53 +02:00
|
|
|
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';
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadMigrationStatus() {
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(`${API}/admin/storage/migration`, {
|
|
|
|
|
headers: headers(),
|
|
|
|
|
credentials: 'same-origin'
|
|
|
|
|
});
|
2026-06-11 09:58:46 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-04-14 21:33:38 +02:00
|
|
|
const m = await resp.json();
|
|
|
|
|
updateMigrationUI(m);
|
|
|
|
|
|
|
|
|
|
// Auto-poll while running
|
|
|
|
|
if (m.status === 'running') {
|
|
|
|
|
if (!migrationPollTimer) {
|
|
|
|
|
migrationPollTimer = setInterval(loadMigrationStatus, 2000);
|
|
|
|
|
}
|
2026-06-11 09:58:46 +00:00
|
|
|
} else {
|
|
|
|
|
stopMigrationPolling();
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
2026-04-15 00:15:53 +02:00
|
|
|
} catch (_e) {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function startMigration() {
|
2026-05-07 23:40:02 +02:00
|
|
|
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-start-migration'));
|
2026-04-14 21:33:38 +02:00
|
|
|
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) {
|
2026-04-25 23:19:36 +02:00
|
|
|
showMigrationMsg(i18n.t('admin.migration_started') || 'Migration started', 'success');
|
2026-04-14 21:33:38 +02:00
|
|
|
loadMigrationStatus();
|
|
|
|
|
} else {
|
|
|
|
|
const e = await resp.json().catch(() => ({}));
|
|
|
|
|
showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-05-07 23:40:02 +02:00
|
|
|
showMigrationMsg(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function pauseMigration() {
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(`${API}/admin/storage/migration/pause`, {
|
2026-04-15 00:15:53 +02:00
|
|
|
method: 'POST',
|
|
|
|
|
headers: headers(),
|
|
|
|
|
credentials: 'same-origin'
|
2026-04-14 21:33:38 +02:00
|
|
|
});
|
|
|
|
|
if (resp.ok) {
|
2026-04-25 23:19:36 +02:00
|
|
|
showMigrationMsg(i18n.t('admin.migration_paused_msg') || 'Migration paused', 'success');
|
2026-04-14 21:33:38 +02:00
|
|
|
loadMigrationStatus();
|
|
|
|
|
}
|
2026-04-15 00:15:53 +02:00
|
|
|
} catch (_e) {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resumeMigration() {
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(`${API}/admin/storage/migration/resume`, {
|
2026-04-15 00:15:53 +02:00
|
|
|
method: 'POST',
|
|
|
|
|
headers: headers(),
|
|
|
|
|
credentials: 'same-origin'
|
2026-04-14 21:33:38 +02:00
|
|
|
});
|
|
|
|
|
if (resp.ok) {
|
2026-04-25 23:19:36 +02:00
|
|
|
showMigrationMsg(i18n.t('admin.migration_resumed_msg') || 'Migration resumed', 'success');
|
2026-04-14 21:33:38 +02:00
|
|
|
loadMigrationStatus();
|
|
|
|
|
}
|
2026-04-15 00:15:53 +02:00
|
|
|
} catch (_e) {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function verifyMigration() {
|
2026-05-07 23:40:02 +02:00
|
|
|
const btn = /** @type {HTMLButtonElement} */ (document.getElementById('btn-verify-migration'));
|
2026-04-14 21:33:38 +02:00
|
|
|
btn.disabled = true;
|
2026-04-25 23:19:36 +02:00
|
|
|
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${escapeHtml(i18n.t('admin.migration_verifying') || 'Verifying...')}`;
|
2026-04-14 21:33:38 +02:00
|
|
|
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) {
|
2026-04-25 23:19:36 +02:00
|
|
|
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>`;
|
2026-04-14 21:33:38 +02:00
|
|
|
} 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`);
|
2026-04-25 23:19:36 +02:00
|
|
|
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>`;
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
resultDiv.style.display = '';
|
2026-05-07 23:40:02 +02:00
|
|
|
resultDiv.innerHTML = `<div class="discovery-result fail"><i class="fas fa-times-circle"></i> Error: ${escapeHtml(/** @type {Error} */ (e).message)}</div>`;
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
btn.disabled = false;
|
2026-04-25 23:19:36 +02:00
|
|
|
btn.innerHTML = `<i class="fas fa-check-double"></i> ${escapeHtml(i18n.t('admin.migration_verify') || 'Verify Integrity')}`;
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function completeMigration() {
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(`${API}/admin/storage/migration/complete`, {
|
2026-04-15 00:15:53 +02:00
|
|
|
method: 'POST',
|
|
|
|
|
headers: headers(),
|
|
|
|
|
credentials: 'same-origin'
|
2026-04-14 21:33:38 +02:00
|
|
|
});
|
|
|
|
|
if (resp.ok) {
|
2026-04-25 23:19:36 +02:00
|
|
|
showMigrationMsg(i18n.t('admin.migration_completed_msg') || 'Migration finalized. Restart the server to use the new backend.', 'success');
|
2026-04-14 21:33:38 +02:00
|
|
|
loadMigrationStatus();
|
|
|
|
|
} else {
|
|
|
|
|
const e = await resp.json().catch(() => ({}));
|
|
|
|
|
showMigrationMsg(`Error: ${e.message || resp.statusText}`, 'error');
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
2026-05-07 23:40:02 +02:00
|
|
|
showMigrationMsg(i18n.t('admin.error_network', { message: /** @type {Error} */ (e).message }), 'error');
|
2026-04-14 21:33:38 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 12:27:52 +01:00
|
|
|
async function init() {
|
2026-04-07 22:48:59 +02:00
|
|
|
try {
|
2026-05-07 13:54:59 +02:00
|
|
|
oxiIconsInit();
|
2026-04-07 22:50:42 +02:00
|
|
|
const me = await fetch(`${API}/auth/me`, {
|
2026-04-07 22:48:59 +02:00
|
|
|
headers: headers(),
|
|
|
|
|
credentials: 'same-origin'
|
|
|
|
|
});
|
|
|
|
|
if (!me.ok) {
|
|
|
|
|
showAccessDenied();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const user = await me.json();
|
|
|
|
|
if (user.role !== 'admin') {
|
|
|
|
|
showAccessDenied();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
currentAdminId = user.id;
|
|
|
|
|
|
2026-04-07 22:50:42 +02:00
|
|
|
const oidcResp = await fetch(`${API}/admin/settings/oidc`, {
|
2026-04-07 22:48:59 +02:00
|
|
|
headers: headers(),
|
|
|
|
|
credentials: 'same-origin'
|
|
|
|
|
});
|
|
|
|
|
if (oidcResp.ok) {
|
|
|
|
|
const s = await oidcResp.json();
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @type {HTMLInputElement} */ (document.getElementById('oidc-enabled')).checked = s.enabled;
|
2026-04-07 22:48:59 +02:00
|
|
|
if (s.enabled) showElement('oidc-form');
|
|
|
|
|
else hideElement('oidc-form');
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @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;
|
2026-04-07 22:48:59 +02:00
|
|
|
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');
|
2026-05-07 23:40:02 +02:00
|
|
|
/** @type {string[]} */ (s.env_overrides || []).forEach((field) => {
|
2026-04-07 22:50:42 +02:00
|
|
|
const badge = document.getElementById(`badge-${field}`);
|
2026-04-07 22:48:59 +02:00
|
|
|
if (badge) badge.innerHTML = '<span class="badge badge-env">ENV</span>';
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await loadDashboard();
|
|
|
|
|
hideElement('loading');
|
|
|
|
|
showElement('main-content');
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error(e);
|
|
|
|
|
showAccessDenied();
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showAccessDenied() {
|
2026-04-07 22:48:59 +02:00
|
|
|
hideElement('loading');
|
|
|
|
|
showElement('access-denied');
|
2026-02-20 12:27:52 +01:00
|
|
|
}
|
|
|
|
|
|
2026-06-02 00:35:45 +02:00
|
|
|
/* ── 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-16 21:26:36 -06:00
|
|
|
/* ── Plugins ── */
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @typedef {Object} PluginInfo
|
|
|
|
|
* @property {string} id
|
|
|
|
|
* @property {string} name
|
|
|
|
|
* @property {string} version
|
|
|
|
|
* @property {number} abi
|
|
|
|
|
* @property {string[]} subscriptions
|
|
|
|
|
* @property {boolean} enabled
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Load installed plugins into the table. A 503 means plugins are disabled on
|
|
|
|
|
* the server — show the explanatory banner instead of the management UI.
|
|
|
|
|
*/
|
|
|
|
|
async function loadPlugins() {
|
|
|
|
|
const tbody = document.getElementById('plugins-tbody');
|
|
|
|
|
const disabledEl = document.getElementById('plugins-disabled');
|
|
|
|
|
const mainEl = document.getElementById('plugins-main');
|
|
|
|
|
if (!tbody || !disabledEl || !mainEl) return;
|
|
|
|
|
try {
|
|
|
|
|
const resp = await fetch(`${API}/admin/plugins`, {
|
|
|
|
|
headers: headers(),
|
|
|
|
|
credentials: 'same-origin'
|
|
|
|
|
});
|
|
|
|
|
if (resp.status === 503) {
|
|
|
|
|
disabledEl.classList.remove('hidden');
|
|
|
|
|
mainEl.classList.add('hidden');
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
disabledEl.classList.add('hidden');
|
|
|
|
|
mainEl.classList.remove('hidden');
|
|
|
|
|
if (!resp.ok) {
|
|
|
|
|
tbody.innerHTML = `<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;
|
|
|
|
|
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>';
|
|
|
|
|
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">${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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 00:08:34 +01:00
|
|
|
/* ── Apply i18n when translations load / change ── */
|
2026-04-07 22:50:42 +02:00
|
|
|
document.addEventListener('translationsLoaded', () => {
|
2026-04-25 22:55:42 +02:00
|
|
|
i18n.translatePage();
|
2026-04-25 23:19:36 +02:00
|
|
|
// Re-render dynamic content that uses i18n.t()
|
2026-04-07 22:48:59 +02:00
|
|
|
loadDashboard();
|
|
|
|
|
if (activeTabName === 'users') loadUsers();
|
2026-03-09 00:08:34 +01:00
|
|
|
});
|
2026-04-07 22:50:42 +02:00
|
|
|
document.addEventListener('localeChanged', () => {
|
2026-04-25 22:55:42 +02:00
|
|
|
i18n.translatePage();
|
2026-04-07 22:48:59 +02:00
|
|
|
loadDashboard();
|
|
|
|
|
if (activeTabName === 'users') loadUsers();
|
2026-03-09 00:08:34 +01:00
|
|
|
});
|
|
|
|
|
|
2026-02-20 12:27:52 +01:00
|
|
|
init();
|
2026-03-05 13:15:34 +01:00
|
|
|
|
|
|
|
|
/* ── Event-listener wiring (replaces inline onclick/onchange) ── */
|
2026-04-07 22:48:59 +02:00
|
|
|
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);
|
|
|
|
|
});
|
2026-04-14 21:33:38 +02:00
|
|
|
document.getElementById('tab-btn-storage').addEventListener('click', function () {
|
|
|
|
|
switchTab('storage', this);
|
|
|
|
|
});
|
2026-06-02 00:35:45 +02:00
|
|
|
document.getElementById('tab-btn-smtp').addEventListener('click', function () {
|
|
|
|
|
switchTab('smtp', this);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-16 21:26:36 -06:00
|
|
|
document.getElementById('tab-btn-plugins').addEventListener('click', function () {
|
|
|
|
|
switchTab('plugins', this);
|
|
|
|
|
});
|
|
|
|
|
|
2026-06-02 00:35:45 +02:00
|
|
|
document.getElementById('btn-smtp-test').addEventListener('click', sendSmtpTest);
|
2026-03-05 13:15:34 +01:00
|
|
|
|
2026-06-16 21:26:36 -06:00
|
|
|
document.getElementById('btn-plugin-install').addEventListener('click', installPlugin);
|
|
|
|
|
|
2026-04-07 22:48:59 +02:00
|
|
|
document.getElementById('ds-registration').addEventListener('change', function () {
|
2026-05-07 23:40:02 +02:00
|
|
|
toggleRegistration(/** @type {HTMLInputElement} */ (this).checked);
|
2026-04-07 22:48:59 +02:00
|
|
|
});
|
2026-03-05 13:15:34 +01:00
|
|
|
|
|
|
|
|
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);
|
2026-04-14 21:33:38 +02:00
|
|
|
|
|
|
|
|
/* ── Storage event listeners ── */
|
|
|
|
|
document.querySelectorAll('input[name="storage-backend"]').forEach((r) => {
|
2026-05-07 23:40:02 +02:00
|
|
|
r.addEventListener('change', () => {
|
|
|
|
|
toggleS3Form(/** @type {HTMLInputElement} */ (r).value === 's3');
|
2026-04-14 21:33:38 +02:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
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);
|