mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user