diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index d09689857..35278b50e 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -34,6 +34,7 @@ function App() { // Simple auth state const [isLoading, setIsLoading] = createSignal(true); const [needsAuth, setNeedsAuth] = createSignal(false); + const [hasAuth, setHasAuth] = createSignal(false); // Don't initialize WebSocket until after auth check const [wsStore, setWsStore] = createSignal(null); @@ -97,6 +98,16 @@ function App() { // Check auth on mount onMount(() => { + // First check security status to see if auth is configured + fetch('/api/security/status') + .then(res => res.json()) + .then(data => { + setHasAuth(data.hasAuthentication || false); + }) + .catch(() => { + setHasAuth(false); + }); + fetch('/api/state', { headers: { 'X-Requested-With': 'XMLHttpRequest', @@ -131,6 +142,30 @@ function App() { const handleLogin = () => { window.location.reload(); }; + + const handleLogout = async () => { + try { + // Clear any session data + await fetch('/api/logout', { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest', + }, + credentials: 'include' + }); + } catch (error) { + console.error('Logout error:', error); + } + + // Clear WebSocket connection + setWsStore(null); + + // Set auth required + setNeedsAuth(true); + + // Reload to clear any cached data + window.location.reload(); + }; // Pass through the store directly (only when initialized) const enhancedStore = () => wsStore(); @@ -211,6 +246,18 @@ function App() { {connected() ? 'Connected' : reconnecting() ? 'Reconnecting...' : 'Disconnected'} + + + diff --git a/internal/api/router.go b/internal/api/router.go index c13c72f8b..1f9a66b53 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -191,6 +191,7 @@ func (r *Router) setupRoutes() { // Security routes r.mux.HandleFunc("/api/security/change-password", r.handleChangePassword) r.mux.HandleFunc("/api/security/remove-password", r.handleRemovePassword) + r.mux.HandleFunc("/api/logout", r.handleLogout) r.mux.HandleFunc("/api/security/status", func(w http.ResponseWriter, req *http.Request) { if req.Method == http.MethodGet { w.Header().Set("Content-Type", "application/json") @@ -1119,6 +1120,59 @@ func (r *Router) handleRemovePassword(w http.ResponseWriter, req *http.Request) }) } +// handleLogout handles logout requests +func (r *Router) handleLogout(w http.ResponseWriter, req *http.Request) { + if req.Method != http.MethodPost { + writeErrorResponse(w, http.StatusMethodNotAllowed, "method_not_allowed", + "Only POST method is allowed", nil) + return + } + + // Get session token from cookie + var sessionToken string + if cookie, err := req.Cookie("pulse_session"); err == nil { + sessionToken = cookie.Value + } + + // Delete the session if it exists + if sessionToken != "" { + sessionMu.Lock() + delete(sessions, sessionToken) + sessionMu.Unlock() + + // Also delete CSRF token if exists + csrfMu.Lock() + delete(csrfTokens, sessionToken) + csrfMu.Unlock() + } + + // Clear the session cookie + http.SetCookie(w, &http.Cookie{ + Name: "pulse_session", + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: req.TLS != nil || req.Header.Get("X-Forwarded-Proto") == "https", + SameSite: http.SameSiteStrictMode, + }) + + // Audit log logout (use admin as username since we have single user for now) + LogAuditEvent("logout", "admin", GetClientIP(req), req.URL.Path, true, "User logged out") + + log.Info(). + Str("user", "admin"). + Str("ip", GetClientIP(req)). + Msg("User logged out") + + // Return success + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": "Successfully logged out", + }) +} + // handleState handles state requests func (r *Router) handleState(w http.ResponseWriter, req *http.Request) { if req.Method != http.MethodGet {