From 6aa4cf785fab92ad73329b158425ed880226a9ff Mon Sep 17 00:00:00 2001 From: Guillaume LEGRAIN Date: Sun, 26 Jul 2026 16:36:38 +0200 Subject: [PATCH 1/4] fix(webui): transient 503s kick authenticated users to the login page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleAuthError() treated HTTP 503 the same as an expired session (401) and navigated the whole page to /login. But 503 is never an auth signal in this app — it's emitted for transient infra states: webui cache not yet warmed after a restart, or the backend being temporarily down. Every webui deploy was logging out open dashboards, and backend blips during an incident bounced on-call engineers to a login form. Restrict the redirect to response.status === 401, matching the global fetch interceptor which already got this right. Call sites keep their existing error handling (retain current data, retry next poll cycle). Generated-By: looper 0.11.0 (runner=worker, agent=claude-code) --- internal/webui/templates/scripts/dashboard_core.templ | 5 +++-- internal/webui/templates/scripts/dashboard_core_templ.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/webui/templates/scripts/dashboard_core.templ b/internal/webui/templates/scripts/dashboard_core.templ index 9f25e7f..6836538 100644 --- a/internal/webui/templates/scripts/dashboard_core.templ +++ b/internal/webui/templates/scripts/dashboard_core.templ @@ -258,8 +258,9 @@ templ DashboardCore() { // Check if response indicates authentication failure handleAuthError(response) { - // Redirect to login if unauthorized or service unavailable - if (response.status === 401 || response.status === 503) { + // Redirect to login only on real auth failure; 503 is a transient + // infra state (cache warm-up, backend restart), not an auth signal. + if (response.status === 401) { window.location.href = '/login'; return true; } diff --git a/internal/webui/templates/scripts/dashboard_core_templ.go b/internal/webui/templates/scripts/dashboard_core_templ.go index 1ffa376..c65aa40 100644 --- a/internal/webui/templates/scripts/dashboard_core_templ.go +++ b/internal/webui/templates/scripts/dashboard_core_templ.go @@ -29,7 +29,7 @@ func DashboardCore() templ.Component { templ_7745c5c3_Var1 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From 0d9e3f8af02b0f9de9ce2cbe5fd6dac211266e09 Mon Sep 17 00:00:00 2001 From: Guillaume LEGRAIN Date: Sun, 26 Jul 2026 17:30:36 +0200 Subject: [PATCH 2/4] fix(webui): don't 401+clear-session when the backend is merely unreachable RequireAuth() treated every ValidateSession() error as an invalid session, clearing it and returning 401. During a backend outage the gRPC call fails with a transport error (codes.Unavailable/DeadlineExceeded), not a real auth rejection, so on-call engineers got bounced to /login and then couldn't log back in while the backend was still down. Add client.IsUnavailableError() to distinguish transport failures from genuine invalid-session responses, and return 503 without clearing the session for the former. Generated-By: looper 0.11.0 (runner=fixer, agent=claude-code) --- internal/webui/client/backend_client.go | 22 +++++++++++++++++ internal/webui/client/backend_client_test.go | 25 ++++++++++++++++++++ internal/webui/middleware/auth.go | 6 +++++ 3 files changed, 53 insertions(+) diff --git a/internal/webui/client/backend_client.go b/internal/webui/client/backend_client.go index 61ca70a..fcee267 100644 --- a/internal/webui/client/backend_client.go +++ b/internal/webui/client/backend_client.go @@ -11,6 +11,9 @@ import ( "google.golang.org/grpc/credentials/insecure" "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + alertpb "notificator/internal/backend/proto/alert" authpb "notificator/internal/backend/proto/auth" "notificator/internal/webui/models" @@ -71,6 +74,25 @@ func (c *BackendClient) IsConnected() bool { return c.conn != nil && c.authClient != nil && c.statisticsClient != nil } +// IsUnavailableError reports whether err comes from being unable to reach the +// backend (transport down, timed out dialing) rather than the backend +// rejecting the request (e.g. an invalid or expired session). +func IsUnavailableError(err error) bool { + if err == nil { + return false + } + st, ok := status.FromError(err) + if !ok { + return false + } + switch st.Code() { + case codes.Unavailable, codes.DeadlineExceeded: + return true + default: + return false + } +} + func (c *BackendClient) HealthCheck() error { if !c.IsConnected() { return fmt.Errorf("not connected to backend") diff --git a/internal/webui/client/backend_client_test.go b/internal/webui/client/backend_client_test.go index 38280af..1339272 100644 --- a/internal/webui/client/backend_client_test.go +++ b/internal/webui/client/backend_client_test.go @@ -2,11 +2,14 @@ package client import ( "context" + "errors" "testing" alertpb "notificator/internal/backend/proto/alert" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // fakeAlertClient embeds the real interface (nil) so it satisfies @@ -92,3 +95,25 @@ func TestDeleteComment_Success(t *testing.T) { t.Fatalf("unexpected error: %v", err) } } + +func TestIsUnavailableError(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"unavailable", status.Error(codes.Unavailable, "backend down"), true}, + {"deadline exceeded", status.Error(codes.DeadlineExceeded, "timeout"), true}, + {"invalid session (plain error)", errors.New("invalid session"), false}, + {"unauthenticated grpc status", status.Error(codes.Unauthenticated, "invalid session"), false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := IsUnavailableError(tc.err); got != tc.want { + t.Errorf("IsUnavailableError(%v) = %v, want %v", tc.err, got, tc.want) + } + }) + } +} diff --git a/internal/webui/middleware/auth.go b/internal/webui/middleware/auth.go index 7d5fc58..f9d0003 100644 --- a/internal/webui/middleware/auth.go +++ b/internal/webui/middleware/auth.go @@ -37,6 +37,12 @@ func (am *AuthMiddleware) RequireAuth() gin.HandlerFunc { // Validate session with backend user, err := am.backendClient.ValidateSession(sessionID) if err != nil { + if client.IsUnavailableError(err) { + // Backend is unreachable, not a bad session - keep the session intact + c.JSON(http.StatusServiceUnavailable, models.ErrorResponse("Authentication service unavailable")) + c.Abort() + return + } // Session is invalid, clear it ClearSession(c) c.JSON(http.StatusUnauthorized, models.ErrorResponse("Invalid or expired session")) From 080178dfd9ef66c27a1e21dbcf9a1bf7ef131a48 Mon Sep 17 00:00:00 2001 From: Guillaume LEGRAIN Date: Thu, 30 Jul 2026 15:00:34 +0200 Subject: [PATCH 3/4] fix(webui): don't clear session on backend-unavailable in page middleware RedirectIfNotAuth() and OptionalAuth() cleared the session cookie on any ValidateSession error, including transient backend-unavailable errors, reproducing the same login-bounce bug RequireAuth() already guards against. Generated-By: looper 0.11.1 (runner=fixer, agent=claude-code) --- internal/webui/middleware/auth.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/webui/middleware/auth.go b/internal/webui/middleware/auth.go index f9d0003..081ca23 100644 --- a/internal/webui/middleware/auth.go +++ b/internal/webui/middleware/auth.go @@ -72,6 +72,8 @@ func (am *AuthMiddleware) OptionalAuth() gin.HandlerFunc { if err == nil && user != nil { c.Set("user", user) c.Set("session_id", sessionID) + } else if err != nil && client.IsUnavailableError(err) { + // Backend is unreachable, not a bad session - keep the session intact } else { // Clear invalid session ClearSession(c) @@ -99,6 +101,12 @@ func (am *AuthMiddleware) RedirectIfNotAuth(redirectTo string) gin.HandlerFunc { // Validate session with backend user, err := am.backendClient.ValidateSession(sessionID) if err != nil { + if client.IsUnavailableError(err) { + // Backend is unreachable, not a bad session - keep the session intact + c.String(http.StatusServiceUnavailable, "Service temporarily unavailable") + c.Abort() + return + } // Session is invalid, clear it and redirect ClearSession(c) c.Redirect(http.StatusFound, redirectTo) From e612e6ca5cb374fc5202f5439451cc3954bd3da4 Mon Sep 17 00:00:00 2001 From: Guillaume LEGRAIN Date: Fri, 31 Jul 2026 09:27:36 +0200 Subject: [PATCH 4/4] fix(webui): don't clear session on backend-unavailable in RedirectIfAuth RedirectIfAuth() guards /login and /register but still cleared the session cookie on any ValidateSession error, including transient codes.Unavailable/DeadlineExceeded transport errors. Mirror the guard already added to RedirectIfNotAuth(): fall through without clearing the session when the backend is merely unreachable. Generated-By: looper 0.11.1 (runner=fixer, agent=claude-code) --- internal/webui/middleware/auth.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/webui/middleware/auth.go b/internal/webui/middleware/auth.go index 081ca23..fa7889b 100644 --- a/internal/webui/middleware/auth.go +++ b/internal/webui/middleware/auth.go @@ -138,6 +138,10 @@ func (am *AuthMiddleware) RedirectIfAuth(redirectTo string) gin.HandlerFunc { c.Redirect(http.StatusFound, redirectTo) c.Abort() return + } else if client.IsUnavailableError(err) { + // Backend is unreachable, not a bad session - keep the session intact + c.Next() + return } else { // Clear invalid session ClearSession(c)