diff --git a/public/css/app.css b/public/css/app.css index 8ae89441..4d715947 100644 --- a/public/css/app.css +++ b/public/css/app.css @@ -2058,3 +2058,11 @@ body.rdp-session-body { /* Animations (rdp- prefix to avoid conflicts) */ @keyframes rdp-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } } @keyframes rdp-spin { to { transform: rotate(360deg); } } + +/* Settings autosave feedback */ +.autosave-status { font-size: 11px; color: var(--text-3); min-height: 14px; transition: filter .15s ease; } +.autosave-status.field-saving { filter: blur(2px); opacity: .7; } +.autosave-status.autosave-error { color: var(--danger, #dc2626); filter: none; opacity: 1; } +@media (prefers-reduced-motion: reduce) { + .autosave-status.field-saving { filter: none; opacity: .5; } +} diff --git a/public/css/pro.css b/public/css/pro.css index b2f00113..ffc8caee 100644 --- a/public/css/pro.css +++ b/public/css/pro.css @@ -3605,3 +3605,11 @@ body.rdp-session-body { /* Animations (rdp- prefix to avoid conflicts) */ @keyframes rdp-pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } } @keyframes rdp-spin { to { transform: rotate(360deg); } } + +/* Settings autosave feedback */ +.autosave-status { font-size: 11px; color: var(--text-3); min-height: 14px; transition: filter .15s ease; } +.autosave-status.field-saving { filter: blur(2px); opacity: .7; } +.autosave-status.autosave-error { color: var(--danger, #dc2626); filter: none; opacity: 1; } +@media (prefers-reduced-motion: reduce) { + .autosave-status.field-saving { filter: none; opacity: .5; } +} diff --git a/public/js/settings.js b/public/js/settings.js index bea343cb..eeb6bcf9 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -305,7 +305,7 @@ else tlsToggle.classList.remove('on'); if (data.data.hasPassword) { var hint = document.getElementById('smtp-password-hint'); - hint.textContent = 'Password is set'; + hint.textContent = (window.GC.t || {})['settings.smtp.password_set'] || 'Password is set'; hint.style.display = ''; } } @@ -318,44 +318,73 @@ if (smtpTlsToggle) { smtpTlsToggle.addEventListener('click', function() { smtpTlsToggle.classList.toggle('on'); + smtpTlsToggle.dispatchEvent(new Event('change')); }); } - // Save SMTP settings - var btnSmtpSave = document.getElementById('btn-smtp-save'); - if (btnSmtpSave) { - btnSmtpSave.addEventListener('click', async function() { - btnLoading(btnSmtpSave); - try { - var payload = { + // SMTP autosave + var Core = window.SettingsAutosaveCore; + function smtpValues() { + var hostEl = document.getElementById('smtp-host'); + var portEl = document.getElementById('smtp-port'); + var userEl = document.getElementById('smtp-user'); + var fromEl = document.getElementById('smtp-from'); + var tlsEl = document.getElementById('smtp-tls'); + var pwEl = document.getElementById('smtp-password'); + return { + 'smtp-host': hostEl ? hostEl.value : '', + 'smtp-port': portEl ? portEl.value : '', + 'smtp-user': userEl ? userEl.value : '', + 'smtp-from': fromEl ? fromEl.value : '', + 'smtp-tls': tlsEl ? tlsEl.classList.contains('on') : false, + 'smtp-password': pwEl ? pwEl.value : '', + }; + } + function smtpSave() { + var payload = { + host: document.getElementById('smtp-host').value, + port: document.getElementById('smtp-port').value, + user: document.getElementById('smtp-user').value, + from: document.getElementById('smtp-from').value, + secure: document.getElementById('smtp-tls').classList.contains('on'), + }; + var pw = document.getElementById('smtp-password').value; + if (pw) payload.password = pw; + payload = Core.stripEmptySecrets(payload, ['password']); + return api.put('/api/smtp/settings', payload).then(function (res) { + if (res && res.ok && pw) { + var hint = document.getElementById('smtp-password-hint'); + if (hint) { hint.textContent = (window.GC.t || {})['settings.smtp.password_set'] || 'Password is set'; hint.style.display = ''; } + } + return res; + }); + } + (function () { + var smtpFields = ['smtp-host', 'smtp-port', 'smtp-user', 'smtp-from', 'smtp-tls', 'smtp-password'] + .map(function (i) { return document.getElementById(i); }).filter(Boolean); + if (smtpFields.length) { + SettingsAutosave.bind({ + cluster: 'smtp', + fields: smtpFields, + statusEl: document.getElementById('smtp-status'), + valuesById: smtpValues, + save: smtpSave, + }); + } + })(); + // SMTP password clear + var smtpClear = document.getElementById('smtp-password-clear'); + if (smtpClear) { + smtpClear.addEventListener('click', function () { + if (!window.confirm((window.GC.t || {})['settings.autosave.clear_secret_confirm'] || 'Remove the stored value?')) return; + SettingsAutosave.enqueue('smtp', function () { + return api.put('/api/smtp/settings', { host: document.getElementById('smtp-host').value, port: document.getElementById('smtp-port').value, - user: document.getElementById('smtp-user').value, from: document.getElementById('smtp-from').value, - secure: document.getElementById('smtp-tls').classList.contains('on'), - }; - var pw = document.getElementById('smtp-password').value; - if (pw) payload.password = pw; - var data = await api.put('/api/smtp/settings', payload); - if (data.ok) { - if (pw) { - var hint = document.getElementById('smtp-password-hint'); - hint.textContent = 'Password is set'; - hint.style.display = ''; - document.getElementById('smtp-password').value = ''; - } - showMessage('smtp-test-result', 'SMTP settings saved', 'success'); - document.getElementById('smtp-test-result').style.display = ''; - } else { - showMessage('smtp-test-result', data.error || 'Failed to save SMTP settings', 'error'); - document.getElementById('smtp-test-result').style.display = ''; - } - } catch (err) { - showMessage('smtp-test-result', err.message, 'error'); - document.getElementById('smtp-test-result').style.display = ''; - } finally { - btnReset(btnSmtpSave); - } + clear_password: true, + }); + }); }); } @@ -395,7 +424,11 @@ // Toggle helpers for managed toggles function setupManagedToggle(id) { var el = document.getElementById(id); - if (el) el.addEventListener('click', function() { el.classList.toggle('on'); }); + if (!el) return; + el.addEventListener('click', function () { + el.classList.toggle('on'); + el.dispatchEvent(new Event('change')); // <-- enables autosave on toggles + }); } ['security-lockout-enabled', 'security-password-enabled', 'security-password-uppercase', 'security-password-number', 'security-password-special'].forEach(setupManagedToggle); @@ -472,49 +505,54 @@ } } - async function saveSecuritySettings(triggerBtn, messageId) { - btnLoading(triggerBtn); - try { - var payload = { + // ─── Security Autosave (single bind over all 8 fields) ─── + (function () { + var g = function (id) { return document.getElementById(id); }; + function securityValues() { + return { + 'security-lockout-enabled': g('security-lockout-enabled') ? g('security-lockout-enabled').classList.contains('on') : false, + 'security-lockout-attempts': g('security-lockout-attempts') ? g('security-lockout-attempts').value : '', + 'security-lockout-duration': g('security-lockout-duration') ? g('security-lockout-duration').value : '', + 'security-password-enabled': g('security-password-enabled') ? g('security-password-enabled').classList.contains('on') : false, + 'security-password-min-length': g('security-password-min-length') ? g('security-password-min-length').value : '', + 'security-password-uppercase': g('security-password-uppercase') ? g('security-password-uppercase').classList.contains('on') : false, + 'security-password-number': g('security-password-number') ? g('security-password-number').classList.contains('on') : false, + 'security-password-special': g('security-password-special') ? g('security-password-special').classList.contains('on') : false, + }; + } + function securitySave() { + var v = securityValues(); + return api.put('/api/settings/security', { lockout: { - enabled: document.getElementById('security-lockout-enabled').classList.contains('on'), - max_attempts: document.getElementById('security-lockout-attempts').value, - duration: document.getElementById('security-lockout-duration').value, + enabled: v['security-lockout-enabled'], + max_attempts: v['security-lockout-attempts'], + duration: v['security-lockout-duration'], }, password: { - complexity_enabled: document.getElementById('security-password-enabled').classList.contains('on'), - min_length: document.getElementById('security-password-min-length').value, - require_uppercase: document.getElementById('security-password-uppercase').classList.contains('on'), - require_number: document.getElementById('security-password-number').classList.contains('on'), - require_special: document.getElementById('security-password-special').classList.contains('on'), + complexity_enabled: v['security-password-enabled'], + min_length: v['security-password-min-length'], + require_uppercase: v['security-password-uppercase'], + require_number: v['security-password-number'], + require_special: v['security-password-special'], }, - }; - var data = await api.put('/api/settings/security', payload); - if (data.ok) { - showMessage(messageId, GC.t['security.saved'] || 'Security settings saved', 'success'); - } else { - showMessage(messageId, data.error || 'Failed to save', 'error'); - } - } catch (err) { - showMessage(messageId, err.message, 'error'); - } finally { - btnReset(triggerBtn); + }); } - } - - var btnSecuritySave = document.getElementById('btn-security-save'); - if (btnSecuritySave) { - btnSecuritySave.addEventListener('click', function() { - saveSecuritySettings(btnSecuritySave, 'security-message'); - }); - } - - var btnPasswordSave = document.getElementById('btn-password-save'); - if (btnPasswordSave) { - btnPasswordSave.addEventListener('click', function() { - saveSecuritySettings(btnPasswordSave, 'security-message-2'); - }); - } + var securityFieldIds = [ + 'security-lockout-enabled', 'security-lockout-attempts', 'security-lockout-duration', + 'security-password-enabled', 'security-password-min-length', 'security-password-uppercase', + 'security-password-number', 'security-password-special', + ]; + var securityFields = securityFieldIds.map(function (id) { return document.getElementById(id); }).filter(Boolean); + if (securityFields.length) { + SettingsAutosave.bind({ + cluster: 'security', + fields: securityFields, + statusEl: document.getElementById('security-status'), + valuesById: securityValues, + save: securitySave, + }); + } + })(); // ─── Monitoring Settings ─────────────────────────────── @@ -536,33 +574,42 @@ } } - var btnDataSave = document.getElementById('btn-data-save'); - if (btnDataSave) { - btnDataSave.addEventListener('click', async function() { - btnLoading(btnDataSave); - try { - var data = await api.put('/api/settings/data', { - retention_traffic_days: document.getElementById('data-traffic-days').value, - retention_activity_days: document.getElementById('data-activity-days').value, - peer_online_timeout: document.getElementById('data-peer-timeout').value, - }); - if (data.ok) { - showMessage('data-message', GC.t['security.saved'] || 'Settings saved', 'success'); - } else { - showMessage('data-message', data.error || 'Failed', 'error'); - } - } catch (err) { - showMessage('data-message', err.message, 'error'); - } finally { - btnReset(btnDataSave); - } - }); - } + (function () { + var trafficDays = document.getElementById('data-traffic-days'); + var activityDays = document.getElementById('data-activity-days'); + var peerTimeout = document.getElementById('data-peer-timeout'); + var dataStatus = document.getElementById('data-status'); + var dataFields = [trafficDays, activityDays, peerTimeout].filter(Boolean); + if (dataFields.length) { + SettingsAutosave.bind({ + cluster: 'data', + fields: dataFields, + statusEl: dataStatus, + valuesById: function () { + return { + 'data-traffic-days': trafficDays ? trafficDays.value : '', + 'data-activity-days': activityDays ? activityDays.value : '', + 'data-peer-timeout': peerTimeout ? peerTimeout.value : '', + }; + }, + save: function () { + return api.put('/api/settings/data', { + retention_traffic_days: trafficDays ? trafficDays.value : '', + retention_activity_days: activityDays ? activityDays.value : '', + peer_online_timeout: peerTimeout ? peerTimeout.value : '', + }); + }, + }); + } + })(); // ─── Monitoring Settings ─────────────────────────────── var monEmailToggle = document.getElementById('monitoring-email-alerts'); - if (monEmailToggle) monEmailToggle.addEventListener('click', function() { monEmailToggle.classList.toggle('on'); }); + if (monEmailToggle) monEmailToggle.addEventListener('click', function() { + monEmailToggle.classList.toggle('on'); + monEmailToggle.dispatchEvent(new Event('change')); + }); async function loadMonitoringSettings() { try { @@ -579,28 +626,32 @@ } } - var btnMonSave = document.getElementById('btn-monitoring-save'); - if (btnMonSave) { - btnMonSave.addEventListener('click', async function() { - btnLoading(btnMonSave); - try { - var data = await api.put('/api/settings/monitoring', { - interval: document.getElementById('monitoring-interval').value, - email_alerts: monEmailToggle.classList.contains('on'), - alert_email: document.getElementById('monitoring-alert-email').value, - }); - if (data.ok) { - showMessage('monitoring-message', GC.t['security.saved'] || 'Settings saved', 'success'); - } else { - showMessage('monitoring-message', data.error || 'Failed', 'error'); - } - } catch (err) { - showMessage('monitoring-message', err.message, 'error'); - } finally { - btnReset(btnMonSave); - } - }); - } + (function () { + var intervalEl = document.getElementById('monitoring-interval'); + var alertEmailEl = document.getElementById('monitoring-alert-email'); + var monFields = [intervalEl, monEmailToggle, alertEmailEl].filter(Boolean); + if (monFields.length) { + SettingsAutosave.bind({ + cluster: 'monitoring', + fields: monFields, + statusEl: document.getElementById('monitoring-status'), + valuesById: function () { + return { + 'monitoring-interval': intervalEl ? intervalEl.value : '', + 'monitoring-email-alerts': monEmailToggle ? monEmailToggle.classList.contains('on') : false, + 'monitoring-alert-email': alertEmailEl ? alertEmailEl.value : '', + }; + }, + save: function () { + return api.put('/api/settings/monitoring', { + interval: intervalEl ? intervalEl.value : '', + email_alerts: monEmailToggle ? monEmailToggle.classList.contains('on') : false, + alert_email: alertEmailEl ? alertEmailEl.value : '', + }); + }, + }); + } + })(); // ─── Email Alert Settings ────────────────────────────── @@ -629,35 +680,47 @@ } } - var btnAlertsSave = document.getElementById('btn-alerts-save'); - if (btnAlertsSave) { - btnAlertsSave.addEventListener('click', async function() { - btnLoading(btnAlertsSave); - try { - // Collect selected events from checkboxes - var events = []; - document.querySelectorAll('.alert-event-group:checked').forEach(function(cb) { - cb.dataset.events.split(',').forEach(function(e) { if (events.indexOf(e) === -1) events.push(e); }); - }); - var data = await api.put('/api/settings/alerts', { - email: document.getElementById('alerts-email').value, - email_events: events.join(','), - backup_reminder_days: document.getElementById('alerts-backup-days').value, - resource_cpu_threshold: document.getElementById('alerts-cpu').value, - resource_ram_threshold: document.getElementById('alerts-ram').value, - }); - if (data.ok) { - showMessage('alerts-message', GC.t['security.saved'] || 'Settings saved', 'success'); - } else { - showMessage('alerts-message', data.error || 'Failed', 'error'); - } - } catch (err) { - showMessage('alerts-message', err.message, 'error'); - } finally { - btnReset(btnAlertsSave); - } - }); - } + // Alerts autosave + function alertsEventsActive() { return !!document.querySelector('.alert-event-group:checked'); } + (function () { + var alertsEmail = document.getElementById('alerts-email'); + var alertsBackupDays = document.getElementById('alerts-backup-days'); + var alertsCpu = document.getElementById('alerts-cpu'); + var alertsRam = document.getElementById('alerts-ram'); + var alertsEventGroups = Array.from(document.querySelectorAll('.alert-event-group')); + var alertsFields = [alertsEmail].concat(alertsEventGroups).concat([alertsBackupDays, alertsCpu, alertsRam]).filter(Boolean); + if (alertsFields.length) { + SettingsAutosave.bind({ + cluster: 'alerts', + fields: alertsFields, + statusEl: document.getElementById('alerts-status'), + valuesById: function () { + var vals = { + 'alerts-email': alertsEmail ? alertsEmail.value : '', + 'alerts-backup-days': alertsBackupDays ? alertsBackupDays.value : '', + 'alerts-cpu': alertsCpu ? alertsCpu.value : '', + 'alerts-ram': alertsRam ? alertsRam.value : '', + }; + alertsEventGroups.forEach(function(cb) { if (cb.id) vals[cb.id] = cb.checked; }); + return vals; + }, + requiredForCommit: function () { return alertsEventsActive() ? ['alerts-email'] : []; }, + save: function () { + var events = []; + document.querySelectorAll('.alert-event-group:checked').forEach(function (cb) { + cb.dataset.events.split(',').forEach(function (e) { if (events.indexOf(e) === -1) events.push(e); }); + }); + return api.put('/api/settings/alerts', { + email: alertsEmail ? alertsEmail.value : '', + email_events: events.join(','), + backup_reminder_days: alertsBackupDays ? alertsBackupDays.value : '', + resource_cpu_threshold: alertsCpu ? alertsCpu.value : '', + resource_ram_threshold: alertsRam ? alertsRam.value : '', + }); + }, + }); + } + })(); // ─── ip2location Settings ────────────────────────────── @@ -706,12 +769,26 @@ }); } + // ip2location clear + var ip2lClear = document.getElementById('ip2location-clear'); + if (ip2lClear) { + ip2lClear.addEventListener('click', function () { + if (!window.confirm((window.GC.t || {})['settings.autosave.clear_secret_confirm'] || 'Remove the stored value?')) return; + SettingsAutosave.enqueue('ip2location', function () { + return api.put('/api/v1/settings/ip2location', { api_key: '', clear: true }); + }); + var keyEl = document.getElementById('ip2location-key'); + if (keyEl) keyEl.value = ''; + }); + } + // ─── Auto-Backup Settings ────────────────────────────── var autobackupEnabledToggle = document.getElementById('autobackup-enabled'); if (autobackupEnabledToggle) { autobackupEnabledToggle.addEventListener('click', function() { autobackupEnabledToggle.classList.toggle('on'); + autobackupEnabledToggle.dispatchEvent(new Event('change')); }); } @@ -839,28 +916,33 @@ } } - var btnAutobackupSave = document.getElementById('btn-autobackup-save'); - if (btnAutobackupSave) { - btnAutobackupSave.addEventListener('click', async function() { - btnLoading(btnAutobackupSave); - try { - var data = await api.put('/api/settings/autobackup', { - enabled: autobackupEnabledToggle.classList.contains('on'), - schedule: document.getElementById('autobackup-schedule').value, - retention: document.getElementById('autobackup-retention').value, - }); - if (data.ok) { - showMessage('autobackup-message', GC.t['autobackup.saved'] || 'Auto-backup settings saved', 'success'); - } else { - showMessage('autobackup-message', data.error || 'Failed', 'error'); - } - } catch (err) { - showMessage('autobackup-message', err.message, 'error'); - } finally { - btnReset(btnAutobackupSave); - } - }); - } + // ─── Autobackup Autosave ─────────────────────────────── + (function () { + var scheduleEl = document.getElementById('autobackup-schedule'); + var retentionEl = document.getElementById('autobackup-retention'); + var abFields = [autobackupEnabledToggle, scheduleEl, retentionEl].filter(Boolean); + if (abFields.length) { + SettingsAutosave.bind({ + cluster: 'autobackup', + fields: abFields, + statusEl: document.getElementById('autobackup-status'), + valuesById: function () { + return { + 'autobackup-enabled': autobackupEnabledToggle ? autobackupEnabledToggle.classList.contains('on') : false, + 'autobackup-schedule': scheduleEl ? scheduleEl.value : '', + 'autobackup-retention': retentionEl ? retentionEl.value : '', + }; + }, + save: function () { + return api.put('/api/settings/autobackup', { + enabled: autobackupEnabledToggle ? autobackupEnabledToggle.classList.contains('on') : false, + schedule: scheduleEl ? scheduleEl.value : '', + retention: retentionEl ? retentionEl.value : '', + }); + }, + }); + } + })(); var btnAutobackupRun = document.getElementById('btn-autobackup-run'); if (btnAutobackupRun) { @@ -889,6 +971,7 @@ if (metricsEnabledToggle) { metricsEnabledToggle.addEventListener('click', function() { metricsEnabledToggle.classList.toggle('on'); + metricsEnabledToggle.dispatchEvent(new Event('change')); }); } @@ -905,24 +988,14 @@ } } - var btnMetricsSave = document.getElementById('btn-metrics-save'); - if (btnMetricsSave) { - btnMetricsSave.addEventListener('click', async function() { - btnLoading(btnMetricsSave); - try { - var data = await api.put('/api/settings/metrics', { - enabled: metricsEnabledToggle.classList.contains('on'), - }); - if (data.ok) { - showMessage('metrics-message', GC.t['security.saved'] || 'Settings saved', 'success'); - } else { - showMessage('metrics-message', data.error || 'Failed', 'error'); - } - } catch (err) { - showMessage('metrics-message', err.message, 'error'); - } finally { - btnReset(btnMetricsSave); - } + var metricsStatus = document.getElementById('metrics-status'); + if (metricsEnabledToggle) { + SettingsAutosave.bind({ + cluster: 'metrics', + fields: [metricsEnabledToggle], + statusEl: metricsStatus, + valuesById: function () { return { 'metrics-enabled': metricsEnabledToggle.classList.contains('on') }; }, + save: function () { return api.put('/api/settings/metrics', { enabled: metricsEnabledToggle.classList.contains('on') }); }, }); } @@ -1004,30 +1077,18 @@ // ─── DNS Settings ───────────────────────────── (function () { var dnsInput = document.getElementById('settings-dns-input'); - var dnsSaveBtn = document.getElementById('btn-dns-save'); if (dnsInput) { api.get('/api/v1/settings/dns').then(function(data) { if (data.ok) dnsInput.value = data.data.dns || ''; }).catch(function() {}); - } - if (dnsSaveBtn) { - dnsSaveBtn.addEventListener('click', async function() { - var btn = this; - btnLoading(btn); - try { - var data = await api.put('/api/v1/settings/dns', { dns: dnsInput.value.trim() }); - if (data.ok) { - showToast(GC.t['settings.dns_saved'] || 'DNS settings saved'); - } else { - showToast(data.error || 'Error', 'error'); - } - } catch (err) { - showToast(err.message || 'Error', 'error'); - } finally { - btnReset(btn); - } + SettingsAutosave.bind({ + cluster: 'dns', + fields: [dnsInput], + statusEl: document.getElementById('dns-status'), + valuesById: function () { return { dns: dnsInput.value.trim() }; }, + save: function () { return api.put('/api/v1/settings/dns', { dns: dnsInput.value.trim() }); }, }); } })(); @@ -1040,19 +1101,15 @@ var el = card.querySelector('input[name="au-mode"][value="' + ((d && d.mode) || 'auto') + '"]'); if (el) el.checked = true; }).catch(function () {}); - var save = document.getElementById('au-mode-save'); - if (save) { - save.addEventListener('click', function () { - var sel = card.querySelector('input[name="au-mode"]:checked'); - var mode = sel ? sel.value : 'auto'; - window.api.put('/api/system/auto-update', { mode: mode }).then(function (j) { - if (window.showToast) { - window.showToast( - (window.GC && GC.t && GC.t['autoupdate.saved']) || 'Mode saved', - (j && j.ok) ? 'success' : 'error' - ); - } - }).catch(function () {}); + var auRadios = Array.prototype.slice.call(document.querySelectorAll('input[name="au-mode"]')); + if (auRadios.length) { + function auVal() { var c = document.querySelector('input[name="au-mode"]:checked'); return c ? c.value : ''; } + SettingsAutosave.bind({ + cluster: 'auto-update', + fields: auRadios, + statusEl: document.getElementById('au-mode-status'), + valuesById: function () { return { 'au-mode': auVal() }; }, + save: function () { return api.put('/api/system/auto-update', { mode: auVal() }); }, }); } })(); @@ -1061,8 +1118,7 @@ // ── Machine Binding Settings ────────────────────────── (async function () { var modeSelect = document.getElementById('mb-mode'); - var saveBtn = document.getElementById('mb-save'); - var msg = document.getElementById('mb-msg'); + var statusEl = document.getElementById('machine-binding-status'); if (!modeSelect) return; try { @@ -1070,16 +1126,12 @@ if (res.ok) modeSelect.value = res.data.mode; } catch {} - saveBtn.addEventListener('click', async function () { - try { - await api.put('/api/v1/settings/machine-binding', { mode: modeSelect.value }); - msg.textContent = GC.t['security.machine_binding.saved'] || 'Saved'; - msg.style.color = 'var(--success)'; - setTimeout(function () { msg.textContent = ''; }, 3000); - } catch (err) { - msg.style.color = 'var(--danger)'; - msg.textContent = err.message || 'Error'; - } + SettingsAutosave.bind({ + cluster: 'machine-binding', + fields: [modeSelect], + statusEl: statusEl, + valuesById: function () { return { 'mb-mode': modeSelect.value }; }, + save: function () { return api.put('/api/v1/settings/machine-binding', { mode: modeSelect.value }); }, }); })(); @@ -1261,7 +1313,7 @@ del.className = 'icon-btn'; del.style.cssText = 'color:var(--red);margin-left:auto'; del.textContent = '\u2715'; - del.addEventListener('click', function () { customNets.splice(i, 1); renderCustom(); }); + del.addEventListener('click', function () { customNets.splice(i, 1); renderCustom(); SettingsAutosave.enqueue('split-tunnel', stSave); }); row.appendChild(del); customList.appendChild(row); }); @@ -1278,6 +1330,7 @@ } customNets.push({ label: label, cidr: cidr }); renderCustom(); + SettingsAutosave.enqueue('split-tunnel', stSave); }); async function loadST() { @@ -1296,14 +1349,26 @@ } catch {} } - document.getElementById('st-save').addEventListener('click', async function () { + function stSave() { var networks = customNets.slice(); - if (privateNets.checked) networks = PRIVATE_CIDRS.concat(networks); - if (linkLocal.checked) networks.push(LINK_LOCAL); - try { - await api.put('/api/v1/settings/split-tunnel', { mode: modeSelect.value, networks: networks, locked: lockedCb.checked }); - if (typeof GC.toast === 'function') GC.toast(GC.t['settings.saved'] || 'Saved'); - } catch (err) { alert(err.message || 'Failed to save'); } + if (privateNets && privateNets.checked) networks = PRIVATE_CIDRS.concat(networks); + if (linkLocal && linkLocal.checked) networks.push(LINK_LOCAL); + return api.put('/api/v1/settings/split-tunnel', { mode: modeSelect.value, networks: networks, locked: lockedCb ? lockedCb.checked : false }); + } + + SettingsAutosave.bind({ + cluster: 'split-tunnel', + fields: [modeSelect, privateNets, linkLocal, lockedCb].filter(Boolean), + statusEl: document.getElementById('st-status'), + valuesById: function () { + return { + 'st-mode': modeSelect ? modeSelect.value : 'off', + 'st-private-nets': privateNets ? privateNets.checked : false, + 'st-link-local': linkLocal ? linkLocal.checked : false, + 'st-locked': lockedCb ? lockedCb.checked : false, + }; + }, + save: stSave, }); loadST(); @@ -1313,6 +1378,14 @@ (function () { var container = document.getElementById('default-theme-buttons'); if (!container) return; + var statusEl = document.getElementById('default-theme-status'); + function flash() { + if (!statusEl) return; + statusEl.classList.remove('autosave-error'); + statusEl.classList.add('field-saving'); + statusEl.textContent = (window.GC && GC.t && GC.t['settings.autosave.saved']) || 'Saved'; + setTimeout(function () { statusEl.classList.remove('field-saving'); }, 500); + } container.addEventListener('click', async function (e) { var btn = e.target.closest('[data-default-theme]'); if (!btn) return; @@ -1323,6 +1396,7 @@ container.querySelectorAll('[data-default-theme]').forEach(function (b) { b.className = b.dataset.defaultTheme === selected ? 'btn btn-primary' : 'btn btn-ghost'; }); + flash(); // Reload page to apply the new theme (templates are server-rendered) setTimeout(function () { window.location.reload(); }, 300); } @@ -1332,17 +1406,17 @@ }); })(); -(() => { - const sliderEl = document.getElementById('gw-down-threshold'); - const sliderOut = document.getElementById('gw-down-threshold-value'); +(function () { + var sliderEl = document.getElementById('gw-down-threshold'); + var sliderOut = document.getElementById('gw-down-threshold-value'); if (sliderEl && sliderOut) { - sliderEl.addEventListener('input', () => { sliderOut.textContent = sliderEl.value + ' s'; }); - sliderEl.addEventListener('change', async () => { - await fetch('/api/v1/settings/gateway-failover', { - method: 'PUT', - headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': (typeof GC !== 'undefined' && GC.csrfToken) ? GC.csrfToken : '' }, - body: JSON.stringify({ gateway_down_threshold_s: parseInt(sliderEl.value, 10) }), - }); + sliderEl.addEventListener('input', function () { sliderOut.textContent = sliderEl.value + ' s'; }); + SettingsAutosave.bind({ + cluster: 'gateway-failover', + fields: [sliderEl], + statusEl: document.getElementById('gw-failover-status'), + valuesById: function () { return { gw: sliderEl.value }; }, + save: function () { return api.put('/api/v1/settings/gateway-failover', { gateway_down_threshold_s: parseInt(sliderEl.value, 10) }); }, }); } })(); @@ -1353,11 +1427,10 @@ var editingIndex = -1; var t = window.GC && window.GC.t || {}; - var saveBtn = document.getElementById('btn-pihole-save'); var addBtn = document.getElementById('btn-pihole-add-instance'); var instancesList = document.getElementById('pihole-instances-list'); var instanceForm = document.getElementById('pihole-instance-form'); - if (!saveBtn && !addBtn && !instancesList) return; + if (!addBtn && !instancesList) return; async function loadPihole() { try { @@ -1479,6 +1552,7 @@ if (!confirm(t['pihole.cfg.confirm_delete'] || 'Delete this instance?')) return; phInstances.splice(idx, 1); renderInstances(); + await SettingsAutosave.enqueue('pihole', function () { return savePihole(false); }); } else if (action === 'edit') { editingIndex = idx; showInstanceForm(inst); @@ -1551,7 +1625,7 @@ renderInstances(); hideInstanceForm(); try { - await savePihole(true); + await SettingsAutosave.enqueue('pihole', function () { return savePihole(false); }); } catch (err) { showToast(err.message || 'Error', 'error'); } @@ -1610,6 +1684,7 @@ if (enabledToggle) { enabledToggle.addEventListener('click', function () { enabledToggle.classList.toggle('on'); + enabledToggle.dispatchEvent(new Event('change')); }); } @@ -1617,6 +1692,7 @@ if (chainToggle) { chainToggle.addEventListener('click', function () { chainToggle.classList.toggle('on'); + chainToggle.dispatchEvent(new Event('change')); }); } @@ -1650,18 +1726,20 @@ } } - if (saveBtn) { - saveBtn.addEventListener('click', async function () { - btnLoading(saveBtn); - try { - await savePihole(true); - } catch (err) { - showToast(err.message || 'Error', 'error'); - } finally { - btnReset(saveBtn); - } - }); - } + var phIntervalEl = document.getElementById('pihole-sync-interval'); + SettingsAutosave.bind({ + cluster: 'pihole', + fields: [enabledToggle, chainToggle, phIntervalEl].filter(Boolean), + statusEl: document.getElementById('pihole-status'), + valuesById: function () { + return { + 'pihole-enabled': enabledToggle ? enabledToggle.classList.contains('on') : false, + 'pihole-manage-chain': chainToggle ? chainToggle.classList.contains('on') : false, + 'pihole-sync-interval': phIntervalEl ? phIntervalEl.value : '30', + }; + }, + save: function () { return savePihole(false); }, + }); loadPihole(); })(); @@ -1672,11 +1750,13 @@ var widgetDevice = document.getElementById('portal-widget-device'); var widgetTraffic = document.getElementById('portal-widget-traffic'); var widgetServices = document.getElementById('portal-widget-services'); - var saveBtn = document.getElementById('btn-portal-save'); if (!enabledToggle) return; [enabledToggle, widgetDevice, widgetTraffic, widgetServices].forEach(function (el) { - if (el) el.addEventListener('click', function () { el.classList.toggle('on'); }); + if (el) el.addEventListener('click', function () { + el.classList.toggle('on'); + el.dispatchEvent(new Event('change')); + }); }); function setToggle(el, val) { @@ -1695,30 +1775,30 @@ console.error('Failed to load portal settings:', err); }); - if (saveBtn) { - saveBtn.addEventListener('click', async function () { - btnLoading(saveBtn); - try { - var data = await api.put('/api/v1/settings/portal', { - enabled: enabledToggle.classList.contains('on'), - widgets: { - device: widgetDevice ? widgetDevice.classList.contains('on') : true, - traffic: widgetTraffic ? widgetTraffic.classList.contains('on') : true, - services: widgetServices ? widgetServices.classList.contains('on') : true, - }, - }); - if (data.ok) { - showMessage('portal-message', GC.t['settings.portal.saved'] || 'Settings saved', 'success'); - } else { - showMessage('portal-message', data.error || 'Failed', 'error'); - } - } catch (err) { - showMessage('portal-message', err.message, 'error'); - } finally { - btnReset(saveBtn); - } - }); - } + var portalFields = [enabledToggle, widgetDevice, widgetTraffic, widgetServices].filter(Boolean); + SettingsAutosave.bind({ + cluster: 'portal', + fields: portalFields, + statusEl: document.getElementById('portal-status'), + valuesById: function () { + return { + 'portal-enabled': enabledToggle.classList.contains('on'), + 'portal-widget-device': widgetDevice ? widgetDevice.classList.contains('on') : true, + 'portal-widget-traffic': widgetTraffic ? widgetTraffic.classList.contains('on') : true, + 'portal-widget-services': widgetServices ? widgetServices.classList.contains('on') : true, + }; + }, + save: function () { + return api.put('/api/v1/settings/portal', { + enabled: enabledToggle.classList.contains('on'), + widgets: { + device: widgetDevice ? widgetDevice.classList.contains('on') : true, + traffic: widgetTraffic ? widgetTraffic.classList.contains('on') : true, + services: widgetServices ? widgetServices.classList.contains('on') : true, + }, + }); + }, + }); })(); // ─── Route Block Default ────────────────────────────── @@ -1726,7 +1806,6 @@ var actionSel = document.getElementById('settings-route-block-action'); var bodyEl = document.getElementById('settings-route-block-body'); var redirectEl = document.getElementById('settings-route-block-redirect'); - var saveBtn = document.getElementById('btn-route-block-save'); if (!actionSel) return; function syncSettingsBlockVisibility() { @@ -1747,27 +1826,29 @@ console.error('Failed to load route block default:', err); }); - if (saveBtn) { - saveBtn.addEventListener('click', async function () { - btnLoading(saveBtn); - try { - var data = await api.put('/api/v1/settings/route-block-default', { - action: actionSel.value, - body: bodyEl ? bodyEl.value : '', - redirect_url: redirectEl ? redirectEl.value : '', - }); - if (data.ok) { - showMessage('settings-route-block-message', GC.t['security.saved'] || 'Settings saved', 'success'); - } else { - showMessage('settings-route-block-message', data.error || 'Failed', 'error'); - } - } catch (err) { - showMessage('settings-route-block-message', err.message, 'error'); - } finally { - btnReset(saveBtn); - } - }); + function rbValues() { + return { + 'settings-route-block-action': actionSel.value, + 'settings-route-block-body': bodyEl ? bodyEl.value || '' : '', + 'settings-route-block-redirect': redirectEl ? redirectEl.value || '' : '', + }; } + var rbFields = ['settings-route-block-action', 'settings-route-block-body', 'settings-route-block-redirect'] + .map(function (i) { return document.getElementById(i); }).filter(Boolean); + SettingsAutosave.bind({ + cluster: 'route-block', + fields: rbFields, + statusEl: document.getElementById('route-block-status'), + valuesById: rbValues, + requiredForCommit: function () { return actionSel.value === 'redirect' ? ['settings-route-block-redirect'] : []; }, + save: function () { + return api.put('/api/v1/settings/route-block-default', { + action: actionSel.value, + body: bodyEl ? bodyEl.value : '', + redirect_url: redirectEl ? redirectEl.value : '', + }); + }, + }); })(); // ─── Domains Registry ───────────────────────────── diff --git a/public/js/settingsAutosave.js b/public/js/settingsAutosave.js new file mode 100644 index 00000000..6a41fb4e --- /dev/null +++ b/public/js/settingsAutosave.js @@ -0,0 +1,99 @@ +(function () { + 'use strict'; + var Core = window.SettingsAutosaveCore; + var enqueue = Core.createQueue(); // shared per-cluster serialization + var t = (window.GC && window.GC.t) || {}; + window.SettingsAutosave = { enqueue: enqueue }; + + function flash(statusEl) { + if (!statusEl) return; + statusEl.classList.remove('autosave-error'); + statusEl.classList.add('field-saving'); + statusEl.textContent = t['settings.autosave.saved'] || 'Saved'; + setTimeout(function () { statusEl.classList.remove('field-saving'); }, 500); + } + function showError(statusEl, msg) { + if (!statusEl) return; + statusEl.classList.remove('field-saving'); + statusEl.classList.add('autosave-error'); + statusEl.textContent = msg || t['settings.autosave.error'] || 'Save failed'; + } + function showPending(statusEl) { + if (!statusEl) return; + statusEl.classList.remove('field-saving', 'autosave-error'); + statusEl.textContent = t['settings.autosave.pending'] || 'Will save once all required fields are filled'; + } + + function isDiscrete(el) { + if (!el || !el.tagName) return false; + if (el.classList && el.classList.contains('toggle')) return true; + if (el.tagName === 'SELECT') return true; + if (el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) return true; + return false; + } + function valueOf(el) { + if (el.classList && el.classList.contains('toggle')) return el.classList.contains('on'); + if (el.type === 'checkbox' || el.type === 'radio') return el.checked; + return el.value; + } + + function bind(opts) { + var cluster = opts.cluster; + var cfg = Core.classify(cluster); + var fields = opts.fields || []; + var statusEl = opts.statusEl || null; + var valuesById = opts.valuesById || function () { return {}; }; + var snapshot = JSON.stringify(valuesById()); // last successfully persisted state + + // Dev guard: warn if any bound field id is absent from valuesById (would never be dirty). + var _boundIds = fields.map(function(f) { return f && f.id; }).filter(Boolean); + var _missing = Core.missingValueKeys(_boundIds, valuesById()); + if (_missing.length) console.warn('[autosave] ' + cluster + ' valuesById missing bound fields: ' + _missing.join(',')); + + function requiredOverride() { return opts.requiredForCommit ? opts.requiredForCommit() : undefined; } + + function rollbackField(el) { + if (!el || !isDiscrete(el)) return; + try { + var snap = JSON.parse(snapshot || '{}'); + if (el.classList && el.classList.contains('toggle')) el.classList.toggle('on', !!snap[el.id]); + else if (el.type === 'checkbox' || el.type === 'radio') el.checked = !!snap[el.id]; + else if (el.tagName === 'SELECT' && snap[el.id] != null) el.value = snap[el.id]; + } catch (e) {} + } + + function commit(triggerEl, triggerValue) { + var values = valuesById(); + if (!Core.isAtomicReady(cfg, values, requiredOverride())) { showPending(statusEl); return; } + if (!Core.isDirty(values, JSON.parse(snapshot || '{}'))) return; + if (triggerEl && Core.needsConfirm(cfg, triggerEl.id, triggerValue)) { + var msg = (cluster === 'machine-binding') + ? (t['settings.autosave.confirm_mb_mode'] || 'This changes device binding and can affect access. Apply it?') + : (t['settings.autosave.confirm_self'] || 'This change can affect your current session. Apply it?'); + if (!window.confirm(msg)) { rollbackField(triggerEl); return; } + } + var frozen = JSON.stringify(values); // freeze what we send (spec: snapshot from sent values) + enqueue(cluster, async function () { + try { + var res = await opts.save(); + if (res && res.ok) { snapshot = frozen; flash(statusEl); } + else { if (isDiscrete(triggerEl)) rollbackField(triggerEl); showError(statusEl, (res && res.error) || null); } + } catch (err) { showError(statusEl, null); } // network error -> localized string, value kept, retry next trigger + }); + } + + fields.forEach(function (el) { + if (isDiscrete(el)) { + el.addEventListener('change', function () { commit(el, valueOf(el)); }); + } else { + var fire = function () { commit(el, el.value); }; + el.addEventListener('blur', function () { clearTimeout(el._asTimer); fire(); }); + el.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { clearTimeout(el._asTimer); el._asTimer = setTimeout(fire, 400); } + }); + } + }); + } + + window.SettingsAutosave.bind = bind; +})(); diff --git a/public/js/settingsAutosaveCore.js b/public/js/settingsAutosaveCore.js new file mode 100644 index 00000000..ae152411 --- /dev/null +++ b/public/js/settingsAutosaveCore.js @@ -0,0 +1,67 @@ +(function (root, factory) { + const api = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = api; // node tests + else root.SettingsAutosaveCore = api; // browser global +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + // Single declarative source for per-cluster special behavior (spec §4.5e). + // Clusters not listed default to { klass: 'independent' }. + // alerts.requiredForCommit + route-block are overridden at bind-time via + // isAtomicReady's overrideRequired arg (events-active / action=redirect). + const SETTINGS_CLUSTERS = { + smtp: { klass: 'atomic', secretKeys: ['password'], requiredForCommit: ['smtp-host', 'smtp-from'] }, + alerts: { klass: 'atomic', requiredForCommit: ['alerts-email'] }, // override: only if an event group is active + 'route-block': { klass: 'atomic', requiredForCommit: [] }, // override: ['settings-route-block-redirect'] when action=redirect + ip2location: { klass: 'independent', secretKeys: ['api_key'] }, + pihole: { klass: 'fullPayload' }, + 'split-tunnel': { klass: 'fullPayload' }, + security: { klass: 'independent', selfAffecting: [{ field: 'security-lockout-attempts', confirmIf: '<=2' }] }, + 'machine-binding': { klass: 'independent', selfAffecting: [{ field: 'mb-mode', confirmAlways: true }] }, + }; + + function classify(cluster) { return SETTINGS_CLUSTERS[cluster] || { klass: 'independent' }; } + + function isDirty(payload, snapshot) { return JSON.stringify(payload) !== JSON.stringify(snapshot); } + + function stripEmptySecrets(payload, secretKeys) { + const out = Object.assign({}, payload); + for (const k of secretKeys || []) { + if (out[k] === '' || out[k] === null || out[k] === undefined) delete out[k]; + } + return out; + } + + function needsConfirm(config, fieldId, value) { + for (const sa of (config && config.selfAffecting) || []) { + if (sa.field !== fieldId) continue; + if (sa.confirmAlways) return true; + if (sa.confirmIf === '<=2' && Number(value) <= 2) return true; + } + return false; + } + + function isAtomicReady(config, valuesById, overrideRequired) { + if (!config || config.klass !== 'atomic') return true; + const required = overrideRequired || config.requiredForCommit || []; + return required.every(id => String((valuesById && valuesById[id]) || '').trim() !== ''); + } + + // Per-key serialized promise chain (spec §4.5c). Used by the controller for + // field autosaves AND by full-payload list-mutation buttons → one lock. + function createQueue() { + const chains = {}; + return function enqueue(key, fn) { + const prev = chains[key] || Promise.resolve(); + const next = prev.then(fn, fn); // runs even after a prior rejection + chains[key] = next.catch(function () {}); + return next; + }; + } + + function missingValueKeys(fieldIds, valuesObj) { + return (fieldIds || []).filter(function(id) { return id && !(id in (valuesObj || {})); }); + } + + return { SETTINGS_CLUSTERS, classify, isDirty, stripEmptySecrets, needsConfirm, isAtomicReady, createQueue, missingValueKeys }; +}); diff --git a/src/i18n/de.json b/src/i18n/de.json index acdc83a4..a9bd6e67 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -1964,7 +1964,14 @@ "settings.portal.widget_traffic": "Traffic-Diagramm", "settings.portal.widget_services": "Dienste", "settings.portal.saved": "Portal-Einstellungen gespeichert", - + "settings.autosave.saved": "Gespeichert", + "settings.autosave.error": "Speichern fehlgeschlagen", + "settings.autosave.pending": "Wird gespeichert, sobald alle Pflichtfelder ausgefüllt sind", + "settings.autosave.confirm_self": "Diese Änderung kann deine aktuelle Sitzung betreffen. Übernehmen?", + "settings.autosave.confirm_mb_mode": "Dies ändert die Gerätebindung und kann den Zugriff betreffen. Übernehmen?", + "settings.autosave.clear_secret": "Entfernen", + "settings.autosave.clear_secret_confirm": "Gespeicherten Wert entfernen?", + "settings.smtp.password_set": "Passwort ist gesetzt", "settings.domains.title": "Domains", "settings.domains.intro": "Hinterlege die Hauptdomains dieses Servers. Eine Domain muss auf diesen Server zeigen (DNS A/AAAA → Server-IP), bevor sie genutzt werden kann.", "settings.domains.add": "Domain hinzufügen", diff --git a/src/i18n/en.json b/src/i18n/en.json index 510b514b..679f5286 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1964,7 +1964,14 @@ "settings.portal.widget_traffic": "Traffic chart", "settings.portal.widget_services": "Services", "settings.portal.saved": "Portal settings saved", - + "settings.autosave.saved": "Saved", + "settings.autosave.error": "Save failed", + "settings.autosave.pending": "Will save once all required fields are filled", + "settings.autosave.confirm_self": "This change can affect your current session. Apply it?", + "settings.autosave.confirm_mb_mode": "This changes device binding and can affect access. Apply it?", + "settings.autosave.clear_secret": "Remove", + "settings.autosave.clear_secret_confirm": "Remove the stored value?", + "settings.smtp.password_set": "Password is set", "settings.domains.title": "Domains", "settings.domains.intro": "Register the main domains this server owns. A domain must point to this server (DNS A/AAAA → server IP) before it can be used.", "settings.domains.add": "Add domain", diff --git a/src/routes/api/settings/observability.js b/src/routes/api/settings/observability.js index e8c88a40..0c1b7d36 100644 --- a/src/routes/api/settings/observability.js +++ b/src/routes/api/settings/observability.js @@ -97,11 +97,28 @@ router.get('/ip2location', (req, res) => { */ router.put('/ip2location', (req, res) => { try { - const { api_key } = req.body; - if (api_key !== undefined) settings.set('ip2location.api_key', String(api_key)); - activity.log('ip2location_settings_updated', 'ip2location API key updated', { - source: 'admin', ipAddress: req.ip, severity: 'info', - }); + const { api_key, clear } = req.body; + const oldKey = settings.get('ip2location.api_key', ''); + + // Determine effective new value (mirrors the write logic below). + let newKey; + if (clear === true) { + newKey = ''; + } else if (api_key !== undefined && String(api_key) !== '') { + newKey = String(api_key); + } else { + // empty api_key without clear → no change → skip write and audit + return res.json({ ok: true }); + } + + // Only write and log when the value actually changed. + if (newKey !== oldKey) { + settings.set('ip2location.api_key', newKey); + activity.log('ip2location_settings_updated', 'ip2location API key updated', { + source: 'admin', ipAddress: req.ip, severity: 'info', + }); + } + res.json({ ok: true }); } catch (err) { res.status(500).json({ ok: false, error: req.t('common.error') }); diff --git a/src/routes/api/smtp.js b/src/routes/api/smtp.js index 40bc23fa..67a6521a 100644 --- a/src/routes/api/smtp.js +++ b/src/routes/api/smtp.js @@ -27,7 +27,7 @@ router.get('/settings', (req, res) => { // PUT /api/smtp/settings — save SMTP settings router.put('/settings', (req, res) => { (async () => { - const { host, port, user, password, from, secure } = req.body; + const { host, port, user, password, from, secure, clear_password } = req.body; if (!host) { return res.status(400).json({ ok: false, error: req.t('smtp.error.host_required') }); @@ -44,7 +44,7 @@ router.put('/settings', (req, res) => { return res.status(400).json({ ok: false, error: req.t('smtp.error.port_invalid') }); } - saveSmtpSettings({ host, port: portNum, user, password, from, secure }); + saveSmtpSettings({ host, port: portNum, user, password, from, secure, clear_password }); res.json({ ok: true }); })().catch((err) => { logger.error({ err: err.message }, 'smtp handler failed'); res.status(500).json({ ok: false, error: req.t('common.error') }); }); }); diff --git a/src/services/email.js b/src/services/email.js index 267acc43..dea49cc1 100644 --- a/src/services/email.js +++ b/src/services/email.js @@ -239,7 +239,7 @@ async function sendTestEmail(to) { /** * Upsert SMTP settings into the settings table. Encrypts password if provided. */ -function saveSmtpSettings({ host, port, user, password, from, secure }) { +function saveSmtpSettings({ host, port, user, password, from, secure, clear_password }) { const db = getDb(); function upsert(key, value) { @@ -257,7 +257,9 @@ function saveSmtpSettings({ host, port, user, password, from, secure }) { if (from !== undefined) upsert('smtp_from', from); if (secure !== undefined) upsert('smtp_secure', secure ? '1' : '0'); - if (password !== undefined && password !== null && password !== '') { + if (clear_password === true) { + upsert('smtp_password_encrypted', ''); + } else if (password !== undefined && password !== null && password !== '') { const encrypted = encrypt(String(password)); upsert('smtp_password_encrypted', encrypted); } diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk index a67aa077..c1d46645 100644 --- a/templates/aurora/pages/settings.njk +++ b/templates/aurora/pages/settings.njk @@ -71,6 +71,7 @@
{{ t('settings.default_theme_hint') }}
+
@@ -135,9 +136,7 @@
{{ t('settings.dns_desc') }}
-
- -
+
{% else %}
{{ t('settings.dns') }} @@ -170,9 +169,7 @@ {{ t('data.peer_timeout_hint') or 'Peer is considered offline after no handshake for this many seconds' }}
-
- -
+
@@ -194,9 +191,7 @@ -
- -
+
@@ -338,10 +333,7 @@
{{ t('security.lockout.locked_accounts') }}
{{ t('security.lockout.no_locked') }}
- -
- -
+
@@ -374,10 +366,6 @@
- -
- -
@@ -404,9 +392,7 @@ -
- -
+
@@ -477,9 +463,9 @@ {{ t('autobackup.last_run_never') }} +
-
{{ t('autobackup.existing_files') }}
@@ -519,6 +505,7 @@ +
@@ -532,9 +519,7 @@
-
- -
+
@@ -568,14 +553,14 @@
{{ t('alerts.events_label') or 'Notify on these events:' }}
{% set alertGroups = [ - { label: t('alerts.group_security') or 'Security', events: 'login_failed,account_locked,password_changed' }, - { label: t('alerts.group_peers') or 'Peers', events: 'peer_connected,peer_disconnected,peer_created,peer_deleted,peer_expired' }, - { label: t('alerts.group_routes') or 'Routes', events: 'route_down,route_up,route_created,route_deleted' }, - { label: t('alerts.group_system') or 'System', events: 'system_start,wg_restart,backup_restored,backup_reminder,resource_alert' } + { label: t('alerts.group_security') or 'Security', events: 'login_failed,account_locked,password_changed', id: 'alerts-events-security' }, + { label: t('alerts.group_peers') or 'Peers', events: 'peer_connected,peer_disconnected,peer_created,peer_deleted,peer_expired', id: 'alerts-events-peers' }, + { label: t('alerts.group_routes') or 'Routes', events: 'route_down,route_up,route_created,route_deleted', id: 'alerts-events-routes' }, + { label: t('alerts.group_system') or 'System', events: 'system_start,wg_restart,backup_restored,backup_reminder,resource_alert', id: 'alerts-events-system' } ] %} {% for g in alertGroups %} {% endfor %} @@ -598,9 +583,7 @@
-
- -
+
@@ -631,9 +614,7 @@ -
- -
+
@@ -653,9 +634,7 @@ /metrics -
- -
+
@@ -673,6 +652,7 @@

{{ t('gateway_failover_settings.down_threshold_help') }}

+
@@ -695,6 +675,7 @@
+
@@ -732,9 +713,7 @@ {{ t('autoupdate.mode_manual') }} -
- -
+
@@ -885,7 +864,7 @@ {{ t('settings.split_tunnel_lock_hint') }} - +
@@ -915,9 +894,7 @@ -
- -
+
@@ -997,9 +974,7 @@
-
- -
+
@@ -1008,6 +983,8 @@ {% endblock %} {% block scripts %} + + diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk index eae174c4..31af5902 100644 --- a/templates/default/pages/settings.njk +++ b/templates/default/pages/settings.njk @@ -204,9 +204,7 @@
{{ t('settings.dns_desc') }}
-
- -
+
{% else %}
{{ t('settings.dns') }} @@ -239,9 +237,7 @@ {{ t('data.peer_timeout_hint') or 'Peer is considered offline after no handshake for this many seconds' }}
-
- -
+
@@ -263,9 +259,7 @@ -
- -
+
@@ -286,6 +280,7 @@
{{ t('settings.default_theme_hint') }}
+
@@ -485,10 +480,7 @@
{{ t('security.lockout.locked_accounts') }}
{{ t('security.lockout.no_locked') }}
- -
- -
+
@@ -521,10 +513,6 @@
- -
- -
@@ -554,9 +542,7 @@ -
- -
+
@@ -639,10 +625,10 @@ +
-
@@ -683,6 +669,7 @@ +
@@ -696,9 +683,7 @@
-
- -
+
@@ -732,14 +717,14 @@
{{ t('alerts.events_label') or 'Notify on these events:' }}
{% set alertGroups = [ - { label: t('alerts.group_security') or 'Security', events: 'login_failed,account_locked,password_changed' }, - { label: t('alerts.group_peers') or 'Peers', events: 'peer_connected,peer_disconnected,peer_created,peer_deleted,peer_expired' }, - { label: t('alerts.group_routes') or 'Routes', events: 'route_down,route_up,route_created,route_deleted' }, - { label: t('alerts.group_system') or 'System', events: 'system_start,wg_restart,backup_restored,backup_reminder,resource_alert' } + { label: t('alerts.group_security') or 'Security', events: 'login_failed,account_locked,password_changed', id: 'alerts-events-security' }, + { label: t('alerts.group_peers') or 'Peers', events: 'peer_connected,peer_disconnected,peer_created,peer_deleted,peer_expired', id: 'alerts-events-peers' }, + { label: t('alerts.group_routes') or 'Routes', events: 'route_down,route_up,route_created,route_deleted', id: 'alerts-events-routes' }, + { label: t('alerts.group_system') or 'System', events: 'system_start,wg_restart,backup_restored,backup_reminder,resource_alert', id: 'alerts-events-system' } ] %} {% for g in alertGroups %} {% endfor %} @@ -762,9 +747,7 @@
-
- -
+
@@ -795,9 +778,7 @@ -
- -
+
@@ -817,9 +798,7 @@ /metrics -
- -
+
@@ -837,6 +816,7 @@

{{ t('gateway_failover_settings.down_threshold_help') }}

+
@@ -859,6 +839,7 @@
+
@@ -898,9 +879,7 @@ {{ t('autoupdate.mode_manual') }} -
- -
+
@@ -1052,7 +1031,7 @@ - +
@@ -1081,9 +1060,7 @@ -
- -
+
@@ -1162,9 +1139,7 @@
-
- -
+
@@ -1173,6 +1148,8 @@ {% endblock %} {% block scripts %} + + diff --git a/templates/pro/pages/settings.njk b/templates/pro/pages/settings.njk index b58c4132..284f6cc2 100644 --- a/templates/pro/pages/settings.njk +++ b/templates/pro/pages/settings.njk @@ -83,9 +83,7 @@
{{ t('settings.dns_desc') }}
-
- -
+
{% else %}
{{ t('settings.dns') }} @@ -118,9 +116,7 @@ {{ t('data.peer_timeout_hint') or 'Peer is considered offline after no handshake for this many seconds' }}
-
- -
+
@@ -142,9 +138,7 @@ -
- -
+
@@ -165,6 +159,7 @@
{{ t('settings.default_theme_hint') }}
+
@@ -364,10 +359,7 @@
{{ t('security.lockout.locked_accounts') }}
{{ t('security.lockout.no_locked') }}
- -
- -
+
@@ -400,10 +392,6 @@
- -
- -
@@ -433,9 +421,7 @@ -
- -
+
@@ -518,10 +504,10 @@ +
-
@@ -562,6 +548,7 @@ +
@@ -575,9 +562,7 @@
-
- -
+
@@ -611,14 +596,14 @@
{{ t('alerts.events_label') or 'Notify on these events:' }}
{% set alertGroups = [ - { label: t('alerts.group_security') or 'Security', events: 'login_failed,account_locked,password_changed' }, - { label: t('alerts.group_peers') or 'Peers', events: 'peer_connected,peer_disconnected,peer_created,peer_deleted,peer_expired' }, - { label: t('alerts.group_routes') or 'Routes', events: 'route_down,route_up,route_created,route_deleted' }, - { label: t('alerts.group_system') or 'System', events: 'system_start,wg_restart,backup_restored,backup_reminder,resource_alert' } + { label: t('alerts.group_security') or 'Security', events: 'login_failed,account_locked,password_changed', id: 'alerts-events-security' }, + { label: t('alerts.group_peers') or 'Peers', events: 'peer_connected,peer_disconnected,peer_created,peer_deleted,peer_expired', id: 'alerts-events-peers' }, + { label: t('alerts.group_routes') or 'Routes', events: 'route_down,route_up,route_created,route_deleted', id: 'alerts-events-routes' }, + { label: t('alerts.group_system') or 'System', events: 'system_start,wg_restart,backup_restored,backup_reminder,resource_alert', id: 'alerts-events-system' } ] %} {% for g in alertGroups %} {% endfor %} @@ -641,9 +626,7 @@
-
- -
+
@@ -674,9 +657,7 @@ -
- -
+
@@ -696,9 +677,7 @@ /metrics -
- -
+
@@ -716,6 +695,7 @@

{{ t('gateway_failover_settings.down_threshold_help') }}

+
@@ -738,6 +718,7 @@
+
@@ -779,9 +760,7 @@ {{ t('autoupdate.mode_manual') }} -
- -
+
@@ -937,9 +916,7 @@ -
- -
+
@@ -969,9 +946,7 @@ -
- -
+
@@ -1051,9 +1026,7 @@
-
- -
+
@@ -1062,6 +1035,8 @@ {% endblock %} {% block scripts %} + + diff --git a/tests/aurora_theme.test.js b/tests/aurora_theme.test.js index 4617ecaa..d998797b 100644 --- a/tests/aurora_theme.test.js +++ b/tests/aurora_theme.test.js @@ -1184,11 +1184,11 @@ describe('aurora theme — settings layout (Task P2-11)', () => { selectAurora(); const res = await agent.get('/settings').expect(200); assert.match(res.text, /id="settings-route-block-action"/, 'settings-route-block-action always present'); - assert.match(res.text, /id="btn-data-save"/, 'btn-data-save present'); + assert.doesNotMatch(res.text, /id="btn-data-save"/, 'btn-data-save absent (autosave)'); // removed by autosave feature assert.match(res.text, /id="data-traffic-days"/, 'data-traffic-days present'); assert.match(res.text, /id="data-activity-days"/, 'data-activity-days present'); assert.match(res.text, /id="data-peer-timeout"/, 'data-peer-timeout present'); - assert.match(res.text, /id="btn-route-block-save"/, 'btn-route-block-save present'); + assert.doesNotMatch(res.text, /id="btn-route-block-save"/, 'btn-route-block-save absent (autosave)'); // removed by autosave feature assert.match(res.text, /id="settings-route-block-action"/, 'settings-route-block-action present'); assert.match(res.text, /id="default-theme-buttons"/, 'default-theme-buttons present'); assert.match(res.text, /data-default-theme="default"/, 'data-default-theme=default present'); @@ -1206,11 +1206,11 @@ describe('aurora theme — settings layout (Task P2-11)', () => { assert.match(res.text, /id="security-lockout-enabled"/, 'security-lockout-enabled present'); assert.match(res.text, /id="security-lockout-attempts"/, 'security-lockout-attempts present'); assert.match(res.text, /id="security-lockout-duration"/, 'security-lockout-duration present'); - assert.match(res.text, /id="btn-security-save"/, 'btn-security-save present'); + assert.doesNotMatch(res.text, /id="btn-security-save"/, 'btn-security-save absent (autosave)'); // removed by autosave feature assert.match(res.text, /id="security-password-enabled"/, 'security-password-enabled present'); - assert.match(res.text, /id="btn-password-save"/, 'btn-password-save present'); + assert.doesNotMatch(res.text, /id="btn-password-save"/, 'btn-password-save absent (autosave)'); // removed by autosave feature assert.match(res.text, /id="mb-mode"/, 'mb-mode present'); - assert.match(res.text, /id="mb-save"/, 'mb-save present'); + assert.doesNotMatch(res.text, /id="mb-save"/, 'mb-save absent (autosave)'); // removed by autosave feature }); it('renders key form field IDs on /settings (backup, advanced tabs)', async () => { @@ -1221,8 +1221,8 @@ describe('aurora theme — settings layout (Task P2-11)', () => { assert.match(res.text, /id="autobackup-enabled"/, 'autobackup-enabled present'); assert.match(res.text, /id="autobackup-schedule"/, 'autobackup-schedule present'); assert.match(res.text, /id="autobackup-retention"/, 'autobackup-retention present'); - assert.match(res.text, /id="btn-autobackup-save"/, 'btn-autobackup-save present'); - assert.match(res.text, /id="btn-monitoring-save"/, 'btn-monitoring-save present'); + assert.doesNotMatch(res.text, /id="btn-autobackup-save"/, 'btn-autobackup-save absent (autosave)'); // removed by autosave feature + assert.doesNotMatch(res.text, /id="btn-monitoring-save"/, 'btn-monitoring-save absent (autosave)'); // removed by autosave feature assert.match(res.text, /id="metrics-enabled"/, 'metrics-enabled present'); assert.match(res.text, /id="gw-down-threshold"/, 'gw-down-threshold present'); assert.match(res.text, /id="ip2location-key"/, 'ip2location-key present'); @@ -1231,7 +1231,7 @@ describe('aurora theme — settings layout (Task P2-11)', () => { assert.match(res.text, /id="btn-add-webhook"/, 'btn-add-webhook present'); assert.match(res.text, /id="card-autoupdate"/, 'card-autoupdate present'); assert.match(res.text, /name="au-mode"/, 'au-mode radio inputs present'); - assert.match(res.text, /id="au-mode-save"/, 'au-mode-save present'); + assert.doesNotMatch(res.text, /id="au-mode-save"/, 'au-mode-save absent (autosave)'); // removed by autosave feature }); it('renders wg-stop-modal as modal-overlay pattern on /settings', async () => { diff --git a/tests/settings_autosave_core.test.js b/tests/settings_autosave_core.test.js new file mode 100644 index 00000000..15ceee49 --- /dev/null +++ b/tests/settings_autosave_core.test.js @@ -0,0 +1,68 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const core = require('../public/js/settingsAutosaveCore'); + +test('classify returns config or independent default', () => { + assert.equal(core.classify('pihole').klass, 'fullPayload'); + assert.equal(core.classify('smtp').klass, 'atomic'); + assert.equal(core.classify('unknown').klass, 'independent'); +}); + +test('isDirty compares payload to snapshot', () => { + assert.equal(core.isDirty({ a: 1 }, { a: 1 }), false); + assert.equal(core.isDirty({ a: 1 }, { a: 2 }), true); +}); + +test('stripEmptySecrets removes only empty/null secret keys', () => { + assert.deepEqual(core.stripEmptySecrets({ api_key: '', enabled: true }, ['api_key']), { enabled: true }); + assert.deepEqual(core.stripEmptySecrets({ api_key: 'k' }, ['api_key']), { api_key: 'k' }); + assert.deepEqual(core.stripEmptySecrets({ password: null }, ['password']), {}); +}); + +test('needsConfirm: mb-mode always, lockout-attempts only when <=2', () => { + assert.equal(core.needsConfirm(core.classify('machine-binding'), 'mb-mode', 'individual'), true); + assert.equal(core.needsConfirm(core.classify('security'), 'security-lockout-attempts', '1'), true); + assert.equal(core.needsConfirm(core.classify('security'), 'security-lockout-attempts', '5'), false); + assert.equal(core.needsConfirm(core.classify('security'), 'security-lockout-enabled', 'on'), false); +}); + +test('isAtomicReady: independent always ready; atomic uses config or override', () => { + assert.equal(core.isAtomicReady(core.classify('metrics'), {}), true); + const smtp = core.classify('smtp'); + assert.equal(core.isAtomicReady(smtp, { 'smtp-host': '', 'smtp-from': '' }), false); + assert.equal(core.isAtomicReady(smtp, { 'smtp-host': 'm', 'smtp-from': 'a@x' }), true); + // Override: alerts with no active events -> email NOT required. + assert.equal(core.isAtomicReady(core.classify('alerts'), { 'alerts-email': '' }, []), true); + assert.equal(core.isAtomicReady(core.classify('alerts'), { 'alerts-email': '' }, ['alerts-email']), false); +}); + +test('createQueue serializes per key in call order', async () => { + const enqueue = core.createQueue(); + const order = []; + const p1 = enqueue('k', async () => { await Promise.resolve(); order.push('a'); }); + const p2 = enqueue('k', async () => { order.push('b'); }); + await Promise.all([p1, p2]); + assert.deepEqual(order, ['a', 'b']); +}); + +test('createQueue continues after a rejected task', async () => { + const enqueue = core.createQueue(); + const order = []; + await enqueue('k', async () => { throw new Error('boom'); }).catch(() => {}); + await enqueue('k', async () => { order.push('next'); }); + assert.deepEqual(order, ['next']); +}); + +test('missingValueKeys returns [] when all fields covered, missing ids when not', () => { + assert.deepEqual(core.missingValueKeys(['a', 'b'], { a: 1, b: 2 }), []); + assert.deepEqual(core.missingValueKeys(['a', 'b', 'c'], { a: 1, b: 2 }), ['c']); + assert.deepEqual(core.missingValueKeys([], { a: 1 }), []); + // Empty-string ids are ignored (elements without id attribute) + assert.deepEqual(core.missingValueKeys(['', 'a'], { a: 1 }), []); + // Null/undefined guards + assert.deepEqual(core.missingValueKeys(null, { a: 1 }), []); + assert.deepEqual(core.missingValueKeys(['a'], null), ['a']); +}); diff --git a/tests/settings_autosave_smoke.test.js b/tests/settings_autosave_smoke.test.js new file mode 100644 index 00000000..bb31e8fa --- /dev/null +++ b/tests/settings_autosave_smoke.test.js @@ -0,0 +1,106 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const supertest = require('supertest'); +const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup'); +const license = require('../src/services/license'); + +let app; +beforeEach(async () => { await setup(); app = require('../src/app').createApp(); }); +afterEach(teardown); + +test('autosave core + controller are served', async () => { + const core = await supertest(app).get('/js/settingsAutosaveCore.js').expect(200); + assert.match(core.text, /SETTINGS_CLUSTERS/); + assert.match(core.text, /createQueue/); + const ctrl = await supertest(app).get('/js/settingsAutosave.js').expect(200); + assert.match(ctrl.text, /SettingsAutosave/); + assert.match(ctrl.text, /addEventListener/); +}); + +test('toggles dispatch a change event (setupManagedToggle)', async () => { + const js = await supertest(app).get('/js/settings.js').expect(200); + assert.match(js.text, /dispatchEvent\(new Event\(['"]change['"]\)\)/); +}); + +test('field-saving style is served in both stylesheets', async () => { + const appCss = await supertest(app).get('/css/app.css').expect(200); + assert.match(appCss.text, /\.field-saving/); + const proCss = await supertest(app).get('/css/pro.css').expect(200); + assert.match(proCss.text, /\.field-saving/); +}); + +test('independent clusters migrated: buttons gone, autosave bound, mb fixed', async () => { + const js = await supertest(app).get('/js/settings.js').expect(200); + assert.match(js.text, /SettingsAutosave\.bind/); + assert.doesNotMatch(js.text, /getElementById\(['"]mb-msg['"]\)/); + assert.doesNotMatch(js.text, /fetch\(['"]\/api\/v1\/settings\/gateway-failover/); // now via api.put + const page = await getAgent().get('/settings').expect(200); + ['btn-metrics-save','btn-dns-save','btn-data-save','btn-monitoring-save','mb-save','au-mode-save'] + .forEach(id => assert.doesNotMatch(page.text, new RegExp('id="' + id + '"'))); + // dedicated status badges present (incl. machine-binding + default-theme) + ['machine-binding-status','default-theme-status'] + .forEach(id => assert.match(page.text, new RegExp('id="' + id + '"'))); + // machine-binding JS points statusEl at its dedicated badge, not the old hidden div + assert.match(js.text, /getElementById\(['"]machine-binding-status['"]\)/); +}); + +test('security/backup/portal migrated; single security bind', async () => { + const page = await getAgent().get('/settings').expect(200); + ['btn-security-save','btn-password-save','btn-autobackup-save','btn-portal-save'] + .forEach(id => assert.doesNotMatch(page.text, new RegExp('id="' + id + '"'))); +}); + +test('atomic clusters migrated incl. route-block; secret-clear buttons present', async () => { + const page = await getAgent().get('/settings').expect(200); + ['btn-smtp-save','btn-alerts-save','btn-route-block-save'] + .forEach(id => assert.doesNotMatch(page.text, new RegExp('id="' + id + '"'))); + assert.match(page.text, /id="ip2location-clear"/); + assert.match(page.text, /id="smtp-password-clear"/); +}); + +test('full-payload clusters migrated; list mutations use the queue', async () => { + const page = await getAgent().get('/settings').expect(200); + assert.doesNotMatch(page.text, /id="btn-pihole-save"/); + assert.doesNotMatch(page.text, /id="st-save"/); + const js = await supertest(app).get('/js/settings.js').expect(200); + assert.match(js.text, /SettingsAutosave\.enqueue\(['"]pihole['"]/); + assert.match(js.text, /SettingsAutosave\.enqueue\(['"]split-tunnel['"]/); +}); + +test('expanded valuesById covers all bound fields for smtp/alerts/route-block', async () => { + const js = await supertest(app).get('/js/settings.js').expect(200); + // SMTP: all 6 bound fields must appear as keys in smtpValues() + assert.match(js.text, /'smtp-port'/); + assert.match(js.text, /'smtp-user'/); + assert.match(js.text, /'smtp-tls'/); + assert.match(js.text, /'smtp-password'/); + // Alerts: threshold ids must appear in alerts valuesById + assert.match(js.text, /'alerts-backup-days'/); + assert.match(js.text, /'alerts-cpu'/); + assert.match(js.text, /'alerts-ram'/); + // Route-block: body field must be present + assert.match(js.text, /'settings-route-block-body'/); + // SMTP post-save must NOT clear the password input + assert.doesNotMatch(js.text, /smtp-password.*\.value\s*=\s*['"]['"]|['"]['"].*smtp-password.*\.value\s*=/); +}); + +test('two concurrent pihole PUTs leave a deterministic (non-corrupted) DB state', async () => { + // Server/DB-level sanity check; the JS-level per-cluster queue serialization is covered by settings_autosave_core.test.js (createQueue). + // pihole_integration is NOT in the global setup() unlock — enable it locally (mirrors pihole_api.test.js pattern). + license._overrideForTest({ pihole_integration: true }); + try { + const agent = getAgent(); const csrf = getCsrf(); + const base = { enabled: true, manage_dns_chain: false, sync_interval_sec: 30 }; + await Promise.all([ + agent.put('/api/v1/settings/pihole').set('X-CSRF-Token', csrf).send(Object.assign({}, base, { instances: [{ id: '1', url: 'http://a', dns_ip: '10.0.0.1', dns_port: 53, verify_tls: true, password_set: false }] })), + agent.put('/api/v1/settings/pihole').set('X-CSRF-Token', csrf).send(Object.assign({}, base, { instances: [{ id: '1', url: 'http://a', dns_ip: '10.0.0.1', dns_port: 53, verify_tls: true, password_set: false }, { id: '2', url: 'http://b', dns_ip: '10.0.0.2', dns_port: 53, verify_tls: true, password_set: false }] })), + ]); + const get = await agent.get('/api/v1/settings/pihole').expect(200); + assert.ok([1, 2].includes(get.body.data.instances.length)); + } finally { + license._overrideForTest({ pihole_integration: false }); + } +}); diff --git a/tests/settings_autosave_templates.test.js b/tests/settings_autosave_templates.test.js new file mode 100644 index 00000000..fcd420be --- /dev/null +++ b/tests/settings_autosave_templates.test.js @@ -0,0 +1,55 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const THEMES = ['aurora', 'default', 'pro']; + +// IDs confirmed REMOVED across all three themes (verified by grep before writing this test). +// btn-ip2location-save is intentionally KEPT in all themes — it is an action button whose +// "save key" flow was kept in scope (only a clear button was added in T4/T7), so it is +// NOT listed here. +const SAVE_IDS = [ + 'btn-dns-save', + 'btn-data-save', + 'btn-route-block-save', + 'btn-security-save', + 'btn-password-save', + 'mb-save', + 'btn-autobackup-save', + 'btn-smtp-save', + 'btn-alerts-save', + 'btn-monitoring-save', + 'btn-metrics-save', + 'au-mode-save', + 'st-save', + 'btn-pihole-save', + 'btn-portal-save', +]; + +test('all three themes: autosave scripts present before settings.js, no migrated save buttons remain', () => { + for (const theme of THEMES) { + const f = path.join(__dirname, '..', 'templates', theme, 'pages', 'settings.njk'); + const html = fs.readFileSync(f, 'utf8'); + + const coreIdx = html.indexOf('settingsAutosaveCore.js'); + const ctrlIdx = html.indexOf('settingsAutosave.js'); + const mainIdx = html.indexOf('/js/settings.js'); + + assert.ok(coreIdx > -1, `${theme}: settingsAutosaveCore.js not found`); + assert.ok(ctrlIdx > -1, `${theme}: settingsAutosave.js not found`); + assert.ok(mainIdx > -1, `${theme}: /js/settings.js not found`); + assert.ok(coreIdx < mainIdx, `${theme}: settingsAutosaveCore.js must appear before settings.js`); + assert.ok(ctrlIdx < mainIdx, `${theme}: settingsAutosave.js must appear before settings.js`); + + for (const id of SAVE_IDS) { + assert.ok( + !html.includes(`id="${id}"`), + `${theme}: save button "${id}" should have been removed by migration but is still present`, + ); + } + } +}); diff --git a/tests/settings_secret_handling.test.js b/tests/settings_secret_handling.test.js new file mode 100644 index 00000000..d50e0fe9 --- /dev/null +++ b/tests/settings_secret_handling.test.js @@ -0,0 +1,37 @@ +'use strict'; +const crypto = require('crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +const { test, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup'); + +beforeEach(async () => { await setup(); }); +afterEach(teardown); + +test('ip2location: empty api_key does not overwrite; clear removes it', async () => { + const agent = getAgent(); const csrf = getCsrf(); + // Set a secret + await agent.put('/api/v1/settings/ip2location').set('X-CSRF-Token', csrf).send({ api_key: 'SECRET123' }).expect(200); + // Send empty api_key — should NOT overwrite + await agent.put('/api/v1/settings/ip2location').set('X-CSRF-Token', csrf).send({ api_key: '' }).expect(200); + let r = await agent.get('/api/v1/settings/ip2location').expect(200); + assert.equal(r.body.data.has_api_key, true, 'secret should still be set after empty api_key PUT'); + // Send clear:true — should remove it + await agent.put('/api/v1/settings/ip2location').set('X-CSRF-Token', csrf).send({ api_key: '', clear: true }).expect(200); + r = await agent.get('/api/v1/settings/ip2location').expect(200); + assert.equal(r.body.data.has_api_key, false, 'secret should be cleared after clear:true PUT'); +}); + +test('smtp: empty password does not overwrite; clear_password removes it', async () => { + const agent = getAgent(); const csrf = getCsrf(); + // Set a password + await agent.put('/api/v1/smtp/settings').set('X-CSRF-Token', csrf).send({ host: 'm', port: '25', from: 'a@x', password: 'PW1' }).expect(200); + // Send without password key — should NOT overwrite + await agent.put('/api/v1/smtp/settings').set('X-CSRF-Token', csrf).send({ host: 'm', port: '25', from: 'a@x' }).expect(200); + let r = await agent.get('/api/v1/smtp/settings').expect(200); + assert.equal(r.body.data.hasPassword, true, 'password should still be set after PUT without password key'); + // Send clear_password:true — should remove it + await agent.put('/api/v1/smtp/settings').set('X-CSRF-Token', csrf).send({ host: 'm', port: '25', from: 'a@x', clear_password: true }).expect(200); + r = await agent.get('/api/v1/smtp/settings').expect(200); + assert.equal(r.body.data.hasPassword, false, 'password should be cleared after clear_password:true PUT'); +});