webui: user-menu Auto-logout and theme fixes

Auto-logout was client-side only while the server kept a fixed 1h idle
timeout, so "Off" still logged you out. Sessions now carry a per-session
timeout the client syncs from the menu (0 = never), capped at 24h.

Theme: the login toggle icon was never initialised on load (stuck on the
'auto' glyph); add color-scheme so native controls follow the OS on
'auto', and re-apply on OS theme change.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-15 21:02:15 +02:00
parent d326939e10
commit 7e0e6f244d
5 changed files with 80 additions and 6 deletions
+20
View File
@@ -8,6 +8,8 @@ import (
"html/template"
"log"
"net/http"
"strconv"
"time"
"infix/webui/internal/handlers"
"infix/webui/internal/restconf"
@@ -129,6 +131,24 @@ func (h *LoginHandler) DoLogout(w http.ResponseWriter, r *http.Request) {
fullRedirect(w, r, "/login")
}
// SetSessionTimeout updates the current session's idle timeout from the
// client's Auto-logout menu. The "timeout" form value is seconds; 0 means
// never expire ("Off"). The store caps the upper bound.
func (h *LoginHandler) SetSessionTimeout(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(cookieName)
if err != nil {
http.Error(w, "no session", http.StatusUnauthorized)
return
}
secs, err := strconv.Atoi(r.FormValue("timeout"))
if err != nil || secs < 0 {
http.Error(w, "bad timeout", http.StatusBadRequest)
return
}
h.Store.SetTimeout(c.Value, time.Duration(secs)*time.Second)
w.WriteHeader(http.StatusNoContent)
}
// fullRedirect forces a full page navigation. When the request comes
// from htmx (boosted form) we use HX-Redirect so the browser does a
// real page load instead of an AJAX swap — this is essential for the
+28 -4
View File
@@ -11,6 +11,7 @@ import (
)
const sessionTimeout = 1 * time.Hour
const maxSessionTimeout = 24 * time.Hour
// sessionEntry is the in-memory state of a single authenticated
// session. The user's password is kept alongside the token so the
@@ -22,6 +23,13 @@ type sessionEntry struct {
csrfToken string
features map[string]bool
lastSeenAt time.Time
timeout time.Duration // idle timeout; 0 = never expire ("Off")
}
// expired reports whether an entry has passed its idle timeout. A zero
// timeout means the session never idle-expires — the user chose "Off".
func expired(e *sessionEntry) bool {
return e.timeout > 0 && time.Since(e.lastSeenAt) > e.timeout
}
// SessionStore issues opaque random session tokens backed by an
@@ -62,6 +70,7 @@ func (s *SessionStore) Create(username, password string, features map[string]boo
csrfToken: csrf,
features: features,
lastSeenAt: time.Now(),
timeout: sessionTimeout,
}
s.mu.Unlock()
return token, csrf, nil
@@ -73,7 +82,7 @@ func (s *SessionStore) Create(username, password string, features map[string]boo
func (s *SessionStore) Lookup(token string) (username, password, csrf string, features map[string]bool, ok bool) {
s.mu.RLock()
e, present := s.sessions[token]
if present && time.Since(e.lastSeenAt) <= sessionTimeout {
if present && !expired(e) {
username, password, csrf, features = e.username, e.password, e.csrfToken, e.features
s.mu.RUnlock()
return username, password, csrf, features, true
@@ -86,7 +95,7 @@ func (s *SessionStore) Lookup(token string) (username, password, csrf string, fe
// expired.
if present {
s.mu.Lock()
if e, ok := s.sessions[token]; ok && time.Since(e.lastSeenAt) > sessionTimeout {
if e, ok := s.sessions[token]; ok && expired(e) {
delete(s.sessions, token)
}
s.mu.Unlock()
@@ -94,6 +103,22 @@ func (s *SessionStore) Lookup(token string) (username, password, csrf string, fe
return "", "", "", nil, false
}
// SetTimeout updates a session's idle timeout (0 = never expire) so the
// server-side expiry tracks the client's Auto-logout menu. Out-of-range
// values — including a negative from integer overflow on a garbage request —
// are capped at maxSessionTimeout so a session can't be pinned open
// indefinitely; 0 still means never. No-op if the token is unknown.
func (s *SessionStore) SetTimeout(token string, d time.Duration) {
if d < 0 || d > maxSessionTimeout {
d = maxSessionTimeout
}
s.mu.Lock()
if e, ok := s.sessions[token]; ok {
e.timeout = d
}
s.mu.Unlock()
}
// Refresh extends a session's lifetime by resetting its last-seen
// timestamp. Called by the auth middleware on each user-driven
// request so an active session doesn't expire mid-use. No-op if the
@@ -118,10 +143,9 @@ func (s *SessionStore) janitor() {
t := time.NewTicker(1 * time.Minute)
defer t.Stop()
for range t.C {
cutoff := time.Now().Add(-sessionTimeout)
s.mu.Lock()
for token, e := range s.sessions {
if e.lastSeenAt.Before(cutoff) {
if expired(e) {
delete(s.sessions, token)
}
}
+1
View File
@@ -297,6 +297,7 @@ func New(
mux.HandleFunc("GET /login", login.ShowLogin)
mux.HandleFunc("POST /login", login.DoLogin)
mux.HandleFunc("POST /logout", login.DoLogout)
mux.HandleFunc("POST /session/timeout", login.SetSessionTimeout)
// Static assets (public).
staticServer := http.FileServerFS(staticFS)
+4
View File
@@ -3,6 +3,10 @@
========================================================================== */
:root {
/* Native controls (scrollbars, inputs, date pickers) follow the OS
light/dark preference while on 'auto'; .dark/.light pin it explicitly. */
color-scheme: light dark;
/* --- Primitives: Slate (Neutral) --- */
--slate-50: #f8fafc;
--slate-100: #f1f5f9;
+27 -2
View File
@@ -1387,9 +1387,20 @@
// Apply saved theme immediately (before DOMContentLoaded to avoid flash)
applyTheme(getTheme());
// Follow the OS when on 'auto'. Colours already track via CSS @media +
// color-scheme; re-applying on change keeps the menu indicators and any
// theme-derived state in sync. Ignored when a theme is pinned explicitly.
try {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
if (!getTheme()) applyTheme(null);
});
} catch (_) {}
document.addEventListener('DOMContentLoaded', function() {
// Apply checkmarks now that DOM is ready
updateDropdownCheck(getTheme() || 'auto');
// Re-apply now that the DOM exists: applyTheme() ran in <head> before the
// dropdown and login toggle were parsed, so their indicators weren't set
// (the login toggle was stuck on its default 'auto' glyph).
applyTheme(getTheme());
// Dropdown theme options (main app)
document.querySelectorAll('.theme-opt').forEach(function(btn) {
@@ -2303,6 +2314,18 @@ function renderCfgLog() {
if (ms > 0) timerId = setTimeout(doLogout, ms);
}
// Mirror the chosen idle timeout to the server so the server-side session
// expiry matches the menu — including "Off" (0 = never). The server starts
// each session at its 1 h default; this re-asserts the user's preference on
// load and whenever they change it.
function syncServerTimeout(secs) {
var fd = new FormData();
fd.append('timeout', secs);
var meta = document.querySelector('meta[name="csrf-token"]');
if (meta) fd.append('csrf', meta.getAttribute('content') || '');
fetch('/session/timeout', { method: 'POST', body: fd, credentials: 'same-origin' }).catch(function () {});
}
// Background pollers (watchdog, *counters refresh) must NOT extend
// the idle window — otherwise the timer never reaches the threshold.
function isPollingPath(p) {
@@ -2314,6 +2337,7 @@ function renderCfgLog() {
updateOpts();
reset();
syncServerTimeout(localStorage.getItem(LS_KEY) || DEFAULT);
// mousemove deliberately omitted: trackpad jitter from a neighbouring
// window would keep the session alive indefinitely.
@@ -2332,6 +2356,7 @@ function renderCfgLog() {
localStorage.setItem(LS_KEY, btn.getAttribute('data-timeout'));
updateOpts();
reset();
syncServerTimeout(btn.getAttribute('data-timeout'));
});
// Session expired server-side while we were idle: any HX request