Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions public/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
8 changes: 8 additions & 0 deletions public/css/pro.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
699 changes: 390 additions & 309 deletions public/js/settings.js

Large diffs are not rendered by default.

99 changes: 99 additions & 0 deletions public/js/settingsAutosave.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
(function () {
'use strict';
var Core = window.SettingsAutosaveCore;
var enqueue = Core.createQueue(); // shared per-cluster serialization
var t = (window.GC && window.GC.t) || {};
window.SettingsAutosave = { enqueue: enqueue };

function flash(statusEl) {
if (!statusEl) return;
statusEl.classList.remove('autosave-error');
statusEl.classList.add('field-saving');
statusEl.textContent = t['settings.autosave.saved'] || 'Saved';
setTimeout(function () { statusEl.classList.remove('field-saving'); }, 500);
}
function showError(statusEl, msg) {
if (!statusEl) return;
statusEl.classList.remove('field-saving');
statusEl.classList.add('autosave-error');
statusEl.textContent = msg || t['settings.autosave.error'] || 'Save failed';
}
function showPending(statusEl) {
if (!statusEl) return;
statusEl.classList.remove('field-saving', 'autosave-error');
statusEl.textContent = t['settings.autosave.pending'] || 'Will save once all required fields are filled';
}

function isDiscrete(el) {
if (!el || !el.tagName) return false;
if (el.classList && el.classList.contains('toggle')) return true;
if (el.tagName === 'SELECT') return true;
if (el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) return true;
return false;
}
function valueOf(el) {
if (el.classList && el.classList.contains('toggle')) return el.classList.contains('on');
if (el.type === 'checkbox' || el.type === 'radio') return el.checked;
return el.value;
}

function bind(opts) {
var cluster = opts.cluster;
var cfg = Core.classify(cluster);
var fields = opts.fields || [];
var statusEl = opts.statusEl || null;
var valuesById = opts.valuesById || function () { return {}; };
var snapshot = JSON.stringify(valuesById()); // last successfully persisted state

// Dev guard: warn if any bound field id is absent from valuesById (would never be dirty).
var _boundIds = fields.map(function(f) { return f && f.id; }).filter(Boolean);
var _missing = Core.missingValueKeys(_boundIds, valuesById());
if (_missing.length) console.warn('[autosave] ' + cluster + ' valuesById missing bound fields: ' + _missing.join(','));

function requiredOverride() { return opts.requiredForCommit ? opts.requiredForCommit() : undefined; }

function rollbackField(el) {
if (!el || !isDiscrete(el)) return;
try {
var snap = JSON.parse(snapshot || '{}');
if (el.classList && el.classList.contains('toggle')) el.classList.toggle('on', !!snap[el.id]);
else if (el.type === 'checkbox' || el.type === 'radio') el.checked = !!snap[el.id];
else if (el.tagName === 'SELECT' && snap[el.id] != null) el.value = snap[el.id];
} catch (e) {}
}

function commit(triggerEl, triggerValue) {
var values = valuesById();
if (!Core.isAtomicReady(cfg, values, requiredOverride())) { showPending(statusEl); return; }
if (!Core.isDirty(values, JSON.parse(snapshot || '{}'))) return;
if (triggerEl && Core.needsConfirm(cfg, triggerEl.id, triggerValue)) {
var msg = (cluster === 'machine-binding')
? (t['settings.autosave.confirm_mb_mode'] || 'This changes device binding and can affect access. Apply it?')
: (t['settings.autosave.confirm_self'] || 'This change can affect your current session. Apply it?');
if (!window.confirm(msg)) { rollbackField(triggerEl); return; }
}
var frozen = JSON.stringify(values); // freeze what we send (spec: snapshot from sent values)
enqueue(cluster, async function () {
try {
var res = await opts.save();
if (res && res.ok) { snapshot = frozen; flash(statusEl); }
else { if (isDiscrete(triggerEl)) rollbackField(triggerEl); showError(statusEl, (res && res.error) || null); }
} catch (err) { showError(statusEl, null); } // network error -> localized string, value kept, retry next trigger
});
}

fields.forEach(function (el) {
if (isDiscrete(el)) {
el.addEventListener('change', function () { commit(el, valueOf(el)); });
} else {
var fire = function () { commit(el, el.value); };
el.addEventListener('blur', function () { clearTimeout(el._asTimer); fire(); });
el.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { clearTimeout(el._asTimer); el._asTimer = setTimeout(fire, 400); }
});
}
});
}

window.SettingsAutosave.bind = bind;
})();
67 changes: 67 additions & 0 deletions public/js/settingsAutosaveCore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
(function (root, factory) {
const api = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = api; // node tests
else root.SettingsAutosaveCore = api; // browser global
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';

// Single declarative source for per-cluster special behavior (spec §4.5e).
// Clusters not listed default to { klass: 'independent' }.
// alerts.requiredForCommit + route-block are overridden at bind-time via
// isAtomicReady's overrideRequired arg (events-active / action=redirect).
const SETTINGS_CLUSTERS = {
smtp: { klass: 'atomic', secretKeys: ['password'], requiredForCommit: ['smtp-host', 'smtp-from'] },
alerts: { klass: 'atomic', requiredForCommit: ['alerts-email'] }, // override: only if an event group is active
'route-block': { klass: 'atomic', requiredForCommit: [] }, // override: ['settings-route-block-redirect'] when action=redirect
ip2location: { klass: 'independent', secretKeys: ['api_key'] },
pihole: { klass: 'fullPayload' },
'split-tunnel': { klass: 'fullPayload' },
security: { klass: 'independent', selfAffecting: [{ field: 'security-lockout-attempts', confirmIf: '<=2' }] },
'machine-binding': { klass: 'independent', selfAffecting: [{ field: 'mb-mode', confirmAlways: true }] },
};

function classify(cluster) { return SETTINGS_CLUSTERS[cluster] || { klass: 'independent' }; }

function isDirty(payload, snapshot) { return JSON.stringify(payload) !== JSON.stringify(snapshot); }

function stripEmptySecrets(payload, secretKeys) {
const out = Object.assign({}, payload);
for (const k of secretKeys || []) {
if (out[k] === '' || out[k] === null || out[k] === undefined) delete out[k];
}
return out;
}

function needsConfirm(config, fieldId, value) {
for (const sa of (config && config.selfAffecting) || []) {
if (sa.field !== fieldId) continue;
if (sa.confirmAlways) return true;
if (sa.confirmIf === '<=2' && Number(value) <= 2) return true;
}
return false;
}

function isAtomicReady(config, valuesById, overrideRequired) {
if (!config || config.klass !== 'atomic') return true;
const required = overrideRequired || config.requiredForCommit || [];
return required.every(id => String((valuesById && valuesById[id]) || '').trim() !== '');
}

// Per-key serialized promise chain (spec §4.5c). Used by the controller for
// field autosaves AND by full-payload list-mutation buttons → one lock.
function createQueue() {
const chains = {};
return function enqueue(key, fn) {
const prev = chains[key] || Promise.resolve();
const next = prev.then(fn, fn); // runs even after a prior rejection
chains[key] = next.catch(function () {});
return next;
};
}

function missingValueKeys(fieldIds, valuesObj) {
return (fieldIds || []).filter(function(id) { return id && !(id in (valuesObj || {})); });
}

return { SETTINGS_CLUSTERS, classify, isDirty, stripEmptySecrets, needsConfirm, isAtomicReady, createQueue, missingValueKeys };
});
9 changes: 8 additions & 1 deletion src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1964,7 +1964,14 @@
"settings.portal.widget_traffic": "Traffic-Diagramm",
"settings.portal.widget_services": "Dienste",
"settings.portal.saved": "Portal-Einstellungen gespeichert",

"settings.autosave.saved": "Gespeichert",
"settings.autosave.error": "Speichern fehlgeschlagen",
"settings.autosave.pending": "Wird gespeichert, sobald alle Pflichtfelder ausgefüllt sind",
"settings.autosave.confirm_self": "Diese Änderung kann deine aktuelle Sitzung betreffen. Übernehmen?",
"settings.autosave.confirm_mb_mode": "Dies ändert die Gerätebindung und kann den Zugriff betreffen. Übernehmen?",
"settings.autosave.clear_secret": "Entfernen",
"settings.autosave.clear_secret_confirm": "Gespeicherten Wert entfernen?",
"settings.smtp.password_set": "Passwort ist gesetzt",
"settings.domains.title": "Domains",
"settings.domains.intro": "Hinterlege die Hauptdomains dieses Servers. Eine Domain muss auf diesen Server zeigen (DNS A/AAAA → Server-IP), bevor sie genutzt werden kann.",
"settings.domains.add": "Domain hinzufügen",
Expand Down
9 changes: 8 additions & 1 deletion src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1964,7 +1964,14 @@
"settings.portal.widget_traffic": "Traffic chart",
"settings.portal.widget_services": "Services",
"settings.portal.saved": "Portal settings saved",

"settings.autosave.saved": "Saved",
"settings.autosave.error": "Save failed",
"settings.autosave.pending": "Will save once all required fields are filled",
"settings.autosave.confirm_self": "This change can affect your current session. Apply it?",
"settings.autosave.confirm_mb_mode": "This changes device binding and can affect access. Apply it?",
"settings.autosave.clear_secret": "Remove",
"settings.autosave.clear_secret_confirm": "Remove the stored value?",
"settings.smtp.password_set": "Password is set",
"settings.domains.title": "Domains",
"settings.domains.intro": "Register the main domains this server owns. A domain must point to this server (DNS A/AAAA → server IP) before it can be used.",
"settings.domains.add": "Add domain",
Expand Down
27 changes: 22 additions & 5 deletions src/routes/api/settings/observability.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,28 @@ router.get('/ip2location', (req, res) => {
*/
router.put('/ip2location', (req, res) => {
try {
const { api_key } = req.body;
if (api_key !== undefined) settings.set('ip2location.api_key', String(api_key));
activity.log('ip2location_settings_updated', 'ip2location API key updated', {
source: 'admin', ipAddress: req.ip, severity: 'info',
});
const { api_key, clear } = req.body;
const oldKey = settings.get('ip2location.api_key', '');

// Determine effective new value (mirrors the write logic below).
let newKey;
if (clear === true) {
newKey = '';
} else if (api_key !== undefined && String(api_key) !== '') {
newKey = String(api_key);
} else {
// empty api_key without clear → no change → skip write and audit
return res.json({ ok: true });
}

// Only write and log when the value actually changed.
if (newKey !== oldKey) {
settings.set('ip2location.api_key', newKey);
activity.log('ip2location_settings_updated', 'ip2location API key updated', {
source: 'admin', ipAddress: req.ip, severity: 'info',
});
}

res.json({ ok: true });
} catch (err) {
res.status(500).json({ ok: false, error: req.t('common.error') });
Expand Down
4 changes: 2 additions & 2 deletions src/routes/api/smtp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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') });
Expand All @@ -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') }); });
});
Expand Down
6 changes: 4 additions & 2 deletions src/services/email.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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);
}
Expand Down
Loading
Loading