diff --git a/src/webui/internal/auth/login.go b/src/webui/internal/auth/login.go index 733445a4..75b61f67 100644 --- a/src/webui/internal/auth/login.go +++ b/src/webui/internal/auth/login.go @@ -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 diff --git a/src/webui/internal/auth/store.go b/src/webui/internal/auth/store.go index a7231217..ef7b9109 100644 --- a/src/webui/internal/auth/store.go +++ b/src/webui/internal/auth/store.go @@ -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) } } diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index c565c13c..63e54b0e 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -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) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 37e24315..880bb59a 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -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; diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 6b5da539..65f5716e 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -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 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