summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-02-07 13:47:13 -1000
committerPeter Stone <thepeterstone@gmail.com>2026-02-07 13:47:13 -1000
commit0620afc98fdc0f764e82807bb0090b78618ddb1d (patch)
treef0a868246adaa1a3e47ffa227787ce2eb3158e4a
parent27ee1a271248e9f1de8ecb981a6cabfa8e498b1b (diff)
Fix timeline task completion replacing view, fix passkey registration CSRF
- Fix checkboxes in timeline calendar grid targeting #tab-content (replaced entire view). Now target closest .untimed-item/.calendar-event with outerHTML - Fix passkey registration 403 by passing CSRFToken from settings handler and exposing it via meta tag for JS to read - Add TDD workflow requirement to CLAUDE.md - Tests written first (red-green): template content assertions for checkbox targets and CSRF token presence, handler data struct verification Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md10
-rw-r--r--internal/handlers/handlers_test.go74
-rw-r--r--internal/handlers/settings.go15
-rw-r--r--web/templates/partials/timeline-tab.html16
-rw-r--r--web/templates/settings.html15
5 files changed, 102 insertions, 28 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
index c4834a5..9bf34fd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -30,7 +30,15 @@ This project uses a three-role development workflow. **Read your role definition
- **Dependency Awareness:** Use `go test`, `go build`, and `lint` to verify state rather than asking the agent to "think" through logic.
- **Proactive Checkpointing:** Treat every 3-4 turns as a potential session end. Update `SESSION_STATE.md` frequently.
-## Workflow: Plan-then-Execute
+## Workflow: TDD — ALWAYS Write Tests First
+**Every bug fix and feature MUST follow Test-Driven Development:**
+1. **Red:** Write a failing test that reproduces the bug or specifies the new behavior.
+2. **Green:** Write the minimum code to make the test pass.
+3. **Refactor:** Clean up if needed, keeping tests green.
+
+**No exceptions.** Do not skip to writing production code. If you find a bug, write a test that fails first, then fix it. This applies to template assertions, handler logic, store queries, and JS behavior (via template content tests).
+
+### Plan-then-Execute
1. **Discovery:** Use terminal tools to locate relevant Go code/handlers.
2. **Planning:** Propose a specific plan and **wait for user confirmation** before editing.
3. **Execution:** Apply changes incrementally, verifying with `go test ./...`.
diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go
index d338cd3..1842c38 100644
--- a/internal/handlers/handlers_test.go
+++ b/internal/handlers/handlers_test.go
@@ -1958,6 +1958,80 @@ func TestDashboardTemplate_HasSettingsLink(t *testing.T) {
}
}
+// TestSettingsTemplate_HasCSRFToken verifies the settings page exposes a CSRF
+// token in a meta tag so that JavaScript (e.g. passkey registration) can include
+// it in POST requests. Without this, passkey registration fails with 403.
+func TestSettingsTemplate_HasCSRFToken(t *testing.T) {
+ content, err := os.ReadFile("../../web/templates/settings.html")
+ if err != nil {
+ t.Skipf("Cannot read template file: %v", err)
+ }
+ tmpl := string(content)
+
+ // The template must include a meta tag or hidden input with the Go template
+ // variable .CSRFToken so JS can retrieve it
+ if !strings.Contains(tmpl, ".CSRFToken") {
+ t.Error("settings.html must expose .CSRFToken (e.g. via meta tag) for JavaScript passkey registration")
+ }
+}
+
+// TestHandleSettingsPage_PassesCSRFToken verifies the settings handler includes
+// a CSRFToken in its template data so the passkey registration JS can use it.
+func TestHandleSettingsPage_PassesCSRFToken(t *testing.T) {
+ h, cleanup := setupTestHandler(t)
+ defer cleanup()
+
+ req := httptest.NewRequest("GET", "/settings", nil)
+ w := httptest.NewRecorder()
+
+ h.HandleSettingsPage(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("Expected status 200, got %d", w.Code)
+ }
+
+ mock := h.renderer.(*MockRenderer)
+ if len(mock.Calls) == 0 {
+ t.Fatal("Expected renderer to be called")
+ }
+
+ lastCall := mock.Calls[len(mock.Calls)-1]
+ // Use reflection or type assertion to check for CSRFToken field
+ // The data struct should have a CSRFToken field
+ type hasCSRF interface{ GetCSRFToken() string }
+
+ // Check via fmt to inspect the struct fields
+ dataStr := fmt.Sprintf("%+v", lastCall.Data)
+ if !strings.Contains(dataStr, "CSRFToken") {
+ t.Error("Settings page template data must include a CSRFToken field for passkey registration")
+ }
+}
+
+// TestTimelineTemplate_CheckboxesTargetSelf verifies that completion checkboxes
+// in the timeline calendar grid target their parent item, not #tab-content.
+func TestTimelineTemplate_CheckboxesTargetSelf(t *testing.T) {
+ content, err := os.ReadFile("../../web/templates/partials/timeline-tab.html")
+ if err != nil {
+ t.Skipf("Cannot read template file: %v", err)
+ }
+ tmpl := string(content)
+
+ // Find all lines with complete-atom/uncomplete-atom that also reference hx-target
+ lines := strings.Split(tmpl, "\n")
+ for i, line := range lines {
+ if !strings.Contains(line, "complete-atom") {
+ continue
+ }
+ // Look at surrounding lines for the hx-target
+ for j := i; j < len(lines) && j < i+8; j++ {
+ if strings.Contains(lines[j], `hx-target="#tab-content"`) {
+ t.Errorf("Line %d: checkbox targeting #tab-content will replace entire view on complete. "+
+ "Should target closest parent element instead.", j+1)
+ }
+ }
+ }
+}
+
func TestHandleTimeline_WithParams(t *testing.T) {
h, cleanup := setupTestHandler(t)
defer cleanup()
diff --git a/internal/handlers/settings.go b/internal/handlers/settings.go
index fa1acee..60fc6be 100644
--- a/internal/handlers/settings.go
+++ b/internal/handlers/settings.go
@@ -6,6 +6,7 @@ import (
"github.com/go-chi/chi/v5"
+ "task-dashboard/internal/auth"
"task-dashboard/internal/models"
)
@@ -21,13 +22,15 @@ func (h *Handler) HandleSettingsPage(w http.ResponseWriter, r *http.Request) {
}
data := struct {
- Configs map[string][]models.SourceConfig
- Sources []string
- Toggles []models.FeatureToggle
+ Configs map[string][]models.SourceConfig
+ Sources []string
+ Toggles []models.FeatureToggle
+ CSRFToken string
}{
- Configs: bySource,
- Sources: []string{"trello", "todoist", "gcal", "gtasks"},
- Toggles: toggles,
+ Configs: bySource,
+ Sources: []string{"trello", "todoist", "gcal", "gtasks"},
+ Toggles: toggles,
+ CSRFToken: auth.GetCSRFTokenFromContext(r.Context()),
}
if err := h.renderer.Render(w, "settings.html", data); err != nil {
diff --git a/web/templates/partials/timeline-tab.html b/web/templates/partials/timeline-tab.html
index c73c1b5..4979744 100644
--- a/web/templates/partials/timeline-tab.html
+++ b/web/templates/partials/timeline-tab.html
@@ -134,8 +134,8 @@
{{if .IsCompleted}}checked{{end}}
hx-post="{{if .IsCompleted}}/uncomplete-atom{{else}}/complete-atom{{end}}"
hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"{{if .ListID}}, "listId": "{{.ListID}}"{{end}}}'
- hx-target="#tab-content"
- hx-swap="innerHTML"
+ hx-target="closest .untimed-item"
+ hx-swap="outerHTML"
class="h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0">
{{end}}
<span class="{{if .IsCompleted}}line-through text-white/50{{end}}">{{.Title}}</span>
@@ -186,8 +186,8 @@
{{if .IsCompleted}}checked{{end}}
hx-post="{{if .IsCompleted}}/uncomplete-atom{{else}}/complete-atom{{end}}"
hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"{{if .ListID}}, "listId": "{{.ListID}}"{{end}}}'
- hx-target="#tab-content"
- hx-swap="innerHTML"
+ hx-target="closest .calendar-event"
+ hx-swap="outerHTML"
onclick="event.stopPropagation();"
class="h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0">
{{end}}
@@ -223,8 +223,8 @@
{{if .IsCompleted}}checked{{end}}
hx-post="{{if .IsCompleted}}/uncomplete-atom{{else}}/complete-atom{{end}}"
hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"{{if .ListID}}, "listId": "{{.ListID}}"{{end}}}'
- hx-target="#tab-content"
- hx-swap="innerHTML"
+ hx-target="closest .untimed-item"
+ hx-swap="outerHTML"
class="h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0">
{{end}}
<span class="{{if .IsCompleted}}line-through text-white/50{{end}}">{{.Title}}</span>
@@ -271,8 +271,8 @@
{{if .IsCompleted}}checked{{end}}
hx-post="{{if .IsCompleted}}/uncomplete-atom{{else}}/complete-atom{{end}}"
hx-vals='{"id": "{{.ID}}", "source": "{{.Source}}"{{if .ListID}}, "listId": "{{.ListID}}"{{end}}}'
- hx-target="#tab-content"
- hx-swap="innerHTML"
+ hx-target="closest .calendar-event"
+ hx-swap="outerHTML"
onclick="event.stopPropagation();"
class="h-4 w-4 rounded bg-black/40 border-white/30 text-white/80 focus:ring-white/30 cursor-pointer flex-shrink-0">
{{end}}
diff --git a/web/templates/settings.html b/web/templates/settings.html
index 50569e4..0803ae3 100644
--- a/web/templates/settings.html
+++ b/web/templates/settings.html
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <meta name="csrf-token" content="{{.CSRFToken}}">
<title>Settings - Task Dashboard</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
@@ -237,19 +238,7 @@
statusEl.textContent = 'Starting registration...';
try {
- const csrfMeta = document.querySelector('input[name="csrf_token"]') || document.querySelector('[name="csrf_token"]');
- let csrfToken = '';
- // Get CSRF token from the feature toggle form's hidden fields or cookie
- const forms = document.querySelectorAll('form');
- for (const f of forms) {
- const input = f.querySelector('input[name="csrf_token"]');
- if (input) { csrfToken = input.value; break; }
- }
- // Fallback: fetch from HTMX headers
- if (!csrfToken) {
- const resp = await fetch('/settings/passkeys');
- // Try to extract from response
- }
+ const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
const beginResp = await fetch('/passkeys/register/begin', {
method: 'POST',