From ba87db78086b317e022b6179c6729ec9370b7750 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 24 Jun 2026 20:53:41 +0200
Subject: [PATCH 01/13] =?UTF-8?q?feat(settings):=20autosave=20core=20?=
=?UTF-8?q?=E2=80=94=20config,=20decision=20helpers,=20serial=20queue?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
public/js/settingsAutosaveCore.js | 63 ++++++++++++++++++++++++++++
tests/settings_autosave_core.test.js | 57 +++++++++++++++++++++++++
2 files changed, 120 insertions(+)
create mode 100644 public/js/settingsAutosaveCore.js
create mode 100644 tests/settings_autosave_core.test.js
diff --git a/public/js/settingsAutosaveCore.js b/public/js/settingsAutosaveCore.js
new file mode 100644
index 00000000..6c609aaf
--- /dev/null
+++ b/public/js/settingsAutosaveCore.js
@@ -0,0 +1,63 @@
+(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;
+ };
+ }
+
+ return { SETTINGS_CLUSTERS, classify, isDirty, stripEmptySecrets, needsConfirm, isAtomicReady, createQueue };
+});
diff --git a/tests/settings_autosave_core.test.js b/tests/settings_autosave_core.test.js
new file mode 100644
index 00000000..225d8479
--- /dev/null
+++ b/tests/settings_autosave_core.test.js
@@ -0,0 +1,57 @@
+'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']);
+});
From c0efeea6d9568aa91f6af14f9402a0a32b9530ad Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 24 Jun 2026 20:58:03 +0200
Subject: [PATCH 02/13] feat(settings): autosave controller + mandatory toggle
change-dispatch + script wiring
---
public/js/settings.js | 6 +-
public/js/settingsAutosave.js | 94 +++++++++++++++++++++++++++
templates/aurora/pages/settings.njk | 2 +
templates/default/pages/settings.njk | 2 +
templates/pro/pages/settings.njk | 2 +
tests/settings_autosave_smoke.test.js | 25 +++++++
6 files changed, 130 insertions(+), 1 deletion(-)
create mode 100644 public/js/settingsAutosave.js
create mode 100644 tests/settings_autosave_smoke.test.js
diff --git a/public/js/settings.js b/public/js/settings.js
index 8751b140..3019785a 100644
--- a/public/js/settings.js
+++ b/public/js/settings.js
@@ -395,7 +395,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);
diff --git a/public/js/settingsAutosave.js b/public/js/settingsAutosave.js
new file mode 100644
index 00000000..b2757b7d
--- /dev/null
+++ b/public/js/settingsAutosave.js
@@ -0,0 +1,94 @@
+(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
+
+ 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/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk
index 91ac77ff..463c0c32 100644
--- a/templates/aurora/pages/settings.njk
+++ b/templates/aurora/pages/settings.njk
@@ -961,6 +961,8 @@
{% endblock %}
{% block scripts %}
+
+
diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk
index 054b0d3e..3bbb2d62 100644
--- a/templates/default/pages/settings.njk
+++ b/templates/default/pages/settings.njk
@@ -1126,6 +1126,8 @@
{% endblock %}
{% block scripts %}
+
+
diff --git a/templates/pro/pages/settings.njk b/templates/pro/pages/settings.njk
index 9a4a4069..2516372b 100644
--- a/templates/pro/pages/settings.njk
+++ b/templates/pro/pages/settings.njk
@@ -1015,6 +1015,8 @@
{% endblock %}
{% block scripts %}
+
+
diff --git a/tests/settings_autosave_smoke.test.js b/tests/settings_autosave_smoke.test.js
new file mode 100644
index 00000000..c5fe519d
--- /dev/null
+++ b/tests/settings_autosave_smoke.test.js
@@ -0,0 +1,25 @@
+'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 } = require('./helpers/setup');
+
+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['"]\)\)/);
+});
From 10617dca6ef9163740811cb5c4535c3bdc7ba2b5 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 24 Jun 2026 21:06:43 +0200
Subject: [PATCH 03/13] feat(settings): autosave feedback styles
(.field-saving, reduced-motion)
---
public/css/app.css | 8 ++++++++
public/css/pro.css | 8 ++++++++
tests/settings_autosave_smoke.test.js | 7 +++++++
3 files changed, 23 insertions(+)
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/tests/settings_autosave_smoke.test.js b/tests/settings_autosave_smoke.test.js
index c5fe519d..b56587da 100644
--- a/tests/settings_autosave_smoke.test.js
+++ b/tests/settings_autosave_smoke.test.js
@@ -23,3 +23,10 @@ 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/);
+});
From 249b07b7904dd8e83d5583981b2804ebc9806a70 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 24 Jun 2026 21:11:06 +0200
Subject: [PATCH 04/13] fix(settings): secret clear paths (ip2location + smtp)
+ autosave i18n
---
src/i18n/de.json | 9 +++++-
src/i18n/en.json | 9 +++++-
src/routes/api/settings/observability.js | 9 ++++--
src/routes/api/smtp.js | 4 +--
src/services/email.js | 6 ++--
tests/settings_secret_handling.test.js | 37 ++++++++++++++++++++++++
6 files changed, 66 insertions(+), 8 deletions(-)
create mode 100644 tests/settings_secret_handling.test.js
diff --git a/src/i18n/de.json b/src/i18n/de.json
index 4e768a81..d82d11b5 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -1963,5 +1963,12 @@
"settings.portal.widget_device": "Gerätestatus",
"settings.portal.widget_traffic": "Traffic-Diagramm",
"settings.portal.widget_services": "Dienste",
- "settings.portal.saved": "Portal-Einstellungen gespeichert"
+ "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?"
}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 1bee9070..06a49d9f 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1963,5 +1963,12 @@
"settings.portal.widget_device": "Device status",
"settings.portal.widget_traffic": "Traffic chart",
"settings.portal.widget_services": "Services",
- "settings.portal.saved": "Portal settings saved"
+ "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?"
}
diff --git a/src/routes/api/settings/observability.js b/src/routes/api/settings/observability.js
index e8c88a40..c72d37aa 100644
--- a/src/routes/api/settings/observability.js
+++ b/src/routes/api/settings/observability.js
@@ -97,8 +97,13 @@ 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));
+ const { api_key, clear } = req.body;
+ if (clear === true) {
+ settings.set('ip2location.api_key', '');
+ } else if (api_key !== undefined && String(api_key) !== '') {
+ settings.set('ip2location.api_key', String(api_key));
+ }
+ // empty api_key without clear → leave unchanged
activity.log('ip2location_settings_updated', 'ip2location API key updated', {
source: 'admin', ipAddress: req.ip, severity: 'info',
});
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/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');
+});
From 8395bf2f139233f4215749b1119ef35e353a14d3 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 24 Jun 2026 21:37:18 +0200
Subject: [PATCH 05/13] feat(settings): autosave for independent clusters (+mb
fix, gateway via api.put)
---
public/js/settings.js | 215 ++++++++++++--------------
templates/aurora/pages/settings.njk | 25 +--
templates/default/pages/settings.njk | 25 +--
templates/pro/pages/settings.njk | 25 +--
tests/settings_autosave_smoke.test.js | 12 +-
5 files changed, 131 insertions(+), 171 deletions(-)
diff --git a/public/js/settings.js b/public/js/settings.js
index 3019785a..d9b0a4a0 100644
--- a/public/js/settings.js
+++ b/public/js/settings.js
@@ -540,33 +540,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 {
@@ -583,28 +592,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 ──────────────────────────────
@@ -893,6 +906,7 @@
if (metricsEnabledToggle) {
metricsEnabledToggle.addEventListener('click', function() {
metricsEnabledToggle.classList.toggle('on');
+ metricsEnabledToggle.dispatchEvent(new Event('change'));
});
}
@@ -909,24 +923,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') }); },
});
}
@@ -1008,30 +1012,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() }); },
});
}
})();
@@ -1044,19 +1036,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() }); },
});
}
})();
@@ -1065,8 +1053,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('mb-message');
if (!modeSelect) return;
try {
@@ -1074,16 +1061,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 }); },
});
})();
@@ -1336,17 +1319,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) }); },
});
}
})();
diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk
index 463c0c32..ff7bd63e 100644
--- a/templates/aurora/pages/settings.njk
+++ b/templates/aurora/pages/settings.njk
@@ -135,9 +135,7 @@
{{ t('settings.dns_desc') }}
-
-
-
+
{% else %}
{{ t('settings.dns') }}
@@ -170,9 +168,7 @@
{{ t('data.peer_timeout_hint') or 'Peer is considered offline after no handshake for this many seconds' }}
-
-
-
+
@@ -357,9 +353,7 @@
-
-
-
+
@@ -584,9 +578,7 @@
-
-
-
+
@@ -606,9 +598,7 @@
/metrics
-
-
-
+
@@ -626,6 +616,7 @@
{{ t('gateway_failover_settings.down_threshold_help') }}
+
@@ -685,9 +676,7 @@
{{ t('autoupdate.mode_manual') }}
-
-
-
+
diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk
index 3bbb2d62..2e418191 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' }}
-
-
-
+
@@ -507,9 +503,7 @@
-
-
-
+
@@ -748,9 +742,7 @@
-
-
-
+
@@ -770,9 +762,7 @@
/metrics
-
-
-
+
@@ -790,6 +780,7 @@
{{ t('gateway_failover_settings.down_threshold_help') }}
+
@@ -851,9 +842,7 @@
{{ t('autoupdate.mode_manual') }}
-
-
-
+
diff --git a/templates/pro/pages/settings.njk b/templates/pro/pages/settings.njk
index 2516372b..5b5ee9fa 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' }}
-
-
-
+
@@ -386,9 +382,7 @@
-
-
-
+
@@ -627,9 +621,7 @@
-
-
-
+
@@ -649,9 +641,7 @@
/metrics
-
-
-
+
@@ -669,6 +659,7 @@
{{ t('gateway_failover_settings.down_threshold_help') }}
+
@@ -732,9 +723,7 @@
{{ t('autoupdate.mode_manual') }}
-
-
-
+
diff --git a/tests/settings_autosave_smoke.test.js b/tests/settings_autosave_smoke.test.js
index b56587da..02b97f5d 100644
--- a/tests/settings_autosave_smoke.test.js
+++ b/tests/settings_autosave_smoke.test.js
@@ -4,7 +4,7 @@ process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBy
const { test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const supertest = require('supertest');
-const { setup, teardown } = require('./helpers/setup');
+const { setup, teardown, getAgent } = require('./helpers/setup');
let app;
beforeEach(async () => { await setup(); app = require('../src/app').createApp(); });
@@ -30,3 +30,13 @@ test('field-saving style is served in both stylesheets', async () => {
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 + '"')));
+});
From c8b2f3d1f948b491dd406ba610ae64ecbd804ec1 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 24 Jun 2026 21:45:36 +0200
Subject: [PATCH 06/13] fix(settings): point mb statusEl at dedicated badge;
unify default-theme status flash
---
public/js/settings.js | 11 ++++++++++-
templates/aurora/pages/settings.njk | 1 +
templates/default/pages/settings.njk | 1 +
templates/pro/pages/settings.njk | 1 +
tests/settings_autosave_smoke.test.js | 5 +++++
5 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/public/js/settings.js b/public/js/settings.js
index d9b0a4a0..fa5ede74 100644
--- a/public/js/settings.js
+++ b/public/js/settings.js
@@ -1053,7 +1053,7 @@
// ── Machine Binding Settings ──────────────────────────
(async function () {
var modeSelect = document.getElementById('mb-mode');
- var statusEl = document.getElementById('mb-message');
+ var statusEl = document.getElementById('machine-binding-status');
if (!modeSelect) return;
try {
@@ -1300,6 +1300,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;
@@ -1310,6 +1318,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);
}
diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk
index ff7bd63e..0a70e73e 100644
--- a/templates/aurora/pages/settings.njk
+++ b/templates/aurora/pages/settings.njk
@@ -71,6 +71,7 @@
{{ t('settings.default_theme_hint') }}
+
diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk
index 2e418191..49f604f2 100644
--- a/templates/default/pages/settings.njk
+++ b/templates/default/pages/settings.njk
@@ -282,6 +282,7 @@
{{ t('settings.default_theme_hint') }}
+
diff --git a/templates/pro/pages/settings.njk b/templates/pro/pages/settings.njk
index 5b5ee9fa..476872bb 100644
--- a/templates/pro/pages/settings.njk
+++ b/templates/pro/pages/settings.njk
@@ -161,6 +161,7 @@
{{ t('settings.default_theme_hint') }}
+
diff --git a/tests/settings_autosave_smoke.test.js b/tests/settings_autosave_smoke.test.js
index 02b97f5d..306b95c9 100644
--- a/tests/settings_autosave_smoke.test.js
+++ b/tests/settings_autosave_smoke.test.js
@@ -39,4 +39,9 @@ test('independent clusters migrated: buttons gone, autosave bound, mb fixed', as
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['"]\)/);
});
From 405753a349e7e0e89fc2a2730fbf29f058f95580 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Wed, 24 Jun 2026 22:01:35 +0200
Subject: [PATCH 07/13] feat(settings): autosave for security (single bind),
backup, portal
---
public/js/settings.js | 165 ++++++++++++++++----------
templates/aurora/pages/settings.njk | 15 +--
templates/default/pages/settings.njk | 15 +--
templates/pro/pages/settings.njk | 15 +--
tests/settings_autosave_smoke.test.js | 6 +
5 files changed, 119 insertions(+), 97 deletions(-)
diff --git a/public/js/settings.js b/public/js/settings.js
index fa5ede74..1de4673f 100644
--- a/public/js/settings.js
+++ b/public/js/settings.js
@@ -506,19 +506,54 @@
}
}
- 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');
- });
- }
+ // ─── 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: v['security-lockout-enabled'],
+ max_attempts: v['security-lockout-attempts'],
+ duration: v['security-lockout-duration'],
+ },
+ password: {
+ 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 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 ───────────────────────────────
@@ -729,6 +764,7 @@
if (autobackupEnabledToggle) {
autobackupEnabledToggle.addEventListener('click', function() {
autobackupEnabledToggle.classList.toggle('on');
+ autobackupEnabledToggle.dispatchEvent(new Event('change'));
});
}
@@ -856,28 +892,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) {
@@ -1668,11 +1709,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) {
@@ -1691,30 +1734,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 ──────────────────────────────
diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk
index 0a70e73e..6f5db983 100644
--- a/templates/aurora/pages/settings.njk
+++ b/templates/aurora/pages/settings.njk
@@ -288,10 +288,7 @@
{{ t('security.lockout.locked_accounts') }}
{{ t('security.lockout.no_locked') }}
-
-
-
-
+
@@ -324,10 +321,6 @@
-
-
-
-
@@ -425,9 +418,9 @@
{{ t('autobackup.last_run_never') }}
+
-
{{ t('autobackup.existing_files') }}
@@ -940,9 +933,7 @@
-
-
-
+
diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk
index 49f604f2..b750da31 100644
--- a/templates/default/pages/settings.njk
+++ b/templates/default/pages/settings.njk
@@ -435,10 +435,7 @@
{{ t('security.lockout.locked_accounts') }}
{{ t('security.lockout.no_locked') }}
-
-
-
-
+
@@ -471,10 +468,6 @@
-
-
-
-
@@ -587,10 +580,10 @@
+
-
-
-
-
+
diff --git a/templates/pro/pages/settings.njk b/templates/pro/pages/settings.njk
index 476872bb..111c1e54 100644
--- a/templates/pro/pages/settings.njk
+++ b/templates/pro/pages/settings.njk
@@ -314,10 +314,7 @@
{{ t('security.lockout.locked_accounts') }}
{{ t('security.lockout.no_locked') }}
-
-
-
-
+
@@ -350,10 +347,6 @@
-
-
-
-
@@ -466,10 +459,10 @@
+
-
-
-
-
+
diff --git a/tests/settings_autosave_smoke.test.js b/tests/settings_autosave_smoke.test.js
index 306b95c9..15b29338 100644
--- a/tests/settings_autosave_smoke.test.js
+++ b/tests/settings_autosave_smoke.test.js
@@ -45,3 +45,9 @@ test('independent clusters migrated: buttons gone, autosave bound, mb fixed', as
// 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 + '"')));
+});
From 41a1d3de3e2cc132c6e022c3f36aeceb3563384d Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Thu, 25 Jun 2026 07:35:31 +0200
Subject: [PATCH 08/13] feat(settings): autosave for atomic clusters
(smtp/alerts/route-block) + secret-clear UI
---
public/js/settings.js | 200 +++++++++++++++-----------
templates/aurora/pages/settings.njk | 14 +-
templates/default/pages/settings.njk | 14 +-
templates/pro/pages/settings.njk | 14 +-
tests/settings_autosave_smoke.test.js | 8 ++
5 files changed, 141 insertions(+), 109 deletions(-)
diff --git a/public/js/settings.js b/public/js/settings.js
index 1de4673f..848182b9 100644
--- a/public/js/settings.js
+++ b/public/js/settings.js
@@ -318,44 +318,64 @@
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() {
+ return {
+ 'smtp-host': document.getElementById('smtp-host').value,
+ 'smtp-from': document.getElementById('smtp-from').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 = 'Password is set'; hint.style.display = ''; }
+ document.getElementById('smtp-password').value = '';
+ }
+ 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,
+ });
+ });
});
}
@@ -681,35 +701,38 @@
}
}
- 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 () { return { 'alerts-email': alertsEmail ? alertsEmail.value : '' }; },
+ 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 ──────────────────────────────
@@ -758,6 +781,19 @@
});
}
+ // 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');
@@ -1765,7 +1801,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() {
@@ -1786,25 +1821,26 @@
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-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 : '',
+ });
+ },
+ });
})();
diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk
index 6f5db983..839d6520 100644
--- a/templates/aurora/pages/settings.njk
+++ b/templates/aurora/pages/settings.njk
@@ -191,9 +191,7 @@
-
-
-
+
@@ -460,6 +458,7 @@
+
@@ -473,9 +472,7 @@
-
-
-
+
@@ -539,9 +536,7 @@
-
-
-
+
@@ -633,6 +628,7 @@
+
diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk
index b750da31..7d6e9c5d 100644
--- a/templates/default/pages/settings.njk
+++ b/templates/default/pages/settings.njk
@@ -259,9 +259,7 @@
-
-
-
+
@@ -624,6 +622,7 @@
+
@@ -637,9 +636,7 @@
-
-
-
+
@@ -703,9 +700,7 @@
-
-
-
+
@@ -797,6 +792,7 @@
+
diff --git a/templates/pro/pages/settings.njk b/templates/pro/pages/settings.njk
index 111c1e54..e7650075 100644
--- a/templates/pro/pages/settings.njk
+++ b/templates/pro/pages/settings.njk
@@ -138,9 +138,7 @@
-
-
-
+
@@ -503,6 +501,7 @@
+
@@ -516,9 +515,7 @@
-
-
-
+
@@ -582,9 +579,7 @@
-
-
-
+
@@ -676,6 +671,7 @@
+
diff --git a/tests/settings_autosave_smoke.test.js b/tests/settings_autosave_smoke.test.js
index 15b29338..496524d1 100644
--- a/tests/settings_autosave_smoke.test.js
+++ b/tests/settings_autosave_smoke.test.js
@@ -51,3 +51,11 @@ test('security/backup/portal migrated; single security bind', async () => {
['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"/);
+});
From f96b722f1a6ad8fc98c30271cd86aa6778c3ff40 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Thu, 25 Jun 2026 07:51:43 +0200
Subject: [PATCH 09/13] feat(settings): autosave for full-payload clusters via
shared queue + race test
---
public/js/settings.js | 63 +++++++++++++++++----------
templates/aurora/pages/settings.njk | 6 +--
templates/default/pages/settings.njk | 6 +--
templates/pro/pages/settings.njk | 8 +---
tests/helpers/setup.js | 1 +
tests/settings_autosave_smoke.test.js | 22 +++++++++-
6 files changed, 68 insertions(+), 38 deletions(-)
diff --git a/public/js/settings.js b/public/js/settings.js
index 848182b9..7597ac6d 100644
--- a/public/js/settings.js
+++ b/public/js/settings.js
@@ -1325,7 +1325,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);
});
@@ -1342,6 +1342,7 @@
}
customNets.push({ label: label, cidr: cidr });
renderCustom();
+ SettingsAutosave.enqueue('split-tunnel', stSave);
});
async function loadST() {
@@ -1360,14 +1361,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();
@@ -1426,11 +1439,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 {
@@ -1552,6 +1564,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);
@@ -1624,7 +1637,7 @@
renderInstances();
hideInstanceForm();
try {
- await savePihole(true);
+ await SettingsAutosave.enqueue('pihole', function () { return savePihole(false); });
} catch (err) {
showToast(err.message || 'Error', 'error');
}
@@ -1683,6 +1696,7 @@
if (enabledToggle) {
enabledToggle.addEventListener('click', function () {
enabledToggle.classList.toggle('on');
+ enabledToggle.dispatchEvent(new Event('change'));
});
}
@@ -1690,6 +1704,7 @@
if (chainToggle) {
chainToggle.addEventListener('click', function () {
chainToggle.classList.toggle('on');
+ chainToggle.dispatchEvent(new Event('change'));
});
}
@@ -1723,18 +1738,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();
})();
diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk
index 839d6520..35cf715c 100644
--- a/templates/aurora/pages/settings.njk
+++ b/templates/aurora/pages/settings.njk
@@ -817,7 +817,7 @@
{{ t('settings.split_tunnel_lock_hint') }}
-
+
@@ -847,9 +847,7 @@
-
-
-
+
diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk
index 7d6e9c5d..1151e7e5 100644
--- a/templates/default/pages/settings.njk
+++ b/templates/default/pages/settings.njk
@@ -984,7 +984,7 @@
-
+
@@ -1013,9 +1013,7 @@
-
-
-
+
diff --git a/templates/pro/pages/settings.njk b/templates/pro/pages/settings.njk
index e7650075..8e8d27d8 100644
--- a/templates/pro/pages/settings.njk
+++ b/templates/pro/pages/settings.njk
@@ -869,9 +869,7 @@
-
-
-
+
@@ -901,9 +899,7 @@
-
-
-
+
diff --git a/tests/helpers/setup.js b/tests/helpers/setup.js
index cf3d4df2..da70a512 100644
--- a/tests/helpers/setup.js
+++ b/tests/helpers/setup.js
@@ -77,6 +77,7 @@ async function setup() {
gateway_pools_limit: 100,
share_links: true,
access_windows: true,
+ pihole_integration: true,
});
app = createApp();
diff --git a/tests/settings_autosave_smoke.test.js b/tests/settings_autosave_smoke.test.js
index 496524d1..e4bf4a73 100644
--- a/tests/settings_autosave_smoke.test.js
+++ b/tests/settings_autosave_smoke.test.js
@@ -4,7 +4,7 @@ process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBy
const { test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const supertest = require('supertest');
-const { setup, teardown, getAgent } = require('./helpers/setup');
+const { setup, teardown, getAgent, getCsrf } = require('./helpers/setup');
let app;
beforeEach(async () => { await setup(); app = require('../src/app').createApp(); });
@@ -59,3 +59,23 @@ test('atomic clusters migrated incl. route-block; secret-clear buttons present',
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('two rapid pihole PUTs both land (no lost update)', async () => {
+ 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));
+});
From 452967e82e28512de81c1e91a88688abc14250ba Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Thu, 25 Jun 2026 07:58:03 +0200
Subject: [PATCH 10/13] test(settings): rename pihole concurrency test to
reflect DB-level scope
---
tests/settings_autosave_smoke.test.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/tests/settings_autosave_smoke.test.js b/tests/settings_autosave_smoke.test.js
index e4bf4a73..6d6d56d4 100644
--- a/tests/settings_autosave_smoke.test.js
+++ b/tests/settings_autosave_smoke.test.js
@@ -69,7 +69,8 @@ test('full-payload clusters migrated; list mutations use the queue', async () =>
assert.match(js.text, /SettingsAutosave\.enqueue\(['"]split-tunnel['"]/);
});
-test('two rapid pihole PUTs both land (no lost update)', async () => {
+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).
const agent = getAgent(); const csrf = getCsrf();
const base = { enabled: true, manage_dns_chain: false, sync_interval_sec: 30 };
await Promise.all([
From 147f4e9d2473cb42a775c72c9a4c3856e7658b8f Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Thu, 25 Jun 2026 08:01:20 +0200
Subject: [PATCH 11/13] test(settings): 3-theme autosave template test +
ip2location audit no-op guard
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add settings_autosave_templates.test.js: verifies all three themes (aurora/default/pro)
have autosave scripts before settings.js and no migrated save buttons remain (btn-ip2location-save
excluded — intentionally kept as action button). Add no-op guard in observability.js PUT
/ip2location: reads old key before write; only logs and writes when value actually changed;
empty-without-clear returns ok immediately (no write, no log). 66/66 settings tests pass.
---
src/routes/api/settings/observability.js | 24 +++++++---
tests/settings_autosave_templates.test.js | 55 +++++++++++++++++++++++
2 files changed, 73 insertions(+), 6 deletions(-)
create mode 100644 tests/settings_autosave_templates.test.js
diff --git a/src/routes/api/settings/observability.js b/src/routes/api/settings/observability.js
index c72d37aa..0c1b7d36 100644
--- a/src/routes/api/settings/observability.js
+++ b/src/routes/api/settings/observability.js
@@ -98,15 +98,27 @@ router.get('/ip2location', (req, res) => {
router.put('/ip2location', (req, res) => {
try {
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) {
- settings.set('ip2location.api_key', '');
+ newKey = '';
} else if (api_key !== undefined && String(api_key) !== '') {
- settings.set('ip2location.api_key', String(api_key));
+ newKey = String(api_key);
+ } else {
+ // empty api_key without clear → no change → skip write and audit
+ return res.json({ ok: true });
}
- // empty api_key without clear → leave unchanged
- activity.log('ip2location_settings_updated', 'ip2location API key updated', {
- source: 'admin', ipAddress: req.ip, severity: 'info',
- });
+
+ // 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/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`,
+ );
+ }
+ }
+});
From bc13588ad8dea0c0578f11586908fe0bf6b66ca2 Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Thu, 25 Jun 2026 08:19:08 +0200
Subject: [PATCH 12/13] fix(settings): autosave dirty-check covers all bound
fields (smtp/alerts/route-block) + i18n + guard
- smtpValues() now returns all 6 bound fields (smtp-host/port/user/from/tls/password)
instead of only 2; post-save password-clear removed to avoid snapshot/value mismatch
- Alerts valuesById expanded to include backup-days, cpu, ram, and 4 event-group
checkboxes (added ids alerts-events-{security,peers,routes,system} to all 3 templates)
- rbValues() adds settings-route-block-body so body changes trigger autosave
- missingValueKeys() helper added to Core + exported; bind() logs console.warn on
mismatch (non-fatal dev guard that would have caught Critical #1)
- 'Password is set' hint replaced with i18n key settings.smtp.password_set (en+de)
- Dead saveSecuritySettings() function removed (zero callers)
- Unit test for missingValueKeys + smoke assertions for expanded valuesById keys
---
public/js/settings.js | 61 +++++++++++----------------
public/js/settingsAutosave.js | 5 +++
public/js/settingsAutosaveCore.js | 6 ++-
src/i18n/de.json | 3 +-
src/i18n/en.json | 3 +-
templates/aurora/pages/settings.njk | 10 ++---
templates/default/pages/settings.njk | 10 ++---
templates/pro/pages/settings.njk | 10 ++---
tests/settings_autosave_core.test.js | 11 +++++
tests/settings_autosave_smoke.test.js | 17 ++++++++
10 files changed, 82 insertions(+), 54 deletions(-)
diff --git a/public/js/settings.js b/public/js/settings.js
index 7597ac6d..475ad6d3 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 = '';
}
}
@@ -325,9 +325,19 @@
// 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': document.getElementById('smtp-host').value,
- 'smtp-from': document.getElementById('smtp-from').value,
+ '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() {
@@ -344,8 +354,7 @@
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 = 'Password is set'; hint.style.display = ''; }
- document.getElementById('smtp-password').value = '';
+ if (hint) { hint.textContent = (window.GC.t || {})['settings.smtp.password_set'] || 'Password is set'; hint.style.display = ''; }
}
return res;
});
@@ -496,36 +505,6 @@
}
}
- async function saveSecuritySettings(triggerBtn, messageId) {
- btnLoading(triggerBtn);
- try {
- var payload = {
- 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,
- },
- 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'),
- },
- };
- 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);
- }
- }
-
// ─── Security Autosave (single bind over all 8 fields) ───
(function () {
var g = function (id) { return document.getElementById(id); };
@@ -715,7 +694,16 @@
cluster: 'alerts',
fields: alertsFields,
statusEl: document.getElementById('alerts-status'),
- valuesById: function () { return { 'alerts-email': alertsEmail ? alertsEmail.value : '' }; },
+ 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 = [];
@@ -1841,6 +1829,7 @@
function rbValues() {
return {
'settings-route-block-action': actionSel.value,
+ 'settings-route-block-body': bodyEl ? bodyEl.value || '' : '',
'settings-route-block-redirect': redirectEl ? redirectEl.value || '' : '',
};
}
diff --git a/public/js/settingsAutosave.js b/public/js/settingsAutosave.js
index b2757b7d..6a41fb4e 100644
--- a/public/js/settingsAutosave.js
+++ b/public/js/settingsAutosave.js
@@ -45,6 +45,11 @@
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) {
diff --git a/public/js/settingsAutosaveCore.js b/public/js/settingsAutosaveCore.js
index 6c609aaf..ae152411 100644
--- a/public/js/settingsAutosaveCore.js
+++ b/public/js/settingsAutosaveCore.js
@@ -59,5 +59,9 @@
};
}
- return { SETTINGS_CLUSTERS, classify, isDirty, stripEmptySecrets, needsConfirm, isAtomicReady, createQueue };
+ 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 d82d11b5..454ba0cb 100644
--- a/src/i18n/de.json
+++ b/src/i18n/de.json
@@ -1970,5 +1970,6 @@
"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.autosave.clear_secret_confirm": "Gespeicherten Wert entfernen?",
+ "settings.smtp.password_set": "Passwort ist gesetzt"
}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 06a49d9f..3a96a395 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1970,5 +1970,6 @@
"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.autosave.clear_secret_confirm": "Remove the stored value?",
+ "settings.smtp.password_set": "Password is set"
}
diff --git a/templates/aurora/pages/settings.njk b/templates/aurora/pages/settings.njk
index 35cf715c..1dbcec77 100644
--- a/templates/aurora/pages/settings.njk
+++ b/templates/aurora/pages/settings.njk
@@ -506,14 +506,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 %}
diff --git a/templates/default/pages/settings.njk b/templates/default/pages/settings.njk
index 1151e7e5..9cfdd960 100644
--- a/templates/default/pages/settings.njk
+++ b/templates/default/pages/settings.njk
@@ -670,14 +670,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 %}
diff --git a/templates/pro/pages/settings.njk b/templates/pro/pages/settings.njk
index 8e8d27d8..b686442b 100644
--- a/templates/pro/pages/settings.njk
+++ b/templates/pro/pages/settings.njk
@@ -549,14 +549,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 %}
diff --git a/tests/settings_autosave_core.test.js b/tests/settings_autosave_core.test.js
index 225d8479..15ceee49 100644
--- a/tests/settings_autosave_core.test.js
+++ b/tests/settings_autosave_core.test.js
@@ -55,3 +55,14 @@ test('createQueue continues after a rejected task', async () => {
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
index 6d6d56d4..f2d778fb 100644
--- a/tests/settings_autosave_smoke.test.js
+++ b/tests/settings_autosave_smoke.test.js
@@ -69,6 +69,23 @@ test('full-payload clusters migrated; list mutations use the queue', async () =>
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).
const agent = getAgent(); const csrf = getCsrf();
From 3234ed0097130dcdac54c5319d5d19a34ec07e0e Mon Sep 17 00:00:00 2001
From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com>
Date: Thu, 25 Jun 2026 08:40:51 +0200
Subject: [PATCH 13/13] =?UTF-8?q?test(settings):=20fix=20autosave=20regres?=
=?UTF-8?q?sions=20=E2=80=94=20revert=20global=20pihole=20unlock,=20local?=
=?UTF-8?q?=20pihole=20override=20in=20smoke,=20update=20stale=20aurora=20?=
=?UTF-8?q?button=20assertions?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
tests/aurora_theme.test.js | 16 ++++++++--------
tests/helpers/setup.js | 1 -
tests/settings_autosave_smoke.test.js | 23 +++++++++++++++--------
3 files changed, 23 insertions(+), 17 deletions(-)
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/helpers/setup.js b/tests/helpers/setup.js
index da70a512..cf3d4df2 100644
--- a/tests/helpers/setup.js
+++ b/tests/helpers/setup.js
@@ -77,7 +77,6 @@ async function setup() {
gateway_pools_limit: 100,
share_links: true,
access_windows: true,
- pihole_integration: true,
});
app = createApp();
diff --git a/tests/settings_autosave_smoke.test.js b/tests/settings_autosave_smoke.test.js
index f2d778fb..bb31e8fa 100644
--- a/tests/settings_autosave_smoke.test.js
+++ b/tests/settings_autosave_smoke.test.js
@@ -5,6 +5,7 @@ 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(); });
@@ -88,12 +89,18 @@ test('expanded valuesById covers all bound fields for smtp/alerts/route-block',
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).
- 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));
+ // 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 });
+ }
});