Files
restic-manager/docs/superpowers/plans/2026-05-05-p4-03-04-rbac-user-mgmt.md
T
steve c9f230ce1d plan: P4-03/04 — RBAC + user management implementation plan
Bite-sized TDD tasks across 7 slices (A schema, B middleware,
C session re-validation, D setup-token flow, E user CRUD API,
F UI, G wiring + sweep). Each task is one commit with concrete
code blocks and test cases — no placeholders.

Refs spec at docs/superpowers/specs/2026-05-05-p4-03-04-rbac-user-mgmt-design.md.
2026-05-05 10:57:24 +01:00

127 KiB
Raw Blame History

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.goroleAtLeast 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.gorequireUser rejects disabled users
  • internal/server/http/ui_handlers.goloadAuthedUser 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

-- 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
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

-- 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
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:

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:

// 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)
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:

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:

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
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

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
// 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
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):

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:

// 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
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

// 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
// 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
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:

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:

// 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:

// 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
<!-- web/templates/pages/forbidden.html -->
{{define "title"}}Forbidden · restic-manager{{end}}

{{define "content"}}
{{$page := .Page}}
<div class="max-w-[1280px] mx-auto px-8 pb-14">
  <div class="crumbs pt-6">
    <a href="/">Dashboard</a><span class="sep">/</span>
    <span class="text-ink-mid">forbidden</span>
  </div>
  <div class="panel mt-8 rounded-[7px] p-8 max-w-[640px]"
       style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
    <div class="text-[14px] font-medium text-bad mb-2">403 — Insufficient role</div>
    <p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6]">
      Your role (<span class="mono">{{$page.Have}}</span>) does not permit
      this page (<span class="mono">{{$page.Required}}</span> required).
      Ask your administrator if you need access.
    </p>
    <a href="/" class="btn btn-primary mt-5">Back to dashboard</a>
  </div>
</div>
{{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

// 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
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()
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 ~115340 in server.go) and rewrite as:

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
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:

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)
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:

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:

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):

// (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:

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
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):

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
// 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=<raw>`) 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
<!-- web/templates/pages/setup.html -->
{{define "title"}}{{.Title}}{{end}}

{{define "content"}}
{{$page := .Page}}
<div class="max-w-[520px] mx-auto px-8 pt-20 pb-14">
  {{if eq $page.Error "expired"}}
    <h1 class="text-[22px] font-medium tracking-[-0.005em]">Link expired</h1>
    <p class="text-pretty text-[13px] text-ink-mute mt-3 leading-[1.6]">
      This setup link has expired or is invalid. Setup links are valid
      for one hour from the moment your administrator generates them.
    </p>
    <p class="text-[12.5px] text-ink-mute mt-3 leading-[1.6]">
      Contact your administrator and ask them to regenerate the link.
    </p>
  {{else}}
    <h1 class="text-[22px] font-medium tracking-[-0.005em]">
      Welcome, <span class="mono">{{$page.Username}}</span>
    </h1>
    <p class="text-pretty text-[13px] text-ink-mute mt-3 leading-[1.6]">
      Pick a password to finish setting up your account. The link expires
      one hour after your administrator generated it, so don't dawdle.
    </p>
    <form method="post" action="/setup" class="mt-7 space-y-4">
      <input type="hidden" name="token" value="{{$page.Token}}" />
      <div>
        <label class="field-label" for="pw">New password</label>
        <input id="pw" name="password" type="password" class="field"
               required minlength="12" autocomplete="new-password" />
      </div>
      <div>
        <label class="field-label" for="pw2">Confirm password</label>
        <input id="pw2" name="password_confirm" type="password" class="field"
               required minlength="12" autocomplete="new-password" />
      </div>
      <button type="submit" class="btn btn-primary btn-block btn-lg">
        Set password and sign in
      </button>
    </form>
    {{if and $page.Error (ne $page.Error "expired")}}
      <p class="text-bad text-[12.5px] mt-4">{{$page.Error}}</p>
    {{end}}
  {{end}}
</div>
{{end}}
  • Step 5: Run tests to verify they pass

Run: go test ./internal/server/http/ -run TestSetupGet Expected: both PASS.

  • Step 6: Commit
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:

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:

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
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:

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
// 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
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:

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:

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
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

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
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
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

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
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
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"

Files:

  • Modify: internal/server/http/api_users.go

  • Step 1: Write tests

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
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
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

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
// 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
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

// 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
<!-- web/templates/pages/users.html -->
{{define "title"}}Users · restic-manager{{end}}

{{define "content"}}
{{$page := .Page}}
<div class="max-w-[1280px] mx-auto px-8 pb-14">
  <div class="crumbs pt-6">
    <a href="/">Dashboard</a><span class="sep">/</span>
    <a href="/settings">Settings</a><span class="sep">/</span>
    <span class="text-ink-mid">users</span>
  </div>

  <div class="flex items-baseline justify-between mt-3.5">
    <h1 class="text-[22px] font-medium tracking-[-0.005em]">
      Users
      <span class="text-ink-fade font-normal text-[14px] ml-2">{{len $page.Users}}</span>
    </h1>
    <div class="flex gap-2">
      <a href="/settings/users/new" class="btn btn-primary">+ Add user</a>
    </div>
  </div>

  <form method="get" action="/settings/users" class="mt-3 text-[12px] text-ink-mute">
    <label class="cursor-pointer flex items-center gap-2">
      <input type="checkbox" name="show_disabled" value="1"
             {{if $page.ShowDisabled}}checked{{end}}
             onchange="this.form.submit()" />
      Show disabled users
    </label>
  </form>

  <div class="panel mt-4 rounded-[7px] overflow-hidden">
    <div class="user-row head">
      <div>Username</div>
      <div>Email</div>
      <div>Role</div>
      <div>Last login</div>
      <div>Status</div>
      <div></div>
    </div>
    {{range $page.Users}}
      <div class="user-row{{if .Disabled}} disabled{{end}}">
        <div class="mono text-ink">
          <a href="/settings/users/{{.ID}}/edit" class="hover:underline">{{.Username}}</a>
        </div>
        <div class="mono text-ink-mid text-[12px]">{{if .Email}}{{.Email}}{{else}}<span class="text-ink-fade"></span>{{end}}</div>
        <div class="mono text-[12px] text-ink-mid">{{.Role}}</div>
        <div class="mono text-[12px] text-ink-mute">
          {{if .LastLoginAt}}{{relTime (deref (timeToPtr .LastLoginAt))}}{{else}}<span class="text-ink-fade">never</span>{{end}}
        </div>
        <div>
          {{if .Disabled}}<span class="tag" style="color: var(--ink-fade);">disabled</span>
          {{else if .MustChangePassword}}<span class="tag" style="color: var(--warn); border-color: color-mix(in oklch, var(--warn), transparent 60%); background: color-mix(in oklch, var(--warn), transparent 92%);">setup pending</span>
          {{else}}<span class="tag" style="color: var(--ok);">enabled</span>{{end}}
        </div>
        <div class="text-right">
          <a href="/settings/users/{{.ID}}/edit" class="btn">Edit</a>
        </div>
      </div>
    {{end}}
  </div>
</div>
{{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:

// 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:

<div class="mono text-[12px] text-ink-mute">
  {{if .LastLoginAt}}{{.LastLoginAt}}{{else}}<span class="text-ink-fade">never</span>{{end}}
</div>
  • Step 3: Add .user-row styles

Append to web/styles/input.css (before /* ---------- schedule rows */ if that ordering is in place):

  .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).

<a href="/settings/users" class="sub-tab {{if eq $tab "users"}}active{{end}}">Users</a>
  • 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.

make build
# restart and visit /settings/users
  • Step 6: Commit
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

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
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
<!-- web/templates/pages/user_edit.html -->
{{define "title"}}{{.Title}}{{end}}

{{define "content"}}
{{$page := .Page}}
<div class="max-w-[760px] mx-auto px-8 pb-14">
  <div class="crumbs pt-6">
    <a href="/">Dashboard</a><span class="sep">/</span>
    <a href="/settings">Settings</a><span class="sep">/</span>
    <a href="/settings/users">Users</a><span class="sep">/</span>
    <span class="text-ink-mid">{{if eq $page.Mode "new"}}new{{else if eq $page.Mode "setup-link"}}setup link{{else}}{{$page.Username}}{{end}}</span>
  </div>

  <h1 class="text-[22px] font-medium tracking-[-0.005em] mt-3.5">
    {{if eq $page.Mode "new"}}New user
    {{else if eq $page.Mode "setup-link"}}Setup link for <span class="mono">{{$page.Username}}</span>
    {{else}}Edit <span class="mono">{{$page.Username}}</span>{{end}}
  </h1>

  {{if eq $page.Mode "setup-link"}}
    {{if eq $page.Error "expired"}}
      <div class="panel mt-7 rounded-[7px] p-6"
           style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
        <div class="text-[13px] font-medium text-bad mb-2">Link expired or already used</div>
        <p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6]">
          This user's setup token is no longer valid. Open their Edit page and click
          <span class="mono">Regenerate setup link</span> to issue a new one.
        </p>
        <a href="/settings/users/{{$page.ID}}/edit" class="btn btn-primary mt-5">Open edit page</a>
      </div>
    {{else}}
      <div class="panel mt-7 rounded-[7px] p-6">
        <p class="text-pretty text-[13px] text-ink-mute leading-[1.6] mb-3">
          Send this link to the user. It expires at
          <span class="mono text-ink-mid">{{absTime $page.SetupExpAt}}</span> UTC
          (~1 hour from now). This is the only time you'll see it — if you lose
          it, regenerate from the Edit page.
        </p>
        <div class="mono text-[13px] text-ink p-3 rounded"
             style="background: var(--bg); border: 1px solid var(--line-soft); word-break: break-all;"
             id="setup-url">{{$page.SetupURL}}</div>
        <button type="button" class="btn btn-primary mt-4"
                onclick="navigator.clipboard.writeText(document.getElementById('setup-url').textContent.trim()).then(function(){var b=event.target;b.textContent='Copied';setTimeout(function(){b.textContent='Copy link';},1500)})">Copy link</button>
        <a href="/settings/users" class="btn ml-2">Done</a>
      </div>
    {{end}}
  {{else}}
    {{/* new + edit form. */}}
    <form method="post"
          action="{{if eq $page.Mode "new"}}/settings/users/new{{else}}/settings/users/{{$page.ID}}/edit{{end}}"
          class="panel mt-7 rounded-[7px] p-6 space-y-4">
      <div>
        <label class="field-label" for="username">Username</label>
        <input id="username" name="username" type="text"
               class="field mono"
               {{if ne $page.Mode "new"}}readonly disabled{{end}}
               value="{{$page.Username}}"
               autocomplete="off" required />
        <div class="field-help">Lowercased automatically.</div>
      </div>
      <div>
        <label class="field-label" for="email">Email <span class="text-ink-fade font-normal">· optional</span></label>
        <input id="email" name="email" type="email" class="field"
               value="{{$page.Email}}" autocomplete="off" />
      </div>
      <div>
        <label class="field-label" for="role">Role</label>
        <select id="role" name="role" class="field">
          <option value="admin"    {{if eq $page.Role "admin"}}selected{{end}}>admin</option>
          <option value="operator" {{if eq $page.Role "operator"}}selected{{end}}>operator</option>
          <option value="viewer"   {{if eq $page.Role "viewer"}}selected{{end}}>viewer</option>
        </select>
      </div>
      {{if $page.Error}}<div class="text-bad text-[12.5px]">{{$page.Error}}</div>{{end}}
      <div class="flex gap-2 pt-2">
        <button type="submit" class="btn btn-primary">{{if eq $page.Mode "new"}}Create user{{else}}Save changes{{end}}</button>
        <a href="/settings/users" class="btn">Cancel</a>
      </div>
    </form>

    {{if eq $page.Mode "edit"}}
      {{/* Side actions: regenerate setup link, disable / re-enable, force logout. */}}
      <div class="panel mt-5 rounded-[7px] p-6">
        <div class="text-[12.5px] text-ink mb-3 font-medium">Other actions</div>
        <div class="flex gap-2 flex-wrap">
          <form method="post" action="/settings/users/{{$page.ID}}/regenerate-setup">
            <button type="submit" class="btn">Regenerate setup link</button>
          </form>
          <form method="post" action="/settings/users/{{$page.ID}}/force-logout">
            <button type="submit" class="btn">Force logout</button>
          </form>
          {{if $page.Disabled}}
            <form method="post" action="/settings/users/{{$page.ID}}/enable">
              <button type="submit" class="btn">Re-enable user</button>
            </form>
          {{else}}
            <form method="post" action="/settings/users/{{$page.ID}}/disable">
              <button type="submit" class="btn btn-danger">Disable user</button>
            </form>
          {{end}}
        </div>
      </div>
    {{end}}
  {{end}}
</div>
{{end}}
  • Step 4: Wire routes

The routes are already declared in Task B4's reorganised routes(). Confirm they're present:

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
make build
# restart, visit /settings/users/new, fill in form, see the setup-link page
  • Step 6: Commit
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

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
make build
# restart server, edit a user, change role, verify the change persists
  • Step 3: Commit
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

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
<!-- web/templates/pages/account.html -->
{{define "title"}}Account · restic-manager{{end}}

{{define "content"}}
{{$page := .Page}}
<div class="max-w-[520px] mx-auto px-8 pb-14">
  <div class="crumbs pt-6">
    <a href="/">Dashboard</a><span class="sep">/</span>
    <span class="text-ink-mid">account</span>
  </div>

  <h1 class="text-[22px] font-medium tracking-[-0.005em] mt-3.5">Account</h1>
  <div class="text-[12.5px] text-ink-mute mt-2 leading-[1.6]">
    Signed in as <span class="mono text-ink-mid">{{$page.Username}}</span>
    ({{$page.Role}}). Change your password below.
  </div>

  {{if $page.Saved}}
    <div class="mt-6 panel rounded-[7px] p-4"
         style="border-color: color-mix(in oklch, var(--ok), transparent 60%);">
      <div class="text-ok text-[13px]">Password updated.</div>
    </div>
  {{end}}

  <form method="post" action="/settings/account" class="mt-6 panel rounded-[7px] p-6 space-y-4">
    {{if not $page.MustChange}}
      <div>
        <label class="field-label" for="current">Current password</label>
        <input id="current" name="current_password" type="password" class="field"
               required autocomplete="current-password" />
      </div>
    {{end}}
    <div>
      <label class="field-label" for="new">New password</label>
      <input id="new" name="new_password" type="password" class="field"
             required minlength="12" autocomplete="new-password" />
    </div>
    <div>
      <label class="field-label" for="confirm">Confirm new password</label>
      <input id="confirm" name="confirm_password" type="password" class="field"
             required minlength="12" autocomplete="new-password" />
    </div>
    {{if $page.Error}}<div class="text-bad text-[12.5px]">{{$page.Error}}</div>{{end}}
    <button type="submit" class="btn btn-primary btn-block btn-lg">Update password</button>
  </form>
</div>
{{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
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():

// 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
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

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
# 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
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 → B1B5
  • Session re-validation → C1
  • Setup-token flow → D1D2 + E2/E5 + F2
  • User CRUD API → E1E6
  • UI → F1F4
  • 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.