mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 09:35:39 +00:00
3322 lines
145 KiB
JavaScript
3322 lines
145 KiB
JavaScript
/**
|
|
* BetterDesk Console - Settings Page
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
|
|
function init() {
|
|
initTabs();
|
|
initPasswordForm();
|
|
initTotpSection();
|
|
initBrandingSection();
|
|
|
|
// Only init server-config sections when the tab is visible (requires server.config permission)
|
|
if (document.getElementById('tab-auth') && document.getElementById('tab-auth').style.display !== 'none') {
|
|
initEnrollmentSection();
|
|
initLdapSection();
|
|
initOidcSection();
|
|
}
|
|
if (document.getElementById('tab-backup') && document.getElementById('tab-backup').style.display !== 'none') {
|
|
initBackupSection();
|
|
}
|
|
if (document.getElementById('tab-updates') && document.getElementById('tab-updates').style.display !== 'none') {
|
|
initUpdateSection();
|
|
}
|
|
if (document.getElementById('tab-advanced') && document.getElementById('tab-advanced').style.display !== 'none') {
|
|
initAdvancedSection();
|
|
}
|
|
|
|
initTutorialSection();
|
|
loadAuditLog();
|
|
loadServerInfo();
|
|
initConnectionModeSection();
|
|
|
|
// Refresh handler
|
|
window.addEventListener('app:refresh', loadAuditLog);
|
|
}
|
|
|
|
// ==================== Tab Navigation ====================
|
|
|
|
function initTabs() {
|
|
const tabs = document.querySelectorAll('.settings-tab');
|
|
tabs.forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
// Deactivate all
|
|
tabs.forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.settings-tab-content').forEach(c => c.classList.remove('active'));
|
|
|
|
// Activate selected
|
|
tab.classList.add('active');
|
|
const target = document.getElementById('tab-' + tab.dataset.tab);
|
|
if (target) target.classList.add('active');
|
|
});
|
|
});
|
|
|
|
// Check URL hash for direct tab navigation
|
|
const hash = window.location.hash.replace('#', '');
|
|
if (['branding', 'server', 'backup', 'updates', 'auth', 'advanced'].includes(hash)) {
|
|
const tab = document.querySelector(`[data-tab="${hash}"]`);
|
|
if (tab) tab.click();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize password change form
|
|
*/
|
|
function initPasswordForm() {
|
|
const form = document.getElementById('password-form');
|
|
const newPassword = document.getElementById('new-password');
|
|
|
|
if (!form) return;
|
|
|
|
// Real-time password validation
|
|
newPassword?.addEventListener('input', () => {
|
|
validatePassword(newPassword.value);
|
|
});
|
|
|
|
// Form submission
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const currentPassword = document.getElementById('current-password').value;
|
|
const newPass = document.getElementById('new-password').value;
|
|
const confirmPass = document.getElementById('confirm-password').value;
|
|
|
|
// Validation
|
|
if (!currentPassword || !newPass || !confirmPass) {
|
|
Notifications.error(_('settings.fill_all_fields'));
|
|
return;
|
|
}
|
|
|
|
if (newPass !== confirmPass) {
|
|
Notifications.error(_('settings.passwords_not_match'));
|
|
return;
|
|
}
|
|
|
|
if (!validatePassword(newPass)) {
|
|
Notifications.error(_('settings.password_requirements_not_met'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await Utils.api('/api/auth/password', {
|
|
method: 'POST',
|
|
body: {
|
|
currentPassword: currentPassword,
|
|
newPassword: newPass,
|
|
confirmPassword: confirmPass
|
|
}
|
|
});
|
|
|
|
Notifications.success(_('settings.password_changed'));
|
|
form.reset();
|
|
|
|
// Reset validation indicators
|
|
document.querySelectorAll('.password-requirements li').forEach(li => {
|
|
li.classList.remove('valid');
|
|
});
|
|
|
|
} catch (error) {
|
|
Notifications.error(error.message || _('errors.password_change_failed'));
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Validate password and update UI indicators
|
|
*/
|
|
function validatePassword(password) {
|
|
const requirements = {
|
|
'req-length': password.length >= 8,
|
|
'req-uppercase': /[A-Z]/.test(password),
|
|
'req-lowercase': /[a-z]/.test(password),
|
|
'req-number': /[0-9]/.test(password)
|
|
};
|
|
|
|
let allMet = true;
|
|
|
|
for (const [id, met] of Object.entries(requirements)) {
|
|
const el = document.getElementById(id);
|
|
if (el) {
|
|
el.classList.toggle('valid', met);
|
|
}
|
|
if (!met) allMet = false;
|
|
}
|
|
|
|
return allMet;
|
|
}
|
|
|
|
/**
|
|
* Load audit log
|
|
*/
|
|
async function loadAuditLog() {
|
|
const tbody = document.getElementById('audit-log-body');
|
|
if (!tbody) return;
|
|
|
|
try {
|
|
const logs = await Utils.api('/api/settings/audit');
|
|
|
|
if (!logs || logs.length === 0) {
|
|
tbody.innerHTML = `<tr><td colspan="4" class="text-center text-muted">${_('settings.no_audit_logs')}</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = logs.map(log => {
|
|
var actionKey = 'audit.action_' + (log.action || '').replace(/[^a-z0-9_]/gi, '_');
|
|
var actionLabel = typeof _ === 'function' ? _(actionKey) : log.action;
|
|
if (actionLabel === actionKey) actionLabel = log.action;
|
|
var actionClass = String(log.action || '').replace(/[^a-z0-9_-]/gi, '');
|
|
return `
|
|
<tr>
|
|
<td>${Utils.formatDate(log.created_at)}</td>
|
|
<td>${Utils.escapeHtml(log.username || '-')}</td>
|
|
<td><span class="audit-action ${actionClass}">${Utils.escapeHtml(actionLabel)}</span></td>
|
|
<td>${Utils.escapeHtml(log.details || '-')}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
|
|
} catch (error) {
|
|
tbody.innerHTML = `<tr><td colspan="4" class="text-center text-danger">${_('errors.load_audit_failed')}</td></tr>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load server info
|
|
*/
|
|
async function loadServerInfo() {
|
|
try {
|
|
const data = await Utils.api('/api/settings/info');
|
|
|
|
document.getElementById('db-path').textContent = data.paths?.database || '-';
|
|
document.getElementById('uptime').textContent = formatUptime(data.server?.uptime);
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load server info:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format uptime in human-readable format
|
|
*/
|
|
function formatUptime(seconds) {
|
|
if (!seconds || seconds < 0) return '-';
|
|
|
|
const days = Math.floor(seconds / 86400);
|
|
const hours = Math.floor((seconds % 86400) / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
|
|
const parts = [];
|
|
if (days > 0) parts.push(`${days}d`);
|
|
if (hours > 0) parts.push(`${hours}h`);
|
|
if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`);
|
|
|
|
return parts.join(' ');
|
|
}
|
|
|
|
// ==================== Connection Mode (P2P / Relay) ====================
|
|
|
|
function initConnectionModeSection() {
|
|
const section = document.getElementById('connection-mode-section');
|
|
if (!section) return;
|
|
|
|
const saveBtn = document.getElementById('connection-mode-save');
|
|
const saveRestartBtn = document.getElementById('connection-mode-save-restart');
|
|
saveBtn?.addEventListener('click', () => saveConnectionMode(false));
|
|
saveRestartBtn?.addEventListener('click', () => saveConnectionMode(true));
|
|
|
|
loadConnectionMode();
|
|
}
|
|
|
|
async function loadConnectionMode() {
|
|
const sourceEl = document.getElementById('connection-mode-source');
|
|
const runtimeEl = document.getElementById('connection-mode-runtime');
|
|
try {
|
|
const resp = await Utils.api('/api/settings/connection-mode');
|
|
const data = resp.data || resp;
|
|
const mode = data.mode || 'p2p_first';
|
|
const p2pRadio = document.getElementById('conn-mode-p2p');
|
|
const relayRadio = document.getElementById('conn-mode-relay');
|
|
if (p2pRadio) p2pRadio.checked = mode === 'p2p_first';
|
|
if (relayRadio) relayRadio.checked = mode === 'relay_only';
|
|
|
|
const fallbackInput = document.getElementById('conn-p2p-fallback-ms');
|
|
if (fallbackInput && data.p2p_fallback_ms != null) {
|
|
fallbackInput.value = data.p2p_fallback_ms;
|
|
}
|
|
const sameNat = document.getElementById('conn-same-nat-relay');
|
|
if (sameNat) sameNat.checked = data.same_nat_relay !== false;
|
|
|
|
if (sourceEl) {
|
|
const src = data.source || data.deployment || 'defaults';
|
|
const writable = data.writable !== false && src !== 'defaults';
|
|
sourceEl.textContent = writable
|
|
? (_('settings.connection_mode_source_saved') || 'Saved in') + ': ' + src
|
|
: (_('settings.connection_mode_source_defaults') || 'Using server defaults (not writable from panel)');
|
|
}
|
|
|
|
if (runtimeEl && data.runtime) {
|
|
const r = data.runtime;
|
|
runtimeEl.hidden = false;
|
|
runtimeEl.textContent = (_('settings.connection_mode_runtime') || 'Active server') + ': '
|
|
+ (r.always_use_relay ? 'relay only' : 'P2P first')
|
|
+ ', fallback=' + (r.p2p_fallback_ms ?? '-')
|
|
+ 'ms, same_nat_relay=' + (r.same_nat_relay ? 'Y' : 'N');
|
|
} else if (runtimeEl) {
|
|
runtimeEl.hidden = true;
|
|
}
|
|
|
|
const saveBtns = [document.getElementById('connection-mode-save'), document.getElementById('connection-mode-save-restart')];
|
|
const disabled = data.writable === false || data.source === 'defaults';
|
|
saveBtns.forEach((b) => { if (b) b.disabled = disabled; });
|
|
} catch (err) {
|
|
console.error('Failed to load connection mode:', err);
|
|
if (sourceEl) sourceEl.textContent = _('errors.server_error');
|
|
}
|
|
}
|
|
|
|
function getConnectionModePayload() {
|
|
const mode = document.querySelector('input[name="connection-mode"]:checked')?.value || 'p2p_first';
|
|
const fallbackMs = parseInt(document.getElementById('conn-p2p-fallback-ms')?.value, 10);
|
|
const sameNatRelay = document.getElementById('conn-same-nat-relay')?.checked !== false;
|
|
return {
|
|
mode,
|
|
p2p_fallback_ms: Number.isFinite(fallbackMs) ? fallbackMs : 2000,
|
|
same_nat_relay: sameNatRelay
|
|
};
|
|
}
|
|
|
|
async function saveConnectionMode(restart) {
|
|
const saveBtn = document.getElementById('connection-mode-save');
|
|
const saveRestartBtn = document.getElementById('connection-mode-save-restart');
|
|
[saveBtn, saveRestartBtn].forEach((b) => { if (b) b.disabled = true; });
|
|
|
|
try {
|
|
const payload = getConnectionModePayload();
|
|
payload.restart = restart;
|
|
const resp = await Utils.api('/api/settings/connection-mode', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (typeof Notifications !== 'undefined') {
|
|
Notifications.success(resp.message || _('settings.connection_mode_saved'));
|
|
}
|
|
|
|
if (restart && resp.data?.restart) {
|
|
const failed = (resp.data.restart.restarts || []).filter((r) => !r.success);
|
|
if (failed.length) {
|
|
Notifications.warning(_('settings.connection_mode_restart_failed'));
|
|
}
|
|
}
|
|
|
|
await loadConnectionMode();
|
|
} catch (err) {
|
|
console.error('Save connection mode failed:', err);
|
|
if (typeof Notifications !== 'undefined') {
|
|
Notifications.error(err.message || _('errors.server_error'));
|
|
}
|
|
} finally {
|
|
[saveBtn, saveRestartBtn].forEach((b) => { if (b) b.disabled = false; });
|
|
}
|
|
}
|
|
|
|
// ==================== TOTP (2FA) Section ====================
|
|
|
|
/**
|
|
* Initialize TOTP section
|
|
*/
|
|
async function initTotpSection() {
|
|
const container = document.getElementById('totp-status-container');
|
|
if (!container) return;
|
|
|
|
try {
|
|
const data = await Utils.api('/api/auth/totp/status');
|
|
|
|
if (data.enabled) {
|
|
renderTotpEnabled(container);
|
|
} else {
|
|
renderTotpDisabled(container);
|
|
}
|
|
} catch (error) {
|
|
container.innerHTML = `<p class="text-danger">${_('errors.server_error')}</p>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Render TOTP enabled state
|
|
*/
|
|
function renderTotpEnabled(container) {
|
|
container.innerHTML = `
|
|
<div class="totp-status totp-enabled">
|
|
<div class="totp-status-badge">
|
|
<span class="material-icons">verified_user</span>
|
|
<span>${_('settings.totp_enabled')}</span>
|
|
</div>
|
|
<p class="totp-status-desc">${_('settings.totp_enabled_desc')}</p>
|
|
<button class="btn btn-danger" id="totp-disable-btn">
|
|
<span class="material-icons">lock_open</span>
|
|
${_('settings.totp_disable')}
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('totp-disable-btn')?.addEventListener('click', handleDisableTotp);
|
|
}
|
|
|
|
/**
|
|
* Render TOTP disabled state
|
|
*/
|
|
function renderTotpDisabled(container) {
|
|
container.innerHTML = `
|
|
<div class="totp-status totp-disabled">
|
|
<div class="totp-status-badge disabled">
|
|
<span class="material-icons">shield</span>
|
|
<span>${_('settings.totp_disabled')}</span>
|
|
</div>
|
|
<p class="totp-status-desc">${_('settings.totp_disabled_desc')}</p>
|
|
<button class="btn btn-primary" id="totp-setup-btn">
|
|
<span class="material-icons">qr_code_2</span>
|
|
${_('settings.totp_setup')}
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('totp-setup-btn')?.addEventListener('click', handleSetupTotp);
|
|
}
|
|
|
|
/**
|
|
* Handle TOTP setup flow
|
|
*/
|
|
async function handleSetupTotp() {
|
|
const container = document.getElementById('totp-status-container');
|
|
|
|
try {
|
|
const data = await Utils.api('/api/auth/totp/setup', { method: 'POST' });
|
|
|
|
container.innerHTML = `
|
|
<div class="totp-setup">
|
|
<div class="totp-setup-steps">
|
|
<div class="totp-step">
|
|
<span class="step-number">1</span>
|
|
<span>${_('settings.totp_step1')}</span>
|
|
</div>
|
|
<div class="totp-step">
|
|
<span class="step-number">2</span>
|
|
<span>${_('settings.totp_step2')}</span>
|
|
</div>
|
|
<div class="totp-step">
|
|
<span class="step-number">3</span>
|
|
<span>${_('settings.totp_step3')}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="totp-qr-container">
|
|
<img src="${data.qrCode}" alt="QR Code" class="totp-qr-image">
|
|
</div>
|
|
|
|
<div class="totp-manual-key">
|
|
<p class="totp-manual-label">${_('settings.totp_manual_key')}:</p>
|
|
<code class="totp-secret-code">${data.secret}</code>
|
|
<button class="btn btn-sm btn-ghost" id="totp-copy-secret-btn">
|
|
<span class="material-icons" style="font-size: 16px;">content_copy</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="totp-verify-form">
|
|
<label class="form-label">${_('settings.totp_enter_code')}:</label>
|
|
<div class="totp-verify-input-group">
|
|
<input type="text" id="totp-setup-code" class="form-input totp-input"
|
|
placeholder="000000" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" autofocus>
|
|
<button class="btn btn-primary" id="totp-verify-btn">
|
|
<span class="material-icons">check</span>
|
|
${_('settings.totp_verify_enable')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="btn btn-ghost totp-cancel-btn" id="totp-cancel-btn">
|
|
${_('actions.cancel')}
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
// Handle verify
|
|
document.getElementById('totp-verify-btn')?.addEventListener('click', async () => {
|
|
const code = document.getElementById('totp-setup-code').value.trim();
|
|
if (!code || code.length !== 6) {
|
|
Notifications.error(_('auth.totp_enter_code'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await Utils.api('/api/auth/totp/enable', {
|
|
method: 'POST',
|
|
body: { code }
|
|
});
|
|
|
|
// Show recovery codes
|
|
showRecoveryCodes(container, result.recoveryCodes);
|
|
|
|
} catch (err) {
|
|
Notifications.error(err.message || _('auth.totp_invalid_code'));
|
|
}
|
|
});
|
|
|
|
// Auto-submit on 6 digits
|
|
document.getElementById('totp-setup-code')?.addEventListener('input', (e) => {
|
|
e.target.value = e.target.value.replace(/[^0-9]/g, '');
|
|
});
|
|
|
|
// Cancel
|
|
document.getElementById('totp-cancel-btn')?.addEventListener('click', () => {
|
|
initTotpSection();
|
|
});
|
|
|
|
document.getElementById('totp-copy-secret-btn')?.addEventListener('click', () => {
|
|
navigator.clipboard.writeText(data.secret).catch(() => {});
|
|
});
|
|
|
|
} catch (error) {
|
|
Notifications.error(error.message || _('errors.server_error'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Show recovery codes after enabling TOTP
|
|
*/
|
|
function showRecoveryCodes(container, codes) {
|
|
container.innerHTML = `
|
|
<div class="totp-recovery">
|
|
<div class="totp-success-header">
|
|
<span class="material-icons totp-success-icon">verified_user</span>
|
|
<h3>${_('settings.totp_enabled_success')}</h3>
|
|
</div>
|
|
|
|
<div class="totp-recovery-warning">
|
|
<span class="material-icons">warning</span>
|
|
<p>${_('settings.totp_recovery_warning')}</p>
|
|
</div>
|
|
|
|
<div class="totp-recovery-codes">
|
|
${codes.map(code => `<code class="recovery-code">${code}</code>`).join('')}
|
|
</div>
|
|
|
|
<div class="totp-recovery-actions">
|
|
<button class="btn btn-secondary" id="copy-recovery-btn">
|
|
<span class="material-icons">content_copy</span>
|
|
${_('actions.copy')}
|
|
</button>
|
|
</div>
|
|
|
|
<button class="btn btn-primary totp-done-btn" id="totp-done-btn">
|
|
<span class="material-icons">check</span>
|
|
${_('settings.totp_done')}
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('copy-recovery-btn')?.addEventListener('click', () => {
|
|
navigator.clipboard.writeText(codes.join('\n'));
|
|
Notifications.success(_('common.copied'));
|
|
});
|
|
|
|
document.getElementById('totp-done-btn')?.addEventListener('click', () => {
|
|
initTotpSection();
|
|
});
|
|
|
|
Notifications.success(_('settings.totp_enabled_success'));
|
|
}
|
|
|
|
/**
|
|
* Handle TOTP disable
|
|
*/
|
|
async function handleDisableTotp() {
|
|
const container = document.getElementById('totp-status-container');
|
|
|
|
container.innerHTML = `
|
|
<div class="totp-disable-confirm">
|
|
<div class="totp-disable-warning">
|
|
<span class="material-icons">warning</span>
|
|
<p>${_('settings.totp_disable_warning')}</p>
|
|
</div>
|
|
<div class="form-group">
|
|
<label class="form-label">${_('settings.current_password')}:</label>
|
|
<input type="password" id="totp-disable-password" class="form-input"
|
|
placeholder="${_('auth.password_placeholder')}" required>
|
|
</div>
|
|
<div class="totp-disable-actions">
|
|
<button class="btn btn-danger" id="confirm-disable-btn">
|
|
<span class="material-icons">lock_open</span>
|
|
${_('settings.totp_disable')}
|
|
</button>
|
|
<button class="btn btn-ghost" id="cancel-disable-btn">
|
|
${_('actions.cancel')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('confirm-disable-btn')?.addEventListener('click', async () => {
|
|
const password = document.getElementById('totp-disable-password').value;
|
|
if (!password) {
|
|
Notifications.error(_('auth.fill_all_fields'));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await Utils.api('/api/auth/totp/disable', {
|
|
method: 'POST',
|
|
body: { password }
|
|
});
|
|
|
|
Notifications.success(_('settings.totp_disabled_success'));
|
|
initTotpSection();
|
|
|
|
} catch (err) {
|
|
Notifications.error(err.message || _('errors.server_error'));
|
|
}
|
|
});
|
|
|
|
document.getElementById('cancel-disable-btn')?.addEventListener('click', () => {
|
|
initTotpSection();
|
|
});
|
|
}
|
|
|
|
// ==================== Branding / Theming Section ====================
|
|
|
|
let brandingData = null;
|
|
|
|
/**
|
|
* Initialize branding configuration section
|
|
*/
|
|
async function initBrandingSection() {
|
|
try {
|
|
const response = await Utils.api('/api/settings/branding');
|
|
brandingData = response.data || response;
|
|
|
|
populateBrandingForm(brandingData);
|
|
initLogoTypeSelector();
|
|
initColorPickers();
|
|
initFontPickers();
|
|
initBackgroundSelectors();
|
|
initBrandingActions();
|
|
|
|
} catch (error) {
|
|
console.error('Failed to load branding:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Populate branding form with current config
|
|
*/
|
|
function populateBrandingForm(data) {
|
|
// Identity fields
|
|
const nameInput = document.getElementById('brand-name');
|
|
const descInput = document.getElementById('brand-description');
|
|
if (nameInput) nameInput.value = data.appName || '';
|
|
if (descInput) descInput.value = data.appDescription || '';
|
|
|
|
// Logo type
|
|
const logoTypeRadio = document.querySelector(`input[name="logo-type"][value="${data.logoType || 'icon'}"]`);
|
|
if (logoTypeRadio) {
|
|
logoTypeRadio.checked = true;
|
|
showLogoPanel(data.logoType || 'icon');
|
|
}
|
|
|
|
// Logo fields
|
|
const iconInput = document.getElementById('logo-icon-name');
|
|
const svgInput = document.getElementById('logo-svg-input');
|
|
const imageInput = document.getElementById('logo-image-url');
|
|
const textInput = document.getElementById('logo-text-input');
|
|
const textAccentInput = document.getElementById('logo-text-accent');
|
|
if (iconInput) iconInput.value = data.logoIcon || 'dns';
|
|
if (svgInput) svgInput.value = data.logoSvg || '';
|
|
if (imageInput) imageInput.value = data.logoUrl || '';
|
|
if (textInput) textInput.value = data.logoText || '';
|
|
if (textAccentInput) textAccentInput.value = data.logoTextAccent || '';
|
|
|
|
// Font fields
|
|
if (data.fontHeading) {
|
|
setFontPickerValue('heading', data.fontHeading);
|
|
}
|
|
if (data.fontBody) {
|
|
setFontPickerValue('body', data.fontBody);
|
|
}
|
|
|
|
// Colors
|
|
if (data.colors) {
|
|
for (const [key, value] of Object.entries(data.colors)) {
|
|
if (!value) continue;
|
|
const picker = document.querySelector(`.color-picker[data-color="${key}"]`);
|
|
const hex = document.querySelector(`.color-hex[data-color="${key}"]`);
|
|
if (picker) picker.value = value;
|
|
if (hex) hex.value = value;
|
|
}
|
|
}
|
|
|
|
// Background & appearance (console)
|
|
const setVal = (id, val) => { const el = document.getElementById(id); if (el != null && el) el.value = val; };
|
|
const setChecked = (id, val) => { const el = document.getElementById(id); if (el) el.checked = val; };
|
|
const checkRadio = (name, val) => {
|
|
const r = document.querySelector(`input[name="${name}"][value="${val}"]`);
|
|
if (r) r.checked = true;
|
|
};
|
|
checkRadio('bg-type', data.bgType || 'none');
|
|
setVal('bg-color', data.bgColor || '');
|
|
setVal('bg-gradient', data.bgGradient || '');
|
|
setVal('bg-image-url', data.bgImageUrl || '');
|
|
setVal('bg-blur', data.bgBlur || '0');
|
|
setVal('bg-overlay', data.bgOverlay || '0');
|
|
setVal('bg-size', data.bgSize || 'cover');
|
|
if (data.bgColor && document.getElementById('bg-color-picker') && /^#[0-9a-fA-F]{6}$/.test(data.bgColor)) {
|
|
document.getElementById('bg-color-picker').value = data.bgColor;
|
|
}
|
|
const bgBlurVal = document.getElementById('bg-blur-value');
|
|
if (bgBlurVal) bgBlurVal.textContent = data.bgBlur || '0';
|
|
const bgOverlayVal = document.getElementById('bg-overlay-value');
|
|
if (bgOverlayVal) bgOverlayVal.textContent = data.bgOverlay || '0';
|
|
showBackgroundPanel('bg', data.bgType || 'none');
|
|
|
|
// Login page
|
|
setVal('login-title', data.loginTitle || '');
|
|
setVal('login-subtitle', data.loginSubtitle || '');
|
|
checkRadio('login-bg-type', data.loginBgType || 'inherit');
|
|
setVal('login-bg-color', data.loginBgColor || '');
|
|
setVal('login-bg-gradient', data.loginBgGradient || '');
|
|
setVal('login-bg-image-url', data.loginBgImageUrl || '');
|
|
setVal('login-bg-overlay', data.loginBgOverlay || '0');
|
|
if (data.loginBgColor && document.getElementById('login-bg-color-picker') && /^#[0-9a-fA-F]{6}$/.test(data.loginBgColor)) {
|
|
document.getElementById('login-bg-color-picker').value = data.loginBgColor;
|
|
}
|
|
const loginOverlayVal = document.getElementById('login-bg-overlay-value');
|
|
if (loginOverlayVal) loginOverlayVal.textContent = data.loginBgOverlay || '0';
|
|
showBackgroundPanel('login-bg', data.loginBgType || 'inherit');
|
|
|
|
// Agent download page
|
|
checkRadio('agent-bg-type', data.agentBgType || 'none');
|
|
setVal('agent-bg-color', data.agentBgColor || '');
|
|
setVal('agent-bg-gradient', data.agentBgGradient || '');
|
|
setVal('agent-bg-image-url', data.agentBgImageUrl || '');
|
|
setChecked('agent-show-powered', data.agentShowPoweredBy !== 'false');
|
|
if (data.agentBgColor && document.getElementById('agent-bg-color-picker') && /^#[0-9a-fA-F]{6}$/.test(data.agentBgColor)) {
|
|
document.getElementById('agent-bg-color-picker').value = data.agentBgColor;
|
|
}
|
|
showBackgroundPanel('agent-bg', data.agentBgType || 'none');
|
|
|
|
// Footer & custom CSS
|
|
setVal('footer-text', data.footerText || '');
|
|
setChecked('show-powered', data.showPoweredBy !== 'false');
|
|
setVal('custom-css', data.customCss || '');
|
|
|
|
// Update preview
|
|
updateLogoPreview();
|
|
}
|
|
|
|
/**
|
|
* Show the active panel for a background group (bg / login-bg / agent-bg)
|
|
* and hide the rest. "none"/"inherit" hide all detail panels.
|
|
*/
|
|
function showBackgroundPanel(prefix, type) {
|
|
['none', 'inherit', 'color', 'gradient', 'image'].forEach(t => {
|
|
const panel = document.getElementById(`${prefix}-${t}-panel`);
|
|
if (panel) panel.classList.add('hidden');
|
|
});
|
|
const active = document.getElementById(`${prefix}-${type}-panel`);
|
|
if (active) active.classList.remove('hidden');
|
|
}
|
|
|
|
/**
|
|
* Wire background selectors: radio toggles, range value labels,
|
|
* color picker sync and image uploads for console/login/agent groups.
|
|
*/
|
|
function initBackgroundSelectors() {
|
|
// Radio toggles for the three background groups
|
|
[['bg-type', 'bg'], ['login-bg-type', 'login-bg'], ['agent-bg-type', 'agent-bg']].forEach(([name, prefix]) => {
|
|
document.querySelectorAll(`input[name="${name}"]`).forEach(radio => {
|
|
radio.addEventListener('change', () => showBackgroundPanel(prefix, radio.value));
|
|
});
|
|
});
|
|
|
|
// Color picker <-> hex text sync for the standalone background pickers
|
|
[['bg-color-picker', 'bg-color'], ['login-bg-color-picker', 'login-bg-color'], ['agent-bg-color-picker', 'agent-bg-color']].forEach(([pickerId, textId]) => {
|
|
const picker = document.getElementById(pickerId);
|
|
const text = document.getElementById(textId);
|
|
if (picker && text) {
|
|
picker.addEventListener('input', () => { text.value = picker.value; });
|
|
text.addEventListener('input', () => {
|
|
if (/^#[0-9a-fA-F]{6}$/.test(text.value)) picker.value = text.value;
|
|
});
|
|
}
|
|
});
|
|
|
|
// Range value labels
|
|
[['bg-blur', 'bg-blur-value'], ['bg-overlay', 'bg-overlay-value'], ['login-bg-overlay', 'login-bg-overlay-value']].forEach(([rangeId, labelId]) => {
|
|
const range = document.getElementById(rangeId);
|
|
const label = document.getElementById(labelId);
|
|
if (range && label) {
|
|
range.addEventListener('input', () => { label.textContent = range.value; });
|
|
}
|
|
});
|
|
|
|
// Background image uploads (shared route, data-target picks the URL field)
|
|
document.querySelectorAll('.branding-bg-file').forEach(input => {
|
|
input.addEventListener('change', handleBackgroundFileUpload);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Handle background image upload — uploads to the shared background route
|
|
* and fills the URL field referenced by the input's data-target attribute.
|
|
*/
|
|
async function handleBackgroundFileUpload(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
const maxSize = 8 * 1024 * 1024; // 8 MB
|
|
if (file.size > maxSize) {
|
|
Utils.showNotification(_('branding.bg_image_too_large'), 'error');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
if (!validTypes.includes(file.type)) {
|
|
Utils.showNotification(_('branding.bg_image_invalid_type'), 'error');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
const targetId = e.target.dataset.target;
|
|
const nameElId = e.target.id.replace('-file', '-file-name');
|
|
const nameEl = document.getElementById(nameElId);
|
|
if (nameEl) nameEl.textContent = file.name;
|
|
|
|
const formData = new FormData();
|
|
formData.append('background', file);
|
|
|
|
try {
|
|
const resp = await fetch('/api/settings/branding/upload-background', {
|
|
method: 'POST',
|
|
headers: { 'x-csrf-token': window.BetterDesk?.csrfToken || '' },
|
|
body: formData
|
|
});
|
|
const result = await resp.json();
|
|
if (!resp.ok || !result.success) {
|
|
throw new Error(result.error || 'Upload failed');
|
|
}
|
|
const urlInput = document.getElementById(targetId);
|
|
if (urlInput) urlInput.value = result.url;
|
|
Notifications.success(_('branding.bg_upload_success'));
|
|
} catch (err) {
|
|
Utils.showNotification(err.message || _('errors.server_error'), 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize logo type selector
|
|
*/
|
|
function initLogoTypeSelector() {
|
|
const radios = document.querySelectorAll('input[name="logo-type"]');
|
|
radios.forEach(radio => {
|
|
radio.addEventListener('change', () => {
|
|
showLogoPanel(radio.value);
|
|
updateLogoPreview();
|
|
});
|
|
});
|
|
|
|
// Live preview on input changes
|
|
document.getElementById('logo-icon-name')?.addEventListener('input', updateLogoPreview);
|
|
document.getElementById('logo-svg-input')?.addEventListener('input', updateLogoPreview);
|
|
document.getElementById('logo-image-url')?.addEventListener('input', updateLogoPreview);
|
|
document.getElementById('logo-text-input')?.addEventListener('input', updateLogoPreview);
|
|
document.getElementById('logo-text-accent')?.addEventListener('input', updateLogoPreview);
|
|
document.getElementById('brand-name')?.addEventListener('input', updateLogoPreview);
|
|
|
|
// File upload handler
|
|
document.getElementById('logo-image-file')?.addEventListener('change', handleLogoFileUpload);
|
|
}
|
|
|
|
/**
|
|
* Show the correct logo config panel
|
|
*/
|
|
function showLogoPanel(type) {
|
|
document.querySelectorAll('.logo-config-panel').forEach(p => p.classList.add('hidden'));
|
|
const panel = document.getElementById(`logo-${type}-panel`);
|
|
if (panel) panel.classList.remove('hidden');
|
|
}
|
|
|
|
function isSafePreviewUrl(url) {
|
|
const trimmed = String(url || '').trim();
|
|
if (!trimmed) return false;
|
|
if (trimmed.startsWith('//') || trimmed.includes('..')) return false;
|
|
if (trimmed.startsWith('/')) return true;
|
|
try {
|
|
const parsed = new URL(trimmed, window.location.origin);
|
|
return parsed.protocol === 'http:' || parsed.protocol === 'https:';
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sanitize SVG content to prevent XSS attacks.
|
|
* Removes potentially dangerous elements and attributes.
|
|
* @param {string} svg - Raw SVG string
|
|
* @returns {string} - Sanitized SVG string
|
|
*/
|
|
function sanitizeSvg(svg) {
|
|
// Parse the SVG
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(svg, 'image/svg+xml');
|
|
|
|
// Check for parsing errors
|
|
const parserError = doc.querySelector('parsererror');
|
|
if (parserError) return '<!-- Invalid SVG -->';
|
|
|
|
const svgEl = doc.querySelector('svg');
|
|
if (!svgEl) return '<!-- No SVG element found -->';
|
|
|
|
// Remove dangerous elements
|
|
const dangerousTags = ['script', 'foreignobject', 'iframe', 'embed', 'object', 'applet'];
|
|
dangerousTags.forEach(tag => {
|
|
doc.querySelectorAll(tag).forEach(el => el.remove());
|
|
});
|
|
|
|
// Remove dangerous attributes from all elements
|
|
const dangerousAttrs = [
|
|
'onclick', 'ondblclick', 'onmousedown', 'onmouseup', 'onmouseover', 'onmousemove',
|
|
'onmouseout', 'onmouseenter', 'onmouseleave', 'onkeydown', 'onkeypress', 'onkeyup',
|
|
'onload', 'onerror', 'onabort', 'onfocus', 'onblur', 'onchange', 'onsubmit', 'onreset',
|
|
'onselect', 'onunload', 'xlink:href'
|
|
];
|
|
|
|
doc.querySelectorAll('*').forEach(el => {
|
|
dangerousAttrs.forEach(attr => el.removeAttribute(attr));
|
|
for (const attr of Array.from(el.attributes || [])) {
|
|
if (/^on/i.test(attr.name)) el.removeAttribute(attr.name);
|
|
}
|
|
if (el.hasAttribute('href')) {
|
|
const href = el.getAttribute('href').trim().toLowerCase();
|
|
if (/^(javascript|data|vbscript|file):/.test(href)) {
|
|
el.removeAttribute('href');
|
|
}
|
|
}
|
|
if (el.hasAttribute('xlink:href')) {
|
|
const href = el.getAttribute('xlink:href').trim().toLowerCase();
|
|
if (/^(javascript|data|vbscript|file):/.test(href)) {
|
|
el.removeAttribute('xlink:href');
|
|
}
|
|
}
|
|
});
|
|
|
|
return svgEl.outerHTML;
|
|
}
|
|
|
|
/**
|
|
* Update logo preview
|
|
*/
|
|
function updateLogoPreview() {
|
|
const preview = document.getElementById('logo-preview');
|
|
if (!preview) return;
|
|
|
|
const type = document.querySelector('input[name="logo-type"]:checked')?.value || 'icon';
|
|
const name = document.getElementById('brand-name')?.value || 'BetterDesk';
|
|
|
|
if (type === 'text') {
|
|
const logoText = document.getElementById('logo-text-input')?.value || name;
|
|
const accentText = document.getElementById('logo-text-accent')?.value || '';
|
|
const fontHeading = document.getElementById('font-heading-value')?.value || '';
|
|
const fontStyle = fontHeading ? `font-family: '${Utils.escapeHtml(fontHeading)}', sans-serif;` : '';
|
|
let html = `<span class="brand-text-logo brand-text-logo-lg" style="${fontStyle}">${Utils.escapeHtml(logoText)}`;
|
|
if (accentText) {
|
|
html += `<span class="brand-text-accent">${Utils.escapeHtml(accentText)}</span>`;
|
|
}
|
|
html += '</span>';
|
|
preview.innerHTML = html;
|
|
} else if (type === 'svg') {
|
|
const svg = document.getElementById('logo-svg-input')?.value || '';
|
|
if (svg.trim()) {
|
|
preview.innerHTML = `<span class="logo-preview-svg">${sanitizeSvg(svg)}</span>`;
|
|
} else {
|
|
preview.innerHTML = `<span class="material-icons">code</span><span class="logo-preview-text">${Utils.escapeHtml(name)}</span>`;
|
|
}
|
|
} else if (type === 'image') {
|
|
const url = document.getElementById('logo-image-url')?.value || '';
|
|
if (url.trim() && isSafePreviewUrl(url)) {
|
|
preview.innerHTML = `<img src="${Utils.escapeHtml(url)}" alt="${Utils.escapeHtml(name)}" style="max-height: 36px;">`;
|
|
} else {
|
|
preview.innerHTML = `<span class="material-icons">photo</span><span class="logo-preview-text">${Utils.escapeHtml(name)}</span>`;
|
|
}
|
|
} else {
|
|
const icon = document.getElementById('logo-icon-name')?.value || 'dns';
|
|
preview.innerHTML = `<span class="material-icons">${Utils.escapeHtml(icon)}</span><span class="logo-preview-text">${Utils.escapeHtml(name)}</span>`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle logo image file upload — uploads to server disk and fills URL field
|
|
*/
|
|
async function handleLogoFileUpload(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
const maxSize = 2 * 1024 * 1024; // 2 MB
|
|
if (file.size > maxSize) {
|
|
Utils.showNotification(_('branding.logo_image_too_large'), 'error');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
|
|
if (!validTypes.includes(file.type)) {
|
|
Utils.showNotification(_('branding.logo_image_invalid_type'), 'error');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
// Show filename
|
|
const nameEl = document.getElementById('logo-file-name');
|
|
if (nameEl) nameEl.textContent = file.name;
|
|
|
|
// Upload to server
|
|
const formData = new FormData();
|
|
formData.append('logo', file);
|
|
|
|
try {
|
|
const resp = await fetch('/api/settings/branding/upload-logo', {
|
|
method: 'POST',
|
|
headers: { 'x-csrf-token': window.BetterDesk?.csrfToken || '' },
|
|
body: formData
|
|
});
|
|
const result = await resp.json();
|
|
if (!resp.ok || !result.success) {
|
|
throw new Error(result.error || 'Upload failed');
|
|
}
|
|
const urlInput = document.getElementById('logo-image-url');
|
|
if (urlInput) urlInput.value = result.url;
|
|
Notifications.success(_('branding.logo_upload_success'));
|
|
updateLogoPreview();
|
|
} catch (err) {
|
|
Utils.showNotification(err.message || _('errors.server_error'), 'error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize color picker sync (picker <-> hex input)
|
|
*/
|
|
function initColorPickers() {
|
|
// Sync color picker → hex input
|
|
document.querySelectorAll('.color-picker').forEach(picker => {
|
|
picker.addEventListener('input', () => {
|
|
const key = picker.dataset.color;
|
|
const hex = document.querySelector(`.color-hex[data-color="${key}"]`);
|
|
if (hex) hex.value = picker.value;
|
|
});
|
|
});
|
|
|
|
// Sync hex input → color picker
|
|
document.querySelectorAll('.color-hex').forEach(hex => {
|
|
hex.addEventListener('input', () => {
|
|
const key = hex.dataset.color;
|
|
const picker = document.querySelector(`.color-picker[data-color="${key}"]`);
|
|
if (picker && /^#[0-9a-fA-F]{6}$/.test(hex.value)) {
|
|
picker.value = hex.value;
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Collect branding form data
|
|
*/
|
|
function collectBrandingData() {
|
|
const data = {
|
|
appName: document.getElementById('brand-name')?.value || 'BetterDesk',
|
|
appDescription: document.getElementById('brand-description')?.value || '',
|
|
logoType: document.querySelector('input[name="logo-type"]:checked')?.value || 'icon',
|
|
logoIcon: document.getElementById('logo-icon-name')?.value || 'dns',
|
|
logoSvg: document.getElementById('logo-svg-input')?.value || '',
|
|
logoUrl: document.getElementById('logo-image-url')?.value || '',
|
|
logoText: document.getElementById('logo-text-input')?.value || '',
|
|
logoTextAccent: document.getElementById('logo-text-accent')?.value || '',
|
|
fontHeading: document.getElementById('font-heading-value')?.value || '',
|
|
fontBody: document.getElementById('font-body-value')?.value || '',
|
|
colors: {}
|
|
};
|
|
|
|
// Collect colors
|
|
document.querySelectorAll('.color-hex').forEach(hex => {
|
|
const key = hex.dataset.color;
|
|
const value = hex.value.trim();
|
|
if (value && /^#[0-9a-fA-F]{6}$/.test(value)) {
|
|
data.colors[key] = value;
|
|
}
|
|
});
|
|
|
|
// Background & appearance (console)
|
|
data.bgType = document.querySelector('input[name="bg-type"]:checked')?.value || 'none';
|
|
data.bgColor = document.getElementById('bg-color')?.value.trim() || '';
|
|
data.bgGradient = document.getElementById('bg-gradient')?.value.trim() || '';
|
|
data.bgImageUrl = document.getElementById('bg-image-url')?.value.trim() || '';
|
|
data.bgBlur = document.getElementById('bg-blur')?.value || '';
|
|
data.bgOverlay = document.getElementById('bg-overlay')?.value || '';
|
|
data.bgSize = document.getElementById('bg-size')?.value || 'cover';
|
|
|
|
// Login page
|
|
data.loginBgType = document.querySelector('input[name="login-bg-type"]:checked')?.value || 'inherit';
|
|
data.loginBgColor = document.getElementById('login-bg-color')?.value.trim() || '';
|
|
data.loginBgGradient = document.getElementById('login-bg-gradient')?.value.trim() || '';
|
|
data.loginBgImageUrl = document.getElementById('login-bg-image-url')?.value.trim() || '';
|
|
data.loginBgOverlay = document.getElementById('login-bg-overlay')?.value || '';
|
|
data.loginTitle = document.getElementById('login-title')?.value.trim() || '';
|
|
data.loginSubtitle = document.getElementById('login-subtitle')?.value.trim() || '';
|
|
|
|
// Agent download page
|
|
data.agentBgType = document.querySelector('input[name="agent-bg-type"]:checked')?.value || 'none';
|
|
data.agentBgColor = document.getElementById('agent-bg-color')?.value.trim() || '';
|
|
data.agentBgGradient = document.getElementById('agent-bg-gradient')?.value.trim() || '';
|
|
data.agentBgImageUrl = document.getElementById('agent-bg-image-url')?.value.trim() || '';
|
|
data.agentShowPoweredBy = document.getElementById('agent-show-powered')?.checked ? 'true' : 'false';
|
|
|
|
// Footer & custom CSS
|
|
data.footerText = document.getElementById('footer-text')?.value.trim() || '';
|
|
data.showPoweredBy = document.getElementById('show-powered')?.checked ? 'true' : 'false';
|
|
data.customCss = document.getElementById('custom-css')?.value || '';
|
|
|
|
return data;
|
|
}
|
|
|
|
/**
|
|
* Font picker state
|
|
*/
|
|
let _fontSearchTimeout = null;
|
|
let _fontCategory = '';
|
|
let _fontPreviewLinks = {};
|
|
|
|
/**
|
|
* Set font picker value
|
|
*/
|
|
function setFontPickerValue(slot, family) {
|
|
const valueInput = document.getElementById(`font-${slot}-value`);
|
|
const currentLabel = document.getElementById(`font-${slot}-current`);
|
|
const preview = document.getElementById(`font-${slot}-preview`);
|
|
const clearBtn = document.querySelector(`#font-${slot}-slot .font-clear-btn`);
|
|
|
|
if (valueInput) valueInput.value = family || '';
|
|
if (currentLabel) currentLabel.textContent = family || _('branding.font_system_default');
|
|
if (clearBtn) clearBtn.style.display = family ? 'inline-flex' : 'none';
|
|
|
|
if (preview) {
|
|
if (family) {
|
|
loadFontPreview(family);
|
|
preview.style.fontFamily = `'${family}', sans-serif`;
|
|
} else {
|
|
preview.style.fontFamily = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load font preview via Google Fonts CSS
|
|
*/
|
|
function loadFontPreview(family) {
|
|
const key = family.replace(/\s+/g, '+');
|
|
if (_fontPreviewLinks[key]) return;
|
|
const link = document.createElement('link');
|
|
link.rel = 'stylesheet';
|
|
link.href = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}:wght@400;700&display=swap`;
|
|
document.head.appendChild(link);
|
|
_fontPreviewLinks[key] = link;
|
|
}
|
|
|
|
/**
|
|
* Initialize font pickers
|
|
*/
|
|
function initFontPickers() {
|
|
['heading', 'body'].forEach(slot => {
|
|
const searchInput = document.getElementById(`font-${slot}-search`);
|
|
const dropdown = document.getElementById(`font-${slot}-dropdown`);
|
|
const clearBtn = document.querySelector(`#font-${slot}-slot .font-clear-btn`);
|
|
|
|
if (!searchInput || !dropdown) return;
|
|
|
|
// Search input
|
|
searchInput.addEventListener('input', () => {
|
|
clearTimeout(_fontSearchTimeout);
|
|
_fontSearchTimeout = setTimeout(() => {
|
|
searchFonts(slot, searchInput.value.trim());
|
|
}, 300);
|
|
});
|
|
|
|
searchInput.addEventListener('focus', () => {
|
|
if (!dropdown.children.length) {
|
|
searchFonts(slot, '');
|
|
}
|
|
dropdown.style.display = 'block';
|
|
});
|
|
|
|
// Close dropdown on outside click
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest(`#font-${slot}-slot`)) {
|
|
dropdown.style.display = 'none';
|
|
}
|
|
});
|
|
|
|
// Clear button
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener('click', () => {
|
|
setFontPickerValue(slot, '');
|
|
updateLogoPreview();
|
|
});
|
|
}
|
|
});
|
|
|
|
// Category filter buttons
|
|
document.querySelectorAll('.font-cat-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.font-cat-btn').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
_fontCategory = btn.dataset.category || '';
|
|
// Re-search both slots with new category
|
|
['heading', 'body'].forEach(slot => {
|
|
const searchInput = document.getElementById(`font-${slot}-search`);
|
|
const dropdown = document.getElementById(`font-${slot}-dropdown`);
|
|
if (searchInput && dropdown && dropdown.style.display === 'block') {
|
|
searchFonts(slot, searchInput.value.trim());
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Load local fonts count
|
|
loadLocalFontCount();
|
|
}
|
|
|
|
/**
|
|
* Search fonts and populate dropdown
|
|
*/
|
|
async function searchFonts(slot, query) {
|
|
const dropdown = document.getElementById(`font-${slot}-dropdown`);
|
|
if (!dropdown) return;
|
|
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (query) params.set('q', query);
|
|
if (_fontCategory) params.set('category', _fontCategory);
|
|
|
|
const fonts = await Utils.api(`/api/settings/fonts?${params}`);
|
|
|
|
dropdown.innerHTML = '';
|
|
|
|
if (!fonts || !fonts.length) {
|
|
dropdown.innerHTML = '<div class="font-dropdown-empty">No fonts found</div>';
|
|
dropdown.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
fonts.forEach(font => {
|
|
const item = document.createElement('div');
|
|
item.className = 'font-dropdown-item';
|
|
|
|
loadFontPreview(font.family);
|
|
|
|
item.innerHTML = `
|
|
<span class="font-item-name" style="font-family: '${Utils.escapeHtml(font.family)}', sans-serif">${Utils.escapeHtml(font.family)}</span>
|
|
<span class="font-item-meta">
|
|
<span class="font-item-category">${Utils.escapeHtml(font.category)}</span>
|
|
${font.downloaded ? '<span class="font-item-local" title="Downloaded">●</span>' : ''}
|
|
</span>
|
|
`;
|
|
|
|
item.addEventListener('click', async () => {
|
|
// Auto-download if not yet cached
|
|
if (!font.downloaded) {
|
|
item.classList.add('font-downloading');
|
|
try {
|
|
await Utils.api('/api/settings/fonts/download', {
|
|
method: 'POST',
|
|
body: { family: font.family }
|
|
});
|
|
loadLocalFontCount();
|
|
} catch (e) {
|
|
// Still use via CDN even if download fails
|
|
console.warn('Font download failed, using CDN:', e);
|
|
}
|
|
item.classList.remove('font-downloading');
|
|
}
|
|
|
|
setFontPickerValue(slot, font.family);
|
|
dropdown.style.display = 'none';
|
|
updateLogoPreview();
|
|
});
|
|
|
|
dropdown.appendChild(item);
|
|
});
|
|
|
|
dropdown.style.display = 'block';
|
|
} catch (error) {
|
|
console.error('Font search failed:', error);
|
|
dropdown.innerHTML = '<div class="font-dropdown-empty">Search failed</div>';
|
|
dropdown.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load local font count
|
|
*/
|
|
async function loadLocalFontCount() {
|
|
try {
|
|
const fonts = await Utils.api('/api/settings/fonts/local');
|
|
const counter = document.getElementById('font-local-count');
|
|
if (counter && Array.isArray(fonts)) {
|
|
counter.textContent = fonts.length;
|
|
}
|
|
} catch (e) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize branding action buttons
|
|
*/
|
|
function initBrandingActions() {
|
|
// Save
|
|
document.getElementById('branding-save-btn')?.addEventListener('click', async () => {
|
|
try {
|
|
const data = collectBrandingData();
|
|
await Utils.api('/api/settings/branding', {
|
|
method: 'POST',
|
|
body: data
|
|
});
|
|
Notifications.success(_('branding.saved'));
|
|
|
|
// Reload page to apply changes
|
|
setTimeout(() => window.location.reload(), 800);
|
|
|
|
} catch (error) {
|
|
Notifications.error(error.message || _('errors.server_error'));
|
|
}
|
|
});
|
|
|
|
// Export
|
|
document.getElementById('branding-export-btn')?.addEventListener('click', async () => {
|
|
try {
|
|
const response = await Utils.api('/api/settings/branding/export');
|
|
const blob = new Blob([JSON.stringify(response, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'betterdesk-theme.json';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
Notifications.success(_('branding.exported'));
|
|
} catch (error) {
|
|
Notifications.error(error.message || _('errors.server_error'));
|
|
}
|
|
});
|
|
|
|
// Import
|
|
document.getElementById('branding-import-input')?.addEventListener('change', async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
try {
|
|
const text = await file.text();
|
|
const preset = JSON.parse(text);
|
|
|
|
await Utils.api('/api/settings/branding/import', {
|
|
method: 'POST',
|
|
body: preset
|
|
});
|
|
|
|
Notifications.success(_('branding.imported'));
|
|
setTimeout(() => window.location.reload(), 800);
|
|
|
|
} catch (error) {
|
|
Notifications.error(error.message || _('branding.import_error'));
|
|
}
|
|
|
|
// Reset file input
|
|
e.target.value = '';
|
|
});
|
|
|
|
// Reset
|
|
document.getElementById('branding-reset-btn')?.addEventListener('click', async () => {
|
|
if (!confirm(_('branding.reset_confirm'))) return;
|
|
|
|
try {
|
|
await Utils.api('/api/settings/branding/reset', { method: 'POST' });
|
|
Notifications.success(_('branding.reset_success'));
|
|
setTimeout(() => window.location.reload(), 800);
|
|
} catch (error) {
|
|
Notifications.error(error.message || _('errors.server_error'));
|
|
}
|
|
});
|
|
}
|
|
|
|
// ==================== Backup & Restore ======================================
|
|
|
|
function initBackupSection() {
|
|
loadBackupStats();
|
|
|
|
// Download backup
|
|
document.getElementById('backup-download-btn')?.addEventListener('click', async () => {
|
|
const btn = document.getElementById('backup-download-btn');
|
|
if (!btn) return;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="material-icons spinning">sync</span> ' + _('backup.creating');
|
|
|
|
try {
|
|
const fetchHeaders = {};
|
|
if (window.BetterDesk && window.BetterDesk.csrfToken) {
|
|
fetchHeaders['X-CSRF-Token'] = window.BetterDesk.csrfToken;
|
|
}
|
|
const response = await fetch('/api/settings/backup', {
|
|
credentials: 'same-origin',
|
|
headers: fetchHeaders
|
|
});
|
|
if (!response.ok) throw new Error('Backup failed');
|
|
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
// Prefer the server-provided filename (full backup .tar.gz).
|
|
let filename = '';
|
|
const disp = response.headers.get('Content-Disposition') || '';
|
|
const match = disp.match(/filename="?([^";]+)"?/i);
|
|
if (match) filename = match[1];
|
|
if (!filename) {
|
|
const date = new Date().toISOString().slice(0, 10);
|
|
filename = `betterdesk-backup-${date}.tar.gz`;
|
|
}
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
|
|
Notifications.success(_('backup.download_success'));
|
|
} catch (error) {
|
|
Notifications.error(error.message || _('errors.server_error'));
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<span class="material-icons">download</span> ' + _('backup.download');
|
|
}
|
|
});
|
|
|
|
// Restore from file
|
|
document.getElementById('restore-file-input')?.addEventListener('change', async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
const name = file.name.toLowerCase();
|
|
const isArchive = name.endsWith('.gz') || name.endsWith('.tar.gz');
|
|
const isJson = name.endsWith('.json');
|
|
if (!isArchive && !isJson) {
|
|
Notifications.error(_('backup.invalid_format'));
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
if (!confirm(_('backup.restore_confirm'))) {
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
|
|
const resultEl = document.getElementById('restore-result');
|
|
const label = document.getElementById('restore-upload-label');
|
|
|
|
try {
|
|
// Build FormData with restore options. The server auto-detects the
|
|
// archive format (gzip magic bytes) vs a legacy JSON snapshot.
|
|
const formData = new FormData();
|
|
formData.append('backup', file);
|
|
// Full disaster-recovery archive options
|
|
formData.append('restoreDatabase', document.getElementById('restore-database')?.checked ?? true);
|
|
formData.append('restoreUploads', document.getElementById('restore-uploads')?.checked ?? true);
|
|
formData.append('restoreSecrets', document.getElementById('restore-secrets')?.checked ?? false);
|
|
formData.append('restoreEnv', document.getElementById('restore-env')?.checked ?? false);
|
|
formData.append('restoreGoDb', document.getElementById('restore-godb')?.checked ?? false);
|
|
|
|
if (label) label.classList.add('loading');
|
|
|
|
const headers = {};
|
|
if (window.BetterDesk && window.BetterDesk.csrfToken) {
|
|
headers['X-CSRF-Token'] = window.BetterDesk.csrfToken;
|
|
}
|
|
|
|
const response = await fetch('/api/settings/restore', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: headers,
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
Notifications.success(_('backup.restore_success'));
|
|
showRestoreResult(result.data, resultEl);
|
|
if (result.data && result.data.requiresRestart) {
|
|
Notifications.warning(_('backup.restart_required'));
|
|
}
|
|
} else {
|
|
Notifications.error(result.error || _('errors.server_error'));
|
|
}
|
|
} catch (error) {
|
|
Notifications.error(error.message || _('errors.server_error'));
|
|
} finally {
|
|
e.target.value = '';
|
|
if (label) label.classList.remove('loading');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadBackupStats() {
|
|
try {
|
|
const data = await Utils.api('/api/settings/backup/stats');
|
|
if (!data) return;
|
|
|
|
const setVal = (id, val) => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.textContent = val;
|
|
};
|
|
const yn = (b) => b ? _('common.yes') : _('common.no');
|
|
|
|
setVal('backup-stat-users', data.users || 0);
|
|
setVal('backup-stat-settings', data.settings || 0);
|
|
setVal('backup-stat-folders', data.folders || 0);
|
|
setVal('backup-stat-groups', (data.userGroups || 0) + (data.deviceGroups || 0));
|
|
setVal('backup-stat-strategies', data.strategies || 0);
|
|
setVal('backup-stat-backend', data.backend === 'betterdesk' ? 'BetterDesk Go' : 'RustDesk');
|
|
const comp = data.components || {};
|
|
setVal('backup-stat-database', (data.dbType === 'postgres' ? 'PostgreSQL' : 'SQLite'));
|
|
setVal('backup-stat-keys', yn(comp.goPrivKey || comp.goApiKey));
|
|
setVal('backup-stat-env', yn(comp.env));
|
|
setVal('backup-stat-uploads', comp.uploads || 0);
|
|
} catch { /* silent */ }
|
|
}
|
|
|
|
function showRestoreResult(data, el) {
|
|
if (!el) return;
|
|
el.style.display = 'block';
|
|
|
|
let html = '<div class="restore-result-inner">';
|
|
if (data.restored.length) {
|
|
html += `<p class="restore-ok"><span class="material-icons">check_circle</span> ${_('backup.restored')}: <strong>${Utils.escapeHtml(data.restored.join(', '))}</strong></p>`;
|
|
}
|
|
if (data.skipped.length) {
|
|
html += `<p class="restore-skip"><span class="material-icons">skip_next</span> ${_('backup.skipped')}: ${Utils.escapeHtml(data.skipped.join(', '))}</p>`;
|
|
}
|
|
if (data.warnings && data.warnings.length) {
|
|
html += `<p class="restore-warn"><span class="material-icons">warning</span> ${data.warnings.map(w => Utils.escapeHtml(w)).join('<br>')}</p>`;
|
|
}
|
|
if (data.backupDate) {
|
|
html += `<p class="restore-meta">${_('backup.backup_date')}: ${Utils.escapeHtml(data.backupDate)}</p>`;
|
|
}
|
|
html += '</div>';
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
// ==================== Tutorials ====================
|
|
|
|
function initTutorialSection() {
|
|
const toggle = document.getElementById('tutorials-enabled');
|
|
const resetBtn = document.getElementById('tutorials-reset-btn');
|
|
if (!toggle) return;
|
|
|
|
// Read current state from Tutorial system (localStorage)
|
|
const tutorialDisabled = typeof Tutorial !== 'undefined' ? Tutorial.isDisabled() :
|
|
localStorage.getItem('betterdesk_tutorial_disabled') === 'true';
|
|
toggle.checked = !tutorialDisabled;
|
|
|
|
toggle.addEventListener('change', function() {
|
|
const disabled = !toggle.checked;
|
|
if (typeof Tutorial !== 'undefined') {
|
|
Tutorial.setDisabled(disabled);
|
|
} else {
|
|
localStorage.setItem('betterdesk_tutorial_disabled', disabled ? 'true' : 'false');
|
|
}
|
|
// Notify tutorial.js to show/hide help button
|
|
window.dispatchEvent(new CustomEvent('tutorial:stateChanged', { detail: { disabled: disabled } }));
|
|
|
|
if (typeof Toast !== 'undefined') {
|
|
Toast.success(
|
|
disabled ? _('settings.tutorials_disabled_toast') : _('settings.tutorials_enabled_toast'),
|
|
'', 3000
|
|
);
|
|
}
|
|
});
|
|
|
|
// Listen for changes from help menu toggle
|
|
window.addEventListener('tutorial:stateChanged', function(e) {
|
|
if (e.detail && typeof e.detail.disabled === 'boolean') {
|
|
toggle.checked = !e.detail.disabled;
|
|
}
|
|
});
|
|
|
|
if (resetBtn) {
|
|
resetBtn.addEventListener('click', function() {
|
|
if (typeof Tutorial !== 'undefined') {
|
|
Tutorial.resetTutorial();
|
|
} else {
|
|
localStorage.removeItem('betterdesk_tutorial_seen');
|
|
}
|
|
if (typeof Toast !== 'undefined') {
|
|
Toast.success(_('settings.tutorials_reset_toast'), '', 3000);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ==================== Self-Update ====================
|
|
|
|
let _updateState = { remoteSHA: null, changedData: null };
|
|
let _updateChannelSaved = null;
|
|
let _updateChannelBusy = false;
|
|
|
|
function initUpdateSection() {
|
|
const checkBtn = document.getElementById('update-check-btn');
|
|
const installBtn = document.getElementById('update-install-btn');
|
|
|
|
if (!checkBtn) return;
|
|
|
|
checkBtn.addEventListener('click', checkForUpdates);
|
|
installBtn?.addEventListener('click', installUpdate);
|
|
|
|
const rebuildBtn = document.getElementById('update-rebuild-server-btn');
|
|
rebuildBtn?.addEventListener('click', rebuildServerBinary);
|
|
loadServerBinaryStatus();
|
|
|
|
loadUpdateChannel();
|
|
const channelSelect = document.getElementById('update-channel-select');
|
|
channelSelect?.addEventListener('change', onUpdateChannelSelectChange);
|
|
|
|
loadUpdateBackups();
|
|
loadBackupRetention();
|
|
loadLastUpdateResult();
|
|
}
|
|
|
|
function renderUpdateChannelBadge(data) {
|
|
const badge = document.getElementById('update-channel-active-badge');
|
|
if (!badge || !data?.channel) return;
|
|
const isDev = data.channel === 'development';
|
|
badge.className = `badge badge-sm ${isDev ? 'badge-warning' : 'badge-success'}`;
|
|
badge.textContent = isDev ? _('updates.channel_development') : _('updates.channel_stable');
|
|
}
|
|
|
|
async function loadUpdateChannel() {
|
|
const select = document.getElementById('update-channel-select');
|
|
const branchEl = document.getElementById('update-channel-branch');
|
|
if (!select) return;
|
|
try {
|
|
const payload = await Utils.api('/api/settings/updates/channel');
|
|
const data = payload?.data ?? payload;
|
|
_updateChannelSaved = data?.channel || 'stable';
|
|
if (data?.channel) select.value = data.channel;
|
|
renderUpdateChannelBadge(data);
|
|
if (branchEl) {
|
|
branchEl.textContent = data?.branch
|
|
? `${data.branch} · ${data.owner || 'UNITRONIX'}/${data.repo || 'BetterDesk'}`
|
|
: '—';
|
|
}
|
|
} catch (_e) {
|
|
if (branchEl) branchEl.textContent = '—';
|
|
}
|
|
}
|
|
|
|
function channelToBranch(channel) {
|
|
return channel === 'development' ? 'dev' : 'main';
|
|
}
|
|
|
|
async function onUpdateChannelSelectChange() {
|
|
if (_updateChannelBusy) return;
|
|
const select = document.getElementById('update-channel-select');
|
|
if (!select) return;
|
|
|
|
const channel = select.value;
|
|
if (channel === _updateChannelSaved) return;
|
|
|
|
const previous = _updateChannelSaved || 'stable';
|
|
const confirmed = await Modal.confirm({
|
|
title: _('updates.channel_save'),
|
|
message: _('updates.channel_confirm_switch', {
|
|
from: channelToBranch(previous),
|
|
to: channelToBranch(channel)
|
|
}),
|
|
confirmLabel: _('updates.channel_save'),
|
|
confirmIcon: 'alt_route'
|
|
});
|
|
|
|
if (!confirmed) {
|
|
select.value = _updateChannelSaved || 'stable';
|
|
return;
|
|
}
|
|
|
|
await persistUpdateChannel(channel, select);
|
|
}
|
|
|
|
async function persistUpdateChannel(channel, selectEl) {
|
|
_updateChannelBusy = true;
|
|
if (selectEl) selectEl.disabled = true;
|
|
try {
|
|
const payload = await Utils.api('/api/settings/updates/channel', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ channel })
|
|
});
|
|
const data = payload?.data ?? payload;
|
|
_updateChannelSaved = channel;
|
|
await loadUpdateChannel();
|
|
Notifications.success(_('updates.channel_saved'));
|
|
if (data?.previousBranch && data.previousBranch !== data.branch) {
|
|
_updateState.remoteSHA = null;
|
|
_updateState.changedData = null;
|
|
const detailsSection = document.getElementById('update-details-section');
|
|
if (detailsSection) detailsSection.style.display = 'none';
|
|
const remoteEl = document.getElementById('update-remote-version');
|
|
if (remoteEl) remoteEl.textContent = '—';
|
|
const statusRow = document.getElementById('update-status-row');
|
|
if (statusRow) statusRow.style.display = 'none';
|
|
}
|
|
} catch (error) {
|
|
if (selectEl) selectEl.value = _updateChannelSaved || 'stable';
|
|
Notifications.error(error.message || _('updates.channel_save_failed'));
|
|
} finally {
|
|
_updateChannelBusy = false;
|
|
if (selectEl) selectEl.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function loadLastUpdateResult() {
|
|
const host = document.querySelector('.update-version-info');
|
|
if (!host) return;
|
|
let banner = document.getElementById('update-last-result-banner');
|
|
try {
|
|
const payload = await Utils.api('/api/settings/updates/last-result');
|
|
const record = payload?.data ?? payload;
|
|
if (!record || (!record.failed?.length && !record.servicesFailed?.length && !record.consoleRestartBlocked)) {
|
|
if (banner) banner.remove();
|
|
return;
|
|
}
|
|
if (!banner) {
|
|
banner = document.createElement('div');
|
|
banner.id = 'update-last-result-banner';
|
|
banner.className = 'update-stale-warning';
|
|
banner.style.marginTop = '12px';
|
|
host.appendChild(banner);
|
|
}
|
|
const lines = [];
|
|
lines.push(`<strong>${Utils.escapeHtml(_('updates.complete_with_errors'))}</strong>`);
|
|
lines.push(`<div style="font-size:12px;margin-top:6px;">SHA: ${Utils.escapeHtml(String(record.sha || '').slice(0, 7))} · ${Utils.escapeHtml(record.savedAt || '')}</div>`);
|
|
for (const f of (record.failed || [])) {
|
|
lines.push(`<div style="font-size:12px;color:var(--danger,#e34935);">${Utils.escapeHtml(f.file)}: ${Utils.escapeHtml(f.error || '')}</div>`);
|
|
}
|
|
for (const s of (record.servicesFailed || [])) {
|
|
lines.push(`<div style="font-size:12px;color:var(--danger,#e34935);">${Utils.escapeHtml(s.service)}: ${Utils.escapeHtml(s.error || '')}</div>`);
|
|
}
|
|
if (record.consoleRestartBlocked) {
|
|
lines.push(`<div style="font-size:12px;color:var(--danger,#e34935);">${Utils.escapeHtml(record.consoleRestartBlocked)}</div>`);
|
|
}
|
|
banner.innerHTML = lines.join('');
|
|
} catch (_) {
|
|
/* optional panel */
|
|
}
|
|
}
|
|
|
|
async function loadServerBinaryStatus() {
|
|
const warning = document.getElementById('update-stale-warning');
|
|
if (!warning) return;
|
|
try {
|
|
const status = await Utils.api('/api/settings/updates/server-binary/status');
|
|
if (status && status.dockerMode) {
|
|
warning.style.display = 'none';
|
|
return;
|
|
}
|
|
if (status && status.stale) {
|
|
const detailEl = document.getElementById('update-stale-detail');
|
|
if (detailEl) {
|
|
detailEl.textContent = status.detail
|
|
? `${_('updates.server_stale_desc')} (${status.detail})`
|
|
: _('updates.server_stale_desc');
|
|
}
|
|
warning.style.display = '';
|
|
} else {
|
|
warning.style.display = 'none';
|
|
}
|
|
} catch (_e) {
|
|
warning.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
async function rebuildServerBinary() {
|
|
const btn = document.getElementById('update-rebuild-server-btn');
|
|
if (!btn) return;
|
|
const original = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = `<span class="material-icons spinning">sync</span> ${_('updates.rebuilding_server')}`;
|
|
try {
|
|
const result = await Utils.api('/api/settings/updates/server-binary/rebuild', { method: 'POST' });
|
|
if (result && result.success) {
|
|
Notifications.success(_('updates.rebuild_server_success'));
|
|
await loadServerBinaryStatus();
|
|
} else {
|
|
Notifications.error((result && result.error) || _('updates.rebuild_server_failed'));
|
|
}
|
|
} catch (error) {
|
|
Notifications.error(error.message || _('updates.rebuild_server_failed'));
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = original;
|
|
}
|
|
}
|
|
|
|
function renderDockerUpdatePanel(data) {
|
|
const panel = document.getElementById('update-docker-info');
|
|
const commandsEl = document.getElementById('update-docker-commands');
|
|
const imagesEl = document.getElementById('update-docker-images');
|
|
const installNote = document.getElementById('update-docker-install-note');
|
|
const installBtn = document.getElementById('update-install-btn');
|
|
const staleWarning = document.getElementById('update-stale-warning');
|
|
const dockerMode = data.deploymentMode === 'docker-image' || data.dockerImageMode;
|
|
|
|
if (!panel) return dockerMode;
|
|
|
|
if (dockerMode) {
|
|
panel.style.display = '';
|
|
if (staleWarning) staleWarning.style.display = 'none';
|
|
if (installBtn) installBtn.style.display = 'none';
|
|
if (installNote) installNote.style.display = '';
|
|
|
|
const dockerUpdate = data.dockerUpdate || {};
|
|
const commands = dockerUpdate.commands || ['docker compose pull', 'docker compose up -d'];
|
|
if (commandsEl) {
|
|
commandsEl.textContent = commands.join('\n');
|
|
}
|
|
if (imagesEl && Array.isArray(dockerUpdate.images) && dockerUpdate.images.length) {
|
|
imagesEl.textContent = `${_('updates.docker_images')}: ${dockerUpdate.images.join(', ')}`;
|
|
}
|
|
} else {
|
|
panel.style.display = 'none';
|
|
if (installBtn) installBtn.style.display = '';
|
|
if (installNote) installNote.style.display = 'none';
|
|
}
|
|
|
|
return dockerMode;
|
|
}
|
|
|
|
async function checkForUpdates() {
|
|
const btn = document.getElementById('update-check-btn');
|
|
const statusRow = document.getElementById('update-status-row');
|
|
const statusBadge = document.getElementById('update-status-badge');
|
|
const remoteEl = document.getElementById('update-remote-version');
|
|
const detailsSection = document.getElementById('update-details-section');
|
|
const installBtn = document.getElementById('update-install-btn');
|
|
|
|
if (!btn) return;
|
|
btn.disabled = true;
|
|
btn.innerHTML = `<span class="material-icons spinning">sync</span> ${_('updates.checking')}`;
|
|
|
|
try {
|
|
const data = await Utils.api('/api/settings/updates/check');
|
|
const dockerMode = renderDockerUpdatePanel(data);
|
|
|
|
// Show commit SHA + message
|
|
if (remoteEl) {
|
|
const sha = data.remoteSHA ? data.remoteSHA.slice(0, 7) : '—';
|
|
remoteEl.textContent = sha;
|
|
if (data.latestMessage) remoteEl.title = data.latestMessage;
|
|
}
|
|
if (statusRow) statusRow.style.display = '';
|
|
|
|
if (data.baselineEstablished) {
|
|
if (statusBadge) statusBadge.innerHTML = `<span class="badge badge-info">${_('updates.baseline_set')}</span>`;
|
|
if (detailsSection) detailsSection.style.display = 'none';
|
|
if (installBtn) installBtn.disabled = true;
|
|
} else if (data.updateAvailable) {
|
|
const behind = data.commitsBehind > 0 ? ` (${data.commitsBehind} ${_('updates.commits_behind')})` : '';
|
|
const badgeLabel = dockerMode ? _('updates.docker_update_available') : _('updates.update_available');
|
|
if (statusBadge) statusBadge.innerHTML = `<span class="badge badge-warning">${badgeLabel}${behind}</span>`;
|
|
|
|
_updateState.remoteSHA = data.remoteSHA;
|
|
|
|
// Fetch changed files
|
|
try {
|
|
const changes = await Utils.api(`/api/settings/updates/changes?sha=${data.remoteSHA}`);
|
|
_updateState.changedData = changes;
|
|
renderUpdateDetails(data, changes, dockerMode);
|
|
if (installBtn) installBtn.disabled = dockerMode;
|
|
} catch (_e) {
|
|
const cl = document.getElementById('update-changelog');
|
|
if (cl) cl.innerHTML = `<p class="text-muted">${_('updates.changes_unavailable')}</p>`;
|
|
if (installBtn) installBtn.disabled = dockerMode;
|
|
}
|
|
|
|
if (detailsSection) detailsSection.style.display = '';
|
|
} else {
|
|
if (statusBadge) {
|
|
const label = data.dockerShaUnknown ? _('updates.docker_sha_unknown') : _('updates.up_to_date');
|
|
statusBadge.innerHTML = `<span class="badge badge-success">${label}</span>`;
|
|
}
|
|
if (detailsSection) detailsSection.style.display = 'none';
|
|
if (installBtn) installBtn.disabled = true;
|
|
}
|
|
} catch (error) {
|
|
Notifications.error(error.message || _('errors.server_error'));
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = `<span class="material-icons">refresh</span> ${_('updates.check_now')}`;
|
|
}
|
|
}
|
|
|
|
function renderUpdateDetails(checkData, changesData, dockerMode = false) {
|
|
const changelogEl = document.getElementById('update-changelog');
|
|
const summaryEl = document.getElementById('update-files-summary');
|
|
|
|
// ---- Recent commits ----
|
|
if (changelogEl) {
|
|
const commits = changesData.commits || [];
|
|
if (commits.length > 0) {
|
|
let html = '<div class="update-commits">';
|
|
for (const c of commits.slice(0, 20)) {
|
|
const d = c.date ? new Date(c.date).toLocaleDateString() : '';
|
|
html += `<div class="update-commit-item">
|
|
<code class="update-commit-sha">${Utils.escapeHtml(c.sha || '')}</code>
|
|
<span class="update-commit-msg">${Utils.escapeHtml(c.message || '')}</span>
|
|
<span class="update-commit-meta">${Utils.escapeHtml(c.author || '')} · ${d}</span>
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
changelogEl.innerHTML = html;
|
|
} else {
|
|
changelogEl.innerHTML = `<p class="text-muted">${_('updates.no_changelog')}</p>`;
|
|
}
|
|
}
|
|
|
|
// ---- Component breakdown ----
|
|
if (summaryEl) {
|
|
const grouped = changesData.grouped || {};
|
|
const meta = {
|
|
console: { icon: 'web', label: _('updates.component_console'), auto: true },
|
|
server: { icon: 'dns', label: _('updates.component_server'), auto: true },
|
|
agent: { icon: 'smart_toy', label: _('updates.component_agent'), auto: false },
|
|
scripts: { icon: 'terminal', label: _('updates.component_scripts'), auto: true },
|
|
other: { icon: 'folder', label: _('updates.component_other'), auto: false }
|
|
};
|
|
|
|
let html = '<div class="update-components">';
|
|
for (const [comp, files] of Object.entries(grouped)) {
|
|
if (!files || files.length === 0) continue;
|
|
const m = meta[comp] || meta.other;
|
|
const badge = m.auto
|
|
? `<span class="badge badge-success badge-sm">${_('updates.auto')}</span>`
|
|
: `<span class="badge badge-warning badge-sm">${_('updates.manual')}</span>`;
|
|
html += `<div class="update-component-row">
|
|
<span class="material-icons">${m.icon}</span>
|
|
<span class="update-component-label">${m.label}</span>
|
|
<span class="update-component-count">${files.length} ${_('updates.files')}</span>
|
|
${badge}
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
html += `<p class="text-muted" style="margin-top:8px;font-size:12px;">${_('updates.total_files')}: <strong>${changesData.totalFiles || 0}</strong></p>`;
|
|
|
|
if (grouped.server?.length > 0) {
|
|
html += `<div class="update-server-section" style="margin-top:12px;padding:12px;border:1px solid var(--border-color, #333);border-radius:8px;">
|
|
<div style="display:flex;align-items:flex-start;gap:10px;margin-bottom:8px;">
|
|
<span class="material-icons" style="font-size:20px;color:var(--primary);">auto_mode</span>
|
|
<div style="flex:1;min-width:0;">
|
|
<div style="font-weight:600;color:var(--text-primary);">${_('updates.include_server')}</div>
|
|
<div class="text-muted" style="font-size:12px;margin-top:2px;">${_('updates.auto_strategy_hint')}</div>
|
|
</div>
|
|
<span id="update-server-status" class="badge badge-warning badge-sm">${_('updates.checking_go')}</span>
|
|
</div>
|
|
<div id="update-server-info" class="text-muted" style="font-size:11px;"></div>
|
|
</div>`;
|
|
}
|
|
|
|
summaryEl.innerHTML = html;
|
|
|
|
// Check Go availability when server section is shown
|
|
if (grouped.server?.length > 0 && !dockerMode) {
|
|
checkServerBuildInfo();
|
|
}
|
|
}
|
|
}
|
|
|
|
async function checkServerBuildInfo() {
|
|
const statusEl = document.getElementById('update-server-status');
|
|
const infoEl = document.getElementById('update-server-info');
|
|
if (!statusEl) return;
|
|
|
|
try {
|
|
const info = await Utils.api('/api/settings/updates/server-info');
|
|
const hasGo = !!info.goAvailable && info.goMeetsMinimum !== false;
|
|
const goNeedsUpgrade = !!info.goAvailable && info.goMeetsMinimum === false;
|
|
const prebuilt = info.prebuilt || {};
|
|
const hasDownload = !!prebuilt.available;
|
|
const canInstallGo = !!info.canInstallGo;
|
|
const vendoredReady = !!info.vendoredGoInstalled;
|
|
|
|
// Status badge
|
|
if (hasGo && hasDownload) {
|
|
statusEl.className = 'badge badge-success badge-sm';
|
|
statusEl.textContent = _('updates.both_available');
|
|
} else if (hasDownload) {
|
|
statusEl.className = 'badge badge-info badge-sm';
|
|
statusEl.textContent = _('updates.download_available');
|
|
} else if (hasGo) {
|
|
statusEl.className = 'badge badge-success badge-sm';
|
|
statusEl.textContent = info.goVersion ? info.goVersion.replace('go version ', '') : 'Go';
|
|
} else if (goNeedsUpgrade || canInstallGo) {
|
|
statusEl.className = 'badge badge-info badge-sm';
|
|
statusEl.textContent = vendoredReady ? _('updates.toolchain_ready') : _('updates.auto_available');
|
|
} else {
|
|
statusEl.className = 'badge badge-warning badge-sm';
|
|
statusEl.textContent = _('updates.no_method');
|
|
}
|
|
|
|
// Info line
|
|
if (infoEl) {
|
|
const parts = [];
|
|
if (info.binaryPath) parts.push(`Binary: ${info.binaryPath}`);
|
|
if (info.sourcePresent) parts.push('Source: present');
|
|
if (hasDownload && prebuilt.releaseName) parts.push(`Release: ${prebuilt.releaseName}`);
|
|
if (info.goSource && info.goSource !== 'path') parts.push(`Go: ${info.goSource}`);
|
|
if (goNeedsUpgrade) parts.push(_('updates.toolchain_will_install'));
|
|
if (!hasGo && !hasDownload && canInstallGo) parts.push(_('updates.toolchain_will_install'));
|
|
infoEl.textContent = parts.join(' · ');
|
|
}
|
|
} catch (_e) {
|
|
statusEl.className = 'badge badge-warning badge-sm';
|
|
statusEl.textContent = _('updates.go_check_failed');
|
|
}
|
|
}
|
|
|
|
function getUpdateScopeLabels() {
|
|
const grouped = _updateState.changedData?.grouped || {};
|
|
const labels = {
|
|
console: _('updates.component_console'),
|
|
server: _('updates.component_server'),
|
|
scripts: _('updates.component_scripts'),
|
|
agent: _('updates.component_agent'),
|
|
other: _('updates.component_other')
|
|
};
|
|
return Object.entries(grouped)
|
|
.filter(([, files]) => files?.length > 0)
|
|
.map(([component, files]) => `${labels[component] || labels.other}: ${files.length} ${_('updates.files')}`);
|
|
}
|
|
|
|
// ---------- Update progress modal ----------
|
|
|
|
const UPDATE_PHASES = [
|
|
{ id: 'confirm', icon: 'task_alt', key: 'updates.phase_confirm' },
|
|
{ id: 'backup', icon: 'inventory', key: 'updates.phase_backup' },
|
|
{ id: 'console', icon: 'cloud_download', key: 'updates.phase_console' },
|
|
{ id: 'server', icon: 'memory', key: 'updates.phase_server' },
|
|
{ id: 'restart', icon: 'restart_alt', key: 'updates.phase_restart' },
|
|
{ id: 'done', icon: 'check_circle', key: 'updates.phase_done' }
|
|
];
|
|
|
|
function buildUpdateModalContent() {
|
|
const items = UPDATE_PHASES.map(p => `
|
|
<div class="update-phase" data-phase="${p.id}">
|
|
<span class="update-phase-icon material-icons">${p.icon}</span>
|
|
<span class="update-phase-label">${_(p.key)}</span>
|
|
<span class="update-phase-state" data-phase-state="${p.id}">
|
|
<span class="material-icons">radio_button_unchecked</span>
|
|
</span>
|
|
</div>
|
|
`).join('');
|
|
return `
|
|
<div class="update-progress-modal">
|
|
<div class="update-progress-bar"><div class="update-progress-bar-fill" id="update-modal-bar" style="width:0%"></div></div>
|
|
<div class="update-phases">${items}</div>
|
|
<div class="update-progress-detail" id="update-modal-detail">${_('updates.preparing')}</div>
|
|
<pre class="update-progress-log" id="update-modal-log" aria-live="polite"></pre>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function setUpdatePhase(phaseId, state, detail) {
|
|
// state: 'pending' | 'active' | 'done' | 'warning' | 'error' | 'skipped'
|
|
const stateEl = document.querySelector(`[data-phase-state="${phaseId}"]`);
|
|
if (stateEl) {
|
|
const icons = {
|
|
pending: 'radio_button_unchecked',
|
|
active: 'sync',
|
|
done: 'check_circle',
|
|
warning: 'warning',
|
|
error: 'error',
|
|
skipped: 'remove_circle_outline'
|
|
};
|
|
const cls = {
|
|
pending: '',
|
|
active: 'spinning',
|
|
done: '',
|
|
warning: '',
|
|
error: '',
|
|
skipped: ''
|
|
};
|
|
stateEl.innerHTML = `<span class="material-icons ${cls[state] || ''}">${icons[state] || 'help'}</span>`;
|
|
stateEl.dataset.state = state;
|
|
}
|
|
if (detail) {
|
|
const det = document.getElementById('update-modal-detail');
|
|
if (det) det.textContent = detail;
|
|
}
|
|
// Advance progress bar based on phase index
|
|
const idx = UPDATE_PHASES.findIndex(p => p.id === phaseId);
|
|
if (idx >= 0) {
|
|
const pct = state === 'done' ? Math.round(((idx + 1) / UPDATE_PHASES.length) * 100)
|
|
: state === 'active' ? Math.round((idx / UPDATE_PHASES.length) * 100)
|
|
: null;
|
|
const bar = document.getElementById('update-modal-bar');
|
|
if (bar && pct !== null) bar.style.width = pct + '%';
|
|
}
|
|
}
|
|
|
|
function logUpdate(line) {
|
|
const log = document.getElementById('update-modal-log');
|
|
if (!log) return;
|
|
const ts = new Date().toLocaleTimeString();
|
|
log.textContent += `[${ts}] ${line}\n`;
|
|
log.scrollTop = log.scrollHeight;
|
|
}
|
|
|
|
function reloadConsole() {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set('_bd_reload', Date.now().toString());
|
|
window.location.replace(url.toString());
|
|
}
|
|
|
|
async function installUpdate() {
|
|
const installBtn = document.getElementById('update-install-btn');
|
|
|
|
if (!_updateState.remoteSHA) {
|
|
Notifications.error(_('updates.no_version'));
|
|
return;
|
|
}
|
|
|
|
const hasServerUpdate = (_updateState.changedData?.grouped?.server || []).length > 0;
|
|
const createBackup = document.getElementById('update-backup-toggle')?.checked ?? true;
|
|
|
|
// Preflight checks (issue #158)
|
|
let preflightWarnings = [];
|
|
try {
|
|
const pfUrl = hasServerUpdate
|
|
? '/api/settings/updates/preflight?serverUpdate=1'
|
|
: '/api/settings/updates/preflight';
|
|
const pf = await Utils.api(pfUrl);
|
|
if (pf && pf.ready === false && Array.isArray(pf.issues) && pf.issues.length) {
|
|
Notifications.error(pf.issues.join('; '));
|
|
return;
|
|
}
|
|
if (pf && Array.isArray(pf.warnings)) preflightWarnings = pf.warnings;
|
|
} catch (_e) { /* non-blocking if preflight endpoint unavailable */ }
|
|
|
|
// Pre-flight confirmation modal
|
|
const scopeItems = getUpdateScopeLabels();
|
|
const strategyNote = hasServerUpdate ? _('updates.auto_strategy_hint') : '';
|
|
const warnHtml = preflightWarnings.length
|
|
? `<p class="text-muted" style="font-size:12px;margin-top:8px;">${preflightWarnings.map(w => Utils.escapeHtml(w)).join('<br>')}</p>`
|
|
: '';
|
|
const confirmHtml = `
|
|
<p>${Utils.escapeHtml(_('updates.install_confirm'))}</p>
|
|
<ul style="margin:8px 0 0 0;padding-left:20px;font-size:13px;color:var(--text-secondary);">
|
|
<li>${Utils.escapeHtml(createBackup ? _('updates.confirm_with_backup') : _('updates.confirm_no_backup'))}</li>
|
|
${scopeItems.map(item => `<li>${Utils.escapeHtml(item)}</li>`).join('')}
|
|
${hasServerUpdate ? `<li>${Utils.escapeHtml(strategyNote || '')}</li>` : ''}
|
|
</ul>
|
|
${warnHtml}
|
|
`;
|
|
const proceed = await new Promise((resolve) => {
|
|
window.Modal.show({
|
|
title: _('updates.confirm_title'),
|
|
content: confirmHtml,
|
|
buttons: [
|
|
{ label: _('actions.cancel'), class: 'btn-secondary', onClick: () => { window.Modal.close(); resolve(false); } },
|
|
{ label: _('updates.install'), class: 'btn-primary', icon: 'system_update', onClick: () => { window.Modal.close(); resolve(true); } }
|
|
],
|
|
closable: true,
|
|
onClose: () => resolve(false)
|
|
});
|
|
});
|
|
if (!proceed) return;
|
|
|
|
if (installBtn) installBtn.disabled = true;
|
|
|
|
// Open progress modal (non-closable while running)
|
|
window.Modal.show({
|
|
title: _('updates.modal_title'),
|
|
content: buildUpdateModalContent(),
|
|
buttons: [],
|
|
closable: false,
|
|
size: 'large'
|
|
});
|
|
|
|
UPDATE_PHASES.forEach(p => setUpdatePhase(p.id, 'pending'));
|
|
setUpdatePhase('confirm', 'done');
|
|
if (createBackup) setUpdatePhase('backup', 'active', _('updates.creating_backup'));
|
|
else setUpdatePhase('backup', 'skipped', _('updates.backup_skipped'));
|
|
logUpdate(`Starting update to ${_updateState.remoteSHA.slice(0, 7)}…`);
|
|
|
|
try {
|
|
// Console download phase indicator (we cannot stream backend
|
|
// progress today, so we just mark it active until response arrives)
|
|
setTimeout(() => {
|
|
setUpdatePhase('backup', 'done');
|
|
setUpdatePhase('console', 'active', _('updates.downloading'));
|
|
}, 800);
|
|
|
|
if (hasServerUpdate) {
|
|
setTimeout(() => {
|
|
setUpdatePhase('console', 'done');
|
|
setUpdatePhase('server', 'active', _('updates.server_processing'));
|
|
}, 4000);
|
|
}
|
|
|
|
const result = await Utils.api('/api/settings/updates/install', {
|
|
method: 'POST',
|
|
body: { remoteSHA: _updateState.remoteSHA, createBackup }
|
|
});
|
|
|
|
// Mark earlier phases done if not already
|
|
['backup', 'console'].forEach(p => {
|
|
const st = document.querySelector(`[data-phase-state="${p}"]`)?.dataset?.state;
|
|
if (st !== 'done' && st !== 'skipped') setUpdatePhase(p, 'done');
|
|
});
|
|
|
|
// Server result
|
|
if (hasServerUpdate) {
|
|
if (result.toolchainInstall) {
|
|
if (result.toolchainInstall.success) {
|
|
logUpdate(`Go toolchain ready: ${result.toolchainInstall.version || ''}`.trim());
|
|
} else {
|
|
logUpdate(`Go toolchain install failed: ${result.toolchainInstall.error || 'unknown'}`);
|
|
}
|
|
}
|
|
const deployFailed = !!(result.serverDeploy && result.serverDeploy.success === false);
|
|
if (result.serverBuild) {
|
|
if (result.serverBuild.success && !deployFailed) {
|
|
const ms = result.serverBuild.duration || 0;
|
|
const secs = ms ? Math.round(ms / 1000) : 0;
|
|
const sizeMB = result.serverBuild.size ? ` (${(result.serverBuild.size / (1024 * 1024)).toFixed(1)} MB)` : '';
|
|
const detail = result.serverBuild.method === 'download'
|
|
? `${_('updates.server_downloaded')}${sizeMB}`
|
|
: `${_('updates.server_built')}${secs ? ` · ${secs}s` : ''}`;
|
|
setUpdatePhase('server', 'done', detail);
|
|
logUpdate(detail);
|
|
} else if (result.serverBuild.success && deployFailed) {
|
|
// Build OK but deploy to service path failed — surface as error
|
|
const detail = _('updates.server_deploy_failed');
|
|
setUpdatePhase('server', 'error', detail);
|
|
logUpdate(`${detail}: ${result.serverDeploy.error || ''}`);
|
|
} else {
|
|
const detail = result.serverBuild.method === 'download'
|
|
? _('updates.server_download_failed')
|
|
: _('updates.server_build_failed');
|
|
setUpdatePhase('server', 'error', detail);
|
|
logUpdate(`${detail}: ${result.serverBuild.error || ''}`);
|
|
}
|
|
} else {
|
|
setUpdatePhase('server', 'skipped', _('updates.server_skipped'));
|
|
}
|
|
if (result.autoRebuild) {
|
|
if (result.autoRebuild.success) {
|
|
logUpdate(_('updates.server_built') + ' (auto-rebuild)');
|
|
setUpdatePhase('server', 'done', _('updates.server_built'));
|
|
} else if (result.autoRebuild.error) {
|
|
logUpdate(`Auto-rebuild: ${result.autoRebuild.error}`);
|
|
}
|
|
}
|
|
if (deployFailed && result.serverBuild?.success) {
|
|
logUpdate(`Deploy failed: ${result.serverDeploy.error || ''}`);
|
|
}
|
|
} else {
|
|
setUpdatePhase('server', 'skipped', _('updates.server_skipped'));
|
|
}
|
|
|
|
const applied = result.applied?.length || 0;
|
|
const failed = result.failed?.length || 0;
|
|
const removed = result.removed?.length || 0;
|
|
logUpdate(`${_('updates.applied')}: ${applied} · ${_('updates.failed')}: ${failed} · ${_('updates.removed')}: ${removed}`);
|
|
for (const item of (result.failed || [])) {
|
|
logUpdate(`${item.file}: ${item.error || ''}`);
|
|
}
|
|
for (const item of (result.servicesFailed || [])) {
|
|
logUpdate(`${item.service}: ${item.error || ''}`);
|
|
}
|
|
|
|
if (result.needsConsoleRestart && !result.consoleRestartBlocked) {
|
|
setUpdatePhase('restart', 'active', _('updates.restarting'));
|
|
logUpdate(_('updates.console_will_restart'));
|
|
setTimeout(() => pollConsoleRestart(), 2500);
|
|
} else if (result.consoleRestartBlocked) {
|
|
setUpdatePhase('restart', 'error', result.consoleRestartBlocked);
|
|
logUpdate(result.consoleRestartBlocked);
|
|
setUpdatePhase('done', 'done', _('updates.complete_with_errors'));
|
|
showUpdateCompletionModal(result);
|
|
if (installBtn) installBtn.disabled = false;
|
|
loadServerBinaryStatus();
|
|
} else {
|
|
setUpdatePhase('restart', 'skipped', _('updates.no_restart_needed'));
|
|
setUpdatePhase('done', 'done', _('updates.complete'));
|
|
showUpdateCompletionModal(result);
|
|
if (installBtn) installBtn.disabled = false;
|
|
loadServerBinaryStatus();
|
|
}
|
|
} catch (error) {
|
|
const activePhase = UPDATE_PHASES.find(p => {
|
|
const st = document.querySelector(`[data-phase-state="${p.id}"]`)?.dataset?.state;
|
|
return st === 'active';
|
|
});
|
|
if (activePhase) setUpdatePhase(activePhase.id, 'error', error.message || _('updates.install_failed'));
|
|
logUpdate(`ERROR: ${error.message || error}`);
|
|
|
|
// Replace empty footer with a Close button so the user can dismiss
|
|
window.Modal.close();
|
|
await window.Modal.alert({
|
|
title: _('updates.install_failed'),
|
|
message: error.message || _('errors.server_error')
|
|
});
|
|
Notifications.error(error.message || _('updates.install_failed'));
|
|
if (installBtn) installBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function showUpdateCompletionModal(result) {
|
|
const lines = [];
|
|
const deployFailed = !!(result.serverDeploy && result.serverDeploy.success === false);
|
|
const hasFailures = (result.failed?.length || 0) > 0
|
|
|| (result.servicesFailed?.length || 0) > 0
|
|
|| !!result.consoleRestartBlocked
|
|
|| deployFailed;
|
|
const summaryKey = hasFailures ? 'updates.complete_with_errors' : 'updates.complete_summary';
|
|
lines.push(`<p>${Utils.escapeHtml(_(summaryKey))}</p>`);
|
|
const stats = [
|
|
{ label: _('updates.applied'), value: result.applied?.length || 0 },
|
|
{ label: _('updates.failed'), value: result.failed?.length || 0 },
|
|
{ label: _('updates.removed'), value: result.removed?.length || 0 }
|
|
];
|
|
lines.push(`<ul style="margin:8px 0;padding-left:20px;font-size:13px;">${stats.map(s => `<li>${Utils.escapeHtml(s.label)}: <strong>${s.value}</strong></li>`).join('')}</ul>`);
|
|
if (result.failed?.length) {
|
|
lines.push(`<ul style="margin:8px 0;padding-left:20px;font-size:12px;color:var(--danger,#e34935);">${result.failed.map(f =>
|
|
`<li><strong>${Utils.escapeHtml(f.file || 'unknown')}</strong>${f.error ? `: ${Utils.escapeHtml(f.error)}` : ''}</li>`
|
|
).join('')}</ul>`);
|
|
}
|
|
if (result.servicesFailed?.length) {
|
|
lines.push(`<ul style="margin:8px 0;padding-left:20px;font-size:12px;color:var(--danger,#e34935);">${result.servicesFailed.map(s =>
|
|
`<li><strong>${Utils.escapeHtml(s.service || 'service')}</strong>${s.error ? `: ${Utils.escapeHtml(s.error)}` : ''}</li>`
|
|
).join('')}</ul>`);
|
|
}
|
|
if (result.consoleRestartBlocked) {
|
|
lines.push(`<p style="font-size:12px;color:var(--danger,#e34935);">${Utils.escapeHtml(result.consoleRestartBlocked)}</p>`);
|
|
}
|
|
if (deployFailed) {
|
|
const errMsg = result.serverDeploy.error || '';
|
|
lines.push(`<p style="font-size:13px;color:var(--danger,#e34935);"><strong>${Utils.escapeHtml(_('updates.server_deploy_failed'))}</strong></p>`);
|
|
if (errMsg) lines.push(`<pre style="font-size:12px;background:var(--bg-secondary,#1a1a1a);padding:8px;border-radius:4px;overflow:auto;max-height:120px;white-space:pre-wrap;">${Utils.escapeHtml(errMsg)}</pre>`);
|
|
} else if (result.serverBuild?.success) {
|
|
const note = result.serverBuild.method === 'download' ? _('updates.server_downloaded') : _('updates.server_built');
|
|
lines.push(`<p style="font-size:13px;color:var(--text-secondary);">${Utils.escapeHtml(note)}</p>`);
|
|
}
|
|
// Some updates require manual reload (e.g., static asset changes)
|
|
const needsReload = (result.applied || []).some(p => /\.(js|css|html|ejs)$/i.test(p));
|
|
if (needsReload) {
|
|
lines.push(`<p style="font-size:13px;margin-top:8px;">${Utils.escapeHtml(_('updates.refresh_recommended'))}</p>`);
|
|
}
|
|
|
|
window.Modal.close();
|
|
window.Modal.show({
|
|
title: _(hasFailures ? 'updates.modal_done_with_errors_title' : 'updates.modal_done_title'),
|
|
content: lines.join(''),
|
|
buttons: [
|
|
{ label: _('updates.modal_close'), class: 'btn-secondary', onClick: () => { window.Modal.close(); } },
|
|
{ label: _('updates.modal_reload_now'), class: 'btn-primary', icon: 'refresh', onClick: reloadConsole }
|
|
],
|
|
closable: !hasFailures
|
|
});
|
|
loadLastUpdateResult();
|
|
}
|
|
|
|
function pollConsoleRestart() {
|
|
let attempts = 0;
|
|
const maxAttempts = 90;
|
|
const previousCacheVersion = window.BetterDesk?.cacheVersion || '';
|
|
const interval = setInterval(async () => {
|
|
attempts++;
|
|
setUpdatePhase('restart', 'active', `${_('updates.restarting')} (${attempts}/${maxAttempts})`);
|
|
try {
|
|
const resp = await fetch('/api/settings/restart-status?_=' + Date.now(), {
|
|
credentials: 'same-origin',
|
|
cache: 'no-store',
|
|
headers: { 'Cache-Control': 'no-cache' }
|
|
});
|
|
if (resp.ok) {
|
|
const body = await resp.json().catch(() => null);
|
|
const status = body?.data || body || {};
|
|
if (previousCacheVersion && status.cacheVersion && status.cacheVersion === previousCacheVersion) {
|
|
return;
|
|
}
|
|
|
|
clearInterval(interval);
|
|
setUpdatePhase('restart', 'done', _('updates.restart_complete'));
|
|
setUpdatePhase('done', 'done', _('updates.complete'));
|
|
logUpdate(_('updates.restart_complete'));
|
|
|
|
// Final modal: tell operator to refresh
|
|
window.Modal.close();
|
|
window.Modal.show({
|
|
title: _('updates.modal_done_title'),
|
|
content: `<p>${Utils.escapeHtml(_('updates.restart_complete_msg'))}</p>`,
|
|
buttons: [
|
|
{ label: _('updates.modal_close'), class: 'btn-secondary', onClick: () => { window.Modal.close(); } },
|
|
{ label: _('updates.modal_reload_now'), class: 'btn-primary', icon: 'refresh', onClick: reloadConsole }
|
|
],
|
|
closable: true
|
|
});
|
|
|
|
// Auto-reload after a short grace period so operators do
|
|
// not have to click — gives them time to read the modal.
|
|
setTimeout(reloadConsole, 8000);
|
|
return;
|
|
}
|
|
} catch (_e) {
|
|
// Server still down, keep polling
|
|
}
|
|
if (attempts >= maxAttempts) {
|
|
clearInterval(interval);
|
|
setUpdatePhase('restart', 'warning', _('updates.restart_timeout'));
|
|
setUpdatePhase('done', 'done', _('updates.complete'));
|
|
logUpdate(_('updates.restart_timeout'));
|
|
|
|
const installBtn = document.getElementById('update-install-btn');
|
|
if (installBtn) installBtn.disabled = false;
|
|
|
|
window.Modal.close();
|
|
window.Modal.show({
|
|
title: _('updates.modal_done_title'),
|
|
content: `<p>${Utils.escapeHtml(_('updates.restart_timeout'))}</p><p>${Utils.escapeHtml(_('updates.refresh_recommended'))}</p>`,
|
|
buttons: [
|
|
{ label: _('updates.modal_close'), class: 'btn-secondary', onClick: () => { window.Modal.close(); } },
|
|
{ label: _('updates.modal_reload_now'), class: 'btn-primary', icon: 'refresh', onClick: reloadConsole }
|
|
],
|
|
closable: true
|
|
});
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
function formatBytes(n) {
|
|
if (!Number.isFinite(n) || n <= 0) return '0 B';
|
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
let i = 0;
|
|
let v = n;
|
|
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
|
|
return `${v.toFixed(v >= 100 || i === 0 ? 0 : 1)} ${units[i]}`;
|
|
}
|
|
|
|
async function loadBackupRetention() {
|
|
const input = document.getElementById('backup-retention-input');
|
|
if (!input) return;
|
|
try {
|
|
const data = await Utils.api('/api/settings/backup/retention');
|
|
const count = (data && typeof data.count === 'number') ? data.count : 0;
|
|
input.value = String(count);
|
|
} catch {
|
|
input.value = '0';
|
|
}
|
|
}
|
|
|
|
async function saveBackupRetention() {
|
|
const input = document.getElementById('backup-retention-input');
|
|
if (!input) return;
|
|
const count = parseInt(input.value, 10);
|
|
if (!Number.isFinite(count) || count < 0 || count > 1000) {
|
|
Notifications.error(_('updates.retention_invalid'));
|
|
return;
|
|
}
|
|
try {
|
|
await Utils.api('/api/settings/backup/retention', {
|
|
method: 'PUT',
|
|
body: { count }
|
|
});
|
|
Notifications.success(_('updates.retention_saved'));
|
|
} catch (err) {
|
|
Notifications.error(err.message || _('errors.server_error'));
|
|
}
|
|
}
|
|
|
|
async function pruneBackupsNow() {
|
|
const input = document.getElementById('backup-retention-input');
|
|
const count = parseInt(input?.value, 10);
|
|
if (!Number.isFinite(count) || count <= 0) {
|
|
Notifications.error(_('updates.retention_required_for_prune'));
|
|
return;
|
|
}
|
|
if (!confirm(_('updates.prune_confirm').replace('{n}', String(count)))) return;
|
|
try {
|
|
const data = await Utils.api('/api/settings/updates/backups/prune', {
|
|
method: 'POST',
|
|
body: { keep: count }
|
|
});
|
|
const deleted = (data && Array.isArray(data.deleted)) ? data.deleted.length : 0;
|
|
Notifications.success(_('updates.prune_done').replace('{n}', String(deleted)));
|
|
await loadUpdateBackups();
|
|
} catch (err) {
|
|
Notifications.error(err.message || _('errors.server_error'));
|
|
}
|
|
}
|
|
|
|
async function deleteBackup(name, btn) {
|
|
if (!confirm(_('updates.delete_confirm').replace('{name}', name))) return;
|
|
if (btn) btn.disabled = true;
|
|
try {
|
|
await Utils.api(`/api/settings/updates/backups/${encodeURIComponent(name)}`, {
|
|
method: 'DELETE'
|
|
});
|
|
Notifications.success(_('updates.delete_success'));
|
|
await loadUpdateBackups();
|
|
} catch (err) {
|
|
Notifications.error(err.message || _('errors.server_error'));
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
async function loadUpdateBackups() {
|
|
const listEl = document.getElementById('update-backups-list');
|
|
if (!listEl) return;
|
|
const summaryEl = document.getElementById('backup-summary');
|
|
|
|
try {
|
|
const data = await Utils.api('/api/settings/updates/backups');
|
|
const backups = Array.isArray(data) ? data : (data.backups || []);
|
|
|
|
if (!backups.length) {
|
|
listEl.innerHTML = `<p class="text-muted">${_('updates.no_backups')}</p>`;
|
|
if (summaryEl) summaryEl.textContent = '';
|
|
return;
|
|
}
|
|
|
|
const totalBytes = backups.reduce((acc, b) => acc + (b.sizeBytes || 0), 0);
|
|
if (summaryEl) {
|
|
summaryEl.textContent = `${_('updates.total_size')}: ${formatBytes(totalBytes)} · ${backups.length} ${_('updates.backups_count')}`;
|
|
}
|
|
|
|
let html = '<div class="update-backups">';
|
|
for (const b of backups) {
|
|
const date = b.timestamp ? new Date(b.timestamp).toLocaleString() : '';
|
|
const sha = b.sha ? ` · ${Utils.escapeHtml(b.sha)}` : '';
|
|
const size = formatBytes(b.sizeBytes || 0);
|
|
html += `<div class="update-backup-item">
|
|
<div class="update-backup-info">
|
|
<strong>${Utils.escapeHtml(b.name)}</strong>
|
|
<span class="text-muted">${date}${sha} · ${b.fileCount || b.filesBackedUp || 0} ${_('updates.files')} · ${size}</span>
|
|
</div>
|
|
<div class="update-backup-actions">
|
|
<button class="btn btn-sm btn-outline" data-backup-restore="${Utils.escapeHtml(b.name)}">
|
|
<span class="material-icons">restore</span> ${_('updates.restore')}
|
|
</button>
|
|
<button class="btn btn-sm btn-danger" data-backup-delete="${Utils.escapeHtml(b.name)}">
|
|
<span class="material-icons">delete</span> ${_('updates.delete')}
|
|
</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
listEl.innerHTML = html;
|
|
|
|
// Attach restore handlers
|
|
listEl.querySelectorAll('[data-backup-restore]').forEach(btn => {
|
|
btn.addEventListener('click', async () => {
|
|
const name = btn.dataset.backupRestore;
|
|
if (!confirm(_('updates.restore_confirm'))) return;
|
|
|
|
btn.disabled = true;
|
|
try {
|
|
await Utils.api('/api/settings/updates/restore', {
|
|
method: 'POST',
|
|
body: { backupName: name }
|
|
});
|
|
Notifications.success(_('updates.restore_success'));
|
|
setTimeout(() => window.location.reload(), 2000);
|
|
} catch (error) {
|
|
Notifications.error(error.message || _('errors.server_error'));
|
|
btn.disabled = false;
|
|
}
|
|
});
|
|
});
|
|
|
|
// Attach delete handlers
|
|
listEl.querySelectorAll('[data-backup-delete]').forEach(btn => {
|
|
btn.addEventListener('click', () => deleteBackup(btn.dataset.backupDelete, btn));
|
|
});
|
|
} catch {
|
|
listEl.innerHTML = `<p class="text-muted">${_('updates.no_backups')}</p>`;
|
|
if (summaryEl) summaryEl.textContent = '';
|
|
}
|
|
}
|
|
|
|
// Wire retention controls (idempotent — handles tab re-mount)
|
|
document.addEventListener('click', (ev) => {
|
|
const target = ev.target.closest('#backup-retention-save, #backup-prune-now');
|
|
if (!target) return;
|
|
if (target.id === 'backup-retention-save') saveBackupRetention();
|
|
else if (target.id === 'backup-prune-now') pruneBackupsNow();
|
|
});
|
|
|
|
// Expose retention loader so tab activation can call it
|
|
window.loadBackupRetention = loadBackupRetention;
|
|
|
|
// ==================== LDAP / Authentication Section ====================
|
|
|
|
/**
|
|
* Initialize LDAP settings section
|
|
*/
|
|
async function initEnrollmentSection() {
|
|
const section = document.getElementById('enrollment-settings-section');
|
|
if (!section) return;
|
|
|
|
const modeRadios = document.querySelectorAll('input[name="settings_enrollment_mode"]');
|
|
const requireCb = document.getElementById('enrollment-require-approval');
|
|
const richCb = document.getElementById('enrollment-rich-approve');
|
|
const tagPickerCb = document.getElementById('enrollment-tag-picker');
|
|
const pendingHint = document.getElementById('enrollment-pending-hint');
|
|
|
|
let saveTimer = null;
|
|
|
|
async function loadEnrollmentSettings() {
|
|
try {
|
|
const res = await Utils.api('/api/settings/enrollment');
|
|
const data = res.data || res;
|
|
const mode = data.mode || 'open';
|
|
modeRadios.forEach(r => { r.checked = r.value === mode; });
|
|
if (requireCb) requireCb.checked = mode === 'managed';
|
|
if (richCb) richCb.checked = data.rich_approve !== false;
|
|
if (tagPickerCb) tagPickerCb.checked = data.tag_picker !== false;
|
|
if (pendingHint && data.pending_count > 0) {
|
|
const msg = (window.BetterDesk?.translations?.settings?.enrollment_pending_count
|
|
|| '{count} pending enrollment request(s)')
|
|
.replace('{count}', String(data.pending_count));
|
|
pendingHint.textContent = msg;
|
|
} else if (pendingHint) {
|
|
pendingHint.textContent = '';
|
|
}
|
|
} catch (err) {
|
|
console.error('Load enrollment settings:', err);
|
|
}
|
|
}
|
|
|
|
function scheduleSave(patch) {
|
|
clearTimeout(saveTimer);
|
|
saveTimer = setTimeout(async () => {
|
|
try {
|
|
await Utils.api('/api/settings/enrollment', {
|
|
method: 'PUT',
|
|
body: patch,
|
|
});
|
|
Notifications?.success?.(window.BetterDesk?.translations?.common?.saved || 'Saved');
|
|
await loadEnrollmentSettings();
|
|
} catch (err) {
|
|
Notifications?.error?.(err.message || 'Save failed');
|
|
}
|
|
}, 400);
|
|
}
|
|
|
|
modeRadios.forEach(radio => {
|
|
radio.addEventListener('change', () => {
|
|
if (!radio.checked) return;
|
|
if (requireCb) requireCb.checked = radio.value === 'managed';
|
|
scheduleSave({ mode: radio.value });
|
|
});
|
|
});
|
|
|
|
if (requireCb) {
|
|
requireCb.addEventListener('change', () => {
|
|
scheduleSave({ require_approval: requireCb.checked });
|
|
});
|
|
}
|
|
if (richCb) {
|
|
richCb.addEventListener('change', () => {
|
|
scheduleSave({ rich_approve: richCb.checked });
|
|
});
|
|
}
|
|
if (tagPickerCb) {
|
|
tagPickerCb.addEventListener('change', () => {
|
|
scheduleSave({ tag_picker: tagPickerCb.checked });
|
|
});
|
|
}
|
|
|
|
await loadEnrollmentSettings();
|
|
}
|
|
|
|
async function initLdapSection() {
|
|
const form = document.getElementById('ldap-form');
|
|
if (!form) return;
|
|
|
|
// Toggle config fields visibility based on enabled checkbox
|
|
const enabledCb = document.getElementById('ldap-enabled');
|
|
const configFields = document.getElementById('ldap-config-fields');
|
|
if (enabledCb && configFields) {
|
|
enabledCb.addEventListener('change', () => {
|
|
configFields.style.display = enabledCb.checked ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
// Toggle direct bind vs bind+search fields
|
|
const directBindCb = document.getElementById('ldap-direct-bind');
|
|
const bindSearchFields = document.getElementById('ldap-bind-search-fields');
|
|
const directBindFields = document.getElementById('ldap-direct-bind-fields');
|
|
if (directBindCb) {
|
|
directBindCb.addEventListener('change', () => {
|
|
if (bindSearchFields) bindSearchFields.style.display = directBindCb.checked ? 'none' : '';
|
|
if (directBindFields) directBindFields.style.display = directBindCb.checked ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
// Auto-update port when TLS checkbox changes
|
|
const useTlsCb = document.getElementById('ldap-use-tls');
|
|
const portInput = document.getElementById('ldap-port');
|
|
if (useTlsCb && portInput) {
|
|
useTlsCb.addEventListener('change', () => {
|
|
const current = parseInt(portInput.value, 10);
|
|
if (useTlsCb.checked && (current === 389 || !current)) {
|
|
portInput.value = 636;
|
|
} else if (!useTlsCb.checked && current === 636) {
|
|
portInput.value = 389;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Load existing config
|
|
await loadLdapConfig();
|
|
|
|
// Test connection button
|
|
document.getElementById('ldap-test-btn')?.addEventListener('click', testLdapConnection);
|
|
|
|
// Form submission
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
await saveLdapConfig();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load LDAP configuration from Go server (via Node.js proxy)
|
|
*/
|
|
async function loadLdapConfig() {
|
|
try {
|
|
const data = await Utils.api('/api/settings/ldap');
|
|
populateLdapForm(data);
|
|
} catch (error) {
|
|
console.error('Failed to load LDAP config:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Populate LDAP form fields with config data
|
|
*/
|
|
function populateLdapForm(data) {
|
|
if (!data) return;
|
|
|
|
const setVal = (id, val) => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.value = val ?? '';
|
|
};
|
|
const setChecked = (id, val) => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.checked = !!val;
|
|
};
|
|
|
|
setChecked('ldap-enabled', data.enabled);
|
|
setVal('ldap-host', data.host);
|
|
setVal('ldap-port', data.port || 389);
|
|
setChecked('ldap-use-tls', data.use_tls);
|
|
setChecked('ldap-start-tls', data.start_tls);
|
|
setChecked('ldap-skip-tls-verify', data.skip_tls_verify);
|
|
setVal('ldap-bind-dn', data.bind_dn);
|
|
setVal('ldap-bind-password', data.bind_password);
|
|
setVal('ldap-base-dn', data.base_dn);
|
|
setVal('ldap-user-filter', data.user_filter);
|
|
setVal('ldap-user-attr-id', data.user_attr_id);
|
|
setVal('ldap-user-attr-email', data.user_attr_email);
|
|
setVal('ldap-user-attr-name', data.user_attr_name);
|
|
setVal('ldap-group-base-dn', data.group_base_dn);
|
|
setVal('ldap-group-filter', data.group_filter);
|
|
setVal('ldap-group-attr-name', data.group_attr_name);
|
|
setVal('ldap-default-role', data.default_role || 'viewer');
|
|
setVal('ldap-group-role-map', data.group_role_map);
|
|
setChecked('ldap-direct-bind', data.direct_bind);
|
|
setVal('ldap-direct-bind-dn', data.direct_bind_dn);
|
|
setVal('ldap-conn-timeout', data.conn_timeout_sec || 10);
|
|
|
|
// Update visibility
|
|
const configFields = document.getElementById('ldap-config-fields');
|
|
if (configFields) configFields.style.display = data.enabled ? '' : 'none';
|
|
|
|
const bindSearchFields = document.getElementById('ldap-bind-search-fields');
|
|
const directBindFields = document.getElementById('ldap-direct-bind-fields');
|
|
if (bindSearchFields) bindSearchFields.style.display = data.direct_bind ? 'none' : '';
|
|
if (directBindFields) directBindFields.style.display = data.direct_bind ? '' : 'none';
|
|
}
|
|
|
|
/**
|
|
* Collect LDAP form data
|
|
*/
|
|
function collectLdapData() {
|
|
const getVal = (id) => {
|
|
const el = document.getElementById(id);
|
|
return el ? el.value.trim() : '';
|
|
};
|
|
const getChecked = (id) => {
|
|
const el = document.getElementById(id);
|
|
return el ? el.checked : false;
|
|
};
|
|
|
|
return {
|
|
enabled: getChecked('ldap-enabled'),
|
|
host: getVal('ldap-host'),
|
|
port: parseInt(getVal('ldap-port'), 10) || 389,
|
|
use_tls: getChecked('ldap-use-tls'),
|
|
start_tls: getChecked('ldap-start-tls'),
|
|
skip_tls_verify: getChecked('ldap-skip-tls-verify'),
|
|
bind_dn: getVal('ldap-bind-dn'),
|
|
bind_password: getVal('ldap-bind-password'),
|
|
base_dn: getVal('ldap-base-dn'),
|
|
user_filter: getVal('ldap-user-filter'),
|
|
user_attr_id: getVal('ldap-user-attr-id'),
|
|
user_attr_email: getVal('ldap-user-attr-email'),
|
|
user_attr_name: getVal('ldap-user-attr-name'),
|
|
group_base_dn: getVal('ldap-group-base-dn'),
|
|
group_filter: getVal('ldap-group-filter'),
|
|
group_attr_name: getVal('ldap-group-attr-name'),
|
|
default_role: getVal('ldap-default-role'),
|
|
group_role_map: getVal('ldap-group-role-map'),
|
|
direct_bind: getChecked('ldap-direct-bind'),
|
|
direct_bind_dn: getVal('ldap-direct-bind-dn'),
|
|
conn_timeout_sec: parseInt(getVal('ldap-conn-timeout'), 10) || 10
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Save LDAP configuration
|
|
*/
|
|
async function saveLdapConfig() {
|
|
try {
|
|
const data = collectLdapData();
|
|
await Utils.api('/api/settings/ldap', {
|
|
method: 'PUT',
|
|
body: data
|
|
});
|
|
Notifications.success(_('settings.ldap_saved'));
|
|
} catch (error) {
|
|
Notifications.error(error.message || _('errors.server_error'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Test LDAP connection with current form values
|
|
*/
|
|
async function testLdapConnection() {
|
|
const btn = document.getElementById('ldap-test-btn');
|
|
const resultEl = document.getElementById('ldap-test-result');
|
|
if (!btn) return;
|
|
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="material-icons spin">sync</span> ' + _('settings.ldap_testing');
|
|
|
|
if (resultEl) {
|
|
resultEl.innerHTML = '';
|
|
resultEl.style.display = 'none';
|
|
}
|
|
|
|
try {
|
|
const data = collectLdapData();
|
|
const result = await Utils.api('/api/settings/ldap/test', {
|
|
method: 'POST',
|
|
body: data
|
|
});
|
|
|
|
if (resultEl) {
|
|
resultEl.style.display = 'block';
|
|
if (result.success) {
|
|
resultEl.innerHTML = '<div class="alert alert-success"><span class="material-icons">check_circle</span> ' + _('settings.ldap_test_success') + '</div>';
|
|
} else {
|
|
resultEl.innerHTML = '<div class="alert alert-danger"><span class="material-icons">error</span> ' + Utils.escapeHtml(result.error || _('settings.ldap_test_failed')) + '</div>';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (resultEl) {
|
|
resultEl.style.display = 'block';
|
|
resultEl.innerHTML = '<div class="alert alert-danger"><span class="material-icons">error</span> ' + Utils.escapeHtml(error.message || _('settings.ldap_test_failed')) + '</div>';
|
|
}
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<span class="material-icons">cable</span> ' + _('settings.ldap_test_connection');
|
|
}
|
|
}
|
|
|
|
// ==================== OIDC / OAuth2 Section ====================
|
|
|
|
async function initOidcSection() {
|
|
const form = document.getElementById('oidc-form');
|
|
if (!form) return;
|
|
|
|
// Toggle config fields visibility based on enabled checkbox
|
|
const enabledCb = document.getElementById('oidc-enabled');
|
|
const configFields = document.getElementById('oidc-config-fields');
|
|
if (enabledCb && configFields) {
|
|
enabledCb.addEventListener('change', () => {
|
|
configFields.style.display = enabledCb.checked ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
// Toggle manual endpoints based on auto-discovery checkbox
|
|
const autoDiscoveryCb = document.getElementById('oidc-auto-discovery');
|
|
const manualEndpoints = document.getElementById('oidc-manual-endpoints');
|
|
if (autoDiscoveryCb && manualEndpoints) {
|
|
autoDiscoveryCb.addEventListener('change', () => {
|
|
manualEndpoints.style.display = autoDiscoveryCb.checked ? 'none' : '';
|
|
});
|
|
}
|
|
|
|
// Load existing config
|
|
await loadOidcConfig();
|
|
|
|
// Test discovery button
|
|
document.getElementById('oidc-test-btn')?.addEventListener('click', testOidcDiscovery);
|
|
|
|
// Form submission
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
await saveOidcConfig();
|
|
});
|
|
}
|
|
|
|
async function loadOidcConfig() {
|
|
try {
|
|
const data = await Utils.api('/api/settings/oidc');
|
|
populateOidcForm(data);
|
|
} catch (error) {
|
|
console.error('Failed to load OIDC config:', error);
|
|
}
|
|
}
|
|
|
|
function populateOidcForm(data) {
|
|
if (!data) return;
|
|
|
|
const setVal = (id, val) => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.value = val ?? '';
|
|
};
|
|
const setChecked = (id, val) => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.checked = !!val;
|
|
};
|
|
|
|
setChecked('oidc-enabled', data.enabled);
|
|
setVal('oidc-display-name', data.display_name);
|
|
setVal('oidc-issuer-url', data.issuer_url);
|
|
setVal('oidc-client-id', data.client_id);
|
|
setVal('oidc-client-secret', data.client_secret);
|
|
setVal('oidc-redirect-url', data.redirect_url);
|
|
setVal('oidc-scopes', data.scopes || 'openid profile email');
|
|
setChecked('oidc-use-pkce', data.use_pkce);
|
|
setChecked('oidc-auto-discovery', data.auto_discovery !== false);
|
|
setChecked('oidc-allow-signup', data.allow_signup !== false);
|
|
setVal('oidc-authorization-url', data.authorization_url);
|
|
setVal('oidc-token-url', data.token_url);
|
|
setVal('oidc-userinfo-url', data.userinfo_url);
|
|
setVal('oidc-claim-username', data.claim_username || 'preferred_username');
|
|
setVal('oidc-claim-email', data.claim_email || 'email');
|
|
setVal('oidc-claim-name', data.claim_name || 'name');
|
|
setVal('oidc-claim-groups', data.claim_groups || 'groups');
|
|
setVal('oidc-default-role', data.default_role || 'viewer');
|
|
setVal('oidc-group-role-map', data.group_role_map);
|
|
|
|
// Update visibility
|
|
const configFields = document.getElementById('oidc-config-fields');
|
|
if (configFields) configFields.style.display = data.enabled ? '' : 'none';
|
|
const manualEndpoints = document.getElementById('oidc-manual-endpoints');
|
|
if (manualEndpoints) manualEndpoints.style.display = (data.auto_discovery !== false) ? 'none' : '';
|
|
}
|
|
|
|
function collectOidcData() {
|
|
const getVal = (id) => {
|
|
const el = document.getElementById(id);
|
|
return el ? el.value.trim() : '';
|
|
};
|
|
const getChecked = (id) => {
|
|
const el = document.getElementById(id);
|
|
return el ? el.checked : false;
|
|
};
|
|
|
|
return {
|
|
enabled: getChecked('oidc-enabled'),
|
|
display_name: getVal('oidc-display-name'),
|
|
issuer_url: getVal('oidc-issuer-url'),
|
|
client_id: getVal('oidc-client-id'),
|
|
client_secret: getVal('oidc-client-secret'),
|
|
redirect_url: getVal('oidc-redirect-url'),
|
|
scopes: getVal('oidc-scopes'),
|
|
use_pkce: getChecked('oidc-use-pkce'),
|
|
auto_discovery: getChecked('oidc-auto-discovery'),
|
|
allow_signup: getChecked('oidc-allow-signup'),
|
|
authorization_url: getVal('oidc-authorization-url'),
|
|
token_url: getVal('oidc-token-url'),
|
|
userinfo_url: getVal('oidc-userinfo-url'),
|
|
claim_username: getVal('oidc-claim-username'),
|
|
claim_email: getVal('oidc-claim-email'),
|
|
claim_name: getVal('oidc-claim-name'),
|
|
claim_groups: getVal('oidc-claim-groups'),
|
|
default_role: getVal('oidc-default-role'),
|
|
group_role_map: getVal('oidc-group-role-map')
|
|
};
|
|
}
|
|
|
|
async function saveOidcConfig() {
|
|
try {
|
|
const data = collectOidcData();
|
|
await Utils.api('/api/settings/oidc', {
|
|
method: 'PUT',
|
|
body: data
|
|
});
|
|
Notifications.success(_('settings.oidc_saved'));
|
|
} catch (error) {
|
|
Notifications.error(error.message || _('errors.server_error'));
|
|
}
|
|
}
|
|
|
|
async function testOidcDiscovery() {
|
|
const btn = document.getElementById('oidc-test-btn');
|
|
const resultEl = document.getElementById('oidc-test-result');
|
|
if (!btn) return;
|
|
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="material-icons spin">sync</span> ' + _('settings.oidc_testing');
|
|
|
|
if (resultEl) {
|
|
resultEl.innerHTML = '';
|
|
resultEl.style.display = 'none';
|
|
}
|
|
|
|
try {
|
|
const issuerUrl = document.getElementById('oidc-issuer-url')?.value?.trim();
|
|
if (!issuerUrl) {
|
|
throw new Error(_('settings.oidc_issuer_required'));
|
|
}
|
|
|
|
const result = await Utils.api('/api/settings/oidc/test', {
|
|
method: 'POST',
|
|
body: { issuer_url: issuerUrl }
|
|
});
|
|
|
|
if (resultEl) {
|
|
resultEl.style.display = 'block';
|
|
if (result.success) {
|
|
let details = '';
|
|
if (result.authorization_endpoint) details += '<br><small>Authorization: ' + Utils.escapeHtml(result.authorization_endpoint) + '</small>';
|
|
if (result.token_endpoint) details += '<br><small>Token: ' + Utils.escapeHtml(result.token_endpoint) + '</small>';
|
|
if (result.userinfo_endpoint) details += '<br><small>Userinfo: ' + Utils.escapeHtml(result.userinfo_endpoint) + '</small>';
|
|
resultEl.innerHTML = '<div class="alert alert-success"><span class="material-icons">check_circle</span> ' + _('settings.oidc_test_success') + details + '</div>';
|
|
} else {
|
|
resultEl.innerHTML = '<div class="alert alert-danger"><span class="material-icons">error</span> ' + Utils.escapeHtml(result.error || _('settings.oidc_test_failed')) + '</div>';
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (resultEl) {
|
|
resultEl.style.display = 'block';
|
|
resultEl.innerHTML = '<div class="alert alert-danger"><span class="material-icons">error</span> ' + Utils.escapeHtml(error.message || _('settings.oidc_test_failed')) + '</div>';
|
|
}
|
|
} finally {
|
|
btn.disabled = false;
|
|
btn.innerHTML = '<span class="material-icons">travel_explore</span> ' + _('settings.oidc_test_discovery');
|
|
}
|
|
}
|
|
|
|
// ==================== Advanced config files =================================
|
|
|
|
const advancedState = { files: [], activeId: null, dirty: false, original: '' };
|
|
|
|
/** Utils.api unwraps { success, data } — accept both shapes. */
|
|
function unwrapAdvancedPayload(resp) {
|
|
if (resp == null) return resp;
|
|
if (Array.isArray(resp)) return resp;
|
|
if (typeof resp === 'object' && resp.content !== undefined && resp.path !== undefined) {
|
|
return resp;
|
|
}
|
|
if (typeof resp === 'object' && resp.data !== undefined) return resp.data;
|
|
return resp;
|
|
}
|
|
|
|
function initAdvancedSection() {
|
|
const listEl = document.getElementById('advanced-config-file-list');
|
|
const textarea = document.getElementById('advanced-config-textarea');
|
|
const saveBtn = document.getElementById('advanced-config-save');
|
|
const reloadBtn = document.getElementById('advanced-config-reload');
|
|
const restartBtn = document.getElementById('advanced-config-restart');
|
|
if (!listEl) return;
|
|
|
|
loadAdvancedFileList();
|
|
|
|
saveBtn?.addEventListener('click', saveAdvancedFile);
|
|
restartBtn?.addEventListener('click', restartAdvancedServices);
|
|
reloadBtn?.addEventListener('click', () => {
|
|
if (advancedState.activeId) loadAdvancedFile(advancedState.activeId, true);
|
|
});
|
|
|
|
textarea?.addEventListener('input', () => {
|
|
advancedState.dirty = textarea.value !== advancedState.original;
|
|
if (saveBtn) saveBtn.disabled = !advancedState.dirty;
|
|
});
|
|
|
|
window.addEventListener('beforeunload', (e) => {
|
|
if (advancedState.dirty) {
|
|
e.preventDefault();
|
|
e.returnValue = '';
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadAdvancedFileList() {
|
|
const listEl = document.getElementById('advanced-config-file-list');
|
|
if (!listEl) return;
|
|
try {
|
|
const resp = await Utils.api('/api/settings/advanced/files');
|
|
advancedState.files = unwrapAdvancedPayload(resp) || [];
|
|
if (!Array.isArray(advancedState.files)) advancedState.files = [];
|
|
if (!advancedState.files.length) {
|
|
listEl.innerHTML = '<li class="text-muted">' + _('settings.advanced_no_files') + '</li>';
|
|
return;
|
|
}
|
|
listEl.innerHTML = advancedState.files.map((f) => {
|
|
const label = _('settings.advanced_file_' + f.id) !== 'settings.advanced_file_' + f.id
|
|
? _('settings.advanced_file_' + f.id)
|
|
: f.id;
|
|
const status = f.exists
|
|
? (f.writable ? '' : ' <span class="badge badge-muted">' + _('settings.advanced_readonly') + '</span>')
|
|
: ' <span class="badge badge-muted">' + _('settings.advanced_missing') + '</span>';
|
|
const disabled = !f.exists && !f.canCreate;
|
|
return '<li><button type="button" class="advanced-config-file-btn' +
|
|
(advancedState.activeId === f.id ? ' active' : '') +
|
|
'" data-id="' + Utils.escapeHtml(f.id) + '"' +
|
|
(disabled ? ' disabled' : '') + '>' +
|
|
Utils.escapeHtml(label) + status + '</button></li>';
|
|
}).join('');
|
|
|
|
listEl.querySelectorAll('.advanced-config-file-btn').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const id = btn.getAttribute('data-id');
|
|
if (!id) return;
|
|
if (advancedState.dirty && !confirm(_('settings.advanced_unsaved_confirm'))) return;
|
|
loadAdvancedFile(id);
|
|
});
|
|
});
|
|
|
|
} catch (err) {
|
|
listEl.innerHTML = '<li class="text-danger">' + Utils.escapeHtml(err.message || _('errors.server_error')) + '</li>';
|
|
}
|
|
}
|
|
|
|
function advancedRestartHint(restart) {
|
|
if (restart === 'none') return _('settings.advanced_restart_none');
|
|
const map = {
|
|
console: _('settings.advanced_restart_console'),
|
|
goserver: _('settings.advanced_restart_goserver'),
|
|
systemd: _('settings.advanced_restart_systemd')
|
|
};
|
|
return map[restart] || _('settings.advanced_restart_generic');
|
|
}
|
|
|
|
function updateAdvancedRestartButton(catalogOrData) {
|
|
const restartBtn = document.getElementById('advanced-config-restart');
|
|
if (!restartBtn) return;
|
|
const canRestart = catalogOrData && catalogOrData.requiresRestart !== 'none';
|
|
const show = canRestart && (catalogOrData.exists || catalogOrData.canCreate);
|
|
restartBtn.hidden = !show;
|
|
}
|
|
|
|
async function loadAdvancedFile(id, force) {
|
|
if (!force && advancedState.dirty && !confirm(_('settings.advanced_unsaved_confirm'))) return;
|
|
|
|
const placeholder = document.getElementById('advanced-config-placeholder');
|
|
const toolbar = document.getElementById('advanced-config-toolbar');
|
|
const textarea = document.getElementById('advanced-config-textarea');
|
|
const hint = document.getElementById('advanced-config-restart-hint');
|
|
const saveBtn = document.getElementById('advanced-config-save');
|
|
const meta = document.getElementById('advanced-config-meta');
|
|
const pathEl = document.getElementById('advanced-config-path');
|
|
|
|
const catalog = advancedState.files.find((f) => f.id === id);
|
|
if (catalog && !catalog.exists && catalog.canCreate) {
|
|
advancedState.activeId = id;
|
|
advancedState.original = '';
|
|
advancedState.dirty = false;
|
|
if (placeholder) placeholder.hidden = true;
|
|
if (toolbar) toolbar.hidden = false;
|
|
if (textarea) {
|
|
textarea.hidden = false;
|
|
textarea.value = '# ' + _('settings.advanced_new_file_hint') + '\n';
|
|
textarea.disabled = !catalog.writable;
|
|
}
|
|
if (pathEl) pathEl.textContent = catalog.path;
|
|
if (meta) meta.textContent = _('settings.advanced_new_file');
|
|
if (hint) {
|
|
hint.hidden = false;
|
|
hint.textContent = advancedRestartHint(catalog.requiresRestart);
|
|
}
|
|
if (saveBtn) saveBtn.disabled = !catalog.writable;
|
|
updateAdvancedRestartButton(catalog);
|
|
document.querySelectorAll('.advanced-config-file-btn').forEach((b) => {
|
|
b.classList.toggle('active', b.getAttribute('data-id') === id);
|
|
});
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const resp = await Utils.api('/api/settings/advanced/files/' + encodeURIComponent(id));
|
|
const data = unwrapAdvancedPayload(resp);
|
|
const cat = advancedState.files.find((f) => f.id === id);
|
|
advancedState.activeId = id;
|
|
advancedState.original = data.content || '';
|
|
advancedState.dirty = false;
|
|
|
|
if (placeholder) placeholder.hidden = true;
|
|
if (toolbar) toolbar.hidden = false;
|
|
if (textarea) {
|
|
textarea.hidden = false;
|
|
textarea.value = advancedState.original;
|
|
textarea.disabled = cat ? !cat.writable : false;
|
|
}
|
|
if (pathEl) pathEl.textContent = data.path;
|
|
if (meta) {
|
|
const kb = ((data.size || 0) / 1024).toFixed(1);
|
|
meta.textContent = kb + ' KB · ' + (data.mtime || '');
|
|
}
|
|
if (hint) {
|
|
hint.hidden = false;
|
|
hint.textContent = advancedRestartHint(data.requiresRestart);
|
|
}
|
|
if (saveBtn) saveBtn.disabled = !cat || !cat.writable;
|
|
updateAdvancedRestartButton(cat || { exists: true });
|
|
|
|
document.querySelectorAll('.advanced-config-file-btn').forEach((b) => {
|
|
b.classList.toggle('active', b.getAttribute('data-id') === id);
|
|
});
|
|
} catch (err) {
|
|
Notifications.error(err.message || _('errors.server_error'));
|
|
}
|
|
}
|
|
|
|
async function saveAdvancedFile() {
|
|
const id = advancedState.activeId;
|
|
const textarea = document.getElementById('advanced-config-textarea');
|
|
if (!id || !textarea) return;
|
|
|
|
const catalog = advancedState.files.find((f) => f.id === id);
|
|
if (catalog && !catalog.writable) {
|
|
Notifications.error(_('settings.advanced_error_not_writable'));
|
|
return;
|
|
}
|
|
|
|
if (!confirm(_('settings.advanced_save_confirm'))) return;
|
|
|
|
const saveBtn = document.getElementById('advanced-config-save');
|
|
if (saveBtn) saveBtn.disabled = true;
|
|
|
|
try {
|
|
const resp = await Utils.api('/api/settings/advanced/files/' + encodeURIComponent(id), {
|
|
method: 'PUT',
|
|
body: { content: textarea.value }
|
|
});
|
|
advancedState.original = textarea.value;
|
|
advancedState.dirty = false;
|
|
let msg = _('settings.advanced_saved');
|
|
if (resp && resp.backupPath) {
|
|
msg += ' (' + _('settings.advanced_backup_created') + ')';
|
|
}
|
|
Notifications.success(msg);
|
|
await loadAdvancedFileList();
|
|
loadAdvancedFile(id, true);
|
|
|
|
if (confirm(_('settings.advanced_restart_after_save'))) {
|
|
await restartAdvancedServices(true);
|
|
}
|
|
} catch (err) {
|
|
Notifications.error(err.message || _('errors.server_error'));
|
|
if (saveBtn) saveBtn.disabled = advancedState.dirty;
|
|
}
|
|
}
|
|
|
|
async function restartAdvancedServices(skipConfirm) {
|
|
const id = advancedState.activeId;
|
|
if (!id) return;
|
|
|
|
if (advancedState.dirty && !confirm(_('settings.advanced_unsaved_confirm'))) return;
|
|
|
|
const catalog = advancedState.files.find((f) => f.id === id);
|
|
const isConsole = catalog && (catalog.requiresRestart === 'console' || id === 'systemd-console'
|
|
|| id === 'console-env' || id === 'console-env-local');
|
|
const confirmKey = isConsole
|
|
? 'settings.advanced_restart_console_confirm'
|
|
: 'settings.advanced_restart_confirm';
|
|
if (!skipConfirm && !confirm(_(confirmKey))) return;
|
|
|
|
const restartBtn = document.getElementById('advanced-config-restart');
|
|
if (restartBtn) restartBtn.disabled = true;
|
|
|
|
try {
|
|
const result = await Utils.api('/api/settings/advanced/restart', {
|
|
method: 'POST',
|
|
body: { fileId: id }
|
|
});
|
|
Notifications.success(_('settings.advanced_restart_started'));
|
|
|
|
if (result && result.needsConsolePoll) {
|
|
pollAdvancedConsoleRestart();
|
|
}
|
|
} catch (err) {
|
|
const detail = err.data && (err.data.details || err.data.error);
|
|
Notifications.error(detail || err.message || _('settings.advanced_restart_failed'));
|
|
} finally {
|
|
if (restartBtn) restartBtn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function pollAdvancedConsoleRestart() {
|
|
let attempts = 0;
|
|
const maxAttempts = 90;
|
|
const previousCacheVersion = window.BetterDesk?.cacheVersion || '';
|
|
Notifications.info(_('settings.advanced_restart_polling'));
|
|
|
|
const interval = setInterval(async () => {
|
|
attempts++;
|
|
try {
|
|
const resp = await fetch('/api/settings/restart-status?_=' + Date.now(), {
|
|
credentials: 'same-origin',
|
|
cache: 'no-store'
|
|
});
|
|
if (resp.ok) {
|
|
const body = await resp.json().catch(() => null);
|
|
const status = body?.data || body || {};
|
|
if (previousCacheVersion && status.cacheVersion && status.cacheVersion === previousCacheVersion) {
|
|
return;
|
|
}
|
|
clearInterval(interval);
|
|
Notifications.success(_('settings.advanced_restart_done'));
|
|
setTimeout(() => window.location.reload(), 2000);
|
|
return;
|
|
}
|
|
} catch (_) { /* console still restarting */ }
|
|
if (attempts >= maxAttempts) {
|
|
clearInterval(interval);
|
|
Notifications.warning(_('settings.advanced_restart_timeout'));
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
})();
|