diff --git a/docs/superpowers/plans/2026-05-05-p4-03-04-rbac-user-mgmt.md b/docs/superpowers/plans/2026-05-05-p4-03-04-rbac-user-mgmt.md
new file mode 100644
index 0000000..ca5120e
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-05-p4-03-04-rbac-user-mgmt.md
@@ -0,0 +1,4046 @@
+# P4-03 / P4-04 — RBAC + User Management Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Enforce role-based access control across the HTTP layer and ship the user-management UI (create / disable / role-change / setup-link with 1h expiry / self-service password change).
+
+**Architecture:** Schema gains three columns on `users` (email, disabled_at, must_change_password) plus a new `user_setup_tokens` table. Chi route-group middleware (`requireRole`) gates each subtree by minimum role with admin as the fail-closed default. A new setup-token flow replaces the temp-password idiom: admin creates user → server returns a one-time link valid for 1 hour → user clicks link → sets password → logged in. Self-service password change at `/settings/account` is open to every role. Sessions re-validate `disabled_at` and current role on every request so admin-driven changes land immediately.
+
+**Tech Stack:** Go 1.25, modernc.org/sqlite, chi v5 router, html/template, htmx, Tailwind. Existing crypto helpers (auth.HashToken, auth.HashPassword, auth.ComparePassword) reused.
+
+**Branch:** `p4-03-04-rbac-user-mgmt` (already exists with the spec commit).
+
+**Spec:** `docs/superpowers/specs/2026-05-05-p4-03-04-rbac-user-mgmt-design.md`
+
+---
+
+## File structure
+
+### Created files
+
+- `internal/store/migrations/0017_users_extensions.sql` — email, disabled_at, must_change_password columns + lowercase username unique index
+- `internal/store/migrations/0018_user_setup_tokens.sql` — table for setup tokens
+- `internal/store/setup_tokens.go` — store API for `user_setup_tokens` (Set / Lookup / Delete / Cleanup)
+- `internal/store/setup_tokens_test.go` — coverage for the above
+- `internal/server/http/rbac.go` — `roleAtLeast` helper + `requireRole` middleware + `forbidden` HTML/JSON renderers
+- `internal/server/http/rbac_test.go` — table-driven middleware tests
+- `internal/server/http/ui_users.go` — Settings → Users handlers (list, new, edit, setup-link, disable/enable, regenerate, force-logout)
+- `internal/server/http/api_users.go` — JSON handlers (list, create, patch, disable, enable, regenerate, force-logout)
+- `internal/server/http/ui_account.go` — `/settings/account` self-service password change
+- `internal/server/http/setup_handler.go` — public `/setup` GET + POST
+- `internal/server/http/users_test.go` — handler-level coverage for the user API + setup flow
+- `web/templates/pages/users.html` — Settings → Users list page
+- `web/templates/pages/user_edit.html` — Add user / edit user / setup-link page (multi-mode template)
+- `web/templates/pages/account.html` — self-service password page
+- `web/templates/pages/setup.html` — public landing page for `/setup?token=...`
+
+### Modified files
+
+- `internal/store/users.go` — extend User struct fields, lowercase normalisation in CreateUser, new methods (SetUserEmail, SetUserRole, DisableUser, EnableUser, SetMustChangePassword, SetPasswordHash, CountEnabledAdmins)
+- `internal/store/types.go` — extend User struct (Email, DisabledAt, MustChangePassword)
+- `internal/store/sessions.go` — add `DeleteSessionsByUserID` for force-logout
+- `internal/server/http/jobs.go` — `requireUser` rejects disabled users
+- `internal/server/http/ui_handlers.go` — `loadAuthedUser` rejects disabled users
+- `internal/server/http/server.go` — re-group routes under role bands, mount new handlers
+- `internal/server/http/auth.go` — login rejects disabled users
+- `web/templates/pages/settings.html` — flip the dormant Users tab live + add "Account" sub-tab link
+- `web/templates/partials/nav.html` — hide Settings tab for non-admins (Account link still reachable directly)
+- `internal/server/http/maintenance_dispatch.go` — periodic sweep of expired setup tokens (or new ticker hook)
+- `tasks.md` — tick P4-03 + P4-04 on completion
+
+---
+
+## Slice A — Schema & store API
+
+### Task A1: Migration 0017 — users extensions
+
+**Files:**
+- Create: `internal/store/migrations/0017_users_extensions.sql`
+- Test: `internal/store/migrate_test.go` (already exists; just runs all migrations on a fresh DB)
+
+- [ ] **Step 1: Write the migration**
+
+```sql
+-- 0017_users_extensions.sql
+--
+-- Add the columns the user-management UI needs:
+-- email — optional, free-form text; format-checked
+-- in Go on insert/update via net/mail.ParseAddress
+-- disabled_at — soft-delete tombstone. NULL = enabled
+-- must_change_password — flag set by admin-create + setup-token flow;
+-- cleared by /setup or /settings/account
+--
+-- Plus a case-insensitive unique index so 'Alice' and 'alice' can't
+-- both exist (lowercase normalisation is applied in the Go layer
+-- on every CreateUser; this index defends the invariant).
+--
+-- Column-level ALTERs (CLAUDE.md prefers these over rebuilds; safe
+-- under foreign_keys=ON).
+
+ALTER TABLE users ADD COLUMN email TEXT;
+ALTER TABLE users ADD COLUMN disabled_at TEXT;
+ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0;
+
+CREATE UNIQUE INDEX users_username_lower ON users(LOWER(username));
+```
+
+- [ ] **Step 2: Run all tests, expect them to pass**
+
+Run: `go test ./internal/store/...`
+Expected: all PASS (existing migrations test exercises the new file).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/store/migrations/0017_users_extensions.sql
+git commit -m "store: migration 0017 — users.email, disabled_at, must_change_password"
+```
+
+---
+
+### Task A2: Migration 0018 — user_setup_tokens
+
+**Files:**
+- Create: `internal/store/migrations/0018_user_setup_tokens.sql`
+
+- [ ] **Step 1: Write the migration**
+
+```sql
+-- 0018_user_setup_tokens.sql
+--
+-- One outstanding setup token per user (PRIMARY KEY on user_id).
+-- Regenerating a link is INSERT OR REPLACE — old token immediately
+-- invalid. Token is stored as sha256(raw) hex, never the raw token,
+-- so a DB leak doesn't leak active links.
+
+CREATE TABLE user_setup_tokens (
+ user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ token_hash TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ created_by TEXT REFERENCES users(id) ON DELETE SET NULL
+);
+
+CREATE INDEX user_setup_tokens_expires ON user_setup_tokens(expires_at);
+```
+
+- [ ] **Step 2: Run all tests, expect them to pass**
+
+Run: `go test ./internal/store/...`
+Expected: all PASS.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/store/migrations/0018_user_setup_tokens.sql
+git commit -m "store: migration 0018 — user_setup_tokens"
+```
+
+---
+
+### Task A3: Extend User struct + add SetupToken type
+
+**Files:**
+- Modify: `internal/store/types.go`
+
+- [ ] **Step 1: Add fields to the User struct**
+
+Find the existing `User` struct (currently has ID, Username, PasswordHash, Role, CreatedAt, LastLoginAt) and add the new fields:
+
+```go
+type User struct {
+ ID string
+ Username string
+ PasswordHash string
+ Role Role
+ Email *string // optional; nil = not set
+ DisabledAt *time.Time // nil = enabled
+ MustChangePassword bool
+ CreatedAt time.Time
+ LastLoginAt *time.Time
+}
+```
+
+- [ ] **Step 2: Add the SetupToken type**
+
+Append to `internal/store/types.go`:
+
+```go
+// SetupToken mirrors the user_setup_tokens table. The raw token
+// itself is never stored; the field shown here is the sha256 hex
+// digest of the raw token, which is what callers compare against.
+type SetupToken struct {
+ UserID string
+ TokenHash string
+ ExpiresAt time.Time
+ CreatedAt time.Time
+ CreatedBy *string // admin user id; nil only after CASCADE SET NULL
+}
+```
+
+- [ ] **Step 3: Run vet, expect compilation errors in users.go**
+
+Run: `go vet ./internal/store/...`
+Expected: errors about missing fields in `scanUser`-flavoured code (we'll fix those next).
+
+- [ ] **Step 4: Commit (broken intermediate, fixed by next task)**
+
+```bash
+git add internal/store/types.go
+git commit -m "store: extend User struct with Email, DisabledAt, MustChangePassword"
+```
+
+---
+
+### Task A4: Update users store — lowercase username, new fields, helper methods
+
+**Files:**
+- Modify: `internal/store/users.go`
+- Test: `internal/store/users_test.go`
+
+- [ ] **Step 1: Write a failing test for lowercase normalisation**
+
+Append to `internal/store/users_test.go`:
+
+```go
+func TestCreateUserLowercasesUsername(t *testing.T) {
+ t.Parallel()
+ s := openTestStore(t)
+ ctx := context.Background()
+ now := time.Now().UTC()
+
+ if err := s.CreateUser(ctx, User{
+ ID: "u1", Username: "Alice",
+ PasswordHash: "x", Role: RoleAdmin, CreatedAt: now,
+ }); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+ got, err := s.GetUserByUsername(ctx, "alice")
+ if err != nil {
+ t.Fatalf("get lower: %v", err)
+ }
+ if got.Username != "alice" {
+ t.Errorf("stored username: got %q want %q", got.Username, "alice")
+ }
+ // Case-insensitive lookup must hit the row by either casing.
+ got, err = s.GetUserByUsername(ctx, "ALICE")
+ if err != nil {
+ t.Fatalf("get upper: %v", err)
+ }
+ if got.ID != "u1" {
+ t.Errorf("upper-case lookup missed: got %+v", got)
+ }
+ // Re-creating with mixed case must collide.
+ if err := s.CreateUser(ctx, User{
+ ID: "u2", Username: "AlIcE",
+ PasswordHash: "x", Role: RoleAdmin, CreatedAt: now,
+ }); err == nil {
+ t.Error("duplicate (different case) should fail")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/store/ -run TestCreateUserLowercasesUsername`
+Expected: FAIL — current `CreateUser` stores the username verbatim.
+
+- [ ] **Step 3: Update the User helpers to lowercase + use LOWER()**
+
+Modify `internal/store/users.go`:
+
+```go
+package store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// CreateUser inserts a row. Username is lowercase-normalised so the
+// case-insensitive unique index from migration 0017 doesn't surprise
+// callers who insert 'Alice' and look up 'alice'.
+func (s *Store) CreateUser(ctx context.Context, u User) error {
+ u.Username = strings.ToLower(strings.TrimSpace(u.Username))
+ must := 0
+ if u.MustChangePassword {
+ must = 1
+ }
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO users (id, username, password_hash, role, email,
+ must_change_password, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
+ u.ID, u.Username, u.PasswordHash, string(u.Role),
+ nullable(u.Email), must,
+ u.CreatedAt.UTC().Format(time.RFC3339Nano))
+ if err != nil {
+ return fmt.Errorf("store: create user: %w", err)
+ }
+ return nil
+}
+
+// GetUserByUsername resolves a user case-insensitively.
+func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT id, username, password_hash, role, email, disabled_at,
+ must_change_password, created_at, last_login_at
+ FROM users WHERE LOWER(username) = LOWER(?)`, username)
+ return scanUser(row.Scan)
+}
+
+// GetUserByID looks up a user by id. Returns ErrNotFound on miss.
+func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT id, username, password_hash, role, email, disabled_at,
+ must_change_password, created_at, last_login_at
+ FROM users WHERE id = ?`, id)
+ return scanUser(row.Scan)
+}
+
+// ListUsers returns every user, sorted by username. Used by the
+// audit log filter and the user-management page.
+func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
+ rows, err := s.db.QueryContext(ctx,
+ `SELECT id, username, password_hash, role, email, disabled_at,
+ must_change_password, created_at, last_login_at
+ FROM users ORDER BY username`)
+ if err != nil {
+ return nil, fmt.Errorf("store: list users: %w", err)
+ }
+ defer func() { _ = rows.Close() }()
+ var out []User
+ for rows.Next() {
+ u, err := scanUser(rows.Scan)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, *u)
+ }
+ return out, rows.Err()
+}
+
+// CountUsers returns the total number of user rows.
+func (s *Store) CountUsers(ctx context.Context) (int, error) {
+ var n int
+ if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil {
+ return 0, fmt.Errorf("store: count users: %w", err)
+ }
+ return n, nil
+}
+
+// CountEnabledAdmins returns the number of users with role='admin'
+// AND disabled_at IS NULL. Used by the last-admin guard before
+// disable / role-demote operations.
+func (s *Store) CountEnabledAdmins(ctx context.Context) (int, error) {
+ var n int
+ if err := s.db.QueryRowContext(ctx,
+ `SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled_at IS NULL`,
+ ).Scan(&n); err != nil {
+ return 0, fmt.Errorf("store: count admins: %w", err)
+ }
+ return n, nil
+}
+
+// MarkUserLogin records a successful authentication.
+func (s *Store) MarkUserLogin(ctx context.Context, id string, when time.Time) error {
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE users SET last_login_at = ? WHERE id = ?`,
+ when.UTC().Format(time.RFC3339Nano), id)
+ if err != nil {
+ return fmt.Errorf("store: mark login: %w", err)
+ }
+ return nil
+}
+
+// SetUserEmail replaces the email field. Empty string clears it.
+func (s *Store) SetUserEmail(ctx context.Context, id, email string) error {
+ em := strings.ToLower(strings.TrimSpace(email))
+ var v any
+ if em == "" {
+ v = nil
+ } else {
+ v = em
+ }
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE users SET email = ? WHERE id = ?`, v, id)
+ if err != nil {
+ return fmt.Errorf("store: set user email: %w", err)
+ }
+ return nil
+}
+
+// SetUserRole changes a user's role.
+func (s *Store) SetUserRole(ctx context.Context, id string, role Role) error {
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE users SET role = ? WHERE id = ?`, string(role), id)
+ if err != nil {
+ return fmt.Errorf("store: set user role: %w", err)
+ }
+ return nil
+}
+
+// DisableUser sets disabled_at = when. Idempotent on already-disabled
+// rows (no-op).
+func (s *Store) DisableUser(ctx context.Context, id string, when time.Time) error {
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE users SET disabled_at = ?
+ WHERE id = ? AND disabled_at IS NULL`,
+ when.UTC().Format(time.RFC3339Nano), id)
+ if err != nil {
+ return fmt.Errorf("store: disable user: %w", err)
+ }
+ return nil
+}
+
+// EnableUser clears disabled_at.
+func (s *Store) EnableUser(ctx context.Context, id string) error {
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE users SET disabled_at = NULL WHERE id = ?`, id)
+ if err != nil {
+ return fmt.Errorf("store: enable user: %w", err)
+ }
+ return nil
+}
+
+// SetMustChangePassword toggles the must_change_password flag.
+func (s *Store) SetMustChangePassword(ctx context.Context, id string, must bool) error {
+ v := 0
+ if must {
+ v = 1
+ }
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE users SET must_change_password = ? WHERE id = ?`, v, id)
+ if err != nil {
+ return fmt.Errorf("store: set must_change_password: %w", err)
+ }
+ return nil
+}
+
+// SetPasswordHash stores a new password_hash and clears the
+// must_change_password flag in one go.
+func (s *Store) SetPasswordHash(ctx context.Context, id, hash string) error {
+ _, err := s.db.ExecContext(ctx,
+ `UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?`,
+ hash, id)
+ if err != nil {
+ return fmt.Errorf("store: set password: %w", err)
+ }
+ return nil
+}
+
+func scanUser(scan func(...any) error) (*User, error) {
+ var u User
+ var role string
+ var email, disabledAt, lastLogin sql.NullString
+ var must int
+ var created string
+ if err := scan(&u.ID, &u.Username, &u.PasswordHash, &role,
+ &email, &disabledAt, &must, &created, &lastLogin); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return nil, fmt.Errorf("store: scan user: %w", err)
+ }
+ u.Role = Role(role)
+ if email.Valid {
+ v := email.String
+ u.Email = &v
+ }
+ if disabledAt.Valid {
+ t, _ := time.Parse(time.RFC3339Nano, disabledAt.String)
+ u.DisabledAt = &t
+ }
+ u.MustChangePassword = must == 1
+ t, _ := time.Parse(time.RFC3339Nano, created)
+ u.CreatedAt = t
+ if lastLogin.Valid {
+ t, _ := time.Parse(time.RFC3339Nano, lastLogin.String)
+ u.LastLoginAt = &t
+ }
+ return &u, nil
+}
+```
+
+Note: this *replaces* the existing file (which has GetUserByUsername / GetUserByID / scanUser using the old column set + old User struct). Read the current file first to confirm the only public surface you're changing/adding is what's listed above.
+
+- [ ] **Step 4: Run all store tests, expect them to pass**
+
+Run: `go test ./internal/store/...`
+Expected: all PASS, including the new `TestCreateUserLowercasesUsername`.
+
+- [ ] **Step 5: Run vet across the repo and fix call sites**
+
+Run: `go vet ./...`
+Expected: errors about anywhere that constructed `User{}` without the new optional fields — those are fine because Go zero-values handle them.
+Actual ones to address: the test file `internal/store/users_test.go` uses `nullable` indirectly only inside the package; no external callers should break. If anything fails outside `internal/store`, fix the call site (likely a test fixture).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/store/users.go internal/store/users_test.go
+git commit -m "store: lowercase username, email/disable helpers, last-admin count"
+```
+
+---
+
+### Task A5: Setup-token store API
+
+**Files:**
+- Create: `internal/store/setup_tokens.go`
+- Create: `internal/store/setup_tokens_test.go`
+
+- [ ] **Step 1: Write failing tests**
+
+```go
+package store
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/oklog/ulid/v2"
+)
+
+func newSetupTokenTestStore(t *testing.T) (*Store, string, string) {
+ t.Helper()
+ st, err := Open(context.Background(), filepath.Join(t.TempDir(), "rm.db"))
+ if err != nil {
+ t.Fatalf("open: %v", err)
+ }
+ t.Cleanup(func() { _ = st.Close() })
+ uid := ulid.Make().String()
+ creator := ulid.Make().String()
+ now := time.Now().UTC()
+ if err := st.CreateUser(context.Background(), User{
+ ID: creator, Username: "creator", PasswordHash: "x",
+ Role: RoleAdmin, CreatedAt: now,
+ }); err != nil {
+ t.Fatalf("create creator: %v", err)
+ }
+ if err := st.CreateUser(context.Background(), User{
+ ID: uid, Username: "target", PasswordHash: "",
+ Role: RoleOperator, CreatedAt: now, MustChangePassword: true,
+ }); err != nil {
+ t.Fatalf("create target: %v", err)
+ }
+ return st, uid, creator
+}
+
+func TestSetupTokenSetAndLookup(t *testing.T) {
+ t.Parallel()
+ st, uid, creator := newSetupTokenTestStore(t)
+ ctx := context.Background()
+ now := time.Now().UTC()
+
+ if err := st.SetSetupToken(ctx, SetupToken{
+ UserID: uid, TokenHash: "abc123",
+ ExpiresAt: now.Add(time.Hour),
+ CreatedAt: now, CreatedBy: &creator,
+ }); err != nil {
+ t.Fatalf("set: %v", err)
+ }
+ got, err := st.LookupSetupToken(ctx, "abc123")
+ if err != nil {
+ t.Fatalf("lookup: %v", err)
+ }
+ if got.UserID != uid {
+ t.Errorf("user_id: got %q want %q", got.UserID, uid)
+ }
+}
+
+func TestSetupTokenReplaces(t *testing.T) {
+ t.Parallel()
+ st, uid, creator := newSetupTokenTestStore(t)
+ ctx := context.Background()
+ now := time.Now().UTC()
+
+ _ = st.SetSetupToken(ctx, SetupToken{
+ UserID: uid, TokenHash: "old",
+ ExpiresAt: now.Add(time.Hour), CreatedAt: now, CreatedBy: &creator,
+ })
+ _ = st.SetSetupToken(ctx, SetupToken{
+ UserID: uid, TokenHash: "new",
+ ExpiresAt: now.Add(time.Hour), CreatedAt: now, CreatedBy: &creator,
+ })
+ if _, err := st.LookupSetupToken(ctx, "old"); err == nil {
+ t.Error("old token should be gone")
+ }
+ if _, err := st.LookupSetupToken(ctx, "new"); err != nil {
+ t.Errorf("new token should resolve: %v", err)
+ }
+}
+
+func TestSetupTokenDelete(t *testing.T) {
+ t.Parallel()
+ st, uid, creator := newSetupTokenTestStore(t)
+ ctx := context.Background()
+ now := time.Now().UTC()
+
+ _ = st.SetSetupToken(ctx, SetupToken{
+ UserID: uid, TokenHash: "tk",
+ ExpiresAt: now.Add(time.Hour), CreatedAt: now, CreatedBy: &creator,
+ })
+ if err := st.DeleteSetupToken(ctx, uid); err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if _, err := st.LookupSetupToken(ctx, "tk"); err == nil {
+ t.Error("deleted token should not resolve")
+ }
+}
+
+func TestSetupTokenCleanupExpired(t *testing.T) {
+ t.Parallel()
+ st, uid, creator := newSetupTokenTestStore(t)
+ ctx := context.Background()
+ now := time.Now().UTC()
+
+ _ = st.SetSetupToken(ctx, SetupToken{
+ UserID: uid, TokenHash: "stale",
+ ExpiresAt: now.Add(-time.Hour), CreatedAt: now.Add(-2 * time.Hour),
+ CreatedBy: &creator,
+ })
+ n, err := st.CleanupExpiredSetupTokens(ctx, now)
+ if err != nil {
+ t.Fatalf("cleanup: %v", err)
+ }
+ if n != 1 {
+ t.Errorf("cleanup count: got %d want 1", n)
+ }
+ if _, err := st.LookupSetupToken(ctx, "stale"); err == nil {
+ t.Error("stale token should be gone")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/store/ -run TestSetupToken`
+Expected: FAIL — `SetSetupToken / LookupSetupToken / DeleteSetupToken / CleanupExpiredSetupTokens` undefined.
+
+- [ ] **Step 3: Implement the store API**
+
+```go
+// internal/store/setup_tokens.go
+package store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+)
+
+// SetSetupToken inserts a row, replacing any existing token for
+// this user (single-outstanding invariant). Caller passes a hash —
+// raw tokens are never persisted.
+func (s *Store) SetSetupToken(ctx context.Context, t SetupToken) error {
+ _, err := s.db.ExecContext(ctx,
+ `INSERT OR REPLACE INTO user_setup_tokens
+ (user_id, token_hash, expires_at, created_at, created_by)
+ VALUES (?, ?, ?, ?, ?)`,
+ t.UserID, t.TokenHash,
+ t.ExpiresAt.UTC().Format(time.RFC3339Nano),
+ t.CreatedAt.UTC().Format(time.RFC3339Nano),
+ nullable(t.CreatedBy))
+ if err != nil {
+ return fmt.Errorf("store: set setup token: %w", err)
+ }
+ return nil
+}
+
+// LookupSetupToken resolves a token hash to its row. Returns
+// ErrNotFound for missing tokens. Expiry is NOT checked here —
+// callers must compare ExpiresAt themselves so they can record
+// 'expired' as a distinct outcome (audit-able) from 'never existed'.
+func (s *Store) LookupSetupToken(ctx context.Context, tokenHash string) (*SetupToken, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT user_id, token_hash, expires_at, created_at, created_by
+ FROM user_setup_tokens WHERE token_hash = ?`, tokenHash)
+ var t SetupToken
+ var createdBy sql.NullString
+ var expiresAt, createdAt string
+ if err := row.Scan(&t.UserID, &t.TokenHash, &expiresAt, &createdAt, &createdBy); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return nil, fmt.Errorf("store: scan setup token: %w", err)
+ }
+ t.ExpiresAt, _ = time.Parse(time.RFC3339Nano, expiresAt)
+ t.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt)
+ if createdBy.Valid {
+ v := createdBy.String
+ t.CreatedBy = &v
+ }
+ return &t, nil
+}
+
+// GetSetupTokenByUserID returns the row for one user (used by the
+// edit page to know whether a 'Regenerate setup link' button should
+// show as 'Generate' or 'Regenerate'). Returns ErrNotFound when no
+// outstanding token exists.
+func (s *Store) GetSetupTokenByUserID(ctx context.Context, userID string) (*SetupToken, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT user_id, token_hash, expires_at, created_at, created_by
+ FROM user_setup_tokens WHERE user_id = ?`, userID)
+ var t SetupToken
+ var createdBy sql.NullString
+ var expiresAt, createdAt string
+ if err := row.Scan(&t.UserID, &t.TokenHash, &expiresAt, &createdAt, &createdBy); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ return nil, fmt.Errorf("store: scan setup token: %w", err)
+ }
+ t.ExpiresAt, _ = time.Parse(time.RFC3339Nano, expiresAt)
+ t.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt)
+ if createdBy.Valid {
+ v := createdBy.String
+ t.CreatedBy = &v
+ }
+ return &t, nil
+}
+
+// DeleteSetupToken removes the row for a user (single-use cleanup
+// after /setup completes successfully).
+func (s *Store) DeleteSetupToken(ctx context.Context, userID string) error {
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM user_setup_tokens WHERE user_id = ?`, userID)
+ if err != nil {
+ return fmt.Errorf("store: delete setup token: %w", err)
+ }
+ return nil
+}
+
+// CleanupExpiredSetupTokens removes rows whose expires_at has passed.
+// Returns the number of rows deleted. Called from the maintenance
+// ticker every minute.
+func (s *Store) CleanupExpiredSetupTokens(ctx context.Context, now time.Time) (int64, error) {
+ res, err := s.db.ExecContext(ctx,
+ `DELETE FROM user_setup_tokens WHERE expires_at < ?`,
+ now.UTC().Format(time.RFC3339Nano))
+ if err != nil {
+ return 0, fmt.Errorf("store: cleanup setup tokens: %w", err)
+ }
+ n, _ := res.RowsAffected()
+ return n, nil
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/store/ -run TestSetupToken`
+Expected: all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/store/setup_tokens.go internal/store/setup_tokens_test.go
+git commit -m "store: user_setup_tokens CRUD + cleanup-expired"
+```
+
+---
+
+### Task A6: DeleteSessionsByUserID
+
+**Files:**
+- Modify: `internal/store/sessions.go`
+- Test: `internal/store/sessions_test.go` (existing or create)
+
+- [ ] **Step 1: Write a failing test**
+
+Append to `internal/store/sessions_test.go` (create the file if it doesn't exist; mirror users_test.go style):
+
+```go
+func TestDeleteSessionsByUserID(t *testing.T) {
+ t.Parallel()
+ s := openTestStore(t)
+ ctx := context.Background()
+ now := time.Now().UTC()
+
+ uid := "u-force"
+ if err := s.CreateUser(ctx, User{
+ ID: uid, Username: "victim",
+ PasswordHash: "x", Role: RoleOperator, CreatedAt: now,
+ }); err != nil {
+ t.Fatalf("create user: %v", err)
+ }
+
+ // Create two sessions for that user.
+ for i, h := range []string{"hash1", "hash2"} {
+ if err := s.CreateSession(ctx, Session{
+ ID: h,
+ UserID: uid,
+ CreatedAt: now,
+ ExpiresAt: now.Add(time.Hour),
+ }, h); err != nil {
+ t.Fatalf("create session %d: %v", i, err)
+ }
+ }
+
+ n, err := s.DeleteSessionsByUserID(ctx, uid)
+ if err != nil {
+ t.Fatalf("delete: %v", err)
+ }
+ if n != 2 {
+ t.Errorf("count: got %d want 2", n)
+ }
+ if _, err := s.LookupSession(ctx, "hash1"); err == nil {
+ t.Error("hash1 should be gone")
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/store/ -run TestDeleteSessionsByUserID`
+Expected: FAIL — method undefined.
+
+- [ ] **Step 3: Add the method**
+
+Append to `internal/store/sessions.go`:
+
+```go
+// DeleteSessionsByUserID removes every session row owned by the
+// user. Returns count for caller logging. Used by:
+// - admin "Force logout" button
+// - admin Disable user (sessions outlive the disable flag, so we
+// also clear them so the user gets bounced immediately)
+func (s *Store) DeleteSessionsByUserID(ctx context.Context, userID string) (int64, error) {
+ res, err := s.db.ExecContext(ctx,
+ `DELETE FROM sessions WHERE user_id = ?`, userID)
+ if err != nil {
+ return 0, fmt.Errorf("store: delete sessions by user: %w", err)
+ }
+ n, _ := res.RowsAffected()
+ return n, nil
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/store/ -run TestDeleteSessionsByUserID`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/store/sessions.go internal/store/sessions_test.go
+git commit -m "store: DeleteSessionsByUserID for force-logout"
+```
+
+---
+
+## Slice B — RBAC middleware
+
+### Task B1: roleAtLeast helper + tests
+
+**Files:**
+- Create: `internal/server/http/rbac.go`
+- Create: `internal/server/http/rbac_test.go`
+
+- [ ] **Step 1: Write failing tests**
+
+```go
+// internal/server/http/rbac_test.go
+package http
+
+import (
+ "testing"
+
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
+)
+
+func TestRoleAtLeast(t *testing.T) {
+ t.Parallel()
+ cases := []struct {
+ have store.Role
+ min store.Role
+ want bool
+ }{
+ {store.RoleViewer, store.RoleViewer, true},
+ {store.RoleOperator, store.RoleViewer, true},
+ {store.RoleAdmin, store.RoleViewer, true},
+ {store.RoleAdmin, store.RoleOperator, true},
+ {store.RoleAdmin, store.RoleAdmin, true},
+ {store.RoleViewer, store.RoleOperator, false},
+ {store.RoleViewer, store.RoleAdmin, false},
+ {store.RoleOperator, store.RoleAdmin, false},
+ {store.Role("nonsense"), store.RoleViewer, false},
+ {store.RoleAdmin, store.Role("nonsense"), false},
+ }
+ for _, c := range cases {
+ got := roleAtLeast(c.have, c.min)
+ if got != c.want {
+ t.Errorf("have=%q min=%q: got %v want %v", c.have, c.min, got, c.want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/server/http/ -run TestRoleAtLeast`
+Expected: FAIL — `roleAtLeast` undefined.
+
+- [ ] **Step 3: Implement roleAtLeast**
+
+```go
+// internal/server/http/rbac.go
+package http
+
+import (
+ "encoding/json"
+ stdhttp "net/http"
+
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
+)
+
+// rank maps each role to a numeric tier so 'A is at least B' becomes
+// 'rank[A] >= rank[B] && both are known'. Unknown roles return 0 →
+// fail-closed against either argument.
+var roleRank = map[store.Role]int{
+ store.RoleViewer: 1,
+ store.RoleOperator: 2,
+ store.RoleAdmin: 3,
+}
+
+// roleAtLeast reports whether `have` meets or exceeds `min` in the
+// admin > operator > viewer hierarchy. Either side being an unknown
+// role returns false.
+func roleAtLeast(have, min store.Role) bool {
+ h, hok := roleRank[have]
+ m, mok := roleRank[min]
+ if !hok || !mok {
+ return false
+ }
+ return h >= m
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/server/http/ -run TestRoleAtLeast`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/server/http/rbac.go internal/server/http/rbac_test.go
+git commit -m "http: roleAtLeast helper for the role hierarchy"
+```
+
+---
+
+### Task B2: requireRole middleware
+
+**Files:**
+- Modify: `internal/server/http/rbac.go`
+- Modify: `internal/server/http/rbac_test.go`
+
+- [ ] **Step 1: Write failing tests**
+
+Append to `internal/server/http/rbac_test.go`:
+
+```go
+import (
+ stdhttp "net/http"
+ "net/http/httptest"
+ "strings"
+)
+
+func TestRequireRoleViewerAdmits(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ uid := makeUser(t, srv, "viewer1", store.RoleViewer)
+ cookie := loginAs(t, srv, uid)
+
+ mid := srv.requireRole(store.RoleViewer)
+ h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
+ w.WriteHeader(stdhttp.StatusOK)
+ }))
+
+ rr := httptest.NewRecorder()
+ req, _ := stdhttp.NewRequest("GET", url+"/dummy", nil)
+ req.AddCookie(cookie)
+ h.ServeHTTP(rr, req)
+ if rr.Code != stdhttp.StatusOK {
+ t.Errorf("status: got %d want 200", rr.Code)
+ }
+}
+
+func TestRequireRoleViewerRejectedFromOperator(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ uid := makeUser(t, srv, "viewer1", store.RoleViewer)
+ cookie := loginAs(t, srv, uid)
+
+ mid := srv.requireRole(store.RoleOperator)
+ h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
+ w.WriteHeader(stdhttp.StatusOK)
+ }))
+
+ rr := httptest.NewRecorder()
+ req, _ := stdhttp.NewRequest("GET", url+"/api/dummy", nil)
+ req.AddCookie(cookie)
+ h.ServeHTTP(rr, req)
+ if rr.Code != stdhttp.StatusForbidden {
+ t.Errorf("status: got %d want 403", rr.Code)
+ }
+ if !strings.Contains(rr.Body.String(), "insufficient_role") {
+ t.Errorf("body: got %q", rr.Body.String())
+ }
+}
+
+func TestRequireRoleUnauthenticated401(t *testing.T) {
+ t.Parallel()
+ srv, _ := newTestServer(t, false)
+
+ mid := srv.requireRole(store.RoleViewer)
+ h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
+ w.WriteHeader(stdhttp.StatusOK)
+ }))
+
+ rr := httptest.NewRecorder()
+ req, _ := stdhttp.NewRequest("GET", "/api/dummy", nil)
+ h.ServeHTTP(rr, req)
+ // API path → 401 JSON; non-API HTML path would 303 to /login. We
+ // exercise the API branch here; the HTML branch lives in TestRequireRoleHTMLRedirect.
+ if rr.Code != stdhttp.StatusUnauthorized {
+ t.Errorf("status: got %d want 401", rr.Code)
+ }
+}
+
+// makeUser is a small helper for these tests — drops a row in the
+// users table and returns the id. Lives in users_test_helpers.go,
+// added in Task B3.
+```
+
+Note: `makeUser` and `loginAs` are helpers we'll create in Task B3 alongside the rest of the RBAC test infrastructure. This step is *intentionally* test-first; you'll see `undefined` for those names until Task B3 lands them.
+
+- [ ] **Step 2: Implement requireRole**
+
+Append to `internal/server/http/rbac.go`:
+
+```go
+// requireRole returns chi middleware that 403s any request whose
+// session-resolved user doesn't meet the minimum role. Unauthenticated
+// requests return 401 (JSON) or 303 → /login (HTML) so the caller
+// gets a usable error rather than a confusing 403.
+//
+// The middleware re-reads the user row on every request — by the time
+// you read this you might be tempted to cache; don't. SQLite's WAL
+// makes the lookup cheap and admin-driven changes (disable, role
+// change) need to land immediately.
+func (s *Server) requireRole(min store.Role) func(stdhttp.Handler) stdhttp.Handler {
+ return func(next stdhttp.Handler) stdhttp.Handler {
+ return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ u, ok := s.requireUser(r)
+ if !ok {
+ if isAPIPath(r) {
+ writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
+ return
+ }
+ stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther)
+ return
+ }
+ if !roleAtLeast(u.Role, min) {
+ if isAPIPath(r) {
+ writeJSONError(w, stdhttp.StatusForbidden, "insufficient_role", "")
+ return
+ }
+ renderForbiddenHTML(s, w, r, u, min)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
+
+// isAPIPath reports whether the path lives under /api/. Lets one
+// middleware return JSON or HTML appropriately without two near-
+// identical wrappers.
+func isAPIPath(r *stdhttp.Request) bool {
+ p := r.URL.Path
+ return len(p) >= 5 && p[:5] == "/api/"
+}
+
+// renderForbiddenHTML emits a small "you don't have permission"
+// panel inside the chrome so the user keeps their nav and can
+// move away to a page they can see.
+func renderForbiddenHTML(s *Server, w stdhttp.ResponseWriter, r *stdhttp.Request, u *store.User, min store.Role) {
+ w.WriteHeader(stdhttp.StatusForbidden)
+ view := s.baseView(r, &uiUserFromStore{u}.User)
+ view.Title = "Forbidden · restic-manager"
+ view.Page = struct {
+ Required string
+ Have string
+ }{
+ Required: string(min),
+ Have: string(u.Role),
+ }
+ if err := s.deps.UI.Render(w, "forbidden", view); err != nil {
+ // Fall back to plain text if the template doesn't exist
+ // (covered by Task B4 — adds the template).
+ _, _ = w.Write([]byte("403 Forbidden — your role does not permit this page."))
+ }
+}
+
+// uiUserFromStore is a small adapter so renderForbiddenHTML can pass
+// a store.User into baseView (which expects *ui.User).
+type uiUserFromStore struct{ U *store.User }
+
+func (a uiUserFromStore) User() {} // intentionally no-op marker
+```
+
+Note: the `baseView` signature in this codebase actually takes a `*ui.User`. We construct one inline:
+
+```go
+// Replace the renderForbiddenHTML body to use the existing ui.User
+// shape rather than a fake adapter:
+func renderForbiddenHTML(s *Server, w stdhttp.ResponseWriter, r *stdhttp.Request, u *store.User, min store.Role) {
+ w.WriteHeader(stdhttp.StatusForbidden)
+ view := s.baseView(r, &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)})
+ view.Title = "Forbidden · restic-manager"
+ view.Page = struct {
+ Required string
+ Have string
+ }{Required: string(min), Have: string(u.Role)}
+ if err := s.deps.UI.Render(w, "forbidden", view); err != nil {
+ _, _ = w.Write([]byte("403 Forbidden — your role does not permit this page."))
+ }
+}
+```
+
+(Drop the `uiUserFromStore` struct — leftover from a discarded approach. Add `"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"` to the imports.)
+
+- [ ] **Step 3: Add the forbidden template**
+
+```html
+
+{{define "title"}}Forbidden · restic-manager{{end}}
+
+{{define "content"}}
+{{$page := .Page}}
+
+
+
+
403 — Insufficient role
+
+ Your role ({{$page.Have}} ) does not permit
+ this page ({{$page.Required}} required).
+ Ask your administrator if you need access.
+
+
Back to dashboard
+
+
+{{end}}
+```
+
+- [ ] **Step 4: Run vet, expect compilation errors**
+
+Run: `go vet ./...`
+Expected: errors about `makeUser` / `loginAs` undefined in the test file we wrote. Move on to Task B3.
+
+---
+
+### Task B3: Test helpers — makeUser, loginAs
+
+**Files:**
+- Create: `internal/server/http/users_test_helpers.go`
+
+- [ ] **Step 1: Add helpers**
+
+```go
+// internal/server/http/users_test_helpers.go
+//go:build test
+
+package http
+
+import (
+ stdhttp "net/http"
+ "testing"
+ "time"
+
+ "github.com/oklog/ulid/v2"
+
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
+)
+
+// makeUser inserts a user with a known password ('test-password').
+// Returns the user id. Used by RBAC middleware tests + the
+// user-management handler tests.
+func makeUser(t *testing.T, srv *Server, username string, role store.Role) string {
+ t.Helper()
+ id := ulid.Make().String()
+ hash, err := auth.HashPassword("test-password")
+ if err != nil {
+ t.Fatalf("hash: %v", err)
+ }
+ if err := srv.deps.Store.CreateUser(t.Context(), store.User{
+ ID: id, Username: username, PasswordHash: hash,
+ Role: role, CreatedAt: time.Now().UTC(),
+ }); err != nil {
+ t.Fatalf("create user %s: %v", username, err)
+ }
+ return id
+}
+
+// loginAs gets a session cookie for the given user. Goes through
+// the real /api/auth/login handler so we exercise the same path
+// production traffic uses.
+func loginAs(t *testing.T, srv *Server, userID string) *stdhttp.Cookie {
+ t.Helper()
+ u, err := srv.deps.Store.GetUserByID(t.Context(), userID)
+ if err != nil {
+ t.Fatalf("get user: %v", err)
+ }
+ rawToken, err := auth.GenerateSessionToken()
+ if err != nil {
+ t.Fatalf("token: %v", err)
+ }
+ hash := auth.HashToken(rawToken)
+ now := time.Now().UTC()
+ if err := srv.deps.Store.CreateSession(t.Context(), store.Session{
+ ID: hash, UserID: u.ID, CreatedAt: now,
+ ExpiresAt: now.Add(8 * time.Hour),
+ }, hash); err != nil {
+ t.Fatalf("session: %v", err)
+ }
+ return &stdhttp.Cookie{
+ Name: sessionCookieName,
+ Value: rawToken,
+ }
+}
+```
+
+If the `//go:build test` tag isn't already in use in the project, drop it — the file will live in the `_test.go` namespace. Actually rename the file to `users_test_helpers_test.go` (add `_test`) so it's only compiled in test builds without needing a build tag.
+
+- [ ] **Step 2: Verify tests pass for the RBAC middleware**
+
+Run: `go test ./internal/server/http/ -run TestRequireRole`
+Expected: all PASS.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/server/http/rbac.go internal/server/http/rbac_test.go \
+ internal/server/http/users_test_helpers_test.go \
+ web/templates/pages/forbidden.html
+git commit -m "http: requireRole middleware + 403 forbidden page"
+```
+
+---
+
+### Task B4: Re-group routes under role bands
+
+**Files:**
+- Modify: `internal/server/http/server.go`
+
+This task is the bulk of the RBAC PR. Read `routes()` carefully before touching anything — there are ~60 endpoints to band.
+
+- [ ] **Step 1: Sketch the role bands as comments at the top of `routes()`**
+
+```go
+func (s *Server) routes(r chi.Router) {
+ // ── role bands ──────────────────────────────────────────────────
+ // Public: no auth — /healthz, /login, /bootstrap, /setup,
+ // /agents/enroll, /agents/announce, /ws/agent
+ // Viewer: auth+R — /, /alerts (GET), /audit, /api/hosts (GET),
+ // /api/fleet/summary, host detail GET pages,
+ // /settings/account
+ // Operator: auth+M — Run-now, restore, ack/resolve, schedules,
+ // source groups, repo creds (CRUD), bandwidth,
+ // cancel jobs, accept/reject pending hosts
+ // Admin: auth+A — /settings/users/*, /settings/notifications/*,
+ // /api/users/*, channel CRUD, force-logout
+ //
+ // Default at the bottom: requireRole(RoleAdmin) — fail-closed for
+ // any future endpoint that doesn't get explicitly placed.
+```
+
+- [ ] **Step 2: Replace routes() with role-banded structure**
+
+The full re-grouping is substantial. Read the current `routes()` function (lines ~115–340 in server.go) and rewrite as:
+
+```go
+func (s *Server) routes(r chi.Router) {
+ // Public, unauthenticated.
+ r.Get("/healthz", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
+ w.WriteHeader(stdhttp.StatusNoContent)
+ })
+ r.Post("/api/auth/login", s.handleLogin)
+ r.Post("/api/auth/logout", s.handleLogout)
+ r.Post("/api/bootstrap", s.handleBootstrap)
+ r.Post("/api/agents/enroll", s.handleAgentEnroll)
+ r.Post("/api/agents/announce", s.handleAnnounce)
+ r.Get("/agent/binary", s.handleAgentBinary)
+ r.Get("/install/*", s.handleInstallAsset)
+ if s.deps.Hub != nil {
+ r.Mount("/ws/agent", ws.AgentHandler(ws.HandlerDeps{
+ Hub: s.deps.Hub,
+ Store: s.deps.Store,
+ JobHub: s.deps.JobHub,
+ AlertEngine: s.deps.AlertEngine,
+ OnHello: s.onAgentHello,
+ OnScheduleAck: s.applyScheduleAck,
+ OnScheduleFire: s.dispatchScheduledJob,
+ }))
+ }
+ r.Get("/ws/agent/pending", s.handlePendingWS)
+ r.Mount("/static/", staticHandler())
+
+ // Setup-token landing — no session required (the token IS the auth).
+ if s.deps.UI != nil {
+ r.Get("/setup", s.handleUISetupGet)
+ r.Post("/setup", s.handleUISetupPost)
+ r.Get("/login", s.handleUILoginGet)
+ r.Post("/login", s.handleUILoginPost)
+ r.Post("/logout", s.handleUILogoutPost)
+ }
+
+ // Viewer band — anyone authenticated can read.
+ r.Group(func(r chi.Router) {
+ r.Use(s.requireRole(store.RoleViewer))
+
+ // Read APIs.
+ r.Get("/api/hosts", s.handleListHosts)
+ r.Get("/api/fleet/summary", s.handleFleetSummary)
+ r.Get("/api/hosts/{id}/snapshots", s.handleListHostSnapshots)
+ r.Get("/api/hosts/{id}/repo-credentials", s.handleGetHostCredentials)
+ r.Get("/api/hosts/{id}/admin-credentials", s.handleGetAdminCredentials)
+ r.Get("/api/hosts/{id}/schedules", s.handleListSchedules)
+ r.Get("/api/hosts/{id}/source-groups", s.handleListSourceGroups)
+ r.Get("/api/hosts/{id}/source-groups/{gid}", s.handleGetSourceGroup)
+ r.Get("/api/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance)
+ r.Get("/api/alerts", s.handleAPIAlerts)
+ r.Get("/api/audit", s.handleAPIAudit)
+
+ // Self-service password change (any authenticated user).
+ r.Post("/api/account/password", s.handleAPIAccountPassword)
+
+ if s.deps.UI != nil {
+ // Read pages.
+ r.Get("/", s.handleUIDashboard)
+ r.Get("/hosts/{id}", s.handleUIHostDetail)
+ r.Get("/hosts/{id}/sources", s.handleUIHostSources)
+ r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet)
+ r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet)
+ r.Get("/hosts/{id}/repo", s.handleUIHostRepo)
+ r.Get("/hosts/{id}/schedules", s.handleUISchedulesList)
+ r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet)
+ r.Get("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleEditGet)
+ r.Get("/jobs/{id}", s.handleUIJobDetail)
+ r.Get("/hosts/{id}/restore", s.handleUIRestoreGet)
+ r.Get("/hosts/{id}/snapshots/{sid}/restore", s.handleUIRestoreGet)
+ r.Get("/hosts/{id}/restore/tree", s.handleUIRestoreTree)
+ r.Get("/alerts", s.handleUIAlerts)
+ r.Get("/audit", s.handleUIAudit)
+ r.Get("/audit.csv", s.handleUIAuditCSV)
+
+ // Self-service account page (any role).
+ r.Get("/settings/account", s.handleUIAccountGet)
+ r.Post("/settings/account", s.handleUIAccountPost)
+ }
+ })
+
+ // Operator band — mutating endpoints up to backup ops.
+ r.Group(func(r chi.Router) {
+ r.Use(s.requireRole(store.RoleOperator))
+
+ // Pending hosts approval.
+ r.Post("/api/pending-hosts/{id}/accept", s.handleAcceptPendingHost)
+ r.Post("/api/pending-hosts/{id}/reject", s.handleRejectPendingHost)
+ r.Post("/api/enrollment-tokens", s.handleCreateEnrollmentToken)
+
+ // Run-now, restore, repo ops.
+ r.Post("/api/hosts/{id}/jobs", s.handleRunNow)
+ r.Put("/api/hosts/{id}/repo-credentials", s.handleSetHostCredentials)
+ r.Put("/api/hosts/{id}/admin-credentials", s.handleSetAdminCredentials)
+ r.Delete("/api/hosts/{id}/admin-credentials", s.handleDeleteAdminCredentials)
+ r.Post("/api/hosts/{id}/schedules", s.handleCreateSchedule)
+ r.Put("/api/hosts/{id}/schedules/{sid}", s.handleUpdateSchedule)
+ r.Delete("/api/hosts/{id}/schedules/{sid}", s.handleDeleteSchedule)
+ r.Post("/api/hosts/{id}/source-groups", s.handleCreateSourceGroup)
+ r.Put("/api/hosts/{id}/source-groups/{gid}", s.handleUpdateSourceGroup)
+ r.Delete("/api/hosts/{id}/source-groups/{gid}", s.handleDeleteSourceGroup)
+ r.Put("/api/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance)
+ r.Put("/api/hosts/{id}/bandwidth", s.handleUpdateHostBandwidth)
+ r.Post("/api/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
+ r.Post("/api/hosts/{id}/repo/prune", s.handleRunRepoPrune)
+ r.Post("/api/hosts/{id}/repo/check", s.handleRunRepoCheck)
+ r.Post("/api/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
+ r.Post("/api/jobs/{id}/cancel", s.handleCancelJob)
+ r.Post("/api/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
+
+ // HTMX form variants outside /api.
+ r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
+ r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup)
+ r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune)
+ r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck)
+ r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
+ r.Post("/hosts/{id}/run-backup", s.handleUIRunBackupGone)
+ r.Post("/hosts/{id}/init-repo", s.handleUIInitRepoGone)
+
+ if s.deps.UI != nil {
+ r.Get("/hosts/new", s.handleUIAddHostGet)
+ r.Post("/hosts/new", s.handleUIAddHostPost)
+ r.Get("/hosts/pending/{token}", s.handleUIPendingHost)
+ r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting)
+ r.Post("/hosts/{id}/sources/new", s.handleUISourceGroupSave)
+ r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave)
+ r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete)
+ r.Post("/hosts/{id}/repo/credentials", s.handleUIRepoCredentialsSave)
+ r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave)
+ r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave)
+ r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit)
+ r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave)
+ r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave)
+ r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete)
+ r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave)
+ r.Post("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleSave)
+ r.Post("/hosts/{id}/schedules/{sid}/delete", s.handleUIScheduleDelete)
+ r.Post("/hosts/{id}/schedules/{sid}/run", s.handleUIScheduleRun)
+ r.Get("/hosts/{id}/restore", s.handleUIRestoreGet) // duplicate of viewer GET; no-op
+ r.Post("/hosts/{id}/restore", s.handleUIRestorePost)
+ r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge)
+ r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve)
+ }
+ })
+
+ // Admin band — channels, users, server-shape config.
+ r.Group(func(r chi.Router) {
+ r.Use(s.requireRole(store.RoleAdmin))
+
+ r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest)
+
+ // User management API.
+ r.Get("/api/users", s.handleAPIUsersList)
+ r.Post("/api/users", s.handleAPIUserCreate)
+ r.Get("/api/users/{id}", s.handleAPIUserGet)
+ r.Patch("/api/users/{id}", s.handleAPIUserPatch)
+ r.Post("/api/users/{id}/disable", s.handleAPIUserDisable)
+ r.Post("/api/users/{id}/enable", s.handleAPIUserEnable)
+ r.Post("/api/users/{id}/regenerate-setup", s.handleAPIUserRegenerateSetup)
+ r.Post("/api/users/{id}/force-logout", s.handleAPIUserForceLogout)
+
+ if s.deps.UI != nil {
+ // Settings shell + sub-tabs.
+ r.Get("/settings", s.handleUISettings)
+ r.Get("/settings/notifications", s.handleUINotificationsList)
+ r.Get("/settings/notifications/new", s.handleUINotificationNewGet)
+ r.Post("/settings/notifications/new", s.handleUINotificationNewPost)
+ r.Get("/settings/notifications/{id}/edit", s.handleUINotificationEditGet)
+ r.Post("/settings/notifications/{id}/edit", s.handleUINotificationEditPost)
+ r.Post("/settings/notifications/{id}/delete", s.handleUINotificationDelete)
+ r.Post("/settings/notifications/{id}/toggle", s.handleUINotificationToggle)
+
+ // Users tab.
+ r.Get("/settings/users", s.handleUIUsersList)
+ r.Get("/settings/users/new", s.handleUIUserNewGet)
+ r.Post("/settings/users/new", s.handleUIUserNewPost)
+ r.Get("/settings/users/{id}/edit", s.handleUIUserEditGet)
+ r.Post("/settings/users/{id}/edit", s.handleUIUserEditPost)
+ r.Get("/settings/users/{id}/setup-link", s.handleUIUserSetupLinkGet)
+ r.Post("/settings/users/{id}/disable", s.handleUIUserDisablePost)
+ r.Post("/settings/users/{id}/enable", s.handleUIUserEnablePost)
+ r.Post("/settings/users/{id}/regenerate-setup", s.handleUIUserRegenerateSetupPost)
+ r.Post("/settings/users/{id}/force-logout", s.handleUIUserForceLogoutPost)
+ }
+ })
+}
+```
+
+This drops a few duplicate route registrations the old code had (e.g. /api/hosts/.../run mounted twice). Verify no caller depends on the duplicates by grepping for any URL patterns that might now 404.
+
+- [ ] **Step 3: Run tests, expect failures from existing tests that assumed open access**
+
+Run: `go test ./internal/server/http/...`
+Expected: failures in tests that exercise mutations without authenticating, or that authenticate as the wrong role. Read each failure carefully and either:
+- (a) update the test to log in as admin (the existing `loginAsAdmin` helper) — applies to most cases
+- (b) update the test to assert 401/403 if it was *checking* the absence of a check
+
+Walk every failing test individually. Don't blanket-fix.
+
+- [ ] **Step 4: Confirm a clean run**
+
+Run: `go test ./internal/server/http/...`
+Expected: all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/server/http/server.go
+git commit -m "http: re-group routes by role band, fail-closed admin default"
+```
+
+---
+
+### Task B5: Confirm fail-closed default
+
+**Files:**
+- Modify: `internal/server/http/rbac_test.go`
+
+- [ ] **Step 1: Write a test that proves an unbanded route is admin-only**
+
+There isn't a literal "default group at the bottom" — chi's last `r.Use` wins for that group, but the structure above places everything explicitly. The fail-closed property is enforced by code review and by the absence of any "no middleware" group except for the public one at the top.
+
+Add a test that documents the property by exercising a known admin-band endpoint with operator creds:
+
+```go
+func TestAdminBandRejectsOperator(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ makeUser(t, srv, "admin1", store.RoleAdmin)
+ opID := makeUser(t, srv, "op1", store.RoleOperator)
+ cookie := loginAs(t, srv, opID)
+
+ req, _ := stdhttp.NewRequest("GET", url+"/api/users", nil)
+ req.AddCookie(cookie)
+ res, err := stdhttp.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("GET: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusForbidden {
+ t.Errorf("status: got %d want 403", res.StatusCode)
+ }
+}
+```
+
+- [ ] **Step 2: Run test, expect 404 (handler not yet implemented in Task E)**
+
+Run: `go test ./internal/server/http/ -run TestAdminBandRejectsOperator`
+Expected: FAIL — `/api/users` 404s because we haven't added the handler yet. *That's actually the correct fail-closed behaviour for a non-existent endpoint*; once Task E1 lands the handler, this test will assert 403 (operator-rejected). Mark this task done and move on; the test will start asserting the right thing once E1 is in.
+
+- [ ] **Step 3: Commit (the test exists, gates Task E)**
+
+```bash
+git add internal/server/http/rbac_test.go
+git commit -m "http: failing test for admin-band reject of operator (lands fully in E1)"
+```
+
+---
+
+## Slice C — Session re-validation
+
+### Task C1: requireUser rejects disabled users + login rejects disabled
+
+**Files:**
+- Modify: `internal/server/http/jobs.go` (the `requireUser` helper)
+- Modify: `internal/server/http/ui_handlers.go` (the `loadAuthedUser` helper)
+- Modify: `internal/server/http/auth.go` (the login handler)
+
+- [ ] **Step 1: Write a failing test**
+
+Append to `internal/server/http/rbac_test.go`:
+
+```go
+func TestRequireRoleRejectsDisabledMidSession(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ uid := makeUser(t, srv, "victim", store.RoleOperator)
+ cookie := loginAs(t, srv, uid)
+
+ // Disable the user *while their session is still valid*.
+ if err := srv.deps.Store.DisableUser(t.Context(), uid, time.Now().UTC()); err != nil {
+ t.Fatalf("disable: %v", err)
+ }
+
+ req, _ := stdhttp.NewRequest("GET", url+"/api/hosts", nil)
+ req.AddCookie(cookie)
+ res, err := stdhttp.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("GET: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusUnauthorized {
+ t.Errorf("status: got %d want 401", res.StatusCode)
+ }
+}
+
+func TestLoginRejectsDisabledUser(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ uid := makeUser(t, srv, "disabled1", store.RoleOperator)
+ if err := srv.deps.Store.DisableUser(t.Context(), uid, time.Now().UTC()); err != nil {
+ t.Fatalf("disable: %v", err)
+ }
+
+ body, _ := json.Marshal(map[string]string{
+ "username": "disabled1", "password": "test-password",
+ })
+ res, err := stdhttp.Post(url+"/api/auth/login", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusUnauthorized {
+ t.Errorf("status: got %d want 401", res.StatusCode)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests, verify they fail**
+
+Run: `go test ./internal/server/http/ -run "TestRequireRoleRejectsDisabledMidSession|TestLoginRejectsDisabledUser"`
+Expected: FAIL — current implementations don't check `disabled_at`.
+
+- [ ] **Step 3: Update requireUser**
+
+In `internal/server/http/jobs.go`:
+
+```go
+func (s *Server) requireUser(r *stdhttp.Request) (*store.User, bool) {
+ c, err := r.Cookie(sessionCookieName)
+ if err != nil {
+ return nil, false
+ }
+ sess, err := s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value))
+ if err != nil {
+ return nil, false
+ }
+ u, err := s.deps.Store.GetUserByID(r.Context(), sess.UserID)
+ if err != nil {
+ return nil, false
+ }
+ if u.DisabledAt != nil {
+ // Disabled mid-session — kill the session and reject the
+ // request as if it were unauthenticated.
+ _ = s.deps.Store.DeleteSession(r.Context(), sess.ID)
+ return nil, false
+ }
+ return u, true
+}
+```
+
+- [ ] **Step 4: Update loadAuthedUser**
+
+In `internal/server/http/ui_handlers.go` (find the `loadAuthedUser` body):
+
+```go
+// (existing function — add the disabled check)
+u, err := s.deps.Store.GetUserByID(r.Context(), sess.UserID)
+if err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ return nil, nil
+ }
+ return nil, err
+}
+if u.DisabledAt != nil {
+ _ = s.deps.Store.DeleteSession(r.Context(), sess.ID)
+ return nil, nil
+}
+return &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}, nil
+```
+
+- [ ] **Step 5: Update login handler**
+
+In `internal/server/http/auth.go` (find the `handleLogin` flow), after the user is fetched + password compared:
+
+```go
+if u.DisabledAt != nil {
+ writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
+ return
+}
+```
+
+(Same pattern in `handleUILoginPost` — return the standard "Invalid credentials" error so we don't leak whether the username exists but is disabled.)
+
+- [ ] **Step 6: Run tests to verify they pass**
+
+Run: `go test ./internal/server/http/...`
+Expected: all PASS.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/server/http/jobs.go internal/server/http/ui_handlers.go \
+ internal/server/http/auth.go internal/server/http/rbac_test.go
+git commit -m "http: session/login reject disabled users; mid-session disable kicks immediately"
+```
+
+---
+
+## Slice D — Setup-token flow
+
+### Task D1: Setup landing GET
+
+**Files:**
+- Create: `internal/server/http/setup_handler.go`
+- Create: `web/templates/pages/setup.html`
+
+- [ ] **Step 1: Write a failing test**
+
+Append to `internal/server/http/users_test.go` (create the file with package + imports if missing):
+
+```go
+package http
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "io"
+ stdhttp "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/oklog/ulid/v2"
+
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
+)
+
+func TestSetupGetValidToken(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ now := time.Now().UTC()
+
+ uid := ulid.Make().String()
+ if err := srv.deps.Store.CreateUser(t.Context(), store.User{
+ ID: uid, Username: "newbie", PasswordHash: "",
+ Role: store.RoleOperator, CreatedAt: now,
+ MustChangePassword: true,
+ }); err != nil {
+ t.Fatalf("create: %v", err)
+ }
+
+ raw := "raw-token-1234567890"
+ hash := sha256Hex(raw)
+ if err := srv.deps.Store.SetSetupToken(context.Background(), store.SetupToken{
+ UserID: uid, TokenHash: hash,
+ ExpiresAt: now.Add(time.Hour), CreatedAt: now,
+ }); err != nil {
+ t.Fatalf("set token: %v", err)
+ }
+
+ res, err := stdhttp.Get(url + "/setup?token=" + raw)
+ if err != nil {
+ t.Fatalf("GET: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusOK {
+ t.Errorf("status: got %d want 200", res.StatusCode)
+ }
+ body, _ := io.ReadAll(res.Body)
+ if !strings.Contains(string(body), "newbie") {
+ t.Errorf("expected username in body: %s", body)
+ }
+}
+
+func TestSetupGetExpiredToken(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ now := time.Now().UTC()
+
+ uid := ulid.Make().String()
+ _ = srv.deps.Store.CreateUser(t.Context(), store.User{
+ ID: uid, Username: "stale",
+ PasswordHash: "", Role: store.RoleViewer, CreatedAt: now,
+ MustChangePassword: true,
+ })
+
+ raw := "expired-token"
+ _ = srv.deps.Store.SetSetupToken(context.Background(), store.SetupToken{
+ UserID: uid, TokenHash: sha256Hex(raw),
+ ExpiresAt: now.Add(-time.Minute), CreatedAt: now.Add(-2 * time.Hour),
+ })
+
+ res, err := stdhttp.Get(url + "/setup?token=" + raw)
+ if err != nil {
+ t.Fatalf("GET: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusGone {
+ t.Errorf("status: got %d want 410", res.StatusCode)
+ }
+}
+
+func sha256Hex(s string) string {
+ h := sha256.Sum256([]byte(s))
+ return hex.EncodeToString(h[:])
+}
+```
+
+- [ ] **Step 2: Run tests, verify they fail**
+
+Run: `go test ./internal/server/http/ -run TestSetupGet`
+Expected: FAIL — `/setup` 404s.
+
+- [ ] **Step 3: Implement the GET handler**
+
+```go
+// internal/server/http/setup_handler.go
+//
+// Public landing page for the user-setup link emitted by the
+// admin's "+ Add user" / "Regenerate setup link" flow.
+//
+// Routes (wired in server.go):
+// GET /setup → handleUISetupGet
+// POST /setup → handleUISetupPost
+//
+// The token in the querystring (`?token=`) is the credential.
+// Auth middleware does not run on these routes.
+package http
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "log/slog"
+ stdhttp "net/http"
+ "time"
+
+ "github.com/oklog/ulid/v2"
+
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
+)
+
+type setupPage struct {
+ Username string
+ Token string // round-tripped to the POST form
+ Error string // displayed when password validation fails
+}
+
+// hashSetupToken is the canonical hashing for setup tokens. Mirrors
+// what the admin handler uses when SetSetupToken is called, so the
+// digest at rest matches what GET /setup hashes.
+func hashSetupToken(raw string) string {
+ h := sha256.Sum256([]byte(raw))
+ return hex.EncodeToString(h[:])
+}
+
+func (s *Server) handleUISetupGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ raw := r.URL.Query().Get("token")
+ if raw == "" {
+ s.renderSetupExpired(w, r)
+ return
+ }
+ tok, err := s.deps.Store.LookupSetupToken(r.Context(), hashSetupToken(raw))
+ if err != nil {
+ s.renderSetupExpired(w, r)
+ return
+ }
+ if tok.ExpiresAt.Before(time.Now().UTC()) {
+ s.renderSetupExpired(w, r)
+ return
+ }
+ u, err := s.deps.Store.GetUserByID(r.Context(), tok.UserID)
+ if err != nil {
+ s.renderSetupExpired(w, r)
+ return
+ }
+ view := s.baseView(r, nil)
+ view.Title = "Set your password · restic-manager"
+ view.Page = setupPage{Username: u.Username, Token: raw}
+ if err := s.deps.UI.Render(w, "setup", view); err != nil {
+ slog.Error("ui setup: render", "err", err)
+ }
+}
+
+func (s *Server) renderSetupExpired(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ w.WriteHeader(stdhttp.StatusGone)
+ view := s.baseView(r, nil)
+ view.Title = "Link expired · restic-manager"
+ view.Page = setupPage{Error: "expired"}
+ _ = s.deps.UI.Render(w, "setup", view)
+}
+```
+
+- [ ] **Step 4: Add the template**
+
+```html
+
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "content"}}
+{{$page := .Page}}
+
+ {{if eq $page.Error "expired"}}
+
Link expired
+
+ This setup link has expired or is invalid. Setup links are valid
+ for one hour from the moment your administrator generates them.
+
+
+ Contact your administrator and ask them to regenerate the link.
+
+ {{else}}
+
+ Welcome, {{$page.Username}}
+
+
+ Pick a password to finish setting up your account. The link expires
+ one hour after your administrator generated it, so don't dawdle.
+
+
+ {{if and $page.Error (ne $page.Error "expired")}}
+
{{$page.Error}}
+ {{end}}
+ {{end}}
+
+{{end}}
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run: `go test ./internal/server/http/ -run TestSetupGet`
+Expected: both PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/server/http/setup_handler.go web/templates/pages/setup.html \
+ internal/server/http/users_test.go
+git commit -m "http: GET /setup landing page with expiry handling"
+```
+
+---
+
+### Task D2: Setup POST — set password and log in
+
+**Files:**
+- Modify: `internal/server/http/setup_handler.go`
+
+- [ ] **Step 1: Write a failing test**
+
+Append to `internal/server/http/users_test.go`:
+
+```go
+import "net/url"
+
+func TestSetupPostHappyPath(t *testing.T) {
+ t.Parallel()
+ srv, urlBase := newTestServer(t, false)
+ now := time.Now().UTC()
+
+ uid := ulid.Make().String()
+ _ = srv.deps.Store.CreateUser(t.Context(), store.User{
+ ID: uid, Username: "newbie",
+ PasswordHash: "", Role: store.RoleOperator, CreatedAt: now,
+ MustChangePassword: true,
+ })
+ raw := "happy-token"
+ _ = srv.deps.Store.SetSetupToken(t.Context(), store.SetupToken{
+ UserID: uid, TokenHash: sha256Hex(raw),
+ ExpiresAt: now.Add(time.Hour), CreatedAt: now,
+ })
+
+ form := url.Values{}
+ form.Set("token", raw)
+ form.Set("password", "averylongpassword")
+ form.Set("password_confirm", "averylongpassword")
+ req, _ := stdhttp.NewRequest("POST", urlBase+"/setup",
+ strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ c := &stdhttp.Client{CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error {
+ return stdhttp.ErrUseLastResponse
+ }}
+ res, err := c.Do(req)
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusSeeOther {
+ t.Errorf("status: got %d want 303", res.StatusCode)
+ }
+ if res.Header.Get("Location") != "/" {
+ t.Errorf("location: got %q want /", res.Header.Get("Location"))
+ }
+
+ // Token is consumed.
+ if _, err := srv.deps.Store.LookupSetupToken(t.Context(), sha256Hex(raw)); err == nil {
+ t.Error("token should be deleted after consumption")
+ }
+
+ // User can now log in via the normal route.
+ logBody, _ := json.Marshal(map[string]string{
+ "username": "newbie", "password": "averylongpassword",
+ })
+ loginRes, _ := stdhttp.Post(urlBase+"/api/auth/login",
+ "application/json", bytes.NewReader(logBody))
+ if loginRes.StatusCode != stdhttp.StatusOK {
+ body, _ := io.ReadAll(loginRes.Body)
+ t.Errorf("login: %d %s", loginRes.StatusCode, body)
+ }
+}
+```
+
+(Add the `"encoding/json"` import if not already present.)
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `go test ./internal/server/http/ -run TestSetupPostHappyPath`
+Expected: FAIL — POST /setup not implemented.
+
+- [ ] **Step 3: Implement the POST handler**
+
+Append to `internal/server/http/setup_handler.go`:
+
+```go
+func (s *Server) handleUISetupPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ if err := r.ParseForm(); err != nil {
+ stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
+ return
+ }
+ raw := r.PostForm.Get("token")
+ pw := r.PostForm.Get("password")
+ pw2 := r.PostForm.Get("password_confirm")
+
+ if raw == "" {
+ s.renderSetupExpired(w, r)
+ return
+ }
+ if pw == "" || pw2 == "" || pw != pw2 || len(pw) < 12 {
+ s.renderSetupForm(w, r, raw, "Passwords must match and be at least 12 characters.")
+ return
+ }
+
+ tok, err := s.deps.Store.LookupSetupToken(r.Context(), hashSetupToken(raw))
+ if err != nil || tok.ExpiresAt.Before(time.Now().UTC()) {
+ s.renderSetupExpired(w, r)
+ return
+ }
+ u, err := s.deps.Store.GetUserByID(r.Context(), tok.UserID)
+ if err != nil {
+ s.renderSetupExpired(w, r)
+ return
+ }
+
+ hash, err := auth.HashPassword(pw)
+ if err != nil {
+ slog.Error("setup: hash password", "err", err)
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ if err := s.deps.Store.SetPasswordHash(r.Context(), u.ID, hash); err != nil {
+ slog.Error("setup: set password", "err", err)
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ if err := s.deps.Store.DeleteSetupToken(r.Context(), u.ID); err != nil {
+ slog.Warn("setup: delete token", "err", err)
+ // Non-fatal — password is set, audit will reflect it.
+ }
+
+ // Drop a session cookie so the user lands authenticated on /.
+ rawSession, err := auth.GenerateSessionToken()
+ if err != nil {
+ slog.Error("setup: session token", "err", err)
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ hashed := auth.HashToken(rawSession)
+ now := time.Now().UTC()
+ if err := s.deps.Store.CreateSession(r.Context(), store.Session{
+ ID: hashed, UserID: u.ID, CreatedAt: now,
+ ExpiresAt: now.Add(8 * time.Hour),
+ }, hashed); err != nil {
+ slog.Error("setup: create session", "err", err)
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ stdhttp.SetCookie(w, &stdhttp.Cookie{
+ Name: sessionCookieName, Value: rawSession,
+ Path: "/", HttpOnly: true,
+ SameSite: stdhttp.SameSiteLaxMode,
+ Secure: s.deps.Cfg.CookieSecure,
+ Expires: now.Add(8 * time.Hour),
+ })
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(),
+ UserID: &u.ID, Actor: "user",
+ Action: "user.setup_completed",
+ TargetKind: ptr("user"), TargetID: &u.ID,
+ TS: now,
+ })
+ stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther)
+}
+
+// renderSetupForm re-renders the setup page with an inline error
+// (e.g. password mismatch). 200 OK with the form intact so the user
+// can correct without losing the token.
+func (s *Server) renderSetupForm(w stdhttp.ResponseWriter, r *stdhttp.Request, token, errMsg string) {
+ view := s.baseView(r, nil)
+ view.Title = "Set your password · restic-manager"
+ // Look up the username again so the page header still shows it
+ // after a validation bounce.
+ username := ""
+ if tok, err := s.deps.Store.LookupSetupToken(r.Context(), hashSetupToken(token)); err == nil {
+ if u, err := s.deps.Store.GetUserByID(r.Context(), tok.UserID); err == nil {
+ username = u.Username
+ }
+ }
+ view.Page = setupPage{Username: username, Token: token, Error: errMsg}
+ _ = s.deps.UI.Render(w, "setup", view)
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/server/http/ -run TestSetup`
+Expected: all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/server/http/setup_handler.go internal/server/http/users_test.go
+git commit -m "http: POST /setup — set password, drop session, audit setup_completed"
+```
+
+---
+
+## Slice E — User CRUD API
+
+(Each task in this slice follows the same TDD shape — write failing test, verify failure, implement, verify pass, commit. The handler files referenced here are kept short and focused.)
+
+### Task E1: GET /api/users (list)
+
+**Files:**
+- Create: `internal/server/http/api_users.go`
+
+- [ ] **Step 1: Write a failing test**
+
+Append to `internal/server/http/users_test.go`:
+
+```go
+func TestAPIUsersList(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
+ makeUser(t, srv, "op1", store.RoleOperator)
+ cookie := loginAs(t, srv, adminID)
+
+ req, _ := stdhttp.NewRequest("GET", url+"/api/users", nil)
+ req.AddCookie(cookie)
+ res, err := stdhttp.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("GET: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusOK {
+ t.Errorf("status: got %d", res.StatusCode)
+ }
+ var got struct {
+ Users []store.User `json:"users"`
+ }
+ _ = json.NewDecoder(res.Body).Decode(&got)
+ if len(got.Users) != 2 {
+ t.Errorf("count: got %d want 2", len(got.Users))
+ }
+}
+```
+
+- [ ] **Step 2: Run test, verify it fails (404)**
+
+Run: `go test ./internal/server/http/ -run TestAPIUsersList`
+Expected: FAIL — handler not registered.
+
+- [ ] **Step 3: Implement the handler**
+
+```go
+// internal/server/http/api_users.go
+package http
+
+import (
+ "encoding/json"
+ "log/slog"
+ stdhttp "net/http"
+)
+
+type listUsersResponse struct {
+ Users []apiUser `json:"users"`
+}
+
+type apiUser struct {
+ ID string `json:"id"`
+ Username string `json:"username"`
+ Role string `json:"role"`
+ Email *string `json:"email,omitempty"`
+ Disabled bool `json:"disabled"`
+ MustChangePassword bool `json:"must_change_password"`
+ CreatedAt string `json:"created_at"`
+ LastLoginAt *string `json:"last_login_at,omitempty"`
+}
+
+func (s *Server) handleAPIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ users, err := s.deps.Store.ListUsers(r.Context())
+ if err != nil {
+ slog.Error("api users: list", "err", err)
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ out := make([]apiUser, len(users))
+ for i, u := range users {
+ var lastLogin *string
+ if u.LastLoginAt != nil {
+ s := u.LastLoginAt.UTC().Format("2006-01-02T15:04:05Z")
+ lastLogin = &s
+ }
+ out[i] = apiUser{
+ ID: u.ID,
+ Username: u.Username,
+ Role: string(u.Role),
+ Email: u.Email,
+ Disabled: u.DisabledAt != nil,
+ MustChangePassword: u.MustChangePassword,
+ CreatedAt: u.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
+ LastLoginAt: lastLogin,
+ }
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(listUsersResponse{Users: out})
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/server/http/ -run TestAPIUsersList`
+Expected: PASS. Also re-run `TestAdminBandRejectsOperator` from Task B5 — it should now assert 403 (the route exists, the operator gets denied).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/server/http/api_users.go internal/server/http/users_test.go
+git commit -m "http: GET /api/users (list)"
+```
+
+---
+
+### Task E2: POST /api/users (create + setup token)
+
+**Files:**
+- Modify: `internal/server/http/api_users.go`
+
+- [ ] **Step 1: Write a failing test**
+
+Append to `users_test.go`:
+
+```go
+func TestAPIUserCreate(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
+ cookie := loginAs(t, srv, adminID)
+
+ body, _ := json.Marshal(map[string]any{
+ "username": "Bob", "email": "bob@example.com", "role": "operator",
+ })
+ req, _ := stdhttp.NewRequest("POST", url+"/api/users", bytes.NewReader(body))
+ req.AddCookie(cookie)
+ req.Header.Set("Content-Type", "application/json")
+ res, err := stdhttp.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("POST: %v", err)
+ }
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusCreated {
+ body, _ := io.ReadAll(res.Body)
+ t.Errorf("status: got %d body=%s", res.StatusCode, body)
+ }
+ var got struct {
+ ID string `json:"id"`
+ SetupURL string `json:"setup_url"`
+ }
+ _ = json.NewDecoder(res.Body).Decode(&got)
+ if got.ID == "" || got.SetupURL == "" {
+ t.Errorf("missing fields: %+v", got)
+ }
+ if !strings.Contains(got.SetupURL, "/setup?token=") {
+ t.Errorf("setup_url shape: %q", got.SetupURL)
+ }
+
+ // Verify lowercase-normalised.
+ u, err := srv.deps.Store.GetUserByUsername(t.Context(), "bob")
+ if err != nil {
+ t.Fatalf("get: %v", err)
+ }
+ if u.Username != "bob" {
+ t.Errorf("username: got %q want bob", u.Username)
+ }
+ if !u.MustChangePassword {
+ t.Error("must_change_password not set")
+ }
+}
+
+func TestAPIUserCreateRejectsDuplicateEnabled(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
+ makeUser(t, srv, "alice", store.RoleOperator)
+ cookie := loginAs(t, srv, adminID)
+
+ body, _ := json.Marshal(map[string]any{
+ "username": "ALICE", "role": "operator",
+ })
+ req, _ := stdhttp.NewRequest("POST", url+"/api/users", bytes.NewReader(body))
+ req.AddCookie(cookie)
+ req.Header.Set("Content-Type", "application/json")
+ res, _ := stdhttp.DefaultClient.Do(req)
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusConflict {
+ t.Errorf("status: got %d want 409", res.StatusCode)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests, verify they fail**
+
+Run: `go test ./internal/server/http/ -run "TestAPIUserCreate$|TestAPIUserCreateRejects"`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement the handler**
+
+Append to `internal/server/http/api_users.go`:
+
+```go
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "net/mail"
+ "strings"
+ "time"
+
+ "github.com/oklog/ulid/v2"
+
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
+)
+
+type createUserRequest struct {
+ Username string `json:"username"`
+ Email string `json:"email,omitempty"`
+ Role string `json:"role"`
+}
+
+type createUserResponse struct {
+ ID string `json:"id"`
+ SetupURL string `json:"setup_url"`
+}
+
+// generateSetupToken returns 32 random bytes hex-encoded (64 chars).
+func generateSetupToken() (string, error) {
+ var b [32]byte
+ if _, err := rand.Read(b[:]); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(b[:]), nil
+}
+
+func validRole(r string) (store.Role, bool) {
+ switch r {
+ case "admin":
+ return store.RoleAdmin, true
+ case "operator":
+ return store.RoleOperator, true
+ case "viewer":
+ return store.RoleViewer, true
+ }
+ return "", false
+}
+
+func (s *Server) handleAPIUserCreate(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ actor, _ := s.requireUser(r) // already gated by middleware
+ var req createUserRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
+ return
+ }
+ uname := strings.ToLower(strings.TrimSpace(req.Username))
+ if uname == "" {
+ writeJSONError(w, stdhttp.StatusBadRequest, "username_required", "")
+ return
+ }
+ role, ok := validRole(req.Role)
+ if !ok {
+ writeJSONError(w, stdhttp.StatusBadRequest, "invalid_role", "")
+ return
+ }
+ if req.Email != "" {
+ if _, err := mail.ParseAddress(req.Email); err != nil {
+ writeJSONError(w, stdhttp.StatusBadRequest, "invalid_email", err.Error())
+ return
+ }
+ }
+
+ // Check for collision against existing user (case-insensitive).
+ existing, err := s.deps.Store.GetUserByUsername(r.Context(), uname)
+ if err == nil {
+ body := map[string]any{
+ "error": "username_taken",
+ "existing_user_id": existing.ID,
+ "disabled": existing.DisabledAt != nil,
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(stdhttp.StatusConflict)
+ _ = json.NewEncoder(w).Encode(body)
+ return
+ } else if !errors.Is(err, store.ErrNotFound) {
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+
+ id := ulid.Make().String()
+ now := time.Now().UTC()
+ var emailPtr *string
+ if req.Email != "" {
+ em := strings.ToLower(strings.TrimSpace(req.Email))
+ emailPtr = &em
+ }
+ if err := s.deps.Store.CreateUser(r.Context(), store.User{
+ ID: id, Username: uname, PasswordHash: "",
+ Role: role, Email: emailPtr, CreatedAt: now,
+ MustChangePassword: true,
+ }); err != nil {
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+
+ rawToken, err := generateSetupToken()
+ if err != nil {
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ var actorID *string
+ if actor != nil {
+ actorID = &actor.ID
+ }
+ if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{
+ UserID: id, TokenHash: hashSetupToken(rawToken),
+ ExpiresAt: now.Add(time.Hour),
+ CreatedAt: now, CreatedBy: actorID,
+ }); err != nil {
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(), UserID: actorID, Actor: "user",
+ Action: "user.created", TargetKind: ptr("user"), TargetID: &id,
+ TS: now,
+ })
+
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.WriteHeader(stdhttp.StatusCreated)
+ _ = json.NewEncoder(w).Encode(createUserResponse{
+ ID: id,
+ SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken,
+ })
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/server/http/ -run "TestAPIUserCreate"`
+Expected: both PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/server/http/api_users.go internal/server/http/users_test.go
+git commit -m "http: POST /api/users — create + setup-token + audit"
+```
+
+---
+
+### Task E3: GET / PATCH /api/users/{id}
+
+**Files:**
+- Modify: `internal/server/http/api_users.go`
+
+- [ ] **Step 1: Write tests**
+
+```go
+func TestAPIUserGet(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
+ target := makeUser(t, srv, "carol", store.RoleViewer)
+ cookie := loginAs(t, srv, adminID)
+
+ req, _ := stdhttp.NewRequest("GET", url+"/api/users/"+target, nil)
+ req.AddCookie(cookie)
+ res, _ := stdhttp.DefaultClient.Do(req)
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusOK {
+ t.Errorf("status: got %d", res.StatusCode)
+ }
+}
+
+func TestAPIUserPatchRoleAndEmail(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
+ target := makeUser(t, srv, "carol", store.RoleViewer)
+ cookie := loginAs(t, srv, adminID)
+
+ body, _ := json.Marshal(map[string]any{
+ "role": "operator", "email": "carol@example.com",
+ })
+ req, _ := stdhttp.NewRequest("PATCH", url+"/api/users/"+target, bytes.NewReader(body))
+ req.AddCookie(cookie)
+ req.Header.Set("Content-Type", "application/json")
+ res, _ := stdhttp.DefaultClient.Do(req)
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusOK {
+ body, _ := io.ReadAll(res.Body)
+ t.Errorf("status: got %d body=%s", res.StatusCode, body)
+ }
+ got, _ := srv.deps.Store.GetUserByID(t.Context(), target)
+ if got.Role != store.RoleOperator {
+ t.Errorf("role: got %q", got.Role)
+ }
+ if got.Email == nil || *got.Email != "carol@example.com" {
+ t.Errorf("email: got %v", got.Email)
+ }
+}
+
+func TestAPIUserPatchRejectsLastAdminDemote(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
+ cookie := loginAs(t, srv, adminID)
+
+ body, _ := json.Marshal(map[string]any{"role": "viewer"})
+ req, _ := stdhttp.NewRequest("PATCH", url+"/api/users/"+adminID, bytes.NewReader(body))
+ req.AddCookie(cookie)
+ req.Header.Set("Content-Type", "application/json")
+ res, _ := stdhttp.DefaultClient.Do(req)
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusConflict {
+ t.Errorf("status: got %d want 409", res.StatusCode)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests, verify they fail**
+
+Run: `go test ./internal/server/http/ -run "TestAPIUserGet|TestAPIUserPatch"`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement handlers**
+
+```go
+import "github.com/go-chi/chi/v5"
+
+func (s *Server) handleAPIUserGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ id := chi.URLParam(r, "id")
+ u, err := s.deps.Store.GetUserByID(r.Context(), id)
+ if err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "")
+ return
+ }
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ out := apiUser{
+ ID: u.ID, Username: u.Username, Role: string(u.Role),
+ Email: u.Email, Disabled: u.DisabledAt != nil,
+ MustChangePassword: u.MustChangePassword,
+ CreatedAt: u.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
+ }
+ if u.LastLoginAt != nil {
+ s := u.LastLoginAt.UTC().Format("2006-01-02T15:04:05Z")
+ out.LastLoginAt = &s
+ }
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(out)
+}
+
+type patchUserRequest struct {
+ Role *string `json:"role,omitempty"`
+ Email *string `json:"email,omitempty"`
+}
+
+func (s *Server) handleAPIUserPatch(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ actor, _ := s.requireUser(r)
+ id := chi.URLParam(r, "id")
+ u, err := s.deps.Store.GetUserByID(r.Context(), id)
+ if err != nil {
+ writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "")
+ return
+ }
+ var req patchUserRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
+ return
+ }
+ if req.Role != nil {
+ newRole, ok := validRole(*req.Role)
+ if !ok {
+ writeJSONError(w, stdhttp.StatusBadRequest, "invalid_role", "")
+ return
+ }
+ // Last-admin guard: cannot demote the only enabled admin.
+ if u.Role == store.RoleAdmin && newRole != store.RoleAdmin && u.DisabledAt == nil {
+ n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
+ if n <= 1 {
+ writeJSONError(w, stdhttp.StatusConflict, "last_admin", "")
+ return
+ }
+ }
+ if err := s.deps.Store.SetUserRole(r.Context(), id, newRole); err != nil {
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ }
+ if req.Email != nil {
+ em := strings.TrimSpace(*req.Email)
+ if em != "" {
+ if _, err := mail.ParseAddress(em); err != nil {
+ writeJSONError(w, stdhttp.StatusBadRequest, "invalid_email", err.Error())
+ return
+ }
+ }
+ if err := s.deps.Store.SetUserEmail(r.Context(), id, em); err != nil {
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ }
+ var actorID *string
+ if actor != nil {
+ actorID = &actor.ID
+ }
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(), UserID: actorID, Actor: "user",
+ Action: "user.updated", TargetKind: ptr("user"), TargetID: &id,
+ TS: time.Now().UTC(),
+ })
+ w.WriteHeader(stdhttp.StatusOK)
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/server/http/ -run "TestAPIUserGet|TestAPIUserPatch"`
+Expected: all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/server/http/api_users.go internal/server/http/users_test.go
+git commit -m "http: GET/PATCH /api/users/{id} with last-admin guard"
+```
+
+---
+
+### Task E4: Disable / enable
+
+**Files:**
+- Modify: `internal/server/http/api_users.go`
+
+- [ ] **Step 1: Write tests**
+
+```go
+func TestAPIUserDisable(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
+ makeUser(t, srv, "admin2", store.RoleAdmin) // satisfy last-admin guard
+ target := makeUser(t, srv, "victim", store.RoleOperator)
+ cookie := loginAs(t, srv, adminID)
+
+ req, _ := stdhttp.NewRequest("POST", url+"/api/users/"+target+"/disable", nil)
+ req.AddCookie(cookie)
+ res, _ := stdhttp.DefaultClient.Do(req)
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusOK {
+ t.Errorf("status: got %d", res.StatusCode)
+ }
+ u, _ := srv.deps.Store.GetUserByID(t.Context(), target)
+ if u.DisabledAt == nil {
+ t.Error("disabled_at not set")
+ }
+}
+
+func TestAPIUserDisableRejectsLastAdmin(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
+ cookie := loginAs(t, srv, adminID)
+
+ req, _ := stdhttp.NewRequest("POST", url+"/api/users/"+adminID+"/disable", nil)
+ req.AddCookie(cookie)
+ res, _ := stdhttp.DefaultClient.Do(req)
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusConflict {
+ t.Errorf("status: got %d want 409", res.StatusCode)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests, verify they fail**
+
+Run: `go test ./internal/server/http/ -run TestAPIUserDisable`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement handlers**
+
+```go
+func (s *Server) handleAPIUserDisable(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ actor, _ := s.requireUser(r)
+ id := chi.URLParam(r, "id")
+ u, err := s.deps.Store.GetUserByID(r.Context(), id)
+ if err != nil {
+ writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "")
+ return
+ }
+ if u.Role == store.RoleAdmin && u.DisabledAt == nil {
+ n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
+ if n <= 1 {
+ writeJSONError(w, stdhttp.StatusConflict, "last_admin", "")
+ return
+ }
+ }
+ now := time.Now().UTC()
+ if err := s.deps.Store.DisableUser(r.Context(), id, now); err != nil {
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ // Kick existing sessions so the user is bounced immediately.
+ _, _ = s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
+ var actorID *string
+ if actor != nil {
+ actorID = &actor.ID
+ }
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(), UserID: actorID, Actor: "user",
+ Action: "user.disabled", TargetKind: ptr("user"), TargetID: &id,
+ TS: now,
+ })
+ w.WriteHeader(stdhttp.StatusOK)
+}
+
+func (s *Server) handleAPIUserEnable(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ actor, _ := s.requireUser(r)
+ id := chi.URLParam(r, "id")
+ if err := s.deps.Store.EnableUser(r.Context(), id); err != nil {
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ var actorID *string
+ if actor != nil {
+ actorID = &actor.ID
+ }
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(), UserID: actorID, Actor: "user",
+ Action: "user.enabled", TargetKind: ptr("user"), TargetID: &id,
+ TS: time.Now().UTC(),
+ })
+ w.WriteHeader(stdhttp.StatusOK)
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/server/http/ -run TestAPIUserDisable`
+Expected: all PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/server/http/api_users.go internal/server/http/users_test.go
+git commit -m "http: disable/enable user with last-admin guard + session kick"
+```
+
+---
+
+### Task E5: Regenerate setup link + force logout
+
+**Files:**
+- Modify: `internal/server/http/api_users.go`
+
+- [ ] **Step 1: Write tests**
+
+```go
+func TestAPIUserRegenerateSetup(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
+ target := makeUser(t, srv, "newbie", store.RoleViewer)
+ // Simulate an outstanding token.
+ _ = srv.deps.Store.SetMustChangePassword(t.Context(), target, true)
+ _ = srv.deps.Store.SetSetupToken(t.Context(), store.SetupToken{
+ UserID: target, TokenHash: "old", ExpiresAt: time.Now().UTC().Add(time.Hour),
+ CreatedAt: time.Now().UTC(),
+ })
+ cookie := loginAs(t, srv, adminID)
+
+ req, _ := stdhttp.NewRequest("POST", url+"/api/users/"+target+"/regenerate-setup", nil)
+ req.AddCookie(cookie)
+ res, _ := stdhttp.DefaultClient.Do(req)
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusOK {
+ t.Errorf("status: got %d", res.StatusCode)
+ }
+ var got struct{ SetupURL string `json:"setup_url"` }
+ _ = json.NewDecoder(res.Body).Decode(&got)
+ if !strings.Contains(got.SetupURL, "/setup?token=") {
+ t.Errorf("setup_url: %q", got.SetupURL)
+ }
+ // Old token gone.
+ if _, err := srv.deps.Store.LookupSetupToken(t.Context(), "old"); err == nil {
+ t.Error("old token should be replaced")
+ }
+}
+
+func TestAPIUserForceLogout(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ adminID := makeUser(t, srv, "admin1", store.RoleAdmin)
+ target := makeUser(t, srv, "victim", store.RoleOperator)
+ loginAs(t, srv, target)
+ cookie := loginAs(t, srv, adminID)
+
+ req, _ := stdhttp.NewRequest("POST", url+"/api/users/"+target+"/force-logout", nil)
+ req.AddCookie(cookie)
+ res, _ := stdhttp.DefaultClient.Do(req)
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusOK {
+ t.Errorf("status: got %d", res.StatusCode)
+ }
+ // The victim's session is gone — confirm by counting via a probe.
+ rr, _ := srv.deps.Store.DeleteSessionsByUserID(t.Context(), target)
+ if rr != 0 {
+ t.Errorf("expected 0 remaining sessions, got %d", rr)
+ }
+}
+```
+
+- [ ] **Step 2: Run tests, verify they fail**
+
+Run: `go test ./internal/server/http/ -run "TestAPIUserRegenerateSetup|TestAPIUserForceLogout"`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement handlers**
+
+```go
+type regenerateSetupResponse struct {
+ SetupURL string `json:"setup_url"`
+}
+
+func (s *Server) handleAPIUserRegenerateSetup(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ actor, _ := s.requireUser(r)
+ id := chi.URLParam(r, "id")
+ if _, err := s.deps.Store.GetUserByID(r.Context(), id); err != nil {
+ writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "")
+ return
+ }
+ rawToken, err := generateSetupToken()
+ if err != nil {
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ now := time.Now().UTC()
+ var actorID *string
+ if actor != nil {
+ actorID = &actor.ID
+ }
+ if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{
+ UserID: id, TokenHash: hashSetupToken(rawToken),
+ ExpiresAt: now.Add(time.Hour),
+ CreatedAt: now, CreatedBy: actorID,
+ }); err != nil {
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ if err := s.deps.Store.SetMustChangePassword(r.Context(), id, true); err != nil {
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(), UserID: actorID, Actor: "user",
+ Action: "user.setup_token.regenerated",
+ TargetKind: ptr("user"), TargetID: &id, TS: now,
+ })
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(regenerateSetupResponse{
+ SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken,
+ })
+}
+
+func (s *Server) handleAPIUserForceLogout(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ actor, _ := s.requireUser(r)
+ id := chi.URLParam(r, "id")
+ n, err := s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
+ if err != nil {
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ var actorID *string
+ if actor != nil {
+ actorID = &actor.ID
+ }
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(), UserID: actorID, Actor: "user",
+ Action: "user.force_logout",
+ TargetKind: ptr("user"), TargetID: &id,
+ TS: time.Now().UTC(),
+ })
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(map[string]int64{"sessions_killed": n})
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/server/http/ -run "TestAPIUserRegenerateSetup|TestAPIUserForceLogout"`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/server/http/api_users.go internal/server/http/users_test.go
+git commit -m "http: regenerate setup link + force-logout"
+```
+
+---
+
+### Task E6: Self-service password change API
+
+**Files:**
+- Create: `internal/server/http/ui_account.go`
+
+- [ ] **Step 1: Write a failing test**
+
+```go
+func TestAPIAccountPasswordChange(t *testing.T) {
+ t.Parallel()
+ srv, url := newTestServer(t, false)
+ uid := makeUser(t, srv, "alice", store.RoleViewer)
+ cookie := loginAs(t, srv, uid)
+
+ body, _ := json.Marshal(map[string]string{
+ "current_password": "test-password",
+ "new_password": "averylongpassword",
+ })
+ req, _ := stdhttp.NewRequest("POST", url+"/api/account/password", bytes.NewReader(body))
+ req.AddCookie(cookie)
+ req.Header.Set("Content-Type", "application/json")
+ res, _ := stdhttp.DefaultClient.Do(req)
+ defer res.Body.Close()
+ if res.StatusCode != stdhttp.StatusOK {
+ body, _ := io.ReadAll(res.Body)
+ t.Errorf("status: got %d body=%s", res.StatusCode, body)
+ }
+}
+```
+
+- [ ] **Step 2: Run test, verify it fails**
+
+Run: `go test ./internal/server/http/ -run TestAPIAccountPasswordChange`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement**
+
+```go
+// internal/server/http/ui_account.go
+package http
+
+import (
+ "encoding/json"
+ stdhttp "net/http"
+ "time"
+
+ "github.com/oklog/ulid/v2"
+
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
+)
+
+type passwordChangeRequest struct {
+ CurrentPassword string `json:"current_password"`
+ NewPassword string `json:"new_password"`
+}
+
+func (s *Server) handleAPIAccountPassword(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ u, ok := s.requireUser(r)
+ if !ok {
+ writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
+ return
+ }
+ var req passwordChangeRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
+ return
+ }
+ if len(req.NewPassword) < 12 {
+ writeJSONError(w, stdhttp.StatusBadRequest, "password_too_short", "min 12 chars")
+ return
+ }
+ // Skip current-password check when must_change_password is set —
+ // the user has no current password to know (set by the legacy
+ // path; setup-token path doesn't use this).
+ if !u.MustChangePassword {
+ if err := auth.ComparePassword(u.PasswordHash, req.CurrentPassword); err != nil {
+ writeJSONError(w, stdhttp.StatusUnauthorized, "current_password_wrong", "")
+ return
+ }
+ }
+ hash, err := auth.HashPassword(req.NewPassword)
+ if err != nil {
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ if err := s.deps.Store.SetPasswordHash(r.Context(), u.ID, hash); err != nil {
+ writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error())
+ return
+ }
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
+ Action: "user.password_changed",
+ TargetKind: ptr("user"), TargetID: &u.ID,
+ TS: time.Now().UTC(),
+ })
+ w.WriteHeader(stdhttp.StatusOK)
+}
+```
+
+- [ ] **Step 4: Run test to verify it passes**
+
+Run: `go test ./internal/server/http/ -run TestAPIAccountPasswordChange`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/server/http/ui_account.go internal/server/http/users_test.go
+git commit -m "http: POST /api/account/password — self-service password change"
+```
+
+---
+
+## Slice F — UI
+
+### Task F1: Settings → Users list page
+
+**Files:**
+- Create: `web/templates/pages/users.html`
+- Create: `internal/server/http/ui_users.go`
+- Modify: `web/templates/pages/settings.html` (turn the dormant Users tab live)
+
+- [ ] **Step 1: Implement the list handler**
+
+```go
+// internal/server/http/ui_users.go
+package http
+
+import (
+ "errors"
+ "log/slog"
+ stdhttp "net/http"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/oklog/ulid/v2"
+
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/store"
+)
+
+type usersPage struct {
+ Users []userRow
+ ShowDisabled bool
+ HasOpenTokens bool // for the page-header banner
+}
+
+type userRow struct {
+ ID string
+ Username string
+ Email string // empty for nil
+ Role string
+ LastLoginAt *time.Time
+ Disabled bool
+ MustChangePassword bool
+}
+
+func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ u := s.requireUIUser(w, r)
+ if u == nil {
+ return
+ }
+ showDisabled := r.URL.Query().Get("show_disabled") == "1"
+ users, err := s.deps.Store.ListUsers(r.Context())
+ if err != nil {
+ slog.Error("ui users: list", "err", err)
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ rows := make([]userRow, 0, len(users))
+ for _, ux := range users {
+ if !showDisabled && ux.DisabledAt != nil {
+ continue
+ }
+ em := ""
+ if ux.Email != nil {
+ em = *ux.Email
+ }
+ rows = append(rows, userRow{
+ ID: ux.ID, Username: ux.Username, Email: em,
+ Role: string(ux.Role),
+ LastLoginAt: ux.LastLoginAt,
+ Disabled: ux.DisabledAt != nil,
+ MustChangePassword: ux.MustChangePassword,
+ })
+ }
+ view := s.baseView(r, u)
+ view.Title = "Users · restic-manager"
+ view.Active = "settings"
+ view.Page = usersPage{Users: rows, ShowDisabled: showDisabled}
+ if err := s.deps.UI.Render(w, "users", view); err != nil {
+ slog.Error("ui users: render", "err", err)
+ }
+}
+```
+
+- [ ] **Step 2: Add the template**
+
+```html
+
+{{define "title"}}Users · restic-manager{{end}}
+
+{{define "content"}}
+{{$page := .Page}}
+
+
+
+
+
+ Users
+ {{len $page.Users}}
+
+
+
+
+
+
+
+
+
Username
+
Email
+
Role
+
Last login
+
Status
+
+
+ {{range $page.Users}}
+
+
+
{{if .Email}}{{.Email}}{{else}}— {{end}}
+
{{.Role}}
+
+ {{if .LastLoginAt}}{{relTime (deref (timeToPtr .LastLoginAt))}}{{else}}never {{end}}
+
+
+ {{if .Disabled}}disabled
+ {{else if .MustChangePassword}}setup pending
+ {{else}}enabled {{end}}
+
+
+
+ {{end}}
+
+
+{{end}}
+```
+
+The template uses `relTime` which expects a `time.Time`. The current `userRow.LastLoginAt` is `*time.Time` — adjust the template to handle nil with `{{if .LastLoginAt}}{{relTime (deref (timeToPtr .LastLoginAt))}}` — too clever. Simpler: change the field to a pre-formatted string in the handler.
+
+Replace the `userRow.LastLoginAt` field with `LastLoginAt string` and format in the handler:
+
+```go
+// in ui_users.go, in the loop:
+ll := ""
+if ux.LastLoginAt != nil {
+ ll = ux.LastLoginAt.UTC().Format("2006-01-02 15:04:05")
+}
+rows = append(rows, userRow{... LastLoginAt: ll, ...})
+```
+
+And in the template, replace the {{if .LastLoginAt}}…{{end}} block with:
+
+```html
+
+ {{if .LastLoginAt}}{{.LastLoginAt}}{{else}}never {{end}}
+
+```
+
+- [ ] **Step 3: Add `.user-row` styles**
+
+Append to `web/styles/input.css` (before `/* ---------- schedule rows */` if that ordering is in place):
+
+```css
+ .user-row {
+ display: grid; align-items: center;
+ grid-template-columns: 180px 1fr 110px 160px 120px 90px;
+ column-gap: 16px;
+ padding: 11px 16px; font-size: 13px;
+ border-bottom: 1px solid var(--line-soft);
+ transition: background 100ms ease;
+ }
+ .user-row:hover { background: var(--panel-hi); }
+ .user-row:last-child { border-bottom: 0; }
+ .user-row.head {
+ cursor: default; padding-top: 9px; padding-bottom: 9px;
+ font-size: 11px; color: var(--ink-fade);
+ text-transform: uppercase; letter-spacing: 0.08em;
+ }
+ .user-row.head:hover { background: transparent; }
+ .user-row.disabled { opacity: 0.55; }
+```
+
+- [ ] **Step 4: Flip the Users tab live in settings.html**
+
+Find the existing `Users` tab markup in `web/templates/pages/settings.html` (currently rendered with a `disabled` style or similar). Make it a live link to `/settings/users`.
+
+The current settings.html has the tab strip rendered with three labels: Notifications, Users, Authentication. Update the Users one to a live anchor and bump the count badge to read from the page model when on the Notifications page (left as-is — Notifications still shows the channel count).
+
+```html
+Users
+```
+
+- [ ] **Step 5: Run a manual smoke**
+
+Build and start the server, log in as admin, navigate to `/settings/users`, confirm the page renders and the `+ Add user` button is visible.
+
+```bash
+make build
+# restart and visit /settings/users
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/server/http/ui_users.go web/templates/pages/users.html \
+ web/styles/input.css web/templates/pages/settings.html
+git commit -m "ui: /settings/users list page"
+```
+
+---
+
+### Task F2: Add user form
+
+**Files:**
+- Create: `web/templates/pages/user_edit.html` (the form template, multi-mode for new + edit)
+- Modify: `internal/server/http/ui_users.go`
+
+- [ ] **Step 1: Implement the new-user GET + POST handlers**
+
+```go
+type userFormPage struct {
+ Mode string // "new" | "edit" | "setup-link"
+ ID string
+ Username string
+ Email string
+ Role string
+ Disabled bool
+ HasSetup bool
+ SetupURL string
+ SetupExpAt time.Time
+ Error string
+}
+
+func (s *Server) handleUIUserNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ u := s.requireUIUser(w, r)
+ if u == nil {
+ return
+ }
+ view := s.baseView(r, u)
+ view.Title = "New user · restic-manager"
+ view.Active = "settings"
+ view.Page = userFormPage{Mode: "new", Role: "operator"}
+ _ = s.deps.UI.Render(w, "user_edit", view)
+}
+
+func (s *Server) handleUIUserNewPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ u := s.requireUIUser(w, r)
+ if u == nil {
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
+ return
+ }
+ uname := strings.ToLower(strings.TrimSpace(r.PostForm.Get("username")))
+ email := strings.TrimSpace(r.PostForm.Get("email"))
+ role, ok := validRole(r.PostForm.Get("role"))
+ if uname == "" || !ok {
+ view := s.baseView(r, u)
+ view.Title = "New user · restic-manager"
+ view.Active = "settings"
+ view.Page = userFormPage{
+ Mode: "new", Username: uname, Email: email,
+ Role: r.PostForm.Get("role"),
+ Error: "Username is required and role must be admin/operator/viewer.",
+ }
+ _ = s.deps.UI.Render(w, "user_edit", view)
+ return
+ }
+
+ // Same collision logic as the API: collide with disabled = re-enable
+ // suggestion; collide with enabled = hard error.
+ existing, err := s.deps.Store.GetUserByUsername(r.Context(), uname)
+ if err == nil {
+ if existing.DisabledAt != nil {
+ // Punt the admin to the edit page where Re-enable is one click.
+ stdhttp.Redirect(w, r, "/settings/users/"+existing.ID+
+ "/edit?reenable=1", stdhttp.StatusSeeOther)
+ return
+ }
+ view := s.baseView(r, u)
+ view.Title = "New user · restic-manager"
+ view.Active = "settings"
+ view.Page = userFormPage{
+ Mode: "new", Username: uname, Email: email,
+ Role: r.PostForm.Get("role"),
+ Error: "A user with that name already exists.",
+ }
+ _ = s.deps.UI.Render(w, "user_edit", view)
+ return
+ } else if !errors.Is(err, store.ErrNotFound) {
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+
+ id := ulid.Make().String()
+ now := time.Now().UTC()
+ var emailPtr *string
+ if email != "" {
+ em := strings.ToLower(email)
+ emailPtr = &em
+ }
+ if err := s.deps.Store.CreateUser(r.Context(), store.User{
+ ID: id, Username: uname, PasswordHash: "",
+ Role: role, Email: emailPtr, CreatedAt: now,
+ MustChangePassword: true,
+ }); err != nil {
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ rawToken, err := generateSetupToken()
+ if err != nil {
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{
+ UserID: id, TokenHash: hashSetupToken(rawToken),
+ ExpiresAt: now.Add(time.Hour),
+ CreatedAt: now, CreatedBy: &u.ID,
+ }); err != nil {
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
+ Action: "user.created", TargetKind: ptr("user"), TargetID: &id,
+ TS: now,
+ })
+ // One-time link page. Pass the raw token as a querystring so the
+ // page can show it once.
+ stdhttp.Redirect(w,
+ r,
+ "/settings/users/"+id+"/setup-link?token="+rawToken,
+ stdhttp.StatusSeeOther)
+}
+```
+
+- [ ] **Step 2: Implement the setup-link GET handler**
+
+```go
+func (s *Server) handleUIUserSetupLinkGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ u := s.requireUIUser(w, r)
+ if u == nil {
+ return
+ }
+ id := chi.URLParam(r, "id")
+ target, err := s.deps.Store.GetUserByID(r.Context(), id)
+ if err != nil {
+ stdhttp.NotFound(w, r)
+ return
+ }
+ rawToken := r.URL.Query().Get("token")
+ tok, err := s.deps.Store.GetSetupTokenByUserID(r.Context(), id)
+ if err != nil || rawToken == "" {
+ // No outstanding token (e.g. user already finished setup, or
+ // admin opened this URL without a token). 410.
+ w.WriteHeader(stdhttp.StatusGone)
+ view := s.baseView(r, u)
+ view.Title = "Link expired · restic-manager"
+ view.Active = "settings"
+ view.Page = userFormPage{
+ Mode: "setup-link", ID: target.ID, Username: target.Username,
+ Error: "expired",
+ }
+ _ = s.deps.UI.Render(w, "user_edit", view)
+ return
+ }
+ view := s.baseView(r, u)
+ view.Title = "Setup link · restic-manager"
+ view.Active = "settings"
+ view.Page = userFormPage{
+ Mode: "setup-link", ID: target.ID, Username: target.Username,
+ Role: string(target.Role),
+ HasSetup: true,
+ SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken,
+ SetupExpAt: tok.ExpiresAt,
+ }
+ _ = s.deps.UI.Render(w, "user_edit", view)
+}
+```
+
+- [ ] **Step 3: Add the multi-mode template**
+
+```html
+
+{{define "title"}}{{.Title}}{{end}}
+
+{{define "content"}}
+{{$page := .Page}}
+
+
+
Dashboard /
+
Settings /
+
Users /
+
{{if eq $page.Mode "new"}}new{{else if eq $page.Mode "setup-link"}}setup link{{else}}{{$page.Username}}{{end}}
+
+
+
+ {{if eq $page.Mode "new"}}New user
+ {{else if eq $page.Mode "setup-link"}}Setup link for {{$page.Username}}
+ {{else}}Edit {{$page.Username}} {{end}}
+
+
+ {{if eq $page.Mode "setup-link"}}
+ {{if eq $page.Error "expired"}}
+
+
Link expired or already used
+
+ This user's setup token is no longer valid. Open their Edit page and click
+ Regenerate setup link to issue a new one.
+
+
Open edit page
+
+ {{else}}
+
+
+ Send this link to the user. It expires at
+ {{absTime $page.SetupExpAt}} UTC
+ (~1 hour from now). This is the only time you'll see it — if you lose
+ it, regenerate from the Edit page.
+
+
{{$page.SetupURL}}
+
Copy link
+
Done
+
+ {{end}}
+ {{else}}
+ {{/* new + edit form. */}}
+
+
+ {{if eq $page.Mode "edit"}}
+ {{/* Side actions: regenerate setup link, disable / re-enable, force logout. */}}
+
+
Other actions
+
+
+
+ {{if $page.Disabled}}
+
+ {{else}}
+
+ {{end}}
+
+
+ {{end}}
+ {{end}}
+
+{{end}}
+```
+
+- [ ] **Step 4: Wire routes**
+
+The routes are already declared in Task B4's reorganised `routes()`. Confirm they're present:
+
+```bash
+grep -n "settings/users" internal/server/http/server.go
+```
+
+Expected: lines for new GET/POST, edit GET/POST, setup-link GET, disable, enable, regenerate-setup, force-logout. If any are missing, add them.
+
+- [ ] **Step 5: Manual smoke**
+
+```bash
+make build
+# restart, visit /settings/users/new, fill in form, see the setup-link page
+```
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/server/http/ui_users.go web/templates/pages/user_edit.html
+git commit -m "ui: /settings/users/new + /setup-link page"
+```
+
+---
+
+### Task F3: Edit user UI handlers
+
+**Files:**
+- Modify: `internal/server/http/ui_users.go`
+
+- [ ] **Step 1: Implement the GET, POST, and the small action POSTs**
+
+```go
+func (s *Server) handleUIUserEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ u := s.requireUIUser(w, r)
+ if u == nil {
+ return
+ }
+ id := chi.URLParam(r, "id")
+ target, err := s.deps.Store.GetUserByID(r.Context(), id)
+ if err != nil {
+ stdhttp.NotFound(w, r)
+ return
+ }
+ em := ""
+ if target.Email != nil {
+ em = *target.Email
+ }
+ view := s.baseView(r, u)
+ view.Title = "Edit user · restic-manager"
+ view.Active = "settings"
+ view.Page = userFormPage{
+ Mode: "edit", ID: target.ID, Username: target.Username,
+ Email: em, Role: string(target.Role),
+ Disabled: target.DisabledAt != nil,
+ }
+ _ = s.deps.UI.Render(w, "user_edit", view)
+}
+
+func (s *Server) handleUIUserEditPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ u := s.requireUIUser(w, r)
+ if u == nil {
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
+ return
+ }
+ id := chi.URLParam(r, "id")
+ target, err := s.deps.Store.GetUserByID(r.Context(), id)
+ if err != nil {
+ stdhttp.NotFound(w, r)
+ return
+ }
+ role, ok := validRole(r.PostForm.Get("role"))
+ if !ok {
+ stdhttp.Error(w, "bad role", stdhttp.StatusBadRequest)
+ return
+ }
+ email := strings.TrimSpace(r.PostForm.Get("email"))
+ if email != "" {
+ if _, err := mail.ParseAddress(email); err != nil {
+ stdhttp.Error(w, "bad email", stdhttp.StatusBadRequest)
+ return
+ }
+ }
+ // Last-admin guard for demote.
+ if target.Role == store.RoleAdmin && role != store.RoleAdmin && target.DisabledAt == nil {
+ n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
+ if n <= 1 {
+ stdhttp.Error(w, "cannot demote last admin", stdhttp.StatusConflict)
+ return
+ }
+ }
+ if err := s.deps.Store.SetUserRole(r.Context(), id, role); err != nil {
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ if err := s.deps.Store.SetUserEmail(r.Context(), id, email); err != nil {
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
+ Action: "user.updated", TargetKind: ptr("user"), TargetID: &id,
+ TS: time.Now().UTC(),
+ })
+ stdhttp.Redirect(w, r, "/settings/users", stdhttp.StatusSeeOther)
+}
+
+func (s *Server) handleUIUserDisablePost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ u := s.requireUIUser(w, r)
+ if u == nil {
+ return
+ }
+ id := chi.URLParam(r, "id")
+ target, err := s.deps.Store.GetUserByID(r.Context(), id)
+ if err != nil {
+ stdhttp.NotFound(w, r)
+ return
+ }
+ if target.Role == store.RoleAdmin && target.DisabledAt == nil {
+ n, _ := s.deps.Store.CountEnabledAdmins(r.Context())
+ if n <= 1 {
+ stdhttp.Error(w, "cannot disable last admin", stdhttp.StatusConflict)
+ return
+ }
+ }
+ now := time.Now().UTC()
+ if err := s.deps.Store.DisableUser(r.Context(), id, now); err != nil {
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ _, _ = s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
+ Action: "user.disabled", TargetKind: ptr("user"), TargetID: &id,
+ TS: now,
+ })
+ stdhttp.Redirect(w, r, "/settings/users", stdhttp.StatusSeeOther)
+}
+
+func (s *Server) handleUIUserEnablePost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ u := s.requireUIUser(w, r)
+ if u == nil {
+ return
+ }
+ id := chi.URLParam(r, "id")
+ if err := s.deps.Store.EnableUser(r.Context(), id); err != nil {
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
+ Action: "user.enabled", TargetKind: ptr("user"), TargetID: &id,
+ TS: time.Now().UTC(),
+ })
+ stdhttp.Redirect(w, r, "/settings/users/"+id+"/edit", stdhttp.StatusSeeOther)
+}
+
+func (s *Server) handleUIUserRegenerateSetupPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ u := s.requireUIUser(w, r)
+ if u == nil {
+ return
+ }
+ id := chi.URLParam(r, "id")
+ if _, err := s.deps.Store.GetUserByID(r.Context(), id); err != nil {
+ stdhttp.NotFound(w, r)
+ return
+ }
+ rawToken, err := generateSetupToken()
+ if err != nil {
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ now := time.Now().UTC()
+ if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{
+ UserID: id, TokenHash: hashSetupToken(rawToken),
+ ExpiresAt: now.Add(time.Hour), CreatedAt: now,
+ CreatedBy: &u.ID,
+ }); err != nil {
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ _ = s.deps.Store.SetMustChangePassword(r.Context(), id, true)
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
+ Action: "user.setup_token.regenerated",
+ TargetKind: ptr("user"), TargetID: &id, TS: now,
+ })
+ stdhttp.Redirect(w, r,
+ "/settings/users/"+id+"/setup-link?token="+rawToken,
+ stdhttp.StatusSeeOther)
+}
+
+func (s *Server) handleUIUserForceLogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ u := s.requireUIUser(w, r)
+ if u == nil {
+ return
+ }
+ id := chi.URLParam(r, "id")
+ _, err := s.deps.Store.DeleteSessionsByUserID(r.Context(), id)
+ if err != nil {
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
+ Action: "user.force_logout",
+ TargetKind: ptr("user"), TargetID: &id,
+ TS: time.Now().UTC(),
+ })
+ stdhttp.Redirect(w, r, "/settings/users/"+id+"/edit", stdhttp.StatusSeeOther)
+}
+```
+
+- [ ] **Step 2: Build, smoke**
+
+```bash
+make build
+# restart server, edit a user, change role, verify the change persists
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/server/http/ui_users.go
+git commit -m "ui: /settings/users edit form + disable/enable/regenerate/force-logout"
+```
+
+---
+
+### Task F4: /settings/account UI
+
+**Files:**
+- Modify: `internal/server/http/ui_account.go`
+- Create: `web/templates/pages/account.html`
+
+- [ ] **Step 1: Add UI handlers**
+
+```go
+type accountPage struct {
+ Username string
+ Role string
+ MustChange bool
+ Error string
+ Saved bool
+}
+
+func (s *Server) handleUIAccountGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ u := s.requireUIUser(w, r)
+ if u == nil {
+ return
+ }
+ full, err := s.deps.Store.GetUserByID(r.Context(), u.ID)
+ if err != nil {
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+ view := s.baseView(r, u)
+ view.Title = "Account · restic-manager"
+ view.Active = "settings"
+ view.Page = accountPage{
+ Username: full.Username, Role: string(full.Role),
+ MustChange: full.MustChangePassword,
+ }
+ _ = s.deps.UI.Render(w, "account", view)
+}
+
+func (s *Server) handleUIAccountPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ u := s.requireUIUser(w, r)
+ if u == nil {
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
+ return
+ }
+ cur := r.PostForm.Get("current_password")
+ pw := r.PostForm.Get("new_password")
+ pw2 := r.PostForm.Get("confirm_password")
+
+ full, err := s.deps.Store.GetUserByID(r.Context(), u.ID)
+ if err != nil {
+ stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
+ return
+ }
+
+ render := func(errMsg string, saved bool) {
+ view := s.baseView(r, u)
+ view.Title = "Account · restic-manager"
+ view.Active = "settings"
+ view.Page = accountPage{
+ Username: full.Username, Role: string(full.Role),
+ MustChange: full.MustChangePassword,
+ Error: errMsg, Saved: saved,
+ }
+ _ = s.deps.UI.Render(w, "account", view)
+ }
+
+ if pw == "" || pw != pw2 || len(pw) < 12 {
+ render("Passwords must match and be at least 12 characters.", false)
+ return
+ }
+ if !full.MustChangePassword {
+ if err := auth.ComparePassword(full.PasswordHash, cur); err != nil {
+ render("Current password is incorrect.", false)
+ return
+ }
+ }
+ hash, err := auth.HashPassword(pw)
+ if err != nil {
+ render("Internal error.", false)
+ return
+ }
+ if err := s.deps.Store.SetPasswordHash(r.Context(), u.ID, hash); err != nil {
+ render("Internal error.", false)
+ return
+ }
+ _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
+ ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
+ Action: "user.password_changed",
+ TargetKind: ptr("user"), TargetID: &u.ID,
+ TS: time.Now().UTC(),
+ })
+ full.MustChangePassword = false
+ render("", true)
+}
+```
+
+- [ ] **Step 2: Add the template**
+
+```html
+
+{{define "title"}}Account · restic-manager{{end}}
+
+{{define "content"}}
+{{$page := .Page}}
+
+
+
+
Account
+
+ Signed in as {{$page.Username}}
+ ({{$page.Role}}). Change your password below.
+
+
+ {{if $page.Saved}}
+
+ {{end}}
+
+
+
+{{end}}
+```
+
+- [ ] **Step 3: Manual smoke**
+
+Log in as a viewer (created via setup-token flow), navigate to `/settings/account`, change the password, log out and back in with the new one.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/server/http/ui_account.go web/templates/pages/account.html
+git commit -m "ui: /settings/account self-service password change"
+```
+
+---
+
+## Slice G — Wiring & sweep
+
+### Task G1: Maintenance ticker — sweep expired setup tokens
+
+**Files:**
+- Modify: `internal/server/http/maintenance_dispatch.go` *or* a new ticker hook (whichever the codebase already uses for periodic-tasks not tied to a host)
+
+- [ ] **Step 1: Identify the host of the cleanup**
+
+Read `internal/server/maintenance/ticker.go` (the existing maintenance ticker) to see whether it has a "global housekeeping" slot or only host-keyed work. If only host-keyed, add a new periodic-task hook in `cmd/server/main.go` that fires every 60s alongside the existing alert-engine ticker.
+
+Quickest path: piggy-back on the alert-engine tick in `internal/alert/engine.go` — it already runs at 60s and has access to `*store.Store`. Add a 1-line call from `e.tick()`:
+
+```go
+// internal/alert/engine.go (inside tick)
+if _, err := e.store.CleanupExpiredSetupTokens(ctx, now); err != nil {
+ slog.Warn("alert: cleanup expired setup tokens", "err", err)
+}
+```
+
+Yes, it's a layering smell (alert engine doing user-mgmt cleanup). Acceptable v1 tradeoff because the alternative is a new dedicated ticker for one query. Documented in a comment: "User-management cleanup piggy-backed here for now; extract a dedicated maintenance loop if more housekeeping queries appear."
+
+- [ ] **Step 2: Run tests, ensure still green**
+
+Run: `go test ./internal/alert/...`
+Expected: PASS.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/alert/engine.go
+git commit -m "alert: piggy-back expired-setup-token cleanup on the engine tick"
+```
+
+---
+
+### Task G2: Live Playwright sweep + sweep notes in tasks.md
+
+**Files:**
+- Modify: `tasks.md`
+- Output: `_diag/p4-03-04-sweep/*.png`
+
+- [ ] **Step 1: Manual end-to-end check**
+
+```bash
+make build
+# restart server. Then in the browser, signed in as admin:
+# 1. Visit /settings/users — see the existing admin row
+# 2. Click + Add user. Username 'op1', email 'op1@example.com', role operator. Submit.
+# 3. Land on /settings/users/{id}/setup-link. Copy the URL.
+# 4. Open that URL in a private window. Set password 'averylongpassword'. Submit.
+# 5. Land on / as op1.
+# 6. Try /settings/users → 403 (forbidden page renders).
+# 7. Visit /settings/account — change password, log out, log back in with the new one.
+# 8. Back as admin: edit op1, click Disable user.
+# 9. In op1's still-open tab, click any link → bounced to /login.
+# 10. Re-enable op1, force-logout, regenerate setup link, walk through the new link.
+```
+
+Capture screenshots at each major step into `_diag/p4-03-04-sweep/01-users-list.png` etc.
+
+- [ ] **Step 2: Tick tasks.md**
+
+```bash
+# Edit tasks.md, find P4-03 and P4-04, replace [ ] with [x] and add an "as shipped"
+# note under "Phase 4 — Update delivery, RBAC polish, OIDC" that summarises:
+# - Three roles enforced via chi route-group middleware
+# - Setup-token flow with 1h expiry, sha256-hashed at rest, raw shown to admin once
+# - Disable-only user lifecycle with last-admin guard, immediate session kick
+# - Self-service /settings/account password change for any role
+# - Email field as metadata only in v1
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tasks.md
+git commit -m "tasks: tick P4-03/04 + sweep notes"
+```
+
+---
+
+## Self-review notes
+
+**Spec coverage:** every section of the design doc maps to at least one task —
+- Role taxonomy → A4 (CountEnabledAdmins) + B (middleware + grouping)
+- Schema → A1, A2, A3, A4, A5, A6
+- RBAC enforcement → B1–B5
+- Session re-validation → C1
+- Setup-token flow → D1–D2 + E2/E5 + F2
+- User CRUD API → E1–E6
+- UI → F1–F4
+- Audit actions → embedded in each create/disable/enable/etc. handler
+- Last-admin guard → E3, E4, F3
+- Token cleanup → G1
+- Acceptance / sweep → G2
+
+**Placeholder scan:** no TBD/TODO; every code block is concrete.
+
+**Type consistency:** `store.User` adds `Email *string`, `DisabledAt *time.Time`, `MustChangePassword bool`; same names used in `apiUser`, `userRow`, `userFormPage`. `SetupToken` shape used identically across Set/Lookup/GetByUserID/Delete/Cleanup. Audit action strings standardised in the spec's `## Audit actions` section and quoted exactly in handler code.
+
+One gotcha to watch during execution: the existing test fixtures (`newTestServer`, `loginAsAdmin`) probably need *no* changes because their pattern still works — you create an admin user, log in. Tests that exercise specific role paths will be added as part of E1+ tasks. If anywhere assumes "any logged-in user can do anything", that test needs adjusting or breaks during Task B4.