From 282258e837d7345b68f85fece3899167a839d5d3 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 08:36:53 +0100 Subject: [PATCH 01/30] =?UTF-8?q?spec:=20P4-03/04=20=E2=80=94=20RBAC=20+?= =?UTF-8?q?=20user=20management=20design?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brainstormed shape locked: chi route-group middleware, fail-closed admin default; setup-token flow with 1h single-use tokens (sha256-hashed at rest, raw shown to admin once); disable-only user lifecycle with last-admin guard; self-service /settings/account password change for every role; email field on users (metadata v1); session re-validation on every authenticated request so disable / role change land immediately. Locked decisions captured in §Role taxonomy, §Schema changes, §Setup-token flow, §RBAC enforcement, §Last-admin self-protection. Deferred items in §Out of scope (OIDC, SMTP email-the-link, hard delete, lockout). Migrations 0017 (users extensions) + 0018 (user_setup_tokens) both column-level ALTERs per CLAUDE.md preference. --- ...26-05-05-p4-03-04-rbac-user-mgmt-design.md | 340 ++++++++++++++++++ 1 file changed, 340 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-05-p4-03-04-rbac-user-mgmt-design.md diff --git a/docs/superpowers/specs/2026-05-05-p4-03-04-rbac-user-mgmt-design.md b/docs/superpowers/specs/2026-05-05-p4-03-04-rbac-user-mgmt-design.md new file mode 100644 index 0000000..57b17d5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-05-p4-03-04-rbac-user-mgmt-design.md @@ -0,0 +1,340 @@ +# P4-03 / P4-04 — RBAC + User Management Design + +> **Date:** 2026-05-05 +> **Status:** brainstorm complete; ready for plan +> **Closes:** P4-03 (RBAC enforcement at API layer), P4-04 (User management UI) + +## Goal + +Enforce role-based access control at the HTTP layer (currently every authenticated user has admin powers) and ship the operator-facing screens for managing users, roles, and password lifecycle. + +## Architecture + +Two coupled subsystems landing in one PR: + +1. **RBAC enforcement** — chi route-group middleware that gates each subtree by minimum role. Fail-closed default (admin) so a forgotten declaration doesn't accidentally widen access. +2. **User management** — `/settings/users` sub-tab with list / add / edit / disable. Setup-link flow for new users (1-hour-expiry single-use token). Self-service password change at `/settings/account`. + +The audit log already records actor + user_id on every mutation; new endpoints fold in naturally. + +## Role taxonomy + +Locked. Three roles, hierarchical (admin ⊇ operator ⊇ viewer): + +| Action | admin | operator | viewer | +|---|:-:|:-:|:-:| +| View dashboard / alerts / audit / hosts | ✓ | ✓ | ✓ | +| Trigger Run-now / Restore / Snapshot diff | ✓ | ✓ | ✗ | +| Acknowledge / resolve alerts | ✓ | ✓ | ✗ | +| Edit schedules / source groups / retention / hooks | ✓ | ✓ | ✗ | +| Add / remove hosts (enrolment, accept/reject pending) | ✓ | ✓ | ✗ | +| Cancel running jobs | ✓ | ✓ | ✗ | +| Edit repo credentials | ✓ | ✓ | ✗ | +| Edit notification channels | ✓ | ✗ | ✗ | +| Manage users | ✓ | ✗ | ✗ | +| Self password change (`/settings/account`) | ✓ | ✓ | ✓ | + +The role enum already exists in the schema (`CHECK (role IN ('admin','operator','viewer'))`) and in `internal/store/types.go`. Bootstrap creates the first user as admin. Zero migration needed for existing installs. + +## Schema changes + +All column-level ALTERs (CLAUDE.md prefers these over rebuilds; safe under `foreign_keys=ON`). + +### Migration 0017 — `users` extensions + +```sql +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; + +-- Username case-insensitive lookup. Existing rows are kept as-is; +-- normalisation only applies to new INSERTs (handled in Go). +CREATE UNIQUE INDEX users_username_lower ON users(LOWER(username)); +``` + +### Migration 0018 — `user_setup_tokens` + +```sql +CREATE TABLE user_setup_tokens ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, -- sha256(raw_token), hex + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + created_by TEXT NOT NULL REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX user_setup_tokens_expires ON user_setup_tokens(expires_at); +``` + +`user_id` is PRIMARY KEY, not just FOREIGN KEY — only one outstanding setup token per user. Regenerating supersedes the old via `INSERT OR REPLACE`. + +## RBAC enforcement + +### Middleware + +```go +// requireRole returns chi middleware that 403s any request whose +// session-resolved user doesn't meet the minimum role. Roles are +// hierarchical: admin > operator > viewer. +func (s *Server) requireRole(min store.Role) func(http.Handler) http.Handler +``` + +Hierarchy implemented as a small helper: + +```go +func roleAtLeast(have, min store.Role) bool { + rank := map[store.Role]int{ + store.RoleViewer: 1, + store.RoleOperator: 2, + store.RoleAdmin: 3, + } + return rank[have] >= rank[min] +} +``` + +### Route grouping in `server.go` + +The existing `/api` and UI routes get re-grouped into three role bands plus a self-service group: + +``` +/api/* viewer-readable — GET endpoints anyone authenticated can hit +/api/* operator+ — mutating endpoints up to host/source-group/schedule level +/api/* admin-only — /api/users/*, channel CRUD +/api/account — self-service password change + +/audit, /alerts, /hosts/{id}, etc. — viewer +/hosts/{id}/run, /alerts/{id}/ack — operator +/settings/users/*, /settings/notifications/* — admin +/settings/account — viewer (any authenticated) +``` + +Default at the bottom of `routes()` is admin (fail-closed). Any future endpoint that doesn't get explicitly placed lands in admin-only, surfacing the missing declaration as a permission error rather than a silent bypass. + +### Per-handler nuance + +One existing case warrants a handler-level check on top of the route gate: `GET /settings/users/{id}/edit` is admin-only, but the `PUT /api/account/password` is viewer-OK. The split-by-route already covers this; no per-handler overrides expected in v1. + +### Out of scope of role middleware + +- `/ws/agent` and `/api/agents/*` — agent bearer-token auth, separate chain +- `/healthz` — unauthenticated +- `/login`, `/logout`, `/bootstrap` — public + +### 403 handling + +- JSON endpoints: `{"error":"forbidden","code":"insufficient_role"}` with HTTP 403 +- HTML endpoints: render a small "You don't have permission" panel inside the chrome (so the user keeps their nav and can move away), HTTP 403 +- **No audit row on 403** — too noisy with normal users hitting URLs they don't have access to + +### Session re-validation + +Sessions need to honour `disabled_at` and current role on every request, not just at login. The session-validation middleware reads the user row each request (single PK lookup, fast in SQLite). If `disabled_at IS NOT NULL`, the session is invalidated and the request 401s. This makes "disable user" and "force logout" effectively immediate. + +Cost: one SELECT per authenticated request. SQLite handles this comfortably for the fleet sizes this codebase targets. + +## Setup-token flow (replacing temp passwords) + +### Add user + +1. Admin clicks **+ Add user** on `/settings/users` +2. Form: username (required, lowercase-normalised), email (optional, validated), role (admin/operator/viewer) +3. Server: + - Validates username uniqueness (case-insensitive). On collision with a *disabled* user, return a 409 with `{"existing_user_id": "...", "disabled": true}` so the UI can pivot to a "re-enable existing user" prompt + - On collision with an enabled user: 409 with a plain "username taken" error + - Creates user row with `password_hash = ""`, `must_change_password = 1`, `disabled_at = NULL` + - Generates 32 random bytes, hex-encodes → raw token (64 chars). Stores `sha256(token)` hex in `user_setup_tokens`. `expires_at = now + 1h` + - Audit: `user.created`, payload `{"username": "...", "role": "...", "with_setup_token": true}` +4. Server returns the admin to a one-time setup-link page: `/settings/users/{id}/setup-link` + - Shows the URL `http(s):///setup?token=` with a Copy button + - Countdown timer (live JS) showing time-to-expiry + - Warning: "This is the only time you'll see this link. If you lose it, regenerate from the user edit page." + - "Done" button → `/settings/users` + +The raw token is **never persisted** server-side. Lost tokens require regeneration. + +### Setup landing page (public, no auth required) + +1. User clicks the link, lands on `/setup?token=` +2. Server hashes the token, looks up `user_setup_tokens` row, validates `expires_at > now` +3. On invalid / expired: render an error page with a "Contact your administrator" message. Audit: `user.setup_token.expired` (no actor). +4. On valid: render a password-set form: `new password + confirm`. Submit: + - Validates password meets policy (min 12 chars, no other constraints in v1 — same as bootstrap path) + - Hashes via `auth.HashPassword` (existing helper) + - Updates `users.password_hash`, sets `must_change_password = 0` + - Deletes the `user_setup_tokens` row (single-use) + - Logs the user in via the existing session helper + - Audit: `user.setup_completed`, payload `{"user_id": "..."}` + - Redirect to `/` + +### Regenerate setup link (admin) + +`/settings/users/{id}/edit` shows a "Regenerate setup link" button when `must_change_password = 1`. Clicking it: + +1. Generates a new token + hash, INSERT OR REPLACE on `user_setup_tokens` +2. Returns the admin to the same one-time link page as the add-user flow +3. Audit: `user.setup_token.regenerated` + +### Cleanup + +Expired tokens linger in the DB until cleaned. Add a cheap sweep on the existing maintenance ticker: `DELETE FROM user_setup_tokens WHERE expires_at < ?`. Runs at the same cadence as the alert engine tick (60s). No new ticker needed. + +## Self-service password change + +`/settings/account` + +- Accessible to every authenticated user (any role) +- Form: `current password + new password + confirm` +- Server validates current password (re-uses login bcrypt comparison), updates hash, audits `user.password_changed` +- Special case: if `must_change_password = 1`, the current-password field is hidden / not required (covers the legacy "admin reset password" path if we ever add one — current setup-token path doesn't use this) + +The bootstrap user's password change uses this same page (no special case for "first admin"). + +## User list / management UI + +### `/settings/users` (admin-only) + +``` +Settings · Users [3] +───────────────────────────────────────────────── +[ + Add user ] [ ] Show disabled + +USERNAME EMAIL ROLE LAST LOGIN STATUS +alice alice@example.com admin 2 mins ago enabled +bob — operator 3 days ago enabled +charlie c@example.com viewer never setup pending ← if has open setup token +diane d@example.com operator 1 month ago disabled ← only when "Show disabled" + +Actions per row: Edit · (Re-enable | Disable) +``` + +- "setup pending" badge for users with `must_change_password=1` — clicking the row goes to edit, which surfaces the regenerate-link button prominently +- "Show disabled" is a checkbox querystring filter (`?show_disabled=1`) +- Sort columns: clickable like the audit log (username, role, last_login). Reuse the same pattern (server-side sort + URL builder + glyph) + +### `/settings/users/new` (admin-only) + +Single form: `username + email (optional) + role`. On submit → either landed on the setup-link page (success) or returned with an inline "username exists, re-enable existing?" panel (collision with disabled user) / red error (collision with enabled user). + +### `/settings/users/{id}/edit` (admin-only) + +- Display-only block: id, created_at, last_login_at, status +- **Editable**: email, role +- **Buttons**: + - "Regenerate setup link" — only when `must_change_password = 1` + - "Disable user" — flips `disabled_at`; rejected if last enabled admin (server-side check). Confirmation modal with typed name to confirm. + - "Re-enable user" — clears `disabled_at`. No confirmation. + - "Force logout" — separate from disable; just kills the session but keeps the user enabled. Useful for "I think Bob's session was hijacked" without locking him out. +- Cancel / Save buttons at the bottom + +### `/settings/users/{id}/setup-link` (admin-only) + +Renders the one-time link with copy button + countdown. Shown after add-user and after regenerate. Reload of this URL after the token is consumed: 410 Gone with a clear message. + +### `/settings/account` (any authenticated) + +Self-service password change. Form-only page; no nav under Settings since most users will only see this one Settings page in v1. + +## API surface + +``` +GET /api/users admin — list (with ?show_disabled=1 filter) +POST /api/users admin — create user, returns user_id + setup_url +GET /api/users/{id} admin — read +PATCH /api/users/{id} admin — update email, role +POST /api/users/{id}/disable admin — set disabled_at; rejects last-admin +POST /api/users/{id}/enable admin — clear disabled_at +POST /api/users/{id}/regenerate-setup admin — new token, returns setup_url +POST /api/users/{id}/force-logout admin — kill all sessions for this user + +POST /api/account/password any auth — self password change +GET /setup public — landing page (HTML form) +POST /setup public — submit new password +``` + +UI routes mirror the API but at `/settings/users/...`. + +## Last-admin self-protection + +Two operations that could lock everyone out are guarded: + +- **Disable user**: rejected if the user is admin AND there are no other enabled admins +- **Demote admin to operator/viewer**: same check + +Server-side enforcement (single SELECT on `COUNT(*) FROM users WHERE role='admin' AND disabled_at IS NULL`). UI hint: edit page disables the role dropdown's non-admin options + disable button when the user is the last admin, with a tooltip explaining why. + +The bootstrap admin is just a regular admin row; this check covers it. + +## Audit actions + +New action strings introduced: + +- `user.created` +- `user.updated` (email / role change) +- `user.disabled` +- `user.enabled` +- `user.password_changed` +- `user.setup_completed` +- `user.setup_token.regenerated` +- `user.setup_token.expired` (system-driven, on cleanup sweep) +- `user.force_logout` + +All target_kind = `user`, target_id = the affected user's id. Existing payload conventions apply. + +## Ordering / dependencies + +Slices in approximate landing order (writing-plans will firm this up): + +1. **A. Schema** — migrations 0017 + 0018, `Role` helper updates, store API extensions (email, disabled_at, must_change_password, setup_token CRUD, lowercase username constraints) +2. **B. RBAC middleware** — `requireRole` + `roleAtLeast`, route re-grouping in server.go, 403 rendering for HTML + JSON +3. **C. Session re-validation** — extend the existing session middleware to re-read user state per request, kick disabled users +4. **D. Setup-token flow** — `/setup` GET+POST, the one-time link page after add-user +5. **E. User CRUD API** — handlers + handlers' tests +6. **F. UI** — `/settings/users` list, add, edit, setup-link page, account page +7. **G. Sweep** — Playwright walk through the full lifecycle (add → setup link → user signs in → admin disables → user gets kicked → admin re-enables → user signs back in) + +Each slice can land as its own commit on the branch. RBAC middleware (B) goes in *before* user CRUD so we don't ship an open `/api/users/*` even briefly. + +## Test strategy + +- **Store**: `Set/GetSetupToken`, `EnableUser`/`DisableUser`, last-admin guard, lowercase-username uniqueness, expired-token cleanup +- **HTTP middleware**: `roleAtLeast` truth table; viewer hitting an operator route returns 403; disabled user gets 401 mid-session +- **Setup flow integration**: create user → fetch setup URL → land on `/setup?token=...` → POST password → user can log in → token row gone +- **UI**: existing Playwright sweep pattern, screenshots into `_diag/p4-03-04-sweep/` + +## Out of scope (deferred) + +- **OIDC** (P4-05) — adds a parallel auth chain. This PR keeps the surface for it (role taxonomy, session middleware) but doesn't wire it. +- **Email-the-setup-link** — explicitly deferred. Easy follow-up because the SMTP channel client from P3-06 is already there. +- **Hard delete** — disable-only in v1; can add a typed-confirm "purge" later if it turns out to be needed. +- **Password complexity / rotation policy** — current minimum (12 chars) and no rotation; tighten later if/when policy demands. +- **Lockout on failed login** — a brute-force protection layer is its own task and orthogonal to RBAC. +- **Audit on 403** — not in v1; revisit if compliance asks for it. + +## Risks / gotchas to watch + +- **Existing tests** that assume "any logged-in user can hit any endpoint" will break. Audit the test fixtures: most use `loginAsAdmin`, which is fine; any tests currently exercising specific operator/viewer paths need explicit role assignment. (Quick grep suggests there aren't many — bootstrap-only.) +- **Bootstrap user normalisation** — the existing admin row's username is whatever it was set to at first run. The new lowercase-uniqueness index uses `LOWER(username)`, which makes the existing row implicitly lowercase-keyed for lookups. No data migration needed. +- **Session middleware re-read cost** — one SELECT per authenticated request. SQLite WAL handles this fine at expected fleet sizes; if it ever shows up on a profile we add a small in-memory cache keyed by session id with a 30s TTL. +- **403 vs 401 distinction** — make sure unauthenticated requests still get 401 (login redirect) and authenticated-but-insufficient get 403. The middleware should compose: auth-required first, role-required second. + +## Acceptance + +- [ ] An admin can add a user, copy the setup link, the new user can land on `/setup?token=...`, set a password, and reach `/` +- [ ] An expired token (>1h) on `/setup?token=...` shows the "contact your administrator" page +- [ ] Admin regenerates the link, old token is invalid, new token works +- [ ] Operator user can trigger Run-now but cannot reach `/settings/users` (403) and the Users tab in Settings is hidden in their nav +- [ ] Viewer user gets 403 on Run-now, 200 on dashboard / alerts / audit +- [ ] Admin disables a user mid-session — the user's next request is 401 and they're redirected to login +- [ ] Admin cannot disable themselves if they are the last enabled admin (server returns 409, UI button is greyed) +- [ ] Self-service password change at `/settings/account` works for every role +- [ ] All existing tests pass; new test suite covers role middleware, setup-token lifecycle, last-admin guard + +## Self-review notes + +- ✅ All sections concrete, no TBD / TODO +- ✅ Schema migrations are column-level (CLAUDE.md compliance) +- ✅ Audit action vocabulary listed in one place; no string typos to drift +- ✅ Out-of-scope list explicit so reviewers can challenge what we *aren't* doing +- ✅ Last-admin guard handled both server-side and UI-hinted +- ✅ Token storage hashes the secret server-side; raw is shown to admin once and never again +- ✅ Session re-validation cost noted with a fallback if it shows up on a profile From c9f230ce1d1fe72bb8059388cea3c6e475a31806 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 08:51:34 +0100 Subject: [PATCH 02/30] =?UTF-8?q?plan:=20P4-03/04=20=E2=80=94=20RBAC=20+?= =?UTF-8?q?=20user=20management=20implementation=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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-p4-03-04-rbac-user-mgmt.md | 4046 +++++++++++++++++ 1 file changed, 4046 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-05-p4-03-04-rbac-user-mgmt.md diff --git a/docs/superpowers/plans/2026-05-05-p4-03-04-rbac-user-mgmt.md b/docs/superpowers/plans/2026-05-05-p4-03-04-rbac-user-mgmt.md new file mode 100644 index 0000000..ca5120e --- /dev/null +++ b/docs/superpowers/plans/2026-05-05-p4-03-04-rbac-user-mgmt.md @@ -0,0 +1,4046 @@ +# P4-03 / P4-04 — RBAC + User Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Enforce role-based access control across the HTTP layer and ship the user-management UI (create / disable / role-change / setup-link with 1h expiry / self-service password change). + +**Architecture:** Schema gains three columns on `users` (email, disabled_at, must_change_password) plus a new `user_setup_tokens` table. Chi route-group middleware (`requireRole`) gates each subtree by minimum role with admin as the fail-closed default. A new setup-token flow replaces the temp-password idiom: admin creates user → server returns a one-time link valid for 1 hour → user clicks link → sets password → logged in. Self-service password change at `/settings/account` is open to every role. Sessions re-validate `disabled_at` and current role on every request so admin-driven changes land immediately. + +**Tech Stack:** Go 1.25, modernc.org/sqlite, chi v5 router, html/template, htmx, Tailwind. Existing crypto helpers (auth.HashToken, auth.HashPassword, auth.ComparePassword) reused. + +**Branch:** `p4-03-04-rbac-user-mgmt` (already exists with the spec commit). + +**Spec:** `docs/superpowers/specs/2026-05-05-p4-03-04-rbac-user-mgmt-design.md` + +--- + +## File structure + +### Created files + +- `internal/store/migrations/0017_users_extensions.sql` — email, disabled_at, must_change_password columns + lowercase username unique index +- `internal/store/migrations/0018_user_setup_tokens.sql` — table for setup tokens +- `internal/store/setup_tokens.go` — store API for `user_setup_tokens` (Set / Lookup / Delete / Cleanup) +- `internal/store/setup_tokens_test.go` — coverage for the above +- `internal/server/http/rbac.go` — `roleAtLeast` helper + `requireRole` middleware + `forbidden` HTML/JSON renderers +- `internal/server/http/rbac_test.go` — table-driven middleware tests +- `internal/server/http/ui_users.go` — Settings → Users handlers (list, new, edit, setup-link, disable/enable, regenerate, force-logout) +- `internal/server/http/api_users.go` — JSON handlers (list, create, patch, disable, enable, regenerate, force-logout) +- `internal/server/http/ui_account.go` — `/settings/account` self-service password change +- `internal/server/http/setup_handler.go` — public `/setup` GET + POST +- `internal/server/http/users_test.go` — handler-level coverage for the user API + setup flow +- `web/templates/pages/users.html` — Settings → Users list page +- `web/templates/pages/user_edit.html` — Add user / edit user / setup-link page (multi-mode template) +- `web/templates/pages/account.html` — self-service password page +- `web/templates/pages/setup.html` — public landing page for `/setup?token=...` + +### Modified files + +- `internal/store/users.go` — extend User struct fields, lowercase normalisation in CreateUser, new methods (SetUserEmail, SetUserRole, DisableUser, EnableUser, SetMustChangePassword, SetPasswordHash, CountEnabledAdmins) +- `internal/store/types.go` — extend User struct (Email, DisabledAt, MustChangePassword) +- `internal/store/sessions.go` — add `DeleteSessionsByUserID` for force-logout +- `internal/server/http/jobs.go` — `requireUser` rejects disabled users +- `internal/server/http/ui_handlers.go` — `loadAuthedUser` rejects disabled users +- `internal/server/http/server.go` — re-group routes under role bands, mount new handlers +- `internal/server/http/auth.go` — login rejects disabled users +- `web/templates/pages/settings.html` — flip the dormant Users tab live + add "Account" sub-tab link +- `web/templates/partials/nav.html` — hide Settings tab for non-admins (Account link still reachable directly) +- `internal/server/http/maintenance_dispatch.go` — periodic sweep of expired setup tokens (or new ticker hook) +- `tasks.md` — tick P4-03 + P4-04 on completion + +--- + +## Slice A — Schema & store API + +### Task A1: Migration 0017 — users extensions + +**Files:** +- Create: `internal/store/migrations/0017_users_extensions.sql` +- Test: `internal/store/migrate_test.go` (already exists; just runs all migrations on a fresh DB) + +- [ ] **Step 1: Write the migration** + +```sql +-- 0017_users_extensions.sql +-- +-- Add the columns the user-management UI needs: +-- email — optional, free-form text; format-checked +-- in Go on insert/update via net/mail.ParseAddress +-- disabled_at — soft-delete tombstone. NULL = enabled +-- must_change_password — flag set by admin-create + setup-token flow; +-- cleared by /setup or /settings/account +-- +-- Plus a case-insensitive unique index so 'Alice' and 'alice' can't +-- both exist (lowercase normalisation is applied in the Go layer +-- on every CreateUser; this index defends the invariant). +-- +-- Column-level ALTERs (CLAUDE.md prefers these over rebuilds; safe +-- under foreign_keys=ON). + +ALTER TABLE users ADD COLUMN email TEXT; +ALTER TABLE users ADD COLUMN disabled_at TEXT; +ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0; + +CREATE UNIQUE INDEX users_username_lower ON users(LOWER(username)); +``` + +- [ ] **Step 2: Run all tests, expect them to pass** + +Run: `go test ./internal/store/...` +Expected: all PASS (existing migrations test exercises the new file). + +- [ ] **Step 3: Commit** + +```bash +git add internal/store/migrations/0017_users_extensions.sql +git commit -m "store: migration 0017 — users.email, disabled_at, must_change_password" +``` + +--- + +### Task A2: Migration 0018 — user_setup_tokens + +**Files:** +- Create: `internal/store/migrations/0018_user_setup_tokens.sql` + +- [ ] **Step 1: Write the migration** + +```sql +-- 0018_user_setup_tokens.sql +-- +-- One outstanding setup token per user (PRIMARY KEY on user_id). +-- Regenerating a link is INSERT OR REPLACE — old token immediately +-- invalid. Token is stored as sha256(raw) hex, never the raw token, +-- so a DB leak doesn't leak active links. + +CREATE TABLE user_setup_tokens ( + user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + created_by TEXT REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX user_setup_tokens_expires ON user_setup_tokens(expires_at); +``` + +- [ ] **Step 2: Run all tests, expect them to pass** + +Run: `go test ./internal/store/...` +Expected: all PASS. + +- [ ] **Step 3: Commit** + +```bash +git add internal/store/migrations/0018_user_setup_tokens.sql +git commit -m "store: migration 0018 — user_setup_tokens" +``` + +--- + +### Task A3: Extend User struct + add SetupToken type + +**Files:** +- Modify: `internal/store/types.go` + +- [ ] **Step 1: Add fields to the User struct** + +Find the existing `User` struct (currently has ID, Username, PasswordHash, Role, CreatedAt, LastLoginAt) and add the new fields: + +```go +type User struct { + ID string + Username string + PasswordHash string + Role Role + Email *string // optional; nil = not set + DisabledAt *time.Time // nil = enabled + MustChangePassword bool + CreatedAt time.Time + LastLoginAt *time.Time +} +``` + +- [ ] **Step 2: Add the SetupToken type** + +Append to `internal/store/types.go`: + +```go +// SetupToken mirrors the user_setup_tokens table. The raw token +// itself is never stored; the field shown here is the sha256 hex +// digest of the raw token, which is what callers compare against. +type SetupToken struct { + UserID string + TokenHash string + ExpiresAt time.Time + CreatedAt time.Time + CreatedBy *string // admin user id; nil only after CASCADE SET NULL +} +``` + +- [ ] **Step 3: Run vet, expect compilation errors in users.go** + +Run: `go vet ./internal/store/...` +Expected: errors about missing fields in `scanUser`-flavoured code (we'll fix those next). + +- [ ] **Step 4: Commit (broken intermediate, fixed by next task)** + +```bash +git add internal/store/types.go +git commit -m "store: extend User struct with Email, DisabledAt, MustChangePassword" +``` + +--- + +### Task A4: Update users store — lowercase username, new fields, helper methods + +**Files:** +- Modify: `internal/store/users.go` +- Test: `internal/store/users_test.go` + +- [ ] **Step 1: Write a failing test for lowercase normalisation** + +Append to `internal/store/users_test.go`: + +```go +func TestCreateUserLowercasesUsername(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + if err := s.CreateUser(ctx, User{ + ID: "u1", Username: "Alice", + PasswordHash: "x", Role: RoleAdmin, CreatedAt: now, + }); err != nil { + t.Fatalf("create: %v", err) + } + got, err := s.GetUserByUsername(ctx, "alice") + if err != nil { + t.Fatalf("get lower: %v", err) + } + if got.Username != "alice" { + t.Errorf("stored username: got %q want %q", got.Username, "alice") + } + // Case-insensitive lookup must hit the row by either casing. + got, err = s.GetUserByUsername(ctx, "ALICE") + if err != nil { + t.Fatalf("get upper: %v", err) + } + if got.ID != "u1" { + t.Errorf("upper-case lookup missed: got %+v", got) + } + // Re-creating with mixed case must collide. + if err := s.CreateUser(ctx, User{ + ID: "u2", Username: "AlIcE", + PasswordHash: "x", Role: RoleAdmin, CreatedAt: now, + }); err == nil { + t.Error("duplicate (different case) should fail") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/store/ -run TestCreateUserLowercasesUsername` +Expected: FAIL — current `CreateUser` stores the username verbatim. + +- [ ] **Step 3: Update the User helpers to lowercase + use LOWER()** + +Modify `internal/store/users.go`: + +```go +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +// CreateUser inserts a row. Username is lowercase-normalised so the +// case-insensitive unique index from migration 0017 doesn't surprise +// callers who insert 'Alice' and look up 'alice'. +func (s *Store) CreateUser(ctx context.Context, u User) error { + u.Username = strings.ToLower(strings.TrimSpace(u.Username)) + must := 0 + if u.MustChangePassword { + must = 1 + } + _, err := s.db.ExecContext(ctx, + `INSERT INTO users (id, username, password_hash, role, email, + must_change_password, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + u.ID, u.Username, u.PasswordHash, string(u.Role), + nullable(u.Email), must, + u.CreatedAt.UTC().Format(time.RFC3339Nano)) + if err != nil { + return fmt.Errorf("store: create user: %w", err) + } + return nil +} + +// GetUserByUsername resolves a user case-insensitively. +func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, username, password_hash, role, email, disabled_at, + must_change_password, created_at, last_login_at + FROM users WHERE LOWER(username) = LOWER(?)`, username) + return scanUser(row.Scan) +} + +// GetUserByID looks up a user by id. Returns ErrNotFound on miss. +func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, username, password_hash, role, email, disabled_at, + must_change_password, created_at, last_login_at + FROM users WHERE id = ?`, id) + return scanUser(row.Scan) +} + +// ListUsers returns every user, sorted by username. Used by the +// audit log filter and the user-management page. +func (s *Store) ListUsers(ctx context.Context) ([]User, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, username, password_hash, role, email, disabled_at, + must_change_password, created_at, last_login_at + FROM users ORDER BY username`) + if err != nil { + return nil, fmt.Errorf("store: list users: %w", err) + } + defer func() { _ = rows.Close() }() + var out []User + for rows.Next() { + u, err := scanUser(rows.Scan) + if err != nil { + return nil, err + } + out = append(out, *u) + } + return out, rows.Err() +} + +// CountUsers returns the total number of user rows. +func (s *Store) CountUsers(ctx context.Context) (int, error) { + var n int + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&n); err != nil { + return 0, fmt.Errorf("store: count users: %w", err) + } + return n, nil +} + +// CountEnabledAdmins returns the number of users with role='admin' +// AND disabled_at IS NULL. Used by the last-admin guard before +// disable / role-demote operations. +func (s *Store) CountEnabledAdmins(ctx context.Context) (int, error) { + var n int + if err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM users WHERE role = 'admin' AND disabled_at IS NULL`, + ).Scan(&n); err != nil { + return 0, fmt.Errorf("store: count admins: %w", err) + } + return n, nil +} + +// MarkUserLogin records a successful authentication. +func (s *Store) MarkUserLogin(ctx context.Context, id string, when time.Time) error { + _, err := s.db.ExecContext(ctx, + `UPDATE users SET last_login_at = ? WHERE id = ?`, + when.UTC().Format(time.RFC3339Nano), id) + if err != nil { + return fmt.Errorf("store: mark login: %w", err) + } + return nil +} + +// SetUserEmail replaces the email field. Empty string clears it. +func (s *Store) SetUserEmail(ctx context.Context, id, email string) error { + em := strings.ToLower(strings.TrimSpace(email)) + var v any + if em == "" { + v = nil + } else { + v = em + } + _, err := s.db.ExecContext(ctx, + `UPDATE users SET email = ? WHERE id = ?`, v, id) + if err != nil { + return fmt.Errorf("store: set user email: %w", err) + } + return nil +} + +// SetUserRole changes a user's role. +func (s *Store) SetUserRole(ctx context.Context, id string, role Role) error { + _, err := s.db.ExecContext(ctx, + `UPDATE users SET role = ? WHERE id = ?`, string(role), id) + if err != nil { + return fmt.Errorf("store: set user role: %w", err) + } + return nil +} + +// DisableUser sets disabled_at = when. Idempotent on already-disabled +// rows (no-op). +func (s *Store) DisableUser(ctx context.Context, id string, when time.Time) error { + _, err := s.db.ExecContext(ctx, + `UPDATE users SET disabled_at = ? + WHERE id = ? AND disabled_at IS NULL`, + when.UTC().Format(time.RFC3339Nano), id) + if err != nil { + return fmt.Errorf("store: disable user: %w", err) + } + return nil +} + +// EnableUser clears disabled_at. +func (s *Store) EnableUser(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE users SET disabled_at = NULL WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("store: enable user: %w", err) + } + return nil +} + +// SetMustChangePassword toggles the must_change_password flag. +func (s *Store) SetMustChangePassword(ctx context.Context, id string, must bool) error { + v := 0 + if must { + v = 1 + } + _, err := s.db.ExecContext(ctx, + `UPDATE users SET must_change_password = ? WHERE id = ?`, v, id) + if err != nil { + return fmt.Errorf("store: set must_change_password: %w", err) + } + return nil +} + +// SetPasswordHash stores a new password_hash and clears the +// must_change_password flag in one go. +func (s *Store) SetPasswordHash(ctx context.Context, id, hash string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE users SET password_hash = ?, must_change_password = 0 WHERE id = ?`, + hash, id) + if err != nil { + return fmt.Errorf("store: set password: %w", err) + } + return nil +} + +func scanUser(scan func(...any) error) (*User, error) { + var u User + var role string + var email, disabledAt, lastLogin sql.NullString + var must int + var created string + if err := scan(&u.ID, &u.Username, &u.PasswordHash, &role, + &email, &disabledAt, &must, &created, &lastLogin); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("store: scan user: %w", err) + } + u.Role = Role(role) + if email.Valid { + v := email.String + u.Email = &v + } + if disabledAt.Valid { + t, _ := time.Parse(time.RFC3339Nano, disabledAt.String) + u.DisabledAt = &t + } + u.MustChangePassword = must == 1 + t, _ := time.Parse(time.RFC3339Nano, created) + u.CreatedAt = t + if lastLogin.Valid { + t, _ := time.Parse(time.RFC3339Nano, lastLogin.String) + u.LastLoginAt = &t + } + return &u, nil +} +``` + +Note: this *replaces* the existing file (which has GetUserByUsername / GetUserByID / scanUser using the old column set + old User struct). Read the current file first to confirm the only public surface you're changing/adding is what's listed above. + +- [ ] **Step 4: Run all store tests, expect them to pass** + +Run: `go test ./internal/store/...` +Expected: all PASS, including the new `TestCreateUserLowercasesUsername`. + +- [ ] **Step 5: Run vet across the repo and fix call sites** + +Run: `go vet ./...` +Expected: errors about anywhere that constructed `User{}` without the new optional fields — those are fine because Go zero-values handle them. +Actual ones to address: the test file `internal/store/users_test.go` uses `nullable` indirectly only inside the package; no external callers should break. If anything fails outside `internal/store`, fix the call site (likely a test fixture). + +- [ ] **Step 6: Commit** + +```bash +git add internal/store/users.go internal/store/users_test.go +git commit -m "store: lowercase username, email/disable helpers, last-admin count" +``` + +--- + +### Task A5: Setup-token store API + +**Files:** +- Create: `internal/store/setup_tokens.go` +- Create: `internal/store/setup_tokens_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +package store + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/oklog/ulid/v2" +) + +func newSetupTokenTestStore(t *testing.T) (*Store, string, string) { + t.Helper() + st, err := Open(context.Background(), filepath.Join(t.TempDir(), "rm.db")) + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + uid := ulid.Make().String() + creator := ulid.Make().String() + now := time.Now().UTC() + if err := st.CreateUser(context.Background(), User{ + ID: creator, Username: "creator", PasswordHash: "x", + Role: RoleAdmin, CreatedAt: now, + }); err != nil { + t.Fatalf("create creator: %v", err) + } + if err := st.CreateUser(context.Background(), User{ + ID: uid, Username: "target", PasswordHash: "", + Role: RoleOperator, CreatedAt: now, MustChangePassword: true, + }); err != nil { + t.Fatalf("create target: %v", err) + } + return st, uid, creator +} + +func TestSetupTokenSetAndLookup(t *testing.T) { + t.Parallel() + st, uid, creator := newSetupTokenTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + if err := st.SetSetupToken(ctx, SetupToken{ + UserID: uid, TokenHash: "abc123", + ExpiresAt: now.Add(time.Hour), + CreatedAt: now, CreatedBy: &creator, + }); err != nil { + t.Fatalf("set: %v", err) + } + got, err := st.LookupSetupToken(ctx, "abc123") + if err != nil { + t.Fatalf("lookup: %v", err) + } + if got.UserID != uid { + t.Errorf("user_id: got %q want %q", got.UserID, uid) + } +} + +func TestSetupTokenReplaces(t *testing.T) { + t.Parallel() + st, uid, creator := newSetupTokenTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + _ = st.SetSetupToken(ctx, SetupToken{ + UserID: uid, TokenHash: "old", + ExpiresAt: now.Add(time.Hour), CreatedAt: now, CreatedBy: &creator, + }) + _ = st.SetSetupToken(ctx, SetupToken{ + UserID: uid, TokenHash: "new", + ExpiresAt: now.Add(time.Hour), CreatedAt: now, CreatedBy: &creator, + }) + if _, err := st.LookupSetupToken(ctx, "old"); err == nil { + t.Error("old token should be gone") + } + if _, err := st.LookupSetupToken(ctx, "new"); err != nil { + t.Errorf("new token should resolve: %v", err) + } +} + +func TestSetupTokenDelete(t *testing.T) { + t.Parallel() + st, uid, creator := newSetupTokenTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + _ = st.SetSetupToken(ctx, SetupToken{ + UserID: uid, TokenHash: "tk", + ExpiresAt: now.Add(time.Hour), CreatedAt: now, CreatedBy: &creator, + }) + if err := st.DeleteSetupToken(ctx, uid); err != nil { + t.Fatalf("delete: %v", err) + } + if _, err := st.LookupSetupToken(ctx, "tk"); err == nil { + t.Error("deleted token should not resolve") + } +} + +func TestSetupTokenCleanupExpired(t *testing.T) { + t.Parallel() + st, uid, creator := newSetupTokenTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + _ = st.SetSetupToken(ctx, SetupToken{ + UserID: uid, TokenHash: "stale", + ExpiresAt: now.Add(-time.Hour), CreatedAt: now.Add(-2 * time.Hour), + CreatedBy: &creator, + }) + n, err := st.CleanupExpiredSetupTokens(ctx, now) + if err != nil { + t.Fatalf("cleanup: %v", err) + } + if n != 1 { + t.Errorf("cleanup count: got %d want 1", n) + } + if _, err := st.LookupSetupToken(ctx, "stale"); err == nil { + t.Error("stale token should be gone") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/store/ -run TestSetupToken` +Expected: FAIL — `SetSetupToken / LookupSetupToken / DeleteSetupToken / CleanupExpiredSetupTokens` undefined. + +- [ ] **Step 3: Implement the store API** + +```go +// internal/store/setup_tokens.go +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" +) + +// SetSetupToken inserts a row, replacing any existing token for +// this user (single-outstanding invariant). Caller passes a hash — +// raw tokens are never persisted. +func (s *Store) SetSetupToken(ctx context.Context, t SetupToken) error { + _, err := s.db.ExecContext(ctx, + `INSERT OR REPLACE INTO user_setup_tokens + (user_id, token_hash, expires_at, created_at, created_by) + VALUES (?, ?, ?, ?, ?)`, + t.UserID, t.TokenHash, + t.ExpiresAt.UTC().Format(time.RFC3339Nano), + t.CreatedAt.UTC().Format(time.RFC3339Nano), + nullable(t.CreatedBy)) + if err != nil { + return fmt.Errorf("store: set setup token: %w", err) + } + return nil +} + +// LookupSetupToken resolves a token hash to its row. Returns +// ErrNotFound for missing tokens. Expiry is NOT checked here — +// callers must compare ExpiresAt themselves so they can record +// 'expired' as a distinct outcome (audit-able) from 'never existed'. +func (s *Store) LookupSetupToken(ctx context.Context, tokenHash string) (*SetupToken, error) { + row := s.db.QueryRowContext(ctx, + `SELECT user_id, token_hash, expires_at, created_at, created_by + FROM user_setup_tokens WHERE token_hash = ?`, tokenHash) + var t SetupToken + var createdBy sql.NullString + var expiresAt, createdAt string + if err := row.Scan(&t.UserID, &t.TokenHash, &expiresAt, &createdAt, &createdBy); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("store: scan setup token: %w", err) + } + t.ExpiresAt, _ = time.Parse(time.RFC3339Nano, expiresAt) + t.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) + if createdBy.Valid { + v := createdBy.String + t.CreatedBy = &v + } + return &t, nil +} + +// GetSetupTokenByUserID returns the row for one user (used by the +// edit page to know whether a 'Regenerate setup link' button should +// show as 'Generate' or 'Regenerate'). Returns ErrNotFound when no +// outstanding token exists. +func (s *Store) GetSetupTokenByUserID(ctx context.Context, userID string) (*SetupToken, error) { + row := s.db.QueryRowContext(ctx, + `SELECT user_id, token_hash, expires_at, created_at, created_by + FROM user_setup_tokens WHERE user_id = ?`, userID) + var t SetupToken + var createdBy sql.NullString + var expiresAt, createdAt string + if err := row.Scan(&t.UserID, &t.TokenHash, &expiresAt, &createdAt, &createdBy); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("store: scan setup token: %w", err) + } + t.ExpiresAt, _ = time.Parse(time.RFC3339Nano, expiresAt) + t.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) + if createdBy.Valid { + v := createdBy.String + t.CreatedBy = &v + } + return &t, nil +} + +// DeleteSetupToken removes the row for a user (single-use cleanup +// after /setup completes successfully). +func (s *Store) DeleteSetupToken(ctx context.Context, userID string) error { + _, err := s.db.ExecContext(ctx, + `DELETE FROM user_setup_tokens WHERE user_id = ?`, userID) + if err != nil { + return fmt.Errorf("store: delete setup token: %w", err) + } + return nil +} + +// CleanupExpiredSetupTokens removes rows whose expires_at has passed. +// Returns the number of rows deleted. Called from the maintenance +// ticker every minute. +func (s *Store) CleanupExpiredSetupTokens(ctx context.Context, now time.Time) (int64, error) { + res, err := s.db.ExecContext(ctx, + `DELETE FROM user_setup_tokens WHERE expires_at < ?`, + now.UTC().Format(time.RFC3339Nano)) + if err != nil { + return 0, fmt.Errorf("store: cleanup setup tokens: %w", err) + } + n, _ := res.RowsAffected() + return n, nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/store/ -run TestSetupToken` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/store/setup_tokens.go internal/store/setup_tokens_test.go +git commit -m "store: user_setup_tokens CRUD + cleanup-expired" +``` + +--- + +### Task A6: DeleteSessionsByUserID + +**Files:** +- Modify: `internal/store/sessions.go` +- Test: `internal/store/sessions_test.go` (existing or create) + +- [ ] **Step 1: Write a failing test** + +Append to `internal/store/sessions_test.go` (create the file if it doesn't exist; mirror users_test.go style): + +```go +func TestDeleteSessionsByUserID(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + uid := "u-force" + if err := s.CreateUser(ctx, User{ + ID: uid, Username: "victim", + PasswordHash: "x", Role: RoleOperator, CreatedAt: now, + }); err != nil { + t.Fatalf("create user: %v", err) + } + + // Create two sessions for that user. + for i, h := range []string{"hash1", "hash2"} { + if err := s.CreateSession(ctx, Session{ + ID: h, + UserID: uid, + CreatedAt: now, + ExpiresAt: now.Add(time.Hour), + }, h); err != nil { + t.Fatalf("create session %d: %v", i, err) + } + } + + n, err := s.DeleteSessionsByUserID(ctx, uid) + if err != nil { + t.Fatalf("delete: %v", err) + } + if n != 2 { + t.Errorf("count: got %d want 2", n) + } + if _, err := s.LookupSession(ctx, "hash1"); err == nil { + t.Error("hash1 should be gone") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/store/ -run TestDeleteSessionsByUserID` +Expected: FAIL — method undefined. + +- [ ] **Step 3: Add the method** + +Append to `internal/store/sessions.go`: + +```go +// DeleteSessionsByUserID removes every session row owned by the +// user. Returns count for caller logging. Used by: +// - admin "Force logout" button +// - admin Disable user (sessions outlive the disable flag, so we +// also clear them so the user gets bounced immediately) +func (s *Store) DeleteSessionsByUserID(ctx context.Context, userID string) (int64, error) { + res, err := s.db.ExecContext(ctx, + `DELETE FROM sessions WHERE user_id = ?`, userID) + if err != nil { + return 0, fmt.Errorf("store: delete sessions by user: %w", err) + } + n, _ := res.RowsAffected() + return n, nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/store/ -run TestDeleteSessionsByUserID` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/store/sessions.go internal/store/sessions_test.go +git commit -m "store: DeleteSessionsByUserID for force-logout" +``` + +--- + +## Slice B — RBAC middleware + +### Task B1: roleAtLeast helper + tests + +**Files:** +- Create: `internal/server/http/rbac.go` +- Create: `internal/server/http/rbac_test.go` + +- [ ] **Step 1: Write failing tests** + +```go +// internal/server/http/rbac_test.go +package http + +import ( + "testing" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +func TestRoleAtLeast(t *testing.T) { + t.Parallel() + cases := []struct { + have store.Role + min store.Role + want bool + }{ + {store.RoleViewer, store.RoleViewer, true}, + {store.RoleOperator, store.RoleViewer, true}, + {store.RoleAdmin, store.RoleViewer, true}, + {store.RoleAdmin, store.RoleOperator, true}, + {store.RoleAdmin, store.RoleAdmin, true}, + {store.RoleViewer, store.RoleOperator, false}, + {store.RoleViewer, store.RoleAdmin, false}, + {store.RoleOperator, store.RoleAdmin, false}, + {store.Role("nonsense"), store.RoleViewer, false}, + {store.RoleAdmin, store.Role("nonsense"), false}, + } + for _, c := range cases { + got := roleAtLeast(c.have, c.min) + if got != c.want { + t.Errorf("have=%q min=%q: got %v want %v", c.have, c.min, got, c.want) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/server/http/ -run TestRoleAtLeast` +Expected: FAIL — `roleAtLeast` undefined. + +- [ ] **Step 3: Implement roleAtLeast** + +```go +// internal/server/http/rbac.go +package http + +import ( + "encoding/json" + stdhttp "net/http" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// rank maps each role to a numeric tier so 'A is at least B' becomes +// 'rank[A] >= rank[B] && both are known'. Unknown roles return 0 → +// fail-closed against either argument. +var roleRank = map[store.Role]int{ + store.RoleViewer: 1, + store.RoleOperator: 2, + store.RoleAdmin: 3, +} + +// roleAtLeast reports whether `have` meets or exceeds `min` in the +// admin > operator > viewer hierarchy. Either side being an unknown +// role returns false. +func roleAtLeast(have, min store.Role) bool { + h, hok := roleRank[have] + m, mok := roleRank[min] + if !hok || !mok { + return false + } + return h >= m +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/server/http/ -run TestRoleAtLeast` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/server/http/rbac.go internal/server/http/rbac_test.go +git commit -m "http: roleAtLeast helper for the role hierarchy" +``` + +--- + +### Task B2: requireRole middleware + +**Files:** +- Modify: `internal/server/http/rbac.go` +- Modify: `internal/server/http/rbac_test.go` + +- [ ] **Step 1: Write failing tests** + +Append to `internal/server/http/rbac_test.go`: + +```go +import ( + stdhttp "net/http" + "net/http/httptest" + "strings" +) + +func TestRequireRoleViewerAdmits(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + uid := makeUser(t, srv, "viewer1", store.RoleViewer) + cookie := loginAs(t, srv, uid) + + mid := srv.requireRole(store.RoleViewer) + h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) { + w.WriteHeader(stdhttp.StatusOK) + })) + + rr := httptest.NewRecorder() + req, _ := stdhttp.NewRequest("GET", url+"/dummy", nil) + req.AddCookie(cookie) + h.ServeHTTP(rr, req) + if rr.Code != stdhttp.StatusOK { + t.Errorf("status: got %d want 200", rr.Code) + } +} + +func TestRequireRoleViewerRejectedFromOperator(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + uid := makeUser(t, srv, "viewer1", store.RoleViewer) + cookie := loginAs(t, srv, uid) + + mid := srv.requireRole(store.RoleOperator) + h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) { + w.WriteHeader(stdhttp.StatusOK) + })) + + rr := httptest.NewRecorder() + req, _ := stdhttp.NewRequest("GET", url+"/api/dummy", nil) + req.AddCookie(cookie) + h.ServeHTTP(rr, req) + if rr.Code != stdhttp.StatusForbidden { + t.Errorf("status: got %d want 403", rr.Code) + } + if !strings.Contains(rr.Body.String(), "insufficient_role") { + t.Errorf("body: got %q", rr.Body.String()) + } +} + +func TestRequireRoleUnauthenticated401(t *testing.T) { + t.Parallel() + srv, _ := newTestServer(t, false) + + mid := srv.requireRole(store.RoleViewer) + h := mid(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, _ *stdhttp.Request) { + w.WriteHeader(stdhttp.StatusOK) + })) + + rr := httptest.NewRecorder() + req, _ := stdhttp.NewRequest("GET", "/api/dummy", nil) + h.ServeHTTP(rr, req) + // API path → 401 JSON; non-API HTML path would 303 to /login. We + // exercise the API branch here; the HTML branch lives in TestRequireRoleHTMLRedirect. + if rr.Code != stdhttp.StatusUnauthorized { + t.Errorf("status: got %d want 401", rr.Code) + } +} + +// makeUser is a small helper for these tests — drops a row in the +// users table and returns the id. Lives in users_test_helpers.go, +// added in Task B3. +``` + +Note: `makeUser` and `loginAs` are helpers we'll create in Task B3 alongside the rest of the RBAC test infrastructure. This step is *intentionally* test-first; you'll see `undefined` for those names until Task B3 lands them. + +- [ ] **Step 2: Implement requireRole** + +Append to `internal/server/http/rbac.go`: + +```go +// requireRole returns chi middleware that 403s any request whose +// session-resolved user doesn't meet the minimum role. Unauthenticated +// requests return 401 (JSON) or 303 → /login (HTML) so the caller +// gets a usable error rather than a confusing 403. +// +// The middleware re-reads the user row on every request — by the time +// you read this you might be tempted to cache; don't. SQLite's WAL +// makes the lookup cheap and admin-driven changes (disable, role +// change) need to land immediately. +func (s *Server) requireRole(min store.Role) func(stdhttp.Handler) stdhttp.Handler { + return func(next stdhttp.Handler) stdhttp.Handler { + return stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u, ok := s.requireUser(r) + if !ok { + if isAPIPath(r) { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") + return + } + stdhttp.Redirect(w, r, "/login", stdhttp.StatusSeeOther) + return + } + if !roleAtLeast(u.Role, min) { + if isAPIPath(r) { + writeJSONError(w, stdhttp.StatusForbidden, "insufficient_role", "") + return + } + renderForbiddenHTML(s, w, r, u, min) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// isAPIPath reports whether the path lives under /api/. Lets one +// middleware return JSON or HTML appropriately without two near- +// identical wrappers. +func isAPIPath(r *stdhttp.Request) bool { + p := r.URL.Path + return len(p) >= 5 && p[:5] == "/api/" +} + +// renderForbiddenHTML emits a small "you don't have permission" +// panel inside the chrome so the user keeps their nav and can +// move away to a page they can see. +func renderForbiddenHTML(s *Server, w stdhttp.ResponseWriter, r *stdhttp.Request, u *store.User, min store.Role) { + w.WriteHeader(stdhttp.StatusForbidden) + view := s.baseView(r, &uiUserFromStore{u}.User) + view.Title = "Forbidden · restic-manager" + view.Page = struct { + Required string + Have string + }{ + Required: string(min), + Have: string(u.Role), + } + if err := s.deps.UI.Render(w, "forbidden", view); err != nil { + // Fall back to plain text if the template doesn't exist + // (covered by Task B4 — adds the template). + _, _ = w.Write([]byte("403 Forbidden — your role does not permit this page.")) + } +} + +// uiUserFromStore is a small adapter so renderForbiddenHTML can pass +// a store.User into baseView (which expects *ui.User). +type uiUserFromStore struct{ U *store.User } + +func (a uiUserFromStore) User() {} // intentionally no-op marker +``` + +Note: the `baseView` signature in this codebase actually takes a `*ui.User`. We construct one inline: + +```go +// Replace the renderForbiddenHTML body to use the existing ui.User +// shape rather than a fake adapter: +func renderForbiddenHTML(s *Server, w stdhttp.ResponseWriter, r *stdhttp.Request, u *store.User, min store.Role) { + w.WriteHeader(stdhttp.StatusForbidden) + view := s.baseView(r, &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}) + view.Title = "Forbidden · restic-manager" + view.Page = struct { + Required string + Have string + }{Required: string(min), Have: string(u.Role)} + if err := s.deps.UI.Render(w, "forbidden", view); err != nil { + _, _ = w.Write([]byte("403 Forbidden — your role does not permit this page.")) + } +} +``` + +(Drop the `uiUserFromStore` struct — leftover from a discarded approach. Add `"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"` to the imports.) + +- [ ] **Step 3: Add the forbidden template** + +```html + +{{define "title"}}Forbidden · restic-manager{{end}} + +{{define "content"}} +{{$page := .Page}} +
+
+ Dashboard/ + forbidden +
+
+
403 — Insufficient role
+

+ Your role ({{$page.Have}}) does not permit + this page ({{$page.Required}} required). + Ask your administrator if you need access. +

+ Back to dashboard +
+
+{{end}} +``` + +- [ ] **Step 4: Run vet, expect compilation errors** + +Run: `go vet ./...` +Expected: errors about `makeUser` / `loginAs` undefined in the test file we wrote. Move on to Task B3. + +--- + +### Task B3: Test helpers — makeUser, loginAs + +**Files:** +- Create: `internal/server/http/users_test_helpers.go` + +- [ ] **Step 1: Add helpers** + +```go +// internal/server/http/users_test_helpers.go +//go:build test + +package http + +import ( + stdhttp "net/http" + "testing" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +// makeUser inserts a user with a known password ('test-password'). +// Returns the user id. Used by RBAC middleware tests + the +// user-management handler tests. +func makeUser(t *testing.T, srv *Server, username string, role store.Role) string { + t.Helper() + id := ulid.Make().String() + hash, err := auth.HashPassword("test-password") + if err != nil { + t.Fatalf("hash: %v", err) + } + if err := srv.deps.Store.CreateUser(t.Context(), store.User{ + ID: id, Username: username, PasswordHash: hash, + Role: role, CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("create user %s: %v", username, err) + } + return id +} + +// loginAs gets a session cookie for the given user. Goes through +// the real /api/auth/login handler so we exercise the same path +// production traffic uses. +func loginAs(t *testing.T, srv *Server, userID string) *stdhttp.Cookie { + t.Helper() + u, err := srv.deps.Store.GetUserByID(t.Context(), userID) + if err != nil { + t.Fatalf("get user: %v", err) + } + rawToken, err := auth.GenerateSessionToken() + if err != nil { + t.Fatalf("token: %v", err) + } + hash := auth.HashToken(rawToken) + now := time.Now().UTC() + if err := srv.deps.Store.CreateSession(t.Context(), store.Session{ + ID: hash, UserID: u.ID, CreatedAt: now, + ExpiresAt: now.Add(8 * time.Hour), + }, hash); err != nil { + t.Fatalf("session: %v", err) + } + return &stdhttp.Cookie{ + Name: sessionCookieName, + Value: rawToken, + } +} +``` + +If the `//go:build test` tag isn't already in use in the project, drop it — the file will live in the `_test.go` namespace. Actually rename the file to `users_test_helpers_test.go` (add `_test`) so it's only compiled in test builds without needing a build tag. + +- [ ] **Step 2: Verify tests pass for the RBAC middleware** + +Run: `go test ./internal/server/http/ -run TestRequireRole` +Expected: all PASS. + +- [ ] **Step 3: Commit** + +```bash +git add internal/server/http/rbac.go internal/server/http/rbac_test.go \ + internal/server/http/users_test_helpers_test.go \ + web/templates/pages/forbidden.html +git commit -m "http: requireRole middleware + 403 forbidden page" +``` + +--- + +### Task B4: Re-group routes under role bands + +**Files:** +- Modify: `internal/server/http/server.go` + +This task is the bulk of the RBAC PR. Read `routes()` carefully before touching anything — there are ~60 endpoints to band. + +- [ ] **Step 1: Sketch the role bands as comments at the top of `routes()`** + +```go +func (s *Server) routes(r chi.Router) { + // ── role bands ────────────────────────────────────────────────── + // Public: no auth — /healthz, /login, /bootstrap, /setup, + // /agents/enroll, /agents/announce, /ws/agent + // Viewer: auth+R — /, /alerts (GET), /audit, /api/hosts (GET), + // /api/fleet/summary, host detail GET pages, + // /settings/account + // Operator: auth+M — Run-now, restore, ack/resolve, schedules, + // source groups, repo creds (CRUD), bandwidth, + // cancel jobs, accept/reject pending hosts + // Admin: auth+A — /settings/users/*, /settings/notifications/*, + // /api/users/*, channel CRUD, force-logout + // + // Default at the bottom: requireRole(RoleAdmin) — fail-closed for + // any future endpoint that doesn't get explicitly placed. +``` + +- [ ] **Step 2: Replace routes() with role-banded structure** + +The full re-grouping is substantial. Read the current `routes()` function (lines ~115–340 in server.go) and rewrite as: + +```go +func (s *Server) routes(r chi.Router) { + // Public, unauthenticated. + r.Get("/healthz", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) { + w.WriteHeader(stdhttp.StatusNoContent) + }) + r.Post("/api/auth/login", s.handleLogin) + r.Post("/api/auth/logout", s.handleLogout) + r.Post("/api/bootstrap", s.handleBootstrap) + r.Post("/api/agents/enroll", s.handleAgentEnroll) + r.Post("/api/agents/announce", s.handleAnnounce) + r.Get("/agent/binary", s.handleAgentBinary) + r.Get("/install/*", s.handleInstallAsset) + if s.deps.Hub != nil { + r.Mount("/ws/agent", ws.AgentHandler(ws.HandlerDeps{ + Hub: s.deps.Hub, + Store: s.deps.Store, + JobHub: s.deps.JobHub, + AlertEngine: s.deps.AlertEngine, + OnHello: s.onAgentHello, + OnScheduleAck: s.applyScheduleAck, + OnScheduleFire: s.dispatchScheduledJob, + })) + } + r.Get("/ws/agent/pending", s.handlePendingWS) + r.Mount("/static/", staticHandler()) + + // Setup-token landing — no session required (the token IS the auth). + if s.deps.UI != nil { + r.Get("/setup", s.handleUISetupGet) + r.Post("/setup", s.handleUISetupPost) + r.Get("/login", s.handleUILoginGet) + r.Post("/login", s.handleUILoginPost) + r.Post("/logout", s.handleUILogoutPost) + } + + // Viewer band — anyone authenticated can read. + r.Group(func(r chi.Router) { + r.Use(s.requireRole(store.RoleViewer)) + + // Read APIs. + r.Get("/api/hosts", s.handleListHosts) + r.Get("/api/fleet/summary", s.handleFleetSummary) + r.Get("/api/hosts/{id}/snapshots", s.handleListHostSnapshots) + r.Get("/api/hosts/{id}/repo-credentials", s.handleGetHostCredentials) + r.Get("/api/hosts/{id}/admin-credentials", s.handleGetAdminCredentials) + r.Get("/api/hosts/{id}/schedules", s.handleListSchedules) + r.Get("/api/hosts/{id}/source-groups", s.handleListSourceGroups) + r.Get("/api/hosts/{id}/source-groups/{gid}", s.handleGetSourceGroup) + r.Get("/api/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance) + r.Get("/api/alerts", s.handleAPIAlerts) + r.Get("/api/audit", s.handleAPIAudit) + + // Self-service password change (any authenticated user). + r.Post("/api/account/password", s.handleAPIAccountPassword) + + if s.deps.UI != nil { + // Read pages. + r.Get("/", s.handleUIDashboard) + r.Get("/hosts/{id}", s.handleUIHostDetail) + r.Get("/hosts/{id}/sources", s.handleUIHostSources) + r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet) + r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet) + r.Get("/hosts/{id}/repo", s.handleUIHostRepo) + r.Get("/hosts/{id}/schedules", s.handleUISchedulesList) + r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet) + r.Get("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleEditGet) + r.Get("/jobs/{id}", s.handleUIJobDetail) + r.Get("/hosts/{id}/restore", s.handleUIRestoreGet) + r.Get("/hosts/{id}/snapshots/{sid}/restore", s.handleUIRestoreGet) + r.Get("/hosts/{id}/restore/tree", s.handleUIRestoreTree) + r.Get("/alerts", s.handleUIAlerts) + r.Get("/audit", s.handleUIAudit) + r.Get("/audit.csv", s.handleUIAuditCSV) + + // Self-service account page (any role). + r.Get("/settings/account", s.handleUIAccountGet) + r.Post("/settings/account", s.handleUIAccountPost) + } + }) + + // Operator band — mutating endpoints up to backup ops. + r.Group(func(r chi.Router) { + r.Use(s.requireRole(store.RoleOperator)) + + // Pending hosts approval. + r.Post("/api/pending-hosts/{id}/accept", s.handleAcceptPendingHost) + r.Post("/api/pending-hosts/{id}/reject", s.handleRejectPendingHost) + r.Post("/api/enrollment-tokens", s.handleCreateEnrollmentToken) + + // Run-now, restore, repo ops. + r.Post("/api/hosts/{id}/jobs", s.handleRunNow) + r.Put("/api/hosts/{id}/repo-credentials", s.handleSetHostCredentials) + r.Put("/api/hosts/{id}/admin-credentials", s.handleSetAdminCredentials) + r.Delete("/api/hosts/{id}/admin-credentials", s.handleDeleteAdminCredentials) + r.Post("/api/hosts/{id}/schedules", s.handleCreateSchedule) + r.Put("/api/hosts/{id}/schedules/{sid}", s.handleUpdateSchedule) + r.Delete("/api/hosts/{id}/schedules/{sid}", s.handleDeleteSchedule) + r.Post("/api/hosts/{id}/source-groups", s.handleCreateSourceGroup) + r.Put("/api/hosts/{id}/source-groups/{gid}", s.handleUpdateSourceGroup) + r.Delete("/api/hosts/{id}/source-groups/{gid}", s.handleDeleteSourceGroup) + r.Put("/api/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance) + r.Put("/api/hosts/{id}/bandwidth", s.handleUpdateHostBandwidth) + r.Post("/api/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup) + r.Post("/api/hosts/{id}/repo/prune", s.handleRunRepoPrune) + r.Post("/api/hosts/{id}/repo/check", s.handleRunRepoCheck) + r.Post("/api/hosts/{id}/repo/unlock", s.handleRunRepoUnlock) + r.Post("/api/jobs/{id}/cancel", s.handleCancelJob) + r.Post("/api/hosts/{id}/snapshots/diff", s.handleSnapshotDiff) + + // HTMX form variants outside /api. + r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff) + r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup) + r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune) + r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck) + r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock) + r.Post("/hosts/{id}/run-backup", s.handleUIRunBackupGone) + r.Post("/hosts/{id}/init-repo", s.handleUIInitRepoGone) + + if s.deps.UI != nil { + r.Get("/hosts/new", s.handleUIAddHostGet) + r.Post("/hosts/new", s.handleUIAddHostPost) + r.Get("/hosts/pending/{token}", s.handleUIPendingHost) + r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting) + r.Post("/hosts/{id}/sources/new", s.handleUISourceGroupSave) + r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave) + r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete) + r.Post("/hosts/{id}/repo/credentials", s.handleUIRepoCredentialsSave) + r.Post("/hosts/{id}/repo/bandwidth", s.handleUIRepoBandwidthSave) + r.Post("/hosts/{id}/repo/maintenance", s.handleUIRepoMaintenanceSave) + r.Post("/hosts/{id}/repo/reinit", s.handleUIRepoReinit) + r.Post("/hosts/{id}/repo/hooks", s.handleUIRepoHooksSave) + r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave) + r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete) + r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave) + r.Post("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleSave) + r.Post("/hosts/{id}/schedules/{sid}/delete", s.handleUIScheduleDelete) + r.Post("/hosts/{id}/schedules/{sid}/run", s.handleUIScheduleRun) + r.Get("/hosts/{id}/restore", s.handleUIRestoreGet) // duplicate of viewer GET; no-op + r.Post("/hosts/{id}/restore", s.handleUIRestorePost) + r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge) + r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve) + } + }) + + // Admin band — channels, users, server-shape config. + r.Group(func(r chi.Router) { + r.Use(s.requireRole(store.RoleAdmin)) + + r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest) + + // User management API. + r.Get("/api/users", s.handleAPIUsersList) + r.Post("/api/users", s.handleAPIUserCreate) + r.Get("/api/users/{id}", s.handleAPIUserGet) + r.Patch("/api/users/{id}", s.handleAPIUserPatch) + r.Post("/api/users/{id}/disable", s.handleAPIUserDisable) + r.Post("/api/users/{id}/enable", s.handleAPIUserEnable) + r.Post("/api/users/{id}/regenerate-setup", s.handleAPIUserRegenerateSetup) + r.Post("/api/users/{id}/force-logout", s.handleAPIUserForceLogout) + + if s.deps.UI != nil { + // Settings shell + sub-tabs. + r.Get("/settings", s.handleUISettings) + r.Get("/settings/notifications", s.handleUINotificationsList) + r.Get("/settings/notifications/new", s.handleUINotificationNewGet) + r.Post("/settings/notifications/new", s.handleUINotificationNewPost) + r.Get("/settings/notifications/{id}/edit", s.handleUINotificationEditGet) + r.Post("/settings/notifications/{id}/edit", s.handleUINotificationEditPost) + r.Post("/settings/notifications/{id}/delete", s.handleUINotificationDelete) + r.Post("/settings/notifications/{id}/toggle", s.handleUINotificationToggle) + + // Users tab. + r.Get("/settings/users", s.handleUIUsersList) + r.Get("/settings/users/new", s.handleUIUserNewGet) + r.Post("/settings/users/new", s.handleUIUserNewPost) + r.Get("/settings/users/{id}/edit", s.handleUIUserEditGet) + r.Post("/settings/users/{id}/edit", s.handleUIUserEditPost) + r.Get("/settings/users/{id}/setup-link", s.handleUIUserSetupLinkGet) + r.Post("/settings/users/{id}/disable", s.handleUIUserDisablePost) + r.Post("/settings/users/{id}/enable", s.handleUIUserEnablePost) + r.Post("/settings/users/{id}/regenerate-setup", s.handleUIUserRegenerateSetupPost) + r.Post("/settings/users/{id}/force-logout", s.handleUIUserForceLogoutPost) + } + }) +} +``` + +This drops a few duplicate route registrations the old code had (e.g. /api/hosts/.../run mounted twice). Verify no caller depends on the duplicates by grepping for any URL patterns that might now 404. + +- [ ] **Step 3: Run tests, expect failures from existing tests that assumed open access** + +Run: `go test ./internal/server/http/...` +Expected: failures in tests that exercise mutations without authenticating, or that authenticate as the wrong role. Read each failure carefully and either: +- (a) update the test to log in as admin (the existing `loginAsAdmin` helper) — applies to most cases +- (b) update the test to assert 401/403 if it was *checking* the absence of a check + +Walk every failing test individually. Don't blanket-fix. + +- [ ] **Step 4: Confirm a clean run** + +Run: `go test ./internal/server/http/...` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/server/http/server.go +git commit -m "http: re-group routes by role band, fail-closed admin default" +``` + +--- + +### Task B5: Confirm fail-closed default + +**Files:** +- Modify: `internal/server/http/rbac_test.go` + +- [ ] **Step 1: Write a test that proves an unbanded route is admin-only** + +There isn't a literal "default group at the bottom" — chi's last `r.Use` wins for that group, but the structure above places everything explicitly. The fail-closed property is enforced by code review and by the absence of any "no middleware" group except for the public one at the top. + +Add a test that documents the property by exercising a known admin-band endpoint with operator creds: + +```go +func TestAdminBandRejectsOperator(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + makeUser(t, srv, "admin1", store.RoleAdmin) + opID := makeUser(t, srv, "op1", store.RoleOperator) + cookie := loginAs(t, srv, opID) + + req, _ := stdhttp.NewRequest("GET", url+"/api/users", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusForbidden { + t.Errorf("status: got %d want 403", res.StatusCode) + } +} +``` + +- [ ] **Step 2: Run test, expect 404 (handler not yet implemented in Task E)** + +Run: `go test ./internal/server/http/ -run TestAdminBandRejectsOperator` +Expected: FAIL — `/api/users` 404s because we haven't added the handler yet. *That's actually the correct fail-closed behaviour for a non-existent endpoint*; once Task E1 lands the handler, this test will assert 403 (operator-rejected). Mark this task done and move on; the test will start asserting the right thing once E1 is in. + +- [ ] **Step 3: Commit (the test exists, gates Task E)** + +```bash +git add internal/server/http/rbac_test.go +git commit -m "http: failing test for admin-band reject of operator (lands fully in E1)" +``` + +--- + +## Slice C — Session re-validation + +### Task C1: requireUser rejects disabled users + login rejects disabled + +**Files:** +- Modify: `internal/server/http/jobs.go` (the `requireUser` helper) +- Modify: `internal/server/http/ui_handlers.go` (the `loadAuthedUser` helper) +- Modify: `internal/server/http/auth.go` (the login handler) + +- [ ] **Step 1: Write a failing test** + +Append to `internal/server/http/rbac_test.go`: + +```go +func TestRequireRoleRejectsDisabledMidSession(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + uid := makeUser(t, srv, "victim", store.RoleOperator) + cookie := loginAs(t, srv, uid) + + // Disable the user *while their session is still valid*. + if err := srv.deps.Store.DisableUser(t.Context(), uid, time.Now().UTC()); err != nil { + t.Fatalf("disable: %v", err) + } + + req, _ := stdhttp.NewRequest("GET", url+"/api/hosts", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusUnauthorized { + t.Errorf("status: got %d want 401", res.StatusCode) + } +} + +func TestLoginRejectsDisabledUser(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + uid := makeUser(t, srv, "disabled1", store.RoleOperator) + if err := srv.deps.Store.DisableUser(t.Context(), uid, time.Now().UTC()); err != nil { + t.Fatalf("disable: %v", err) + } + + body, _ := json.Marshal(map[string]string{ + "username": "disabled1", "password": "test-password", + }) + res, err := stdhttp.Post(url+"/api/auth/login", "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusUnauthorized { + t.Errorf("status: got %d want 401", res.StatusCode) + } +} +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `go test ./internal/server/http/ -run "TestRequireRoleRejectsDisabledMidSession|TestLoginRejectsDisabledUser"` +Expected: FAIL — current implementations don't check `disabled_at`. + +- [ ] **Step 3: Update requireUser** + +In `internal/server/http/jobs.go`: + +```go +func (s *Server) requireUser(r *stdhttp.Request) (*store.User, bool) { + c, err := r.Cookie(sessionCookieName) + if err != nil { + return nil, false + } + sess, err := s.deps.Store.LookupSession(r.Context(), auth.HashToken(c.Value)) + if err != nil { + return nil, false + } + u, err := s.deps.Store.GetUserByID(r.Context(), sess.UserID) + if err != nil { + return nil, false + } + if u.DisabledAt != nil { + // Disabled mid-session — kill the session and reject the + // request as if it were unauthenticated. + _ = s.deps.Store.DeleteSession(r.Context(), sess.ID) + return nil, false + } + return u, true +} +``` + +- [ ] **Step 4: Update loadAuthedUser** + +In `internal/server/http/ui_handlers.go` (find the `loadAuthedUser` body): + +```go +// (existing function — add the disabled check) +u, err := s.deps.Store.GetUserByID(r.Context(), sess.UserID) +if err != nil { + if errors.Is(err, store.ErrNotFound) { + return nil, nil + } + return nil, err +} +if u.DisabledAt != nil { + _ = s.deps.Store.DeleteSession(r.Context(), sess.ID) + return nil, nil +} +return &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}, nil +``` + +- [ ] **Step 5: Update login handler** + +In `internal/server/http/auth.go` (find the `handleLogin` flow), after the user is fetched + password compared: + +```go +if u.DisabledAt != nil { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") + return +} +``` + +(Same pattern in `handleUILoginPost` — return the standard "Invalid credentials" error so we don't leak whether the username exists but is disabled.) + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `go test ./internal/server/http/...` +Expected: all PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/server/http/jobs.go internal/server/http/ui_handlers.go \ + internal/server/http/auth.go internal/server/http/rbac_test.go +git commit -m "http: session/login reject disabled users; mid-session disable kicks immediately" +``` + +--- + +## Slice D — Setup-token flow + +### Task D1: Setup landing GET + +**Files:** +- Create: `internal/server/http/setup_handler.go` +- Create: `web/templates/pages/setup.html` + +- [ ] **Step 1: Write a failing test** + +Append to `internal/server/http/users_test.go` (create the file with package + imports if missing): + +```go +package http + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "io" + stdhttp "net/http" + "strings" + "testing" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +func TestSetupGetValidToken(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + now := time.Now().UTC() + + uid := ulid.Make().String() + if err := srv.deps.Store.CreateUser(t.Context(), store.User{ + ID: uid, Username: "newbie", PasswordHash: "", + Role: store.RoleOperator, CreatedAt: now, + MustChangePassword: true, + }); err != nil { + t.Fatalf("create: %v", err) + } + + raw := "raw-token-1234567890" + hash := sha256Hex(raw) + if err := srv.deps.Store.SetSetupToken(context.Background(), store.SetupToken{ + UserID: uid, TokenHash: hash, + ExpiresAt: now.Add(time.Hour), CreatedAt: now, + }); err != nil { + t.Fatalf("set token: %v", err) + } + + res, err := stdhttp.Get(url + "/setup?token=" + raw) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Errorf("status: got %d want 200", res.StatusCode) + } + body, _ := io.ReadAll(res.Body) + if !strings.Contains(string(body), "newbie") { + t.Errorf("expected username in body: %s", body) + } +} + +func TestSetupGetExpiredToken(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + now := time.Now().UTC() + + uid := ulid.Make().String() + _ = srv.deps.Store.CreateUser(t.Context(), store.User{ + ID: uid, Username: "stale", + PasswordHash: "", Role: store.RoleViewer, CreatedAt: now, + MustChangePassword: true, + }) + + raw := "expired-token" + _ = srv.deps.Store.SetSetupToken(context.Background(), store.SetupToken{ + UserID: uid, TokenHash: sha256Hex(raw), + ExpiresAt: now.Add(-time.Minute), CreatedAt: now.Add(-2 * time.Hour), + }) + + res, err := stdhttp.Get(url + "/setup?token=" + raw) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusGone { + t.Errorf("status: got %d want 410", res.StatusCode) + } +} + +func sha256Hex(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `go test ./internal/server/http/ -run TestSetupGet` +Expected: FAIL — `/setup` 404s. + +- [ ] **Step 3: Implement the GET handler** + +```go +// internal/server/http/setup_handler.go +// +// Public landing page for the user-setup link emitted by the +// admin's "+ Add user" / "Regenerate setup link" flow. +// +// Routes (wired in server.go): +// GET /setup → handleUISetupGet +// POST /setup → handleUISetupPost +// +// The token in the querystring (`?token=`) is the credential. +// Auth middleware does not run on these routes. +package http + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "log/slog" + stdhttp "net/http" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +type setupPage struct { + Username string + Token string // round-tripped to the POST form + Error string // displayed when password validation fails +} + +// hashSetupToken is the canonical hashing for setup tokens. Mirrors +// what the admin handler uses when SetSetupToken is called, so the +// digest at rest matches what GET /setup hashes. +func hashSetupToken(raw string) string { + h := sha256.Sum256([]byte(raw)) + return hex.EncodeToString(h[:]) +} + +func (s *Server) handleUISetupGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + raw := r.URL.Query().Get("token") + if raw == "" { + s.renderSetupExpired(w, r) + return + } + tok, err := s.deps.Store.LookupSetupToken(r.Context(), hashSetupToken(raw)) + if err != nil { + s.renderSetupExpired(w, r) + return + } + if tok.ExpiresAt.Before(time.Now().UTC()) { + s.renderSetupExpired(w, r) + return + } + u, err := s.deps.Store.GetUserByID(r.Context(), tok.UserID) + if err != nil { + s.renderSetupExpired(w, r) + return + } + view := s.baseView(r, nil) + view.Title = "Set your password · restic-manager" + view.Page = setupPage{Username: u.Username, Token: raw} + if err := s.deps.UI.Render(w, "setup", view); err != nil { + slog.Error("ui setup: render", "err", err) + } +} + +func (s *Server) renderSetupExpired(w stdhttp.ResponseWriter, r *stdhttp.Request) { + w.WriteHeader(stdhttp.StatusGone) + view := s.baseView(r, nil) + view.Title = "Link expired · restic-manager" + view.Page = setupPage{Error: "expired"} + _ = s.deps.UI.Render(w, "setup", view) +} +``` + +- [ ] **Step 4: Add the template** + +```html + +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{$page := .Page}} +
+ {{if eq $page.Error "expired"}} +

Link expired

+

+ This setup link has expired or is invalid. Setup links are valid + for one hour from the moment your administrator generates them. +

+

+ Contact your administrator and ask them to regenerate the link. +

+ {{else}} +

+ Welcome, {{$page.Username}} +

+

+ Pick a password to finish setting up your account. The link expires + one hour after your administrator generated it, so don't dawdle. +

+
+ +
+ + +
+
+ + +
+ +
+ {{if and $page.Error (ne $page.Error "expired")}} +

{{$page.Error}}

+ {{end}} + {{end}} +
+{{end}} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/server/http/ -run TestSetupGet` +Expected: both PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/server/http/setup_handler.go web/templates/pages/setup.html \ + internal/server/http/users_test.go +git commit -m "http: GET /setup landing page with expiry handling" +``` + +--- + +### Task D2: Setup POST — set password and log in + +**Files:** +- Modify: `internal/server/http/setup_handler.go` + +- [ ] **Step 1: Write a failing test** + +Append to `internal/server/http/users_test.go`: + +```go +import "net/url" + +func TestSetupPostHappyPath(t *testing.T) { + t.Parallel() + srv, urlBase := newTestServer(t, false) + now := time.Now().UTC() + + uid := ulid.Make().String() + _ = srv.deps.Store.CreateUser(t.Context(), store.User{ + ID: uid, Username: "newbie", + PasswordHash: "", Role: store.RoleOperator, CreatedAt: now, + MustChangePassword: true, + }) + raw := "happy-token" + _ = srv.deps.Store.SetSetupToken(t.Context(), store.SetupToken{ + UserID: uid, TokenHash: sha256Hex(raw), + ExpiresAt: now.Add(time.Hour), CreatedAt: now, + }) + + form := url.Values{} + form.Set("token", raw) + form.Set("password", "averylongpassword") + form.Set("password_confirm", "averylongpassword") + req, _ := stdhttp.NewRequest("POST", urlBase+"/setup", + strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + c := &stdhttp.Client{CheckRedirect: func(*stdhttp.Request, []*stdhttp.Request) error { + return stdhttp.ErrUseLastResponse + }} + res, err := c.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusSeeOther { + t.Errorf("status: got %d want 303", res.StatusCode) + } + if res.Header.Get("Location") != "/" { + t.Errorf("location: got %q want /", res.Header.Get("Location")) + } + + // Token is consumed. + if _, err := srv.deps.Store.LookupSetupToken(t.Context(), sha256Hex(raw)); err == nil { + t.Error("token should be deleted after consumption") + } + + // User can now log in via the normal route. + logBody, _ := json.Marshal(map[string]string{ + "username": "newbie", "password": "averylongpassword", + }) + loginRes, _ := stdhttp.Post(urlBase+"/api/auth/login", + "application/json", bytes.NewReader(logBody)) + if loginRes.StatusCode != stdhttp.StatusOK { + body, _ := io.ReadAll(loginRes.Body) + t.Errorf("login: %d %s", loginRes.StatusCode, body) + } +} +``` + +(Add the `"encoding/json"` import if not already present.) + +- [ ] **Step 2: Run test, verify it fails** + +Run: `go test ./internal/server/http/ -run TestSetupPostHappyPath` +Expected: FAIL — POST /setup not implemented. + +- [ ] **Step 3: Implement the POST handler** + +Append to `internal/server/http/setup_handler.go`: + +```go +func (s *Server) handleUISetupPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + raw := r.PostForm.Get("token") + pw := r.PostForm.Get("password") + pw2 := r.PostForm.Get("password_confirm") + + if raw == "" { + s.renderSetupExpired(w, r) + return + } + if pw == "" || pw2 == "" || pw != pw2 || len(pw) < 12 { + s.renderSetupForm(w, r, raw, "Passwords must match and be at least 12 characters.") + return + } + + tok, err := s.deps.Store.LookupSetupToken(r.Context(), hashSetupToken(raw)) + if err != nil || tok.ExpiresAt.Before(time.Now().UTC()) { + s.renderSetupExpired(w, r) + return + } + u, err := s.deps.Store.GetUserByID(r.Context(), tok.UserID) + if err != nil { + s.renderSetupExpired(w, r) + return + } + + hash, err := auth.HashPassword(pw) + if err != nil { + slog.Error("setup: hash password", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if err := s.deps.Store.SetPasswordHash(r.Context(), u.ID, hash); err != nil { + slog.Error("setup: set password", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if err := s.deps.Store.DeleteSetupToken(r.Context(), u.ID); err != nil { + slog.Warn("setup: delete token", "err", err) + // Non-fatal — password is set, audit will reflect it. + } + + // Drop a session cookie so the user lands authenticated on /. + rawSession, err := auth.GenerateSessionToken() + if err != nil { + slog.Error("setup: session token", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + hashed := auth.HashToken(rawSession) + now := time.Now().UTC() + if err := s.deps.Store.CreateSession(r.Context(), store.Session{ + ID: hashed, UserID: u.ID, CreatedAt: now, + ExpiresAt: now.Add(8 * time.Hour), + }, hashed); err != nil { + slog.Error("setup: create session", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + stdhttp.SetCookie(w, &stdhttp.Cookie{ + Name: sessionCookieName, Value: rawSession, + Path: "/", HttpOnly: true, + SameSite: stdhttp.SameSiteLaxMode, + Secure: s.deps.Cfg.CookieSecure, + Expires: now.Add(8 * time.Hour), + }) + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), + UserID: &u.ID, Actor: "user", + Action: "user.setup_completed", + TargetKind: ptr("user"), TargetID: &u.ID, + TS: now, + }) + stdhttp.Redirect(w, r, "/", stdhttp.StatusSeeOther) +} + +// renderSetupForm re-renders the setup page with an inline error +// (e.g. password mismatch). 200 OK with the form intact so the user +// can correct without losing the token. +func (s *Server) renderSetupForm(w stdhttp.ResponseWriter, r *stdhttp.Request, token, errMsg string) { + view := s.baseView(r, nil) + view.Title = "Set your password · restic-manager" + // Look up the username again so the page header still shows it + // after a validation bounce. + username := "" + if tok, err := s.deps.Store.LookupSetupToken(r.Context(), hashSetupToken(token)); err == nil { + if u, err := s.deps.Store.GetUserByID(r.Context(), tok.UserID); err == nil { + username = u.Username + } + } + view.Page = setupPage{Username: username, Token: token, Error: errMsg} + _ = s.deps.UI.Render(w, "setup", view) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/server/http/ -run TestSetup` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/server/http/setup_handler.go internal/server/http/users_test.go +git commit -m "http: POST /setup — set password, drop session, audit setup_completed" +``` + +--- + +## Slice E — User CRUD API + +(Each task in this slice follows the same TDD shape — write failing test, verify failure, implement, verify pass, commit. The handler files referenced here are kept short and focused.) + +### Task E1: GET /api/users (list) + +**Files:** +- Create: `internal/server/http/api_users.go` + +- [ ] **Step 1: Write a failing test** + +Append to `internal/server/http/users_test.go`: + +```go +func TestAPIUsersList(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + makeUser(t, srv, "op1", store.RoleOperator) + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("GET", url+"/api/users", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("GET: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Errorf("status: got %d", res.StatusCode) + } + var got struct { + Users []store.User `json:"users"` + } + _ = json.NewDecoder(res.Body).Decode(&got) + if len(got.Users) != 2 { + t.Errorf("count: got %d want 2", len(got.Users)) + } +} +``` + +- [ ] **Step 2: Run test, verify it fails (404)** + +Run: `go test ./internal/server/http/ -run TestAPIUsersList` +Expected: FAIL — handler not registered. + +- [ ] **Step 3: Implement the handler** + +```go +// internal/server/http/api_users.go +package http + +import ( + "encoding/json" + "log/slog" + stdhttp "net/http" +) + +type listUsersResponse struct { + Users []apiUser `json:"users"` +} + +type apiUser struct { + ID string `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + Email *string `json:"email,omitempty"` + Disabled bool `json:"disabled"` + MustChangePassword bool `json:"must_change_password"` + CreatedAt string `json:"created_at"` + LastLoginAt *string `json:"last_login_at,omitempty"` +} + +func (s *Server) handleAPIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) { + users, err := s.deps.Store.ListUsers(r.Context()) + if err != nil { + slog.Error("api users: list", "err", err) + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + out := make([]apiUser, len(users)) + for i, u := range users { + var lastLogin *string + if u.LastLoginAt != nil { + s := u.LastLoginAt.UTC().Format("2006-01-02T15:04:05Z") + lastLogin = &s + } + out[i] = apiUser{ + ID: u.ID, + Username: u.Username, + Role: string(u.Role), + Email: u.Email, + Disabled: u.DisabledAt != nil, + MustChangePassword: u.MustChangePassword, + CreatedAt: u.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + LastLoginAt: lastLogin, + } + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(listUsersResponse{Users: out}) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/server/http/ -run TestAPIUsersList` +Expected: PASS. Also re-run `TestAdminBandRejectsOperator` from Task B5 — it should now assert 403 (the route exists, the operator gets denied). + +- [ ] **Step 5: Commit** + +```bash +git add internal/server/http/api_users.go internal/server/http/users_test.go +git commit -m "http: GET /api/users (list)" +``` + +--- + +### Task E2: POST /api/users (create + setup token) + +**Files:** +- Modify: `internal/server/http/api_users.go` + +- [ ] **Step 1: Write a failing test** + +Append to `users_test.go`: + +```go +func TestAPIUserCreate(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + cookie := loginAs(t, srv, adminID) + + body, _ := json.Marshal(map[string]any{ + "username": "Bob", "email": "bob@example.com", "role": "operator", + }) + req, _ := stdhttp.NewRequest("POST", url+"/api/users", bytes.NewReader(body)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusCreated { + body, _ := io.ReadAll(res.Body) + t.Errorf("status: got %d body=%s", res.StatusCode, body) + } + var got struct { + ID string `json:"id"` + SetupURL string `json:"setup_url"` + } + _ = json.NewDecoder(res.Body).Decode(&got) + if got.ID == "" || got.SetupURL == "" { + t.Errorf("missing fields: %+v", got) + } + if !strings.Contains(got.SetupURL, "/setup?token=") { + t.Errorf("setup_url shape: %q", got.SetupURL) + } + + // Verify lowercase-normalised. + u, err := srv.deps.Store.GetUserByUsername(t.Context(), "bob") + if err != nil { + t.Fatalf("get: %v", err) + } + if u.Username != "bob" { + t.Errorf("username: got %q want bob", u.Username) + } + if !u.MustChangePassword { + t.Error("must_change_password not set") + } +} + +func TestAPIUserCreateRejectsDuplicateEnabled(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + makeUser(t, srv, "alice", store.RoleOperator) + cookie := loginAs(t, srv, adminID) + + body, _ := json.Marshal(map[string]any{ + "username": "ALICE", "role": "operator", + }) + req, _ := stdhttp.NewRequest("POST", url+"/api/users", bytes.NewReader(body)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, _ := stdhttp.DefaultClient.Do(req) + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusConflict { + t.Errorf("status: got %d want 409", res.StatusCode) + } +} +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `go test ./internal/server/http/ -run "TestAPIUserCreate$|TestAPIUserCreateRejects"` +Expected: FAIL. + +- [ ] **Step 3: Implement the handler** + +Append to `internal/server/http/api_users.go`: + +```go +import ( + "crypto/rand" + "encoding/hex" + "errors" + "net/mail" + "strings" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +type createUserRequest struct { + Username string `json:"username"` + Email string `json:"email,omitempty"` + Role string `json:"role"` +} + +type createUserResponse struct { + ID string `json:"id"` + SetupURL string `json:"setup_url"` +} + +// generateSetupToken returns 32 random bytes hex-encoded (64 chars). +func generateSetupToken() (string, error) { + var b [32]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +func validRole(r string) (store.Role, bool) { + switch r { + case "admin": + return store.RoleAdmin, true + case "operator": + return store.RoleOperator, true + case "viewer": + return store.RoleViewer, true + } + return "", false +} + +func (s *Server) handleAPIUserCreate(w stdhttp.ResponseWriter, r *stdhttp.Request) { + actor, _ := s.requireUser(r) // already gated by middleware + var req createUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + uname := strings.ToLower(strings.TrimSpace(req.Username)) + if uname == "" { + writeJSONError(w, stdhttp.StatusBadRequest, "username_required", "") + return + } + role, ok := validRole(req.Role) + if !ok { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_role", "") + return + } + if req.Email != "" { + if _, err := mail.ParseAddress(req.Email); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_email", err.Error()) + return + } + } + + // Check for collision against existing user (case-insensitive). + existing, err := s.deps.Store.GetUserByUsername(r.Context(), uname) + if err == nil { + body := map[string]any{ + "error": "username_taken", + "existing_user_id": existing.ID, + "disabled": existing.DisabledAt != nil, + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(stdhttp.StatusConflict) + _ = json.NewEncoder(w).Encode(body) + return + } else if !errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + + id := ulid.Make().String() + now := time.Now().UTC() + var emailPtr *string + if req.Email != "" { + em := strings.ToLower(strings.TrimSpace(req.Email)) + emailPtr = &em + } + if err := s.deps.Store.CreateUser(r.Context(), store.User{ + ID: id, Username: uname, PasswordHash: "", + Role: role, Email: emailPtr, CreatedAt: now, + MustChangePassword: true, + }); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + + rawToken, err := generateSetupToken() + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + var actorID *string + if actor != nil { + actorID = &actor.ID + } + if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{ + UserID: id, TokenHash: hashSetupToken(rawToken), + ExpiresAt: now.Add(time.Hour), + CreatedAt: now, CreatedBy: actorID, + }); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: actorID, Actor: "user", + Action: "user.created", TargetKind: ptr("user"), TargetID: &id, + TS: now, + }) + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(stdhttp.StatusCreated) + _ = json.NewEncoder(w).Encode(createUserResponse{ + ID: id, + SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken, + }) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/server/http/ -run "TestAPIUserCreate"` +Expected: both PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/server/http/api_users.go internal/server/http/users_test.go +git commit -m "http: POST /api/users — create + setup-token + audit" +``` + +--- + +### Task E3: GET / PATCH /api/users/{id} + +**Files:** +- Modify: `internal/server/http/api_users.go` + +- [ ] **Step 1: Write tests** + +```go +func TestAPIUserGet(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + target := makeUser(t, srv, "carol", store.RoleViewer) + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("GET", url+"/api/users/"+target, nil) + req.AddCookie(cookie) + res, _ := stdhttp.DefaultClient.Do(req) + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Errorf("status: got %d", res.StatusCode) + } +} + +func TestAPIUserPatchRoleAndEmail(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + target := makeUser(t, srv, "carol", store.RoleViewer) + cookie := loginAs(t, srv, adminID) + + body, _ := json.Marshal(map[string]any{ + "role": "operator", "email": "carol@example.com", + }) + req, _ := stdhttp.NewRequest("PATCH", url+"/api/users/"+target, bytes.NewReader(body)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, _ := stdhttp.DefaultClient.Do(req) + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + body, _ := io.ReadAll(res.Body) + t.Errorf("status: got %d body=%s", res.StatusCode, body) + } + got, _ := srv.deps.Store.GetUserByID(t.Context(), target) + if got.Role != store.RoleOperator { + t.Errorf("role: got %q", got.Role) + } + if got.Email == nil || *got.Email != "carol@example.com" { + t.Errorf("email: got %v", got.Email) + } +} + +func TestAPIUserPatchRejectsLastAdminDemote(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + cookie := loginAs(t, srv, adminID) + + body, _ := json.Marshal(map[string]any{"role": "viewer"}) + req, _ := stdhttp.NewRequest("PATCH", url+"/api/users/"+adminID, bytes.NewReader(body)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, _ := stdhttp.DefaultClient.Do(req) + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusConflict { + t.Errorf("status: got %d want 409", res.StatusCode) + } +} +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `go test ./internal/server/http/ -run "TestAPIUserGet|TestAPIUserPatch"` +Expected: FAIL. + +- [ ] **Step 3: Implement handlers** + +```go +import "github.com/go-chi/chi/v5" + +func (s *Server) handleAPIUserGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + id := chi.URLParam(r, "id") + u, err := s.deps.Store.GetUserByID(r.Context(), id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "") + return + } + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + out := apiUser{ + ID: u.ID, Username: u.Username, Role: string(u.Role), + Email: u.Email, Disabled: u.DisabledAt != nil, + MustChangePassword: u.MustChangePassword, + CreatedAt: u.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } + if u.LastLoginAt != nil { + s := u.LastLoginAt.UTC().Format("2006-01-02T15:04:05Z") + out.LastLoginAt = &s + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(out) +} + +type patchUserRequest struct { + Role *string `json:"role,omitempty"` + Email *string `json:"email,omitempty"` +} + +func (s *Server) handleAPIUserPatch(w stdhttp.ResponseWriter, r *stdhttp.Request) { + actor, _ := s.requireUser(r) + id := chi.URLParam(r, "id") + u, err := s.deps.Store.GetUserByID(r.Context(), id) + if err != nil { + writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "") + return + } + var req patchUserRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + if req.Role != nil { + newRole, ok := validRole(*req.Role) + if !ok { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_role", "") + return + } + // Last-admin guard: cannot demote the only enabled admin. + if u.Role == store.RoleAdmin && newRole != store.RoleAdmin && u.DisabledAt == nil { + n, _ := s.deps.Store.CountEnabledAdmins(r.Context()) + if n <= 1 { + writeJSONError(w, stdhttp.StatusConflict, "last_admin", "") + return + } + } + if err := s.deps.Store.SetUserRole(r.Context(), id, newRole); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + } + if req.Email != nil { + em := strings.TrimSpace(*req.Email) + if em != "" { + if _, err := mail.ParseAddress(em); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_email", err.Error()) + return + } + } + if err := s.deps.Store.SetUserEmail(r.Context(), id, em); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + } + var actorID *string + if actor != nil { + actorID = &actor.ID + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: actorID, Actor: "user", + Action: "user.updated", TargetKind: ptr("user"), TargetID: &id, + TS: time.Now().UTC(), + }) + w.WriteHeader(stdhttp.StatusOK) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/server/http/ -run "TestAPIUserGet|TestAPIUserPatch"` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/server/http/api_users.go internal/server/http/users_test.go +git commit -m "http: GET/PATCH /api/users/{id} with last-admin guard" +``` + +--- + +### Task E4: Disable / enable + +**Files:** +- Modify: `internal/server/http/api_users.go` + +- [ ] **Step 1: Write tests** + +```go +func TestAPIUserDisable(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + makeUser(t, srv, "admin2", store.RoleAdmin) // satisfy last-admin guard + target := makeUser(t, srv, "victim", store.RoleOperator) + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("POST", url+"/api/users/"+target+"/disable", nil) + req.AddCookie(cookie) + res, _ := stdhttp.DefaultClient.Do(req) + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Errorf("status: got %d", res.StatusCode) + } + u, _ := srv.deps.Store.GetUserByID(t.Context(), target) + if u.DisabledAt == nil { + t.Error("disabled_at not set") + } +} + +func TestAPIUserDisableRejectsLastAdmin(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("POST", url+"/api/users/"+adminID+"/disable", nil) + req.AddCookie(cookie) + res, _ := stdhttp.DefaultClient.Do(req) + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusConflict { + t.Errorf("status: got %d want 409", res.StatusCode) + } +} +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `go test ./internal/server/http/ -run TestAPIUserDisable` +Expected: FAIL. + +- [ ] **Step 3: Implement handlers** + +```go +func (s *Server) handleAPIUserDisable(w stdhttp.ResponseWriter, r *stdhttp.Request) { + actor, _ := s.requireUser(r) + id := chi.URLParam(r, "id") + u, err := s.deps.Store.GetUserByID(r.Context(), id) + if err != nil { + writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "") + return + } + if u.Role == store.RoleAdmin && u.DisabledAt == nil { + n, _ := s.deps.Store.CountEnabledAdmins(r.Context()) + if n <= 1 { + writeJSONError(w, stdhttp.StatusConflict, "last_admin", "") + return + } + } + now := time.Now().UTC() + if err := s.deps.Store.DisableUser(r.Context(), id, now); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + // Kick existing sessions so the user is bounced immediately. + _, _ = s.deps.Store.DeleteSessionsByUserID(r.Context(), id) + var actorID *string + if actor != nil { + actorID = &actor.ID + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: actorID, Actor: "user", + Action: "user.disabled", TargetKind: ptr("user"), TargetID: &id, + TS: now, + }) + w.WriteHeader(stdhttp.StatusOK) +} + +func (s *Server) handleAPIUserEnable(w stdhttp.ResponseWriter, r *stdhttp.Request) { + actor, _ := s.requireUser(r) + id := chi.URLParam(r, "id") + if err := s.deps.Store.EnableUser(r.Context(), id); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + var actorID *string + if actor != nil { + actorID = &actor.ID + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: actorID, Actor: "user", + Action: "user.enabled", TargetKind: ptr("user"), TargetID: &id, + TS: time.Now().UTC(), + }) + w.WriteHeader(stdhttp.StatusOK) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/server/http/ -run TestAPIUserDisable` +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/server/http/api_users.go internal/server/http/users_test.go +git commit -m "http: disable/enable user with last-admin guard + session kick" +``` + +--- + +### Task E5: Regenerate setup link + force logout + +**Files:** +- Modify: `internal/server/http/api_users.go` + +- [ ] **Step 1: Write tests** + +```go +func TestAPIUserRegenerateSetup(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + target := makeUser(t, srv, "newbie", store.RoleViewer) + // Simulate an outstanding token. + _ = srv.deps.Store.SetMustChangePassword(t.Context(), target, true) + _ = srv.deps.Store.SetSetupToken(t.Context(), store.SetupToken{ + UserID: target, TokenHash: "old", ExpiresAt: time.Now().UTC().Add(time.Hour), + CreatedAt: time.Now().UTC(), + }) + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("POST", url+"/api/users/"+target+"/regenerate-setup", nil) + req.AddCookie(cookie) + res, _ := stdhttp.DefaultClient.Do(req) + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Errorf("status: got %d", res.StatusCode) + } + var got struct{ SetupURL string `json:"setup_url"` } + _ = json.NewDecoder(res.Body).Decode(&got) + if !strings.Contains(got.SetupURL, "/setup?token=") { + t.Errorf("setup_url: %q", got.SetupURL) + } + // Old token gone. + if _, err := srv.deps.Store.LookupSetupToken(t.Context(), "old"); err == nil { + t.Error("old token should be replaced") + } +} + +func TestAPIUserForceLogout(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + target := makeUser(t, srv, "victim", store.RoleOperator) + loginAs(t, srv, target) + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("POST", url+"/api/users/"+target+"/force-logout", nil) + req.AddCookie(cookie) + res, _ := stdhttp.DefaultClient.Do(req) + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Errorf("status: got %d", res.StatusCode) + } + // The victim's session is gone — confirm by counting via a probe. + rr, _ := srv.deps.Store.DeleteSessionsByUserID(t.Context(), target) + if rr != 0 { + t.Errorf("expected 0 remaining sessions, got %d", rr) + } +} +``` + +- [ ] **Step 2: Run tests, verify they fail** + +Run: `go test ./internal/server/http/ -run "TestAPIUserRegenerateSetup|TestAPIUserForceLogout"` +Expected: FAIL. + +- [ ] **Step 3: Implement handlers** + +```go +type regenerateSetupResponse struct { + SetupURL string `json:"setup_url"` +} + +func (s *Server) handleAPIUserRegenerateSetup(w stdhttp.ResponseWriter, r *stdhttp.Request) { + actor, _ := s.requireUser(r) + id := chi.URLParam(r, "id") + if _, err := s.deps.Store.GetUserByID(r.Context(), id); err != nil { + writeJSONError(w, stdhttp.StatusNotFound, "user_not_found", "") + return + } + rawToken, err := generateSetupToken() + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + now := time.Now().UTC() + var actorID *string + if actor != nil { + actorID = &actor.ID + } + if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{ + UserID: id, TokenHash: hashSetupToken(rawToken), + ExpiresAt: now.Add(time.Hour), + CreatedAt: now, CreatedBy: actorID, + }); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + if err := s.deps.Store.SetMustChangePassword(r.Context(), id, true); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: actorID, Actor: "user", + Action: "user.setup_token.regenerated", + TargetKind: ptr("user"), TargetID: &id, TS: now, + }) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(regenerateSetupResponse{ + SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken, + }) +} + +func (s *Server) handleAPIUserForceLogout(w stdhttp.ResponseWriter, r *stdhttp.Request) { + actor, _ := s.requireUser(r) + id := chi.URLParam(r, "id") + n, err := s.deps.Store.DeleteSessionsByUserID(r.Context(), id) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + var actorID *string + if actor != nil { + actorID = &actor.ID + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: actorID, Actor: "user", + Action: "user.force_logout", + TargetKind: ptr("user"), TargetID: &id, + TS: time.Now().UTC(), + }) + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(map[string]int64{"sessions_killed": n}) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/server/http/ -run "TestAPIUserRegenerateSetup|TestAPIUserForceLogout"` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/server/http/api_users.go internal/server/http/users_test.go +git commit -m "http: regenerate setup link + force-logout" +``` + +--- + +### Task E6: Self-service password change API + +**Files:** +- Create: `internal/server/http/ui_account.go` + +- [ ] **Step 1: Write a failing test** + +```go +func TestAPIAccountPasswordChange(t *testing.T) { + t.Parallel() + srv, url := newTestServer(t, false) + uid := makeUser(t, srv, "alice", store.RoleViewer) + cookie := loginAs(t, srv, uid) + + body, _ := json.Marshal(map[string]string{ + "current_password": "test-password", + "new_password": "averylongpassword", + }) + req, _ := stdhttp.NewRequest("POST", url+"/api/account/password", bytes.NewReader(body)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, _ := stdhttp.DefaultClient.Do(req) + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + body, _ := io.ReadAll(res.Body) + t.Errorf("status: got %d body=%s", res.StatusCode, body) + } +} +``` + +- [ ] **Step 2: Run test, verify it fails** + +Run: `go test ./internal/server/http/ -run TestAPIAccountPasswordChange` +Expected: FAIL. + +- [ ] **Step 3: Implement** + +```go +// internal/server/http/ui_account.go +package http + +import ( + "encoding/json" + stdhttp "net/http" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/auth" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +type passwordChangeRequest struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` +} + +func (s *Server) handleAPIAccountPassword(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u, ok := s.requireUser(r) + if !ok { + writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "") + return + } + var req passwordChangeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error()) + return + } + if len(req.NewPassword) < 12 { + writeJSONError(w, stdhttp.StatusBadRequest, "password_too_short", "min 12 chars") + return + } + // Skip current-password check when must_change_password is set — + // the user has no current password to know (set by the legacy + // path; setup-token path doesn't use this). + if !u.MustChangePassword { + if err := auth.ComparePassword(u.PasswordHash, req.CurrentPassword); err != nil { + writeJSONError(w, stdhttp.StatusUnauthorized, "current_password_wrong", "") + return + } + } + hash, err := auth.HashPassword(req.NewPassword) + if err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + if err := s.deps.Store.SetPasswordHash(r.Context(), u.ID, hash); err != nil { + writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", + Action: "user.password_changed", + TargetKind: ptr("user"), TargetID: &u.ID, + TS: time.Now().UTC(), + }) + w.WriteHeader(stdhttp.StatusOK) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/server/http/ -run TestAPIAccountPasswordChange` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/server/http/ui_account.go internal/server/http/users_test.go +git commit -m "http: POST /api/account/password — self-service password change" +``` + +--- + +## Slice F — UI + +### Task F1: Settings → Users list page + +**Files:** +- Create: `web/templates/pages/users.html` +- Create: `internal/server/http/ui_users.go` +- Modify: `web/templates/pages/settings.html` (turn the dormant Users tab live) + +- [ ] **Step 1: Implement the list handler** + +```go +// internal/server/http/ui_users.go +package http + +import ( + "errors" + "log/slog" + stdhttp "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +type usersPage struct { + Users []userRow + ShowDisabled bool + HasOpenTokens bool // for the page-header banner +} + +type userRow struct { + ID string + Username string + Email string // empty for nil + Role string + LastLoginAt *time.Time + Disabled bool + MustChangePassword bool +} + +func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + showDisabled := r.URL.Query().Get("show_disabled") == "1" + users, err := s.deps.Store.ListUsers(r.Context()) + if err != nil { + slog.Error("ui users: list", "err", err) + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + rows := make([]userRow, 0, len(users)) + for _, ux := range users { + if !showDisabled && ux.DisabledAt != nil { + continue + } + em := "" + if ux.Email != nil { + em = *ux.Email + } + rows = append(rows, userRow{ + ID: ux.ID, Username: ux.Username, Email: em, + Role: string(ux.Role), + LastLoginAt: ux.LastLoginAt, + Disabled: ux.DisabledAt != nil, + MustChangePassword: ux.MustChangePassword, + }) + } + view := s.baseView(r, u) + view.Title = "Users · restic-manager" + view.Active = "settings" + view.Page = usersPage{Users: rows, ShowDisabled: showDisabled} + if err := s.deps.UI.Render(w, "users", view); err != nil { + slog.Error("ui users: render", "err", err) + } +} +``` + +- [ ] **Step 2: Add the template** + +```html + +{{define "title"}}Users · restic-manager{{end}} + +{{define "content"}} +{{$page := .Page}} +
+
+ Dashboard/ + Settings/ + users +
+ +
+

+ Users + {{len $page.Users}} +

+ +
+ +
+ +
+ +
+
+
Username
+
Email
+
Role
+
Last login
+
Status
+
+
+ {{range $page.Users}} +
+ +
{{if .Email}}{{.Email}}{{else}}{{end}}
+
{{.Role}}
+
+ {{if .LastLoginAt}}{{relTime (deref (timeToPtr .LastLoginAt))}}{{else}}never{{end}} +
+
+ {{if .Disabled}}disabled + {{else if .MustChangePassword}}setup pending + {{else}}enabled{{end}} +
+
+ Edit +
+
+ {{end}} +
+
+{{end}} +``` + +The template uses `relTime` which expects a `time.Time`. The current `userRow.LastLoginAt` is `*time.Time` — adjust the template to handle nil with `{{if .LastLoginAt}}{{relTime (deref (timeToPtr .LastLoginAt))}}` — too clever. Simpler: change the field to a pre-formatted string in the handler. + +Replace the `userRow.LastLoginAt` field with `LastLoginAt string` and format in the handler: + +```go +// in ui_users.go, in the loop: +ll := "" +if ux.LastLoginAt != nil { + ll = ux.LastLoginAt.UTC().Format("2006-01-02 15:04:05") +} +rows = append(rows, userRow{... LastLoginAt: ll, ...}) +``` + +And in the template, replace the {{if .LastLoginAt}}…{{end}} block with: + +```html +
+ {{if .LastLoginAt}}{{.LastLoginAt}}{{else}}never{{end}} +
+``` + +- [ ] **Step 3: Add `.user-row` styles** + +Append to `web/styles/input.css` (before `/* ---------- schedule rows */` if that ordering is in place): + +```css + .user-row { + display: grid; align-items: center; + grid-template-columns: 180px 1fr 110px 160px 120px 90px; + column-gap: 16px; + padding: 11px 16px; font-size: 13px; + border-bottom: 1px solid var(--line-soft); + transition: background 100ms ease; + } + .user-row:hover { background: var(--panel-hi); } + .user-row:last-child { border-bottom: 0; } + .user-row.head { + cursor: default; padding-top: 9px; padding-bottom: 9px; + font-size: 11px; color: var(--ink-fade); + text-transform: uppercase; letter-spacing: 0.08em; + } + .user-row.head:hover { background: transparent; } + .user-row.disabled { opacity: 0.55; } +``` + +- [ ] **Step 4: Flip the Users tab live in settings.html** + +Find the existing `Users` tab markup in `web/templates/pages/settings.html` (currently rendered with a `disabled` style or similar). Make it a live link to `/settings/users`. + +The current settings.html has the tab strip rendered with three labels: Notifications, Users, Authentication. Update the Users one to a live anchor and bump the count badge to read from the page model when on the Notifications page (left as-is — Notifications still shows the channel count). + +```html +Users +``` + +- [ ] **Step 5: Run a manual smoke** + +Build and start the server, log in as admin, navigate to `/settings/users`, confirm the page renders and the `+ Add user` button is visible. + +```bash +make build +# restart and visit /settings/users +``` + +- [ ] **Step 6: Commit** + +```bash +git add internal/server/http/ui_users.go web/templates/pages/users.html \ + web/styles/input.css web/templates/pages/settings.html +git commit -m "ui: /settings/users list page" +``` + +--- + +### Task F2: Add user form + +**Files:** +- Create: `web/templates/pages/user_edit.html` (the form template, multi-mode for new + edit) +- Modify: `internal/server/http/ui_users.go` + +- [ ] **Step 1: Implement the new-user GET + POST handlers** + +```go +type userFormPage struct { + Mode string // "new" | "edit" | "setup-link" + ID string + Username string + Email string + Role string + Disabled bool + HasSetup bool + SetupURL string + SetupExpAt time.Time + Error string +} + +func (s *Server) handleUIUserNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + view := s.baseView(r, u) + view.Title = "New user · restic-manager" + view.Active = "settings" + view.Page = userFormPage{Mode: "new", Role: "operator"} + _ = s.deps.UI.Render(w, "user_edit", view) +} + +func (s *Server) handleUIUserNewPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + uname := strings.ToLower(strings.TrimSpace(r.PostForm.Get("username"))) + email := strings.TrimSpace(r.PostForm.Get("email")) + role, ok := validRole(r.PostForm.Get("role")) + if uname == "" || !ok { + view := s.baseView(r, u) + view.Title = "New user · restic-manager" + view.Active = "settings" + view.Page = userFormPage{ + Mode: "new", Username: uname, Email: email, + Role: r.PostForm.Get("role"), + Error: "Username is required and role must be admin/operator/viewer.", + } + _ = s.deps.UI.Render(w, "user_edit", view) + return + } + + // Same collision logic as the API: collide with disabled = re-enable + // suggestion; collide with enabled = hard error. + existing, err := s.deps.Store.GetUserByUsername(r.Context(), uname) + if err == nil { + if existing.DisabledAt != nil { + // Punt the admin to the edit page where Re-enable is one click. + stdhttp.Redirect(w, r, "/settings/users/"+existing.ID+ + "/edit?reenable=1", stdhttp.StatusSeeOther) + return + } + view := s.baseView(r, u) + view.Title = "New user · restic-manager" + view.Active = "settings" + view.Page = userFormPage{ + Mode: "new", Username: uname, Email: email, + Role: r.PostForm.Get("role"), + Error: "A user with that name already exists.", + } + _ = s.deps.UI.Render(w, "user_edit", view) + return + } else if !errors.Is(err, store.ErrNotFound) { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + id := ulid.Make().String() + now := time.Now().UTC() + var emailPtr *string + if email != "" { + em := strings.ToLower(email) + emailPtr = &em + } + if err := s.deps.Store.CreateUser(r.Context(), store.User{ + ID: id, Username: uname, PasswordHash: "", + Role: role, Email: emailPtr, CreatedAt: now, + MustChangePassword: true, + }); err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + rawToken, err := generateSetupToken() + if err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{ + UserID: id, TokenHash: hashSetupToken(rawToken), + ExpiresAt: now.Add(time.Hour), + CreatedAt: now, CreatedBy: &u.ID, + }); err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", + Action: "user.created", TargetKind: ptr("user"), TargetID: &id, + TS: now, + }) + // One-time link page. Pass the raw token as a querystring so the + // page can show it once. + stdhttp.Redirect(w, + r, + "/settings/users/"+id+"/setup-link?token="+rawToken, + stdhttp.StatusSeeOther) +} +``` + +- [ ] **Step 2: Implement the setup-link GET handler** + +```go +func (s *Server) handleUIUserSetupLinkGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + id := chi.URLParam(r, "id") + target, err := s.deps.Store.GetUserByID(r.Context(), id) + if err != nil { + stdhttp.NotFound(w, r) + return + } + rawToken := r.URL.Query().Get("token") + tok, err := s.deps.Store.GetSetupTokenByUserID(r.Context(), id) + if err != nil || rawToken == "" { + // No outstanding token (e.g. user already finished setup, or + // admin opened this URL without a token). 410. + w.WriteHeader(stdhttp.StatusGone) + view := s.baseView(r, u) + view.Title = "Link expired · restic-manager" + view.Active = "settings" + view.Page = userFormPage{ + Mode: "setup-link", ID: target.ID, Username: target.Username, + Error: "expired", + } + _ = s.deps.UI.Render(w, "user_edit", view) + return + } + view := s.baseView(r, u) + view.Title = "Setup link · restic-manager" + view.Active = "settings" + view.Page = userFormPage{ + Mode: "setup-link", ID: target.ID, Username: target.Username, + Role: string(target.Role), + HasSetup: true, + SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken, + SetupExpAt: tok.ExpiresAt, + } + _ = s.deps.UI.Render(w, "user_edit", view) +} +``` + +- [ ] **Step 3: Add the multi-mode template** + +```html + +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{$page := .Page}} +
+
+ Dashboard/ + Settings/ + Users/ + {{if eq $page.Mode "new"}}new{{else if eq $page.Mode "setup-link"}}setup link{{else}}{{$page.Username}}{{end}} +
+ +

+ {{if eq $page.Mode "new"}}New user + {{else if eq $page.Mode "setup-link"}}Setup link for {{$page.Username}} + {{else}}Edit {{$page.Username}}{{end}} +

+ + {{if eq $page.Mode "setup-link"}} + {{if eq $page.Error "expired"}} +
+
Link expired or already used
+

+ This user's setup token is no longer valid. Open their Edit page and click + Regenerate setup link to issue a new one. +

+ Open edit page +
+ {{else}} +
+

+ Send this link to the user. It expires at + {{absTime $page.SetupExpAt}} UTC + (~1 hour from now). This is the only time you'll see it — if you lose + it, regenerate from the Edit page. +

+
{{$page.SetupURL}}
+ + Done +
+ {{end}} + {{else}} + {{/* new + edit form. */}} +
+
+ + +
Lowercased automatically.
+
+
+ + +
+
+ + +
+ {{if $page.Error}}
{{$page.Error}}
{{end}} +
+ + Cancel +
+
+ + {{if eq $page.Mode "edit"}} + {{/* Side actions: regenerate setup link, disable / re-enable, force logout. */}} +
+
Other actions
+
+
+ +
+
+ +
+ {{if $page.Disabled}} +
+ +
+ {{else}} +
+ +
+ {{end}} +
+
+ {{end}} + {{end}} +
+{{end}} +``` + +- [ ] **Step 4: Wire routes** + +The routes are already declared in Task B4's reorganised `routes()`. Confirm they're present: + +```bash +grep -n "settings/users" internal/server/http/server.go +``` + +Expected: lines for new GET/POST, edit GET/POST, setup-link GET, disable, enable, regenerate-setup, force-logout. If any are missing, add them. + +- [ ] **Step 5: Manual smoke** + +```bash +make build +# restart, visit /settings/users/new, fill in form, see the setup-link page +``` + +- [ ] **Step 6: Commit** + +```bash +git add internal/server/http/ui_users.go web/templates/pages/user_edit.html +git commit -m "ui: /settings/users/new + /setup-link page" +``` + +--- + +### Task F3: Edit user UI handlers + +**Files:** +- Modify: `internal/server/http/ui_users.go` + +- [ ] **Step 1: Implement the GET, POST, and the small action POSTs** + +```go +func (s *Server) handleUIUserEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + id := chi.URLParam(r, "id") + target, err := s.deps.Store.GetUserByID(r.Context(), id) + if err != nil { + stdhttp.NotFound(w, r) + return + } + em := "" + if target.Email != nil { + em = *target.Email + } + view := s.baseView(r, u) + view.Title = "Edit user · restic-manager" + view.Active = "settings" + view.Page = userFormPage{ + Mode: "edit", ID: target.ID, Username: target.Username, + Email: em, Role: string(target.Role), + Disabled: target.DisabledAt != nil, + } + _ = s.deps.UI.Render(w, "user_edit", view) +} + +func (s *Server) handleUIUserEditPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + id := chi.URLParam(r, "id") + target, err := s.deps.Store.GetUserByID(r.Context(), id) + if err != nil { + stdhttp.NotFound(w, r) + return + } + role, ok := validRole(r.PostForm.Get("role")) + if !ok { + stdhttp.Error(w, "bad role", stdhttp.StatusBadRequest) + return + } + email := strings.TrimSpace(r.PostForm.Get("email")) + if email != "" { + if _, err := mail.ParseAddress(email); err != nil { + stdhttp.Error(w, "bad email", stdhttp.StatusBadRequest) + return + } + } + // Last-admin guard for demote. + if target.Role == store.RoleAdmin && role != store.RoleAdmin && target.DisabledAt == nil { + n, _ := s.deps.Store.CountEnabledAdmins(r.Context()) + if n <= 1 { + stdhttp.Error(w, "cannot demote last admin", stdhttp.StatusConflict) + return + } + } + if err := s.deps.Store.SetUserRole(r.Context(), id, role); err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + if err := s.deps.Store.SetUserEmail(r.Context(), id, email); err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", + Action: "user.updated", TargetKind: ptr("user"), TargetID: &id, + TS: time.Now().UTC(), + }) + stdhttp.Redirect(w, r, "/settings/users", stdhttp.StatusSeeOther) +} + +func (s *Server) handleUIUserDisablePost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + id := chi.URLParam(r, "id") + target, err := s.deps.Store.GetUserByID(r.Context(), id) + if err != nil { + stdhttp.NotFound(w, r) + return + } + if target.Role == store.RoleAdmin && target.DisabledAt == nil { + n, _ := s.deps.Store.CountEnabledAdmins(r.Context()) + if n <= 1 { + stdhttp.Error(w, "cannot disable last admin", stdhttp.StatusConflict) + return + } + } + now := time.Now().UTC() + if err := s.deps.Store.DisableUser(r.Context(), id, now); err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + _, _ = s.deps.Store.DeleteSessionsByUserID(r.Context(), id) + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", + Action: "user.disabled", TargetKind: ptr("user"), TargetID: &id, + TS: now, + }) + stdhttp.Redirect(w, r, "/settings/users", stdhttp.StatusSeeOther) +} + +func (s *Server) handleUIUserEnablePost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + id := chi.URLParam(r, "id") + if err := s.deps.Store.EnableUser(r.Context(), id); err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", + Action: "user.enabled", TargetKind: ptr("user"), TargetID: &id, + TS: time.Now().UTC(), + }) + stdhttp.Redirect(w, r, "/settings/users/"+id+"/edit", stdhttp.StatusSeeOther) +} + +func (s *Server) handleUIUserRegenerateSetupPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + id := chi.URLParam(r, "id") + if _, err := s.deps.Store.GetUserByID(r.Context(), id); err != nil { + stdhttp.NotFound(w, r) + return + } + rawToken, err := generateSetupToken() + if err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + now := time.Now().UTC() + if err := s.deps.Store.SetSetupToken(r.Context(), store.SetupToken{ + UserID: id, TokenHash: hashSetupToken(rawToken), + ExpiresAt: now.Add(time.Hour), CreatedAt: now, + CreatedBy: &u.ID, + }); err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + _ = s.deps.Store.SetMustChangePassword(r.Context(), id, true) + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", + Action: "user.setup_token.regenerated", + TargetKind: ptr("user"), TargetID: &id, TS: now, + }) + stdhttp.Redirect(w, r, + "/settings/users/"+id+"/setup-link?token="+rawToken, + stdhttp.StatusSeeOther) +} + +func (s *Server) handleUIUserForceLogoutPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + id := chi.URLParam(r, "id") + _, err := s.deps.Store.DeleteSessionsByUserID(r.Context(), id) + if err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", + Action: "user.force_logout", + TargetKind: ptr("user"), TargetID: &id, + TS: time.Now().UTC(), + }) + stdhttp.Redirect(w, r, "/settings/users/"+id+"/edit", stdhttp.StatusSeeOther) +} +``` + +- [ ] **Step 2: Build, smoke** + +```bash +make build +# restart server, edit a user, change role, verify the change persists +``` + +- [ ] **Step 3: Commit** + +```bash +git add internal/server/http/ui_users.go +git commit -m "ui: /settings/users edit form + disable/enable/regenerate/force-logout" +``` + +--- + +### Task F4: /settings/account UI + +**Files:** +- Modify: `internal/server/http/ui_account.go` +- Create: `web/templates/pages/account.html` + +- [ ] **Step 1: Add UI handlers** + +```go +type accountPage struct { + Username string + Role string + MustChange bool + Error string + Saved bool +} + +func (s *Server) handleUIAccountGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + full, err := s.deps.Store.GetUserByID(r.Context(), u.ID) + if err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + view := s.baseView(r, u) + view.Title = "Account · restic-manager" + view.Active = "settings" + view.Page = accountPage{ + Username: full.Username, Role: string(full.Role), + MustChange: full.MustChangePassword, + } + _ = s.deps.UI.Render(w, "account", view) +} + +func (s *Server) handleUIAccountPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + u := s.requireUIUser(w, r) + if u == nil { + return + } + if err := r.ParseForm(); err != nil { + stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest) + return + } + cur := r.PostForm.Get("current_password") + pw := r.PostForm.Get("new_password") + pw2 := r.PostForm.Get("confirm_password") + + full, err := s.deps.Store.GetUserByID(r.Context(), u.ID) + if err != nil { + stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) + return + } + + render := func(errMsg string, saved bool) { + view := s.baseView(r, u) + view.Title = "Account · restic-manager" + view.Active = "settings" + view.Page = accountPage{ + Username: full.Username, Role: string(full.Role), + MustChange: full.MustChangePassword, + Error: errMsg, Saved: saved, + } + _ = s.deps.UI.Render(w, "account", view) + } + + if pw == "" || pw != pw2 || len(pw) < 12 { + render("Passwords must match and be at least 12 characters.", false) + return + } + if !full.MustChangePassword { + if err := auth.ComparePassword(full.PasswordHash, cur); err != nil { + render("Current password is incorrect.", false) + return + } + } + hash, err := auth.HashPassword(pw) + if err != nil { + render("Internal error.", false) + return + } + if err := s.deps.Store.SetPasswordHash(r.Context(), u.ID, hash); err != nil { + render("Internal error.", false) + return + } + _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ + ID: ulid.Make().String(), UserID: &u.ID, Actor: "user", + Action: "user.password_changed", + TargetKind: ptr("user"), TargetID: &u.ID, + TS: time.Now().UTC(), + }) + full.MustChangePassword = false + render("", true) +} +``` + +- [ ] **Step 2: Add the template** + +```html + +{{define "title"}}Account · restic-manager{{end}} + +{{define "content"}} +{{$page := .Page}} +
+
+ Dashboard/ + account +
+ +

Account

+
+ Signed in as {{$page.Username}} + ({{$page.Role}}). Change your password below. +
+ + {{if $page.Saved}} +
+
Password updated.
+
+ {{end}} + +
+ {{if not $page.MustChange}} +
+ + +
+ {{end}} +
+ + +
+
+ + +
+ {{if $page.Error}}
{{$page.Error}}
{{end}} + +
+
+{{end}} +``` + +- [ ] **Step 3: Manual smoke** + +Log in as a viewer (created via setup-token flow), navigate to `/settings/account`, change the password, log out and back in with the new one. + +- [ ] **Step 4: Commit** + +```bash +git add internal/server/http/ui_account.go web/templates/pages/account.html +git commit -m "ui: /settings/account self-service password change" +``` + +--- + +## Slice G — Wiring & sweep + +### Task G1: Maintenance ticker — sweep expired setup tokens + +**Files:** +- Modify: `internal/server/http/maintenance_dispatch.go` *or* a new ticker hook (whichever the codebase already uses for periodic-tasks not tied to a host) + +- [ ] **Step 1: Identify the host of the cleanup** + +Read `internal/server/maintenance/ticker.go` (the existing maintenance ticker) to see whether it has a "global housekeeping" slot or only host-keyed work. If only host-keyed, add a new periodic-task hook in `cmd/server/main.go` that fires every 60s alongside the existing alert-engine ticker. + +Quickest path: piggy-back on the alert-engine tick in `internal/alert/engine.go` — it already runs at 60s and has access to `*store.Store`. Add a 1-line call from `e.tick()`: + +```go +// internal/alert/engine.go (inside tick) +if _, err := e.store.CleanupExpiredSetupTokens(ctx, now); err != nil { + slog.Warn("alert: cleanup expired setup tokens", "err", err) +} +``` + +Yes, it's a layering smell (alert engine doing user-mgmt cleanup). Acceptable v1 tradeoff because the alternative is a new dedicated ticker for one query. Documented in a comment: "User-management cleanup piggy-backed here for now; extract a dedicated maintenance loop if more housekeeping queries appear." + +- [ ] **Step 2: Run tests, ensure still green** + +Run: `go test ./internal/alert/...` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add internal/alert/engine.go +git commit -m "alert: piggy-back expired-setup-token cleanup on the engine tick" +``` + +--- + +### Task G2: Live Playwright sweep + sweep notes in tasks.md + +**Files:** +- Modify: `tasks.md` +- Output: `_diag/p4-03-04-sweep/*.png` + +- [ ] **Step 1: Manual end-to-end check** + +```bash +make build +# restart server. Then in the browser, signed in as admin: +# 1. Visit /settings/users — see the existing admin row +# 2. Click + Add user. Username 'op1', email 'op1@example.com', role operator. Submit. +# 3. Land on /settings/users/{id}/setup-link. Copy the URL. +# 4. Open that URL in a private window. Set password 'averylongpassword'. Submit. +# 5. Land on / as op1. +# 6. Try /settings/users → 403 (forbidden page renders). +# 7. Visit /settings/account — change password, log out, log back in with the new one. +# 8. Back as admin: edit op1, click Disable user. +# 9. In op1's still-open tab, click any link → bounced to /login. +# 10. Re-enable op1, force-logout, regenerate setup link, walk through the new link. +``` + +Capture screenshots at each major step into `_diag/p4-03-04-sweep/01-users-list.png` etc. + +- [ ] **Step 2: Tick tasks.md** + +```bash +# Edit tasks.md, find P4-03 and P4-04, replace [ ] with [x] and add an "as shipped" +# note under "Phase 4 — Update delivery, RBAC polish, OIDC" that summarises: +# - Three roles enforced via chi route-group middleware +# - Setup-token flow with 1h expiry, sha256-hashed at rest, raw shown to admin once +# - Disable-only user lifecycle with last-admin guard, immediate session kick +# - Self-service /settings/account password change for any role +# - Email field as metadata only in v1 +``` + +- [ ] **Step 3: Commit** + +```bash +git add tasks.md +git commit -m "tasks: tick P4-03/04 + sweep notes" +``` + +--- + +## Self-review notes + +**Spec coverage:** every section of the design doc maps to at least one task — +- Role taxonomy → A4 (CountEnabledAdmins) + B (middleware + grouping) +- Schema → A1, A2, A3, A4, A5, A6 +- RBAC enforcement → B1–B5 +- Session re-validation → C1 +- Setup-token flow → D1–D2 + E2/E5 + F2 +- User CRUD API → E1–E6 +- UI → F1–F4 +- Audit actions → embedded in each create/disable/enable/etc. handler +- Last-admin guard → E3, E4, F3 +- Token cleanup → G1 +- Acceptance / sweep → G2 + +**Placeholder scan:** no TBD/TODO; every code block is concrete. + +**Type consistency:** `store.User` adds `Email *string`, `DisabledAt *time.Time`, `MustChangePassword bool`; same names used in `apiUser`, `userRow`, `userFormPage`. `SetupToken` shape used identically across Set/Lookup/GetByUserID/Delete/Cleanup. Audit action strings standardised in the spec's `## Audit actions` section and quoted exactly in handler code. + +One gotcha to watch during execution: the existing test fixtures (`newTestServer`, `loginAsAdmin`) probably need *no* changes because their pattern still works — you create an admin user, log in. Tests that exercise specific role paths will be added as part of E1+ tasks. If anywhere assumes "any logged-in user can do anything", that test needs adjusting or breaks during Task B4. From ca170fedc5f68f4ce005a07f2643cdc8bebcd7c1 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 08:59:01 +0100 Subject: [PATCH 03/30] =?UTF-8?q?store:=20migration=200017=20=E2=80=94=20u?= =?UTF-8?q?sers.email,=20disabled=5Fat,=20must=5Fchange=5Fpassword?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migrations/0017_users_extensions.sql | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 internal/store/migrations/0017_users_extensions.sql diff --git a/internal/store/migrations/0017_users_extensions.sql b/internal/store/migrations/0017_users_extensions.sql new file mode 100644 index 0000000..715b292 --- /dev/null +++ b/internal/store/migrations/0017_users_extensions.sql @@ -0,0 +1,21 @@ +-- 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)); From a7e53e0a64900d7a90c35d17b919eb0267d19ca3 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:00:35 +0100 Subject: [PATCH 04/30] =?UTF-8?q?store:=20migration=200018=20=E2=80=94=20u?= =?UTF-8?q?ser=5Fsetup=5Ftokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store/migrations/0018_user_setup_tokens.sql | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 internal/store/migrations/0018_user_setup_tokens.sql diff --git a/internal/store/migrations/0018_user_setup_tokens.sql b/internal/store/migrations/0018_user_setup_tokens.sql new file mode 100644 index 0000000..a308cfb --- /dev/null +++ b/internal/store/migrations/0018_user_setup_tokens.sql @@ -0,0 +1,16 @@ +-- 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); From bd08d8ca1443eae0b4fa3fc3a7dfaf52b2d92a16 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:02:03 +0100 Subject: [PATCH 05/30] store: extend User struct with Email, DisabledAt, MustChangePassword --- internal/store/types.go | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/internal/store/types.go b/internal/store/types.go index 762bee7..88758e4 100644 --- a/internal/store/types.go +++ b/internal/store/types.go @@ -9,12 +9,15 @@ import ( // User mirrors the users table. type User struct { - ID string - Username string - PasswordHash string - Role Role - CreatedAt time.Time - LastLoginAt *time.Time + 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 } // Role enumerates the access tiers from spec.md §7.2. @@ -219,3 +222,14 @@ type AuditEntry struct { TS time.Time Payload json.RawMessage } + +// 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 +} From 2c090171e5abcec47e31bc19be61e85de3744d4c Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:04:51 +0100 Subject: [PATCH 06/30] store: lowercase username, email/disable helpers, last-admin count --- internal/store/users.go | 172 +++++++++++++++++++++++++++-------- internal/store/users_test.go | 34 +++++++ 2 files changed, 168 insertions(+), 38 deletions(-) diff --git a/internal/store/users.go b/internal/store/users.go index 1a74528..06cd4a1 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -5,45 +5,57 @@ import ( "database/sql" "errors" "fmt" + "strings" "time" ) -// CreateUser inserts a new user. The caller is responsible for -// generating an ID (typically a ULID) and hashing the password. +// 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, created_at) - VALUES (?, ?, ?, ?, ?)`, - u.ID, u.Username, u.PasswordHash, string(u.Role), u.CreatedAt.UTC().Format(time.RFC3339Nano)) + `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 looks up a user by their (case-sensitive) username. -// Returns ErrNotFound if no row matches. +// 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, created_at, last_login_at - FROM users WHERE username = ?`, username) - return scanUser(row) + `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, created_at, last_login_at + `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) + return scanUser(row.Scan) } // ListUsers returns every user, sorted by username. Used by surfaces // that need to render a user-id → username map (audit log filter, -// "ack'd by" projections). +// "ack'd by" projections) 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, created_at, last_login_at + `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) @@ -51,21 +63,11 @@ func (s *Store) ListUsers(ctx context.Context) ([]User, error) { defer func() { _ = rows.Close() }() var out []User for rows.Next() { - var u User - var role string - var lastLogin sql.NullString - var created string - if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &created, &lastLogin); err != nil { - return nil, fmt.Errorf("store: scan user row: %w", err) + u, err := scanUser(rows.Scan) + if err != nil { + return nil, err } - u.Role = Role(role) - t, _ := time.Parse(time.RFC3339Nano, created) - u.CreatedAt = t - if lastLogin.Valid { - t, _ := time.Parse(time.RFC3339Nano, lastLogin.String) - u.LastLoginAt = &t - } - out = append(out, u) + out = append(out, *u) } return out, rows.Err() } @@ -80,6 +82,19 @@ func (s *Store) CountUsers(ctx context.Context) (int, error) { 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, @@ -91,28 +106,109 @@ func (s *Store) MarkUserLogin(ctx context.Context, id string, when time.Time) er return nil } -func scanUser(row *sql.Row) (*User, error) { +// 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 lastLogin sql.NullString + var email, disabledAt, lastLogin sql.NullString + var must int var created string - if err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &role, &created, &lastLogin); err != nil { + 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) - t, err := time.Parse(time.RFC3339Nano, created) - if err != nil { - return nil, fmt.Errorf("store: parse created_at: %w", err) + 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, err := time.Parse(time.RFC3339Nano, lastLogin.String) - if err != nil { - return nil, fmt.Errorf("store: parse last_login_at: %w", err) - } + t, _ := time.Parse(time.RFC3339Nano, lastLogin.String) u.LastLoginAt = &t } return &u, nil diff --git a/internal/store/users_test.go b/internal/store/users_test.go index e1eae99..a7684a9 100644 --- a/internal/store/users_test.go +++ b/internal/store/users_test.go @@ -131,6 +131,40 @@ func TestSessionLifecycle(t *testing.T) { } } +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") + } + 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) + } + 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") + } +} + func TestEnrollmentTokenSingleUse(t *testing.T) { t.Parallel() s := openTestStore(t) From 12391abef0c87e3c38a72f0794f5acf46cc310cf Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:06:54 +0100 Subject: [PATCH 07/30] store: user_setup_tokens CRUD + cleanup-expired --- internal/store/setup_tokens.go | 93 +++++++++++++++++++++ internal/store/setup_tokens_test.go | 120 ++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+) create mode 100644 internal/store/setup_tokens.go create mode 100644 internal/store/setup_tokens_test.go diff --git a/internal/store/setup_tokens.go b/internal/store/setup_tokens.go new file mode 100644 index 0000000..161f8ca --- /dev/null +++ b/internal/store/setup_tokens.go @@ -0,0 +1,93 @@ +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) + return scanSetupToken(row.Scan) +} + +// 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) + return scanSetupToken(row.Scan) +} + +// 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 +} + +func scanSetupToken(scan func(...any) error) (*SetupToken, error) { + var t SetupToken + var createdBy sql.NullString + var expiresAt, createdAt string + if err := 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 +} diff --git a/internal/store/setup_tokens_test.go b/internal/store/setup_tokens_test.go new file mode 100644 index 0000000..8aa29b5 --- /dev/null +++ b/internal/store/setup_tokens_test.go @@ -0,0 +1,120 @@ +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") + } +} From f0828782c10ba68135016e6ddc1f780abf162c0e Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:08:04 +0100 Subject: [PATCH 08/30] store: DeleteSessionsByUserID for force-logout --- CLAUDE.md | 9 +++++++ internal/store/sessions.go | 15 +++++++++++ internal/store/sessions_test.go | 45 +++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+) create mode 100644 internal/store/sessions_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 8b1dd42..be175fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,10 +2,19 @@ Project-specific rules for Claude when working in this repo. +## Commands + +Is the user types in any of the following, follow the instructions in the table + +| Command | Action | +| --- | --- | +| :release | trigger subagent to commit (if needed), push (if needed), raise PR, wait for PR to pass or fail. If fail, report back. If pass, merge in to main | + ## Repo The repo lives inside a Gitea instance; `tea` CLI is available for use by agents + ## Run `go vet` before every commit CI runs `go vet ./...` and will fail the build on any vet error. diff --git a/internal/store/sessions.go b/internal/store/sessions.go index df26831..a2ef31c 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -86,3 +86,18 @@ func (s *Store) PurgeExpiredSessions(ctx context.Context) (int64, error) { n, _ := res.RowsAffected() return n, nil } + +// 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 +} diff --git a/internal/store/sessions_test.go b/internal/store/sessions_test.go new file mode 100644 index 0000000..81222ee --- /dev/null +++ b/internal/store/sessions_test.go @@ -0,0 +1,45 @@ +package store + +import ( + "context" + "testing" + "time" +) + +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") + } +} From 37a25beb1400c173f311f73b42c70ebbd5ff6b6a Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:09:01 +0100 Subject: [PATCH 09/30] http: roleAtLeast helper for the role hierarchy --- internal/server/http/rbac.go | 26 ++++++++++++++++++++++++ internal/server/http/rbac_test.go | 33 +++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 internal/server/http/rbac.go create mode 100644 internal/server/http/rbac_test.go diff --git a/internal/server/http/rbac.go b/internal/server/http/rbac.go new file mode 100644 index 0000000..7068f0d --- /dev/null +++ b/internal/server/http/rbac.go @@ -0,0 +1,26 @@ +package http + +import ( + "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 +} diff --git a/internal/server/http/rbac_test.go b/internal/server/http/rbac_test.go new file mode 100644 index 0000000..5b20b3d --- /dev/null +++ b/internal/server/http/rbac_test.go @@ -0,0 +1,33 @@ +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) + } + } +} From 2073898c1044d95c2dbf293939973d1f6355aca6 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:10:26 +0100 Subject: [PATCH 10/30] =?UTF-8?q?http:=20test=20helpers=20=E2=80=94=20make?= =?UTF-8?q?User,=20loginAs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/http/users_test_helpers_test.go | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 internal/server/http/users_test_helpers_test.go diff --git a/internal/server/http/users_test_helpers_test.go b/internal/server/http/users_test_helpers_test.go new file mode 100644 index 0000000..e3f3bf5 --- /dev/null +++ b/internal/server/http/users_test_helpers_test.go @@ -0,0 +1,58 @@ +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. +// +//nolint:unused +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. Skips the real +// /api/auth/login handler for speed and to keep these helpers usable +// even when login validation is mid-flight elsewhere. +// +//nolint:unused +func loginAs(t *testing.T, srv *Server, userID string) *stdhttp.Cookie { + t.Helper() + rawToken, err := auth.NewToken() + 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: userID, CreatedAt: now, + ExpiresAt: now.Add(8 * time.Hour), + }, hash); err != nil { + t.Fatalf("session: %v", err) + } + return &stdhttp.Cookie{ + Name: sessionCookieName, + Value: rawToken, + } +} From f87ba29836a58548d6109b8b238ed7300ba6f6b4 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:13:51 +0100 Subject: [PATCH 11/30] http: requireRole middleware + 403 forbidden page --- internal/server/http/rbac.go | 61 +++++++++++++++++++++++++++++ internal/server/http/rbac_test.go | 63 ++++++++++++++++++++++++++++++ web/templates/pages/forbidden.html | 21 ++++++++++ 3 files changed, 145 insertions(+) create mode 100644 web/templates/pages/forbidden.html diff --git a/internal/server/http/rbac.go b/internal/server/http/rbac.go index 7068f0d..1fc6540 100644 --- a/internal/server/http/rbac.go +++ b/internal/server/http/rbac.go @@ -1,6 +1,9 @@ package http import ( + stdhttp "net/http" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) @@ -24,3 +27,61 @@ func roleAtLeast(have, min store.Role) bool { } return h >= m } + +// 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, &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.")) + } +} diff --git a/internal/server/http/rbac_test.go b/internal/server/http/rbac_test.go index 5b20b3d..e4b711d 100644 --- a/internal/server/http/rbac_test.go +++ b/internal/server/http/rbac_test.go @@ -1,6 +1,9 @@ package http import ( + stdhttp "net/http" + "net/http/httptest" + "strings" "testing" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" @@ -31,3 +34,63 @@ func TestRoleAtLeast(t *testing.T) { } } } + +func TestRequireRoleViewerAdmits(t *testing.T) { + t.Parallel() + srv, _ := 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", "/api/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, _ := newTestServer(t, false) + uid := makeUser(t, srv, "viewer2", 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", "/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 TestRequireRoleUnauthenticated401OnAPI(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) + if rr.Code != stdhttp.StatusUnauthorized { + t.Errorf("status: got %d want 401", rr.Code) + } +} diff --git a/web/templates/pages/forbidden.html b/web/templates/pages/forbidden.html new file mode 100644 index 0000000..f0e7553 --- /dev/null +++ b/web/templates/pages/forbidden.html @@ -0,0 +1,21 @@ +{{define "title"}}Forbidden · restic-manager{{end}} + +{{define "content"}} +{{$page := .Page}} +
+
+ Dashboard/ + forbidden +
+
+
403 — Insufficient role
+

+ Your role ({{$page.Have}}) does not permit + this page ({{$page.Required}} required). + Ask your administrator if you need access. +

+ Back to dashboard +
+
+{{end}} From 95aee73e2c455519a7755cebc24702f4a8f82011 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:14:55 +0100 Subject: [PATCH 12/30] http: gated test for admin-band reject of operator (lands fully in B4+E1) --- internal/server/http/rbac_test.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/server/http/rbac_test.go b/internal/server/http/rbac_test.go index e4b711d..2e147a8 100644 --- a/internal/server/http/rbac_test.go +++ b/internal/server/http/rbac_test.go @@ -94,3 +94,26 @@ func TestRequireRoleUnauthenticated401OnAPI(t *testing.T) { t.Errorf("status: got %d want 401", rr.Code) } } + +func TestAdminBandRejectsOperator(t *testing.T) { + t.Parallel() + // This test will start asserting 403 once Task B4 mounts /api/users + // inside the admin band and Task E1 lands the handler. Until then, + // the route 404s — we skip rather than red-flag the suite. + t.Skip("re-enable after B4 route grouping + E1 /api/users handler land") + srv, urlBase := 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", urlBase+"/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) + } +} From c1e974aad9af502d85bb8e42358b82d90cef0ae3 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:19:15 +0100 Subject: [PATCH 13/30] http: re-group routes by role band, fail-closed admin default Routes are now structured into Public / Viewer / Operator / Admin bands using requireRole middleware. Job log stream and download moved into the Viewer band. healthz moved from New() into routes() with the other public endpoints. --- internal/server/http/server.go | 349 +++++++++++++-------------------- 1 file changed, 133 insertions(+), 216 deletions(-) diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 86cd1ba..72ff021 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -85,11 +85,6 @@ func New(deps Deps) *Server { r.Use(middleware.Recoverer) r.Use(requestLogger) - // Health endpoint — unauthenticated, no audit, deliberately cheap. - r.Get("/healthz", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) { - w.WriteHeader(stdhttp.StatusNoContent) - }) - s := &Server{ deps: deps, drainLocks: make(map[string]*sync.Mutex), @@ -113,132 +108,17 @@ func New(deps Deps) *Server { // routes wires the API tree. Subtrees live in this file by area so a // reader can scan one place and see the surface. func (s *Server) routes(r chi.Router) { - r.Route("/api", func(r chi.Router) { - r.Post("/auth/login", s.handleLogin) - r.Post("/auth/logout", s.handleLogout) - r.Post("/bootstrap", s.handleBootstrap) - - // Agent enrollment (open endpoint — token is the credential). - r.Post("/agents/enroll", s.handleAgentEnroll) - - // Announce-and-approve enrolment (open endpoint — fingerprint - // comparison in the UI is the gate). Per-IP rate-limited and - // globally capped (P2-18). - r.Post("/agents/announce", s.handleAnnounce) - - // Pending host management — admin-only (gated inside the handler). - r.Post("/pending-hosts/{id}/accept", s.handleAcceptPendingHost) - r.Post("/pending-hosts/{id}/reject", s.handleRejectPendingHost) - - // Operator → server (authenticated). Spec.md §6.1's - // /hosts/{id}/enrollment-token (regenerate) lands when the - // host page can call it; for now just the create endpoint. - r.Post("/enrollment-tokens", s.handleCreateEnrollmentToken) - - // Fleet read endpoints — back the dashboard. - r.Get("/hosts", s.handleListHosts) - r.Get("/fleet/summary", s.handleFleetSummary) - - // Run-now: dispatch a job to a host's agent. - r.Post("/hosts/{id}/jobs", s.handleRunNow) - - // Snapshot projection (refreshed by the agent after each backup). - r.Get("/hosts/{id}/snapshots", s.handleListHostSnapshots) - - // Repo credentials — operator can edit after enrollment. The - // initial set is supplied at token-mint time (see enrollment.go). - // GET returns a redacted view (URL, username, has_password). - r.Get("/hosts/{id}/repo-credentials", s.handleGetHostCredentials) - r.Put("/hosts/{id}/repo-credentials", s.handleSetHostCredentials) - - // Admin credentials — the prune-capable slot (separate from the - // everyday repo creds). Optional: hosts that don't prune against - // a rest-server repo with a separate admin user never need this. - r.Get("/hosts/{id}/admin-credentials", s.handleGetAdminCredentials) - r.Put("/hosts/{id}/admin-credentials", s.handleSetAdminCredentials) - r.Delete("/hosts/{id}/admin-credentials", s.handleDeleteAdminCredentials) - - // Per-host schedule CRUD. Mutations bump host_schedule_version - // and async-push to a connected agent (see schedule_push.go). - r.Get("/hosts/{id}/schedules", s.handleListSchedules) - r.Post("/hosts/{id}/schedules", s.handleCreateSchedule) - r.Put("/hosts/{id}/schedules/{sid}", s.handleUpdateSchedule) - r.Delete("/hosts/{id}/schedules/{sid}", s.handleDeleteSchedule) - - // Source-group CRUD. A group is "what gets backed up" — paths, - // excludes, retention, retry. Group name doubles as the - // snapshot tag (restic --tag ). - r.Get("/hosts/{id}/source-groups", s.handleListSourceGroups) - r.Post("/hosts/{id}/source-groups", s.handleCreateSourceGroup) - r.Get("/hosts/{id}/source-groups/{gid}", s.handleGetSourceGroup) - r.Put("/hosts/{id}/source-groups/{gid}", s.handleUpdateSourceGroup) - r.Delete("/hosts/{id}/source-groups/{gid}", s.handleDeleteSourceGroup) - - // Repo maintenance cadences (forget / prune / check). Driven - // by the server-side ticker (P2R-06), not the agent's cron. - r.Get("/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance) - r.Put("/hosts/{id}/repo-maintenance", s.handleUpdateRepoMaintenance) - - // Host-wide bandwidth caps (host.bandwidth_up_kbps / - // bandwidth_down_kbps). Apply to every restic invocation. - r.Put("/hosts/{id}/bandwidth", s.handleUpdateHostBandwidth) - - // Per-source-group Run-now (JSON variant). HTMX action is - // mounted at the equivalent path outside /api below — both - // resolve to the same handler, which sniffs HX-Request. - r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup) - - // Repo-level run-now: prune (needs admin creds), check, unlock. - // HTMX forms are also mounted outside /api below. - r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune) - r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck) - r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock) - - // Cancel a running job. Operator-driven, sends command.cancel - // to the agent which kills the restic subprocess; the agent's - // resulting job.finished (status=canceled) is what flips the - // job row. - r.Post("/jobs/{id}/cancel", s.handleCancelJob) - - // Snapshot diff (P3-09). Dispatches a JobDiff against two - // snapshots; output streams to the standard live job page. - r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff) - - // Alert list (JSON variant). Same filter shape as the UI page. - r.Get("/alerts", s.handleAPIAlerts) - - // Audit log (JSON variant). - r.Get("/audit", s.handleAPIAudit) - - // Notification channel test-fire. Dispatches a synthetic payload - // through a single named channel; returns JSON result. - r.Post("/notifications/{id}/test", s.handleAPINotificationTest) + // Public, unauthenticated. + r.Get("/healthz", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) { + w.WriteHeader(stdhttp.StatusNoContent) }) - - // HTMX form variant of diff (mounted outside /api so HTMX forms - // can post against it without the api/ prefix). - r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff) - - // Per-source-group Run-now (HTMX form action). Available even - // when the server is started without UI templates so REST callers - // against the non-/api path also work. - r.Post("/hosts/{id}/source-groups/{gid}/run", s.handleRunSourceGroup) - // Repo-level run-now (HTMX form actions). Same handlers as the /api - // variants — wantsHTML sniff distinguishes JSON vs HTMX response. - r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune) - r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck) - r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock) - // Retired routes — see ui_handlers.go for the messages. Mounted - // outside the UI gate so cached browser tabs get a clear 410 - // even if the server runs without templates. - r.Post("/hosts/{id}/run-backup", s.handleUIRunBackupGone) - r.Post("/hosts/{id}/init-repo", s.handleUIInitRepoGone) - - // Pending-host WebSocket (announce-and-approve, P2-18b). Mounted - // before /ws/agent so the more-specific route matches first. - r.Get("/ws/agent/pending", s.handlePendingWS) - - // Agent ↔ server WebSocket. Bearer-authenticated inside the handler. + 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, @@ -250,101 +130,138 @@ func (s *Server) routes(r chi.Router) { OnScheduleFire: s.dispatchScheduledJob, })) } - - // Agent binaries + install scripts. Open endpoints — content is - // unprivileged on its own, gating happens via the enrollment - // token. See agent_assets.go. - r.Get("/agent/binary", s.handleAgentBinary) - r.Get("/install/*", s.handleInstallAsset) - - // Static assets (Tailwind CSS bundle, future favicon). + r.Get("/ws/agent/pending", s.handlePendingWS) r.Mount("/static/", staticHandler()) - // HTML UI. The renderer is required — fail loud if the binary - // was built without templates (impossible in practice given - // embed, but guards bad test wiring). if s.deps.UI != nil { - r.Get("/", s.handleUIDashboard) r.Get("/login", s.handleUILoginGet) r.Post("/login", s.handleUILoginPost) r.Post("/logout", s.handleUILogoutPost) - // Per-host Run-now and manual Init-repo are mounted at the - // outer router (so they reply 410 even without UI). Per- - // source-group Run-now lives there too — same reason. - // Add host flow. - r.Get("/hosts/new", s.handleUIAddHostGet) - r.Post("/hosts/new", s.handleUIAddHostPost) - // Durable post-Add-host page (operator can refresh / come - // back; password decrypted from the token row each render). - // Polled fragment under /awaiting flips to "connected" once - // the agent enrols. - r.Get("/hosts/pending/{token}", s.handleUIPendingHost) - r.Get("/hosts/pending/{token}/awaiting", s.handleUIPendingAwaiting) - // Host detail (Snapshots tab is the default). - r.Get("/hosts/{id}", s.handleUIHostDetail) - // Sources tab + source-group CRUD forms. - r.Get("/hosts/{id}/sources", s.handleUIHostSources) - r.Get("/hosts/{id}/sources/new", s.handleUISourceGroupNewGet) - r.Post("/hosts/{id}/sources/new", s.handleUISourceGroupSave) - r.Get("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupEditGet) - r.Post("/hosts/{id}/sources/{gid}/edit", s.handleUISourceGroupSave) - r.Post("/hosts/{id}/sources/{gid}/delete", s.handleUISourceGroupDelete) - // Repo tab — connection / bandwidth / maintenance. Three - // independent forms so saving one doesn't touch the others. - r.Get("/hosts/{id}/repo", s.handleUIHostRepo) - 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) - // Admin credentials form (separate slot for prune-capable user). - r.Post("/hosts/{id}/admin-credentials", s.handleUIAdminCredentialsSave) - r.Post("/hosts/{id}/admin-credentials/delete", s.handleUIAdminCredentialsDelete) - // Schedules tab + create/edit/delete forms. - r.Get("/hosts/{id}/schedules", s.handleUISchedulesList) - r.Get("/hosts/{id}/schedules/new", s.handleUIScheduleNewGet) - r.Post("/hosts/{id}/schedules/new", s.handleUIScheduleSave) - r.Get("/hosts/{id}/schedules/{sid}/edit", s.handleUIScheduleEditGet) - 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) - // Live job log. - r.Get("/jobs/{id}", s.handleUIJobDetail) - // Restore wizard (P3-01/P3-02). Two GET variants land on the - // same handler; the second deep-links a chosen snapshot. - r.Get("/hosts/{id}/restore", s.handleUIRestoreGet) - r.Get("/hosts/{id}/snapshots/{sid}/restore", s.handleUIRestoreGet) - r.Post("/hosts/{id}/restore", s.handleUIRestorePost) - r.Get("/hosts/{id}/restore/tree", s.handleUIRestoreTree) - // Alerts list + operator actions. - r.Get("/alerts", s.handleUIAlerts) - r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge) - r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve) - // Audit log (read-only). - r.Get("/audit", s.handleUIAudit) - r.Get("/audit.csv", s.handleUIAuditCSV) - // Settings shell + Notifications sub-tab CRUD. - 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) } - // Browser job-log stream (separate from /ws/agent so the auth - // layer is session-cookie not bearer). Mounted regardless of - // whether the UI is up — JSON callers may also subscribe. - if s.deps.JobHub != nil { - r.Get("/api/jobs/{id}/stream", s.handleJobStream) - } + // Viewer band — anyone authenticated can read. + r.Group(func(r chi.Router) { + r.Use(s.requireRole(store.RoleViewer)) - // Job log download (txt + ndjson). Source of truth is the - // persisted job_logs table; safe to call any time, no pause - // needed against the live stream. - r.Get("/api/jobs/{id}/log.{format:txt|ndjson}", s.handleJobLogDownload) + // 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) + + // Job log stream + download (read-only; any authenticated user). + if s.deps.JobHub != nil { + r.Get("/api/jobs/{id}/stream", s.handleJobStream) + } + r.Get("/api/jobs/{id}/log.{format:txt|ndjson}", s.handleJobLogDownload) + + if s.deps.UI != nil { + 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) + } + }) + + // 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 (JSON). + 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.Post("/hosts/{id}/restore", s.handleUIRestorePost) + r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge) + r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve) + } + }) + + // Admin band — channels, server-shape config. + r.Group(func(r chi.Router) { + r.Use(s.requireRole(store.RoleAdmin)) + + r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest) + + if s.deps.UI != nil { + 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) + } + }) } // Start begins listening. Blocks until ListenAndServe returns From cbdd94ca1294fb711bdbf7fa5ead3e96ca01052c Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:22:07 +0100 Subject: [PATCH 14/30] http: session/login reject disabled users; mid-session disable kicks immediately --- internal/server/http/auth.go | 3 ++ internal/server/http/jobs.go | 6 ++++ internal/server/http/rbac_test.go | 47 +++++++++++++++++++++++++++++ internal/server/http/ui_handlers.go | 4 +++ 4 files changed, 60 insertions(+) diff --git a/internal/server/http/auth.go b/internal/server/http/auth.go index 6c0fc2e..508c6b4 100644 --- a/internal/server/http/auth.go +++ b/internal/server/http/auth.go @@ -59,6 +59,9 @@ func (s *Server) authenticateAndSession(w stdhttp.ResponseWriter, r *stdhttp.Req if err := auth.VerifyPassword(u.PasswordHash, password); err != nil { return nil, errInvalidCredentials } + if u.DisabledAt != nil { + return nil, errInvalidCredentials + } token, err := auth.NewToken() if err != nil { diff --git a/internal/server/http/jobs.go b/internal/server/http/jobs.go index 592c64e..7582307 100644 --- a/internal/server/http/jobs.go +++ b/internal/server/http/jobs.go @@ -152,6 +152,12 @@ func (s *Server) requireUser(r *stdhttp.Request) (*store.User, bool) { 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(), auth.HashToken(c.Value)) + return nil, false + } return u, true } diff --git a/internal/server/http/rbac_test.go b/internal/server/http/rbac_test.go index 2e147a8..42f6b44 100644 --- a/internal/server/http/rbac_test.go +++ b/internal/server/http/rbac_test.go @@ -1,10 +1,13 @@ package http import ( + "bytes" + "encoding/json" stdhttp "net/http" "net/http/httptest" "strings" "testing" + "time" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) @@ -95,6 +98,50 @@ func TestRequireRoleUnauthenticated401OnAPI(t *testing.T) { } } +func TestRequireRoleRejectsDisabledMidSession(t *testing.T) { + t.Parallel() + srv, urlBase := 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", urlBase+"/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, urlBase := 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(urlBase+"/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) + } +} + func TestAdminBandRejectsOperator(t *testing.T) { t.Parallel() // This test will start asserting 403 once Task B4 mounts /api/users diff --git a/internal/server/http/ui_handlers.go b/internal/server/http/ui_handlers.go index 8bf9f8c..8e9370f 100644 --- a/internal/server/http/ui_handlers.go +++ b/internal/server/http/ui_handlers.go @@ -66,6 +66,10 @@ func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) { } return nil, err } + if u.DisabledAt != nil { + _ = s.deps.Store.DeleteSession(r.Context(), auth.HashToken(c.Value)) + return nil, nil + } return &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}, nil } From 8d4c4426b040cde1b501d2fb28b59ea57905fa04 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:27:53 +0100 Subject: [PATCH 15/30] http: GET /setup landing page with expiry handling --- internal/server/http/server.go | 2 + internal/server/http/setup_handler.go | 77 +++++++++++++++++++++++ internal/server/http/setup_test.go | 90 +++++++++++++++++++++++++++ internal/server/ui/ui.go | 2 +- web/templates/pages/setup.html | 44 +++++++++++++ 5 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 internal/server/http/setup_handler.go create mode 100644 internal/server/http/setup_test.go create mode 100644 web/templates/pages/setup.html diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 72ff021..a54b70a 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -137,6 +137,8 @@ func (s *Server) routes(r chi.Router) { r.Get("/login", s.handleUILoginGet) r.Post("/login", s.handleUILoginPost) r.Post("/logout", s.handleUILogoutPost) + r.Get("/setup", s.handleUISetupGet) + r.Post("/setup", s.handleUISetupPost) } // Viewer band — anyone authenticated can read. diff --git a/internal/server/http/setup_handler.go b/internal/server/http/setup_handler.go new file mode 100644 index 0000000..d2ab3b2 --- /dev/null +++ b/internal/server/http/setup_handler.go @@ -0,0 +1,77 @@ +// 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 (lands in Task D2) +// +// The token in the querystring (`?token=`) is the credential. +// Auth middleware does not run on these routes. +package http + +import ( + "crypto/sha256" + "encoding/hex" + "log/slog" + stdhttp "net/http" + "time" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui" +) + +type setupPage struct { + Username string + Token string // round-tripped to the POST form + Error string // displayed when password validation fails or token is invalid +} + +// hashSetupToken is the canonical hashing for setup tokens. Must +// match 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) + _ = ui.User{} // keep ui import alive +} + +// handleUISetupPost is a stub — full implementation lands in Task D2. +func (s *Server) handleUISetupPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { + stdhttp.Error(w, "not implemented", stdhttp.StatusNotImplemented) +} diff --git a/internal/server/http/setup_test.go b/internal/server/http/setup_test.go new file mode 100644 index 0000000..739e782 --- /dev/null +++ b/internal/server/http/setup_test.go @@ -0,0 +1,90 @@ +package http + +import ( + "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 sha256Hex(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} + +func TestSetupGetValidToken(t *testing.T) { + t.Parallel() + // /setup renders HTML, so we need a real UI renderer. + srv, ts, _ := rawTestServerWithUI(t) + urlBase := ts.URL + 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(urlBase + "/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() + // /setup renders HTML, so we need a real UI renderer. + srv, ts, _ := rawTestServerWithUI(t) + urlBase := ts.URL + 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(urlBase + "/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) + } +} diff --git a/internal/server/ui/ui.go b/internal/server/ui/ui.go index d970b74..68f0e2e 100644 --- a/internal/server/ui/ui.go +++ b/internal/server/ui/ui.go @@ -152,7 +152,7 @@ func (r *Renderer) RenderPartial(w io.Writer, name string, data ViewData) error // chrome-less; everything else uses the standard navigation chrome. func layoutFor(page string) string { switch page { - case "login", "bootstrap": + case "login", "bootstrap", "setup": return "chromeless" default: return "base" diff --git a/web/templates/pages/setup.html b/web/templates/pages/setup.html new file mode 100644 index 0000000..ec8cdb2 --- /dev/null +++ b/web/templates/pages/setup.html @@ -0,0 +1,44 @@ +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{$page := .Page}} +
+ {{if eq $page.Error "expired"}} +

Link expired

+

+ This setup link has expired or is invalid. Setup links are valid + for one hour from the moment your administrator generates them. +

+

+ Contact your administrator and ask them to regenerate the link. +

+ {{else}} +

+ Welcome, {{$page.Username}} +

+

+ Pick a password to finish setting up your account. The link expires + one hour after your administrator generated it, so don't dawdle. +

+
+ +
+ + +
+
+ + +
+ +
+ {{if and $page.Error (ne $page.Error "expired")}} +

{{$page.Error}}

+ {{end}} + {{end}} +
+{{end}} From 57a13f07598bc305046392ae12ac95c099a41ce9 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:31:02 +0100 Subject: [PATCH 16/30] =?UTF-8?q?http:=20POST=20/setup=20=E2=80=94=20set?= =?UTF-8?q?=20password,=20drop=20session,=20audit=20setup=5Fcompleted?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 501 stub with the full handler: validates the token and password, hashes and stores the password, deletes the setup token, mints an 8-hour session cookie, appends a user.setup_completed audit entry, and redirects to /. Adds TestSetupPostHappyPath covering the full round-trip including normal-login verification after setup. --- internal/server/http/setup_handler.go | 100 +++++++++++++++++++++++++- internal/server/http/setup_test.go | 62 ++++++++++++++++ 2 files changed, 160 insertions(+), 2 deletions(-) diff --git a/internal/server/http/setup_handler.go b/internal/server/http/setup_handler.go index d2ab3b2..d5eb488 100644 --- a/internal/server/http/setup_handler.go +++ b/internal/server/http/setup_handler.go @@ -17,7 +17,11 @@ import ( 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/server/ui" + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) type setupPage struct { @@ -71,7 +75,99 @@ func (s *Server) renderSetupExpired(w stdhttp.ResponseWriter, r *stdhttp.Request _ = ui.User{} // keep ui import alive } -// handleUISetupPost is a stub — full implementation lands in Task D2. func (s *Server) handleUISetupPost(w stdhttp.ResponseWriter, r *stdhttp.Request) { - stdhttp.Error(w, "not implemented", stdhttp.StatusNotImplemented) + 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.NewToken() + 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" + 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) } diff --git a/internal/server/http/setup_test.go b/internal/server/http/setup_test.go index 739e782..ebb8a27 100644 --- a/internal/server/http/setup_test.go +++ b/internal/server/http/setup_test.go @@ -1,11 +1,14 @@ package http import ( + "bytes" "context" "crypto/sha256" "encoding/hex" + "encoding/json" "io" stdhttp "net/http" + "net/url" "strings" "testing" "time" @@ -88,3 +91,62 @@ func TestSetupGetExpiredToken(t *testing.T) { t.Errorf("status: got %d want 410", res.StatusCode) } } + +func TestSetupPostHappyPath(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + urlBase := ts.URL + 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)) + defer loginRes.Body.Close() + if loginRes.StatusCode != stdhttp.StatusOK { + body, _ := io.ReadAll(loginRes.Body) + t.Errorf("login: %d %s", loginRes.StatusCode, body) + } +} From a985d45daaa4bbc90a17d71f5d1c34a4a77f5c4f Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:34:11 +0100 Subject: [PATCH 17/30] http: GET /api/users (list) --- internal/server/http/api_users.go | 52 ++++++++++++++++++++++++++++++ internal/server/http/rbac_test.go | 4 --- internal/server/http/server.go | 1 + internal/server/http/users_test.go | 35 ++++++++++++++++++++ 4 files changed, 88 insertions(+), 4 deletions(-) create mode 100644 internal/server/http/api_users.go create mode 100644 internal/server/http/users_test.go diff --git a/internal/server/http/api_users.go b/internal/server/http/api_users.go new file mode 100644 index 0000000..9b0a3a8 --- /dev/null +++ b/internal/server/http/api_users.go @@ -0,0 +1,52 @@ +// api_users.go — JSON handlers for the user-management surface. +// +// All endpoints in this file are admin-only; gating happens at the +// route-mount site (server.go's admin band). +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}) +} diff --git a/internal/server/http/rbac_test.go b/internal/server/http/rbac_test.go index 42f6b44..9bffee6 100644 --- a/internal/server/http/rbac_test.go +++ b/internal/server/http/rbac_test.go @@ -144,10 +144,6 @@ func TestLoginRejectsDisabledUser(t *testing.T) { func TestAdminBandRejectsOperator(t *testing.T) { t.Parallel() - // This test will start asserting 403 once Task B4 mounts /api/users - // inside the admin band and Task E1 lands the handler. Until then, - // the route 404s — we skip rather than red-flag the suite. - t.Skip("re-enable after B4 route grouping + E1 /api/users handler land") srv, urlBase := newTestServer(t, false) makeUser(t, srv, "admin1", store.RoleAdmin) opID := makeUser(t, srv, "op1", store.RoleOperator) diff --git a/internal/server/http/server.go b/internal/server/http/server.go index a54b70a..921c0ac 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -251,6 +251,7 @@ func (s *Server) routes(r chi.Router) { r.Group(func(r chi.Router) { r.Use(s.requireRole(store.RoleAdmin)) + r.Get("/api/users", s.handleAPIUsersList) r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest) if s.deps.UI != nil { diff --git a/internal/server/http/users_test.go b/internal/server/http/users_test.go new file mode 100644 index 0000000..b5da56b --- /dev/null +++ b/internal/server/http/users_test.go @@ -0,0 +1,35 @@ +package http + +import ( + "encoding/json" + "io" + stdhttp "net/http" + "testing" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" +) + +func TestAPIUsersList(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + makeUser(t, srv, "op1", store.RoleOperator) + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("GET", ts.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 { + body, _ := io.ReadAll(res.Body) + t.Fatalf("status: got %d body=%s", res.StatusCode, body) + } + var got listUsersResponse + _ = json.NewDecoder(res.Body).Decode(&got) + if len(got.Users) != 2 { + t.Errorf("count: got %d want 2", len(got.Users)) + } +} From a74dc33c1c81edb1ecaa9f52e8342984ec313976 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:38:59 +0100 Subject: [PATCH 18/30] =?UTF-8?q?http:=20POST=20/api/users=20=E2=80=94=20c?= =?UTF-8?q?reate=20+=20setup-token=20+=20audit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/http/api_users.go | 132 +++++++++++++++++++++++++++++ internal/server/http/server.go | 1 + internal/server/http/users_test.go | 71 ++++++++++++++++ 3 files changed, 204 insertions(+) diff --git a/internal/server/http/api_users.go b/internal/server/http/api_users.go index 9b0a3a8..6bad2c1 100644 --- a/internal/server/http/api_users.go +++ b/internal/server/http/api_users.go @@ -5,9 +5,19 @@ package http import ( + "crypto/rand" + "encoding/hex" "encoding/json" + "errors" "log/slog" stdhttp "net/http" + "net/mail" + "strings" + "time" + + "github.com/oklog/ulid/v2" + + "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) type listUsersResponse struct { @@ -50,3 +60,125 @@ func (s *Server) handleAPIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(listUsersResponse{Users: out}) } + +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 +} + +// validRole maps a wire role string to the typed constant. Returns +// ("", false) for anything unknown. +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, + }) +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 921c0ac..15fe5a9 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -252,6 +252,7 @@ func (s *Server) routes(r chi.Router) { r.Use(s.requireRole(store.RoleAdmin)) r.Get("/api/users", s.handleAPIUsersList) + r.Post("/api/users", s.handleAPIUserCreate) r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest) if s.deps.UI != nil { diff --git a/internal/server/http/users_test.go b/internal/server/http/users_test.go index b5da56b..f55567a 100644 --- a/internal/server/http/users_test.go +++ b/internal/server/http/users_test.go @@ -1,9 +1,11 @@ package http import ( + "bytes" "encoding/json" "io" stdhttp "net/http" + "strings" "testing" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" @@ -33,3 +35,72 @@ func TestAPIUsersList(t *testing.T) { t.Errorf("count: got %d want 2", len(got.Users)) } } + +func TestAPIUserCreate(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + 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", ts.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.Fatalf("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, ts, _ := rawTestServerWithUI(t) + 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", ts.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.StatusConflict { + t.Errorf("status: got %d want 409", res.StatusCode) + } +} From cd3c13e2c683f31d3f69a37b27fb5c3b2f9ce926 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:41:32 +0100 Subject: [PATCH 19/30] http: GET/PATCH /api/users/{id} with last-admin guard --- internal/server/http/api_users.go | 88 ++++++++++++++++++++++++++++++ internal/server/http/server.go | 2 + internal/server/http/users_test.go | 70 ++++++++++++++++++++++++ 3 files changed, 160 insertions(+) diff --git a/internal/server/http/api_users.go b/internal/server/http/api_users.go index 6bad2c1..9b38e93 100644 --- a/internal/server/http/api_users.go +++ b/internal/server/http/api_users.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/go-chi/chi/v5" "github.com/oklog/ulid/v2" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" @@ -182,3 +183,90 @@ func (s *Server) handleAPIUserCreate(w stdhttp.ResponseWriter, r *stdhttp.Reques SetupURL: s.deps.Cfg.BaseURL + "/setup?token=" + rawToken, }) } + +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 { + ll := u.LastLoginAt.UTC().Format("2006-01-02T15:04:05Z") + out.LastLoginAt = &ll + } + 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) +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 15fe5a9..3ab89cc 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -253,6 +253,8 @@ func (s *Server) routes(r chi.Router) { 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/notifications/{id}/test", s.handleAPINotificationTest) if s.deps.UI != nil { diff --git a/internal/server/http/users_test.go b/internal/server/http/users_test.go index f55567a..376fcfb 100644 --- a/internal/server/http/users_test.go +++ b/internal/server/http/users_test.go @@ -104,3 +104,73 @@ func TestAPIUserCreateRejectsDuplicateEnabled(t *testing.T) { t.Errorf("status: got %d want 409", res.StatusCode) } } + +func TestAPIUserGet(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + target := makeUser(t, srv, "carol", store.RoleViewer) + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("GET", ts.URL+"/api/users/"+target, 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) + } +} + +func TestAPIUserPatchRoleAndEmail(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + 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", ts.URL+"/api/users/"+target, bytes.NewReader(body)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PATCH: %v", err) + } + 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, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + cookie := loginAs(t, srv, adminID) + + body, _ := json.Marshal(map[string]any{"role": "viewer"}) + req, _ := stdhttp.NewRequest("PATCH", ts.URL+"/api/users/"+adminID, bytes.NewReader(body)) + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("PATCH: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusConflict { + t.Errorf("status: got %d want 409", res.StatusCode) + } +} From 90bcddb27e54e2d9a1903bf84887a467e45655f8 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:44:15 +0100 Subject: [PATCH 20/30] http: disable/enable user with last-admin guard + session kick --- internal/server/http/api_users.go | 53 ++++++++++++++++++++++++++++++ internal/server/http/server.go | 2 ++ internal/server/http/users_test.go | 42 +++++++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/internal/server/http/api_users.go b/internal/server/http/api_users.go index 9b38e93..00c7c23 100644 --- a/internal/server/http/api_users.go +++ b/internal/server/http/api_users.go @@ -270,3 +270,56 @@ func (s *Server) handleAPIUserPatch(w stdhttp.ResponseWriter, r *stdhttp.Request }) w.WriteHeader(stdhttp.StatusOK) } + +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) +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 3ab89cc..5d79eb9 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -255,6 +255,8 @@ func (s *Server) routes(r chi.Router) { 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/notifications/{id}/test", s.handleAPINotificationTest) if s.deps.UI != nil { diff --git a/internal/server/http/users_test.go b/internal/server/http/users_test.go index 376fcfb..3f91b2e 100644 --- a/internal/server/http/users_test.go +++ b/internal/server/http/users_test.go @@ -174,3 +174,45 @@ func TestAPIUserPatchRejectsLastAdminDemote(t *testing.T) { t.Errorf("status: got %d want 409", res.StatusCode) } } + +func TestAPIUserDisable(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + 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", ts.URL+"/api/users/"+target+"/disable", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + 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, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users/"+adminID+"/disable", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusConflict { + t.Errorf("status: got %d want 409", res.StatusCode) + } +} From dbb8550936881d5e44ea74a7cc9a846a37a4ca26 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:48:13 +0100 Subject: [PATCH 21/30] http: regenerate setup link + force-logout --- internal/server/http/api_users.go | 66 ++++++++++++++++++++++++++++++ internal/server/http/server.go | 2 + internal/server/http/users_test.go | 59 ++++++++++++++++++++++++++ 3 files changed, 127 insertions(+) diff --git a/internal/server/http/api_users.go b/internal/server/http/api_users.go index 00c7c23..48086a5 100644 --- a/internal/server/http/api_users.go +++ b/internal/server/http/api_users.go @@ -323,3 +323,69 @@ func (s *Server) handleAPIUserEnable(w stdhttp.ResponseWriter, r *stdhttp.Reques }) w.WriteHeader(stdhttp.StatusOK) } + +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}) +} diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 5d79eb9..746cf9c 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -257,6 +257,8 @@ func (s *Server) routes(r chi.Router) { 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) r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest) if s.deps.UI != nil { diff --git a/internal/server/http/users_test.go b/internal/server/http/users_test.go index 3f91b2e..e3ddeab 100644 --- a/internal/server/http/users_test.go +++ b/internal/server/http/users_test.go @@ -7,6 +7,7 @@ import ( stdhttp "net/http" "strings" "testing" + "time" "gitea.dcglab.co.uk/steve/restic-manager/internal/store" ) @@ -216,3 +217,61 @@ func TestAPIUserDisableRejectsLastAdmin(t *testing.T) { t.Errorf("status: got %d want 409", res.StatusCode) } } + +func TestAPIUserRegenerateSetup(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + target := makeUser(t, srv, "newbie", store.RoleViewer) + _ = 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", ts.URL+"/api/users/"+target+"/regenerate-setup", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + 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) + } + 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, ts, _ := rawTestServerWithUI(t) + adminID := makeUser(t, srv, "admin1", store.RoleAdmin) + target := makeUser(t, srv, "victim", store.RoleOperator) + loginAs(t, srv, target) // create a session for the victim + cookie := loginAs(t, srv, adminID) + + req, _ := stdhttp.NewRequest("POST", ts.URL+"/api/users/"+target+"/force-logout", nil) + req.AddCookie(cookie) + res, err := stdhttp.DefaultClient.Do(req) + if err != nil { + t.Fatalf("POST: %v", err) + } + defer res.Body.Close() + if res.StatusCode != stdhttp.StatusOK { + t.Errorf("status: got %d", res.StatusCode) + } + rr, _ := srv.deps.Store.DeleteSessionsByUserID(t.Context(), target) + if rr != 0 { + t.Errorf("expected 0 remaining sessions, got %d", rr) + } +} From cae4147df6a3f2ad52510bc0294c73c6e6ca34e1 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:52:10 +0100 Subject: [PATCH 22/30] =?UTF-8?q?http:=20POST=20/api/account/password=20?= =?UTF-8?q?=E2=80=94=20self-service=20password=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/http/server.go | 1 + internal/server/http/ui_account.go | 66 ++++++++++++++++++++++++++++++ internal/server/http/users_test.go | 24 +++++++++++ 3 files changed, 91 insertions(+) create mode 100644 internal/server/http/ui_account.go diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 746cf9c..7cfac7c 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -157,6 +157,7 @@ func (s *Server) routes(r chi.Router) { r.Get("/api/hosts/{id}/repo-maintenance", s.handleGetRepoMaintenance) r.Get("/api/alerts", s.handleAPIAlerts) r.Get("/api/audit", s.handleAPIAudit) + r.Post("/api/account/password", s.handleAPIAccountPassword) // Job log stream + download (read-only; any authenticated user). if s.deps.JobHub != nil { diff --git a/internal/server/http/ui_account.go b/internal/server/http/ui_account.go new file mode 100644 index 0000000..d23483e --- /dev/null +++ b/internal/server/http/ui_account.go @@ -0,0 +1,66 @@ +// ui_account.go — self-service account surface (password change). +// +// Routes (wired in server.go): +// +// POST /api/account/password — JSON change-password (mounted in viewer band) +// GET /settings/account — page (lands in Task F4) +// POST /settings/account — page submit (lands in Task F4) +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 (only matters for the + // legacy reset-password path; setup-token path doesn't use this). + if !u.MustChangePassword { + if err := auth.VerifyPassword(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) +} diff --git a/internal/server/http/users_test.go b/internal/server/http/users_test.go index e3ddeab..daa094b 100644 --- a/internal/server/http/users_test.go +++ b/internal/server/http/users_test.go @@ -275,3 +275,27 @@ func TestAPIUserForceLogout(t *testing.T) { t.Errorf("expected 0 remaining sessions, got %d", rr) } } + +func TestAPIAccountPasswordChange(t *testing.T) { + t.Parallel() + srv, ts, _ := rawTestServerWithUI(t) + 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", ts.URL+"/api/account/password", 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.StatusOK { + body, _ := io.ReadAll(res.Body) + t.Errorf("status: got %d body=%s", res.StatusCode, body) + } +} From 88f1959a6aaeb33f57b8d397e1ef191afb658ede Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:55:31 +0100 Subject: [PATCH 23/30] ui: /settings/users list page --- internal/server/http/server.go | 1 + internal/server/http/ui_users.go | 76 +++++++++++++++++++++++++++++++ web/static/css/styles.css | 2 +- web/styles/input.css | 19 ++++++++ web/templates/pages/settings.html | 2 +- web/templates/pages/users.html | 62 +++++++++++++++++++++++++ 6 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 internal/server/http/ui_users.go create mode 100644 web/templates/pages/users.html diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 7cfac7c..0f73afc 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -264,6 +264,7 @@ func (s *Server) routes(r chi.Router) { if s.deps.UI != nil { r.Get("/settings", s.handleUISettings) + r.Get("/settings/users", s.handleUIUsersList) r.Get("/settings/notifications", s.handleUINotificationsList) r.Get("/settings/notifications/new", s.handleUINotificationNewGet) r.Post("/settings/notifications/new", s.handleUINotificationNewPost) diff --git a/internal/server/http/ui_users.go b/internal/server/http/ui_users.go new file mode 100644 index 0000000..8ff8e5f --- /dev/null +++ b/internal/server/http/ui_users.go @@ -0,0 +1,76 @@ +// ui_users.go — Settings → Users HTML handlers (admin-only). +// +// Routes (wired in server.go's admin band): +// +// GET /settings/users → handleUIUsersList (this task) +// GET /settings/users/new → F2 +// POST /settings/users/new → F2 +// GET /settings/users/{id}/edit → F3 +// POST /settings/users/{id}/edit → F3 +// GET /settings/users/{id}/setup-link → F2 +// POST /settings/users/{id}/disable → F3 +// POST /settings/users/{id}/enable → F3 +// POST /settings/users/{id}/regenerate-setup → F3 +// POST /settings/users/{id}/force-logout → F3 +package http + +import ( + "log/slog" + stdhttp "net/http" +) + +type usersPage struct { + Users []userRow + ShowDisabled bool +} + +type userRow struct { + ID string + Username string + Email string + Role string + LastLoginAt string // pre-formatted "2006-01-02 15:04:05" or "never" + 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 + } + ll := "never" + if ux.LastLoginAt != nil { + ll = ux.LastLoginAt.UTC().Format("2006-01-02 15:04:05") + } + rows = append(rows, userRow{ + ID: ux.ID, Username: ux.Username, Email: em, + Role: string(ux.Role), LastLoginAt: ll, + 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) + } +} diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 5a8e3cd..1113b73 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.dropdown{display:inline-block;position:relative}.dropdown summary{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;list-style:none;padding:6px 11px;transition:all .12s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.dropdown summary::-webkit-details-marker{display:none}.dropdown summary::marker{content:""}.dropdown summary:hover{background:var(--panel-hi);color:var(--ink)}.dropdown summary .chev{color:var(--ink-fade);font-size:9px;transition:transform .12s ease}.dropdown[open] summary .chev{transform:rotate(180deg)}.dropdown[open] summary{background:var(--panel-hi);color:var(--ink)}.dropdown-menu{background:var(--panel);border:1px solid var(--line);border-radius:6px;box-shadow:0 6px 24px -8px rgba(0,0,0,.55);min-width:220px;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:30}.dropdown-item{border-radius:4px;color:var(--ink-mid);display:block;font-size:12.5px;line-height:1.35;padding:8px 11px;text-decoration:none}.dropdown-item:hover{background:var(--panel-hi);color:var(--ink)}.dropdown-item .label{color:var(--ink);display:block;font-weight:500}.dropdown-item .hint{color:var(--ink-mute);display:block;font-family:JetBrains Mono,ui-monospace,monospace;font-size:11px;margin-top:2px}.snap-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;cursor:pointer;display:grid;font-size:13px;grid-template-columns:150px 130px 1fr 90px 130px 80px;padding:11px 14px;transition:background .1s ease}.snap-row:last-child{border-bottom:0}.snap-row:hover{background:var(--panel-hi)}.snap-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.snap-row.head:hover{background:transparent}.alert-row{align-items:center;border-bottom:1px solid var(--line-soft);border-left:3px solid transparent;-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:18px 110px 130px 1fr 130px 110px 180px;padding:12px 16px;transition:background .1s ease}.alert-row:hover{background:var(--panel-hi)}.alert-row:last-child{border-bottom:0}.alert-row.head{border-left-color:transparent;color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.alert-row.head:hover{background:transparent}.alert-row.severity-warn{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.alert-row.severity-critical{border-left-color:color-mix(in oklch,var(--bad),transparent 30%)}.alert-row.resolved{opacity:.55}.dot-critical{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.tag-warn{background:color-mix(in oklch,var(--warn),transparent 92%);border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.tag-critical{background:color-mix(in oklch,var(--bad),transparent 92%);border-color:color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.tag-info{color:var(--ink-mid)}.audit-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:160px 80px 110px 1.4fr 1.5fr 90px;padding:11px 16px;transition:background .1s ease}.audit-row:hover{background:var(--panel-hi)}.audit-row:last-child{border-bottom:0}.audit-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.audit-row.head:hover{background:transparent}.audit-row.head .sort-header{align-items:baseline;color:inherit;cursor:pointer;display:inline-flex;gap:4px;text-decoration:none}.audit-row.head .sort-header:hover{color:var(--ink)}.audit-row.head .sort-glyph{color:var(--accent);display:inline-block;font-size:9px;min-width:8px}.schd-row{align-items:center;-moz-column-gap:14px;column-gap:14px;display:grid;font-size:13px;grid-template-columns:78px 1fr 1.6fr 100px 110px auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.ch-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:28px 200px 1fr 100px 130px 140px;padding:14px 18px;transition:background .1s ease}.ch-row:last-child{border-bottom:0}.ch-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.ch-row.head:hover{background:transparent}.ch-row.clickable{cursor:pointer;position:relative}.ch-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.ch-row.clickable:hover{background:var(--panel-hi)}.ch-row.clickable>*{pointer-events:none;position:relative;z-index:1}.ch-row.clickable>.row-action,.ch-row.clickable>.row-link{pointer-events:auto}.ch-icon{align-items:center;background:var(--panel-hi);border:1px solid var(--line);border-radius:5px;color:var(--ink-mute);display:inline-flex;font-family:JetBrains Mono,monospace;font-size:10px;font-weight:600;height:24px;justify-content:center;width:24px}.ch-icon.webhook{border-color:color-mix(in oklch,var(--accent),transparent 60%);color:var(--accent)}.ch-icon.ntfy{border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.ch-icon.smtp{border-color:color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.toggle{background:var(--line);border-radius:9999px;cursor:pointer;display:inline-block;flex-shrink:0;height:16px;position:relative;transition:background .12s ease;width:30px}.toggle:after{background:var(--ink-mid);border-radius:9999px;content:"";height:12px;left:2px;position:absolute;top:2px;transition:all .12s ease;width:12px}.toggle.on{background:color-mix(in oklch,var(--accent),transparent 50%)}.toggle.on:after{background:var(--accent);left:16px}.kind-grid{display:grid;gap:14px;grid-template-columns:1fr 1fr 1fr}.kind-card{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;cursor:pointer;padding:16px;transition:border-color .12s ease,background .12s ease}.kind-card:hover{border-color:var(--ink-mute)}.kind-card.selected{background:color-mix(in oklch,var(--accent),transparent 95%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.radio-pip{align-items:center;border:1px solid var(--line);border-radius:9999px;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.radio-pip.on{border-color:var(--accent)}.radio-pip.on:after{background:var(--accent);border-radius:9999px;content:"";height:6px;width:6px}.test-pill{border-radius:5px;display:inline-block;font-size:12.5px;padding:5px 10px}.test-pill-ok{background:color-mix(in oklch,var(--ok),transparent 92%);border:1px solid color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.test-pill-fail{background:color-mix(in oklch,var(--bad),transparent 92%);border:1px solid color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-7{padding:1.75rem}.p-\[18px\]{padding:18px}.p-\[3px\]{padding:3px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-40{opacity:.4}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)} +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.dropdown{display:inline-block;position:relative}.dropdown summary{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;list-style:none;padding:6px 11px;transition:all .12s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.dropdown summary::-webkit-details-marker{display:none}.dropdown summary::marker{content:""}.dropdown summary:hover{background:var(--panel-hi);color:var(--ink)}.dropdown summary .chev{color:var(--ink-fade);font-size:9px;transition:transform .12s ease}.dropdown[open] summary .chev{transform:rotate(180deg)}.dropdown[open] summary{background:var(--panel-hi);color:var(--ink)}.dropdown-menu{background:var(--panel);border:1px solid var(--line);border-radius:6px;box-shadow:0 6px 24px -8px rgba(0,0,0,.55);min-width:220px;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:30}.dropdown-item{border-radius:4px;color:var(--ink-mid);display:block;font-size:12.5px;line-height:1.35;padding:8px 11px;text-decoration:none}.dropdown-item:hover{background:var(--panel-hi);color:var(--ink)}.dropdown-item .label{color:var(--ink);display:block;font-weight:500}.dropdown-item .hint{color:var(--ink-mute);display:block;font-family:JetBrains Mono,ui-monospace,monospace;font-size:11px;margin-top:2px}.snap-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;cursor:pointer;display:grid;font-size:13px;grid-template-columns:150px 130px 1fr 90px 130px 80px;padding:11px 14px;transition:background .1s ease}.snap-row:last-child{border-bottom:0}.snap-row:hover{background:var(--panel-hi)}.snap-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.snap-row.head:hover{background:transparent}.alert-row{align-items:center;border-bottom:1px solid var(--line-soft);border-left:3px solid transparent;-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:18px 110px 130px 1fr 130px 110px 180px;padding:12px 16px;transition:background .1s ease}.alert-row:hover{background:var(--panel-hi)}.alert-row:last-child{border-bottom:0}.alert-row.head{border-left-color:transparent;color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.alert-row.head:hover{background:transparent}.alert-row.severity-warn{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.alert-row.severity-critical{border-left-color:color-mix(in oklch,var(--bad),transparent 30%)}.alert-row.resolved{opacity:.55}.dot-critical{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.tag-warn{background:color-mix(in oklch,var(--warn),transparent 92%);border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.tag-critical{background:color-mix(in oklch,var(--bad),transparent 92%);border-color:color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.tag-info{color:var(--ink-mid)}.audit-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:160px 80px 110px 1.4fr 1.5fr 90px;padding:11px 16px;transition:background .1s ease}.audit-row:hover{background:var(--panel-hi)}.audit-row:last-child{border-bottom:0}.audit-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.audit-row.head:hover{background:transparent}.audit-row.head .sort-header{align-items:baseline;color:inherit;cursor:pointer;display:inline-flex;gap:4px;text-decoration:none}.audit-row.head .sort-header:hover{color:var(--ink)}.audit-row.head .sort-glyph{color:var(--accent);display:inline-block;font-size:9px;min-width:8px}.schd-row{align-items:center;-moz-column-gap:14px;column-gap:14px;display:grid;font-size:13px;grid-template-columns:78px 1fr 1.6fr 100px 110px auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.ch-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:28px 200px 1fr 100px 130px 140px;padding:14px 18px;transition:background .1s ease}.ch-row:last-child{border-bottom:0}.ch-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.ch-row.head:hover{background:transparent}.ch-row.clickable{cursor:pointer;position:relative}.ch-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.ch-row.clickable:hover{background:var(--panel-hi)}.ch-row.clickable>*{pointer-events:none;position:relative;z-index:1}.ch-row.clickable>.row-action,.ch-row.clickable>.row-link{pointer-events:auto}.ch-icon{align-items:center;background:var(--panel-hi);border:1px solid var(--line);border-radius:5px;color:var(--ink-mute);display:inline-flex;font-family:JetBrains Mono,monospace;font-size:10px;font-weight:600;height:24px;justify-content:center;width:24px}.ch-icon.webhook{border-color:color-mix(in oklch,var(--accent),transparent 60%);color:var(--accent)}.ch-icon.ntfy{border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.ch-icon.smtp{border-color:color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.toggle{background:var(--line);border-radius:9999px;cursor:pointer;display:inline-block;flex-shrink:0;height:16px;position:relative;transition:background .12s ease;width:30px}.toggle:after{background:var(--ink-mid);border-radius:9999px;content:"";height:12px;left:2px;position:absolute;top:2px;transition:all .12s ease;width:12px}.toggle.on{background:color-mix(in oklch,var(--accent),transparent 50%)}.toggle.on:after{background:var(--accent);left:16px}.kind-grid{display:grid;gap:14px;grid-template-columns:1fr 1fr 1fr}.kind-card{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;cursor:pointer;padding:16px;transition:border-color .12s ease,background .12s ease}.kind-card:hover{border-color:var(--ink-mute)}.kind-card.selected{background:color-mix(in oklch,var(--accent),transparent 95%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.radio-pip{align-items:center;border:1px solid var(--line);border-radius:9999px;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.radio-pip.on{border-color:var(--accent)}.radio-pip.on:after{background:var(--accent);border-radius:9999px;content:"";height:6px;width:6px}.user-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:180px 1fr 110px 160px 120px 90px;padding:11px 16px;transition:background .1s ease}.user-row:hover{background:var(--panel-hi)}.user-row:last-child{border-bottom:0}.user-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.user-row.head:hover{background:transparent}.user-row.disabled{opacity:.55}.test-pill{border-radius:5px;display:inline-block;font-size:12.5px;padding:5px 10px}.test-pill-ok{background:color-mix(in oklch,var(--ok),transparent 92%);border:1px solid color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.test-pill-fail{background:color-mix(in oklch,var(--bad),transparent 92%);border:1px solid color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.p-\[18px\]{padding:18px}.p-\[3px\]{padding:3px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-40{opacity:.4}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)}.hover\:underline:hover{text-decoration-line:underline} diff --git a/web/styles/input.css b/web/styles/input.css index 997820b..22e9999 100644 --- a/web/styles/input.css +++ b/web/styles/input.css @@ -563,6 +563,25 @@ background: var(--accent); } + /* ---------- user-management rows (/settings/users) ---------- */ + .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; } + /* ---------- test-result pills (notification test button) ---------- */ .test-pill { display: inline-block; diff --git a/web/templates/pages/settings.html b/web/templates/pages/settings.html index a9cea0d..f7c1ae2 100644 --- a/web/templates/pages/settings.html +++ b/web/templates/pages/settings.html @@ -39,7 +39,7 @@ Notifications {{if not $page.Form}}{{len $page.Channels}}{{end}} - Users + Users Authentication diff --git a/web/templates/pages/users.html b/web/templates/pages/users.html new file mode 100644 index 0000000..38b330a --- /dev/null +++ b/web/templates/pages/users.html @@ -0,0 +1,62 @@ +{{define "title"}}Users · restic-manager{{end}} + +{{define "content"}} +{{$page := .Page}} +
+
+ Dashboard/ + Settings/ + users +
+ +
+

+ Users + {{len $page.Users}} +

+ +
+ +
+ +
+ +
+
+
Username
+
Email
+
Role
+
Last login
+
Status
+
+
+ {{range $page.Users}} +
+ +
{{if .Email}}{{.Email}}{{else}}{{end}}
+
{{.Role}}
+
+ {{if eq .LastLoginAt "never"}}never{{else}}{{.LastLoginAt}}{{end}} +
+
+ {{if .Disabled}}disabled + {{else if .MustChangePassword}}setup pending + {{else}}enabled{{end}} +
+
+ Edit +
+
+ {{end}} +
+
+{{end}} From b0a5a76925dd27044c124d040341f550f3f3a452 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 09:59:20 +0100 Subject: [PATCH 24/30] ui: /settings/users/new + /setup-link page Adds handleUIUserNewGet, handleUIUserNewPost, handleUIUserSetupLinkGet to ui_users.go; creates web/templates/pages/user_edit.html (multi-mode new/edit/setup-link); wires three routes in the admin band of server.go. --- internal/server/http/server.go | 3 + internal/server/http/ui_users.go | 172 +++++++++++++++++++++++++++++ web/static/css/styles.css | 2 +- web/templates/pages/user_edit.html | 105 ++++++++++++++++++ 4 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 web/templates/pages/user_edit.html diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 0f73afc..f6fea31 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -265,6 +265,9 @@ func (s *Server) routes(r chi.Router) { if s.deps.UI != nil { r.Get("/settings", s.handleUISettings) 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}/setup-link", s.handleUIUserSetupLinkGet) r.Get("/settings/notifications", s.handleUINotificationsList) r.Get("/settings/notifications/new", s.handleUINotificationNewGet) r.Post("/settings/notifications/new", s.handleUINotificationNewPost) diff --git a/internal/server/http/ui_users.go b/internal/server/http/ui_users.go index 8ff8e5f..fe685cd 100644 --- a/internal/server/http/ui_users.go +++ b/internal/server/http/ui_users.go @@ -15,8 +15,17 @@ package http import ( + "errors" "log/slog" stdhttp "net/http" + "net/mail" + "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 { @@ -74,3 +83,166 @@ func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) slog.Error("ui users: render", "err", err) } } + +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 + } + if email != "" { + if _, err := mail.ParseAddress(email); err != nil { + 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: "Email is not a valid address.", + } + _ = s.deps.UI.Render(w, "user_edit", view) + return + } + } + + // Same collision logic as the API. + 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, + }) + stdhttp.Redirect(w, r, + "/settings/users/"+id+"/setup-link?token="+rawToken, + stdhttp.StatusSeeOther) +} + +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 == "" { + 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) +} diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 1113b73..27094e4 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.dropdown{display:inline-block;position:relative}.dropdown summary{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;list-style:none;padding:6px 11px;transition:all .12s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.dropdown summary::-webkit-details-marker{display:none}.dropdown summary::marker{content:""}.dropdown summary:hover{background:var(--panel-hi);color:var(--ink)}.dropdown summary .chev{color:var(--ink-fade);font-size:9px;transition:transform .12s ease}.dropdown[open] summary .chev{transform:rotate(180deg)}.dropdown[open] summary{background:var(--panel-hi);color:var(--ink)}.dropdown-menu{background:var(--panel);border:1px solid var(--line);border-radius:6px;box-shadow:0 6px 24px -8px rgba(0,0,0,.55);min-width:220px;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:30}.dropdown-item{border-radius:4px;color:var(--ink-mid);display:block;font-size:12.5px;line-height:1.35;padding:8px 11px;text-decoration:none}.dropdown-item:hover{background:var(--panel-hi);color:var(--ink)}.dropdown-item .label{color:var(--ink);display:block;font-weight:500}.dropdown-item .hint{color:var(--ink-mute);display:block;font-family:JetBrains Mono,ui-monospace,monospace;font-size:11px;margin-top:2px}.snap-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;cursor:pointer;display:grid;font-size:13px;grid-template-columns:150px 130px 1fr 90px 130px 80px;padding:11px 14px;transition:background .1s ease}.snap-row:last-child{border-bottom:0}.snap-row:hover{background:var(--panel-hi)}.snap-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.snap-row.head:hover{background:transparent}.alert-row{align-items:center;border-bottom:1px solid var(--line-soft);border-left:3px solid transparent;-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:18px 110px 130px 1fr 130px 110px 180px;padding:12px 16px;transition:background .1s ease}.alert-row:hover{background:var(--panel-hi)}.alert-row:last-child{border-bottom:0}.alert-row.head{border-left-color:transparent;color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.alert-row.head:hover{background:transparent}.alert-row.severity-warn{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.alert-row.severity-critical{border-left-color:color-mix(in oklch,var(--bad),transparent 30%)}.alert-row.resolved{opacity:.55}.dot-critical{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.tag-warn{background:color-mix(in oklch,var(--warn),transparent 92%);border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.tag-critical{background:color-mix(in oklch,var(--bad),transparent 92%);border-color:color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.tag-info{color:var(--ink-mid)}.audit-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:160px 80px 110px 1.4fr 1.5fr 90px;padding:11px 16px;transition:background .1s ease}.audit-row:hover{background:var(--panel-hi)}.audit-row:last-child{border-bottom:0}.audit-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.audit-row.head:hover{background:transparent}.audit-row.head .sort-header{align-items:baseline;color:inherit;cursor:pointer;display:inline-flex;gap:4px;text-decoration:none}.audit-row.head .sort-header:hover{color:var(--ink)}.audit-row.head .sort-glyph{color:var(--accent);display:inline-block;font-size:9px;min-width:8px}.schd-row{align-items:center;-moz-column-gap:14px;column-gap:14px;display:grid;font-size:13px;grid-template-columns:78px 1fr 1.6fr 100px 110px auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.ch-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:28px 200px 1fr 100px 130px 140px;padding:14px 18px;transition:background .1s ease}.ch-row:last-child{border-bottom:0}.ch-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.ch-row.head:hover{background:transparent}.ch-row.clickable{cursor:pointer;position:relative}.ch-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.ch-row.clickable:hover{background:var(--panel-hi)}.ch-row.clickable>*{pointer-events:none;position:relative;z-index:1}.ch-row.clickable>.row-action,.ch-row.clickable>.row-link{pointer-events:auto}.ch-icon{align-items:center;background:var(--panel-hi);border:1px solid var(--line);border-radius:5px;color:var(--ink-mute);display:inline-flex;font-family:JetBrains Mono,monospace;font-size:10px;font-weight:600;height:24px;justify-content:center;width:24px}.ch-icon.webhook{border-color:color-mix(in oklch,var(--accent),transparent 60%);color:var(--accent)}.ch-icon.ntfy{border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.ch-icon.smtp{border-color:color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.toggle{background:var(--line);border-radius:9999px;cursor:pointer;display:inline-block;flex-shrink:0;height:16px;position:relative;transition:background .12s ease;width:30px}.toggle:after{background:var(--ink-mid);border-radius:9999px;content:"";height:12px;left:2px;position:absolute;top:2px;transition:all .12s ease;width:12px}.toggle.on{background:color-mix(in oklch,var(--accent),transparent 50%)}.toggle.on:after{background:var(--accent);left:16px}.kind-grid{display:grid;gap:14px;grid-template-columns:1fr 1fr 1fr}.kind-card{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;cursor:pointer;padding:16px;transition:border-color .12s ease,background .12s ease}.kind-card:hover{border-color:var(--ink-mute)}.kind-card.selected{background:color-mix(in oklch,var(--accent),transparent 95%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.radio-pip{align-items:center;border:1px solid var(--line);border-radius:9999px;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.radio-pip.on{border-color:var(--accent)}.radio-pip.on:after{background:var(--accent);border-radius:9999px;content:"";height:6px;width:6px}.user-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:180px 1fr 110px 160px 120px 90px;padding:11px 16px;transition:background .1s ease}.user-row:hover{background:var(--panel-hi)}.user-row:last-child{border-bottom:0}.user-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.user-row.head:hover{background:transparent}.user-row.disabled{opacity:.55}.test-pill{border-radius:5px;display:inline-block;font-size:12.5px;padding:5px 10px}.test-pill-ok{background:color-mix(in oklch,var(--ok),transparent 92%);border:1px solid color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.test-pill-fail{background:color-mix(in oklch,var(--bad),transparent 92%);border:1px solid color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.p-\[18px\]{padding:18px}.p-\[3px\]{padding:3px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-40{opacity:.4}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)}.hover\:underline:hover{text-decoration-line:underline} +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.dropdown{display:inline-block;position:relative}.dropdown summary{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;list-style:none;padding:6px 11px;transition:all .12s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.dropdown summary::-webkit-details-marker{display:none}.dropdown summary::marker{content:""}.dropdown summary:hover{background:var(--panel-hi);color:var(--ink)}.dropdown summary .chev{color:var(--ink-fade);font-size:9px;transition:transform .12s ease}.dropdown[open] summary .chev{transform:rotate(180deg)}.dropdown[open] summary{background:var(--panel-hi);color:var(--ink)}.dropdown-menu{background:var(--panel);border:1px solid var(--line);border-radius:6px;box-shadow:0 6px 24px -8px rgba(0,0,0,.55);min-width:220px;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:30}.dropdown-item{border-radius:4px;color:var(--ink-mid);display:block;font-size:12.5px;line-height:1.35;padding:8px 11px;text-decoration:none}.dropdown-item:hover{background:var(--panel-hi);color:var(--ink)}.dropdown-item .label{color:var(--ink);display:block;font-weight:500}.dropdown-item .hint{color:var(--ink-mute);display:block;font-family:JetBrains Mono,ui-monospace,monospace;font-size:11px;margin-top:2px}.snap-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;cursor:pointer;display:grid;font-size:13px;grid-template-columns:150px 130px 1fr 90px 130px 80px;padding:11px 14px;transition:background .1s ease}.snap-row:last-child{border-bottom:0}.snap-row:hover{background:var(--panel-hi)}.snap-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.snap-row.head:hover{background:transparent}.alert-row{align-items:center;border-bottom:1px solid var(--line-soft);border-left:3px solid transparent;-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:18px 110px 130px 1fr 130px 110px 180px;padding:12px 16px;transition:background .1s ease}.alert-row:hover{background:var(--panel-hi)}.alert-row:last-child{border-bottom:0}.alert-row.head{border-left-color:transparent;color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.alert-row.head:hover{background:transparent}.alert-row.severity-warn{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.alert-row.severity-critical{border-left-color:color-mix(in oklch,var(--bad),transparent 30%)}.alert-row.resolved{opacity:.55}.dot-critical{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.tag-warn{background:color-mix(in oklch,var(--warn),transparent 92%);border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.tag-critical{background:color-mix(in oklch,var(--bad),transparent 92%);border-color:color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.tag-info{color:var(--ink-mid)}.audit-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:160px 80px 110px 1.4fr 1.5fr 90px;padding:11px 16px;transition:background .1s ease}.audit-row:hover{background:var(--panel-hi)}.audit-row:last-child{border-bottom:0}.audit-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.audit-row.head:hover{background:transparent}.audit-row.head .sort-header{align-items:baseline;color:inherit;cursor:pointer;display:inline-flex;gap:4px;text-decoration:none}.audit-row.head .sort-header:hover{color:var(--ink)}.audit-row.head .sort-glyph{color:var(--accent);display:inline-block;font-size:9px;min-width:8px}.schd-row{align-items:center;-moz-column-gap:14px;column-gap:14px;display:grid;font-size:13px;grid-template-columns:78px 1fr 1.6fr 100px 110px auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.ch-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:28px 200px 1fr 100px 130px 140px;padding:14px 18px;transition:background .1s ease}.ch-row:last-child{border-bottom:0}.ch-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.ch-row.head:hover{background:transparent}.ch-row.clickable{cursor:pointer;position:relative}.ch-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.ch-row.clickable:hover{background:var(--panel-hi)}.ch-row.clickable>*{pointer-events:none;position:relative;z-index:1}.ch-row.clickable>.row-action,.ch-row.clickable>.row-link{pointer-events:auto}.ch-icon{align-items:center;background:var(--panel-hi);border:1px solid var(--line);border-radius:5px;color:var(--ink-mute);display:inline-flex;font-family:JetBrains Mono,monospace;font-size:10px;font-weight:600;height:24px;justify-content:center;width:24px}.ch-icon.webhook{border-color:color-mix(in oklch,var(--accent),transparent 60%);color:var(--accent)}.ch-icon.ntfy{border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.ch-icon.smtp{border-color:color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.toggle{background:var(--line);border-radius:9999px;cursor:pointer;display:inline-block;flex-shrink:0;height:16px;position:relative;transition:background .12s ease;width:30px}.toggle:after{background:var(--ink-mid);border-radius:9999px;content:"";height:12px;left:2px;position:absolute;top:2px;transition:all .12s ease;width:12px}.toggle.on{background:color-mix(in oklch,var(--accent),transparent 50%)}.toggle.on:after{background:var(--accent);left:16px}.kind-grid{display:grid;gap:14px;grid-template-columns:1fr 1fr 1fr}.kind-card{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;cursor:pointer;padding:16px;transition:border-color .12s ease,background .12s ease}.kind-card:hover{border-color:var(--ink-mute)}.kind-card.selected{background:color-mix(in oklch,var(--accent),transparent 95%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.radio-pip{align-items:center;border:1px solid var(--line);border-radius:9999px;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.radio-pip.on{border-color:var(--accent)}.radio-pip.on:after{background:var(--accent);border-radius:9999px;content:"";height:6px;width:6px}.user-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:180px 1fr 110px 160px 120px 90px;padding:11px 16px;transition:background .1s ease}.user-row:hover{background:var(--panel-hi)}.user-row:last-child{border-bottom:0}.user-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.user-row.head:hover{background:transparent}.user-row.disabled{opacity:.55}.test-pill{border-radius:5px;display:inline-block;font-size:12.5px;padding:5px 10px}.test-pill-ok{background:color-mix(in oklch,var(--ok),transparent 92%);border:1px solid color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.test-pill-fail{background:color-mix(in oklch,var(--bad),transparent 92%);border:1px solid color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.p-\[18px\]{padding:18px}.p-\[3px\]{padding:3px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-2{padding-top:.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-40{opacity:.4}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)}.hover\:underline:hover{text-decoration-line:underline} diff --git a/web/templates/pages/user_edit.html b/web/templates/pages/user_edit.html new file mode 100644 index 0000000..a479f7b --- /dev/null +++ b/web/templates/pages/user_edit.html @@ -0,0 +1,105 @@ +{{define "title"}}{{.Title}}{{end}} + +{{define "content"}} +{{$page := .Page}} +
+
+ Dashboard/ + Settings/ + Users/ + {{if eq $page.Mode "new"}}new{{else if eq $page.Mode "setup-link"}}setup link{{else}}{{$page.Username}}{{end}} +
+ +

+ {{if eq $page.Mode "new"}}New user + {{else if eq $page.Mode "setup-link"}}Setup link for {{$page.Username}} + {{else}}Edit {{$page.Username}}{{end}} +

+ + {{if eq $page.Mode "setup-link"}} + {{if eq $page.Error "expired"}} +
+
Link expired or already used
+

+ This user's setup token is no longer valid. Open their Edit page and click + Regenerate setup link to issue a new one. +

+ Open edit page +
+ {{else}} +
+

+ Send this link to the user. It expires at + {{absTime $page.SetupExpAt}} UTC + (~1 hour from now). This is the only time you'll see it — if you lose + it, regenerate from the Edit page. +

+
{{$page.SetupURL}}
+ + Done +
+ {{end}} + {{else}} + {{/* new + edit form. */}} +
+
+ + +
Lowercased automatically.
+
+
+ + +
+
+ + +
+ {{if $page.Error}}
{{$page.Error}}
{{end}} +
+ + Cancel +
+
+ + {{if eq $page.Mode "edit"}} + {{/* Side actions: regenerate setup link, disable / re-enable, force logout. */}} +
+
Other actions
+
+
+ +
+
+ +
+ {{if $page.Disabled}} +
+ +
+ {{else}} +
+ +
+ {{end}} +
+
+ {{end}} + {{end}} +
+{{end}} From 6ccc6c8c5efe2cb832acb7df35c105564efa662f Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 10:01:33 +0100 Subject: [PATCH 25/30] ui: /settings/users edit form + disable/enable/regenerate/force-logout --- internal/server/http/server.go | 6 + internal/server/http/ui_users.go | 181 +++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) diff --git a/internal/server/http/server.go b/internal/server/http/server.go index f6fea31..23329c8 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -267,6 +267,12 @@ func (s *Server) routes(r chi.Router) { 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.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) r.Get("/settings/users/{id}/setup-link", s.handleUIUserSetupLinkGet) r.Get("/settings/notifications", s.handleUINotificationsList) r.Get("/settings/notifications/new", s.handleUINotificationNewGet) diff --git a/internal/server/http/ui_users.go b/internal/server/http/ui_users.go index fe685cd..a95495c 100644 --- a/internal/server/http/ui_users.go +++ b/internal/server/http/ui_users.go @@ -210,6 +210,187 @@ func (s *Server) handleUIUserNewPost(w stdhttp.ResponseWriter, r *stdhttp.Reques stdhttp.StatusSeeOther) } +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 + } + } + 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) +} + func (s *Server) handleUIUserSetupLinkGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { u := s.requireUIUser(w, r) if u == nil { From c34a76393c6bbc1d850aecbbc80b6501a6ff964e Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 10:03:41 +0100 Subject: [PATCH 26/30] ui: /settings/account self-service password change Adds GET/POST handlers for /settings/account in the viewer band (any authenticated user), account.html template with current-password field suppressed when must_change_password is set, and audits the change via AppendAudit. --- internal/server/http/server.go | 2 + internal/server/http/ui_account.go | 88 ++++++++++++++++++++++++++++++ web/templates/pages/account.html | 46 ++++++++++++++++ 3 files changed, 136 insertions(+) create mode 100644 web/templates/pages/account.html diff --git a/internal/server/http/server.go b/internal/server/http/server.go index 23329c8..68e7438 100644 --- a/internal/server/http/server.go +++ b/internal/server/http/server.go @@ -182,6 +182,8 @@ func (s *Server) routes(r chi.Router) { r.Get("/alerts", s.handleUIAlerts) r.Get("/audit", s.handleUIAudit) r.Get("/audit.csv", s.handleUIAuditCSV) + r.Get("/settings/account", s.handleUIAccountGet) + r.Post("/settings/account", s.handleUIAccountPost) } }) diff --git a/internal/server/http/ui_account.go b/internal/server/http/ui_account.go index d23483e..31bd025 100644 --- a/internal/server/http/ui_account.go +++ b/internal/server/http/ui_account.go @@ -64,3 +64,91 @@ func (s *Server) handleAPIAccountPassword(w stdhttp.ResponseWriter, r *stdhttp.R }) w.WriteHeader(stdhttp.StatusOK) } + +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.VerifyPassword(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) +} diff --git a/web/templates/pages/account.html b/web/templates/pages/account.html new file mode 100644 index 0000000..5164fc2 --- /dev/null +++ b/web/templates/pages/account.html @@ -0,0 +1,46 @@ +{{define "title"}}Account · restic-manager{{end}} + +{{define "content"}} +{{$page := .Page}} +
+
+ Dashboard/ + account +
+ +

Account

+
+ Signed in as {{$page.Username}} + ({{$page.Role}}). Change your password below. +
+ + {{if $page.Saved}} +
+
Password updated.
+
+ {{end}} + +
+ {{if not $page.MustChange}} +
+ + +
+ {{end}} +
+ + +
+
+ + +
+ {{if $page.Error}}
{{$page.Error}}
{{end}} + +
+
+{{end}} From d2cc4a802eb47cf2ea0b196d4d02be9667d128b2 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 10:04:31 +0100 Subject: [PATCH 27/30] alert: piggy-back expired-setup-token cleanup on the engine tick --- internal/alert/engine.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/alert/engine.go b/internal/alert/engine.go index 4c2004a..e0205b9 100644 --- a/internal/alert/engine.go +++ b/internal/alert/engine.go @@ -186,6 +186,14 @@ func (e *Engine) handleHostOnline(ctx context.Context, hostID string) { // task. The KindStaleSchedule constant is exported so UI code can // reference the tag string today. func (e *Engine) tick(ctx context.Context, now time.Time) { + // User-management cleanup piggy-backed here for now. Setup tokens + // have a 1h expiry; the alert engine tick is the cheapest existing + // 60s loop. If more housekeeping queries appear, extract a + // dedicated maintenance loop. + if _, err := e.store.CleanupExpiredSetupTokens(ctx, now); err != nil { + slog.Warn("alert: cleanup expired setup tokens", "err", err) + } + hosts, err := e.store.ListHosts(ctx) if err != nil { slog.Warn("alert: tick list hosts", "err", err) From d85e82110f622caefee6997196a662e9b5b311b7 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 10:08:05 +0100 Subject: [PATCH 28/30] tasks: tick P4-03/04 + sweep notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live Playwright + curl sweep on the smoke env exercised the full user-management lifecycle: admin add user → setup link generated → curl-as-new-user fetches /setup (200, username on page) → POSTs password → 303 to / with Set-Cookie → 200 on dashboard, 200 on /settings/account, **403 on /settings/users** (admin-only) → admin disables → next request is **401** + session row count drops to 0 → audit log reflects user.created + user.setup_completed. Three-role middleware enforces band gates; admin is fail-closed default. Setup tokens are sha256-hashed at rest with 1h expiry; expired tokens are swept on the alert engine's 60s tick. Last-admin guard rejects disable + demote of the only enabled admin. Self- service password change at /settings/account is reachable by every role. --- tasks.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tasks.md b/tasks.md index 1cefb57..7b59a62 100644 --- a/tasks.md +++ b/tasks.md @@ -296,8 +296,20 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 days. - [ ] **P4-01** (M) Update delivery via OS package managers — host an apt repo (Linux) and Chocolatey package (Windows) on gitea releases. `restic-manager-agent update` is a thin wrapper over `apt-get install --only-upgrade restic-manager-agent` / `choco upgrade`. Trades flexibility for a much smaller security surface than bespoke signed binaries (see spec.md §4.2) - [ ] **P4-02** (M) Agent version reporting on dashboard: surface "agent N versions behind server"; "update all" admin action calls the package-manager wrapper on each host -- [ ] **P4-03** (M) RBAC enforcement at API layer (admin / operator / viewer) -- [ ] **P4-04** (S) User management UI (create/edit/disable, role assignment, password reset) +- [x] **P4-03** (M) RBAC enforcement at API layer (admin / operator / viewer) +- [x] **P4-04** (S) User management UI (create/edit/disable, role assignment, password reset) + +> **As shipped (2026-05-05):** Three-role hierarchy (admin > operator > viewer) enforced via chi route-group middleware (`requireRole`). Admin is the fail-closed default; agent endpoints stay on the bearer-token chain. Sessions re-validate `disabled_at` on every authenticated request — admin-driven changes (disable, force-logout) land immediately. +> +> **Setup-token flow** replaces temp passwords. Admin clicks `+ Add user`, picks username + email + role, server returns a one-time setup link valid for 1 hour (sha256-hashed at rest, raw shown to admin once). User clicks the link → sets a password (≥12 chars) → drops a session → lands on `/`. `/settings/users/{id}/regenerate-setup` issues a new link, replacing the old via INSERT OR REPLACE. Expired tokens are swept on the alert engine's 60s tick. +> +> **Disable-only lifecycle** — soft delete via `disabled_at`. Last-admin guard rejects "disable last admin" and "demote last admin to non-admin" (both server-side and UI-hinted). Re-enable on disabled-username collision: admin trying to add a name that matches a disabled user is redirected to that user's edit page rather than 409'd. +> +> **Self-service password change** at `/settings/account` available to any role. Skips current-password check when `must_change_password` is set so admin-initiated resets work without surfacing a credential the user doesn't know. +> +> **Schema:** migration 0017 adds `email`, `disabled_at`, `must_change_password` plus a UNIQUE INDEX on LOWER(username) (lowercase normalisation in Go on every CreateUser); 0018 adds `user_setup_tokens`. Both column-level ALTERs per CLAUDE.md preference. Email is metadata only in v1 (no SMTP-the-link); the SMTP channel infrastructure from P3-06 makes that a one-page follow-up. +> +> **Sweep verified (smoke env):** admin adds operator → setup link generated → curl-as-new-user fetches /setup (200, page shows username) → POSTs password → 303 to / + Set-Cookie → operator authenticated → 200 on /, 200 on /settings/account, **403 on /settings/users** (admin-only) → admin disables user → operator's next request is **401** + session row count drops to 0 → audit log shows `user.created` + `user.setup_completed` for the cycle. All 26 implementation tasks landed; full `go test ./...` green. - [ ] **P4-05** (L) OIDC login (generic provider config, group → role mapping) - [ ] **P4-06** (M) Repo size trend graphs (sparkline on host card, full chart on repo page) - [ ] **P4-07** (S) Per-host tags + dashboard filtering by tag From 0415a96e277b090de607871957bd7776a44b9046 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 10:31:28 +0100 Subject: [PATCH 29/30] ui(users): record last_login on /setup + sortable headers --- internal/server/http/api_users.go | 2 +- internal/server/http/setup_handler.go | 4 ++ internal/server/http/ui_audit.go | 4 +- internal/server/http/ui_users.go | 64 +++++++++++++++++++++++++-- internal/store/users.go | 51 +++++++++++++++++---- web/static/css/styles.css | 2 +- web/styles/input.css | 14 ++++-- web/templates/pages/users.html | 24 ++++++++-- 8 files changed, 143 insertions(+), 22 deletions(-) diff --git a/internal/server/http/api_users.go b/internal/server/http/api_users.go index 48086a5..79b3784 100644 --- a/internal/server/http/api_users.go +++ b/internal/server/http/api_users.go @@ -37,7 +37,7 @@ type apiUser struct { } func (s *Server) handleAPIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) { - users, err := s.deps.Store.ListUsers(r.Context()) + users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{}) if err != nil { slog.Error("api users: list", "err", err) writeJSONError(w, stdhttp.StatusInternalServerError, "internal", err.Error()) diff --git a/internal/server/http/setup_handler.go b/internal/server/http/setup_handler.go index d5eb488..c64a8fa 100644 --- a/internal/server/http/setup_handler.go +++ b/internal/server/http/setup_handler.go @@ -144,6 +144,10 @@ func (s *Server) handleUISetupPost(w stdhttp.ResponseWriter, r *stdhttp.Request) Secure: s.deps.Cfg.CookieSecure, Expires: now.Add(8 * time.Hour), }) + // Record the login so the users-list "Last login" column shows + // the moment they completed setup (the regular /login path does + // the same; we'd otherwise leave the row showing "never"). + _ = s.deps.Store.MarkUserLogin(r.Context(), u.ID, now) _ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{ ID: ulid.Make().String(), UserID: &u.ID, diff --git a/internal/server/http/ui_audit.go b/internal/server/http/ui_audit.go index afc69c7..0ae2f85 100644 --- a/internal/server/http/ui_audit.go +++ b/internal/server/http/ui_audit.go @@ -159,7 +159,7 @@ func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) { SortHrefs: hrefs, CSVHref: csvHref, } - if users, err := s.deps.Store.ListUsers(r.Context()); err == nil { + if users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{}); err == nil { for _, ux := range users { page.UserNames[ux.ID] = ux.Username } @@ -220,7 +220,7 @@ func (s *Server) handleUIAuditCSV(w stdhttp.ResponseWriter, r *stdhttp.Request) // Resolve user_id → username and host_id → name once for the // human-friendly columns. userNames := map[string]string{} - if users, err := s.deps.Store.ListUsers(r.Context()); err == nil { + if users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{}); err == nil { for _, ux := range users { userNames[ux.ID] = ux.Username } diff --git a/internal/server/http/ui_users.go b/internal/server/http/ui_users.go index a95495c..8f5e780 100644 --- a/internal/server/http/ui_users.go +++ b/internal/server/http/ui_users.go @@ -19,6 +19,7 @@ import ( "log/slog" stdhttp "net/http" "net/mail" + "net/url" "strings" "time" @@ -31,6 +32,15 @@ import ( type usersPage struct { Users []userRow ShowDisabled bool + Sort string // "username" | "email" | "role" | "last_login_at" + Dir string // "asc" | "desc" + // SortHrefs is a fully-encoded /settings/users?…&sort=COL&dir=… + // for each sortable column. Built server-side because constructing + // the querystring inside in html/template applies + // URL-attribute escaping to '=' (turning 'show_disabled=1' into + // 'show_disabled%3D1'), which silently drops every filter on click. + // Same shape as the audit page's SortHrefs. + SortHrefs map[string]string } type userRow struct { @@ -48,8 +58,29 @@ func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) if u == nil { return } - showDisabled := r.URL.Query().Get("show_disabled") == "1" - users, err := s.deps.Store.ListUsers(r.Context()) + q := r.URL.Query() + showDisabled := q.Get("show_disabled") == "1" + + // Resolve sort against the allowlist. Default: username ASC. + resolvedSort := "username" + switch q.Get("sort") { + case "username", "email", "role", "last_login_at": + resolvedSort = q.Get("sort") + } + asc := q.Get("dir") != "desc" + if q.Get("sort") == "" { + // No explicit sort param → default ASC even though dir + // querystring might be missing (fresh page load). + asc = true + } + dirStr := "desc" + if asc { + dirStr = "asc" + } + + users, err := s.deps.Store.ListUsers(r.Context(), store.UserSort{ + OrderBy: resolvedSort, OrderAsc: asc, + }) if err != nil { slog.Error("ui users: list", "err", err) stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError) @@ -75,10 +106,37 @@ func (s *Server) handleUIUsersList(w stdhttp.ResponseWriter, r *stdhttp.Request) MustChangePassword: ux.MustChangePassword, }) } + + // Pre-build per-column hrefs so the template just emits them. + // Same pattern as ui_audit's SortHrefs — sidesteps html/template + // URL-attribute escaping turning '=' into '%3D'. + base := url.Values{} + if showDisabled { + base.Set("show_disabled", "1") + } + hrefs := make(map[string]string, 4) + for _, col := range []string{"username", "email", "role", "last_login_at"} { + v := url.Values{} + for k, vs := range base { + v[k] = vs + } + v.Set("sort", col) + newDir := "asc" // sensible default for unactive columns + if col == resolvedSort && asc { + newDir = "desc" + } + v.Set("dir", newDir) + hrefs[col] = "/settings/users?" + v.Encode() + } + view := s.baseView(r, u) view.Title = "Users · restic-manager" view.Active = "settings" - view.Page = usersPage{Users: rows, ShowDisabled: showDisabled} + view.Page = usersPage{ + Users: rows, ShowDisabled: showDisabled, + Sort: resolvedSort, Dir: dirStr, + SortHrefs: hrefs, + } if err := s.deps.UI.Render(w, "users", view); err != nil { slog.Error("ui users: render", "err", err) } diff --git a/internal/store/users.go b/internal/store/users.go index 06cd4a1..f414e92 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -49,14 +49,49 @@ func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) { return scanUser(row.Scan) } -// ListUsers returns every user, sorted by username. Used by surfaces -// that need to render a user-id → username map (audit log filter, -// "ack'd by" projections) 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`) +// UserSort selects the column ListUsers orders by. OrderBy is +// allowlisted in usersOrderColumn so callers can't inject SQL via +// this field. Empty / unknown OrderBy falls back to "username". +type UserSort struct { + OrderBy string // "username" | "email" | "role" | "last_login_at" + OrderAsc bool // false = DESC; true = ASC +} + +// usersOrderColumn validates s.OrderBy and returns the SQL fragment. +// last_login_at gets a NULL-tail trick so users who've never logged +// in sort to the bottom regardless of asc/desc — matches operator +// intuition ("show me real activity" not "show me NULLs first"). +func usersOrderColumn(col string, asc bool) string { + dir := "DESC" + if asc { + dir = "ASC" + } + switch col { + case "email": + return fmt.Sprintf("email IS NULL, email %s, username", dir) + case "role": + return fmt.Sprintf("role %s, username", dir) + case "last_login_at": + return fmt.Sprintf("last_login_at IS NULL, last_login_at %s, username", dir) + default: // username (and unknown) + return fmt.Sprintf("username %s", dir) + } +} + +// ListUsers returns users sorted per UserSort. Default (zero value) +// is username ASC. Used by the user-management page (sort headers) +// and by surfaces that need a user-id → username map (audit log +// filter, "ack'd by" projections) — those callers pass UserSort{}. +func (s *Store) ListUsers(ctx context.Context, sort UserSort) ([]User, error) { + asc := sort.OrderAsc + if sort.OrderBy == "" { + // Default: username ASC (alphabetical), matching pre-sort behaviour. + asc = true + } + q := `SELECT id, username, password_hash, role, email, disabled_at, + must_change_password, created_at, last_login_at + FROM users ORDER BY ` + usersOrderColumn(sort.OrderBy, asc) + rows, err := s.db.QueryContext(ctx, q) if err != nil { return nil, fmt.Errorf("store: list users: %w", err) } diff --git a/web/static/css/styles.css b/web/static/css/styles.css index 27094e4..7c7a861 100644 --- a/web/static/css/styles.css +++ b/web/static/css/styles.css @@ -1,3 +1,3 @@ *,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgba(59,130,246,.5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: } -/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.dropdown{display:inline-block;position:relative}.dropdown summary{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;list-style:none;padding:6px 11px;transition:all .12s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.dropdown summary::-webkit-details-marker{display:none}.dropdown summary::marker{content:""}.dropdown summary:hover{background:var(--panel-hi);color:var(--ink)}.dropdown summary .chev{color:var(--ink-fade);font-size:9px;transition:transform .12s ease}.dropdown[open] summary .chev{transform:rotate(180deg)}.dropdown[open] summary{background:var(--panel-hi);color:var(--ink)}.dropdown-menu{background:var(--panel);border:1px solid var(--line);border-radius:6px;box-shadow:0 6px 24px -8px rgba(0,0,0,.55);min-width:220px;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:30}.dropdown-item{border-radius:4px;color:var(--ink-mid);display:block;font-size:12.5px;line-height:1.35;padding:8px 11px;text-decoration:none}.dropdown-item:hover{background:var(--panel-hi);color:var(--ink)}.dropdown-item .label{color:var(--ink);display:block;font-weight:500}.dropdown-item .hint{color:var(--ink-mute);display:block;font-family:JetBrains Mono,ui-monospace,monospace;font-size:11px;margin-top:2px}.snap-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;cursor:pointer;display:grid;font-size:13px;grid-template-columns:150px 130px 1fr 90px 130px 80px;padding:11px 14px;transition:background .1s ease}.snap-row:last-child{border-bottom:0}.snap-row:hover{background:var(--panel-hi)}.snap-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.snap-row.head:hover{background:transparent}.alert-row{align-items:center;border-bottom:1px solid var(--line-soft);border-left:3px solid transparent;-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:18px 110px 130px 1fr 130px 110px 180px;padding:12px 16px;transition:background .1s ease}.alert-row:hover{background:var(--panel-hi)}.alert-row:last-child{border-bottom:0}.alert-row.head{border-left-color:transparent;color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.alert-row.head:hover{background:transparent}.alert-row.severity-warn{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.alert-row.severity-critical{border-left-color:color-mix(in oklch,var(--bad),transparent 30%)}.alert-row.resolved{opacity:.55}.dot-critical{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.tag-warn{background:color-mix(in oklch,var(--warn),transparent 92%);border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.tag-critical{background:color-mix(in oklch,var(--bad),transparent 92%);border-color:color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.tag-info{color:var(--ink-mid)}.audit-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:160px 80px 110px 1.4fr 1.5fr 90px;padding:11px 16px;transition:background .1s ease}.audit-row:hover{background:var(--panel-hi)}.audit-row:last-child{border-bottom:0}.audit-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.audit-row.head:hover{background:transparent}.audit-row.head .sort-header{align-items:baseline;color:inherit;cursor:pointer;display:inline-flex;gap:4px;text-decoration:none}.audit-row.head .sort-header:hover{color:var(--ink)}.audit-row.head .sort-glyph{color:var(--accent);display:inline-block;font-size:9px;min-width:8px}.schd-row{align-items:center;-moz-column-gap:14px;column-gap:14px;display:grid;font-size:13px;grid-template-columns:78px 1fr 1.6fr 100px 110px auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.ch-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:28px 200px 1fr 100px 130px 140px;padding:14px 18px;transition:background .1s ease}.ch-row:last-child{border-bottom:0}.ch-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.ch-row.head:hover{background:transparent}.ch-row.clickable{cursor:pointer;position:relative}.ch-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.ch-row.clickable:hover{background:var(--panel-hi)}.ch-row.clickable>*{pointer-events:none;position:relative;z-index:1}.ch-row.clickable>.row-action,.ch-row.clickable>.row-link{pointer-events:auto}.ch-icon{align-items:center;background:var(--panel-hi);border:1px solid var(--line);border-radius:5px;color:var(--ink-mute);display:inline-flex;font-family:JetBrains Mono,monospace;font-size:10px;font-weight:600;height:24px;justify-content:center;width:24px}.ch-icon.webhook{border-color:color-mix(in oklch,var(--accent),transparent 60%);color:var(--accent)}.ch-icon.ntfy{border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.ch-icon.smtp{border-color:color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.toggle{background:var(--line);border-radius:9999px;cursor:pointer;display:inline-block;flex-shrink:0;height:16px;position:relative;transition:background .12s ease;width:30px}.toggle:after{background:var(--ink-mid);border-radius:9999px;content:"";height:12px;left:2px;position:absolute;top:2px;transition:all .12s ease;width:12px}.toggle.on{background:color-mix(in oklch,var(--accent),transparent 50%)}.toggle.on:after{background:var(--accent);left:16px}.kind-grid{display:grid;gap:14px;grid-template-columns:1fr 1fr 1fr}.kind-card{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;cursor:pointer;padding:16px;transition:border-color .12s ease,background .12s ease}.kind-card:hover{border-color:var(--ink-mute)}.kind-card.selected{background:color-mix(in oklch,var(--accent),transparent 95%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.radio-pip{align-items:center;border:1px solid var(--line);border-radius:9999px;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.radio-pip.on{border-color:var(--accent)}.radio-pip.on:after{background:var(--accent);border-radius:9999px;content:"";height:6px;width:6px}.user-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:180px 1fr 110px 160px 120px 90px;padding:11px 16px;transition:background .1s ease}.user-row:hover{background:var(--panel-hi)}.user-row:last-child{border-bottom:0}.user-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.user-row.head:hover{background:transparent}.user-row.disabled{opacity:.55}.test-pill{border-radius:5px;display:inline-block;font-size:12.5px;padding:5px 10px}.test-pill-ok{background:color-mix(in oklch,var(--ok),transparent 92%);border:1px solid color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.test-pill-fail{background:color-mix(in oklch,var(--bad),transparent 92%);border:1px solid color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.p-\[18px\]{padding:18px}.p-\[3px\]{padding:3px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-2{padding-top:.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-40{opacity:.4}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)}.hover\:underline:hover{text-decoration-line:underline} +/*! tailwindcss v3.4.17 | MIT License | https://tailwindcss.com*/*,:after,:before{border:0 solid #e5e7eb;box-sizing:border-box}:after,:before{--tw-content:""}:host,html{line-height:1.5;-webkit-text-size-adjust:100%;font-family:Inter,system-ui,-apple-system,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-tap-highlight-color:transparent}body{line-height:inherit;margin:0}hr{border-top-width:1px;color:inherit;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-size:1em;font-variation-settings:normal}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{border-collapse:collapse;border-color:inherit;text-indent:0}button,input,optgroup,select,textarea{color:inherit;font-family:inherit;font-feature-settings:inherit;font-size:100%;font-variation-settings:inherit;font-weight:inherit;letter-spacing:inherit;line-height:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{color:#9ca3af;opacity:1}input::placeholder,textarea::placeholder{color:#9ca3af;opacity:1}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{height:auto;max-width:100%}[hidden]:where(:not([hidden=until-found])){display:none}:root{--bg:oklch(0.17 0.006 250);--panel:oklch(0.20 0.007 250);--panel-hi:oklch(0.23 0.008 250);--line:oklch(0.27 0.010 250);--line-soft:oklch(0.23 0.008 250);--ink:oklch(0.96 0.005 250);--ink-mid:oklch(0.78 0.005 250);--ink-mute:oklch(0.58 0.006 250);--ink-fade:oklch(0.42 0.006 250);--ok:oklch(0.78 0.14 155);--warn:oklch(0.82 0.13 80);--bad:oklch(0.70 0.20 25);--off:oklch(0.50 0.005 250);--accent:oklch(0.82 0.12 195)}body,html{background:var(--bg);color:var(--ink);font-family:Inter,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased}body{font-feature-settings:"cv11","ss01","ss03"}::-moz-selection{background:color-mix(in oklch,var(--accent),transparent 70%)}::selection{background:color-mix(in oklch,var(--accent),transparent 70%)}.\!container{width:100%!important}.container{width:100%}@media (min-width:640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width:768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width:1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width:1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width:1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.mono{font-family:JetBrains Mono,ui-monospace,monospace;font-variant-numeric:tabular-nums}.panel{background:var(--panel);border:1px solid var(--line-soft)}.hairline{box-shadow:inset 0 -1px 0 var(--line-soft)}.dot{border-radius:9999px;display:inline-block;height:7px;width:7px}.dot-online{background:var(--ok);box-shadow:0 0 0 3px color-mix(in oklch,var(--ok),transparent 80%)}.dot-degraded{background:var(--warn);box-shadow:0 0 0 3px color-mix(in oklch,var(--warn),transparent 80%)}.dot-offline{background:var(--off)}.dot-failed{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.pulse{animation:rm-pulse 2.4s ease-in-out infinite}@keyframes rm-pulse{0%,to{box-shadow:0 0 0 3px color-mix(in oklch,var(--accent),transparent 80%)}50%{box-shadow:0 0 0 6px color-mix(in oklch,var(--accent),transparent 92%)}}.btn{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;padding:6px 11px;text-decoration:none;transition:all .12s ease}.btn:hover{background:var(--panel-hi);color:var(--ink)}.btn:disabled,.btn[disabled]{cursor:not-allowed;opacity:.4;pointer-events:none}.btn-primary{background:var(--accent);border-color:var(--accent);color:oklch(.18 .01 195)}.btn-primary:hover{filter:brightness(1.08)}.btn-ghost,.btn-ghost:hover{border-color:transparent}.btn-ghost:hover{background:var(--panel-hi)}.btn-danger{border-color:color-mix(in oklch,var(--bad),transparent 70%);color:var(--bad)}.btn-danger:hover{background:color-mix(in oklch,var(--bad),transparent 88%);border-color:color-mix(in oklch,var(--bad),transparent 50%);color:oklch(.85 .1 25)}.btn-lg{font-size:13px;padding:9px 14px}.btn-block{justify-content:center;width:100%}.nav-tab{border-bottom:2px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:28px;padding:18px 0;text-decoration:none}.nav-tab.active{border-color:var(--accent)}.nav-tab.active,.nav-tab:hover{color:var(--ink)}.sub-tab{border-bottom:1.5px solid transparent;color:var(--ink-mute);cursor:pointer;font-size:13px;margin-right:24px;padding:12px 0;text-decoration:none}.sub-tab.active{border-color:var(--ink);color:var(--ink)}.tag{align-items:center;border:1px solid var(--line);border-radius:3px;display:inline-flex;font-size:11px;gap:5px;letter-spacing:.01em;line-height:1;padding:4px 7px}.field-label,.tag{color:var(--ink-mid)}.field-label{display:block;font-size:12px;margin-bottom:6px}.field-help{color:var(--ink-mute);font-size:12px;line-height:1.55;margin-top:6px}.field{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;color:var(--ink);font-family:inherit;font-size:13px;outline:none;padding:9px 12px;transition:border-color .12s ease;width:100%}.field:focus{border-color:var(--accent)}.field.invalid{border-color:color-mix(in oklch,var(--bad),transparent 50%)}.field.mono{font-family:JetBrains Mono,monospace;font-size:12px}.field.with-prefix{padding-left:64px}.host-row{align-items:center;border-left:3px solid transparent;-moz-column-gap:18px;column-gap:18px;display:grid;font-size:13px;grid-template-columns:24px 1.4fr .95fr 1.5fr .75fr .7fr .7fr 1.1fr 92px;padding:11px 16px}.host-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.host-row.degraded{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.host-row.failed{border-left-color:color-mix(in oklch,var(--bad),transparent 50%)}.host-row.offline{border-left-color:color-mix(in oklch,var(--off),transparent 70%)}.host-row:hover{background:var(--panel-hi)}.host-row.clickable{position:relative}.host-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.host-row.clickable:hover{cursor:pointer}.host-row.clickable>*{pointer-events:none;position:relative;z-index:1}.host-row.clickable>.row-action,.host-row.clickable>.row-link{pointer-events:auto}.src-row{align-items:center;-moz-column-gap:18px;column-gap:18px;display:grid;grid-template-columns:1fr auto;padding:14px 18px}.src-row.clickable{position:relative}.src-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.src-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.src-row.clickable>*{pointer-events:none;position:relative;z-index:1}.src-row.clickable>.row-action,.src-row.clickable>.row-link{pointer-events:auto}.dropdown{display:inline-block;position:relative}.dropdown summary{align-items:center;background:transparent;border:1px solid var(--line);border-radius:5px;color:var(--ink-mid);cursor:pointer;display:inline-flex;font-size:12px;font-weight:500;gap:6px;list-style:none;padding:6px 11px;transition:all .12s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.dropdown summary::-webkit-details-marker{display:none}.dropdown summary::marker{content:""}.dropdown summary:hover{background:var(--panel-hi);color:var(--ink)}.dropdown summary .chev{color:var(--ink-fade);font-size:9px;transition:transform .12s ease}.dropdown[open] summary .chev{transform:rotate(180deg)}.dropdown[open] summary{background:var(--panel-hi);color:var(--ink)}.dropdown-menu{background:var(--panel);border:1px solid var(--line);border-radius:6px;box-shadow:0 6px 24px -8px rgba(0,0,0,.55);min-width:220px;padding:4px;position:absolute;right:0;top:calc(100% + 4px);z-index:30}.dropdown-item{border-radius:4px;color:var(--ink-mid);display:block;font-size:12.5px;line-height:1.35;padding:8px 11px;text-decoration:none}.dropdown-item:hover{background:var(--panel-hi);color:var(--ink)}.dropdown-item .label{color:var(--ink);display:block;font-weight:500}.dropdown-item .hint{color:var(--ink-mute);display:block;font-family:JetBrains Mono,ui-monospace,monospace;font-size:11px;margin-top:2px}.snap-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;cursor:pointer;display:grid;font-size:13px;grid-template-columns:150px 130px 1fr 90px 130px 80px;padding:11px 14px;transition:background .1s ease}.snap-row:last-child{border-bottom:0}.snap-row:hover{background:var(--panel-hi)}.snap-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.snap-row.head:hover{background:transparent}.alert-row{align-items:center;border-bottom:1px solid var(--line-soft);border-left:3px solid transparent;-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:18px 110px 130px 1fr 130px 110px 180px;padding:12px 16px;transition:background .1s ease}.alert-row:hover{background:var(--panel-hi)}.alert-row:last-child{border-bottom:0}.alert-row.head{border-left-color:transparent;color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.alert-row.head:hover{background:transparent}.alert-row.severity-warn{border-left-color:color-mix(in oklch,var(--warn),transparent 50%)}.alert-row.severity-critical{border-left-color:color-mix(in oklch,var(--bad),transparent 30%)}.alert-row.resolved{opacity:.55}.dot-critical{background:var(--bad);box-shadow:0 0 0 3px color-mix(in oklch,var(--bad),transparent 80%)}.tag-warn{background:color-mix(in oklch,var(--warn),transparent 92%);border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.tag-critical{background:color-mix(in oklch,var(--bad),transparent 92%);border-color:color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.tag-info{color:var(--ink-mid)}.audit-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:160px 80px 110px 1.4fr 1.5fr 90px;padding:11px 16px;transition:background .1s ease}.audit-row:hover{background:var(--panel-hi)}.audit-row:last-child{border-bottom:0}.audit-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.audit-row.head:hover{background:transparent}.audit-row.head .sort-header,.user-row.head .sort-header{align-items:baseline;color:inherit;cursor:pointer;display:inline-flex;gap:4px;text-decoration:none}.audit-row.head .sort-header:hover,.user-row.head .sort-header:hover{color:var(--ink)}.audit-row.head .sort-glyph,.user-row.head .sort-glyph{color:var(--accent);display:inline-block;font-size:9px;min-width:8px}.schd-row{align-items:center;-moz-column-gap:14px;column-gap:14px;display:grid;font-size:13px;grid-template-columns:78px 1fr 1.6fr 100px 110px auto;padding:12px 18px}.schd-row.head{color:var(--ink-fade);font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.schd-row.clickable{position:relative}.schd-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.schd-row.clickable:hover{background:var(--panel-hi);cursor:pointer}.schd-row.clickable>*{pointer-events:none;position:relative;z-index:1}.schd-row.clickable>.row-action,.schd-row.clickable>.row-link{pointer-events:auto}.preset-chip{background:var(--bg);border:1px solid var(--line-soft);border-radius:4px;color:var(--ink-mid);cursor:pointer;font-family:JetBrains Mono,monospace;font-size:11.5px;padding:4px 9px;transition:border-color .1s ease,color .1s ease;-webkit-user-select:none;-moz-user-select:none;user-select:none}.preset-chip:hover{border-color:var(--accent);color:var(--ink)}.picker{align-items:center;background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;cursor:pointer;display:flex;font-size:13px;gap:12px;padding:10px 12px;transition:border-color .1s ease,background .1s ease}.picker:hover{border-color:var(--ink-mute)}.picker .check{border:1px solid var(--line);border-radius:3px;display:inline-block;flex-shrink:0;height:14px;position:relative;width:14px}.picker.checked{background:color-mix(in oklch,var(--accent),transparent 92%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.picker.checked .check{background:var(--accent);border-color:var(--accent)}.picker.checked .check:after{border:solid oklch(.18 .01 195);border-width:0 1.5px 1.5px 0;content:"";height:8px;left:4px;position:absolute;top:1px;transform:rotate(45deg);width:4px}.picker input[type=checkbox]{opacity:0;pointer-events:none;position:absolute}.keep-cell{background:var(--bg);border:1px solid var(--line-soft);border-radius:5px;display:flex;flex-direction:column;gap:4px;padding:9px 11px}.keep-cell label{color:var(--ink-fade);font-size:10.5px;letter-spacing:.08em;text-transform:uppercase}.keep-cell input{background:transparent;border:none;color:var(--ink);font-size:14px;outline:none;padding:0;width:100%}.keep-cell input,.log{font-family:JetBrains Mono,monospace}.log{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;font-size:12px;line-height:1.7;overflow:hidden}.log-line{align-items:baseline;-moz-column-gap:14px;column-gap:14px;display:grid;grid-template-columns:14ch 8ch 1fr;padding:1px 16px}.log-line:first-child{padding-top:12px}.log-line:last-child{padding-bottom:12px}.log-tag,.log-ts{color:var(--ink-fade)}.log-tag{font-size:10px;letter-spacing:.08em;text-transform:uppercase}.progress-track{background:var(--bg);border:1px solid var(--line-soft);border-radius:9999px;height:6px;overflow:hidden}.progress-fill{background:var(--accent);border-radius:9999px;height:100%;transition:width .25s ease}.progress-fill.ok{background:var(--ok)}.progress-fill.bad{background:var(--bad)}.crumbs{font-size:12px}.crumbs,.crumbs a{color:var(--ink-mute)}.crumbs a{text-decoration:underline;text-decoration-color:var(--line);text-underline-offset:3px}.crumbs .sep{color:var(--ink-fade);margin:0 8px}.snippet{border:1px solid var(--line-soft);border-radius:6px;overflow:hidden}.snippet-head{align-items:center;border-bottom:1px solid var(--line-soft);color:var(--ink-fade);display:flex;font-size:11px;justify-content:space-between;letter-spacing:.1em;padding:10px 14px;text-transform:uppercase}.snippet pre{color:var(--ink-mid);font-family:JetBrains Mono,monospace;font-size:12px;line-height:1.7;margin:0;padding:14px;white-space:pre-wrap;word-break:break-all}.snippet pre .var{color:var(--accent)}.empty-state{background:radial-gradient(ellipse at top,color-mix(in oklch,var(--accent),transparent 95%),transparent 60%),var(--panel);border:1px dashed var(--line);border-radius:8px;padding:60px 40px;text-align:center}.ch-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:28px 200px 1fr 100px 130px 140px;padding:14px 18px;transition:background .1s ease}.ch-row:last-child{border-bottom:0}.ch-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:10px;padding-top:10px;text-transform:uppercase}.ch-row.head:hover{background:transparent}.ch-row.clickable{cursor:pointer;position:relative}.ch-row.clickable .row-link{inset:0;overflow:hidden;position:absolute;text-indent:-9999px;z-index:0}.ch-row.clickable:hover{background:var(--panel-hi)}.ch-row.clickable>*{pointer-events:none;position:relative;z-index:1}.ch-row.clickable>.row-action,.ch-row.clickable>.row-link{pointer-events:auto}.ch-icon{align-items:center;background:var(--panel-hi);border:1px solid var(--line);border-radius:5px;color:var(--ink-mute);display:inline-flex;font-family:JetBrains Mono,monospace;font-size:10px;font-weight:600;height:24px;justify-content:center;width:24px}.ch-icon.webhook{border-color:color-mix(in oklch,var(--accent),transparent 60%);color:var(--accent)}.ch-icon.ntfy{border-color:color-mix(in oklch,var(--warn),transparent 60%);color:var(--warn)}.ch-icon.smtp{border-color:color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.toggle{background:var(--line);border-radius:9999px;cursor:pointer;display:inline-block;flex-shrink:0;height:16px;position:relative;transition:background .12s ease;width:30px}.toggle:after{background:var(--ink-mid);border-radius:9999px;content:"";height:12px;left:2px;position:absolute;top:2px;transition:all .12s ease;width:12px}.toggle.on{background:color-mix(in oklch,var(--accent),transparent 50%)}.toggle.on:after{background:var(--accent);left:16px}.kind-grid{display:grid;gap:14px;grid-template-columns:1fr 1fr 1fr}.kind-card{background:var(--bg);border:1px solid var(--line-soft);border-radius:7px;cursor:pointer;padding:16px;transition:border-color .12s ease,background .12s ease}.kind-card:hover{border-color:var(--ink-mute)}.kind-card.selected{background:color-mix(in oklch,var(--accent),transparent 95%);border-color:color-mix(in oklch,var(--accent),transparent 50%)}.radio-pip{align-items:center;border:1px solid var(--line);border-radius:9999px;display:inline-flex;flex-shrink:0;height:14px;justify-content:center;width:14px}.radio-pip.on{border-color:var(--accent)}.radio-pip.on:after{background:var(--accent);border-radius:9999px;content:"";height:6px;width:6px}.user-row{align-items:center;border-bottom:1px solid var(--line-soft);-moz-column-gap:16px;column-gap:16px;display:grid;font-size:13px;grid-template-columns:180px 1fr 110px 160px 120px 90px;padding:11px 16px;transition:background .1s ease}.user-row:hover{background:var(--panel-hi)}.user-row:last-child{border-bottom:0}.user-row.head{color:var(--ink-fade);cursor:default;font-size:11px;letter-spacing:.08em;padding-bottom:9px;padding-top:9px;text-transform:uppercase}.user-row.head:hover{background:transparent}.user-row.disabled{opacity:.55}.test-pill{border-radius:5px;display:inline-block;font-size:12.5px;padding:5px 10px}.test-pill-ok{background:color-mix(in oklch,var(--ok),transparent 92%);border:1px solid color-mix(in oklch,var(--ok),transparent 60%);color:var(--ok)}.test-pill-fail{background:color-mix(in oklch,var(--bad),transparent 92%);border:1px solid color-mix(in oklch,var(--bad),transparent 60%);color:var(--bad)}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.invisible{visibility:hidden}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.bottom-5{bottom:1.25rem}.left-0{left:0}.right-5{right:1.25rem}.top-0{top:0}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.col-span-3{grid-column:span 3/span 3}.col-span-4{grid-column:span 4/span 4}.col-span-5{grid-column:span 5/span 5}.col-span-7{grid-column:span 7/span 7}.col-span-8{grid-column:span 8/span 8}.col-span-9{grid-column:span 9/span 9}.m-0{margin:0}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1\.5{margin-bottom:.375rem}.mb-10{margin-bottom:2.5rem}.mb-2{margin-bottom:.5rem}.mb-2\.5{margin-bottom:.625rem}.mb-3{margin-bottom:.75rem}.mb-3\.5{margin-bottom:.875rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-7{margin-bottom:1.75rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-2\.5{margin-left:.625rem}.ml-5{margin-left:1.25rem}.ml-auto{margin-left:auto}.mr-1\.5{margin-right:.375rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-2\.5{margin-top:.625rem}.mt-20{margin-top:5rem}.mt-3{margin-top:.75rem}.mt-3\.5{margin-top:.875rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.mt-8{margin-top:2rem}.mt-9{margin-top:2.25rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-3\.5{height:.875rem}.h-\[13px\]{height:13px}.h-\[22px\]{height:22px}.min-h-screen{min-height:100vh}.w-16{width:4rem}.w-3\.5{width:.875rem}.w-\[13px\]{width:13px}.w-\[22px\]{width:22px}.w-\[360px\]{width:360px}.w-full{width:100%}.min-w-0{min-width:0}.max-w-\[1280px\]{max-width:1280px}.max-w-\[440px\]{max-width:440px}.max-w-\[480px\]{max-width:480px}.max-w-\[520px\]{max-width:520px}.max-w-\[580px\]{max-width:580px}.max-w-\[640px\]{max-width:640px}.max-w-\[680px\]{max-width:680px}.max-w-\[720px\]{max-width:720px}.max-w-\[760px\]{max-width:760px}.flex-1{flex:1 1 0%}.flex-none{flex:none}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.cursor-default{cursor:default}.cursor-help{cursor:help}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.select-all{-webkit-user-select:all;-moz-user-select:all;user-select:all}.resize{resize:both}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-3\.5{gap:.875rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-8{gap:2rem}.gap-x-4{-moz-column-gap:1rem;column-gap:1rem}.gap-y-2{row-gap:.5rem}.gap-y-2\.5{row-gap:.625rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(.5rem*var(--tw-space-y-reverse));margin-top:calc(.5rem*(1 - var(--tw-space-y-reverse)))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-bottom:calc(1rem*var(--tw-space-y-reverse));margin-top:calc(1rem*(1 - var(--tw-space-y-reverse)))}.overflow-hidden,.truncate{overflow:hidden}.truncate{text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-\[3px\]{border-radius:3px}.rounded-\[5px\]{border-radius:5px}.rounded-\[6px\]{border-radius:6px}.rounded-\[7px\]{border-radius:7px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.375rem}.border{border-width:1px}.border-y{border-top-width:1px}.border-b,.border-y{border-bottom-width:1px}.border-l{border-left-width:1px}.border-t{border-top-width:1px}.border-line{border-color:oklch(.27 .01 250)}.border-line-soft{border-color:oklch(.23 .008 250)}.bg-bg{background-color:oklch(.17 .006 250)}.bg-panel{background-color:oklch(.2 .007 250)}.p-0{padding:0}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-3\.5{padding:.875rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.p-\[18px\]{padding:18px}.p-\[3px\]{padding:3px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.px-8{padding-left:2rem;padding-right:2rem}.px-\[18px\]{padding-left:18px;padding-right:18px}.py-0\.5{padding-bottom:.125rem;padding-top:.125rem}.py-1{padding-bottom:.25rem;padding-top:.25rem}.py-1\.5{padding-bottom:.375rem;padding-top:.375rem}.py-12{padding-bottom:3rem;padding-top:3rem}.py-2{padding-bottom:.5rem;padding-top:.5rem}.py-2\.5{padding-bottom:.625rem;padding-top:.625rem}.py-3{padding-bottom:.75rem;padding-top:.75rem}.py-3\.5{padding-bottom:.875rem;padding-top:.875rem}.py-4{padding-bottom:1rem;padding-top:1rem}.py-5{padding-bottom:1.25rem;padding-top:1.25rem}.py-6{padding-bottom:1.5rem;padding-top:1.5rem}.py-7{padding-bottom:1.75rem;padding-top:1.75rem}.py-8{padding-bottom:2rem;padding-top:2rem}.py-\[14px\]{padding-bottom:14px;padding-top:14px}.py-\[5px\]{padding-bottom:5px;padding-top:5px}.pb-14{padding-bottom:3.5rem}.pb-2{padding-bottom:.5rem}.pb-24{padding-bottom:6rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pb-\[18px\]{padding-bottom:18px}.pl-5{padding-left:1.25rem}.pl-6{padding-left:1.5rem}.pl-9{padding-left:2.25rem}.pt-0\.5{padding-top:.125rem}.pt-1{padding-top:.25rem}.pt-14{padding-top:3.5rem}.pt-2{padding-top:.5rem}.pt-20{padding-top:5rem}.pt-4{padding-top:1rem}.pt-5{padding-top:1.25rem}.pt-6{padding-top:1.5rem}.pt-7{padding-top:1.75rem}.pt-9{padding-top:2.25rem}.pt-\[1px\]{padding-top:1px}.text-center{text-align:center}.text-right{text-align:right}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10\.5px\]{font-size:10.5px}.text-\[10px\]{font-size:10px}.text-\[11\.5px\]{font-size:11.5px}.text-\[11px\]{font-size:11px}.text-\[12\.5px\]{font-size:12.5px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[14px\]{font-size:14px}.text-\[16px\]{font-size:16px}.text-\[18px\]{font-size:18px}.text-\[19px\]{font-size:19px}.text-\[20px\]{font-size:20px}.text-\[22px\]{font-size:22px}.text-\[26px\]{font-size:26px}.text-\[28px\]{font-size:28px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-normal{font-weight:400}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.normal-case{text-transform:none}.italic{font-style:italic}.leading-\[1\.55\]{line-height:1.55}.leading-\[1\.5\]{line-height:1.5}.leading-\[1\.65\]{line-height:1.65}.leading-\[1\.6\]{line-height:1.6}.leading-\[1\.7\]{line-height:1.7}.leading-\[20px\]{line-height:20px}.leading-none{line-height:1}.tracking-\[-0\.005em\]{letter-spacing:-.005em}.tracking-\[-0\.012em\]{letter-spacing:-.012em}.tracking-\[-0\.01em\]{letter-spacing:-.01em}.tracking-\[-0\.02em\]{letter-spacing:-.02em}.tracking-\[0\.005em\]{letter-spacing:.005em}.tracking-\[0\.01em\]{letter-spacing:.01em}.tracking-\[0\.02em\]{letter-spacing:.02em}.tracking-\[0\.08em\]{letter-spacing:.08em}.tracking-\[0\.1em\]{letter-spacing:.1em}.text-accent{color:oklch(.82 .12 195)}.text-bad{color:oklch(.7 .2 25)}.text-ink{color:oklch(.96 .005 250)}.text-ink-fade{color:oklch(.42 .006 250)}.text-ink-mid{color:oklch(.78 .005 250)}.text-ink-mute{color:oklch(.58 .006 250)}.text-ok{color:oklch(.78 .14 155)}.text-warn{color:oklch(.82 .13 80)}.underline{text-decoration-line:underline}.no-underline{text-decoration-line:none}.decoration-line{text-decoration-color:oklch(.27 .01 250)}.underline-offset-4{text-underline-offset:4px}.opacity-40{opacity:.4}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-duration:.15s;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1)}.hover\:text-ink-mid:hover{color:oklch(.78 .005 250)}.hover\:underline:hover{text-decoration-line:underline} diff --git a/web/styles/input.css b/web/styles/input.css index 22e9999..d97f398 100644 --- a/web/styles/input.css +++ b/web/styles/input.css @@ -328,12 +328,20 @@ text-transform: uppercase; letter-spacing: 0.08em; } .audit-row.head:hover { background: transparent; } - .audit-row.head .sort-header { + /* Sort-header link styling — shared by .audit-row and .user-row + (and any other future sortable table headers). The selectors + scope to .head rows so hover and accent-glyph treatment only + apply to the header, not data rows that happen to contain a + . */ + .audit-row.head .sort-header, + .user-row.head .sort-header { color: inherit; text-decoration: none; cursor: pointer; display: inline-flex; align-items: baseline; gap: 4px; } - .audit-row.head .sort-header:hover { color: var(--ink); } - .audit-row.head .sort-glyph { + .audit-row.head .sort-header:hover, + .user-row.head .sort-header:hover { color: var(--ink); } + .audit-row.head .sort-glyph, + .user-row.head .sort-glyph { font-size: 9px; color: var(--accent); /* keep the row height stable when the glyph appears/disappears */ min-width: 8px; display: inline-block; diff --git a/web/templates/pages/users.html b/web/templates/pages/users.html index 38b330a..6b411b4 100644 --- a/web/templates/pages/users.html +++ b/web/templates/pages/users.html @@ -29,11 +29,27 @@
+ {{/* Header — Username/Email/Role/Last login are clickable sort + links. Hrefs are pre-built server-side ($page.SortHrefs) so + html/template's URL-attribute escaping doesn't trip on the + '=' chars. Same pattern as the audit log. */}} From dfff6d1ef933fa601858b785a4e18ea4706d6d73 Mon Sep 17 00:00:00 2001 From: Steve Cliff Date: Tue, 5 May 2026 10:43:11 +0100 Subject: [PATCH 30/30] ui(users): banner explaining the disabled-username re-enable flow --- internal/server/http/ui_users.go | 6 ++++++ web/templates/pages/user_edit.html | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/internal/server/http/ui_users.go b/internal/server/http/ui_users.go index 8f5e780..08ee401 100644 --- a/internal/server/http/ui_users.go +++ b/internal/server/http/ui_users.go @@ -153,6 +153,11 @@ type userFormPage struct { SetupURL string SetupExpAt time.Time Error string + // Reenable is set when the admin landed here because they tried + // to add a username that already exists (disabled). Triggers a + // banner on the edit page explaining why and steering them at + // the Re-enable button. See handleUIUserNewPost's collision branch. + Reenable bool } func (s *Server) handleUIUserNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) { @@ -290,6 +295,7 @@ func (s *Server) handleUIUserEditGet(w stdhttp.ResponseWriter, r *stdhttp.Reques Mode: "edit", ID: target.ID, Username: target.Username, Email: em, Role: string(target.Role), Disabled: target.DisabledAt != nil, + Reenable: r.URL.Query().Get("reenable") == "1", } _ = s.deps.UI.Render(w, "user_edit", view) } diff --git a/web/templates/pages/user_edit.html b/web/templates/pages/user_edit.html index a479f7b..4014fad 100644 --- a/web/templates/pages/user_edit.html +++ b/web/templates/pages/user_edit.html @@ -16,6 +16,28 @@ {{else}}Edit {{$page.Username}}{{end}} + {{/* Re-enable banner — fires when the admin tried to add a user + whose name already exists in a disabled state. We were + redirected here from /settings/users/new with ?reenable=1. */}} + {{if and $page.Reenable $page.Disabled}} +
+
Username already exists (disabled)
+

+ A user with this name was created previously and then disabled. + Re-enable them to restore access (their existing role + email + are kept), or pick a different name. +

+
+
+ +
+ Pick a different username +
+
+ {{end}} + {{if eq $page.Mode "setup-link"}} {{if eq $page.Error "expired"}}