diff --git a/internal/web/connect.go b/internal/web/connect.go index dea5bdc..5d89f87 100644 --- a/internal/web/connect.go +++ b/internal/web/connect.go @@ -134,6 +134,7 @@ func (h *ConnectHandler) handleCallback(w http.ResponseWriter, r *http.Request) return } + setFlash(w, flashConnected) http.Redirect(w, r, "/", http.StatusSeeOther) } diff --git a/internal/web/flash.go b/internal/web/flash.go new file mode 100644 index 0000000..26c3e82 --- /dev/null +++ b/internal/web/flash.go @@ -0,0 +1,54 @@ +package web + +import "net/http" + +// flashCookie carries a one-shot notification code between a POST→redirect and +// the next rendered page (PRG pattern). The value is a non-sensitive code (not +// user data), so it is not signed; HttpOnly + SameSite=Lax + a short MaxAge bound +// it. The flashBanner component maps the code to a styled message. +const flashCookie = "tapir_flash" + +// Flash codes. Kept small and stable — the message + severity live in +// flashMessages (view.go), not here, so the cookie never carries free text. +const ( + flashConnected = "connected" + flashConnectFailed = "connect_failed" + flashDisconnected = "disconnected" + flashDeleted = "deleted" + flashRegistered = "registered" +) + +// flashMaxAge bounds how long an unread flash lingers (seconds). Long enough to +// survive the redirect, short enough that a stale banner never reappears. +const flashMaxAge = 60 + +// setFlash queues a one-shot notification surfaced by the next full page render. +func setFlash(w http.ResponseWriter, code string) { + http.SetCookie(w, &http.Cookie{ + Name: flashCookie, + Value: code, + Path: "/", + MaxAge: flashMaxAge, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// takeFlash returns the pending flash code (if any) and clears the cookie so the +// banner shows exactly once. Call it only on full-page renders, not HTMX +// fragments, so a fragment swap never consumes a flash meant for the next page. +func takeFlash(w http.ResponseWriter, r *http.Request) string { + c, err := r.Cookie(flashCookie) + if err != nil || c.Value == "" { + return "" + } + http.SetCookie(w, &http.Cookie{ + Name: flashCookie, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) + return c.Value +} diff --git a/internal/web/flash_internal_test.go b/internal/web/flash_internal_test.go new file mode 100644 index 0000000..a3fedd2 --- /dev/null +++ b/internal/web/flash_internal_test.go @@ -0,0 +1,54 @@ +package web + +import ( + "context" + "strings" + "testing" +) + +// TestFlashBannerRendersEachKind proves the reusable notification component +// renders a banner with the right message and severity class for every flash +// code, and renders nothing for an empty or unknown (e.g. forged) code. +func TestFlashBannerRendersEachKind(t *testing.T) { + cases := []struct { + code string + wantText string + wantKind string + }{ + {flashConnected, "YouTube account connected", "flash-success"}, + {flashConnectFailed, "Could not connect", "flash-error"}, + {flashDisconnected, "Account disconnected", "flash-success"}, + {flashDeleted, "account and all its data were deleted", "flash-success"}, + {flashRegistered, "Welcome to Tapir", "flash-success"}, + } + for _, tc := range cases { + t.Run(tc.code, func(t *testing.T) { + var sb strings.Builder + if err := flashBanner(tc.code).Render(context.Background(), &sb); err != nil { + t.Fatalf("render: %v", err) + } + got := sb.String() + if !strings.Contains(got, tc.wantText) { + t.Errorf("banner %q = %q, want it to contain %q", tc.code, got, tc.wantText) + } + if !strings.Contains(got, tc.wantKind) { + t.Errorf("banner %q = %q, want severity class %q", tc.code, got, tc.wantKind) + } + if !strings.Contains(got, `role="status"`) { + t.Errorf("banner %q must carry role=status for assistive tech, got %q", tc.code, got) + } + }) + } +} + +func TestFlashBannerRendersNothingForUnknownCode(t *testing.T) { + for _, code := range []string{"", "bogus", "