xtablo-source/.planning/phases/03-tablos-crud/03-02-PLAN.md

325 lines
26 KiB
Markdown
Raw Normal View History

---
phase: 03-tablos-crud
plan: 02
type: execute
wave: 2
depends_on: [01]
files_modified:
- backend/internal/web/handlers_tablos.go
- backend/templates/tablos.templ
- backend/templates/tablos_templ.go
- backend/templates/layout.templ
- backend/templates/layout_templ.go
- backend/templates/index.templ
- backend/templates/index_templ.go
- backend/internal/web/handlers.go
- backend/internal/web/router.go
- backend/cmd/web/main.go
autonomous: true
requirements:
- TABLO-01
- TABLO-02
- TABLO-06
tags:
- go
- htmx
- templ
- chi
- csrf
must_haves:
truths:
- "Signed-in user visiting GET / sees a dashboard titled 'Your Tablos' listing only their tablos newest-first"
- "Empty state renders 'No tablos yet' + 'Create your first tablo to get started.' when user has zero tablos"
- "Clicking 'New tablo' fetches an inline form into #create-form-slot via hx-get /tablos/new"
- "Submitting a valid POST /tablos via HTMX inserts the row, returns 200 with HX-Retarget: #tablos-list + HX-Reswap: afterbegin, prepends a new TabloCard, and clears #create-form-slot via OOB swap"
- "Submitting POST /tablos without HTMX inserts the row and 303-redirects to /"
- "Empty-title POST returns 422 with 'Title is required.' field error"
artifacts:
- path: "backend/internal/web/handlers_tablos.go"
provides: "TablosDeps, TablosListHandler, TablosNewHandler, TablosCreateHandler"
contains: "TablosCreateHandler"
- path: "backend/templates/tablos.templ"
provides: "TablosDashboard, TablosEmptyState, TabloCard, TabloCreateFormFragment, TabloCardWithOOBFormClear"
contains: "TabloCardWithOOBFormClear"
- path: "backend/internal/web/router.go"
provides: "GET / → TablosListHandler, GET /tablos/new, POST /tablos route registration"
contains: "TablosListHandler"
- path: "backend/cmd/web/main.go"
provides: "Construct TablosDeps and pass to NewRouter"
contains: "TablosDeps{Queries: q}"
key_links:
- from: "backend/internal/web/router.go"
to: "TablosCreateHandler"
via: "r.Post(\"/tablos\", ...)"
pattern: "r\\.Post\\(\"/tablos\""
- from: "backend/templates/tablos.templ"
to: "ui.CSRFField"
via: "@ui.CSRFField(csrfToken) inside create form"
pattern: "ui\\.CSRFField"
- from: "TablosCreateHandler"
to: "Queries.InsertTablo"
via: "sqlc binding"
pattern: "InsertTablo"
---
<objective>
First vertical slice of Phase 3: a signed-in user can SEE their tablos on the dashboard and CREATE a new tablo end-to-end via HTMX, with a graceful non-JS POST fallback. Turns TestTabloList, TestTabloList_Empty, TestTabloCreate, TestTabloCreate_Validation green (TABLO-01, TABLO-02, TABLO-06 partially).
Purpose: Deliver real user-visible value as the first slice — the dashboard renders, the create flow works, and the user can see a brand-new tablo appear without a full reload.
Output:
- handlers_tablos.go with TablosDeps + List/New/Create handlers
- tablos.templ with dashboard page, empty state, card, create form, OOB-clear template
- router.go updated: replace IndexHandler with TablosListHandler; mount /tablos/new and /tablos
- main.go constructs TablosDeps
- layout.templ footer "Phase 3 · Tablos"
- index.templ + IndexHandler removed (or emptied) — dashboard now lives in tablos.templ
</objective>
<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@.planning/phases/03-tablos-crud/03-CONTEXT.md
@.planning/phases/03-tablos-crud/03-RESEARCH.md
@.planning/phases/03-tablos-crud/03-PATTERNS.md
@.planning/phases/03-tablos-crud/03-UI-SPEC.md
@.planning/phases/03-tablos-crud/03-01-SUMMARY.md
@backend/internal/web/handlers_auth.go
@backend/internal/web/router.go
@backend/cmd/web/main.go
@backend/templates/auth_signup.templ
@backend/templates/index.templ
@backend/templates/layout.templ
@backend/internal/web/ui/button.templ
@backend/internal/web/ui/card.templ
@backend/internal/web/ui/variants.go
@backend/internal/db/sqlc/tablos.sql.go
<interfaces>
<!-- Contracts this plan establishes for Plan 03 -->
TablosDeps (declared in backend/internal/web/handlers_tablos.go):
- Queries *sqlc.Queries
Handler constructors (signature pattern matches AuthDeps):
- TablosListHandler(deps TablosDeps) http.HandlerFunc → GET /
- TablosNewHandler(deps TablosDeps) http.HandlerFunc → GET /tablos/new (returns TabloCreateFormFragment)
- TablosCreateHandler(deps TablosDeps) http.HandlerFunc → POST /tablos
NewRouter new signature (replaces existing):
func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler
Templates (callable by Plan 03):
- TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo) — full page
- TablosEmptyState() — empty list state
- TabloCard(t sqlc.Tablo, csrfToken string) — single card on dashboard; wraps in ui.Card with class "tablo-delete-zone" on delete button container
- TabloCreateFormFragment(form TabloCreateForm, errs TabloCreateErrors, csrfToken string) — inline create form
- TabloCardWithOOBFormClear(t sqlc.Tablo, csrfToken string) — card + OOB div clearing #create-form-slot
Form types (declared in backend/templates/tablos.templ or a sibling tablos_forms.go file, mirroring auth_forms.go):
- TabloCreateForm { Title, Description, Color string }
- TabloCreateErrors { Title, General string }
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: tablos.templ — dashboard, empty state, card, create form, OOB-clear (+ generate)</name>
<files>backend/templates/tablos.templ, backend/templates/tablos_templ.go, backend/templates/tablos_forms.go, backend/templates/layout.templ, backend/templates/layout_templ.go</files>
<read_first>
backend/templates/auth_signup.templ
backend/templates/auth_forms.go
backend/templates/auth_form_errors.templ
backend/templates/index.templ
backend/templates/layout.templ
backend/internal/web/ui/card.templ
backend/internal/web/ui/button.templ
backend/internal/web/ui/variants.go
backend/internal/web/ui/csrf_field.templ
backend/internal/db/sqlc/models.go (Tablo struct)
.planning/phases/03-tablos-crud/03-UI-SPEC.md (Component Inventory, Interaction Contracts §1+§2, Copywriting Contract)
.planning/phases/03-tablos-crud/03-PATTERNS.md (tablos.templ analog block + OOB swap template shape)
.planning/phases/03-tablos-crud/03-RESEARCH.md (Pattern 4 dual-target swap, Pitfall 5 top-level OOB)
</read_first>
<behavior>
- TablosDashboard renders inside @Layout("Tablos — Xtablo", user, csrfToken).
- Page heading "Your Tablos" using `text-[28px] font-semibold leading-tight` per UI-SPEC Typography.
- "New tablo" button (ui.ButtonVariantDefault, ui.ButtonToneSolid, ui.SizeMD, Type "button") fires hx-get="/tablos/new", hx-target="#create-form-slot", hx-swap="innerHTML".
- `<div id="create-form-slot"></div>` rendered above `<div id="tablos-list">`.
- When tablos slice is empty → @TablosEmptyState() rendered inside #tablos-list.
- Empty state copy exactly: heading "No tablos yet", body "Create your first tablo to get started.", CTA button "New tablo" with aria-label "Create your first tablo" and same hx-get="/tablos/new" attrs.
- TabloCard renders inside @ui.Card with: title (Heading style `text-xl font-semibold leading-snug`), optional description paragraph only when `tablo.Description.Valid && tablo.Description.String != ""` (Pitfall 6), optional color dot (only when `tablo.Color.Valid && tablo.Color.String != ""`) using `inline-block w-2.5 h-2.5 rounded-full` with inline `style="background-color: {tablo.Color.String}"`, link "View" to `/tablos/{id}`, and a `.tablo-delete-zone` wrapper containing the Delete button. (Plan 03 wires delete-confirm; Plan 02 just needs the zone div + a Delete button stub.)
- TabloCreateFormFragment renders a `<form id="create-form" method="POST" action="/tablos" hx-post="/tablos" hx-target="#create-form-slot" hx-swap="innerHTML">` with @ui.CSRFField(csrfToken), @GeneralError(errs.General), labelled Title input (required), Description textarea (3 rows, optional), Color text input (placeholder "#6366f1 or indigo", optional), submit button "Create tablo", cancel link/button that fires `hx-get="/tablos/new/cancel"` OR simpler: clears the slot via hx-get to an endpoint that returns empty — for Phase 02 use a "Cancel" button with `hx-on:click` not allowed (no JS); use a plain anchor `<a href="/">Cancel</a>` that reloads / and resets the slot.
- Field error rendering uses @FieldError(errs.Title) directly under the Title input.
- TabloCardWithOOBFormClear renders @TabloCard(...) as the primary card AND a top-level sibling `<div id="create-form-slot" hx-swap-oob="true"></div>` (must be siblings, not nested per Pitfall 5).
- layout.templ footer changes from "Phase 2 · Authentication" to "Phase 3 · Tablos".
</behavior>
<action>
Create backend/templates/tablos.templ (package templates) and a sibling backend/templates/tablos_forms.go declaring `type TabloCreateForm struct { Title, Description, Color string }` and `type TabloCreateErrors struct { Title, General string }` (mirrors auth_forms.go).
Implement these templ components per the behavior block:
- TablosDashboard(user *auth.User, csrfToken string, tablos []sqlc.Tablo)
- TablosEmptyState()
- TabloCard(t sqlc.Tablo, csrfToken string) — include `<div class="tablo-delete-zone">` wrapper around the Delete button. The Delete button for now uses `hx-get="/tablos/{id}/delete-confirm"`, `hx-target="closest .tablo-delete-zone"`, `hx-swap="outerHTML"`, Variant ButtonVariantDanger, Tone ButtonToneSoft, Size SizeMD, Type "button". Plan 03 implements /delete-confirm.
- TabloCreateFormFragment(form TabloCreateForm, errs TabloCreateErrors, csrfToken string)
- TabloCardWithOOBFormClear(t sqlc.Tablo, csrfToken string) — emit @TabloCard then a `<div id="create-form-slot" hx-swap-oob="true"></div>` as TOP-LEVEL siblings inside the template body (no wrapping container).
Imports in tablos.templ: "backend/internal/auth", "backend/internal/db/sqlc", "backend/internal/web/ui". Use `{ tablo.Title }`, `{ tablo.Description.String }`, etc. — templ auto-escapes (V5 XSS).
Modify backend/templates/layout.templ footer text from "Phase 2 · Authentication" → "Phase 3 · Tablos". No other layout edits.
Run `just generate` (or `templ generate`) to produce tablos_templ.go and the updated layout_templ.go. Pitfall 3.
</action>
<verify>
<automated>cd backend && just generate && grep -q "templ TablosDashboard" templates/tablos.templ && grep -q "templ TablosEmptyState" templates/tablos.templ && grep -q "templ TabloCard" templates/tablos.templ && grep -q "templ TabloCreateFormFragment" templates/tablos.templ && grep -q "templ TabloCardWithOOBFormClear" templates/tablos.templ && grep -q "id=\"create-form-slot\" hx-swap-oob=\"true\"" templates/tablos.templ && grep -q "Phase 3 · Tablos" templates/layout.templ && grep -q "Create your first tablo to get started." templates/tablos.templ && grep -q "No tablos yet" templates/tablos.templ && grep -q "tablo-delete-zone" templates/tablos.templ && grep -q "type TabloCreateForm" templates/tablos_forms.go && go build ./...</automated>
</verify>
<acceptance_criteria>
- File backend/templates/tablos.templ exists with `package templates` and imports "backend/internal/auth", "backend/internal/db/sqlc", "backend/internal/web/ui".
- File contains the strings: `templ TablosDashboard`, `templ TablosEmptyState`, `templ TabloCard`, `templ TabloCreateFormFragment`, `templ TabloCardWithOOBFormClear`.
- File contains exact copy strings (UI-SPEC Copywriting Contract): "Your Tablos", "New tablo", "Create a tablo", "Create tablo", "No tablos yet", "Create your first tablo to get started.".
- File contains `id="create-form-slot"` and `id="tablos-list"`.
- TabloCardWithOOBFormClear emits `<div id="create-form-slot" hx-swap-oob="true">` as a top-level sibling (grep shows it OUTSIDE the @TabloCard call — verify by reading the templ component body).
- File contains `class="tablo-delete-zone"` wrapper around the Delete button.
- tablos.templ guards description rendering with `if tablo.Description.Valid && tablo.Description.String != ""` and color dot with `if tablo.Color.Valid && tablo.Color.String != ""`.
- tablos_forms.go declares `type TabloCreateForm struct` with Title, Description, Color string fields and `type TabloCreateErrors struct` with Title, General string fields.
- templates/layout.templ now contains "Phase 3 · Tablos" and does NOT contain "Phase 2 · Authentication".
- `just generate` produced tablos_templ.go and refreshed layout_templ.go.
- `go build ./...` exits 0.
</acceptance_criteria>
<done>Templates compile, helper form/errors types declared, layout footer updated.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: handlers_tablos.go — TablosDeps + List/New/Create handlers + router/main wiring</name>
<files>backend/internal/web/handlers_tablos.go, backend/internal/web/router.go, backend/internal/web/handlers.go, backend/templates/index.templ, backend/templates/index_templ.go, backend/cmd/web/main.go</files>
<read_first>
backend/internal/web/handlers_auth.go
backend/internal/web/handlers.go
backend/internal/web/router.go
backend/internal/web/middleware.go
backend/cmd/web/main.go
backend/internal/db/sqlc/tablos.sql.go
backend/internal/db/sqlc/models.go
backend/internal/auth/middleware.go (Authed)
backend/internal/web/handlers_tablos_test.go (RED tests from Plan 01)
.planning/phases/03-tablos-crud/03-PATTERNS.md (handlers_tablos.go analog block + router/main modify blocks)
.planning/phases/03-tablos-crud/03-RESEARCH.md (Patterns 1-4, 7, 8 + Pitfalls 1, 2, 5, 6)
.planning/phases/03-tablos-crud/03-UI-SPEC.md (Interaction Contract §1 + §2 + Validation copy)
</read_first>
<behavior>
- TablosDeps struct holds *sqlc.Queries.
- TablosListHandler: GET / — extracts user via auth.Authed(ctx); calls Queries.ListTablosByUser(ctx, user.ID); renders TablosDashboard with the slice (empty slice OK, template handles empty state); Content-Type: text/html; charset=utf-8.
- TablosNewHandler: GET /tablos/new — renders TabloCreateFormFragment with zero-value form/errs; HTMX-only intent but works without HX-Request too.
- TablosCreateHandler: POST /tablos — reads title/description/color via r.PostFormValue (NOT r.Body — Pitfall 2); validates: title required → "Title is required.", title len > 255 → "Title must be 255 characters or fewer." (UI-SPEC copy). On validation error: status 422, render TabloCreateFormFragment with field errors (HTMX path) or full TablosDashboard with errs (non-HTMX path). Build InsertTabloParams: Title=trimmed title, Description=pgtype.Text{Valid: description != "", String: description}, Color=pgtype.Text{Valid: color != "", String: color}, UserID=user.ID. On success + HX-Request: set HX-Retarget: #tablos-list, HX-Reswap: afterbegin, render TabloCardWithOOBFormClear. On success + non-HTMX: 303 to /.
- All three handlers extract *auth.User via `_, user, _ := auth.Authed(r.Context())`.
- Router signature changes to NewRouter(pinger, staticDir, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string).
- Inside the RequireAuth chi group: GET / → TablosListHandler(tabloDeps), GET /tablos/new → TablosNewHandler(tabloDeps), POST /tablos → TablosCreateHandler(tabloDeps). Static segments declared before parametric (Pitfall 1) — Plan 03 will add /tablos/{id} routes AFTER /tablos/new.
- cmd/web/main.go constructs `tabloDeps := web.TablosDeps{Queries: q}` and passes it to NewRouter.
- Delete the old IndexHandler from handlers.go (and templates.Index in index.templ → emptied or file deleted). DemoTimeHandler stays. If deleting index.templ causes any remaining references to break, leave the file but reduce it to a no-op package declaration; remove IndexHandler from handlers.go either way.
- Stub for Plan 03: also register placeholder routes for endpoints the test scaffold exercises to avoid 404 noise during partial green: leave undefined; Plan 03 implements them. Tests for those endpoints stay RED until Plan 03.
</behavior>
<action>
1) Replace the Plan 01 stub `type TablosDeps struct { Queries *sqlc.Queries }` (or create) at the top of backend/internal/web/handlers_tablos.go in package web. Add full handler implementations following handlers_auth.go style exactly (constructor returns http.HandlerFunc closing over deps).
2) Implement TablosListHandler, TablosNewHandler, TablosCreateHandler per the behavior block. For nullable insert params use `pgtype.Text{String: s, Valid: s != ""}` (import "github.com/jackc/pgx/v5/pgtype"). For trim/validate, use `strings.TrimSpace`. For error responses use `w.WriteHeader(http.StatusUnprocessableEntity)` + form fragment (HX) or full dashboard (non-HX). For HTMX success use w.Header().Set("HX-Retarget", "#tablos-list") and w.Header().Set("HX-Reswap", "afterbegin"). Status 200 for HTMX success; 303 to "/" otherwise (http.StatusSeeOther — Pitfall 9 from Phase 2 carries over).
3) Edit backend/internal/web/router.go: change NewRouter signature to add `tabloDeps TablosDeps` as the parameter immediately after `deps AuthDeps`. Inside the existing protected group, replace `r.Get("/", IndexHandler())` with `r.Get("/", TablosListHandler(tabloDeps))` and add (in this order — static-before-parametric, Pitfall 1):
- r.Get("/tablos/new", TablosNewHandler(tabloDeps))
- r.Post("/tablos", TablosCreateHandler(tabloDeps))
Do NOT yet add /tablos/{id} routes — Plan 03 owns those.
4) Edit backend/cmd/web/main.go: after the existing `deps := web.AuthDeps{...}` line, add `tabloDeps := web.TablosDeps{Queries: q}` and pass it as the new arg to NewRouter (positional, between deps and csrfKey).
5) Remove backend/internal/web/handlers.go IndexHandler. Remove backend/templates/index.templ (delete or reduce to bare `package templates`). Delete backend/templates/index_templ.go if `just generate` does not regenerate it (templ should remove it on next run if the .templ is gone; if leftover, delete manually).
6) Run `just generate` then `just test`. TestTabloList, TestTabloList_Empty, TestTabloCreate, TestTabloCreate_Validation must turn green; tests for endpoints not yet implemented (Plan 03) remain red.
</action>
<verify>
<automated>cd backend && just generate && grep -q "type TablosDeps struct" internal/web/handlers_tablos.go && grep -q "func TablosListHandler" internal/web/handlers_tablos.go && grep -q "func TablosNewHandler" internal/web/handlers_tablos.go && grep -q "func TablosCreateHandler" internal/web/handlers_tablos.go && grep -q "HX-Retarget" internal/web/handlers_tablos.go && grep -q "HX-Reswap" internal/web/handlers_tablos.go && grep -q "pgtype.Text" internal/web/handlers_tablos.go && grep -q "r.PostFormValue(\"title\")" internal/web/handlers_tablos.go && grep -q "tabloDeps TablosDeps" internal/web/router.go && grep -q "r.Get(\"/tablos/new\"" internal/web/router.go && grep -q "r.Post(\"/tablos\"" internal/web/router.go && grep -q "TablosDeps{Queries: q}" cmd/web/main.go && ! grep -q "IndexHandler" internal/web/handlers.go && go build ./... && go test ./internal/web/... -run "TestTabloList|TestTabloList_Empty|TestTabloCreate|TestTabloCreate_Validation" -count=1</automated>
</verify>
<acceptance_criteria>
- backend/internal/web/handlers_tablos.go declares: `type TablosDeps struct { Queries *sqlc.Queries }`, `func TablosListHandler(deps TablosDeps) http.HandlerFunc`, `func TablosNewHandler(deps TablosDeps) http.HandlerFunc`, `func TablosCreateHandler(deps TablosDeps) http.HandlerFunc`.
- handlers_tablos.go uses `r.PostFormValue("title")`, `r.PostFormValue("description")`, `r.PostFormValue("color")` (no r.Body reads).
- handlers_tablos.go contains literal "Title is required." and "Title must be 255 characters or fewer." strings.
- handlers_tablos.go sets HX-Retarget and HX-Reswap headers in the create-success path.
- handlers_tablos.go uses pgtype.Text{...Valid: s != ""...} for nullable Description and Color inserts.
- router.go signature reads `func NewRouter(pinger Pinger, staticDir string, deps AuthDeps, tabloDeps TablosDeps, csrfKey []byte, env string, trustedOrigins ...string) http.Handler`.
- router.go protected group contains `r.Get("/", TablosListHandler(tabloDeps))` (no IndexHandler reference), `r.Get("/tablos/new", TablosNewHandler(tabloDeps))` (declared BEFORE any parametric /tablos/{id} route which is added in Plan 03), `r.Post("/tablos", TablosCreateHandler(tabloDeps))`.
- cmd/web/main.go contains `tabloDeps := web.TablosDeps{Queries: q}` and passes it to NewRouter.
- backend/internal/web/handlers.go no longer contains the symbol `IndexHandler` (`grep -c IndexHandler handlers.go` == 0).
- `go build ./...` exits 0.
- `go test ./internal/web/... -run "TestTabloList|TestTabloList_Empty|TestTabloCreate|TestTabloCreate_Validation"` exits 0 (all four pass).
- Phase 1/2 tests still pass: `go test ./internal/web/... -run "TestSignup|TestLogin|TestLogout|TestCSRF"` exits 0.
</acceptance_criteria>
<done>Dashboard + create slice green; create-form-slot OOB clear verified; non-HTMX 303 verified.</done>
</task>
<task type="checkpoint:human-verify" gate="blocking">
<name>Task 3: Human verify list + create flow</name>
<what-built>
Dashboard at GET / showing user's tablos newest-first or empty state. "New tablo" button expands inline form via HTMX; submitting creates a row, clears the form slot, and prepends the new card without a full reload. Non-JS fallback works via plain form POST.
</what-built>
<how-to-verify>
1. cd backend && just dev (web server on local port).
2. Open http://localhost:PORT/login, log in with an existing test account (or sign up).
3. On the dashboard, confirm heading "Your Tablos" and either tablo cards or empty-state text "No tablos yet" + "Create your first tablo to get started.".
4. Click "New tablo" — inline form appears above the list, no full page reload (check DevTools Network panel: only XHR to /tablos/new).
5. Submit form with title "My first tablo" + description "Hello" + color "#6366f1": form collapses, new card appears at top of list, no full reload.
6. Submit form with empty title: form stays open, "Title is required." appears under the field.
7. Open a private window with no JS — submit the form: page does a real POST and redirects to /; new tablo appears in list.
8. View page source: verify CSRF token is rendered in the form's hidden _csrf field.
</how-to-verify>
<resume-signal>Type "approved" if the dashboard + create flow works as described, or describe issues.</resume-signal>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Browser → POST /tablos | Untrusted form input (title, description, color) crosses into the handler. |
| Browser → GET /tablos/new | Authed GET; no body input. |
| Handler → Postgres | Parameterised via sqlc-generated InsertTabloParams. |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-03-02-01 | Spoofing | session cookie | mitigate (inherited) | RequireAuth middleware from Phase 2 wraps the entire tablos group — unauthed requests redirect to /login. |
| T-03-02-02 | Tampering | CSRF on POST /tablos | mitigate | gorilla/csrf middleware from Phase 2 validates _csrf field; @ui.CSRFField(csrfToken) embedded in TabloCreateFormFragment. Missing token → 403 before handler runs. |
| T-03-02-03 | Tampering | r.Body drained by gorilla/csrf | mitigate | Use r.PostFormValue exclusively (Pitfall 2). |
| T-03-02-04 | DoS | over-long title | mitigate | TablosCreateHandler validates len(strings.TrimSpace(title)) > 0 AND <= 255; >255 returns 422 before DB insert. |
| T-03-02-05 | XSS via title/description/color | Tampering | mitigate | templ auto-escapes all `{ variable }` interpolations; color rendered into inline style attribute is templ-escaped (inline style accepts limited chars, but templ's escaping prevents attribute breakout). No templ.Raw anywhere. |
| T-03-02-06 | Tampering via OOB swap | hx-swap-oob top-level requirement | mitigate | TabloCardWithOOBFormClear emits the OOB div as a top-level sibling (Pitfall 5); template structure asserted in Task 1 acceptance criteria. |
| T-03-02-07 | Elevation of privilege | Listing other users' tablos | mitigate | ListTablosByUser query is scoped by $1 = user.ID from auth.Authed(ctx); no path parameters, no way to widen scope. |
</threat_model>
<verification>
- TestTabloList, TestTabloList_Empty, TestTabloCreate (incl. HTMX + non-HTMX sub-asserts), TestTabloCreate_Validation all pass.
- Full Phase 1/2 test suite still passes (no regression in TestSignup/TestLogin/TestLogout/TestCSRF).
- Manual browser verification of create flow per Task 3 checkpoint.
- `grep -c IndexHandler backend/internal/web/handlers.go` returns 0.
</verification>
<success_criteria>
1. Dashboard at GET / renders user's tablos newest-first (TABLO-01).
2. Empty state appears when user has zero tablos with exact UI-SPEC copy.
3. Creating a tablo via HTMX prepends a new card and clears the form slot in one round trip (TABLO-02 + TABLO-06).
4. Empty-title validation surfaces "Title is required." inline.
5. Non-HTMX POST /tablos redirects 303 to / (TABLO-06 degrade-gracefully).
6. CSRF token present in the create form (AUTH-06 inherited).
7. Plan 03 has the wiring it needs: TablosDeps available, NewRouter signature stable, TabloCard renders a `.tablo-delete-zone` placeholder.
</success_criteria>
<output>
After completion, create `.planning/phases/03-tablos-crud/03-02-SUMMARY.md` documenting: TablosDeps shape, list of new handlers/templates, NewRouter signature change, which tests turned green (4 of 10), and which remain red for Plan 03 (6 of 10).
</output>