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