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

4047 lines
127 KiB
Markdown
Raw Blame History

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