Merge pull request 'Phase 4 — P4-03/04: RBAC + user management' (#14) from p4-03-04-rbac-user-mgmt into main
Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
@@ -2,10 +2,19 @@
|
|||||||
|
|
||||||
Project-specific rules for Claude when working in this repo.
|
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
|
## Repo
|
||||||
|
|
||||||
The repo lives inside a Gitea instance; `tea` CLI is available for use by agents
|
The repo lives inside a Gitea instance; `tea` CLI is available for use by agents
|
||||||
|
|
||||||
|
|
||||||
## Run `go vet` before every commit
|
## Run `go vet` before every commit
|
||||||
|
|
||||||
CI runs `go vet ./...` and will fail the build on any vet error.
|
CI runs `go vet ./...` and will fail the build on any vet error.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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)://<base>/setup?token=<raw>` 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=<raw>`
|
||||||
|
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
|
||||||
@@ -186,6 +186,14 @@ func (e *Engine) handleHostOnline(ctx context.Context, hostID string) {
|
|||||||
// task. The KindStaleSchedule constant is exported so UI code can
|
// task. The KindStaleSchedule constant is exported so UI code can
|
||||||
// reference the tag string today.
|
// reference the tag string today.
|
||||||
func (e *Engine) tick(ctx context.Context, now time.Time) {
|
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)
|
hosts, err := e.store.ListHosts(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("alert: tick list hosts", "err", err)
|
slog.Warn("alert: tick list hosts", "err", err)
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
// 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 (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"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 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(), store.UserSort{})
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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})
|
||||||
|
}
|
||||||
@@ -59,6 +59,9 @@ func (s *Server) authenticateAndSession(w stdhttp.ResponseWriter, r *stdhttp.Req
|
|||||||
if err := auth.VerifyPassword(u.PasswordHash, password); err != nil {
|
if err := auth.VerifyPassword(u.PasswordHash, password); err != nil {
|
||||||
return nil, errInvalidCredentials
|
return nil, errInvalidCredentials
|
||||||
}
|
}
|
||||||
|
if u.DisabledAt != nil {
|
||||||
|
return nil, errInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
token, err := auth.NewToken()
|
token, err := auth.NewToken()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -152,6 +152,12 @@ func (s *Server) requireUser(r *stdhttp.Request) (*store.User, bool) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false
|
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
|
return u, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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."))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
stdhttp "net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+156
-216
@@ -85,11 +85,6 @@ func New(deps Deps) *Server {
|
|||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(requestLogger)
|
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{
|
s := &Server{
|
||||||
deps: deps,
|
deps: deps,
|
||||||
drainLocks: make(map[string]*sync.Mutex),
|
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
|
// routes wires the API tree. Subtrees live in this file by area so a
|
||||||
// reader can scan one place and see the surface.
|
// reader can scan one place and see the surface.
|
||||||
func (s *Server) routes(r chi.Router) {
|
func (s *Server) routes(r chi.Router) {
|
||||||
r.Route("/api", func(r chi.Router) {
|
// Public, unauthenticated.
|
||||||
r.Post("/auth/login", s.handleLogin)
|
r.Get("/healthz", func(w stdhttp.ResponseWriter, _ *stdhttp.Request) {
|
||||||
r.Post("/auth/logout", s.handleLogout)
|
w.WriteHeader(stdhttp.StatusNoContent)
|
||||||
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 <name>).
|
|
||||||
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)
|
|
||||||
})
|
})
|
||||||
|
r.Post("/api/auth/login", s.handleLogin)
|
||||||
// HTMX form variant of diff (mounted outside /api so HTMX forms
|
r.Post("/api/auth/logout", s.handleLogout)
|
||||||
// can post against it without the api/ prefix).
|
r.Post("/api/bootstrap", s.handleBootstrap)
|
||||||
r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
|
r.Post("/api/agents/enroll", s.handleAgentEnroll)
|
||||||
|
r.Post("/api/agents/announce", s.handleAnnounce)
|
||||||
// Per-source-group Run-now (HTMX form action). Available even
|
r.Get("/agent/binary", s.handleAgentBinary)
|
||||||
// when the server is started without UI templates so REST callers
|
r.Get("/install/*", s.handleInstallAsset)
|
||||||
// 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.
|
|
||||||
if s.deps.Hub != nil {
|
if s.deps.Hub != nil {
|
||||||
r.Mount("/ws/agent", ws.AgentHandler(ws.HandlerDeps{
|
r.Mount("/ws/agent", ws.AgentHandler(ws.HandlerDeps{
|
||||||
Hub: s.deps.Hub,
|
Hub: s.deps.Hub,
|
||||||
@@ -250,101 +130,161 @@ func (s *Server) routes(r chi.Router) {
|
|||||||
OnScheduleFire: s.dispatchScheduledJob,
|
OnScheduleFire: s.dispatchScheduledJob,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
r.Get("/ws/agent/pending", s.handlePendingWS)
|
||||||
// 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.Mount("/static/", staticHandler())
|
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 {
|
if s.deps.UI != nil {
|
||||||
r.Get("/", s.handleUIDashboard)
|
|
||||||
r.Get("/login", s.handleUILoginGet)
|
r.Get("/login", s.handleUILoginGet)
|
||||||
r.Post("/login", s.handleUILoginPost)
|
r.Post("/login", s.handleUILoginPost)
|
||||||
r.Post("/logout", s.handleUILogoutPost)
|
r.Post("/logout", s.handleUILogoutPost)
|
||||||
// Per-host Run-now and manual Init-repo are mounted at the
|
r.Get("/setup", s.handleUISetupGet)
|
||||||
// outer router (so they reply 410 even without UI). Per-
|
r.Post("/setup", s.handleUISetupPost)
|
||||||
// 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
|
// Viewer band — anyone authenticated can read.
|
||||||
// layer is session-cookie not bearer). Mounted regardless of
|
r.Group(func(r chi.Router) {
|
||||||
// whether the UI is up — JSON callers may also subscribe.
|
r.Use(s.requireRole(store.RoleViewer))
|
||||||
if s.deps.JobHub != nil {
|
|
||||||
r.Get("/api/jobs/{id}/stream", s.handleJobStream)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Job log download (txt + ndjson). Source of truth is the
|
// Read APIs.
|
||||||
// persisted job_logs table; safe to call any time, no pause
|
r.Get("/api/hosts", s.handleListHosts)
|
||||||
// needed against the live stream.
|
r.Get("/api/fleet/summary", s.handleFleetSummary)
|
||||||
r.Get("/api/jobs/{id}/log.{format:txt|ndjson}", s.handleJobLogDownload)
|
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)
|
||||||
|
r.Post("/api/account/password", s.handleAPIAccountPassword)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
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 (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.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)
|
||||||
|
r.Post("/api/notifications/{id}/test", s.handleAPINotificationTest)
|
||||||
|
|
||||||
|
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}/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)
|
||||||
|
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
|
// Start begins listening. Blocks until ListenAndServe returns
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
// 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=<raw>`) is the credential.
|
||||||
|
// Auth middleware does not run on these routes.
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"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/server/ui"
|
||||||
|
"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 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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),
|
||||||
|
})
|
||||||
|
// 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,
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
stdhttp "net/http"
|
||||||
|
"net/url"
|
||||||
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -159,7 +159,7 @@ func (s *Server) handleUIAudit(w stdhttp.ResponseWriter, r *stdhttp.Request) {
|
|||||||
SortHrefs: hrefs,
|
SortHrefs: hrefs,
|
||||||
CSVHref: csvHref,
|
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 {
|
for _, ux := range users {
|
||||||
page.UserNames[ux.ID] = ux.Username
|
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
|
// Resolve user_id → username and host_id → name once for the
|
||||||
// human-friendly columns.
|
// human-friendly columns.
|
||||||
userNames := map[string]string{}
|
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 {
|
for _, ux := range users {
|
||||||
userNames[ux.ID] = ux.Username
|
userNames[ux.ID] = ux.Username
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ func (s *Server) sessionUser(r *stdhttp.Request) (*ui.User, error) {
|
|||||||
}
|
}
|
||||||
return nil, err
|
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
|
return &ui.User{ID: u.ID, Username: u.Username, Role: string(u.Role)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
// 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 (
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
stdhttp "net/http"
|
||||||
|
"net/mail"
|
||||||
|
"net/url"
|
||||||
|
"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
|
||||||
|
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 <a href="…"> 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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
Sort: resolvedSort, Dir: dirStr,
|
||||||
|
SortHrefs: hrefs,
|
||||||
|
}
|
||||||
|
if err := s.deps.UI.Render(w, "users", view); err != nil {
|
||||||
|
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
|
||||||
|
// 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) {
|
||||||
|
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) 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,
|
||||||
|
Reenable: r.URL.Query().Get("reenable") == "1",
|
||||||
|
}
|
||||||
|
_ = 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 {
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,301 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
stdhttp "net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
// chrome-less; everything else uses the standard navigation chrome.
|
||||||
func layoutFor(page string) string {
|
func layoutFor(page string) string {
|
||||||
switch page {
|
switch page {
|
||||||
case "login", "bootstrap":
|
case "login", "bootstrap", "setup":
|
||||||
return "chromeless"
|
return "chromeless"
|
||||||
default:
|
default:
|
||||||
return "base"
|
return "base"
|
||||||
|
|||||||
@@ -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));
|
||||||
@@ -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);
|
||||||
@@ -86,3 +86,18 @@ func (s *Store) PurgeExpiredSessions(ctx context.Context) (int64, error) {
|
|||||||
n, _ := res.RowsAffected()
|
n, _ := res.RowsAffected()
|
||||||
return n, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-6
@@ -9,12 +9,15 @@ import (
|
|||||||
|
|
||||||
// User mirrors the users table.
|
// User mirrors the users table.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID string
|
ID string
|
||||||
Username string
|
Username string
|
||||||
PasswordHash string
|
PasswordHash string
|
||||||
Role Role
|
Role Role
|
||||||
CreatedAt time.Time
|
Email *string // optional; nil = not set
|
||||||
LastLoginAt *time.Time
|
DisabledAt *time.Time // nil = enabled
|
||||||
|
MustChangePassword bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
LastLoginAt *time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// Role enumerates the access tiers from spec.md §7.2.
|
// Role enumerates the access tiers from spec.md §7.2.
|
||||||
@@ -219,3 +222,14 @@ type AuditEntry struct {
|
|||||||
TS time.Time
|
TS time.Time
|
||||||
Payload json.RawMessage
|
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
|
||||||
|
}
|
||||||
|
|||||||
+174
-43
@@ -5,67 +5,104 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateUser inserts a new user. The caller is responsible for
|
// CreateUser inserts a row. Username is lowercase-normalised so the
|
||||||
// generating an ID (typically a ULID) and hashing the password.
|
// 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 {
|
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,
|
_, err := s.db.ExecContext(ctx,
|
||||||
`INSERT INTO users (id, username, password_hash, role, created_at)
|
`INSERT INTO users (id, username, password_hash, role, email,
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
must_change_password, created_at)
|
||||||
u.ID, u.Username, u.PasswordHash, string(u.Role), u.CreatedAt.UTC().Format(time.RFC3339Nano))
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
u.ID, u.Username, u.PasswordHash, string(u.Role),
|
||||||
|
nullable(u.Email), must,
|
||||||
|
u.CreatedAt.UTC().Format(time.RFC3339Nano))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store: create user: %w", err)
|
return fmt.Errorf("store: create user: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByUsername looks up a user by their (case-sensitive) username.
|
// GetUserByUsername resolves a user case-insensitively.
|
||||||
// Returns ErrNotFound if no row matches.
|
|
||||||
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
func (s *Store) GetUserByUsername(ctx context.Context, username string) (*User, error) {
|
||||||
row := s.db.QueryRowContext(ctx,
|
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,
|
||||||
FROM users WHERE username = ?`, username)
|
must_change_password, created_at, last_login_at
|
||||||
return scanUser(row)
|
FROM users WHERE LOWER(username) = LOWER(?)`, username)
|
||||||
|
return scanUser(row.Scan)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUserByID looks up a user by id. Returns ErrNotFound on miss.
|
// GetUserByID looks up a user by id. Returns ErrNotFound on miss.
|
||||||
func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
|
func (s *Store) GetUserByID(ctx context.Context, id string) (*User, error) {
|
||||||
row := s.db.QueryRowContext(ctx,
|
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)
|
FROM users WHERE id = ?`, id)
|
||||||
return scanUser(row)
|
return scanUser(row.Scan)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUsers returns every user, sorted by username. Used by surfaces
|
// UserSort selects the column ListUsers orders by. OrderBy is
|
||||||
// that need to render a user-id → username map (audit log filter,
|
// allowlisted in usersOrderColumn so callers can't inject SQL via
|
||||||
// "ack'd by" projections).
|
// this field. Empty / unknown OrderBy falls back to "username".
|
||||||
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
|
type UserSort struct {
|
||||||
rows, err := s.db.QueryContext(ctx,
|
OrderBy string // "username" | "email" | "role" | "last_login_at"
|
||||||
`SELECT id, username, password_hash, role, created_at, last_login_at
|
OrderAsc bool // false = DESC; true = ASC
|
||||||
FROM users ORDER BY username`)
|
}
|
||||||
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: list users: %w", err)
|
return nil, fmt.Errorf("store: list users: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = rows.Close() }()
|
defer func() { _ = rows.Close() }()
|
||||||
var out []User
|
var out []User
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u User
|
u, err := scanUser(rows.Scan)
|
||||||
var role string
|
if err != nil {
|
||||||
var lastLogin sql.NullString
|
return nil, err
|
||||||
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.Role = Role(role)
|
out = append(out, *u)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
@@ -80,6 +117,19 @@ func (s *Store) CountUsers(ctx context.Context) (int, error) {
|
|||||||
return n, nil
|
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.
|
// MarkUserLogin records a successful authentication.
|
||||||
func (s *Store) MarkUserLogin(ctx context.Context, id string, when time.Time) error {
|
func (s *Store) MarkUserLogin(ctx context.Context, id string, when time.Time) error {
|
||||||
_, err := s.db.ExecContext(ctx,
|
_, err := s.db.ExecContext(ctx,
|
||||||
@@ -91,28 +141,109 @@ func (s *Store) MarkUserLogin(ctx context.Context, id string, when time.Time) er
|
|||||||
return nil
|
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 u User
|
||||||
var role string
|
var role string
|
||||||
var lastLogin sql.NullString
|
var email, disabledAt, lastLogin sql.NullString
|
||||||
|
var must int
|
||||||
var created string
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, ErrNotFound
|
return nil, ErrNotFound
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("store: scan user: %w", err)
|
return nil, fmt.Errorf("store: scan user: %w", err)
|
||||||
}
|
}
|
||||||
u.Role = Role(role)
|
u.Role = Role(role)
|
||||||
t, err := time.Parse(time.RFC3339Nano, created)
|
if email.Valid {
|
||||||
if err != nil {
|
v := email.String
|
||||||
return nil, fmt.Errorf("store: parse created_at: %w", err)
|
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
|
u.CreatedAt = t
|
||||||
if lastLogin.Valid {
|
if lastLogin.Valid {
|
||||||
t, err := time.Parse(time.RFC3339Nano, lastLogin.String)
|
t, _ := time.Parse(time.RFC3339Nano, lastLogin.String)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("store: parse last_login_at: %w", err)
|
|
||||||
}
|
|
||||||
u.LastLoginAt = &t
|
u.LastLoginAt = &t
|
||||||
}
|
}
|
||||||
return &u, nil
|
return &u, nil
|
||||||
|
|||||||
@@ -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) {
|
func TestEnrollmentTokenSingleUse(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
s := openTestStore(t)
|
s := openTestStore(t)
|
||||||
|
|||||||
@@ -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-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-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)
|
- [x] **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-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-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-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
|
- [ ] **P4-07** (S) Per-host tags + dashboard filtering by tag
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
+30
-3
@@ -328,12 +328,20 @@
|
|||||||
text-transform: uppercase; letter-spacing: 0.08em;
|
text-transform: uppercase; letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
.audit-row.head:hover { background: transparent; }
|
.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
|
||||||
|
<a class="sort-header">. */
|
||||||
|
.audit-row.head .sort-header,
|
||||||
|
.user-row.head .sort-header {
|
||||||
color: inherit; text-decoration: none; cursor: pointer;
|
color: inherit; text-decoration: none; cursor: pointer;
|
||||||
display: inline-flex; align-items: baseline; gap: 4px;
|
display: inline-flex; align-items: baseline; gap: 4px;
|
||||||
}
|
}
|
||||||
.audit-row.head .sort-header:hover { color: var(--ink); }
|
.audit-row.head .sort-header:hover,
|
||||||
.audit-row.head .sort-glyph {
|
.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);
|
font-size: 9px; color: var(--accent);
|
||||||
/* keep the row height stable when the glyph appears/disappears */
|
/* keep the row height stable when the glyph appears/disappears */
|
||||||
min-width: 8px; display: inline-block;
|
min-width: 8px; display: inline-block;
|
||||||
@@ -563,6 +571,25 @@
|
|||||||
background: var(--accent);
|
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-result pills (notification test button) ---------- */
|
||||||
.test-pill {
|
.test-pill {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{{define "title"}}Account · restic-manager{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
{{$page := .Page}}
|
||||||
|
<div class="max-w-[520px] mx-auto px-8 pb-14">
|
||||||
|
<div class="crumbs pt-6">
|
||||||
|
<a href="/">Dashboard</a><span class="sep">/</span>
|
||||||
|
<span class="text-ink-mid">account</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-[22px] font-medium tracking-[-0.005em] mt-3.5">Account</h1>
|
||||||
|
<div class="text-[12.5px] text-ink-mute mt-2 leading-[1.6]">
|
||||||
|
Signed in as <span class="mono text-ink-mid">{{$page.Username}}</span>
|
||||||
|
({{$page.Role}}). Change your password below.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if $page.Saved}}
|
||||||
|
<div class="mt-6 panel rounded-[7px] p-4"
|
||||||
|
style="border-color: color-mix(in oklch, var(--ok), transparent 60%);">
|
||||||
|
<div class="text-ok text-[13px]">Password updated.</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<form method="post" action="/settings/account" class="mt-6 panel rounded-[7px] p-6 space-y-4">
|
||||||
|
{{if not $page.MustChange}}
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="current">Current password</label>
|
||||||
|
<input id="current" name="current_password" type="password" class="field"
|
||||||
|
required autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="new">New password</label>
|
||||||
|
<input id="new" name="new_password" type="password" class="field"
|
||||||
|
required minlength="12" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="confirm">Confirm new password</label>
|
||||||
|
<input id="confirm" name="confirm_password" type="password" class="field"
|
||||||
|
required minlength="12" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
{{if $page.Error}}<div class="text-bad text-[12.5px]">{{$page.Error}}</div>{{end}}
|
||||||
|
<button type="submit" class="btn btn-primary btn-block btn-lg">Update password</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{{define "title"}}Forbidden · restic-manager{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
{{$page := .Page}}
|
||||||
|
<div class="max-w-[1280px] mx-auto px-8 pb-14">
|
||||||
|
<div class="crumbs pt-6">
|
||||||
|
<a href="/">Dashboard</a><span class="sep">/</span>
|
||||||
|
<span class="text-ink-mid">forbidden</span>
|
||||||
|
</div>
|
||||||
|
<div class="panel mt-8 rounded-[7px] p-8 max-w-[640px]"
|
||||||
|
style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
|
||||||
|
<div class="text-[14px] font-medium text-bad mb-2">403 — Insufficient role</div>
|
||||||
|
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6]">
|
||||||
|
Your role (<span class="mono">{{$page.Have}}</span>) does not permit
|
||||||
|
this page (<span class="mono">{{$page.Required}}</span> required).
|
||||||
|
Ask your administrator if you need access.
|
||||||
|
</p>
|
||||||
|
<a href="/" class="btn btn-primary mt-5">Back to dashboard</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
Notifications
|
Notifications
|
||||||
{{if not $page.Form}}<span class="mono text-ink-fade text-[11px] ml-1">{{len $page.Channels}}</span>{{end}}
|
{{if not $page.Form}}<span class="mono text-ink-fade text-[11px] ml-1">{{len $page.Channels}}</span>{{end}}
|
||||||
</a>
|
</a>
|
||||||
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Users</span>
|
<a href="/settings/users" class="sub-tab {{if eq $page.ActiveTab "users"}}active{{end}}">Users</a>
|
||||||
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Authentication</span>
|
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Authentication</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{{define "title"}}{{.Title}}{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
{{$page := .Page}}
|
||||||
|
<div class="max-w-[520px] mx-auto px-8 pt-20 pb-14">
|
||||||
|
{{if eq $page.Error "expired"}}
|
||||||
|
<h1 class="text-[22px] font-medium tracking-[-0.005em]">Link expired</h1>
|
||||||
|
<p class="text-pretty text-[13px] text-ink-mute mt-3 leading-[1.6]">
|
||||||
|
This setup link has expired or is invalid. Setup links are valid
|
||||||
|
for one hour from the moment your administrator generates them.
|
||||||
|
</p>
|
||||||
|
<p class="text-[12.5px] text-ink-mute mt-3 leading-[1.6]">
|
||||||
|
Contact your administrator and ask them to regenerate the link.
|
||||||
|
</p>
|
||||||
|
{{else}}
|
||||||
|
<h1 class="text-[22px] font-medium tracking-[-0.005em]">
|
||||||
|
Welcome, <span class="mono">{{$page.Username}}</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-pretty text-[13px] text-ink-mute mt-3 leading-[1.6]">
|
||||||
|
Pick a password to finish setting up your account. The link expires
|
||||||
|
one hour after your administrator generated it, so don't dawdle.
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/setup" class="mt-7 space-y-4">
|
||||||
|
<input type="hidden" name="token" value="{{$page.Token}}" />
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="pw">New password</label>
|
||||||
|
<input id="pw" name="password" type="password" class="field"
|
||||||
|
required minlength="12" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="pw2">Confirm password</label>
|
||||||
|
<input id="pw2" name="password_confirm" type="password" class="field"
|
||||||
|
required minlength="12" autocomplete="new-password" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-block btn-lg">
|
||||||
|
Set password and sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{{if and $page.Error (ne $page.Error "expired")}}
|
||||||
|
<p class="text-bad text-[12.5px] mt-4">{{$page.Error}}</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
{{define "title"}}{{.Title}}{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
{{$page := .Page}}
|
||||||
|
<div class="max-w-[760px] mx-auto px-8 pb-14">
|
||||||
|
<div class="crumbs pt-6">
|
||||||
|
<a href="/">Dashboard</a><span class="sep">/</span>
|
||||||
|
<a href="/settings">Settings</a><span class="sep">/</span>
|
||||||
|
<a href="/settings/users">Users</a><span class="sep">/</span>
|
||||||
|
<span class="text-ink-mid">{{if eq $page.Mode "new"}}new{{else if eq $page.Mode "setup-link"}}setup link{{else}}{{$page.Username}}{{end}}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-[22px] font-medium tracking-[-0.005em] mt-3.5">
|
||||||
|
{{if eq $page.Mode "new"}}New user
|
||||||
|
{{else if eq $page.Mode "setup-link"}}Setup link for <span class="mono">{{$page.Username}}</span>
|
||||||
|
{{else}}Edit <span class="mono">{{$page.Username}}</span>{{end}}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{{/* 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}}
|
||||||
|
<div class="panel mt-5 rounded-[7px] p-5"
|
||||||
|
style="border-color: color-mix(in oklch, var(--warn), transparent 50%);
|
||||||
|
background: color-mix(in oklch, var(--warn), transparent 95%);">
|
||||||
|
<div class="text-[13px] font-medium text-warn mb-2">Username already exists (disabled)</div>
|
||||||
|
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6] mb-4">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<form method="post" action="/settings/users/{{$page.ID}}/enable">
|
||||||
|
<button type="submit" class="btn btn-primary">Re-enable {{$page.Username}}</button>
|
||||||
|
</form>
|
||||||
|
<a href="/settings/users/new" class="btn">Pick a different username</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if eq $page.Mode "setup-link"}}
|
||||||
|
{{if eq $page.Error "expired"}}
|
||||||
|
<div class="panel mt-7 rounded-[7px] p-6"
|
||||||
|
style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
|
||||||
|
<div class="text-[13px] font-medium text-bad mb-2">Link expired or already used</div>
|
||||||
|
<p class="text-pretty text-[12.5px] text-ink-mute leading-[1.6]">
|
||||||
|
This user's setup token is no longer valid. Open their Edit page and click
|
||||||
|
<span class="mono">Regenerate setup link</span> to issue a new one.
|
||||||
|
</p>
|
||||||
|
<a href="/settings/users/{{$page.ID}}/edit" class="btn btn-primary mt-5">Open edit page</a>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="panel mt-7 rounded-[7px] p-6">
|
||||||
|
<p class="text-pretty text-[13px] text-ink-mute leading-[1.6] mb-3">
|
||||||
|
Send this link to the user. It expires at
|
||||||
|
<span class="mono text-ink-mid">{{absTime $page.SetupExpAt}}</span> UTC
|
||||||
|
(~1 hour from now). This is the only time you'll see it — if you lose
|
||||||
|
it, regenerate from the Edit page.
|
||||||
|
</p>
|
||||||
|
<div class="mono text-[13px] text-ink p-3 rounded"
|
||||||
|
style="background: var(--bg); border: 1px solid var(--line-soft); word-break: break-all;"
|
||||||
|
id="setup-url">{{$page.SetupURL}}</div>
|
||||||
|
<button type="button" class="btn btn-primary mt-4"
|
||||||
|
onclick="navigator.clipboard.writeText(document.getElementById('setup-url').textContent.trim()).then(function(){var b=event.target;b.textContent='Copied';setTimeout(function(){b.textContent='Copy link';},1500)})">Copy link</button>
|
||||||
|
<a href="/settings/users" class="btn ml-2">Done</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{else}}
|
||||||
|
{{/* new + edit form. */}}
|
||||||
|
<form method="post"
|
||||||
|
action="{{if eq $page.Mode "new"}}/settings/users/new{{else}}/settings/users/{{$page.ID}}/edit{{end}}"
|
||||||
|
class="panel mt-7 rounded-[7px] p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="username">Username</label>
|
||||||
|
<input id="username" name="username" type="text"
|
||||||
|
class="field mono"
|
||||||
|
{{if ne $page.Mode "new"}}readonly disabled{{end}}
|
||||||
|
value="{{$page.Username}}"
|
||||||
|
autocomplete="off" required />
|
||||||
|
<div class="field-help">Lowercased automatically.</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="email">Email <span class="text-ink-fade font-normal">· optional</span></label>
|
||||||
|
<input id="email" name="email" type="email" class="field"
|
||||||
|
value="{{$page.Email}}" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="field-label" for="role">Role</label>
|
||||||
|
<select id="role" name="role" class="field">
|
||||||
|
<option value="admin" {{if eq $page.Role "admin"}}selected{{end}}>admin</option>
|
||||||
|
<option value="operator" {{if eq $page.Role "operator"}}selected{{end}}>operator</option>
|
||||||
|
<option value="viewer" {{if eq $page.Role "viewer"}}selected{{end}}>viewer</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{{if $page.Error}}<div class="text-bad text-[12.5px]">{{$page.Error}}</div>{{end}}
|
||||||
|
<div class="flex gap-2 pt-2">
|
||||||
|
<button type="submit" class="btn btn-primary">{{if eq $page.Mode "new"}}Create user{{else}}Save changes{{end}}</button>
|
||||||
|
<a href="/settings/users" class="btn">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{if eq $page.Mode "edit"}}
|
||||||
|
{{/* Side actions: regenerate setup link, disable / re-enable, force logout. */}}
|
||||||
|
<div class="panel mt-5 rounded-[7px] p-6">
|
||||||
|
<div class="text-[12.5px] text-ink mb-3 font-medium">Other actions</div>
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<form method="post" action="/settings/users/{{$page.ID}}/regenerate-setup">
|
||||||
|
<button type="submit" class="btn">Regenerate setup link</button>
|
||||||
|
</form>
|
||||||
|
<form method="post" action="/settings/users/{{$page.ID}}/force-logout">
|
||||||
|
<button type="submit" class="btn">Force logout</button>
|
||||||
|
</form>
|
||||||
|
{{if $page.Disabled}}
|
||||||
|
<form method="post" action="/settings/users/{{$page.ID}}/enable">
|
||||||
|
<button type="submit" class="btn">Re-enable user</button>
|
||||||
|
</form>
|
||||||
|
{{else}}
|
||||||
|
<form method="post" action="/settings/users/{{$page.ID}}/disable">
|
||||||
|
<button type="submit" class="btn btn-danger">Disable user</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
{{define "title"}}Users · restic-manager{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
{{$page := .Page}}
|
||||||
|
<div class="max-w-[1280px] mx-auto px-8 pb-14">
|
||||||
|
<div class="crumbs pt-6">
|
||||||
|
<a href="/">Dashboard</a><span class="sep">/</span>
|
||||||
|
<a href="/settings">Settings</a><span class="sep">/</span>
|
||||||
|
<span class="text-ink-mid">users</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-baseline justify-between mt-3.5">
|
||||||
|
<h1 class="text-[22px] font-medium tracking-[-0.005em]">
|
||||||
|
Users
|
||||||
|
<span class="text-ink-fade font-normal text-[14px] ml-2">{{len $page.Users}}</span>
|
||||||
|
</h1>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<a href="/settings/users/new" class="btn btn-primary">+ Add user</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" action="/settings/users" class="mt-3 text-[12px] text-ink-mute">
|
||||||
|
<label class="cursor-pointer flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="show_disabled" value="1"
|
||||||
|
{{if $page.ShowDisabled}}checked{{end}}
|
||||||
|
onchange="this.form.submit()" />
|
||||||
|
Show disabled users
|
||||||
|
</label>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="panel mt-4 rounded-[7px] overflow-hidden">
|
||||||
|
{{/* 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. */}}
|
||||||
|
<div class="user-row head">
|
||||||
|
<div>
|
||||||
|
<a href="{{index $page.SortHrefs "username"}}"
|
||||||
|
class="sort-header">Username <span class="sort-glyph">{{sortGlyph "username" $page.Sort $page.Dir}}</span></a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{index $page.SortHrefs "email"}}"
|
||||||
|
class="sort-header">Email <span class="sort-glyph">{{sortGlyph "email" $page.Sort $page.Dir}}</span></a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{index $page.SortHrefs "role"}}"
|
||||||
|
class="sort-header">Role <span class="sort-glyph">{{sortGlyph "role" $page.Sort $page.Dir}}</span></a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="{{index $page.SortHrefs "last_login_at"}}"
|
||||||
|
class="sort-header">Last login <span class="sort-glyph">{{sortGlyph "last_login_at" $page.Sort $page.Dir}}</span></a>
|
||||||
|
</div>
|
||||||
|
<div>Status</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
{{range $page.Users}}
|
||||||
|
<div class="user-row{{if .Disabled}} disabled{{end}}">
|
||||||
|
<div class="mono text-ink">
|
||||||
|
<a href="/settings/users/{{.ID}}/edit" class="hover:underline">{{.Username}}</a>
|
||||||
|
</div>
|
||||||
|
<div class="mono text-ink-mid text-[12px]">{{if .Email}}{{.Email}}{{else}}<span class="text-ink-fade">—</span>{{end}}</div>
|
||||||
|
<div class="mono text-[12px] text-ink-mid">{{.Role}}</div>
|
||||||
|
<div class="mono text-[12px] text-ink-mute">
|
||||||
|
{{if eq .LastLoginAt "never"}}<span class="text-ink-fade">never</span>{{else}}{{.LastLoginAt}}{{end}}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{if .Disabled}}<span class="tag" style="color: var(--ink-fade);">disabled</span>
|
||||||
|
{{else if .MustChangePassword}}<span class="tag" style="color: var(--warn); border-color: color-mix(in oklch, var(--warn), transparent 60%); background: color-mix(in oklch, var(--warn), transparent 92%);">setup pending</span>
|
||||||
|
{{else}}<span class="tag" style="color: var(--ok);">enabled</span>{{end}}
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<a href="/settings/users/{{.ID}}/edit" class="btn">Edit</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user