diff --git a/.samples/original-broken-layout.png b/.samples/original-broken-layout.png new file mode 100644 index 0000000..05c2b4c Binary files /dev/null and b/.samples/original-broken-layout.png differ diff --git a/.samples/same-session-openedasnew-correct-layout.png b/.samples/same-session-openedasnew-correct-layout.png new file mode 100644 index 0000000..aced977 Binary files /dev/null and b/.samples/same-session-openedasnew-correct-layout.png differ diff --git a/README.md b/README.md index 2b55d57..69f823b 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,18 @@ journalctl -u gotermix -f --- +## Session & logout + +- Auth cookie TTL is **30 minutes** from last activity. +- While the browser tab is open and you are active, the session extends automatically (heartbeat every 5 minutes). +- After **30 minutes of inactivity** the page signs you out automatically. +- The **Logout** button in the toolbar signs you out immediately — the workspace URL stays valid and all terminals resume after you sign in again. +- The **End** button destroys the saved layout (fresh session on next visit). + +> Cookie expiry and inactivity timeout are independent: the cookie expires 30 min after the last heartbeat; the JS inactivity timer fires 30 min after the last mouse/keyboard event. Closing the browser tab stops heartbeats; reopening after >30 min will redirect to login. + +--- + ![Login Page](.samples/login-page.png "Login Page") ![Terminal](.samples/terminal.png "Terminal") diff --git a/internals/auth.go b/internals/auth.go index 8849abd..9f36b11 100644 --- a/internals/auth.go +++ b/internals/auth.go @@ -92,3 +92,24 @@ func isAuthed(r *http.Request) bool { c, err := r.Cookie(authCookieName) return err == nil && validAuthToken(c.Value) } + +// refreshAuthCookie issues a fresh auth cookie, extending the session TTL +// (sliding window). No-op in -nopw mode or if current token is already invalid. +func refreshAuthCookie(w http.ResponseWriter, r *http.Request) { + if nopwMode { + return + } + c, err := r.Cookie(authCookieName) + if err != nil || !validAuthToken(c.Value) { + return + } + http.SetCookie(w, &http.Cookie{ + Name: authCookieName, + Value: makeAuthToken(), + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(authTokenTTL.Seconds()), + }) +} diff --git a/internals/config.go b/internals/config.go index 61bd297..ee9ee3f 100644 --- a/internals/config.go +++ b/internals/config.go @@ -15,7 +15,7 @@ const ( sessionTTL = 24 * time.Hour authCookieName = "gws_auth" csrfCookieName = "gws_csrf" - authTokenTTL = 12 * time.Hour + authTokenTTL = 30 * time.Minute credsFilename = "gws-creds.json" defaultUser = "ivor" defaultPass = "Silv3rSw0rd!" diff --git a/internals/handlers.go b/internals/handlers.go index d74230c..17ae93b 100644 --- a/internals/handlers.go +++ b/internals/handlers.go @@ -210,6 +210,47 @@ func handleAuth(w http.ResponseWriter, r *http.Request) { w.Write([]byte(`{"ok":true}`)) //nolint:errcheck } +// handleLogout clears the auth cookie without destroying the workspace. +// The terminal URL remains valid — user re-authenticates to resume. +func handleLogout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + http.SetCookie(w, &http.Cookie{ + Name: authCookieName, + Value: "", + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: -1, + }) + next := r.FormValue("next") + if !isValidNext(next) { + next = "/" + } + http.Redirect(w, r, "/login?next="+next, http.StatusSeeOther) +} + +// handleRefresh extends the auth cookie TTL for an active authenticated session. +// Called by the frontend heartbeat every 5 minutes. +func handleRefresh(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + w.Write([]byte(`{"error":"POST only"}`)) //nolint:errcheck + return + } + if !isAuthed(r) { + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"error":"unauthorized"}`)) //nolint:errcheck + return + } + refreshAuthCookie(w, r) + w.Write([]byte(`{"ok":true}`)) //nolint:errcheck +} + func handleWS(w http.ResponseWriter, r *http.Request) { if !isAuthed(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) diff --git a/internals/server.go b/internals/server.go index 08ffad6..a194a45 100644 --- a/internals/server.go +++ b/internals/server.go @@ -205,6 +205,8 @@ func Run() { mux.HandleFunc("/static/app.css", handleStaticCSS) mux.HandleFunc("/static/app.js", handleStaticJS) mux.HandleFunc("/api/workspace/", handleWorkspaceAPI) + mux.HandleFunc("/logout", handleLogout) + mux.HandleFunc("/api/refresh", handleRefresh) ln, _ := net.Listen("tcp", *addr) fmt.Printf("Go Web Shell https://%s\n", *addr) diff --git a/internals/web/app.css b/internals/web/app.css index 38b12a4..c4215ad 100644 --- a/internals/web/app.css +++ b/internals/web/app.css @@ -152,6 +152,9 @@ html, body { height: 100%; background: #0d0f14; color: #e2e8f0; .tb-btn.tb-info { background: rgba(59,130,246,0.1); color: #93c5fd; border: 1px solid rgba(59,130,246,0.2); } .tb-btn.tb-info:hover { background: rgba(59,130,246,0.18); color: #bfdbfe; } +.tb-btn.tb-logout { background: rgba(245,158,11,0.08); color: #fbbf24; + border: 1px solid rgba(245,158,11,0.15); } +.tb-btn.tb-logout:hover { background: rgba(245,158,11,0.15); color: #fcd34d; } .tb-sep { width: 1px; height: 20px; background: rgba(255,255,255,0.07); flex-shrink: 0; margin: 0 2px; } /* ── Toast ── */ diff --git a/internals/web/app.js b/internals/web/app.js index d17a317..cf90e2b 100644 --- a/internals/web/app.js +++ b/internals/web/app.js @@ -379,6 +379,9 @@ function splitPane(dir) { // replaceNode uses leaf.parent (old parent) — set new parents AFTER replaceNode(tab, leaf, splitNode); + // Re-apply grandparent's flex so the new splitNode.el inherits correct sizing. + // Without this, the new container has no flex rule and collapses visually. + if (splitNode.parent) applyFlex(splitNode.parent); leaf.parent = splitNode; newLeaf.parent = splitNode; @@ -439,6 +442,8 @@ function closePane() { replaceNode(tab, splitNode, sibling); sibling.el.removeAttribute('style'); + // Re-apply grandparent's flex so sibling gets correct sizing in its new position. + if (sibling.parent) applyFlex(sibling.parent); const next = findFirstLeaf(sibling); setActiveLeaf(tab, next); @@ -708,6 +713,57 @@ function modalFb(id, msg, type) { el.className = 'm-fb' + (type ? ' ' + type : ''); } +// ── Logout & inactivity ──────────────────────────────────────────────── +const INACTIVITY_MS = 30 * 60 * 1000; // 30 min inactivity → auto-logout +const HEARTBEAT_MS = 5 * 60 * 1000; // 5 min heartbeat → extend server cookie + +let _inactTimer = null; +let _hbTimer = null; + +function _resetInactivity() { + clearTimeout(_inactTimer); + _inactTimer = setTimeout(doLogout, INACTIVITY_MS); +} + +function _startHeartbeat() { + clearInterval(_hbTimer); + _hbTimer = setInterval(_sendHeartbeat, HEARTBEAT_MS); +} + +async function _sendHeartbeat() { + try { + const res = await fetch('/api/refresh', { method: 'POST' }); + if (res.status === 401) { + // Cookie already expired server-side — stop timers, go to login + clearInterval(_hbTimer); + clearTimeout(_inactTimer); + window.location.href = '/login?next=/s/' + WORKSPACE_ID; + } + } catch (_) {} +} + +async function doLogout() { + clearTimeout(_inactTimer); + clearInterval(_hbTimer); + try { + const form = new URLSearchParams(); + form.append('next', '/s/' + WORKSPACE_ID); + await fetch('/logout', { + method: 'POST', + body: form, + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + }); + } catch (_) {} + window.location.href = '/login?next=/s/' + WORKSPACE_ID; +} + +// Reset inactivity timer on any user interaction. +['mousemove', 'keydown', 'touchstart', 'click'].forEach(ev => + document.addEventListener(ev, _resetInactivity, { passive: true }) +); +_resetInactivity(); +_startHeartbeat(); + // ── Bootstrap ────────────────────────────────────────────────────────── // Terminal page is only served to authenticated users (server redirects // unauthenticated requests to /login). Load saved workspace or start fresh. diff --git a/internals/web/shell.html b/internals/web/shell.html index eb2b7b3..2775df8 100644 --- a/internals/web/shell.html +++ b/internals/web/shell.html @@ -148,6 +148,14 @@ End + +