From f8432f59fb12329d59f7acf7b4352fdaaa2c0ab8 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 28 Apr 2026 14:05:02 +0200 Subject: [PATCH] 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 --- src/webui/internal/server/middleware.go | 4 ++ src/webui/static/css/style.css | 3 +- src/webui/static/js/app.js | 96 +++++++++++++++++++++++++ src/webui/templates/layouts/base.html | 22 ++++++ 4 files changed, 124 insertions(+), 1 deletion(-) diff --git a/src/webui/internal/server/middleware.go b/src/webui/internal/server/middleware.go index be3b4fbd..189ea999 100644 --- a/src/webui/internal/server/middleware.go +++ b/src/webui/internal/server/middleware.go @@ -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 } diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index d203b107..79fd05b4 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -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 { diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index bc0733e8..aa5c2ae7 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -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(); + }); + } + }); +})(); diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html index f2f52795..48df5619 100644 --- a/src/webui/templates/layouts/base.html +++ b/src/webui/templates/layouts/base.html @@ -56,6 +56,28 @@ + + + + + +