From 2fe4833434deda840055da53d1722b9169f46824 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 3 Jun 2026 16:52:03 +0200 Subject: [PATCH] feat(web): reusable flash/notification banner (PRG) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a one-shot flash component used across the app — connect success, disconnect, account delete, and registration — instead of per-page ad-hoc markup. setFlash queues a short-lived HttpOnly+SameSite cookie carrying an opaque code; takeFlash consumes it on the next full-page render (not on HTMX fragments). flashBanner maps the code to a styled, role=status banner; the message text lives server-side in flashMessages so the cookie never carries free text and a forged/unknown code renders nothing. Wire it into the list page (the PRG landing spot for connect/registration) and set it on registration and connect-callback success. Styled with the existing design-system tokens; header gains an Account nav link. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/web/connect.go | 1 + internal/web/flash.go | 54 +++ internal/web/flash_internal_test.go | 54 +++ internal/web/handlers.go | 2 +- internal/web/registration.go | 1 + internal/web/view.go | 34 +- internal/web/views.templ | 22 +- internal/web/views_templ.go | 494 ++++++++++++++++------------ 8 files changed, 448 insertions(+), 214 deletions(-) create mode 100644 internal/web/flash.go create mode 100644 internal/web/flash_internal_test.go 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", "