webui: auto-logout with configurable inactivity timeout

Adds a per-user inactivity countdown that POSTs /logout after a
configurable timeout (default 15 min, persisted in localStorage)
and navigates to /login. Background pollers (watchdog, *counters
refresh) are excluded from the activity reset so they don't keep
the session alive indefinitely. htmx:responseError on 401 short-
circuits straight to /login so a server-side expiration doesn't
leave the UI failing silently.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-15 19:45:16 +02:00
parent 9720c7000f
commit f8432f59fb
4 changed files with 124 additions and 1 deletions
+4
View File
@@ -161,6 +161,10 @@ func isPollingPath(path string) bool {
func deny(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("HX-Request") == "true" {
// HX-Redirect is honored by HTMX even on a 401 response, so
// the page navigates to /login instead of leaving the user
// staring at a stale screen where every click silently fails.
w.Header().Set("HX-Redirect", "/login")
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
+2 -1
View File
@@ -1860,7 +1860,8 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); }
/* Theme checkmark — hidden by default, shown on active option */
.theme-check { margin-left: auto; flex-shrink: 0; opacity: 0; transition: opacity 0.1s; }
.theme-opt.is-active .theme-check { opacity: 1; }
.theme-opt.is-active .theme-check,
.timeout-opt.is-active .theme-check { opacity: 1; }
/* Login page floating theme button */
.login-theme-btn {
+96
View File
@@ -1653,3 +1653,99 @@ function renderCfgLog() {
}
});
})();
// ─── Inactivity auto-logout ────────────────────────────────────────────────
// Submits POST /logout after a configurable period of inactivity.
// The chosen timeout is stored in localStorage; default is 15 min.
// Reset by deliberate user input and by HTMX requests that aren't
// background pollers.
(function() {
var LS_KEY = 'auto-logout';
var DEFAULT = '900';
var timerId = null;
function getMs() {
var n = parseInt(localStorage.getItem(LS_KEY) || DEFAULT, 10);
return isNaN(n) ? parseInt(DEFAULT, 10) * 1000 : n * 1000;
}
function updateOpts() {
var current = localStorage.getItem(LS_KEY) || DEFAULT;
document.querySelectorAll('.timeout-opt').forEach(function(btn) {
btn.classList.toggle('is-active', btn.getAttribute('data-timeout') === current);
});
}
function doLogout() {
// Tear down the server-side session in the background so it can't
// outlive the navigation. keepalive lets the request finish even
// if the user is mid-tab-close. getCSRFToken() from the early IIFE
// isn't in scope here — read the meta tag directly.
var fd = new FormData();
var meta = document.querySelector('meta[name="csrf-token"]');
if (meta) fd.append('csrf', meta.getAttribute('content') || '');
fetch('/logout', {
method: 'POST',
body: fd,
credentials: 'same-origin',
keepalive: true,
}).catch(function () {});
window.location.replace('/login');
}
function reset() {
clearTimeout(timerId);
var ms = getMs();
if (ms > 0) timerId = setTimeout(doLogout, ms);
}
// Background pollers (watchdog, *counters refresh) must NOT extend
// the idle window — otherwise the timer never reaches the threshold.
function isPollingPath(p) {
return p === '/device-status' || (p && p.indexOf('/counters') !== -1);
}
document.addEventListener('DOMContentLoaded', function() {
if (document.querySelector('form[action="/login"]')) return;
updateOpts();
reset();
// mousemove deliberately omitted: trackpad jitter from a neighbouring
// window would keep the session alive indefinitely.
['mousedown', 'keypress', 'touchstart', 'scroll', 'click'].forEach(function(ev) {
window.addEventListener(ev, reset, { passive: true });
});
document.addEventListener('htmx:afterRequest', function(evt) {
var cfg = evt && evt.detail && evt.detail.requestConfig;
if (cfg && isPollingPath(cfg.path)) return;
reset();
});
document.addEventListener('click', function(e) {
var btn = e.target.closest('.timeout-opt');
if (!btn) return;
localStorage.setItem(LS_KEY, btn.getAttribute('data-timeout'));
updateOpts();
reset();
});
// Session expired server-side while we were idle: any HX request
// now returns 401. Send the user to /login so the page reflects
// reality instead of failing silently on every click.
document.addEventListener('htmx:responseError', function(evt) {
var s = evt && evt.detail && evt.detail.xhr && evt.detail.xhr.status;
if (s === 401) window.location.replace('/login');
});
// Route the manual Logout button through doLogout so the session is
// torn down server-side before navigation, not after.
var logoutForm = document.querySelector('form[action="/logout"]');
if (logoutForm) {
logoutForm.addEventListener('submit', function(e) {
e.preventDefault();
doLogout();
});
}
});
})();
+22
View File
@@ -56,6 +56,28 @@
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</button>
<div class="dropdown-divider"></div>
<div class="dropdown-section-label">Auto-logout</div>
<button class="dropdown-item timeout-opt" data-timeout="300">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><polyline points="12 7 12 12 15 15"></polyline></svg>
5 min
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</button>
<button class="dropdown-item timeout-opt" data-timeout="900">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><polyline points="12 7 12 12 15 15"></polyline></svg>
15 min
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</button>
<button class="dropdown-item timeout-opt" data-timeout="1800">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><polyline points="12 7 12 12 15 15"></polyline></svg>
30 min
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</button>
<button class="dropdown-item timeout-opt" data-timeout="0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"></circle><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line></svg>
Off
<svg class="theme-check" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
</button>
<div class="dropdown-divider"></div>
<form method="POST" action="/logout">
<input type="hidden" name="csrf" value="{{.CsrfToken}}">
<button type="submit" class="dropdown-item dropdown-item-danger">