feat(web): reusable flash/notification banner (PRG)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user