Files
BetterDesk/web-nodejs/public/js/settings.js
T
UNITRONIX 261a7fcd78 fix(ux): resolve light theme issues and enhance theme management
- Fixed light theme to correctly apply the blue topbar and ensure text colors are consistent with the selected theme.
- Updated theme management to persist the selected theme and its corresponding palette across sessions.
- Enhanced documentation to clarify theme color mappings and behavior.
- Introduced built-in theme palettes for light and dark modes to streamline theme application.
- Improved CSS variable handling for better theme integration in the UI.

This update addresses user feedback regarding theme inconsistencies and improves overall theme functionality.
2026-07-26 10:09:54 +02:00

5142 lines
228 KiB
JavaScript

/**
* BetterDesk Console - Settings Page
*/
(function() {
'use strict';
let _ldapWasEnabled = false;
let _oidcWasEnabled = false;
let _smtpWasConfigured = false;
document.addEventListener('DOMContentLoaded', init);
function init() {
initTabs();
initSettingsSearch();
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();
initClientSessionsSection();
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();
}
if (document.getElementById('tab-email') && document.getElementById('tab-email').style.display !== 'none') {
initEmailSection();
}
initTutorialSection();
loadAuditLog();
loadServerInfo();
initMeshSettingsSection();
initDeviceScopeSection();
initConnectionModeSection();
initPublicEndpointsSection();
initAuthSubnav();
// Refresh handler
window.addEventListener('app:refresh', loadAuditLog);
}
// ==================== Settings UI helpers ====================
const _settingsSaveTimers = {};
function tSettings(key, fallback) {
const val = _('settings.' + key);
if (!val || val === 'settings.' + key) return fallback || key;
return val;
}
async function settingsConfirmCritical(options) {
if (window.Modal && typeof Modal.confirm === 'function') {
return Modal.confirm({
title: options.title || _('common.confirm'),
message: options.message || '',
confirmLabel: options.confirmLabel || _('actions.confirm'),
cancelLabel: options.cancelLabel,
confirmIcon: options.icon || 'warning',
danger: options.danger
});
}
return confirm(options.message || options.title || '');
}
function scheduleSettingsSave(key, saveFn, debounceMs = 400) {
clearTimeout(_settingsSaveTimers[key]);
_settingsSaveTimers[key] = setTimeout(() => {
saveFn().catch((err) => {
console.error('Settings save failed:', err);
});
}, debounceMs);
}
async function applyWithConfirm({ previousValue, newValue, setUiValue, confirmOptions, save }) {
if (!(await settingsConfirmCritical(confirmOptions))) {
if (setUiValue) setUiValue(previousValue);
return false;
}
try {
await save(newValue);
return true;
} catch (err) {
if (setUiValue) setUiValue(previousValue);
throw err;
}
}
function initAuthSubnav() {
const subtabs = document.querySelectorAll('.settings-auth-subtab');
const panels = document.querySelectorAll('.settings-auth-panel');
if (!subtabs.length) return;
subtabs.forEach((tab) => {
tab.addEventListener('click', () => {
const panelId = tab.dataset.authPanel;
if (!panelId) return;
subtabs.forEach((t) => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
panels.forEach((panel) => {
const isActive = panel.id === 'auth-panel-' + panelId;
panel.classList.toggle('active', isActive);
panel.hidden = !isActive;
});
applySettingsSearchFilter();
});
});
}
// ==================== Tab Navigation ====================
function initTabs() {
const tabs = document.querySelectorAll('.settings-shell-tab, .settings-tab');
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => {
t.classList.remove('active');
t.setAttribute('aria-selected', 'false');
});
document.querySelectorAll('.settings-tab-content').forEach(c => c.classList.remove('active'));
tab.classList.add('active');
tab.setAttribute('aria-selected', 'true');
const target = document.getElementById('tab-' + tab.dataset.tab);
if (target) target.classList.add('active');
window.history.replaceState(null, '', '#' + tab.dataset.tab);
applySettingsSearchFilter();
});
});
const hash = window.location.hash.replace('#', '');
if (['general', 'branding', 'server', 'backup', 'updates', 'auth', 'advanced', 'email'].includes(hash)) {
const tabName = hash === 'server' ? 'general' : hash;
const tab = document.querySelector(`[data-tab="${tabName}"]`);
if (tab) tab.click();
}
}
function initSettingsSearch() {
const input = document.getElementById('settings-search-input');
const clear = document.getElementById('settings-search-clear');
if (!input) return;
input.addEventListener('input', applySettingsSearchFilter);
clear?.addEventListener('click', () => {
input.value = '';
applySettingsSearchFilter();
input.focus();
});
}
function applySettingsSearchFilter() {
const input = document.getElementById('settings-search-input');
const clear = document.getElementById('settings-search-clear');
const status = document.getElementById('settings-search-status');
const query = (input?.value || '').trim().toLowerCase();
if (clear) clear.hidden = !query;
const activeTab = document.querySelector('.settings-tab-content.active');
if (!activeTab) return;
const panels = activeTab.querySelectorAll('.settings-panel, .settings-auth-panel.active .settings-panel');
let visible = 0;
panels.forEach(panel => {
if (!query) {
panel.hidden = false;
visible++;
return;
}
const haystack = panel.textContent.toLowerCase();
const match = haystack.includes(query);
panel.hidden = !match;
if (match) visible++;
});
if (status) {
if (query) {
const tpl = tSettings('ui.search_results', '{count} matching sections');
status.textContent = tpl.replace('{count}', String(visible));
status.hidden = false;
} else {
status.textContent = '';
status.hidden = true;
}
}
}
/**
* 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);
const goVerEl = document.getElementById('go-server-version');
if (goVerEl) {
const goVer = data.goServer?.version;
goVerEl.textContent = goVer ? `v${goVer}` : '-';
if (goVer && goVer === 'dev' && data.app?.version && data.app.version !== 'dev') {
goVerEl.title = typeof _ === 'function' ? _('settings.go_server_version_dev_hint') : '';
} else {
goVerEl.title = '';
}
}
} 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(' ');
}
// ==================== Device scope default ====================
function initDeviceScopeSection() {
const form = document.getElementById('device-scope-form');
if (!form) return;
const warningEl = document.getElementById('device-scope-warning');
function updateWarning() {
const restricted = document.getElementById('device-scope-restricted')?.checked;
if (warningEl) warningEl.hidden = !restricted;
}
async function loadDeviceScopeSetting() {
try {
const data = await Utils.api('/api/settings/device-scope');
const mode = data.mode === 'restricted' ? 'restricted' : 'open';
const openEl = document.getElementById('device-scope-open');
const restrictedEl = document.getElementById('device-scope-restricted');
if (openEl) openEl.checked = mode === 'open';
if (restrictedEl) restrictedEl.checked = mode === 'restricted';
updateWarning();
} catch (err) {
console.error('Failed to load device scope setting:', err);
}
}
form.querySelectorAll('input[name="device_scope_mode"]').forEach(input => {
input.addEventListener('change', updateWarning);
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const selected = form.querySelector('input[name="device_scope_mode"]:checked');
const mode = selected ? selected.value : 'open';
try {
await Utils.api('/api/settings/device-scope', {
method: 'POST',
body: { mode }
});
Notifications.success(_('settings.device_scope_saved'));
updateWarning();
} catch (err) {
Notifications.error(err.message || _('errors.server_error'));
}
});
loadDeviceScopeSetting();
}
// ==================== MeshCentral compatibility ====================
function initMeshSettingsSection() {
const panel = document.getElementById('mesh-settings-panel');
if (!panel) return;
const enabledEl = document.getElementById('mesh-status-enabled');
const agentsEl = document.getElementById('mesh-agents-online');
const serverIdEl = document.getElementById('mesh-server-id');
const downloadBtn = document.getElementById('mesh-download-msh');
const copyBtn = document.getElementById('mesh-copy-server-id');
const nameInput = document.getElementById('mesh-msh-name');
const certBanner = document.getElementById('mesh-cert-banner');
const groupsList = document.getElementById('mesh-groups-list');
const recordingsTable = document.getElementById('mesh-recordings-table');
let meshGroups = [];
async function loadMeshStatus() {
try {
const resp = await Utils.api('/api/mesh/status');
const data = resp.data || resp;
if (enabledEl) {
enabledEl.textContent = data.enabled
? (tSettings('mesh.enabled_label', 'Enabled'))
: (tSettings('mesh.disabled_label', 'Disabled'));
}
if (agentsEl) agentsEl.textContent = String(data.agents_online != null ? data.agents_online : 0);
if (serverIdEl && data.server_id) serverIdEl.textContent = data.server_id;
if (certBanner) {
if (data.enabled && !data.cert_present) {
certBanner.textContent = tSettings('mesh.cert_missing', 'Agent-server certificate not found — backup after first agent connects.');
certBanner.classList.remove('hidden');
} else if (data.cert_present) {
certBanner.textContent = (tSettings('mesh.cert_ok', 'Certificate present')) + (data.cert_modified ? ' — ' + data.cert_modified : '');
certBanner.classList.remove('hidden', 'alert-warning');
certBanner.classList.add('alert-info');
} else {
certBanner.classList.add('hidden');
}
}
} catch {
if (enabledEl) enabledEl.textContent = tSettings('mesh.disabled_label', 'Disabled');
}
}
async function loadMeshGroups() {
if (!groupsList) return;
try {
const resp = await Utils.api('/api/mesh/groups');
const data = resp.data || resp;
meshGroups = Array.isArray(data.groups) ? data.groups : [];
groupsList.innerHTML = meshGroups.map((g, idx) =>
`<div class="mesh-group-row" data-idx="${idx}"><code>${Utils.escapeHtml(g.id || '')}</code> — ${Utils.escapeHtml(g.name || '')}</div>`
).join('') || '<p class="text-muted">' + tSettings('mesh.groups_empty', 'No mesh groups yet.') + '</p>';
} catch {
groupsList.innerHTML = '<p class="text-muted">' + tSettings('mesh.groups_load_error', 'Could not load groups.') + '</p>';
}
}
async function loadMeshRecordings() {
if (!recordingsTable) return;
try {
const resp = await Utils.api('/api/session/recordings');
const data = resp.data || resp;
const rows = Array.isArray(data.recordings) ? data.recordings : [];
if (!rows.length) {
recordingsTable.innerHTML = '<p class="text-muted">' + tSettings('mesh.recordings_empty', 'No server recordings yet.') + '</p>';
return;
}
recordingsTable.innerHTML = '<table class="data-table"><thead><tr><th>Transport</th><th>Peer</th><th>Type</th><th>Size</th><th></th></tr></thead><tbody>' +
rows.map((r) => {
const dl = r.transport === 'mesh' && r.id
? `<a class="btn btn-sm btn-secondary" href="/api/mesh/recordings/${encodeURIComponent(r.id)}">${tSettings('mesh.recording_download', 'Download')}</a>`
: '';
return `<tr><td>${Utils.escapeHtml(r.transport || '')}</td><td>${Utils.escapeHtml(r.peer_id || '')}</td><td>${Utils.escapeHtml(r.session_type || '')}</td><td>${r.size || 0}</td><td>${dl}</td></tr>`;
}).join('') + '</tbody></table>';
} catch {
recordingsTable.innerHTML = '<p class="text-muted">' + tSettings('mesh.recordings_load_error', 'Could not load recordings.') + '</p>';
}
}
document.getElementById('mesh-group-add')?.addEventListener('click', () => {
const id = document.getElementById('mesh-group-id')?.value.trim();
const name = document.getElementById('mesh-group-name')?.value.trim();
if (!id || !name) return;
meshGroups.push({ id, name });
const idInput = document.getElementById('mesh-group-id');
const nameInputG = document.getElementById('mesh-group-name');
if (idInput) idInput.value = '';
if (nameInputG) nameInputG.value = '';
if (groupsList) {
groupsList.innerHTML = meshGroups.map((g) =>
`<div class="mesh-group-row"><code>${Utils.escapeHtml(g.id)}</code> — ${Utils.escapeHtml(g.name)}</div>`
).join('');
}
});
document.getElementById('mesh-groups-save')?.addEventListener('click', async () => {
try {
await Utils.api('/api/mesh/groups', { method: 'POST', body: { groups: meshGroups } });
Notifications.success(tSettings('mesh.groups_saved', 'Mesh groups saved'));
loadMeshGroups();
} catch (err) {
Notifications.error(err.message || tSettings('mesh.groups_save_error', 'Save failed'));
}
});
if (downloadBtn && nameInput) {
downloadBtn.addEventListener('click', (e) => {
const name = encodeURIComponent(nameInput.value.trim() || 'BetterDesk Mesh');
downloadBtn.href = `/api/mesh/download.msh?name=${name}`;
});
}
copyBtn?.addEventListener('click', () => {
const text = serverIdEl?.textContent || '';
if (!text || text === '-') return;
navigator.clipboard?.writeText(text).then(() => {
Notifications.success(tSettings('mesh.copy_server_id_ok', 'Server ID copied'));
}).catch(() => {});
});
loadMeshStatus();
loadMeshGroups();
loadMeshRecordings();
}
// ==================== 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 confirmKey = restart
? 'confirm.connection_restart'
: 'confirm.connection_mode';
const confirmed = await settingsConfirmCritical({
title: tSettings(confirmKey + '_title', restart ? 'Restart server?' : 'Save connection strategy?'),
message: tSettings(confirmKey, restart
? 'Save connection settings and restart the BetterDesk server? Active sessions may disconnect briefly.'
: 'Apply the new connection strategy for RustDesk clients?'),
confirmLabel: restart
? _('settings.connection_mode_save_restart')
: _('settings.connection_mode_save'),
icon: restart ? 'restart_alt' : 'swap_horiz',
danger: restart
});
if (!confirmed) return;
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; });
}
}
// ==================== Public RustDesk client endpoints ====================
function initPublicEndpointsSection() {
const section = document.getElementById('public-endpoints-section');
if (!section) return;
document.getElementById('public-endpoints-save')?.addEventListener('click', savePublicEndpoints);
loadPublicEndpoints();
}
async function loadPublicEndpoints() {
try {
const resp = await Utils.api('/api/settings/public-endpoints');
const data = resp.data || resp;
const serverIdEl = document.getElementById('public-server-id');
const relayEl = document.getElementById('public-relay-server');
const apiEl = document.getElementById('public-api-url');
if (serverIdEl) serverIdEl.value = data.public_server_id || '';
if (relayEl) relayEl.value = data.public_relay_server || '';
if (apiEl) apiEl.value = data.public_api_url || '';
} catch (err) {
console.error('Failed to load public endpoints:', err);
}
}
function getPublicEndpointsPayload() {
return {
public_server_id: document.getElementById('public-server-id')?.value?.trim() || '',
public_relay_server: document.getElementById('public-relay-server')?.value?.trim() || '',
public_api_url: document.getElementById('public-api-url')?.value?.trim() || '',
};
}
async function savePublicEndpoints() {
const confirmed = await settingsConfirmCritical({
title: tSettings('public_endpoints_save_title', 'Save public client endpoints?'),
message: tSettings('public_endpoints_save_confirm', 'Update RustDesk client configuration values used on the Dashboard (ID server, relay, API URL)?'),
confirmLabel: _('settings.public_endpoints_save'),
icon: 'public',
danger: false
});
if (!confirmed) return;
const saveBtn = document.getElementById('public-endpoints-save');
if (saveBtn) saveBtn.disabled = true;
try {
const payload = getPublicEndpointsPayload();
const resp = await Utils.api('/api/settings/public-endpoints', {
method: 'PUT',
body: JSON.stringify(payload)
});
if (typeof Notifications !== 'undefined') {
Notifications.success(resp.message || _('settings.public_endpoints_saved'));
}
await loadPublicEndpoints();
} catch (err) {
console.error('Save public endpoints failed:', err);
if (typeof Notifications !== 'undefined') {
Notifications.error(err.message || _('errors.server_error'));
}
} finally {
if (saveBtn) saveBtn.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;
let _brandingSnapshot = null;
let _brandingDirty = false;
let _previewDebounce = null;
let _autosaveDebounce = null;
let _canBrandingEdit = true;
let _fontSource = 'google';
let _brandingProfiles = [];
const _backgroundUploads = new Map();
/**
* Initialize branding configuration section
*/
async function initBrandingSection() {
const tab = document.getElementById('tab-branding');
if (!tab) return;
_canBrandingEdit = tab.dataset.canBrandingEdit !== '0';
try {
const response = await Utils.api('/api/settings/branding');
brandingData = response.data || response;
populateBrandingForm(brandingData);
updateBrandingReadability(response.readability);
_brandingSnapshot = JSON.stringify(collectBrandingData());
initBrandingModules();
initLogoTypeSelector();
initColorPickers();
initFontPickers();
initFontSourceTabs();
initBackgroundSelectors();
initBrandingProfiles();
initBuiltinThemes();
initBrandingActions();
initBrandingLivePreview();
setBrandingStatus('saved');
} catch (error) {
console.error('Failed to load branding:', error);
}
}
function initBrandingModules() {
const nav = document.getElementById('branding-module-nav');
if (!nav) return;
nav.querySelectorAll('.branding-module-btn').forEach(btn => {
btn.addEventListener('click', () => {
const module = btn.dataset.brandingModule;
nav.querySelectorAll('.branding-module-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.branding-module-panel').forEach(panel => {
panel.classList.toggle('active', panel.dataset.brandingModule === module);
});
});
});
}
function setBrandingStatus(state, message) {
const statusEl = document.getElementById('branding-save-status');
const textEl = document.getElementById('branding-status-text');
if (!statusEl || !textEl) return;
statusEl.classList.remove('is-dirty', 'is-saving', 'is-saved', 'is-error');
const map = {
saved: [_('branding.status_saved'), 'is-saved', 'check_circle'],
dirty: [_('branding.status_dirty'), 'is-dirty', 'edit'],
saving: [_('branding.status_saving'), 'is-saving', 'sync'],
error: [_('branding.status_error'), 'is-error', 'error']
};
const cfg = map[state] || map.saved;
textEl.textContent = message || cfg[0];
statusEl.classList.add(cfg[1]);
const icon = statusEl.querySelector('.branding-status-icon');
if (icon) icon.textContent = cfg[2];
if (state === 'saving' && icon) icon.classList.add('spinning');
else if (icon) icon.classList.remove('spinning');
}
function onBrandingFieldChange() {
const current = JSON.stringify(collectBrandingData());
_brandingDirty = current !== _brandingSnapshot;
if (_brandingDirty) setBrandingStatus('dirty');
scheduleBrandingPreview();
if (_canBrandingEdit) scheduleBrandingAutosave();
}
function scheduleBrandingPreview() {
clearTimeout(_previewDebounce);
_previewDebounce = setTimeout(() => {
if (typeof BrandingPreview !== 'undefined') {
const applyPage = document.getElementById('branding-preview-page-toggle')?.checked;
BrandingPreview.apply(collectBrandingData(), { applyToPage: !!applyPage });
}
updateLogoPreview();
}, 100);
}
function scheduleBrandingAutosave() {
// Appearance changes can touch many fields at once and the server rate
// limiter is intentionally strict. Keep live preview instant, but require
// an explicit save for stable, predictable updates.
clearTimeout(_autosaveDebounce);
}
async function saveBrandingNow(options = {}) {
if (!_canBrandingEdit) return;
setBrandingStatus('saving');
try {
await waitForBackgroundUploads();
const data = collectBrandingData();
const resp = await Utils.api('/api/settings/branding', {
method: 'POST',
body: data
});
brandingData = resp.data || data;
updateBrandingReadability(resp.readability);
_brandingSnapshot = JSON.stringify(collectBrandingData());
_brandingDirty = false;
setBrandingStatus('saved', options.silent ? _('branding.autosaved') : _('branding.saved'));
if (typeof BrandingPreview !== 'undefined') {
const rev = resp.revision || Date.now();
BrandingPreview.refreshBrandingStylesheet(rev);
}
applyBrandingToChrome(data);
if (!options.silent) {
Notifications.success(_('branding.saved'));
}
return true;
} catch (error) {
setBrandingStatus('error', error.message || _('errors.server_error'));
if (!options.silent) Notifications.error(error.message || _('errors.server_error'));
return false;
}
}
function updateBrandingReadability(readability) {
const box = document.getElementById('branding-readability-alert');
const text = document.getElementById('branding-readability-text');
if (!box || !text) return;
const issues = Array.isArray(readability?.issues) ? readability.issues : [];
if (!issues.length) {
box.hidden = true;
text.textContent = '';
return;
}
const errors = issues.filter(issue => issue.severity === 'error');
box.hidden = false;
box.classList.toggle('has-errors', errors.length > 0);
text.textContent = issues.slice(0, 3).map(issue => issue.message).join(' ');
}
async function waitForBackgroundUploads() {
const pending = Array.from(_backgroundUploads.values());
if (!pending.length) return;
await Promise.all(pending);
}
function revertBrandingChanges() {
if (!_brandingSnapshot) return;
try {
const data = JSON.parse(_brandingSnapshot);
populateBrandingForm(data);
_brandingDirty = false;
setBrandingStatus('saved');
scheduleBrandingPreview();
if (typeof BrandingPreview !== 'undefined') BrandingPreview.clearPagePreview();
} catch (e) {
console.error('Revert failed:', e);
}
}
function applyBrandingToChrome(data) {
const name = data.appName || 'BetterDesk';
document.querySelectorAll('.sidebar-logo-text, .brand-text-logo').forEach(el => {
if (data.logoType === 'text' && data.logoText) {
el.textContent = data.logoText;
}
});
document.title = document.title.replace(/^[^-]+/, name);
const favicon = document.querySelector('link[rel="icon"]');
if (favicon) favicon.href = `/branding/favicon.svg?v=${Date.now()}`;
}
function initBrandingLivePreview() {
document.getElementById('branding-preview-page-toggle')?.addEventListener('change', scheduleBrandingPreview);
const tab = document.getElementById('tab-branding');
if (!tab) return;
tab.addEventListener('input', (e) => {
if (e.target.closest('#tab-branding')) onBrandingFieldChange();
});
tab.addEventListener('change', (e) => {
if (e.target?.classList?.contains('branding-bg-file')) return;
if (e.target.closest('#tab-branding')) onBrandingFieldChange();
});
scheduleBrandingPreview();
}
async function brandingPrompt(message, options = {}) {
if (window.Modal && typeof Modal.prompt === 'function') {
return Modal.prompt({
title: options.title || _('settings.tab_branding'),
label: message,
placeholder: options.placeholder || '',
confirmLabel: options.confirmLabel || _('actions.save')
});
}
return prompt(message);
}
async function brandingConfirm(message, options = {}) {
if (window.Modal && typeof Modal.confirm === 'function') {
return Modal.confirm({
title: options.title || _('settings.tab_branding'),
message,
danger: !!options.danger,
confirmLabel: options.confirmLabel || _('actions.confirm')
});
}
return confirm(message);
}
async function initBrandingProfiles() {
const select = document.getElementById('branding-profile-select');
if (!select) return;
await refreshBrandingProfiles(select);
document.getElementById('branding-profile-apply-btn')?.addEventListener('click', async () => {
const id = parseInt(select.value, 10);
if (!id) return;
try {
const resp = await Utils.api(`/api/settings/branding/profiles/${id}/apply`, { method: 'POST' });
brandingData = resp.data || brandingData;
populateBrandingForm(brandingData);
_brandingSnapshot = JSON.stringify(collectBrandingData());
_brandingDirty = false;
setBrandingStatus('saved');
scheduleBrandingPreview();
if (typeof BrandingPreview !== 'undefined') {
BrandingPreview.refreshBrandingStylesheet(resp.revision || Date.now());
}
Notifications.success(_('branding.profile_applied'));
} catch (e) {
Notifications.error(e.message || _('errors.server_error'));
}
});
document.getElementById('branding-profile-new-btn')?.addEventListener('click', async () => {
const name = await brandingPrompt(_('branding.profile_name_prompt'));
if (!name || !name.trim()) return;
try {
await Utils.api('/api/settings/branding/profiles', {
method: 'POST',
body: { name: name.trim(), description: '', branding: collectBrandingData() }
});
await refreshBrandingProfiles(select);
Notifications.success(_('branding.profile_created'));
} catch (e) {
Notifications.error(e.message || _('errors.server_error'));
}
});
document.getElementById('branding-profile-save-btn')?.addEventListener('click', async () => {
const id = parseInt(select.value, 10);
if (!id) {
Notifications.error(_('branding.profile_select_first'));
return;
}
try {
await Utils.api(`/api/settings/branding/profiles/${id}`, {
method: 'PUT',
body: { branding: collectBrandingData() }
});
Notifications.success(_('branding.profile_updated'));
} catch (e) {
Notifications.error(e.message || _('errors.server_error'));
}
});
document.getElementById('branding-profile-duplicate-btn')?.addEventListener('click', async () => {
const id = parseInt(select.value, 10);
if (!id) return;
try {
await Utils.api(`/api/settings/branding/profiles/${id}/duplicate`, { method: 'POST', body: {} });
await refreshBrandingProfiles(select);
Notifications.success(_('branding.profile_duplicated'));
} catch (e) {
Notifications.error(e.message || _('errors.server_error'));
}
});
document.getElementById('branding-profile-delete-btn')?.addEventListener('click', async () => {
const id = parseInt(select.value, 10);
if (!id) return;
if (!(await brandingConfirm(_('branding.profile_delete_confirm'), { danger: true }))) return;
try {
await Utils.api(`/api/settings/branding/profiles/${id}`, { method: 'DELETE' });
await refreshBrandingProfiles(select);
Notifications.success(_('branding.profile_deleted'));
} catch (e) {
Notifications.error(e.message || _('errors.server_error'));
}
});
}
async function refreshBrandingProfiles(select) {
try {
const resp = await Utils.api('/api/settings/branding/profiles');
_brandingProfiles = resp.data || resp || [];
select.innerHTML = `<option value="">${_('branding.profile_none')}</option>`;
_brandingProfiles.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = p.is_active ? `${p.name} (${_('branding.profile_active')})` : p.name;
if (p.is_active) opt.selected = true;
select.appendChild(opt);
});
} catch (e) {
console.warn('Failed to load profiles:', e);
}
}
async function initBuiltinThemes() {
const gallery = document.getElementById('branding-theme-gallery');
if (!gallery) return;
try {
const resp = await Utils.api('/api/settings/themes');
const themes = resp.data || resp || [];
gallery.innerHTML = '';
themes.forEach(theme => {
const card = document.createElement('button');
card.type = 'button';
card.className = 'branding-theme-card';
card.title = theme.description || theme.name;
const colors = theme.colors || {};
const swatches = ['bgPrimary', 'bgSecondary', 'accentBlue', 'accentGreen']
.map(k => colors[k] || '#30363d')
.map(c => `<span class="branding-theme-swatch" style="background:${Utils.escapeHtml(c)}"></span>`)
.join('');
card.innerHTML = `<div class="branding-theme-swatches">${swatches}</div><span class="branding-theme-name">${Utils.escapeHtml(theme.name || theme.id)}</span>`;
card.addEventListener('click', async () => {
if (!_canBrandingEdit) return;
if (!(await brandingConfirm(_('branding.theme_apply_confirm')))) return;
try {
await Utils.api(`/api/settings/themes/${encodeURIComponent(theme.id)}/apply`, { method: 'POST' });
const fresh = await Utils.api('/api/settings/branding');
brandingData = fresh.data || fresh;
populateBrandingForm(brandingData);
_brandingSnapshot = JSON.stringify(collectBrandingData());
_brandingDirty = false;
setBrandingStatus('saved');
scheduleBrandingPreview();
if (typeof BrandingPreview !== 'undefined') BrandingPreview.refreshBrandingStylesheet(Date.now());
Notifications.success(_('branding.theme_applied'));
} catch (e) {
Notifications.error(e.message || _('errors.server_error'));
}
});
gallery.appendChild(card);
});
} catch (e) {
console.warn('Builtin themes failed:', e);
}
}
function initFontSourceTabs() {
document.querySelectorAll('.font-source-tab').forEach(tab => {
tab.addEventListener('click', () => {
_fontSource = tab.dataset.fontSource || 'google';
document.querySelectorAll('.font-source-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
document.querySelectorAll('.font-source-panel').forEach(p => p.classList.remove('active'));
const panel = document.getElementById(`font-source-${_fontSource}`);
if (panel) panel.classList.add('active');
if (_fontSource === 'local') renderLocalFontList();
});
});
document.getElementById('font-upload-file')?.addEventListener('change', handleFontUpload);
renderLocalFontList();
}
async function renderLocalFontList() {
const list = document.getElementById('font-local-list');
if (!list) return;
try {
const fonts = await Utils.api('/api/settings/fonts/local');
list.innerHTML = '';
if (!fonts || !fonts.length) {
list.innerHTML = `<div class="font-dropdown-empty">${_('branding.font_local_empty')}</div>`;
return;
}
fonts.forEach(font => {
const row = document.createElement('div');
row.className = 'font-local-item';
row.innerHTML = `
<span style="font-family:'${Utils.escapeHtml(font.family)}',sans-serif">${Utils.escapeHtml(font.family)}</span>
<button type="button" class="btn btn-ghost btn-sm font-local-delete" data-family="${Utils.escapeHtml(font.family)}" title="${_('branding.font_delete')}">
<span class="material-icons">delete</span>
</button>`;
row.querySelector('.font-local-delete')?.addEventListener('click', async () => {
try {
await Utils.api(`/api/settings/fonts/${encodeURIComponent(font.family)}`, { method: 'DELETE' });
renderLocalFontList();
loadLocalFontCount();
} catch (e) {
Notifications.error(e.message || _('errors.server_error'));
}
});
list.appendChild(row);
});
} catch (e) {
list.innerHTML = `<div class="font-dropdown-empty">${_('errors.server_error')}</div>`;
}
}
async function handleFontUpload(e) {
const file = e.target.files[0];
if (!file || !_canBrandingEdit) return;
const family = document.getElementById('font-upload-name')?.value.trim() || file.name.replace(/\.[^.]+$/, '');
const formData = new FormData();
formData.append('font', file);
formData.append('family', family);
try {
const resp = await fetch('/api/settings/fonts/upload', {
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');
Notifications.success(_('branding.font_upload_success'));
renderLocalFontList();
loadLocalFontCount();
e.target.value = '';
} catch (err) {
Notifications.error(err.message || _('errors.server_error'));
e.target.value = '';
}
}
/**
* 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;
}
}
// Theme mode (dark / light / custom)
const themeMode = data.themeMode === 'auto' ? 'dark' : (data.themeMode || 'dark');
const themeRadio = document.querySelector(`input[name="theme-mode"][value="${themeMode}"]`)
|| document.querySelector('input[name="theme-mode"][value="dark"]');
if (themeRadio) themeRadio.checked = true;
// 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');
// Glass surfaces
setChecked('glass-enabled', data.glassEnabled !== 'false');
setVal('glass-color', data.glassColor || '#161b22');
setVal('glass-blur', data.glassBlur || '16');
setVal('glass-opacity', data.glassOpacity || '55');
if (data.glassColor && document.getElementById('glass-color-picker') && /^#[0-9a-fA-F]{6}$/.test(data.glassColor)) {
document.getElementById('glass-color-picker').value = data.glassColor;
}
const glassBlurVal = document.getElementById('glass-blur-value');
if (glassBlurVal) glassBlurVal.textContent = data.glassBlur || '16';
const glassOpacityVal = document.getElementById('glass-opacity-value');
if (glassOpacityVal) glassOpacityVal.textContent = data.glassOpacity || '55';
// 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');
// RdClient / remote dashboard
checkRadio('rdclient-bg-type', data.rdclientBgType || 'inherit');
setVal('rdclient-bg-color', data.rdclientBgColor || '');
setVal('rdclient-bg-gradient', data.rdclientBgGradient || '');
setVal('rdclient-bg-image-url', data.rdclientBgImageUrl || '');
setVal('rdclient-bg-overlay', data.rdclientBgOverlay || '0');
setVal('rdclient-bg-size', data.rdclientBgSize || 'cover');
if (data.rdclientBgColor && document.getElementById('rdclient-bg-color-picker') && /^#[0-9a-fA-F]{6}$/.test(data.rdclientBgColor)) {
document.getElementById('rdclient-bg-color-picker').value = data.rdclientBgColor;
}
const rdclientOverlayVal = document.getElementById('rdclient-bg-overlay-value');
if (rdclientOverlayVal) rdclientOverlayVal.textContent = data.rdclientBgOverlay || '0';
showBackgroundPanel('rdclient-bg', data.rdclientBgType || 'inherit');
syncExistingBackgroundUploadStatuses(data);
markSelectedBackgroundImage(data.bgImageUrl || '');
// 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'], ['rdclient-bg-type', 'rdclient-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'], ['rdclient-bg-color-picker', 'rdclient-bg-color'], ['glass-color-picker', 'glass-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'], ['rdclient-bg-overlay', 'rdclient-bg-overlay-value'], ['glass-blur', 'glass-blur-value'], ['glass-opacity', 'glass-opacity-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);
const label = document.querySelector(`label[for="${input.id}"]`);
label?.addEventListener('click', (event) => {
event.preventDefault();
input.click();
});
});
initBackgroundLibrary();
}
async function initBackgroundLibrary() {
const library = document.getElementById('bg-image-library');
if (!library) return;
library.addEventListener('click', (event) => {
const item = event.target.closest('.background-library-item');
if (!item) return;
selectBackgroundLibraryImage(item.dataset.url || '');
});
await loadBackgroundLibrary(document.getElementById('bg-image-url')?.value || '');
}
async function loadBackgroundLibrary(selectedUrl = '') {
const library = document.getElementById('bg-image-library');
if (!library) return;
library.innerHTML = `<div class="background-library-empty">${_('common.loading')}</div>`;
try {
const resp = await Utils.api('/api/settings/branding/backgrounds');
const items = Array.isArray(resp.data) ? resp.data : [];
renderBackgroundLibrary(items, selectedUrl || document.getElementById('bg-image-url')?.value || '');
} catch (err) {
library.innerHTML = `<div class="background-library-empty">${Utils.escapeHtml(err.message || _('errors.server_error'))}</div>`;
}
}
function renderBackgroundLibrary(items, selectedUrl = '') {
const library = document.getElementById('bg-image-library');
if (!library) return;
if (!items.length) {
library.innerHTML = `<div class="background-library-empty">${_('desktop.wp_unavailable')}</div>`;
return;
}
library.innerHTML = items.map(item => {
const url = String(item.url || '');
const name = String(item.name || url.split('/').pop() || '');
const isActive = url === selectedUrl;
return `
<button type="button" class="background-library-item${isActive ? ' active' : ''}" data-url="${Utils.escapeHtml(url)}" title="${Utils.escapeHtml(name)}">
<span class="background-library-thumb" style="background-image:url('${Utils.escapeHtml(url).replace(/'/g, '%27')}')"></span>
<span class="background-library-name">${Utils.escapeHtml(name)}</span>
</button>
`;
}).join('');
}
function selectBackgroundLibraryImage(url) {
if (!url) return;
const input = document.getElementById('bg-image-url');
if (input) input.value = url;
selectBackgroundImageType('bg-image-url');
setBackgroundUploadStatus(document.getElementById('bg-file-name'), url.split('/').pop() || url, url);
markSelectedBackgroundImage(url);
onBrandingFieldChange();
}
function markSelectedBackgroundImage(url) {
const library = document.getElementById('bg-image-library');
if (!library) return;
library.querySelectorAll('.background-library-item').forEach(item => {
item.classList.toggle('active', item.dataset.url === url);
});
}
/**
* 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) {
Notifications.error(_('branding.bg_image_too_large'));
e.target.value = '';
return;
}
const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
if (!validTypes.includes(file.type)) {
Notifications.error(_('branding.bg_image_invalid_type'));
e.target.value = '';
return;
}
const targetId = e.target.dataset.target;
const nameEl = getBackgroundFileNameElement(e.target);
const statusEl = getBackgroundUploadStatusElement(e.target);
setBackgroundUploadStatus(nameEl, file.name, _('branding.status_saving'));
updateBackgroundUploadPanel(statusEl, {
state: 'uploading',
icon: 'sync',
message: `${file.name}${_('branding.status_saving')}`,
percent: 1
});
const formData = new FormData();
formData.append('background', file);
formData.append('target', backgroundTargetToBrandingKey(targetId));
const uploadKey = targetId || e.target.id;
const uploadPromise = (async () => {
const result = await uploadBackgroundImage(formData, (percent) => {
setBackgroundUploadStatus(nameEl, file.name, `${percent}%`);
updateBackgroundUploadPanel(statusEl, {
state: 'uploading',
icon: 'sync',
message: `${file.name}${percent}%`,
percent
});
});
const urlInput = document.getElementById(targetId);
if (urlInput) urlInput.value = result.url;
setBackgroundUploadStatus(nameEl, file.name, result.url || 'OK');
updateBackgroundUploadPanel(statusEl, {
state: 'saving',
icon: 'sync',
message: `${_('branding.bg_upload_success')} ${_('branding.status_saving')}`,
percent: 100
});
selectBackgroundImageType(targetId);
if (result.data && typeof result.data === 'object') {
brandingData = result.data;
populateBrandingForm(brandingData);
_brandingSnapshot = JSON.stringify(collectBrandingData());
_brandingDirty = false;
setBrandingStatus('saved', _('branding.autosaved'));
if (typeof BrandingPreview !== 'undefined') {
BrandingPreview.refreshBrandingStylesheet(result.revision || Date.now());
}
} else {
onBrandingFieldChange();
}
return result.url;
})();
_backgroundUploads.set(uploadKey, uploadPromise);
let uploadedUrl = '';
try {
uploadedUrl = await uploadPromise;
} catch (err) {
setBackgroundUploadStatus(nameEl, file.name, err.message || _('errors.server_error'));
updateBackgroundUploadPanel(statusEl, {
state: 'error',
icon: 'error',
message: err.message || _('errors.server_error'),
percent: 100
});
Notifications.error(err.message || _('errors.server_error'));
} finally {
if (_backgroundUploads.get(uploadKey) === uploadPromise) {
_backgroundUploads.delete(uploadKey);
}
e.target.value = '';
}
if (uploadedUrl) {
clearTimeout(_autosaveDebounce);
await loadBackgroundLibrary(uploadedUrl);
markSelectedBackgroundImage(uploadedUrl);
updateBackgroundUploadPanel(statusEl, {
state: 'success',
icon: 'check_circle',
message: `${_('branding.bg_upload_success')} ${_('branding.autosaved')}`,
percent: 100
});
Notifications.success(`${_('branding.bg_upload_success')} ${_('branding.autosaved')}`);
}
}
function getBackgroundFileNameElement(input) {
const map = {
'bg-image-file': 'bg-file-name',
'login-bg-image-file': 'login-bg-file-name',
'agent-bg-image-file': 'agent-bg-file-name',
'rdclient-bg-image-file': 'rdclient-bg-file-name'
};
return document.getElementById(map[input.id])
|| document.getElementById(input.id.replace('-file', '-file-name'));
}
function setBackgroundUploadStatus(el, fileName, status) {
if (!el) return;
el.textContent = status ? `${fileName} (${status})` : fileName;
el.title = el.textContent;
}
function syncExistingBackgroundUploadStatuses(data) {
[
{ targetId: 'bg-image-url', type: data.bgType, url: data.bgImageUrl, labelId: 'bg-file-name' },
{ targetId: 'login-bg-image-url', type: data.loginBgType, url: data.loginBgImageUrl, labelId: 'login-bg-file-name' },
{ targetId: 'agent-bg-image-url', type: data.agentBgType, url: data.agentBgImageUrl, labelId: 'agent-bg-file-name' },
{ targetId: 'rdclient-bg-image-url', type: data.rdclientBgType, url: data.rdclientBgImageUrl, labelId: 'rdclient-bg-file-name' }
].forEach(item => {
const statusEl = getBackgroundUploadStatusElementByTarget(item.targetId);
const labelEl = document.getElementById(item.labelId);
if (!item.url || item.type !== 'image') {
if (statusEl) statusEl.hidden = true;
if (labelEl) labelEl.textContent = '';
return;
}
const fileName = item.url.split('/').pop() || item.url;
setBackgroundUploadStatus(labelEl, fileName, item.url);
updateBackgroundUploadPanel(statusEl, {
state: 'success',
icon: 'check_circle',
message: `${_('branding.status_saved')}: ${item.url}`,
percent: 100
});
});
}
function getBackgroundUploadStatusElement(input) {
return getBackgroundUploadStatusElementByTarget(input.dataset.target);
}
function getBackgroundUploadStatusElementByTarget(targetId) {
const map = {
'bg-image-url': 'bg-upload-status',
'login-bg-image-url': 'login-bg-upload-status',
'agent-bg-image-url': 'agent-bg-upload-status',
'rdclient-bg-image-url': 'rdclient-bg-upload-status'
};
return document.getElementById(map[targetId]);
}
function updateBackgroundUploadPanel(el, { state, icon, message, percent }) {
if (!el) return;
el.hidden = false;
el.classList.remove('is-uploading', 'is-saving', 'is-success', 'is-error');
if (state) el.classList.add(`is-${state}`);
const iconEl = el.querySelector('.branding-upload-status-icon');
const messageEl = el.querySelector('.branding-upload-message');
const fillEl = el.querySelector('.branding-upload-progress-fill');
if (iconEl && icon) iconEl.textContent = icon;
if (messageEl) messageEl.textContent = message || '';
if (fillEl && Number.isFinite(percent)) {
fillEl.style.width = `${Math.max(0, Math.min(100, percent))}%`;
}
}
function uploadBackgroundImage(formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/settings/branding/upload-background');
xhr.withCredentials = true;
const token = window.BetterDesk?.csrfToken || '';
if (token) xhr.setRequestHeader('x-csrf-token', token);
xhr.responseType = 'json';
xhr.upload.onprogress = (event) => {
if (!event.lengthComputable || typeof onProgress !== 'function') return;
const percent = Math.max(1, Math.min(99, Math.round((event.loaded / event.total) * 100)));
onProgress(percent);
};
xhr.onload = () => {
const result = xhr.response || parseUploadJsonResponse(xhr.responseText);
if (xhr.status < 200 || xhr.status >= 300 || !result?.success) {
reject(new Error(result?.error || `Upload failed (${xhr.status})`));
return;
}
if (typeof onProgress === 'function') onProgress(100);
resolve(result);
};
xhr.onerror = () => reject(new Error('Upload failed'));
xhr.onabort = () => reject(new Error('Upload cancelled'));
xhr.send(formData);
});
}
function parseUploadJsonResponse(text) {
if (!text) return null;
try {
return JSON.parse(text);
} catch (_) {
return null;
}
}
function selectBackgroundImageType(targetId) {
const map = {
'bg-image-url': ['bg-type', 'bg'],
'login-bg-image-url': ['login-bg-type', 'login-bg'],
'agent-bg-image-url': ['agent-bg-type', 'agent-bg'],
'rdclient-bg-image-url': ['rdclient-bg-type', 'rdclient-bg']
};
const cfg = map[targetId];
if (!cfg) return;
const [radioName, prefix] = cfg;
const imageRadio = document.querySelector(`input[name="${radioName}"][value="image"]`);
if (imageRadio && !imageRadio.checked) {
imageRadio.checked = true;
showBackgroundPanel(prefix, 'image');
}
}
function backgroundTargetToBrandingKey(targetId) {
const map = {
'bg-image-url': 'bgImageUrl',
'login-bg-image-url': 'loginBgImageUrl',
'agent-bg-image-url': 'agentBgImageUrl',
'rdclient-bg-image-url': 'rdclientBgImageUrl'
};
return map[targetId] || '';
}
/**
* 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) {
Notifications.error(_('branding.logo_image_too_large'));
e.target.value = '';
return;
}
const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
if (!validTypes.includes(file.type)) {
Notifications.error(_('branding.logo_image_invalid_type'));
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) {
Notifications.error(err.message || _('errors.server_error'));
}
}
/**
* Initialize color picker sync (picker <-> hex input)
*/
function initColorPickers() {
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;
const customRadio = document.querySelector('input[name="theme-mode"][value="custom"]');
if (customRadio) customRadio.checked = true;
onBrandingFieldChange();
});
});
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;
}
const customRadio = document.querySelector('input[name="theme-mode"][value="custom"]');
if (customRadio) customRadio.checked = true;
onBrandingFieldChange();
});
});
document.querySelectorAll('input[name="theme-mode"]').forEach(radio => {
radio.addEventListener('change', () => {
if (!radio.checked) return;
if (radio.value === 'light') {
applyBuiltInPalette({
bgPrimary: '#f0f2f5', bgSecondary: '#ffffff', bgTertiary: '#eaeef2', bgElevated: '#ffffff',
textPrimary: '#1f2328', textSecondary: '#656d76',
accentBlue: '#0969da', accentBlueHover: '#0550ae',
accentGreen: '#1a7f37', accentRed: '#cf222e', accentYellow: '#9a6700', accentPurple: '#8250df',
borderPrimary: '#d0d7de', borderSecondary: '#eaeef2'
});
const glassColor = document.getElementById('glass-color');
const glassPicker = document.getElementById('glass-color-picker');
if (glassColor) glassColor.value = '#ffffff';
if (glassPicker) glassPicker.value = '#ffffff';
} else if (radio.value === 'dark') {
applyBuiltInPalette({
bgPrimary: '#0d1117', bgSecondary: '#161b22', bgTertiary: '#21262d', bgElevated: '#30363d',
textPrimary: '#e6edf3', textSecondary: '#8b949e',
accentBlue: '#58a6ff', accentBlueHover: '#79c0ff',
accentGreen: '#2ea44f', accentRed: '#f85149', accentYellow: '#d29922', accentPurple: '#a371f7',
borderPrimary: '#30363d', borderSecondary: '#21262d'
});
const glassColor = document.getElementById('glass-color');
const glassPicker = document.getElementById('glass-color-picker');
if (glassColor) glassColor.value = '#161b22';
if (glassPicker) glassPicker.value = '#161b22';
}
onBrandingFieldChange();
});
});
}
function applyBuiltInPalette(colors) {
Object.entries(colors).forEach(([key, value]) => {
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;
});
}
/**
* 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;
}
});
data.themeMode = document.querySelector('input[name="theme-mode"]:checked')?.value || 'dark';
// 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';
// Glass surfaces
data.glassEnabled = document.getElementById('glass-enabled')?.checked ? 'true' : 'false';
data.glassColor = document.getElementById('glass-color')?.value.trim() || '';
data.glassBlur = document.getElementById('glass-blur')?.value || '';
data.glassOpacity = document.getElementById('glass-opacity')?.value || '';
// 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';
// RdClient / remote dashboard
data.rdclientBgType = document.querySelector('input[name="rdclient-bg-type"]:checked')?.value || 'inherit';
data.rdclientBgColor = document.getElementById('rdclient-bg-color')?.value.trim() || '';
data.rdclientBgGradient = document.getElementById('rdclient-bg-gradient')?.value.trim() || '';
data.rdclientBgImageUrl = document.getElementById('rdclient-bg-image-url')?.value.trim() || '';
data.rdclientBgOverlay = document.getElementById('rdclient-bg-overlay')?.value || '';
data.rdclientBgSize = document.getElementById('rdclient-bg-size')?.value || 'cover';
// 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);
if (_fontSource === 'google') params.set('source', 'google');
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() {
document.getElementById('branding-save-btn')?.addEventListener('click', () => saveBrandingNow());
document.getElementById('branding-revert-btn')?.addEventListener('click', revertBrandingChanges);
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
});
const fresh = await Utils.api('/api/settings/branding');
brandingData = fresh.data || fresh;
populateBrandingForm(brandingData);
_brandingSnapshot = JSON.stringify(collectBrandingData());
_brandingDirty = false;
setBrandingStatus('saved');
scheduleBrandingPreview();
if (typeof BrandingPreview !== 'undefined') BrandingPreview.refreshBrandingStylesheet(Date.now());
Notifications.success(_('branding.imported'));
} 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 (!(await brandingConfirm(_('branding.reset_confirm'), { danger: true }))) return;
try {
await Utils.api('/api/settings/branding/reset', { method: 'POST' });
const fresh = await Utils.api('/api/settings/branding');
brandingData = fresh.data || fresh;
populateBrandingForm(brandingData);
_brandingSnapshot = JSON.stringify(collectBrandingData());
_brandingDirty = false;
setBrandingStatus('saved');
scheduleBrandingPreview();
if (typeof BrandingPreview !== 'undefined') BrandingPreview.refreshBrandingStylesheet(Date.now());
Notifications.success(_('branding.reset_success'));
} 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 (!await settingsConfirmCritical({
title: tSettings('confirm.backup_restore_title', 'Restore backup?'),
message: tSettings('confirm.backup_restore', _('backup.restore_confirm')),
confirmLabel: _('backup.upload'),
icon: 'restore',
danger: true
})) {
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', async function() {
const confirmed = await settingsConfirmCritical({
title: tSettings('confirm.tutorials_reset_title', 'Reset tutorials?'),
message: tSettings('confirm.tutorials_reset', 'Reset all tutorial progress? Guided tips will show again on each page.'),
confirmLabel: _('tutorial.reset_all'),
icon: 'refresh'
});
if (!confirmed) return;
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 renderUpdateHeroStatus(state, opts = {}) {
const icon = document.getElementById('update-hero-icon');
const badge = document.getElementById('update-hero-badge');
const headline = document.getElementById('update-status-headline');
const desc = document.getElementById('update-status-desc');
if (!icon || !headline) return;
const iconStateByStatus = {
idle: 'idle',
checking: 'checking',
uptodate: 'success',
available: 'warning',
baseline: 'success',
error: 'error'
};
icon.dataset.state = iconStateByStatus[state] || 'idle';
const badgeByStatus = {
uptodate: { variant: 'success', icon: 'check' },
available: { variant: 'warning', icon: 'priority_high' },
baseline: { variant: 'success', icon: 'check' },
error: { variant: 'error', icon: 'error_outline' }
};
const badgeCfg = badgeByStatus[state];
if (badge) {
if (badgeCfg) {
badge.hidden = false;
badge.className = 'win11-updates-hero-badge win11-updates-hero-badge--' + badgeCfg.variant;
const badgeIcon = badge.querySelector('.material-icons');
if (badgeIcon) badgeIcon.textContent = badgeCfg.icon;
} else {
badge.hidden = true;
badge.className = 'win11-updates-hero-badge';
}
}
const messages = {
idle: { h: _('updates.title'), d: _('updates.desc') },
checking: { h: _('updates.checking'), d: _('updates.desc') },
uptodate: { h: _('updates.up_to_date'), d: opts.detail || _('updates.desc') },
available: { h: _('updates.update_available'), d: opts.detail || _('updates.desc') },
baseline: { h: _('updates.baseline_set'), d: _('updates.desc') },
error: { h: _('updates.install_failed'), d: opts.detail || _('updates.desc') }
};
const msg = messages[state] || messages.idle;
headline.textContent = msg.h;
if (desc) desc.textContent = msg.d;
}
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();
renderUpdateHeroStatus('idle');
}
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.getElementById('update-alerts');
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;
renderUpdateHeroStatus('checking');
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>`;
renderUpdateHeroStatus('baseline');
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>`;
const detail = data.latestMessage
? `${data.latestMessage}${behind}`
: `${badgeLabel}${behind}`.trim();
renderUpdateHeroStatus('available', { detail });
_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>`;
}
const detail = data.latestMessage || (data.remoteSHA ? `SHA ${data.remoteSHA.slice(0, 7)}` : '');
renderUpdateHeroStatus('uptodate', { detail: detail || _('updates.desc') });
if (detailsSection) detailsSection.style.display = 'none';
if (installBtn) installBtn.disabled = true;
}
} catch (error) {
renderUpdateHeroStatus('error', { detail: error.message || _('errors.server_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' }
];
let _updateReloadTimers = { countdown: null, auto: null };
function getActiveUpdateModalEl() {
return document.querySelector('#modal-container .modal-overlay.open .modal');
}
function updateModalTitle(title) {
const titleEl = document.querySelector('#modal-container .modal-overlay.open .modal-title');
if (titleEl) titleEl.textContent = title;
}
function setModalClosable(closable) {
const overlay = document.querySelector('#modal-container .modal-overlay.open');
if (!overlay) return;
const header = overlay.querySelector('.modal-header');
if (!header) return;
let closeBtn = header.querySelector('.modal-close');
if (closable && !closeBtn) {
closeBtn = document.createElement('button');
closeBtn.className = 'modal-close';
closeBtn.setAttribute('aria-label', _('actions.close'));
closeBtn.innerHTML = '<span class="material-icons">close</span>';
closeBtn.addEventListener('click', () => window.Modal.close());
header.appendChild(closeBtn);
} else if (!closable && closeBtn) {
closeBtn.remove();
}
}
function updateModalFooter(buttons) {
const modal = getActiveUpdateModalEl();
if (!modal) return;
let footer = modal.querySelector('.modal-footer');
if (!footer && buttons.length) {
footer = document.createElement('div');
footer.className = 'modal-footer update-wizard-footer-enter';
modal.appendChild(footer);
} else if (footer && !buttons.length) {
footer.remove();
return;
}
if (!footer) return;
footer.classList.add('update-wizard-footer-enter');
footer.innerHTML = buttons.map((btn, idx) => `
<button type="button" class="btn ${btn.class || 'btn-secondary'}" data-btn-index="${idx}">
${btn.icon ? `<span class="material-icons">${btn.icon}</span>` : ''}
${Utils.escapeHtml(btn.label)}
</button>
`).join('');
buttons.forEach((btn, idx) => {
footer.querySelector(`[data-btn-index="${idx}"]`)?.addEventListener('click', () => btn.onClick?.());
});
}
function clearUpdateReloadTimers() {
if (_updateReloadTimers.countdown) clearInterval(_updateReloadTimers.countdown);
if (_updateReloadTimers.auto) clearTimeout(_updateReloadTimers.auto);
_updateReloadTimers = { countdown: null, auto: null };
}
function buildUpdateModalContent() {
const stepLabel = _('updates.progress_step')
.replace('{current}', '1')
.replace('{total}', String(UPDATE_PHASES.length));
const items = UPDATE_PHASES.map(p => `
<div class="update-phase is-pending" 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-wizard" data-state="running">
<div class="update-wizard-header">
<div class="update-wizard-bar">
<div class="update-wizard-bar-fill" id="update-modal-bar" style="width:0%"></div>
</div>
<div class="update-wizard-meta">
<span class="update-wizard-step" id="update-modal-step">${Utils.escapeHtml(stepLabel)}</span>
<span class="update-wizard-detail" id="update-modal-detail">${Utils.escapeHtml(_('updates.preparing'))}</span>
</div>
</div>
<div class="update-wizard-phases update-phases">${items}</div>
<div class="update-wizard-log-panel" id="update-wizard-log-panel">
<div class="update-wizard-log-toolbar">
<span class="update-wizard-log-title">${Utils.escapeHtml(_('updates.log_title'))}</span>
<div class="update-wizard-log-actions">
<button type="button" class="btn btn-ghost btn-sm" id="update-log-expand">${Utils.escapeHtml(_('updates.log_expand'))}</button>
<button type="button" class="btn btn-ghost btn-sm" id="update-log-copy">
<span class="material-icons">content_copy</span>
${Utils.escapeHtml(_('updates.log_copy'))}
</button>
</div>
</div>
<pre class="update-progress-log" id="update-modal-log" aria-live="polite"></pre>
</div>
<div class="update-wizard-banner" id="update-wizard-banner" hidden>
<div class="update-wizard-banner-body">
<div id="update-wizard-banner-text"></div>
<div id="update-wizard-banner-details" class="update-wizard-banner-details"></div>
<div class="update-wizard-countdown" id="update-reload-countdown-wrap" hidden>
<span id="update-reload-countdown"></span>
<div class="update-wizard-countdown-bar">
<div class="update-wizard-countdown-bar-fill"></div>
</div>
</div>
</div>
</div>
</div>
`;
}
function bindUpdateLogControls() {
document.getElementById('update-log-expand')?.addEventListener('click', () => toggleUpdateLogExpanded());
document.getElementById('update-log-copy')?.addEventListener('click', () => copyUpdateLog());
}
function getUpdateLogText() {
return document.getElementById('update-modal-log')?.textContent || '';
}
function copyUpdateLog() {
const text = getUpdateLogText();
if (!text) return;
navigator.clipboard?.writeText(text).then(() => {
Notifications.success(_('common.copied'));
}).catch(() => {});
}
function toggleUpdateLogExpanded(forceExpand) {
const panel = document.getElementById('update-wizard-log-panel');
const btn = document.getElementById('update-log-expand');
if (!panel) return;
const expanded = forceExpand === true ? true
: forceExpand === false ? false
: !panel.classList.contains('expanded');
panel.classList.toggle('expanded', expanded);
if (btn) btn.textContent = _(expanded ? 'updates.log_collapse' : 'updates.log_expand');
}
function updateUpdateStepCounter(phaseId, state) {
const stepEl = document.getElementById('update-modal-step');
if (!stepEl) return;
const idx = UPDATE_PHASES.findIndex(p => p.id === phaseId);
if (idx < 0) return;
let current = idx + 1;
if (state === 'pending') current = Math.max(1, idx);
stepEl.textContent = _('updates.progress_step')
.replace('{current}', String(current))
.replace('{total}', String(UPDATE_PHASES.length));
}
function setUpdatePhase(phaseId, state, detail) {
// state: 'pending' | 'active' | 'done' | 'warning' | 'error' | 'skipped'
const row = document.querySelector(`.update-phase[data-phase="${phaseId}"]`);
if (row) {
row.classList.remove('is-pending', 'is-active', 'is-done', 'is-warning', 'is-error', 'is-skipped');
const rowState = state === 'pending' ? 'pending'
: state === 'active' ? 'active'
: state === 'done' ? 'done'
: state === 'warning' ? 'warning'
: state === 'error' ? 'error'
: state === 'skipped' ? 'skipped' : 'pending';
row.classList.add(`is-${rowState}`);
}
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: state === 'done' ? 'just-done' : '',
warning: '',
error: '',
skipped: ''
};
stateEl.innerHTML = `<span class="material-icons ${cls[state] || ''}">${icons[state] || 'help'}</span>`;
stateEl.dataset.state = state;
if (state === 'done') {
setTimeout(() => {
stateEl.querySelector('.just-done')?.classList.remove('just-done');
}, 400);
}
}
if (detail) {
const det = document.getElementById('update-modal-detail');
if (det) det.textContent = detail;
}
updateUpdateStepCounter(phaseId, state);
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();
const atBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 24;
log.textContent += `[${ts}] ${line}\n`;
if (atBottom) log.scrollTop = log.scrollHeight;
}
function reloadConsole() {
clearUpdateReloadTimers();
const url = new URL(window.location.href);
url.searchParams.set('_bd_reload', Date.now().toString());
window.location.replace(url.toString());
}
function hasUpdateFailures(result) {
if (!result) return false;
const deployFailed = !!(result.serverDeploy && result.serverDeploy.success === false);
return (result.failed?.length || 0) > 0
|| (result.servicesFailed?.length || 0) > 0
|| !!result.consoleRestartBlocked
|| !!result.restartTimeout
|| deployFailed;
}
function buildUpdateSummaryHtml(result, options = {}) {
const { includeSummary = true } = options;
const lines = [];
const deployFailed = !!(result?.serverDeploy && result.serverDeploy.success === false);
const hasFailures = hasUpdateFailures(result);
if (includeSummary) {
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>${stats.map(s => `<li>${Utils.escapeHtml(s.label)}: <strong>${s.value}</strong></li>`).join('')}</ul>`);
if (result?.failed?.length) {
lines.push(`<ul class="update-wizard-error-list">${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 class="update-wizard-error-list">${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 class="update-wizard-error-list">${Utils.escapeHtml(result.consoleRestartBlocked)}</p>`);
}
if (result?.restartTimeout) {
lines.push(`<p>${Utils.escapeHtml(_('updates.restart_timeout'))}</p>`);
lines.push(`<p>${Utils.escapeHtml(_('updates.refresh_recommended'))}</p>`);
}
if (result?.errorMessage) {
lines.push(`<p class="update-wizard-error-list">${Utils.escapeHtml(result.errorMessage)}</p>`);
}
if (deployFailed) {
const errMsg = result.serverDeploy.error || '';
lines.push(`<p class="update-wizard-error-list"><strong>${Utils.escapeHtml(_('updates.server_deploy_failed'))}</strong></p>`);
if (errMsg) lines.push(`<pre style="font-size:11px;white-space:pre-wrap;margin:4px 0 0;">${Utils.escapeHtml(errMsg)}</pre>`);
} else if (result?.serverBuild?.success) {
const note = result.serverBuild.method === 'download' ? _('updates.server_downloaded') : _('updates.server_built');
lines.push(`<p>${Utils.escapeHtml(note)}</p>`);
}
const needsReload = (result?.applied || []).some(p => /\.(js|css|html|ejs)$/i.test(p));
if (needsReload && !result?.restartTimeout) {
lines.push(`<p>${Utils.escapeHtml(_('updates.refresh_recommended'))}</p>`);
}
return lines.join('');
}
function startReloadCountdown(seconds = 8) {
clearUpdateReloadTimers();
const wrap = document.getElementById('update-reload-countdown-wrap');
const countdownEl = document.getElementById('update-reload-countdown');
const barFill = document.querySelector('.update-wizard-countdown-bar-fill');
if (wrap) wrap.hidden = false;
if (barFill) {
barFill.style.animation = 'none';
void barFill.offsetWidth;
barFill.style.animation = `updateCountdownShrink ${seconds}s linear forwards`;
}
let remaining = seconds;
const tick = () => {
if (countdownEl) {
countdownEl.textContent = _('updates.reload_countdown').replace('{seconds}', String(remaining));
}
};
tick();
_updateReloadTimers.countdown = setInterval(() => {
remaining -= 1;
if (remaining >= 0) tick();
}, 1000);
_updateReloadTimers.auto = setTimeout(reloadConsole, seconds * 1000);
}
function transitionUpdateModal(state, result) {
const wizard = document.querySelector('.update-wizard');
if (!wizard) return;
wizard.dataset.state = state;
const banner = document.getElementById('update-wizard-banner');
const bannerText = document.getElementById('update-wizard-banner-text');
const bannerDetails = document.getElementById('update-wizard-banner-details');
const countdownWrap = document.getElementById('update-reload-countdown-wrap');
clearUpdateReloadTimers();
if (countdownWrap) countdownWrap.hidden = true;
banner.hidden = false;
banner.classList.remove('is-success', 'is-error', 'is-warning');
const hasFailures = hasUpdateFailures(result);
const closeFooterBtn = {
label: _('updates.modal_close'),
class: 'btn-secondary',
onClick: () => {
window.Modal.close();
enableUpdateInstallBtn();
}
};
if (state === 'success_reload') {
setModalClosable(true);
updateModalTitle(_('updates.modal_done_title'));
banner.classList.add('is-success');
if (bannerText) bannerText.innerHTML = `<p>${Utils.escapeHtml(_('updates.restart_complete_msg'))}</p>`;
if (bannerDetails) bannerDetails.innerHTML = '';
updateModalFooter([
closeFooterBtn,
{ label: _('updates.modal_reload_now'), class: 'btn-primary', icon: 'refresh', onClick: reloadConsole }
]);
startReloadCountdown(8);
} else if (state === 'success_no_reload') {
setModalClosable(true);
updateModalTitle(_('updates.modal_done_title'));
banner.classList.add('is-success');
if (bannerText) bannerText.innerHTML = `<p>${Utils.escapeHtml(_('updates.complete_summary'))}</p>`;
if (bannerDetails) bannerDetails.innerHTML = buildUpdateSummaryHtml(result, { includeSummary: false });
const footer = [closeFooterBtn];
const needsReload = (result?.applied || []).some(p => /\.(js|css|html|ejs)$/i.test(p));
if (needsReload) {
footer.push({ label: _('updates.modal_reload_now'), class: 'btn-primary', icon: 'refresh', onClick: reloadConsole });
}
updateModalFooter(footer);
} else if (state === 'partial_error' || state === 'error') {
toggleUpdateLogExpanded(true);
setModalClosable(true);
updateModalTitle(_('updates.modal_done_with_errors_title'));
banner.classList.add(result?.restartTimeout ? 'is-warning' : 'is-error');
if (bannerText) bannerText.innerHTML = '';
if (bannerDetails) bannerDetails.innerHTML = buildUpdateSummaryHtml(result);
const errorFooter = [
closeFooterBtn,
{ label: _('updates.log_copy'), class: 'btn-secondary', icon: 'content_copy', onClick: copyUpdateLog },
{ label: _('updates.retry_update'), class: 'btn-primary', icon: 'replay', onClick: retryUpdateInstall }
];
if (result?.restartTimeout) {
errorFooter.splice(1, 0, {
label: _('updates.modal_reload_now'),
class: 'btn-secondary',
icon: 'refresh',
onClick: reloadConsole
});
}
updateModalFooter(errorFooter);
enableUpdateInstallBtn();
}
loadLastUpdateResult();
}
function enableUpdateInstallBtn() {
const installBtn = document.getElementById('update-install-btn');
if (installBtn) installBtn.disabled = false;
}
async function retryUpdateInstall() {
const proceed = await window.Modal.confirm({
title: _('updates.retry_update'),
message: _('updates.retry_confirm'),
confirmLabel: _('updates.retry_update'),
confirmIcon: 'replay'
});
if (!proceed) return;
window.Modal.close();
installUpdate({ skipConfirm: true });
}
function showUpdateCompletionModal(result) {
if (hasUpdateFailures(result)) {
transitionUpdateModal('partial_error', result);
} else {
transitionUpdateModal('success_no_reload', result);
}
}
async function installUpdate(options = {}) {
const { skipConfirm = false } = options;
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;
if (!skipConfirm) {
// 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 */ }
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;
clearUpdateReloadTimers();
window.Modal.show({
title: _('updates.modal_title'),
content: buildUpdateModalContent(),
buttons: [],
closable: false,
size: 'large',
onOpen: () => bindUpdateLogControls()
});
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 {
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 }
});
['backup', 'console'].forEach(p => {
const st = document.querySelector(`[data-phase-state="${p}"]`)?.dataset?.state;
if (st !== 'done' && st !== 'skipped') setUpdatePhase(p, 'done');
});
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) {
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(result), 2500);
} else if (result.consoleRestartBlocked) {
setUpdatePhase('restart', 'error', result.consoleRestartBlocked);
logUpdate(result.consoleRestartBlocked);
setUpdatePhase('done', 'done', _('updates.complete_with_errors'));
showUpdateCompletionModal(result);
loadServerBinaryStatus();
} else {
setUpdatePhase('restart', 'skipped', _('updates.no_restart_needed'));
setUpdatePhase('done', 'done', _('updates.complete'));
showUpdateCompletionModal(result);
enableUpdateInstallBtn();
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}`);
transitionUpdateModal('error', { errorMessage: error.message || _('errors.server_error') });
Notifications.error(error.message || _('updates.install_failed'));
enableUpdateInstallBtn();
}
}
function pollConsoleRestart(installResult) {
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'));
const merged = { ...(installResult || {}), restartTimeout: false };
if (hasUpdateFailures(merged)) {
transitionUpdateModal('partial_error', merged);
} else {
transitionUpdateModal('success_reload', merged);
}
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 merged = {
...(installResult || {}),
restartTimeout: true,
failed: installResult?.failed || [],
applied: installResult?.applied || [],
removed: installResult?.removed || []
};
transitionUpdateModal('partial_error', merged);
}
}, 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;
let enrollmentMode = 'open';
async function loadEnrollmentSettings() {
try {
const res = await Utils.api('/api/settings/enrollment');
const data = res.data || res;
const mode = data.mode || 'open';
enrollmentMode = mode;
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', async () => {
if (!radio.checked) return;
const previous = enrollmentMode;
const next = radio.value;
if (next === previous) return;
const modeLabel = (window.BetterDesk?.translations?.tokens?.['mode_' + next]
|| next);
const ok = await applyWithConfirm({
previousValue: previous,
newValue: next,
setUiValue: (val) => {
modeRadios.forEach(r => { r.checked = r.value === val; });
if (requireCb) requireCb.checked = val === 'managed';
},
confirmOptions: {
title: tSettings('confirm.enrollment_mode_title', 'Change enrollment mode?'),
message: tSettings('confirm.enrollment_mode', 'Switch enrollment to “{mode}”? This affects how new RustDesk clients can register.')
.replace('{mode}', modeLabel),
confirmLabel: _('actions.save'),
icon: 'how_to_reg'
},
save: async (mode) => {
if (requireCb) requireCb.checked = mode === 'managed';
await Utils.api('/api/settings/enrollment', {
method: 'PUT',
body: { mode },
});
enrollmentMode = mode;
Notifications?.success?.(window.BetterDesk?.translations?.common?.saved || 'Saved');
await loadEnrollmentSettings();
}
});
if (!ok && requireCb) requireCb.checked = previous === 'managed';
});
});
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 initClientSessionsSection() {
const form = document.getElementById('client-sessions-form');
if (!form) return;
const expiryInput = document.getElementById('client-sessions-expiry-days');
const slidingCb = document.getElementById('client-sessions-sliding');
const maxDaysInput = document.getElementById('client-sessions-max-days');
async function loadClientSessionsSettings() {
try {
const res = await Utils.api('/api/settings/client-sessions');
const data = res.data || res;
if (expiryInput) expiryInput.value = String(data.expiry_days ?? 7);
if (slidingCb) slidingCb.checked = data.sliding !== false;
if (maxDaysInput) maxDaysInput.value = String(data.max_days ?? 30);
} catch (err) {
console.error('Load client session settings:', err);
}
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
const expiryDays = parseInt(expiryInput?.value, 10) || 7;
const maxDays = parseInt(maxDaysInput?.value, 10) || 30;
try {
await Utils.api('/api/settings/client-sessions', {
method: 'PUT',
body: {
expiry_days: expiryDays,
sliding: !!slidingCb?.checked,
max_days: maxDays,
},
});
Notifications?.success?.(window.BetterDesk?.translations?.common?.saved || 'Saved');
await loadClientSessionsSettings();
} catch (err) {
Notifications?.error?.(err.message || 'Save failed');
}
});
await loadClientSessionsSettings();
}
async function initLdapSection() {
const form = document.getElementById('ldap-form');
if (!form) return;
const enabledCb = document.getElementById('ldap-enabled');
const configFields = document.getElementById('ldap-config-fields');
if (enabledCb && configFields) {
enabledCb.addEventListener('change', async () => {
if (enabledCb.checked && !_ldapWasEnabled) {
const ok = await settingsConfirmCritical({
title: tSettings('confirm.ldap_enable_title', 'Enable LDAP?'),
message: tSettings('confirm.ldap_enable', 'Enable LDAP / Active Directory sign-in? Console users may authenticate against your directory.'),
confirmLabel: _('settings.ldap_enabled'),
icon: 'domain'
});
if (!ok) {
enabledCb.checked = false;
configFields.style.display = 'none';
return;
}
}
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();
const data = collectLdapData();
if (data.enabled) {
const confirmed = await settingsConfirmCritical({
title: tSettings('confirm.ldap_save_title', 'Save LDAP settings?'),
message: tSettings('confirm.ldap_save', 'Save LDAP configuration? Incorrect settings can block directory sign-in.'),
confirmLabel: _('settings.ldap_save'),
icon: 'save'
});
if (!confirmed) return;
}
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);
_ldapWasEnabled = !!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'));
_ldapWasEnabled = data.enabled;
} 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 ====================
const OIDC_CALLBACK_PATHS = ['/api/auth/oidc/callback', '/api/oidc/callback'];
function suggestedOidcRedirectUrl() {
return window.location.origin.replace(/\/+$/, '') + '/api/auth/oidc/callback';
}
function isValidOidcRedirectUrlClient(raw) {
if (!raw || typeof raw !== 'string') return false;
try {
const u = new URL(raw.trim());
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
const path = (u.pathname || '/').replace(/\/+$/, '') || '/';
return OIDC_CALLBACK_PATHS.includes(path);
} catch {
return false;
}
}
function updateOidcRedirectWarning() {
const warn = document.getElementById('oidc-redirect-url-warning');
const input = document.getElementById('oidc-redirect-url');
const enabled = document.getElementById('oidc-enabled')?.checked;
if (!warn || !input) return;
const value = (input.value || '').trim();
if (!enabled || !value) {
warn.style.display = 'none';
warn.textContent = '';
return;
}
if (isValidOidcRedirectUrlClient(value)) {
warn.style.display = 'none';
warn.textContent = '';
return;
}
const suggest = suggestedOidcRedirectUrl();
warn.style.display = '';
warn.textContent = _('settings.oidc_redirect_url_invalid') + ' ' +
_('settings.oidc_redirect_url_example').replace('{url}', suggest);
}
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', async () => {
if (enabledCb.checked && !_oidcWasEnabled) {
const ok = await settingsConfirmCritical({
title: tSettings('confirm.oidc_enable_title', 'Enable SSO?'),
message: tSettings('confirm.oidc_enable', 'Enable OIDC / SSO sign-in? Users may authenticate through your identity provider.'),
confirmLabel: _('settings.oidc_enabled'),
icon: 'login'
});
if (!ok) {
enabledCb.checked = false;
configFields.style.display = 'none';
updateOidcRedirectWarning();
return;
}
}
configFields.style.display = enabledCb.checked ? '' : 'none';
// Prefill Redirect URL with panel callback when enabling and field is empty
if (enabledCb.checked) {
const redirectInput = document.getElementById('oidc-redirect-url');
if (redirectInput && !redirectInput.value.trim()) {
redirectInput.value = suggestedOidcRedirectUrl();
}
const panelInput = document.getElementById('oidc-panel-url');
if (panelInput && !panelInput.value.trim()) {
panelInput.value = window.location.origin;
}
}
updateOidcRedirectWarning();
});
}
// 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' : '';
});
}
document.getElementById('oidc-redirect-url')?.addEventListener('input', updateOidcRedirectWarning);
document.getElementById('oidc-redirect-url')?.addEventListener('change', updateOidcRedirectWarning);
// 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();
const data = collectOidcData();
if (data.enabled) {
if (!isValidOidcRedirectUrlClient(data.redirect_url)) {
updateOidcRedirectWarning();
Notifications.error(_('settings.oidc_redirect_url_invalid'));
document.getElementById('oidc-redirect-url')?.focus();
return;
}
const confirmed = await settingsConfirmCritical({
title: tSettings('confirm.oidc_save_title', 'Save SSO settings?'),
message: tSettings('confirm.oidc_save', 'Save OIDC / SSO configuration? Incorrect settings can block SSO sign-in.'),
confirmLabel: _('settings.oidc_save'),
icon: 'save'
});
if (!confirmed) return;
}
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);
_oidcWasEnabled = !!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-panel-url', data.panel_url || window.location.origin);
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' : '';
updateOidcRedirectWarning();
}
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'),
panel_url: getVal('oidc-panel-url') || window.location.origin,
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'));
_oidcWasEnabled = data.enabled;
} 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', async () => {
const id = btn.getAttribute('data-id');
if (!id) return;
if (advancedState.dirty && !await settingsConfirmCritical({
title: tSettings('confirm.advanced_discard_title', 'Discard changes?'),
message: _('settings.advanced_unsaved_confirm'),
confirmLabel: _('actions.confirm'),
icon: 'undo',
danger: true
})) 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 && !await settingsConfirmCritical({
title: tSettings('confirm.advanced_discard_title', 'Discard changes?'),
message: _('settings.advanced_unsaved_confirm'),
confirmLabel: _('actions.confirm'),
icon: 'undo',
danger: true
})) 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 (!await settingsConfirmCritical({
title: tSettings('confirm.advanced_save_title', 'Save configuration file?'),
message: _('settings.advanced_save_confirm'),
confirmLabel: _('settings.advanced_save'),
icon: 'save'
})) 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 (await settingsConfirmCritical({
title: tSettings('confirm.advanced_restart_after_title', 'Restart service?'),
message: _('settings.advanced_restart_after_save'),
confirmLabel: _('settings.advanced_apply_restart'),
icon: 'restart_alt'
})) {
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 && !await settingsConfirmCritical({
title: tSettings('confirm.advanced_discard_title', 'Discard changes?'),
message: _('settings.advanced_unsaved_confirm'),
confirmLabel: _('actions.confirm'),
icon: 'undo',
danger: true
})) 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 && !await settingsConfirmCritical({
title: tSettings('confirm.advanced_restart_title', 'Restart service?'),
message: _(confirmKey),
confirmLabel: _('settings.advanced_apply_restart'),
icon: 'restart_alt',
danger: true
})) 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);
}
// ==================== Email / SMTP ====================
function initEmailSection() {
const saveBtn = document.getElementById('email-smtp-save-btn');
const testBtn = document.getElementById('email-smtp-test-btn');
if (!saveBtn || !testBtn) return;
loadEmailSmtpConfig();
saveBtn.addEventListener('click', async () => {
const body = {
host: document.getElementById('email-smtp-host')?.value.trim() || '',
port: parseInt(document.getElementById('email-smtp-port')?.value, 10) || 587,
secure: !!document.getElementById('email-smtp-secure')?.checked,
user: document.getElementById('email-smtp-user')?.value.trim() || '',
pass: document.getElementById('email-smtp-pass')?.value || '',
from: document.getElementById('email-smtp-from')?.value.trim() || '',
alert_email: document.getElementById('email-smtp-alert')?.value.trim() || '',
};
if (!_smtpWasConfigured || body.host) {
const confirmed = await settingsConfirmCritical({
title: tSettings('confirm.smtp_save_title', 'Save SMTP settings?'),
message: tSettings('confirm.smtp_save', 'Save SMTP configuration? Alert emails and notifications will use this mail server.'),
confirmLabel: _('settings.email.smtp_save') || _('actions.save'),
icon: 'email'
});
if (!confirmed) return;
}
try {
await Utils.api('/api/settings/email/smtp', { method: 'PUT', body });
Notifications.success(_('settings.email.smtp_saved'));
document.getElementById('email-smtp-pass').value = '';
await loadEmailSmtpConfig();
} catch (err) {
Notifications.error(err.message || _('errors.server_error'));
}
});
testBtn.addEventListener('click', async () => {
try {
const data = await Utils.api('/api/settings/email/smtp/test', { method: 'POST', body: {} });
if (data.success) {
Notifications.success(_('settings.email.smtp_test_success'));
} else {
Notifications.error(data.error || _('settings.email.smtp_test_failed'));
}
} catch (_) {
Notifications.error(_('settings.email.smtp_test_failed'));
}
});
}
async function loadEmailSmtpConfig() {
try {
const config = await Utils.api('/api/settings/email/smtp');
if (!config.configured) {
_smtpWasConfigured = false;
return;
}
_smtpWasConfigured = true;
document.getElementById('email-smtp-host').value = config.host || '';
document.getElementById('email-smtp-port').value = config.port || 587;
document.getElementById('email-smtp-secure').checked = !!config.secure;
document.getElementById('email-smtp-user').value = config.user || '';
document.getElementById('email-smtp-from').value = config.from || '';
document.getElementById('email-smtp-alert').value = config.alert_email || '';
} catch (_) { /* not configured yet */ }
}
})();