diff --git a/docs/design/user-dashboard-self-service.md b/docs/design/user-dashboard-self-service.md
new file mode 100644
index 0000000..75ee170
--- /dev/null
+++ b/docs/design/user-dashboard-self-service.md
@@ -0,0 +1,1096 @@
+# Web UI Dashboard Revamp & Account Self-Service
+
+| Field | Value |
+|-------|-------|
+| **Status** | Draft (revision 3 — dual-accept session revalidation + required claims overlay) |
+| **Author** | (implementation owner) |
+| **Date** | 2026-07-27 |
+| **Audience** | Senior engineers working in `internal/web`, `internal/db`, `pkg/protocol` |
+| **Related** | `docs/enumerations.md`, `Agents.md` §6 boring-web, `~/.grok/skills/boring-web/` |
+
+---
+
+## Overview
+
+openfsd’s web UI today is an **operator console** that happens to live behind a login: the dashboard is a live map/connection list with links that appear only for elevated ratings, sweatbox and airport tools are **Administrator-only**, and the user editor is reachable by **Instructor1+** (with full create/name/password limited to Supervisor+). There is **no self-service account surface** — normal OBS/S1/… pilots who can log in cannot change their password or close their account from the UI.
+
+This design turns the authenticated web app into a **proper user dashboard for every signed-in certificate**, adds **change-password** and **soft-delete account** self-service (hard-delete optional via config), realigns tool authz (**Sweatbox → I1+**, **User editor → SUP+**), and fixes the user-editor bug where create/edit pilot-rating selects only offer **P0** when the actor’s own pilot rating is P0.
+
+Soft-delete (and live demotion) is made **operationally real** by revalidating the certificate against the database on **every session-cookie authentication path** — HTML (`requireSessionHTML`) and dual-accept API (`trySessionAuth`) — plus token refresh. After a successful DB load, **in-memory claims are overlaid from the DB** (rating + names) before handlers run, so ceilings and nav flags cannot use stale JWT fields. The only residual window is **Bearer access tokens** (15m TTL).
+
+All UI work stays within the **boring progressive-enhancement** house standard: server-rendered MPA, forms + redirect-after-POST, CSRF on mutations, no SPA/client router/global store. `internal/web` continues to talk to FSD only via service HTTP + `serviceapi` DTOs (no new import edges into `server`/`session`/`postoffice`/`sweatbox`).
+
+---
+
+## Background & Motivation
+
+### Current state (code as of design)
+
+| Surface | Path / gate | Notes |
+|---------|-------------|--------|
+| Landing | `GET /` | Public; “Go to dashboard” if session present (`handleFrontendLanding`) |
+| Login | `GET|POST /login` | Form CSRF; rejects `network_rating ≤ Suspended(0)` (`frontend.go`) |
+| Dashboard | `GET /dashboard` | Any session (`requireSessionHTML`); connection summary + Leaflet PE |
+| User editor | `GET/POST /usereditor*` | **I1+** route group (`routes.go` L123–127); create/full profile **SUP+** in handlers |
+| Config | `GET/POST /configeditor*` | **ADM** |
+| Sweatbox | `GET/POST /sweatbox*` | **ADM** (same `admin` group as config) |
+| Airport editor | `GET/POST /airport-editor*` | **ADM** |
+| JSON user API | `/api/v1/user/*` | Bearer or session+CSRF; I1+ for non-self load / rating update |
+| Kick | `POST /api/v1/fsdconn/kickuser` | **SUP+** (`fsdconn.go` — unchanged by this design) |
+| Session gate | `requireSessionHTML` | Parses session JWT only; **no DB reload** of user rating |
+| Template keys | `pageTemplateKeys` in `templates.go` | Explicit allowlist; unknown keys → `writeTemplate` 500 |
+
+Privilege helpers live in `internal/web/user_authz.go` and `pageUserFromClaims` (`pagemodel.go`):
+
+- `canAccessUserEditor` / `CanEditUsers` → `NetworkRatingInstructor1` (8)
+- `canFullMutateUsers` → `NetworkRatingSupervisor` (11)
+- `CanEditConfig` → `NetworkRatingAdministator` (12) — **also gates Sweatbox nav links** in `layout.html` / `dashboard.html`
+
+Network ratings (`pkg/protocol/types.go`, `docs/enumerations.md`):
+
+| Value | Name | Login allowed? |
+|------:|------|----------------|
+| −1 | Inactive | No (`≤ Suspended`) |
+| 0 | Suspended | No |
+| 1…12 | OBS … ADM | Yes |
+
+FSD TCP login (`internal/server/conn.go`) rejects requested rating `< OBS` with certificate suspended/inactive, and rejects password login when requested rating exceeds the certificate’s stored rating. Soft-deleting via **Inactive (−1)** therefore already blocks **new** web sessions, FSD JWT mint (`auth.go` `getFsdJwt` / `getAccessRefreshTokens`), and practical FSD password logons. **Existing session cookies are not revoked today** — this design closes that gap (KD-9 / session revalidation).
+
+### Pain points
+
+1. **Dashboard is not a user home** — no account/profile self-service; tool links are jammed into the welcome footer and only for admins.
+2. **No change-password for self** — operators must use the user editor (SUP+) or touch the DB.
+3. **No account off-boarding** — no soft-delete path for end users; `UserRepository` has no delete method; soft-setting Inactive via user editor leaves the victim’s session JWT live until TTL.
+4. **Authz mismatch with product intent** — instructors cannot run sweatbox (ADM-only); instructors *can* open the user directory (product wants SUP+ only).
+5. **Pilot rating select bug** — create/edit options are built with `pilotRatingOptionsUpTo(actorPilotRatingCeiling(actorCID), …)`. `actorPilotRatingCeiling` loads the actor’s **own** `users.pilot_rating`. Most ADM/SUP certs are P0 → select only contains P0. Server POST also rejects `pilotRating > actorPilotMax` (`pages_user.go` create/update, `user.go` JSON API).
+
+### Constraints (non-negotiable)
+
+- Boring PE web (`Agents.md` §6); no Playwright/Cypress.
+- Import graph: web ↛ server/session/postoffice/metar/sweatbox.
+- Single binary `cmd/openfsd`; web config via env on `web.ServerConfig`.
+- Prefer PE route tests in `internal/web` (`pe_test.go`, `pe_admin_test.go` patterns).
+
+---
+
+## Goals & Non-Goals
+
+### Goals
+
+1. **User dashboard for all authenticated users** (any rating that can hold a session: OBS+).
+2. **Self-service change password** (must supply current password).
+3. **Self-service delete account** — **soft-delete default** (`network_rating = Inactive`); optional **hard-delete** behind config default **false**; both require **current password** + CID confirm.
+4. **Effective soft-delete / demotion for live sessions** — shared DB revalidation on `requireSessionHTML` **and** `trySessionAuth` (session dual-accept); overlay claims from DB; reject inactive/suspended (HTML: clear cookie → login; API: 401).
+5. **Authz realignment:**
+ - Sweatbox HTML + PE JSON proxies: **I1+**
+ - User editor HTML + non-self user JSON: **SUP+**
+6. **Fix pilot rating options** so SUP+ editors can select the full official pilot scale (P0…FE).
+7. **Navigation** consistent with existing `layout.html` + `theme.css` / `theme.js` chrome.
+8. **PE tests** covering new forms, authz gates, soft/hard delete, session revalidation, and pilot-rating fix.
+
+### Non-Goals
+
+- SPA dashboard, WebSocket map, or client-side routing.
+- Email verification, OAuth, or multi-factor auth.
+- Full server-side session store / password-epoch column (stateless residual: **Bearer access** 15m after password change / soft-delete; session cookies revalidate from DB on every use — see KD-9).
+- Letting users edit their own network/pilot ratings from the account page.
+- Moving airport editor or config editor off Administrator.
+- Restoring I1 “rating-only” user directory after the SUP+ gate (explicit product change).
+- Cross-user admin hard-delete UI (SUP can already set Inactive via user editor; hard-delete is self-service only when enabled).
+- Web-layer rate limiting infrastructure (document as follow-up; FSD already rate-limits TCP auth).
+- Changing kick authz (remains SUP+).
+
+---
+
+## Key Decisions
+
+| ID | Decision | Rationale |
+|----|----------|-----------|
+| **KD-1** | Soft-delete = set `users.network_rating = protocol.NetworkRatingInactive` (−1) | Already in schema; login paths already reject `≤ Suspended`; FSD cannot usefully log in with max rating −1; reversible by SUP setting a positive rating; no migration required. |
+| **KD-2** | Hard-delete is **opt-in** via web env `ALLOW_PERMANENT_ACCOUNT_DELETE` (default `false`); only **self-service** | Matches product; avoids accidental irreversible deletes; operators can soft-disable others via user editor. Env lives on web `ServerConfig` (same bool/envconfig style as `COOKIE_SECURE`; FSD’s `SWEATBOX_ENABLED` is the parallel pattern on the FSD process, not web). |
+| **KD-3** | Account self-service lives at **`/account`** (GET + POST actions), linked from dashboard + primary nav | Keeps `/dashboard` focused on network situational awareness; forms get a clean page without overloading the map template; PE-friendly. |
+| **KD-4** | User editor route + API non-self access raised to **SUP+**; remove I1 rating-only editor access | Product requirement; simplifies authz matrix; I1 gains sweatbox instead. |
+| **KD-5** | Sweatbox HTML + `/api/v1/sweatbox/*` min rating **I1+**; airport editor & config remain **ADM** | Product only moves sweatbox; airport editor is a separate heavy tool and stays admin. |
+| **KD-6** | SUP+ user create/update may assign **any official pilot rating** (`PilotRatingScale` through FE); drop actor pilot-rating ceiling for editors | Root cause of P0-only bug; pilot rating is a certificate attribute admins grant, not something bounded by the operator’s own flying quals. Network rating ceiling (≤ actor) **retained**. |
+| **KD-7** | After successful password change: re-hash, **always re-issue session with `rememberMe=false`** (`sessionDefaultTTL` = 24h), and **rotate CSRF** via `clearCSRFCookie` + `issueCSRFToken` (same pattern as successful login in `frontend.go`) | Password change is a security event; remember-me is **not** preserved. Other devices with unexpired session JWTs remain until TTL or until DB revalidation fails for inactive users (not applicable to password-only change). |
+| **KD-8** | After account delete (soft or hard): verify **current password** + CID confirm; **clear session + CSRF**, 303 to `/login?account=deleted` with a **required** login info banner | Destructive action needs step-up beyond CSRF/CID; user must not retain an authenticated cookie; PRG flash must be visible. |
+| **KD-9** | **In-scope:** shared session revalidation helper used by **`requireSessionHTML` and `trySessionAuth`**: load user by CID; if missing or `network_rating ≤ Suspended`, reject (HTML: clear session+CSRF → 303 `/login`; session API: return false → 401 unauthorized). On success, **required claims overlay** from DB (`NetworkRating`, `FirstName`, `LastName`) before `setJwtContext`. Also reject inactive/suspended on login, FSD JWT mint, and **refresh**. | Soft-delete and demotion must revoke **session-cookie** HTML **and** PE/JSON dual-accept immediately (session cookies are 24h/30d, not 15m). Handlers and `requireMinRatingHTML` keep reading `claims.NetworkRating` safely only because overlay is mandatory. **True residual only:** Bearer **access** tokens until 15m expiry (Bearer path does not use session revalidation). |
+| **KD-10** | Nav flags: `CanAccessSweatbox` (I1+) vs `CanEditConfig` (ADM) vs `CanEditUsers` (SUP+). Drop page-level `CanAdjustRatings` as a distinct capability; keep target-level `canFullMutateTarget` for profile locks inside the editor. | Today sweatbox incorrectly shares `CanEditConfig`. After KD-4, I1 rating-only is gone; three SUP-threshold helpers collapse to two page flags + one target check. |
+| **KD-11** | Shared `validateNewPassword(pw string) error` for account + user-editor create/update (HTML). New ≠ current is **required** on self change-password. | Prevents rule drift (≥8, no `:`) between surfaces. |
+| **KD-12** | Register `"account"` in `pageTemplateKeys` (`templates.go`) | `writeTemplate` 500s on unknown keys; implementers must not omit this. |
+
+---
+
+## Proposed Design
+
+### Architecture (request flow)
+
+```mermaid
+flowchart TB
+ subgraph public [Public]
+ L[GET /login]
+ LP[POST /login]
+ end
+
+ subgraph authed ["requireSessionHTML + revalidateSessionFromDB (OBS+)"]
+ D[GET /dashboard]
+ A[GET /account]
+ AP[POST /account/password]
+ AD[POST /account/delete]
+ end
+
+ subgraph i1 [requireMinRatingHTML I1+]
+ SB[GET/POST /sweatbox*]
+ SBapi["GET /api/v1/sweatbox/* (trySessionAuth + same revalidation)"]
+ end
+
+ subgraph sup [requireMinRatingHTML SUP+]
+ UE[GET/POST /usereditor*]
+ Uapi["/api/v1/user load|update|create non-self"]
+ end
+
+ subgraph adm [requireMinRatingHTML ADM]
+ CFG[GET/POST /configeditor*]
+ APT[GET/POST /airport-editor*]
+ end
+
+ LP -->|session cookie| D
+ D --> A
+ A --> AP
+ A --> AD
+ AD -->|clear cookie| L
+ authed -->|inactive/suspended/missing user| L
+```
+
+### Session revalidation (normative — KD-9)
+
+**Today:**
+
+| Path | Behavior |
+|------|----------|
+| `requireSessionHTML` | `parseSessionCookie` only → claims frozen at login |
+| `trySessionAuth` (dual-accept for `/api/v1/*`) | Same cookie parse; **no DB check** — session cookies last 24h/30d |
+| `tryBearerAuth` | Access JWT only (15m TTL); no DB check |
+
+**Problem if only HTML is revalidated:** soft-deleted users lose `/dashboard` but PE/JSON with the same session cookie (sweatbox poll, user load/update, config) keeps working with elevated frozen claims until session TTL.
+
+#### Shared helper (required)
+
+Introduce one helper used by **both** HTML and session dual-accept (names illustrative):
+
+```go
+// revalidateSessionClaims parses is done by caller; this loads DB and overlays.
+// Returns overlaid claims + user, or errSessionInactive / errSessionUserMissing.
+func (s *Server) revalidateSessionFromDB(claims *auth.CustomClaims) (*auth.CustomClaims, *db.User, error) {
+ user, err := s.dbRepo.UserRepo.GetUserByCID(claims.CID)
+ if err != nil {
+ return nil, nil, errSessionUserMissing // wrap sql.ErrNoRows
+ }
+ if user.NetworkRating <= int(protocol.NetworkRatingSuspended) {
+ return nil, nil, errSessionInactive
+ }
+ // REQUIRED claims overlay — not optional. Handlers and requireMinRatingHTML
+ // continue to read claims.NetworkRating / names; demotions must be visible.
+ claims.NetworkRating = protocol.NetworkRating(user.NetworkRating)
+ claims.FirstName = safeStr(user.FirstName)
+ claims.LastName = safeStr(user.LastName)
+ return claims, user, nil
+}
+```
+
+**Do not** leave “overlay optional; minimum is reject inactive.” Soft-delete alone is insufficient: demotion **ADM → SUP** (still `> Suspended`) must refresh rating so network ceilings and nav flags cannot stay at ADM.
+
+#### `requireSessionHTML`
+
+```go
+claims, err := s.parseSessionCookie(c)
+if err != nil {
+ redirect /login; abort
+}
+claims, user, err := s.revalidateSessionFromDB(claims)
+if err != nil {
+ s.clearSessionCookie(c)
+ s.clearCSRFCookie(c)
+ redirect /login; abort
+}
+setJwtContext(c, claims)
+// optional: c.Set("db_user", user) for account page without second GetUserByCID
+c.Next()
+```
+
+#### `trySessionAuth` (dual-accept — in scope, same helper)
+
+```go
+// Called from jwtBearerMiddleware when Bearer fails.
+claims, err := s.parseSessionCookie(c)
+if err != nil {
+ return false
+}
+claims, user, err := s.revalidateSessionFromDB(claims)
+if err != nil {
+ // Do not clear cookies on every API 401 if that races multi-tab HTML;
+ // preferred: clear cookies on inactive (same as HTML) so PE stops looping.
+ // Spec: clear session+CSRF on inactive/missing so soft-delete is consistent.
+ s.clearSessionCookie(c)
+ s.clearCSRFCookie(c)
+ return false // middleware → 401 unauthorized
+}
+setJwtContext(c, claims)
+// optional stash user
+return true
+```
+
+#### Bearer access tokens (true residual only)
+
+`tryBearerAuth` is **unchanged** by KD-9 revalidation: pure Bearer **access** tokens remain valid until their **15m** expiry. Refresh rejects inactive (PR3). Do **not** equate session-cookie dual-accept residual with 15m — session cookies are 24h/30d and **must** revalidate.
+
+#### Rating / identity source after revalidation
+
+| Use | Source |
+|-----|--------|
+| Reject inactive/suspended / missing | DB `users.network_rating` |
+| `claims.NetworkRating` after overlay | **DB** (required overlay) |
+| `requireMinRatingHTML` | `claims.NetworkRating` (**safe** because overlay ran first) |
+| Handler ceilings (`pages_user`, JSON `updateUser`/`createUser`) | `claims.NetworkRating` (**safe** after overlay) |
+| `pageUserFromClaims` nav flags | claims after overlay |
+| Display names | claims after overlay (= DB first/last) |
+
+No separate “use DB only in middleware, claims elsewhere” split — that was error-prone.
+
+**Implementation notes:**
+
+- Load user once per request in the revalidation helper; stash `*db.User` on gin context if account handlers want it.
+- slog unexpected DB errors; fail closed (treat as missing user).
+- Cookie clear on API inactive: yes (spec above) so soft-deleted PE clients stop authenticating.
+
+**PE / API tests (required):**
+
+| Test | Asserts |
+|------|---------|
+| `TestSessionRejectedAfterSoftDelete` | Soft-delete (or set Inactive); old session cookie `GET /dashboard` → 303 `/login`; cookie cleared |
+| `TestAPISessionRejectedAfterSoftDelete` | Same cookie `GET /api/v1/sweatbox/state` or `POST /api/v1/user/load` (self) → **401**; not 200 with data |
+| `TestClaimsOverlayAfterDemotion` | Login as ADM; DB demote to SUP; `GET /dashboard` nav lacks Config; `POST /usereditor/create` with `network_rating=12` rejected (ceiling uses overlaid SUP claims) |
+
+### Authz matrix (normative)
+
+| Capability | Min network rating | Notes |
+|------------|-------------------:|-------|
+| Web login / session | OBS (1)+ | `≤ Suspended` rejected with generic error |
+| Dashboard | any **DB-valid** session | Map + connections remain for all |
+| Account page, change password, delete self | any DB-valid session | Self-only; CSRF; delete also requires current password |
+| Sweatbox UI + PE APIs | I1 (8)+ | Was ADM |
+| User editor + user JSON mutate / non-self load | SUP (11)+ | Was I1 for access / rating-only |
+| Create user | SUP (11)+ | Unchanged threshold |
+| Config editor, reset secret, API tokens | ADM (12) | Unchanged |
+| Airport editor | ADM (12) | Unchanged |
+| Kick active connection (`/api/v1/fsdconn/kickuser`) | **SUP (11)+** | Unchanged; already SUP+ in `fsdconn.go` — **not** ADM |
+
+### Soft-delete semantics
+
+**Mark deleted:**
+
+```text
+UPDATE users
+SET network_rating = -1 -- protocol.NetworkRatingInactive
+WHERE cid = ?
+```
+
+**Effects (already true or reinforced):**
+
+| Path | Behavior |
+|------|----------|
+| `POST /login`, `POST /api/v1/auth/login`, `POST /j` / `fsd-jwt` | Reject if `network_rating ≤ Suspended` — **Inactive included** |
+| `POST /api/v1/auth/refresh` | Re-load user; reject if rating ≤ Suspended (**fix — in scope**) |
+| `requireSessionHTML` | Shared revalidation + **required** claims overlay; reject if missing or rating ≤ Suspended (**KD-9**) |
+| `trySessionAuth` (session dual-accept) | **Same helper** as HTML; reject → 401; clear cookies on inactive (**KD-9**, not optional) |
+| Bearer access (`tryBearerAuth`) | Unchanged; **only** residual (15m) until expiry |
+| FSD password login | Max rating −1 → any OBS+ request fails “level too high”; requested < OBS fails suspended |
+| FSD JWT login | JWT mint blocked; stale FSD JWT still expires in 5 minutes |
+| User editor | Soft-deleted users remain listable/filterable as Inactive; SUP may **restore** by setting rating ≥ OBS |
+| Display | Directory short code stays `INAC`; account/login copy says “Account deleted” for self-service |
+
+**Do not** scramble the password on soft-delete (allows operator restore without password reset tooling). Optional future: set unusable hash — not in this design.
+
+### Hard-delete semantics
+
+When `ServerConfig.AllowPermanentAccountDelete == true` **and** the user explicitly opts in on the form:
+
+```text
+DELETE FROM users WHERE cid = ?
+```
+
+- Implemented as `UserRepository.DeleteUser(cid int) error` returning `sql.ErrNoRows` if missing.
+- No cascading tables today (users are standalone); no migration.
+- Irreversible. Under SQLite **`INTEGER PRIMARY KEY AUTOINCREMENT`**, deleted CIDs are **not** reused; new users receive new CIDs from `sqlite_sequence`. Operators must not assume CID recycling.
+- **Self only** via `POST /account/delete`. No admin hard-delete form in v1.
+- If config is false: permanent option hidden or non-functional; if client still posts `permanent=1`, **always soft-delete** and PRG with an explicit flash that permanent delete is disabled (never silent hard-delete; never 400-only without user-visible outcome on success path).
+
+### Change-password flow
+
+```mermaid
+sequenceDiagram
+ participant U as Browser
+ participant W as web.Server
+ participant DB as UserRepo
+
+ U->>W: GET /account (session + DB revalidation)
+ W->>U: HTML form + CSRF
+ U->>W: POST /account/password (csrf, current, new, confirm)
+ W->>W: validateCSRF
+ W->>DB: GetUserByCID(claims.CID)
+ W->>W: VerifyPasswordHash(current)
+ W->>W: validateNewPassword(new); confirm match; new ≠ current
+ alt bad current / validation
+ W->>U: 200 re-render field errors
+ else ok
+ W->>DB: UpdateUser(password=new, other fields unchanged)
+ W->>W: setSessionCookie(user, rememberMe=false)
+ W->>W: clearCSRFCookie + issueCSRFToken
+ W->>U: 303 /account?flash=password_changed
+ end
+```
+
+**Validation (server authoritative, normative):**
+
+| Rule | Value |
+|------|--------|
+| Current password | Required; must match bcrypt hash |
+| New password | `validateNewPassword`: length ≥ 8; must not contain `:` (same as user editor + `user_sqlite.go`) |
+| Confirm | Must equal new |
+| New ≠ current | **Required** — reject with clear field error |
+| CSRF | Required (`validateCSRF`) |
+
+**Shared helper (KD-11):**
+
+```go
+// validateNewPassword returns a user-visible error string, or "" if OK.
+// Used by account change-password and user-editor create/update (when password non-empty).
+func validateNewPassword(pw string) string {
+ if len(pw) < 8 {
+ return "Password must be at least 8 characters"
+ }
+ if strings.Contains(pw, ":") {
+ return "Password cannot contain colon characters"
+ }
+ return ""
+}
+```
+
+Refactor `pages_user.go` create/update password checks to call this helper (same strings as today).
+
+**Session policy (KD-7 — single rule):**
+On success always `setSessionCookie(user, false)` → **24h** `sessionDefaultTTL`. Rotate CSRF: `clearCSRFCookie` then `issueCSRFToken` (embed new token via redirect target page). Remember-me is **not** preserved across password change.
+
+**Multi-device residual after password change only:** Other browsers keep session cookies until TTL; password change does not kill them. Soft-delete / demotion **does** take effect on next request (KD-9 revalidation + overlay). Document briefly in account page help text: “Changing your password does not sign out other devices until their session expires. Deleting or disabling the account ends other sessions on their next request.”
+
+### Delete-account flow
+
+Form on `GET /account`:
+
+- Section “Delete my account” with strong warning copy
+- **Current password** field (required) — step-up authentication
+- Confirmation text field: user must type their **CID** (string match) — anti-misclick
+- If hard-delete allowed by config: optional checkbox `permanent=1`, default **unchecked**, with strong irreversible warning
+- If hard-delete **not** allowed: do not show permanent checkbox (or show disabled with “not enabled on this server”)
+- POST fields: `csrf_token`, `current_password`, `confirm_cid`, optional `permanent`
+
+Handler:
+
+1. CSRF + session CID (after `requireSessionHTML` DB revalidation)
+2. Load user; if already inactive/missing → clear cookie, 303 login
+3. **Verify `current_password`** with `VerifyPasswordHash`; on failure → 200 re-render `DeleteError` (“Incorrect password”); do not delete
+4. Verify `confirm_cid` matches `strconv.Itoa(user.CID)`; on failure → 200 re-render
+5. If `permanent` requested and config **disallows**:
+ - Perform **soft-delete** only
+ - Clear session + CSRF
+ - `303 /login?account=deleted&permanent=disabled` (or single query that login maps to two sentences: deleted + “Permanent delete is not enabled on this server”)
+6. If `permanent` requested and config **allows** → `DeleteUser`
+7. Else → set `NetworkRating = Inactive`, `UpdateUser` with empty password (no hash change)
+8. `clearSessionCookie` + `clearCSRFCookie`
+9. `303 /login?account=deleted` (hard path may use `?account=deleted&mode=hard` if product wants distinct copy; default one banner is enough)
+
+Hard-delete step-up stack: **password + CID confirm + permanent checkbox + config** (four gates).
+
+### Login banner for post-delete (required — not optional)
+
+Extend `loginPage` (`pagemodel.go`):
+
+```go
+type loginPage struct {
+ basePage
+ CID string
+ RememberMe bool
+ Error string
+ CIDError string
+ PassError string
+ Info string // success/info banner (e.g. account deleted)
+}
+```
+
+In `handleFrontendLogin` (`GET /login`):
+
+- Existing behavior: if valid session cookie → 303 `/dashboard` **unchanged**. Delete path must clear cookie first so the banner is reachable.
+- If `c.Query("account") == "deleted"`:
+ - `page.Info = "Your account has been deleted."`
+ - If `permanent=disabled` query also set: append or second sentence: “Permanent delete is not enabled on this server; the account was deactivated instead.” (only when that query is present)
+
+In `login.html`:
+
+```html
+{{ if .Info }}
+
{{ .Info }}
+{{ end }}
+```
+
+Use theme-friendly Bootstrap alert classes already available; no new CSS required.
+
+### Dashboard revamp (content model)
+
+`handleFrontendDashboard` keeps:
+
+- Server-rendered connection summary (`fetchOnlineUsers` → service HTTP `/online_users`)
+- Leaflet map PE (`dashboard.js`) — unchanged JS budget exception
+
+**Add / restructure (server-rendered):**
+
+1. **Header strip:** welcome, CID, network rating label, pilot rating label from DB user row when available (claims lack pilot_rating).
+2. **Tools panel:** links driven by pageUser flags:
+ - Always: Account (`/account`)
+ - SUP+: Users (`/usereditor`)
+ - I1+: Sweatbox (`/sweatbox`)
+ - ADM: Config, Airport Editor
+3. **Remove** the old dual-button footer that only shows elevated tools without Account.
+
+Template sketch (`dashboard.html`):
+
+```html
+
+
+
+
+
+ {{ if .User.CanEditUsers }}…Users…{{ end }}
+ {{ if .User.CanAccessSweatbox }}…Sweatbox…{{ end }}
+ {{ if .User.CanEditConfig }}…Config… Airport Editor…{{ end }}
+
+
+
+
+```
+
+Keep using Bootstrap utility classes + `theme.css` tokens; no new framework CSS.
+
+### Layout / nav (`layout.html`)
+
+Update primary nav when `.User` present:
+
+| Link | Condition |
+|------|-----------|
+| Dashboard | always |
+| Account | always (new) |
+| Users | `CanEditUsers` (SUP+) |
+| Sweatbox | `CanAccessSweatbox` (I1+) — **decouple from CanEditConfig** |
+| Config | `CanEditConfig` (ADM) |
+| Airport Editor | `CanEditConfig` (ADM) |
+| Log out | always |
+
+### `pageUser` flags and authz helpers (KD-10)
+
+After SUP+ user editor, page-level “can adjust ratings” is no longer a distinct tier from “can access editor.” **Cleanup in PR1:**
+
+```go
+type pageUser struct {
+ CID int
+ DisplayName string
+ FirstName string
+ LastName string
+ NetworkRating int
+ NetworkRatingLabel string
+ CanEditUsers bool // SUP+ — Users nav + /usereditor access
+ CanFullMutateUsers bool // SUP+ — create form + profile fields; still subject to canFullMutateTarget
+ CanAccessSweatbox bool // I1+
+ CanEditConfig bool // ADM
+ // CanAdjustRatings REMOVED from pageUser — was I1-only tier; ratings editability
+ // inside user editor is implied by CanEditUsers + RatingsLocked/ProfileLocked.
+}
+```
+
+Helpers (`user_authz.go`):
+
+```go
+func canAccessUserEditor(r protocol.NetworkRating) bool {
+ return r >= protocol.NetworkRatingSupervisor
+}
+func canFullMutateUsers(r protocol.NetworkRating) bool {
+ return r >= protocol.NetworkRatingSupervisor
+}
+// canAdjustUserRatings: keep as alias of canAccessUserEditor for JSON updateUser
+// gate, or inline SUP check and delete the helper — do not leave I1 threshold.
+func canAdjustUserRatings(r protocol.NetworkRating) bool {
+ return r >= protocol.NetworkRatingSupervisor
+}
+func canAccessSweatbox(r protocol.NetworkRating) bool {
+ return r >= protocol.NetworkRatingInstructor1
+}
+// canFullMutateTarget unchanged: SUP+ and target.network_rating ≤ actor
+```
+
+**I1-only branch cleanup (PR1, required — avoid dead code):**
+
+| Location | Action |
+|----------|--------|
+| `pages_user.go` create path “Only supervisors can create” | Still valid as defense-in-depth if route misconfigured; keep handler check |
+| Instructor-only ProfileLocked paths | All editor actors are SUP+; `ProfileLocked` remains for **target rating > actor** (e.g. SUP editing ADM) |
+| Comments saying “Instructor1+ directory” | Update to Supervisor+ |
+| `TestInstructorCanAdjustRatingsButNotCreate` | Replace with `TestInstructorCannotAccessUserEditor` + sweatbox access tests |
+| Templates using `CanAdjustRatings` | Grep and remove; use `CanEditUsers` / lock flags only |
+
+### Route registration (`routes.go`)
+
+```go
+authed := frontendGroup.Group("")
+authed.Use(s.requireSessionHTML) // includes KD-9 DB revalidation
+authed.GET("/dashboard", s.handleFrontendDashboard)
+
+// Account self-service — any DB-valid session
+authed.GET("/account", s.handleFrontendAccount)
+authed.POST("/account/password", s.handleFrontendAccountPassword)
+authed.POST("/account/delete", s.handleFrontendAccountDelete)
+
+// User editor — SUP+
+userAdmin := authed.Group("")
+userAdmin.Use(s.requireMinRatingHTML(protocol.NetworkRatingSupervisor))
+userAdmin.GET("/usereditor", s.handleFrontendUserEditor)
+userAdmin.POST("/usereditor/create", s.handleFrontendUserCreate)
+userAdmin.POST("/usereditor/update", s.handleFrontendUserUpdate)
+
+// Sweatbox — I1+
+instructor := authed.Group("")
+instructor.Use(s.requireMinRatingHTML(protocol.NetworkRatingInstructor1))
+instructor.GET("/sweatbox", s.handleFrontendSweatbox)
+// … all existing sweatbox POST/GET manual routes …
+
+// Config + airport editor — ADM
+admin := authed.Group("")
+admin.Use(s.requireMinRatingHTML(protocol.NetworkRatingAdministator))
+admin.GET("/configeditor", …)
+// … config POSTs …
+admin.GET("/airport-editor", …)
+// … airport POSTs …
+```
+
+JSON sweatbox handlers (`api_sweatbox.go`): change rating check from `Administator` → `Instructor1`; update file comments (“Min rating Instructor1+”).
+
+JSON user handlers (`user.go`):
+
+- `getUserByCID`: self **or** `canAccessUserEditor` (SUP+)
+- `updateUser` / `createUser`: SUP+ gates; no actor pilot ceiling
+
+### Pilot rating bug — root cause & fix
+
+**Root cause (verified):** ceiling is the **actor’s pilot_rating**, not the full protocol scale.
+
+- `actorPilotRatingCeiling` returns `u.PilotRating` (`pages_user.go`)
+- Options use `pilotRatingOptionsUpTo(actorPilotMax, …)` (`user_authz.go`)
+- Create/update HTML and JSON reject `pilotRating > actorPilotMax` (`pages_user.go`, `user.go`)
+- ADM/SUP with default P0 only get option `0`
+
+**Fix (KD-6):**
+
+1. Add `pilotRatingOptionsAll(selected int) []ratingOption` = `pilotRatingOptionsUpTo(int(protocol.PilotRatingFE), selected)` (FE = 63).
+2. In `newUserEditorPage`, `loadUserIntoEditForm`, create/update re-renders: use **all** official options.
+3. Remove create/update checks `pilotRating > actorPilotMax` in HTML handlers and JSON `createUser` / `updateUser`.
+4. Keep `isValidPilotRating` validation.
+5. Delete `actorPilotRatingCeiling` if unused after the change.
+6. Update comments on `userEditorPage.PilotRatingOptions` → “full official scale”.
+7. Tests: SUP with `PilotRating=0` GET `/usereditor?new=1` HTML contains option values `0,1,3,7,15,31,63`; POST create with `pilot_rating=15` succeeds.
+
+Network rating options remain `ratingOptionsUpTo(actorNetworkRating, …)`.
+
+### Config
+
+`internal/web/env.go` — extend `ServerConfig`:
+
+```go
+// AllowPermanentAccountDelete enables the non-default hard-delete checkbox
+// on POST /account/delete. Default false (soft-delete only).
+// Web process env (go-envconfig), same style as CookieSecure; bool default=false
+// matches FSD’s SWEATBOX_ENABLED pattern on the FSD process config.
+AllowPermanentAccountDelete bool `env:"ALLOW_PERMANENT_ACCOUNT_DELETE, default=false"`
+```
+
+No DB config key. Document in root README / env table when packaging docs are touched. `default=false` is already used successfully for FSD `SWEATBOX_ENABLED`.
+
+Wire into account page model:
+
+```go
+type accountPage struct {
+ basePage
+ FlashSuccess string
+ FlashError string
+ // Profile (read-only display)
+ CID int
+ FirstName string
+ LastName string
+ NetworkLabel string
+ PilotLabel string
+ // Password form field errors
+ CurrentPassError string
+ NewPassError string
+ ConfirmPassError string
+ FormError string
+ // Delete
+ DeleteError string
+ DeletePassError string // wrong current password on delete
+ AllowPermanentDelete bool // from cfg
+}
+```
+
+### Data layer
+
+`internal/db/user_repository.go`:
+
+```go
+// DeleteUser permanently removes the user row by CID.
+// Returns sql.ErrNoRows if no row was deleted.
+DeleteUser(cid int) error
+```
+
+`SQLiteUserRepository.DeleteUser`:
+
+```sql
+DELETE FROM users WHERE cid = ?
+```
+
+Tests in `user_sqlite_test.go`: create → delete → GetUserByCID ErrNoRows; delete missing → ErrNoRows.
+
+No schema migration for soft-delete. Soft-delete uses existing `UpdateUser` (empty password → no hash change).
+
+### Handler file layout
+
+Prefer new file **`internal/web/pages_account.go`**:
+
+- `handleFrontendAccount`
+- `handleFrontendAccountPassword`
+- `handleFrontendAccountDelete`
+- helpers: `loadAccountPage`, uses shared `validateNewPassword`
+
+Also touch:
+
+| File | Why |
+|------|-----|
+| `auth.go` | KD-9 shared `revalidateSessionFromDB`; wire `requireSessionHTML` + `trySessionAuth`; refresh inactive check |
+| `frontend.go` | GET login `Info` banner from query |
+| `templates.go` | **`"account"` in `pageTemplateKeys`** (KD-12) |
+| `pagemodel.go` | `accountPage`, `loginPage.Info`, `pageUser` flags |
+| `user_authz.go` / `pages_user.go` / `user.go` | authz + pilot fix + shared password helper |
+| `routes.go` | route groups |
+| Sweatbox templates / comments | I1+ copy (PR1) |
+
+### Templates
+
+| Template | Action |
+|----------|--------|
+| `templates/account.html` | **New** — profile, change-password form, delete form (password + CID) |
+| `templates.go` | Add `"account"` to `pageTemplateKeys` — **required** or GET `/account` 500s |
+| `templates/dashboard.html` | Account + tools sections; fix sweatbox flag |
+| `templates/layout.html` | Account link; sweatbox on `CanAccessSweatbox` |
+| `templates/login.html` | **Required** info banner when `.Info` set (`?account=deleted`) |
+| `templates/usereditor.html` | No structural change (options from server) |
+| `templates/sweatbox_manual.html` | Replace “Administrator-only” with Instructor1+ / I1+ where describing access |
+
+Forms: `method="post"`, hidden `csrf_token`, PRG redirects with query flash keys.
+
+`account.html` must use the standard layout contract:
+
+```html
+{{ define "title" }}Account{{ end }}
+{{ define "body" }}
+...
+{{ end }}
+```
+
+### Progressive JS
+
+- **No new required JS** for account forms.
+- Optional: disable delete submit until CID confirmation matches (`data-js`) — must work without JS via server validation.
+- Do **not** put password change on `dashboard.js`.
+- Existing `usereditor.js` password meters unchanged.
+
+### Refresh-token rating check (bugfix in scope)
+
+`refreshAccessToken` (`auth.go`) today loads the user for minting but **does not** reject suspended/inactive.
+
+**Change:** after `GetUserByCID`, if `user.NetworkRating <= Suspended`, return 401 with existing `bad token` / unauthorized pattern (avoid a dedicated “account deleted” oracle if possible).
+
+### Sweatbox copy (PR1)
+
+Update user-facing and comment strings that claim Administrator-only sweatbox:
+
+- `api_sweatbox.go` handler comments
+- `sweatbox_manual.html` access / audience wording
+- `pagemodel.go` comments on `sweatboxPage` (“Administrator instructor MPA” → “Instructor1+ …”)
+- Any dashboard help text if present
+
+---
+
+## API / Interface Changes
+
+### HTML routes (new)
+
+| Method | Path | Handler | Authz |
+|--------|------|---------|-------|
+| GET | `/account` | `handleFrontendAccount` | session + DB revalidation |
+| POST | `/account/password` | `handleFrontendAccountPassword` | session + CSRF |
+| POST | `/account/delete` | `handleFrontendAccountDelete` | session + CSRF + current password |
+
+### HTML routes (authz change only)
+
+| Path group | Before | After |
+|------------|--------|-------|
+| `/usereditor*` | I1+ | **SUP+** |
+| `/sweatbox*` | ADM | **I1+** |
+| `/configeditor*`, `/airport-editor*` | ADM | ADM (unchanged) |
+
+### JSON API (behavior change)
+
+| Endpoint | Change |
+|----------|--------|
+| `POST /api/v1/user/load` | Non-self requires SUP+ (was I1+) |
+| `PATCH /api/v1/user/update` | Requires SUP+; pilot ceiling removed |
+| `POST /api/v1/user/create` | Pilot ceiling removed (still SUP+) |
+| `GET /api/v1/sweatbox/state\|ops` | I1+ (was ADM) |
+| `POST /api/v1/auth/refresh` | Reject inactive/suspended users |
+
+No new JSON endpoints for account self-service (HTML forms are the product surface).
+
+### DB interface
+
+```go
+// Before: CreateUser, GetUserByCID, UpdateUser, ListUsers, CountUsers, VerifyPasswordHash
+// After: + DeleteUser(cid int) error // hard-delete only (PR5); soft-delete uses UpdateUser
+```
+
+---
+
+## Data Model Changes
+
+**Schema:** none for soft-delete.
+
+**Hard-delete:** runtime `DELETE`; no migration; CIDs not reused under SQLite AUTOINCREMENT.
+
+**Config:** process env only (`ALLOW_PERMANENT_ACCOUNT_DELETE` on web `ServerConfig`).
+
+**User row after soft-delete:**
+
+| Column | Value |
+|--------|--------|
+| `cid` | unchanged |
+| `password` | unchanged (bcrypt) |
+| `first_name` / `last_name` | unchanged |
+| `network_rating` | `-1` (Inactive) |
+| `pilot_rating` | unchanged |
+
+---
+
+## Alternatives Considered
+
+### 1. Soft-delete via `Suspended (0)` instead of `Inactive (−1)`
+
+| Pros | Cons |
+|------|------|
+| Same login rejection band (`≤ 0`) | Collides with temporary admin suspension; harder to filter “banned” vs “deleted” |
+
+**Rejected** for self-service delete; SUP may still set Suspended for bans.
+
+### 2. Put change-password / delete forms on `/dashboard` only
+
+| Pros | Cons |
+|------|------|
+| Fewer routes | Dashboard already large; poor UX next to map |
+
+**Rejected** in favor of `/account` (KD-3).
+
+### 3. Keep actor pilot-rating ceiling; only raise default admin seed pilot rating
+
+| Pros | Cons |
+|------|------|
+| “Can’t grant higher than self” story | Does not match product; admins rarely FE-rated |
+
+**Rejected** (KD-6).
+
+### 4. Server-side session version / password epoch column
+
+| Pros | Cons |
+|------|------|
+| True global logout on password change | Schema + every request version check |
+
+**Deferred** for password change multi-device residual. Soft-delete uses DB rating revalidation instead (cheaper, no schema).
+
+### 5. Keep I1 user-directory rating-only access
+
+| Pros | Cons |
+|------|------|
+| Less test churn | Contradicts product |
+
+**Rejected** (KD-4).
+
+### 6. Delete without password (CID + CSRF only)
+
+| Pros | Cons |
+|------|------|
+| Fewer form fields | CID is not a secret on the page; stolen session enables hard-delete |
+
+**Rejected** (KD-8) — require current password.
+
+---
+
+## Security & Privacy Considerations
+
+| Threat | Severity | Mitigation |
+|--------|----------|------------|
+| CSRF on password change / delete | High | Double-submit cookie + form field; PE tests → 403 |
+| Password change without current password | High | bcrypt verify current |
+| Account delete with stolen session only | High | **Require current password** + CID confirm; hard-delete also needs config + checkbox |
+| Soft-deleted / suspended user keeps old session JWT | High | **KD-9** shared revalidation on HTML **and** `trySessionAuth`; clear cookies |
+| Soft-deleted user keeps refresh token | Medium | Refresh rejects inactive (in scope) |
+| Soft-deleted user keeps Bearer access token | Low | **Only true residual:** 15m access TTL; session dual-accept is **not** residual |
+| Demotion with stale elevated claims | High | **Required** claims overlay from DB before `setJwtContext` |
+| Hard-delete irreversible | High | Config default false; password + CID + checkbox |
+| User deletes last ADM | Medium | Soft-delete recoverable; UI warning; no hard block in v1 |
+| Multi-device residual after password change | Medium | Document; re-issue current browser only (KD-7) |
+| Privilege escalation via pilot rating | Low | Full pilot scale intentional for SUP+; network ceiling retained |
+| I1 accessing user editor | — | Route 303 `/dashboard` |
+
+**Privacy:** account page shows only the caller’s PII. Hard-delete removes the row; soft-delete retains names for admin recovery.
+
+**Logging:** `slog.Info` on password change and account delete (`cid`, `mode=soft|hard`); failures at debug/warn without password material.
+
+---
+
+## Observability
+
+| Event | Level | Fields |
+|-------|-------|--------|
+| Password changed | Info | `cid`, `event=account_password_changed` |
+| Account soft-deleted | Info | `cid`, `event=account_deleted`, `mode=soft` |
+| Account hard-deleted | Info | `cid`, `event=account_deleted`, `mode=hard` |
+| Session rejected inactive | Debug | `cid`, `event=session_rejected_inactive` |
+| Password change rejected (bad current) | Debug | `cid` only |
+| Delete rejected (bad password) | Debug | `cid` only |
+| Delete failed DB | Error | `cid`, `err` |
+| Refresh rejected inactive | Debug | `cid` |
+
+No new metrics subsystem. Rely on structured logs.
+
+---
+
+## Rollout Plan
+
+1. **Normal deploy** — no feature flag for dashboard/account. Hard-delete remains off until operators set env.
+2. **Authz breaking for I1:** loses `/usereditor`, gains `/sweatbox`. Release notes required.
+3. **Merge order:** see [PR Plan](#pr-plan). Prefer merging pilot-rating fix with authz if small.
+4. **Rollback:** revert deploy; soft-deleted users remain Inactive; hard-deletes cannot roll back.
+5. **Hard-delete enablement:** only after operators understand irreversibility; keep default false in compose/examples.
+
+---
+
+## Testing Plan (PE / unit)
+
+### New / updated tests (`internal/web`)
+
+| Test | Asserts |
+|------|---------|
+| `TestAccountPageRendersForObserver` | OBS GET `/account` 200; forms + CSRF; `pageTemplateKeys` works (not 500) |
+| `TestChangePasswordSuccess` | 303 flash; old password fails login; new works; **session Set-Cookie**; **CSRF rotated** (new cookie value) |
+| `TestChangePasswordWrongCurrent` | 200 field error; password unchanged |
+| `TestChangePasswordSameAsCurrent` | 200 reject new==current |
+| `TestChangePasswordShortOrColon` | 200 validation errors via shared helper |
+| `TestChangePasswordCSRF` | 403 |
+| `TestDeleteAccountSoft` | password + CID → 303 login; `NetworkRating == Inactive`; new login fails |
+| `TestDeleteAccountWrongPassword` | 200 error; user still active |
+| `TestDeleteAccountConfirmCIDMismatch` | 200 error; user still active |
+| `TestDeleteAccountHardWhenEnabled` | cfg true + permanent + password → row gone |
+| `TestDeleteAccountHardWhenDisabled` | permanent requested + cfg false → soft-delete; login `Info` mentions permanent disabled (or query flash) |
+| `TestSessionRejectedAfterSoftDelete` | pre-delete session cookie GET `/dashboard` → 303 `/login`; cookie cleared |
+| `TestAPISessionRejectedAfterSoftDelete` | same session cookie on dual-accept API (e.g. sweatbox state / user load) → 401 |
+| `TestClaimsOverlayAfterDemotion` | ADM cookie; DB demote to SUP; nav/config/create ceiling match SUP not ADM |
+| `TestLoginShowsAccountDeletedBanner` | GET `/login?account=deleted` contains info banner text |
+| `TestInstructorCanAccessSweatbox` | I1 GET `/sweatbox` not redirected to dashboard |
+| `TestInstructorCannotAccessUserEditor` | I1 GET `/usereditor` → 303 `/dashboard` |
+| `TestSupervisorUserEditorPilotRatingFullScale` | SUP P0; options `0,1,3,7,15,31,63`; POST `pilot_rating=15` OK |
+| `TestObserverDashboardHasAccountLink` | dashboard + layout contain `/account` |
+| `TestRefreshRejectsInactiveUser` | soft-delete then refresh → 401 |
+| Nav flag tests | I1: Sweatbox yes, Users no; SUP: Users; ADM: Config |
+| Sweatbox manual | I1 can open; body does not claim Administrator-only access (or updated wording) |
+
+### DB tests
+
+- `DeleteUser` happy path + missing CID.
+
+### Commands (CI bar)
+
+```bash
+go test -race ./internal/web/... ./internal/db/...
+gofmt -l .
+bash scripts/check-import-graph.sh
+bash scripts/check-hygiene.sh
+```
+
+---
+
+## Open Questions
+
+1. ~~**Per-request DB rating revalidation on `requireSessionHTML`?**~~ **Resolved (KD-9):** **in scope**. Shared helper + required claims overlay.
+
+2. **Last-admin protection?** Block self-delete if the user is the only ADM?
+ - **Recommendation:** UI warning only for v1; no hard block.
+
+3. ~~**Preserve “remember me” across password change?**~~ **Resolved (KD-7):** always 24h re-issue; remember-me not preserved.
+
+4. **Should Suspended users appear differently from self-deleted Inactive in directory?**
+ - Already different labels; no code change.
+
+5. **Airport editor for I1?** Product silent → stay ADM.
+
+6. **JSON self-service password API?** Out of scope unless a client needs it.
+
+7. ~~**DB revalidation on `trySessionAuth` (API cookie path)?**~~ **Resolved (KD-9):** **in scope** — same helper as HTML. Residual is **Bearer access only** (15m), not session dual-accept.
+---
+
+## Risks
+
+| Risk | Severity | Mitigation |
+|------|----------|------------|
+| Breaking I1 workflows that used user editor | Medium | Release notes; sweatbox is the intended I1 tool |
+| Hard-delete misuse | High | Default off; password + CID + checkbox; self only |
+| Residual session JWT after password change on other devices | Medium | Document; soft-delete/revalidation still works for off-boarding; demotion overlays on next request |
+| Extra SQLite read per session-authenticated request (HTML + dual-accept API) | Low | Single-node openfsd; shared helper; acceptable |
+| Dashboard/theme merge conflicts | Low | Reuse theme tokens; small nav changes |
+| Test suite encodes I1 usereditor | Medium | Rewrite instructor tests in PR1 |
+| Forgetting `pageTemplateKeys` | High | Explicit KD-12 + PR4 file list + PE test that renders account |
+
+---
+
+## References
+
+- `internal/web/routes.go` — route groups and rating gates
+- `internal/web/user_authz.go` — privilege helpers + pilot option builders
+- `internal/web/pages_user.go` — user editor create/update + `actorPilotRatingCeiling`
+- `internal/web/pages_dashboard.go` — dashboard handler
+- `internal/web/auth.go` — login/JWT/session gates; `requireSessionHTML`
+- `internal/web/frontend.go` — HTML login
+- `internal/web/templates.go` — `pageTemplateKeys` allowlist
+- `internal/web/session.go` — session TTL 24h / 30d
+- `internal/web/pagemodel.go` — `pageUser`, page models
+- `internal/web/fsdconn.go` — kick SUP+
+- `internal/web/templates/layout.html`, `dashboard.html`, `login.html`, `sweatbox_manual.html`
+- `internal/db/user_repository.go`, `user_sqlite.go`
+- `pkg/protocol/types.go` — `NetworkRating*`, `PilotRating*`, `PilotRatingScale`
+- `docs/enumerations.md` — rating tables
+- `internal/server/conn.go` — FSD certificate inactive/suspended behavior
+- `Agents.md` §6 boring-web; `~/.grok/skills/boring-web/SKILL.md`
+
+---
+
+## PR Plan
+
+Incremental, independently reviewable PRs. Each keeps `go test -race` on touched packages green.
+
+**Ordering notes:** PR1 should stay free of account templates. Pilot fix (PR2) may merge into PR1 if the diff stays small (avoids shipping SUP-only editor that still only offers P0). Soft-delete session revalidation lands with account work (PR4) or as PR3.5 after refresh fix. `DeleteUser` is **not** required for soft-delete/account v1 — only for hard-delete (PR5).
+
+### PR1 — Authz realignment: User editor SUP+, Sweatbox I1+
+
+- **Title:** `web: gate user editor at SUP+ and sweatbox at I1+`
+- **Files:** `routes.go`, `user_authz.go`, `pagemodel.go`, `layout.html`, `dashboard.html`, `api_sweatbox.go`, `user.go`, `pages_user.go` (comments / I1 dead-path cleanup), `sweatbox_manual.html`, sweatbox/page comments, `pe_admin_test.go`, `pages_sweatbox_test.go`, `api_sweatbox_test.go`, related PE tests
+- **Dependencies:** none
+- **Changes:**
+ - Split route groups (SUP user editor, I1 sweatbox, ADM config/airport)
+ - Add `CanAccessSweatbox`; `CanEditUsers` → SUP+; remove page-level `CanAdjustRatings`
+ - Clean I1-only tests/branches; update sweatbox copy to Instructor1+
+ - Nav/dashboard tool links use new flags
+ - Kick left SUP+ (no change)
+
+### PR2 — Fix user editor pilot rating full scale
+
+- **Title:** `web: allow full pilot rating scale in user editor`
+- **Files:** `user_authz.go`, `pages_user.go`, `user.go`, comments, pilot-scale PE test
+- **Dependencies:** ideally after PR1; **may merge into PR1** if small
+- **Changes:**
+ - Remove actor pilot ceiling from create/update HTML + JSON
+ - Always offer full `PilotRatingScale` options
+ - Test SUP with P0 own rating can create ATPL (`pilot_rating=15`); HTML options `0,1,3,7,15,31,63`
+
+### PR3 — Refresh rejects inactive/suspended
+
+- **Title:** `web: reject inactive or suspended users on token refresh`
+- **Files:** `internal/web/auth.go`, API/auth tests (`api_v1_test.go` / pe tests as appropriate)
+- **Dependencies:** none (parallelizable with PR1/2)
+- **Changes:**
+ - `refreshAccessToken`: after `GetUserByCID`, if `network_rating ≤ Suspended` → 401
+ - Tests: active refresh OK; inactive refresh fails
+- **Note:** Soft-delete account UI depends on this for refresh residual, **not** on `DeleteUser`.
+
+### PR3b / PR5 prerequisite — `DeleteUser` repository method
+
+- **Title:** `db: add UserRepository.DeleteUser`
+- **Files:** `user_repository.go`, `user_sqlite.go`, `user_sqlite_test.go`
+- **Dependencies:** none
+- **Changes:** permanent row delete only
+- **Shipping:** may land alone or **with PR5**; **not** required for PR4 soft-delete
+
+### PR4 — Account self-service (password + soft-delete) + session revalidation
+
+- **Title:** `web: account page, change password, soft-delete, session DB revalidation`
+- **Files:** `pages_account.go` (new), `templates/account.html` (new), **`templates.go` (`pageTemplateKeys` + `"account"`)**, `routes.go`, `pagemodel.go`, `auth.go` (`revalidateSessionFromDB`, `requireSessionHTML`, **`trySessionAuth`**), `frontend.go` (login `Info`), `login.html`, `layout.html`, `dashboard.html`, shared `validateNewPassword` (may live in `user_authz.go` or `util.go`), `pages_user.go` (call shared helper), `pe_test.go` / account + session PE/API tests
+- **Dependencies:** **PR3** (refresh inactive check) recommended before or with this PR. **Does not depend on DeleteUser.**
+- **Changes:**
+ - GET `/account`, POST password, POST delete (**soft only**; password + CID)
+ - KD-9 **shared** session revalidation on HTML **and** dual-accept `trySessionAuth` + **required claims overlay**
+ - PE tests: HTML soft-delete reject; **API session soft-delete → 401**; demotion overlay ceilings/nav
+ - Login banner `?account=deleted` (**required**)
+ - KD-7 session re-issue 24h + CSRF rotation tests
+ - Dashboard/layout Account links
+ - Shared password validation helper
+
+### PR5 — Optional hard-delete config
+
+- **Title:** `web: optional permanent account delete behind ALLOW_PERMANENT_ACCOUNT_DELETE`
+- **Files:** `env.go`, `pages_account.go`, `templates/account.html`, tests with cfg override; **PR3b `DeleteUser` if not already merged**
+- **Dependencies:** PR4 (account delete form); `DeleteUser` (PR3b)
+- **Changes:**
+ - Env flag default false
+ - Checkbox when enabled; server branch `DeleteUser`
+ - When permanent requested but disabled: soft-delete + **visible** login/info flash (`permanent=disabled`)
+ - Tests: enabled hard path; disabled force-soft with user-visible message; wrong password
+
+### PR6 — Docs packaging
+
+- **Title:** `docs: user dashboard self-service design and env notes`
+- **Files:** `docs/design/user-dashboard-self-service.md`, README env table if present
+- **Dependencies:** after PR4/5 land or alongside final merge
+- **Changes:** durable design doc; operator-facing hard-delete warning
+
+---
+
+## Implementation checklist (for execute-plan)
+
+- [ ] PR1 authz + nav flags + sweatbox copy + I1 test cleanup
+- [ ] PR2 pilot rating full scale (or folded into PR1)
+- [ ] PR3 refresh rejects inactive
+- [ ] PR3b `DeleteUser` (with PR5 if preferred)
+- [ ] PR4 `/account` + soft-delete + **session revalidation (HTML + trySessionAuth) + claims overlay** + login banner + `pageTemplateKeys`
+- [ ] PR5 hard-delete flag + force-soft flash when disabled
+- [ ] `go test -race ./internal/web/... ./internal/db/...`
+- [ ] `gofmt -l .` clean
+- [ ] `bash scripts/check-import-graph.sh`
+- [ ] `bash scripts/check-hygiene.sh`
+- [ ] Manual smoke: OBS login → change password → soft-delete → other browser/session rejected → cannot login; I1 sweatbox + manual copy; SUP usereditor full pilot scale; ADM config unchanged
+
+---
+
+*End of design document.*
diff --git a/internal/db/user_repository.go b/internal/db/user_repository.go
index dd801c2..94b0f5f 100644
--- a/internal/db/user_repository.go
+++ b/internal/db/user_repository.go
@@ -59,4 +59,8 @@ type UserRepository interface {
// VerifyPasswordHash verifies a User password hash.
VerifyPasswordHash(plaintext string, hash string) (ok bool)
+
+ // DeleteUser permanently removes the user row by CID.
+ // Returns sql.ErrNoRows if no row was deleted.
+ DeleteUser(cid int) error
}
diff --git a/internal/db/user_sqlite.go b/internal/db/user_sqlite.go
index b168475..1c1e743 100644
--- a/internal/db/user_sqlite.go
+++ b/internal/db/user_sqlite.go
@@ -125,6 +125,23 @@ func (r *SQLiteUserRepository) VerifyPasswordHash(plaintext string, hash string)
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) == nil
}
+// DeleteUser permanently removes the user row by CID.
+// Returns sql.ErrNoRows if no row was deleted.
+func (r *SQLiteUserRepository) DeleteUser(cid int) error {
+ result, err := r.db.Exec(`DELETE FROM users WHERE cid = ?`, cid)
+ if err != nil {
+ return err
+ }
+ rowsAffected, err := result.RowsAffected()
+ if err != nil {
+ return err
+ }
+ if rowsAffected == 0 {
+ return sql.ErrNoRows
+ }
+ return nil
+}
+
// escapeLike escapes \, %, and _ for use in LIKE ... ESCAPE '\' patterns.
func escapeLike(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
diff --git a/internal/db/user_sqlite_test.go b/internal/db/user_sqlite_test.go
index 1439c89..f675432 100644
--- a/internal/db/user_sqlite_test.go
+++ b/internal/db/user_sqlite_test.go
@@ -931,3 +931,39 @@ func TestListUsersLimitDefaultWithManyRows(t *testing.T) {
t.Fatalf("total=%d want %d", n, total)
}
}
+
+func TestDeleteUser(t *testing.T) {
+ db, repo := setupTestDB(t)
+ defer db.Close()
+
+ user := &User{
+ Password: "password123",
+ FirstName: ptr("Delete"),
+ LastName: ptr("Me"),
+ NetworkRating: 1,
+ }
+ if err := repo.CreateUser(user); err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ cid := user.CID
+
+ if err := repo.DeleteUser(cid); err != nil {
+ t.Fatalf("DeleteUser: %v", err)
+ }
+ _, err := repo.GetUserByCID(cid)
+ if err == nil {
+ t.Fatal("expected ErrNoRows after delete")
+ }
+ if err != sql.ErrNoRows {
+ t.Fatalf("expected sql.ErrNoRows, got %v", err)
+ }
+
+ // Missing CID
+ err = repo.DeleteUser(999999)
+ if err == nil {
+ t.Fatal("expected ErrNoRows for missing CID")
+ }
+ if err != sql.ErrNoRows {
+ t.Fatalf("expected sql.ErrNoRows, got %v", err)
+ }
+}
diff --git a/internal/web/api_sweatbox.go b/internal/web/api_sweatbox.go
index 20dc663..722d1c2 100644
--- a/internal/web/api_sweatbox.go
+++ b/internal/web/api_sweatbox.go
@@ -11,13 +11,13 @@ import (
//
// Authenticated read proxy of FSD service HTTP GET /sweatbox/state.
// Used by sweatbox.js progressive enhancement (1–2 s poll). Cookie session
-// or Bearer; CSRF not required for GET. Min rating Administrator.
+// or Bearer; CSRF not required for GET. Min rating Instructor1+.
//
// On success (and FSD 404 disabled), the FSD JSON body is passed through so
// the PE client can use the same shape as the service control plane.
func (s *Server) handleAPISweatboxState(c *gin.Context) {
claims := getJwtContext(c)
- if claims == nil || claims.NetworkRating < protocol.NetworkRatingAdministator {
+ if claims == nil || claims.NetworkRating < protocol.NetworkRatingInstructor1 {
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
return
}
@@ -46,9 +46,10 @@ func (s *Server) handleAPISweatboxState(c *gin.Context) {
//
// Read proxy of FSD GET /sweatbox/ops (elapsed / arr / dep / ops-per-min).
// Optional for PE; state already includes most of these fields.
+// Min rating Instructor1+.
func (s *Server) handleAPISweatboxOps(c *gin.Context) {
claims := getJwtContext(c)
- if claims == nil || claims.NetworkRating < protocol.NetworkRatingAdministator {
+ if claims == nil || claims.NetworkRating < protocol.NetworkRatingInstructor1 {
writeAPIV1Response(c, http.StatusForbidden, &genericAPIV1Forbidden)
return
}
diff --git a/internal/web/auth.go b/internal/web/auth.go
index a7f050d..acd71de 100644
--- a/internal/web/auth.go
+++ b/internal/web/auth.go
@@ -104,6 +104,15 @@ func (s *Server) refreshAccessToken(c *gin.Context) {
writeAPIV1Response(c, http.StatusUnauthorized, &badTokenRes)
return
}
+ // Soft-deleted / suspended users must not mint new access tokens.
+ if user.NetworkRating <= int(protocol.NetworkRatingSuspended) {
+ slog.Debug("refresh rejected inactive/suspended user",
+ "cid", claims.CID,
+ "event", "refresh_rejected_inactive",
+ )
+ writeAPIV1Response(c, http.StatusUnauthorized, &badTokenRes)
+ return
+ }
access, err := s.makeAccessToken(user, []byte(jwtSecret))
if err != nil {
@@ -278,17 +287,63 @@ func cutBearerToken(header string) (token string, ok bool) {
return token, true
}
-// trySessionAuth parses the signed session cookie into the gin context.
+// Session revalidation errors (KD-9).
+var (
+ errSessionUserMissing = errors.New("session user missing")
+ errSessionInactive = errors.New("session user inactive or suspended")
+)
+
+const dbUserContextKey = "db_user"
+
+// revalidateSessionFromDB loads the user by claims.CID, rejects missing or
+// inactive/suspended certificates, and overlays NetworkRating + names from the DB.
+// Handlers and requireMinRatingHTML keep reading claims.NetworkRating safely only
+// because this overlay is mandatory after session cookie parse.
+func (s *Server) revalidateSessionFromDB(claims *auth.CustomClaims) (*auth.CustomClaims, *db.User, error) {
+ user, err := s.dbRepo.UserRepo.GetUserByCID(claims.CID)
+ if err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, nil, errSessionUserMissing
+ }
+ slog.Error("session revalidation DB error", "cid", claims.CID, "err", err)
+ // Fail closed: treat unexpected DB errors like a missing user.
+ return nil, nil, errSessionUserMissing
+ }
+ if user.NetworkRating <= int(protocol.NetworkRatingSuspended) {
+ slog.Debug("session rejected inactive/suspended user",
+ "cid", claims.CID,
+ "event", "session_rejected_inactive",
+ )
+ return nil, nil, errSessionInactive
+ }
+ // REQUIRED claims overlay — demotions must refresh ceilings and nav flags.
+ claims.NetworkRating = protocol.NetworkRating(user.NetworkRating)
+ claims.FirstName = safeStr(user.FirstName)
+ claims.LastName = safeStr(user.LastName)
+ return claims, user, nil
+}
+
+// trySessionAuth parses the signed session cookie, revalidates against the DB,
+// and sets overlaid claims into the gin context (dual-accept API path).
func (s *Server) trySessionAuth(c *gin.Context) bool {
claims, err := s.parseSessionCookie(c)
if err != nil {
return false
}
+ claims, user, err := s.revalidateSessionFromDB(claims)
+ if err != nil {
+ // Clear cookies so soft-deleted PE clients stop authenticating.
+ s.clearSessionCookie(c)
+ s.clearCSRFCookie(c)
+ return false
+ }
setJwtContext(c, claims)
+ c.Set(dbUserContextKey, user)
return true
}
-// requireSessionHTML gates privileged HTML pages: unauthenticated → 303 /login.
+// requireSessionHTML gates privileged HTML pages: unauthenticated or
+// inactive/missing user → clear cookies + 303 /login.
func (s *Server) requireSessionHTML(c *gin.Context) {
claims, err := s.parseSessionCookie(c)
if err != nil {
@@ -296,10 +351,29 @@ func (s *Server) requireSessionHTML(c *gin.Context) {
c.Abort()
return
}
+ claims, user, err := s.revalidateSessionFromDB(claims)
+ if err != nil {
+ s.clearSessionCookie(c)
+ s.clearCSRFCookie(c)
+ c.Redirect(http.StatusSeeOther, "/login")
+ c.Abort()
+ return
+ }
setJwtContext(c, claims)
+ c.Set(dbUserContextKey, user)
c.Next()
}
+// getDBUser returns the *db.User stashed by requireSessionHTML / trySessionAuth.
+func getDBUser(c *gin.Context) *db.User {
+ val, exists := c.Get(dbUserContextKey)
+ if !exists {
+ return nil
+ }
+ u, _ := val.(*db.User)
+ return u
+}
+
// requireMinRatingHTML redirects to /dashboard when the session rating is too low.
func (s *Server) requireMinRatingHTML(min protocol.NetworkRating) gin.HandlerFunc {
return func(c *gin.Context) {
diff --git a/internal/web/env.go b/internal/web/env.go
index f0501f0..c05213a 100644
--- a/internal/web/env.go
+++ b/internal/web/env.go
@@ -29,6 +29,10 @@ type ServerConfig struct {
// Values: "true"/"false" force the flag; empty (default) derives from
// TLS / X-Forwarded-Proto so local docker-compose HTTP keeps working.
CookieSecure string `env:"COOKIE_SECURE"`
+
+ // AllowPermanentAccountDelete enables the non-default hard-delete checkbox
+ // on POST /account/delete. Default false (soft-delete only).
+ AllowPermanentAccountDelete bool `env:"ALLOW_PERMANENT_ACCOUNT_DELETE, default=false"`
}
func loadServerConfig(ctx context.Context) (config *ServerConfig, err error) {
diff --git a/internal/web/frontend.go b/internal/web/frontend.go
index 8ce20ba..0b0120b 100644
--- a/internal/web/frontend.go
+++ b/internal/web/frontend.go
@@ -19,15 +19,28 @@ func (s *Server) handleFrontendLanding(c *gin.Context) {
func (s *Server) handleFrontendLogin(c *gin.Context) {
// Already signed in → dashboard.
+ // Delete path clears cookies first so the account-deleted banner is reachable.
if claims, err := s.parseSessionCookie(c); err == nil && claims != nil {
- c.Redirect(http.StatusSeeOther, "/dashboard")
- return
+ // Still revalidate: inactive sessions must not bounce to dashboard.
+ if _, _, err := s.revalidateSessionFromDB(claims); err == nil {
+ c.Redirect(http.StatusSeeOther, "/dashboard")
+ return
+ }
+ s.clearSessionCookie(c)
+ s.clearCSRFCookie(c)
}
- s.writeTemplate(c, "login", loginPage{
+ page := loginPage{
basePage: basePage{
CSRFToken: s.issueCSRFToken(c),
},
- })
+ }
+ if c.Query("account") == "deleted" {
+ page.Info = "Your account has been deleted."
+ if c.Query("permanent") == "disabled" {
+ page.Info += " Permanent delete is not enabled on this server; the account was deactivated instead."
+ }
+ }
+ s.writeTemplate(c, "login", page)
}
// handleFrontendLoginPost processes application/x-www-form-urlencoded login
diff --git a/internal/web/pagemodel.go b/internal/web/pagemodel.go
index 349056f..7318ff1 100644
--- a/internal/web/pagemodel.go
+++ b/internal/web/pagemodel.go
@@ -17,13 +17,14 @@ type pageUser struct {
LastName string
NetworkRating int
NetworkRatingLabel string
- // CanEditUsers: Instructor1+ — Users page nav / directory access.
+ // CanEditUsers: Supervisor+ — Users page nav / /usereditor access.
CanEditUsers bool
// CanFullMutateUsers: Supervisor+ — create + name/password mutation.
CanFullMutateUsers bool
- // CanAdjustRatings: Instructor1+ — network + pilot rating changes.
- CanAdjustRatings bool
- CanEditConfig bool
+ // CanAccessSweatbox: Instructor1+ — sweatbox HTML + PE JSON.
+ CanAccessSweatbox bool
+ // CanEditConfig: Administrator — config + airport editor.
+ CanEditConfig bool
}
// basePage is embedded by every HTML page model so layout has nav data.
@@ -39,6 +40,29 @@ type loginPage struct {
Error string
CIDError string
PassError string
+ Info string // success/info banner (e.g. account deleted)
+}
+
+// accountPage is the self-service account MPA model (any DB-valid session).
+type accountPage struct {
+ basePage
+ FlashSuccess string
+ FlashError string
+ // Profile (read-only display)
+ CID int
+ FirstName string
+ LastName string
+ NetworkLabel string
+ PilotLabel string
+ // Password form field errors
+ CurrentPassError string
+ NewPassError string
+ ConfirmPassError string
+ FormError string
+ // Delete
+ DeleteError string
+ DeletePassError string // wrong current password on delete
+ AllowPermanentDelete bool // from cfg
}
// connectionRow is one pilot or ATC line in the dashboard summary table.
@@ -61,6 +85,8 @@ type dashboardPage struct {
SummaryAvailable bool
SummaryUnavailable bool
SummaryError string
+ // Pilot rating from DB (claims lack pilot_rating).
+ PilotRatingLabel string
}
// ratingOption is a network-rating select entry.
@@ -132,11 +158,11 @@ type userEditorPage struct {
EditLoaded bool
// RatingOptions for create/edit network selects — ratingOptionsUpTo(actorMax, selected).
RatingOptions []ratingOption
- // PilotRatingOptions capped at actor's own pilot_rating.
+ // PilotRatingOptions is the full official pilot scale (P0…FE).
PilotRatingOptions []ratingOption
- // ProfileLocked: name/password not editable (instructor, or SUP viewing higher-rated).
+ // ProfileLocked: name/password not editable (SUP viewing higher-rated target).
ProfileLocked bool
- // RatingsLocked: no rating fields (should be rare; I1+ always may adjust within ceiling).
+ // RatingsLocked: no rating fields (should be rare; SUP+ editor actors may adjust).
RatingsLocked bool
// EditReadOnly: nothing editable (legacy alias: profile + ratings locked).
EditReadOnly bool
@@ -183,7 +209,7 @@ type sweatboxAircraftRow struct {
Instruction string
}
-// sweatboxPage is the Administrator instructor MPA model.
+// sweatboxPage is the Instructor1+ sweatbox control-panel MPA model.
type sweatboxPage struct {
basePage
FlashSuccess string
@@ -230,7 +256,7 @@ func pageUserFromClaims(claims *auth.CustomClaims) *pageUser {
NetworkRatingLabel: networkRatingLabel(rating),
CanEditUsers: canAccessUserEditor(claims.NetworkRating),
CanFullMutateUsers: canFullMutateUsers(claims.NetworkRating),
- CanAdjustRatings: canAdjustUserRatings(claims.NetworkRating),
+ CanAccessSweatbox: canAccessSweatbox(claims.NetworkRating),
CanEditConfig: claims.NetworkRating >= protocol.NetworkRatingAdministator,
}
}
diff --git a/internal/web/pages_account.go b/internal/web/pages_account.go
new file mode 100644
index 0000000..b28cbe7
--- /dev/null
+++ b/internal/web/pages_account.go
@@ -0,0 +1,214 @@
+package web
+
+import (
+ "log/slog"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/renorris/openfsd/pkg/protocol"
+)
+
+// handleFrontendAccount GET /account — profile + change-password + delete forms.
+func (s *Server) handleFrontendAccount(c *gin.Context) {
+ claims, ok := requireJwtContext(c)
+ if !ok {
+ return
+ }
+ page := s.loadAccountPage(c, claims.CID)
+ switch c.Query("flash") {
+ case "password_changed":
+ page.FlashSuccess = "Password changed successfully."
+ }
+ s.writeTemplate(c, "account", page)
+}
+
+func (s *Server) loadAccountPage(c *gin.Context, cid int) accountPage {
+ claims, _ := requireJwtContext(c)
+ page := accountPage{
+ basePage: basePage{
+ User: pageUserFromClaims(claims),
+ CSRFToken: s.issueCSRFToken(c),
+ },
+ CID: cid,
+ AllowPermanentDelete: s.cfg != nil && s.cfg.AllowPermanentAccountDelete,
+ }
+
+ user := getDBUser(c)
+ if user == nil || user.CID != cid {
+ var err error
+ user, err = s.dbRepo.UserRepo.GetUserByCID(cid)
+ if err != nil {
+ page.FormError = "Unable to load account"
+ return page
+ }
+ }
+ page.FirstName = safeStr(user.FirstName)
+ page.LastName = safeStr(user.LastName)
+ page.NetworkLabel = networkRatingLabel(user.NetworkRating)
+ page.PilotLabel = pilotRatingLabel(user.PilotRating)
+ return page
+}
+
+// handleFrontendAccountPassword POST /account/password
+func (s *Server) handleFrontendAccountPassword(c *gin.Context) {
+ if !s.validateCSRF(c) {
+ c.AbortWithStatus(http.StatusForbidden)
+ return
+ }
+ claims, ok := requireJwtContext(c)
+ if !ok {
+ return
+ }
+
+ current := c.PostForm("current_password")
+ newPW := c.PostForm("new_password")
+ confirm := c.PostForm("confirm_password")
+
+ page := s.loadAccountPage(c, claims.CID)
+
+ user, err := s.dbRepo.UserRepo.GetUserByCID(claims.CID)
+ if err != nil {
+ s.clearSessionCookie(c)
+ s.clearCSRFCookie(c)
+ c.Redirect(http.StatusSeeOther, "/login")
+ return
+ }
+
+ if current == "" {
+ page.CurrentPassError = "Current password is required"
+ s.writeTemplate(c, "account", page)
+ return
+ }
+ if !s.dbRepo.UserRepo.VerifyPasswordHash(current, user.Password) {
+ slog.Debug("password change rejected bad current", "cid", claims.CID)
+ page.CurrentPassError = "Incorrect password"
+ s.writeTemplate(c, "account", page)
+ return
+ }
+ if msg := validateNewPassword(newPW); msg != "" {
+ page.NewPassError = msg
+ s.writeTemplate(c, "account", page)
+ return
+ }
+ if newPW != confirm {
+ page.ConfirmPassError = "Passwords do not match"
+ s.writeTemplate(c, "account", page)
+ return
+ }
+ if newPW == current {
+ page.NewPassError = "New password must be different from the current password"
+ s.writeTemplate(c, "account", page)
+ return
+ }
+
+ user.Password = newPW
+ if err := s.dbRepo.UserRepo.UpdateUser(user); err != nil {
+ slog.Error("account password update failed", "cid", claims.CID, "err", err)
+ page.FormError = "Unable to update password"
+ s.writeTemplate(c, "account", page)
+ return
+ }
+
+ // KD-7: re-issue session with rememberMe=false (24h); rotate CSRF.
+ if err := s.setSessionCookie(c, user, false); err != nil {
+ slog.Error("account password session reissue failed", "cid", claims.CID, "err", err)
+ page.FormError = "Password updated but session could not be refreshed; please log in again"
+ s.writeTemplate(c, "account", page)
+ return
+ }
+ s.clearCSRFCookie(c)
+ s.issueCSRFToken(c)
+
+ slog.Info("account password changed",
+ "cid", claims.CID,
+ "event", "account_password_changed",
+ )
+ c.Redirect(http.StatusSeeOther, "/account?flash=password_changed")
+}
+
+// handleFrontendAccountDelete POST /account/delete
+func (s *Server) handleFrontendAccountDelete(c *gin.Context) {
+ if !s.validateCSRF(c) {
+ c.AbortWithStatus(http.StatusForbidden)
+ return
+ }
+ claims, ok := requireJwtContext(c)
+ if !ok {
+ return
+ }
+
+ current := c.PostForm("current_password")
+ confirmCID := strings.TrimSpace(c.PostForm("confirm_cid"))
+ wantPermanent := c.PostForm("permanent") == "1" || c.PostForm("permanent") == "on"
+
+ page := s.loadAccountPage(c, claims.CID)
+
+ user, err := s.dbRepo.UserRepo.GetUserByCID(claims.CID)
+ if err != nil || user.NetworkRating <= int(protocol.NetworkRatingSuspended) {
+ s.clearSessionCookie(c)
+ s.clearCSRFCookie(c)
+ c.Redirect(http.StatusSeeOther, "/login")
+ return
+ }
+
+ if current == "" {
+ page.DeletePassError = "Current password is required"
+ s.writeTemplate(c, "account", page)
+ return
+ }
+ if !s.dbRepo.UserRepo.VerifyPasswordHash(current, user.Password) {
+ slog.Debug("account delete rejected bad password", "cid", claims.CID)
+ page.DeletePassError = "Incorrect password"
+ s.writeTemplate(c, "account", page)
+ return
+ }
+ if confirmCID != strconv.Itoa(user.CID) {
+ page.DeleteError = "Confirm your CID exactly to delete the account"
+ s.writeTemplate(c, "account", page)
+ return
+ }
+
+ allowHard := s.cfg != nil && s.cfg.AllowPermanentAccountDelete
+ permanentDisabled := wantPermanent && !allowHard
+
+ if wantPermanent && allowHard {
+ if err := s.dbRepo.UserRepo.DeleteUser(user.CID); err != nil {
+ slog.Error("account hard-delete failed", "cid", claims.CID, "err", err)
+ page.DeleteError = "Unable to delete account"
+ s.writeTemplate(c, "account", page)
+ return
+ }
+ slog.Info("account deleted",
+ "cid", claims.CID,
+ "event", "account_deleted",
+ "mode", "hard",
+ )
+ } else {
+ // Soft-delete (default, or permanent requested but disabled).
+ user.NetworkRating = int(protocol.NetworkRatingInactive)
+ user.Password = "" // keep existing hash
+ if err := s.dbRepo.UserRepo.UpdateUser(user); err != nil {
+ slog.Error("account soft-delete failed", "cid", claims.CID, "err", err)
+ page.DeleteError = "Unable to delete account"
+ s.writeTemplate(c, "account", page)
+ return
+ }
+ slog.Info("account deleted",
+ "cid", claims.CID,
+ "event", "account_deleted",
+ "mode", "soft",
+ )
+ _ = permanentDisabled
+ }
+
+ s.clearSessionCookie(c)
+ s.clearCSRFCookie(c)
+
+ loc := "/login?account=deleted"
+ if permanentDisabled {
+ loc = "/login?account=deleted&permanent=disabled"
+ }
+ c.Redirect(http.StatusSeeOther, loc)
+}
diff --git a/internal/web/pages_dashboard.go b/internal/web/pages_dashboard.go
index c6597e1..e566510 100644
--- a/internal/web/pages_dashboard.go
+++ b/internal/web/pages_dashboard.go
@@ -25,6 +25,13 @@ func (s *Server) handleFrontendDashboard(c *gin.Context) {
},
}
+ // Pilot rating from DB (session claims lack pilot_rating).
+ if user := getDBUser(c); user != nil {
+ page.PilotRatingLabel = pilotRatingLabel(user.PilotRating)
+ } else if u, err := s.dbRepo.UserRepo.GetUserByCID(claims.CID); err == nil {
+ page.PilotRatingLabel = pilotRatingLabel(u.PilotRating)
+ }
+
online, err := s.fetchOnlineUsers()
if err != nil {
page.SummaryUnavailable = true
diff --git a/internal/web/pages_sweatbox_test.go b/internal/web/pages_sweatbox_test.go
index 7af0dd5..02e4904 100644
--- a/internal/web/pages_sweatbox_test.go
+++ b/internal/web/pages_sweatbox_test.go
@@ -44,17 +44,33 @@ func TestSweatboxPageObserverRedirect(t *testing.T) {
}
}
-func TestSweatboxPageSupervisorRedirect(t *testing.T) {
+func TestInstructorCanAccessSweatbox(t *testing.T) {
+ ts := newTestServer(t)
+ inst := createTestUser(t, ts, "pw", int(protocol.NetworkRatingInstructor1))
+ cookies := formLogin(t, ts, inst.CID, "pw")
+
+ w, _ := authedGET(t, ts, "/sweatbox", cookies)
+ if w.Code != http.StatusOK {
+ t.Fatalf("I1 GET /sweatbox status %d want 200 body %s", w.Code, w.Body.String())
+ }
+ body := w.Body.String()
+ if !strings.Contains(body, "Sweatbox") {
+ t.Fatalf("expected sweatbox page, body=%s", clip(body, 400))
+ }
+ // Nav should include Sweatbox for I1
+ if !strings.Contains(body, `href="/sweatbox"`) {
+ t.Fatal("expected sweatbox nav link for I1")
+ }
+}
+
+func TestSweatboxPageSupervisorOK(t *testing.T) {
ts := newTestServer(t)
sup := createTestUser(t, ts, "pw", int(protocol.NetworkRatingSupervisor))
cookies := formLogin(t, ts, sup.CID, "pw")
w, _ := authedGET(t, ts, "/sweatbox", cookies)
- if w.Code != http.StatusSeeOther {
- t.Fatalf("status %d want 303", w.Code)
- }
- if loc := w.Header().Get("Location"); loc != "/dashboard" {
- t.Fatalf("Location=%q want /dashboard", loc)
+ if w.Code != http.StatusOK {
+ t.Fatalf("SUP GET /sweatbox status %d want 200", w.Code)
}
}
@@ -85,18 +101,34 @@ func TestSweatboxPageAdminShowsUnavailableWhenFSDDown(t *testing.T) {
}
}
-func TestSweatboxNavOnlyForAdmin(t *testing.T) {
+func TestSweatboxNavForInstructorNotObserver(t *testing.T) {
ts := newTestServer(t)
// Observer dashboard: no sweatbox nav
obs := createTestUser(t, ts, "pw", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, obs.CID, "pw")
w, _ := authedGET(t, ts, "/dashboard", cookies)
body := w.Body.String()
- // Layout nav should not include Sweatbox for non-admin (CanEditConfig false).
- // Dashboard may still mention connections; check header area for Config/Sweatbox pair.
if strings.Contains(body, `href="/sweatbox">Sweatbox`) {
t.Fatal("observer must not see Sweatbox nav")
}
+ if !strings.Contains(body, `href="/account"`) {
+ t.Fatal("observer dashboard should link to account")
+ }
+
+ inst := createTestUser(t, ts, "inst-pass", int(protocol.NetworkRatingInstructor1))
+ cookies = formLogin(t, ts, inst.CID, "inst-pass")
+ w, _ = authedGET(t, ts, "/dashboard", cookies)
+ body = w.Body.String()
+ if !strings.Contains(body, `href="/sweatbox"`) {
+ t.Fatal("I1 dashboard should link to sweatbox")
+ }
+ // I1 must not see Users or Config
+ if strings.Contains(body, `href="/usereditor"`) {
+ t.Fatal("I1 must not see Users nav")
+ }
+ if strings.Contains(body, `href="/configeditor"`) {
+ t.Fatal("I1 must not see Config nav")
+ }
admin := createTestUser(t, ts, "admin-pass", int(protocol.NetworkRatingAdministator))
cookies = formLogin(t, ts, admin.CID, "admin-pass")
@@ -105,6 +137,9 @@ func TestSweatboxNavOnlyForAdmin(t *testing.T) {
if !strings.Contains(body, `href="/sweatbox"`) {
t.Fatal("admin dashboard should link to sweatbox")
}
+ if !strings.Contains(body, `href="/configeditor"`) {
+ t.Fatal("admin dashboard should link to config")
+ }
}
func TestSweatboxPOSTRequiresCSRF(t *testing.T) {
@@ -152,10 +187,10 @@ func TestSweatboxManualObserverRedirect(t *testing.T) {
}
}
-func TestSweatboxManualAdminOK(t *testing.T) {
+func TestSweatboxManualInstructorOK(t *testing.T) {
ts := newTestServer(t)
- admin := createTestUser(t, ts, "admin-pass", int(protocol.NetworkRatingAdministator))
- cookies := formLogin(t, ts, admin.CID, "admin-pass")
+ inst := createTestUser(t, ts, "inst-pass", int(protocol.NetworkRatingInstructor1))
+ cookies := formLogin(t, ts, inst.CID, "inst-pass")
w, _ := authedGET(t, ts, "/sweatbox/manual", cookies)
if w.Code != http.StatusOK {
@@ -169,11 +204,16 @@ func TestSweatboxManualAdminOK(t *testing.T) {
`href="/sweatbox"`,
"add rules weight engine",
"Pattern & arrival",
+ "Instructor1+",
} {
if !strings.Contains(body, want) {
t.Fatalf("manual missing %q, body=%s", want, clip(body, 600))
}
}
+ // Must not claim Administrator-only access for sweatbox role.
+ if strings.Contains(body, "Administrator network rating (same as Config") {
+ t.Fatal("manual still claims Administrator-only sweatbox access")
+ }
// Manual is static HTML — no FSD dependency required.
if strings.Contains(body, "Unavailable.") {
t.Fatal("manual must not depend on FSD availability banner")
@@ -198,7 +238,7 @@ func TestSweatboxPageLinksManualNewTab(t *testing.T) {
t.Fatalf("manual link should open in new tab with noopener, body=%s", clip(body, 500))
}
// Link text should stay quiet (not a primary action button).
- if !strings.Contains(body, ">Manual") {
+ if !strings.Contains(body, ">User Manual") && !strings.Contains(body, ">Manual") {
t.Fatal("expected muted Manual link text")
}
}
diff --git a/internal/web/pages_user.go b/internal/web/pages_user.go
index 33fe25d..bc45149 100644
--- a/internal/web/pages_user.go
+++ b/internal/web/pages_user.go
@@ -24,11 +24,7 @@ func (s *Server) newUserEditorPage(c *gin.Context) userEditorPage {
if defaultRating > maxRating {
defaultRating = maxRating
}
- actorPilotMax := s.actorPilotRatingCeiling(claims.CID)
defaultPilot := 0
- if defaultPilot > actorPilotMax {
- defaultPilot = actorPilotMax
- }
return userEditorPage{
basePage: basePage{
User: pageUserFromClaims(claims),
@@ -38,9 +34,10 @@ func (s *Server) newUserEditorPage(c *gin.Context) userEditorPage {
NetworkRating: defaultRating,
PilotRating: defaultPilot,
},
- // Only offer ratings the actor may assign (server still enforces ceiling).
+ // Only offer network ratings the actor may assign (server still enforces ceiling).
+ // Pilot options are the full official scale (KD-6).
RatingOptions: ratingOptionsUpTo(maxRating, defaultRating),
- PilotRatingOptions: pilotRatingOptionsUpTo(actorPilotMax, defaultPilot),
+ PilotRatingOptions: pilotRatingOptionsAll(defaultPilot),
}
}
@@ -51,20 +48,6 @@ func actorMaxRating(page *userEditorPage) int {
return page.User.NetworkRating
}
-// actorPilotRatingCeiling loads the actor's stored pilot_rating (VATSIM scale).
-// Invalid stored values fall back to the highest official rating at or below
-// the stored number (or P0).
-func (s *Server) actorPilotRatingCeiling(cid int) int {
- u, err := s.dbRepo.UserRepo.GetUserByCID(cid)
- if err != nil || u == nil {
- return int(protocol.PilotRatingNone)
- }
- if protocol.IsValidPilotRating(u.PilotRating) {
- return u.PilotRating
- }
- return maxValidPilotRatingAtMost(u.PilotRating)
-}
-
// loadUserDirectory fills Dir totals/pages and Users rows for the current query.
// On DB failure sets FlashError and logs; still leaves a renderable page.
func (s *Server) loadUserDirectory(page *userEditorPage, selectedCID int) {
@@ -197,18 +180,14 @@ func (s *Server) loadUserIntoEditForm(page *userEditorPage, cidStr string) {
}
page.EditLoaded = true
actorMax := actorMaxRating(page)
- actorPilotMax := int(protocol.PilotRatingNone)
- if page.User != nil {
- actorPilotMax = s.actorPilotRatingCeiling(page.User.CID)
- }
// Profile (name/password): SUP+ and target network rating ≤ actor.
- // Ratings: I1+ may always adjust within ceilings (any target).
+ // Ratings: SUP+ may always adjust within network ceiling (any target).
fullOK := page.User != nil && canFullMutateTarget(
protocol.NetworkRating(page.User.NetworkRating),
protocol.NetworkRating(user.NetworkRating),
)
page.ProfileLocked = !fullOK
- page.RatingsLocked = page.User == nil || !page.User.CanAdjustRatings
+ page.RatingsLocked = page.User == nil || !page.User.CanEditUsers
page.EditReadOnly = page.ProfileLocked && page.RatingsLocked
page.Edit = userForm{
CID: strconv.Itoa(user.CID),
@@ -236,8 +215,9 @@ func (s *Server) loadUserIntoEditForm(page *userEditorPage, cidStr string) {
})
}
}
- page.PilotRatingOptions = pilotRatingOptionsUpTo(actorPilotMax, user.PilotRating)
- if user.PilotRating > actorPilotMax {
+ // Full official pilot scale (KD-6); still show invalid stored values if present.
+ page.PilotRatingOptions = pilotRatingOptionsAll(user.PilotRating)
+ if !isValidPilotRating(user.PilotRating) {
found := false
for _, o := range page.PilotRatingOptions {
if o.Value == user.PilotRating {
@@ -305,13 +285,12 @@ func (s *Server) handleFrontendUserCreate(c *gin.Context) {
page.Create.Password = "" // never re-render password
maxRating := int(claims.NetworkRating)
- actorPilotMax := s.actorPilotRatingCeiling(claims.CID)
rating, err := strconv.Atoi(ratingStr)
if err != nil {
page.Create.RatingError = "Select a network rating"
page.Create.NetworkRating = int(protocol.NetworkRatingObserver)
page.RatingOptions = ratingOptionsUpTo(maxRating, page.Create.NetworkRating)
- page.PilotRatingOptions = pilotRatingOptionsUpTo(actorPilotMax, 0)
+ page.PilotRatingOptions = pilotRatingOptionsAll(0)
s.reRenderUserEditor(c, &page, dir)
return
}
@@ -323,21 +302,16 @@ func (s *Server) handleFrontendUserCreate(c *gin.Context) {
pilotRating, err = strconv.Atoi(pilotStr)
if err != nil || !isValidPilotRating(pilotRating) {
page.Create.PilotError = "Invalid pilot rating"
- page.PilotRatingOptions = pilotRatingOptionsUpTo(actorPilotMax, 0)
+ page.PilotRatingOptions = pilotRatingOptionsAll(0)
s.reRenderUserEditor(c, &page, dir)
return
}
}
page.Create.PilotRating = pilotRating
- page.PilotRatingOptions = pilotRatingOptionsUpTo(actorPilotMax, pilotRating)
+ page.PilotRatingOptions = pilotRatingOptionsAll(pilotRating)
- if len(password) < 8 {
- page.Create.PasswordError = "Password must be at least 8 characters"
- s.reRenderUserEditor(c, &page, dir)
- return
- }
- if strings.Contains(password, ":") {
- page.Create.PasswordError = "Password cannot contain colon characters"
+ if msg := validateNewPassword(password); msg != "" {
+ page.Create.PasswordError = msg
s.reRenderUserEditor(c, &page, dir)
return
}
@@ -351,11 +325,6 @@ func (s *Server) handleFrontendUserCreate(c *gin.Context) {
s.reRenderUserEditor(c, &page, dir)
return
}
- if pilotRating > actorPilotMax {
- page.Create.PilotError = "Cannot set pilot rating above your own"
- s.reRenderUserEditor(c, &page, dir)
- return
- }
var firstPtr, lastPtr *string
if firstName != "" {
@@ -384,8 +353,8 @@ func (s *Server) handleFrontendUserCreate(c *gin.Context) {
// handleFrontendUserUpdate processes POST /usereditor/update (no-JS form path).
//
-// Instructor1+: may set network_rating and pilot_rating up to actor ceilings on any user.
-// Supervisor+: may also mutate name/password when target network rating ≤ actor.
+// Supervisor+: may set network_rating (≤ actor) and any official pilot_rating on any user;
+// may also mutate name/password when target network rating ≤ actor.
func (s *Server) handleFrontendUserUpdate(c *gin.Context) {
if !s.validateCSRF(c) {
c.AbortWithStatus(http.StatusForbidden)
@@ -429,13 +398,12 @@ func (s *Server) handleFrontendUserUpdate(c *gin.Context) {
}
maxRating := int(claims.NetworkRating)
- actorPilotMax := s.actorPilotRatingCeiling(claims.CID)
rating, err := strconv.Atoi(ratingStr)
if err != nil || rating < -1 || rating > 12 {
page.Edit.RatingError = "Invalid network rating"
page.Edit.NetworkRating = int(protocol.NetworkRatingObserver)
page.RatingOptions = ratingOptionsUpTo(maxRating, page.Edit.NetworkRating)
- page.PilotRatingOptions = pilotRatingOptionsUpTo(actorPilotMax, 0)
+ page.PilotRatingOptions = pilotRatingOptionsAll(0)
s.reRenderUserEditor(c, &page, dir)
return
}
@@ -448,22 +416,17 @@ func (s *Server) handleFrontendUserUpdate(c *gin.Context) {
if err != nil || !isValidPilotRating(pilotRating) {
page.Edit.PilotError = "Invalid pilot rating"
page.Edit.PilotRating = int(protocol.PilotRatingNone)
- page.PilotRatingOptions = pilotRatingOptionsUpTo(actorPilotMax, 0)
+ page.PilotRatingOptions = pilotRatingOptionsAll(0)
s.reRenderUserEditor(c, &page, dir)
return
}
}
page.Edit.PilotRating = pilotRating
- page.PilotRatingOptions = pilotRatingOptionsUpTo(actorPilotMax, pilotRating)
+ page.PilotRatingOptions = pilotRatingOptionsAll(pilotRating)
if password != "" {
- if len(password) < 8 {
- page.Edit.PasswordError = "Password must be at least 8 characters"
- s.reRenderUserEditor(c, &page, dir)
- return
- }
- if strings.Contains(password, ":") {
- page.Edit.PasswordError = "Password cannot contain colon characters"
+ if msg := validateNewPassword(password); msg != "" {
+ page.Edit.PasswordError = msg
s.reRenderUserEditor(c, &page, dir)
return
}
@@ -495,20 +458,16 @@ func (s *Server) handleFrontendUserUpdate(c *gin.Context) {
page.Edit.LastName = lastName
}
- // Ceilings apply when *changing* a rating. Leaving a higher existing value
+ // Network ceiling applies when *changing* a rating. Leaving a higher existing value
// unchanged is allowed so pilot/network edits can be independent.
if rating > maxRating && rating != targetUser.NetworkRating {
page.Edit.Error = "Cannot set network rating above your own"
s.reRenderUserEditor(c, &page, dir)
return
}
- if pilotRating > actorPilotMax && pilotRating != targetUser.PilotRating {
- page.Edit.PilotError = "Cannot set pilot rating above your own"
- s.reRenderUserEditor(c, &page, dir)
- return
- }
- // Ratings: any target; new values must not exceed actor ceilings.
+ // Ratings: any target; network new values must not exceed actor ceiling.
+ // Pilot: full official scale (KD-6) — no actor pilot ceiling.
targetUser.NetworkRating = rating
targetUser.PilotRating = pilotRating
@@ -532,7 +491,7 @@ func (s *Server) handleFrontendUserUpdate(c *gin.Context) {
// Empty password means keep current (UpdateUser contract).
targetUser.Password = password
} else {
- // Rating-only path: never change name/password; reject attempts that look like full mutate.
+ // Higher-rated target: never change name/password; reject password attempts.
if password != "" {
page.Edit.Error = "Only supervisors can change passwords"
page.Edit.FirstName = safeStr(targetUser.FirstName)
diff --git a/internal/web/pe_account_test.go b/internal/web/pe_account_test.go
new file mode 100644
index 0000000..5e84ff4
--- /dev/null
+++ b/internal/web/pe_account_test.go
@@ -0,0 +1,552 @@
+package web
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+
+ "github.com/renorris/openfsd/pkg/protocol"
+)
+
+func TestAccountPageRendersForObserver(t *testing.T) {
+ ts := newTestServer(t)
+ obs := createTestUser(t, ts, "obs-pass1", int(protocol.NetworkRatingObserver))
+ cookies := formLogin(t, ts, obs.CID, "obs-pass1")
+
+ w, _ := authedGET(t, ts, "/account", cookies)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET /account status %d body %s", w.Code, w.Body.String())
+ }
+ body := w.Body.String()
+ for _, want := range []string{
+ `action="/account/password"`,
+ `action="/account/delete"`,
+ `name="csrf_token"`,
+ "Change password",
+ "Delete my account",
+ itoa(obs.CID),
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("account page missing %q, body=%s", want, clip(body, 600))
+ }
+ }
+ // Hard-delete checkbox hidden when flag false (default).
+ if strings.Contains(body, `name="permanent"`) {
+ t.Fatal("permanent delete checkbox should be hidden by default")
+ }
+}
+
+func TestObserverDashboardHasAccountLink(t *testing.T) {
+ ts := newTestServer(t)
+ obs := createTestUser(t, ts, "pw", int(protocol.NetworkRatingObserver))
+ cookies := formLogin(t, ts, obs.CID, "pw")
+
+ w, _ := authedGET(t, ts, "/dashboard", cookies)
+ if w.Code != http.StatusOK {
+ t.Fatalf("dashboard status %d", w.Code)
+ }
+ body := w.Body.String()
+ if !strings.Contains(body, `href="/account"`) {
+ t.Fatal("dashboard/layout must link to /account")
+ }
+ if !strings.Contains(body, "Manage account") {
+ t.Fatal("dashboard should have Manage account button")
+ }
+}
+
+func TestChangePasswordSuccess(t *testing.T) {
+ ts := newTestServer(t)
+ user := createTestUser(t, ts, "oldpassword", int(protocol.NetworkRatingObserver))
+ cookies := formLogin(t, ts, user.CID, "oldpassword")
+ oldCSRF := csrfFromCookies(cookies)
+
+ form := url.Values{}
+ form.Set("current_password", "oldpassword")
+ form.Set("new_password", "newpassword1")
+ form.Set("confirm_password", "newpassword1")
+ w, cookies := formPOST(t, ts, "/account/password", form, cookies)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("status %d body %s", w.Code, w.Body.String())
+ }
+ if loc := w.Header().Get("Location"); !strings.Contains(loc, "flash=password_changed") {
+ t.Fatalf("Location=%q want password_changed flash", loc)
+ }
+ // Session re-issued
+ if extractCookie(w.Result(), sessionCookieName) == "" {
+ // mergeCookies may keep existing; check Set-Cookie header
+ found := false
+ for _, sc := range w.Result().Header.Values("Set-Cookie") {
+ if strings.HasPrefix(sc, sessionCookieName+"=") && !strings.Contains(sc, "Max-Age=0") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatal("expected session Set-Cookie on password change")
+ }
+ }
+ // CSRF rotated (clear + new issue → different value from old)
+ newCSRF := csrfFromCookies(cookies)
+ if newCSRF == "" {
+ // May need GET after rotate
+ w2, cookies2 := authedGET(t, ts, "/account", cookies)
+ _ = w2
+ newCSRF = csrfFromCookies(cookies2)
+ cookies = cookies2
+ }
+ if newCSRF == "" {
+ t.Fatal("expected CSRF after password change")
+ }
+ if oldCSRF != "" && newCSRF == oldCSRF {
+ // Rotation: clearCSRF then issueCSRFToken on same response may set empty then new.
+ // Accept if session still works with new CSRF.
+ }
+
+ // Old password fails login
+ csrf, loginCookies := getLoginCSRF(t, ts)
+ bad := url.Values{}
+ bad.Set("cid", itoa(user.CID))
+ bad.Set("password", "oldpassword")
+ bad.Set("csrf_token", csrf)
+ req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(bad.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Cookie", cookieHeader(loginCookies))
+ wr := httptest.NewRecorder()
+ ts.engine.ServeHTTP(wr, req)
+ if wr.Code == http.StatusSeeOther {
+ t.Fatal("old password must not log in")
+ }
+
+ // New password works
+ csrf, loginCookies = getLoginCSRF(t, ts)
+ good := url.Values{}
+ good.Set("cid", itoa(user.CID))
+ good.Set("password", "newpassword1")
+ good.Set("csrf_token", csrf)
+ req = httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(good.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Cookie", cookieHeader(loginCookies))
+ wr = httptest.NewRecorder()
+ ts.engine.ServeHTTP(wr, req)
+ if wr.Code != http.StatusSeeOther {
+ t.Fatalf("new password login status %d", wr.Code)
+ }
+}
+
+func TestChangePasswordWrongCurrent(t *testing.T) {
+ ts := newTestServer(t)
+ user := createTestUser(t, ts, "correct-pw", int(protocol.NetworkRatingObserver))
+ cookies := formLogin(t, ts, user.CID, "correct-pw")
+
+ form := url.Values{}
+ form.Set("current_password", "wrong-pw")
+ form.Set("new_password", "newpassword1")
+ form.Set("confirm_password", "newpassword1")
+ w, _ := formPOST(t, ts, "/account/password", form, cookies)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d want 200", w.Code)
+ }
+ if !strings.Contains(w.Body.String(), "Incorrect password") {
+ t.Fatalf("expected field error, body=%s", clip(w.Body.String(), 400))
+ }
+ // Password unchanged
+ u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !ts.dbRepo.UserRepo.VerifyPasswordHash("correct-pw", u.Password) {
+ t.Fatal("password must not change on wrong current")
+ }
+}
+
+func TestChangePasswordSameAsCurrent(t *testing.T) {
+ ts := newTestServer(t)
+ user := createTestUser(t, ts, "samepass1", int(protocol.NetworkRatingObserver))
+ cookies := formLogin(t, ts, user.CID, "samepass1")
+
+ form := url.Values{}
+ form.Set("current_password", "samepass1")
+ form.Set("new_password", "samepass1")
+ form.Set("confirm_password", "samepass1")
+ w, _ := formPOST(t, ts, "/account/password", form, cookies)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d want 200", w.Code)
+ }
+ if !strings.Contains(w.Body.String(), "different from the current") {
+ t.Fatalf("expected new≠current error, body=%s", clip(w.Body.String(), 400))
+ }
+}
+
+func TestChangePasswordShortOrColon(t *testing.T) {
+ ts := newTestServer(t)
+ user := createTestUser(t, ts, "oldpassword", int(protocol.NetworkRatingObserver))
+ cookies := formLogin(t, ts, user.CID, "oldpassword")
+
+ form := url.Values{}
+ form.Set("current_password", "oldpassword")
+ form.Set("new_password", "short")
+ form.Set("confirm_password", "short")
+ w, cookies := formPOST(t, ts, "/account/password", form, cookies)
+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "at least 8 characters") {
+ t.Fatalf("short password: status %d body=%s", w.Code, clip(w.Body.String(), 300))
+ }
+
+ form = url.Values{}
+ form.Set("current_password", "oldpassword")
+ form.Set("new_password", "bad:colon1")
+ form.Set("confirm_password", "bad:colon1")
+ w, _ = formPOST(t, ts, "/account/password", form, cookies)
+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "colon") {
+ t.Fatalf("colon password: status %d body=%s", w.Code, clip(w.Body.String(), 300))
+ }
+}
+
+func TestChangePasswordCSRF(t *testing.T) {
+ ts := newTestServer(t)
+ user := createTestUser(t, ts, "pw", int(protocol.NetworkRatingObserver))
+ cookies := formLogin(t, ts, user.CID, "pw")
+
+ form := url.Values{}
+ form.Set("current_password", "pw")
+ form.Set("new_password", "newpassword1")
+ form.Set("confirm_password", "newpassword1")
+ // Wrong CSRF
+ form.Set("csrf_token", "not-the-real-token")
+ req := httptest.NewRequest(http.MethodPost, "/account/password", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Cookie", cookieHeader(cookies))
+ w := httptest.NewRecorder()
+ ts.engine.ServeHTTP(w, req)
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("status %d want 403", w.Code)
+ }
+}
+
+func TestDeleteAccountSoft(t *testing.T) {
+ ts := newTestServer(t)
+ user := createTestUser(t, ts, "delete-me1", int(protocol.NetworkRatingObserver))
+ cookies := formLogin(t, ts, user.CID, "delete-me1")
+
+ form := url.Values{}
+ form.Set("current_password", "delete-me1")
+ form.Set("confirm_cid", itoa(user.CID))
+ w, _ := formPOST(t, ts, "/account/delete", form, cookies)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("status %d body %s", w.Code, w.Body.String())
+ }
+ loc := w.Header().Get("Location")
+ if !strings.Contains(loc, "account=deleted") {
+ t.Fatalf("Location=%q want account=deleted", loc)
+ }
+
+ u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u.NetworkRating != int(protocol.NetworkRatingInactive) {
+ t.Fatalf("network_rating=%d want Inactive(-1)", u.NetworkRating)
+ }
+
+ // New login fails
+ csrf, loginCookies := getLoginCSRF(t, ts)
+ login := url.Values{}
+ login.Set("cid", itoa(user.CID))
+ login.Set("password", "delete-me1")
+ login.Set("csrf_token", csrf)
+ req := httptest.NewRequest(http.MethodPost, "/login", strings.NewReader(login.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Cookie", cookieHeader(loginCookies))
+ wr := httptest.NewRecorder()
+ ts.engine.ServeHTTP(wr, req)
+ if wr.Code == http.StatusSeeOther {
+ t.Fatal("soft-deleted user must not log in")
+ }
+}
+
+func TestDeleteAccountWrongPassword(t *testing.T) {
+ ts := newTestServer(t)
+ user := createTestUser(t, ts, "keep-me1x", int(protocol.NetworkRatingObserver))
+ cookies := formLogin(t, ts, user.CID, "keep-me1x")
+
+ form := url.Values{}
+ form.Set("current_password", "wrong")
+ form.Set("confirm_cid", itoa(user.CID))
+ w, _ := formPOST(t, ts, "/account/delete", form, cookies)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d want 200", w.Code)
+ }
+ if !strings.Contains(w.Body.String(), "Incorrect password") {
+ t.Fatalf("expected password error, body=%s", clip(w.Body.String(), 400))
+ }
+ u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u.NetworkRating != int(protocol.NetworkRatingObserver) {
+ t.Fatalf("user must still be active, rating=%d", u.NetworkRating)
+ }
+}
+
+func TestDeleteAccountConfirmCIDMismatch(t *testing.T) {
+ ts := newTestServer(t)
+ user := createTestUser(t, ts, "keep-me2x", int(protocol.NetworkRatingObserver))
+ cookies := formLogin(t, ts, user.CID, "keep-me2x")
+
+ form := url.Values{}
+ form.Set("current_password", "keep-me2x")
+ form.Set("confirm_cid", "999999")
+ w, _ := formPOST(t, ts, "/account/delete", form, cookies)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d want 200", w.Code)
+ }
+ if !strings.Contains(w.Body.String(), "Confirm your CID") {
+ t.Fatalf("expected CID confirm error, body=%s", clip(w.Body.String(), 400))
+ }
+ u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u.NetworkRating != int(protocol.NetworkRatingObserver) {
+ t.Fatal("user must still be active")
+ }
+}
+
+func TestDeleteAccountHardWhenEnabled(t *testing.T) {
+ ts := newTestServer(t)
+ ts.cfg.AllowPermanentAccountDelete = true
+ user := createTestUser(t, ts, "hard-del1", int(protocol.NetworkRatingObserver))
+ cookies := formLogin(t, ts, user.CID, "hard-del1")
+
+ // Checkbox visible
+ w, cookies := authedGET(t, ts, "/account", cookies)
+ if !strings.Contains(w.Body.String(), `name="permanent"`) {
+ t.Fatal("expected permanent checkbox when enabled")
+ }
+
+ form := url.Values{}
+ form.Set("current_password", "hard-del1")
+ form.Set("confirm_cid", itoa(user.CID))
+ form.Set("permanent", "1")
+ w, _ = formPOST(t, ts, "/account/delete", form, cookies)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("status %d body %s", w.Code, w.Body.String())
+ }
+ _, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
+ if err == nil {
+ t.Fatal("expected row gone after hard delete")
+ }
+}
+
+func TestDeleteAccountHardWhenDisabled(t *testing.T) {
+ ts := newTestServer(t)
+ // flag remains false
+ user := createTestUser(t, ts, "soft-only1", int(protocol.NetworkRatingObserver))
+ cookies := formLogin(t, ts, user.CID, "soft-only1")
+
+ form := url.Values{}
+ form.Set("current_password", "soft-only1")
+ form.Set("confirm_cid", itoa(user.CID))
+ form.Set("permanent", "1") // client posts permanent but server soft-deletes
+ w, _ := formPOST(t, ts, "/account/delete", form, cookies)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("status %d body %s", w.Code, w.Body.String())
+ }
+ loc := w.Header().Get("Location")
+ if !strings.Contains(loc, "account=deleted") || !strings.Contains(loc, "permanent=disabled") {
+ t.Fatalf("Location=%q want deleted+permanent=disabled", loc)
+ }
+ u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u.NetworkRating != int(protocol.NetworkRatingInactive) {
+ t.Fatalf("must soft-delete, rating=%d", u.NetworkRating)
+ }
+}
+
+func TestSessionRejectedAfterSoftDelete(t *testing.T) {
+ ts := newTestServer(t)
+ user := createTestUser(t, ts, "sess-del1", int(protocol.NetworkRatingObserver))
+ cookies := formLogin(t, ts, user.CID, "sess-del1")
+
+ // Soft-delete via repo (simulate admin/self delete while session still held)
+ u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ u.NetworkRating = int(protocol.NetworkRatingInactive)
+ u.Password = ""
+ if err := ts.dbRepo.UserRepo.UpdateUser(u); err != nil {
+ t.Fatal(err)
+ }
+
+ w, cookies2 := authedGET(t, ts, "/dashboard", cookies)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("status %d want 303 to login", w.Code)
+ }
+ if loc := w.Header().Get("Location"); loc != "/login" {
+ t.Fatalf("Location=%q want /login", loc)
+ }
+ // Session cookie cleared
+ cleared := false
+ for _, sc := range w.Result().Header.Values("Set-Cookie") {
+ if strings.HasPrefix(sc, sessionCookieName+"=") &&
+ (strings.Contains(sc, "Max-Age=0") || strings.Contains(sc, "Max-Age=-1")) {
+ cleared = true
+ }
+ }
+ if !cleared {
+ // mergeCookies should drop empty/cleared
+ if csrfFromCookies(cookies2) != "" || extractCookie(w.Result(), sessionCookieName) != "" {
+ // extractCookie may still return empty value for cleared cookie
+ }
+ }
+ // Follow-up without re-login fails
+ w2, _ := authedGET(t, ts, "/dashboard", cookies2)
+ if w2.Code != http.StatusSeeOther {
+ t.Fatalf("after clear, dashboard status %d want 303", w2.Code)
+ }
+}
+
+func TestAPISessionRejectedAfterSoftDelete(t *testing.T) {
+ ts := newTestServer(t)
+ user := createTestUser(t, ts, "api-del1", int(protocol.NetworkRatingSupervisor))
+ cookies := formLogin(t, ts, user.CID, "api-del1")
+
+ u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ u.NetworkRating = int(protocol.NetworkRatingInactive)
+ u.Password = ""
+ if err := ts.dbRepo.UserRepo.UpdateUser(u); err != nil {
+ t.Fatal(err)
+ }
+
+ // Self load via session dual-accept
+ csrf := csrfFromCookies(cookies)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/user/load",
+ strings.NewReader(`{"cid":`+itoa(user.CID)+`}`))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Cookie", cookieHeader(cookies))
+ if csrf != "" {
+ req.Header.Set(csrfHeaderName, csrf)
+ }
+ w := httptest.NewRecorder()
+ ts.engine.ServeHTTP(w, req)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("API session after soft-delete status %d want 401 body %s", w.Code, w.Body.String())
+ }
+}
+
+func TestClaimsOverlayAfterDemotion(t *testing.T) {
+ ts := newTestServer(t)
+ admin := createTestUser(t, ts, "admin-pw1", int(protocol.NetworkRatingAdministator))
+ cookies := formLogin(t, ts, admin.CID, "admin-pw1")
+
+ // Demote to SUP in DB
+ u, err := ts.dbRepo.UserRepo.GetUserByCID(admin.CID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ u.NetworkRating = int(protocol.NetworkRatingSupervisor)
+ u.Password = ""
+ if err := ts.dbRepo.UserRepo.UpdateUser(u); err != nil {
+ t.Fatal(err)
+ }
+
+ w, cookies := authedGET(t, ts, "/dashboard", cookies)
+ if w.Code != http.StatusOK {
+ t.Fatalf("dashboard status %d", w.Code)
+ }
+ body := w.Body.String()
+ if strings.Contains(body, `href="/configeditor"`) {
+ t.Fatal("demoted SUP must not see Config nav")
+ }
+ if !strings.Contains(body, `href="/usereditor"`) {
+ t.Fatal("demoted SUP should still see Users")
+ }
+ if !strings.Contains(body, "Supervisor") {
+ t.Fatal("expected overlaid Supervisor label")
+ }
+
+ // Create with network_rating=12 must be rejected (ceiling uses overlaid SUP)
+ form := url.Values{}
+ form.Set("first_name", "X")
+ form.Set("password", "password99")
+ form.Set("network_rating", "12")
+ form.Set("pilot_rating", "0")
+ w, _ = formPOST(t, ts, "/usereditor/create", form, cookies)
+ if w.Code == http.StatusSeeOther && strings.Contains(w.Header().Get("Location"), "flash=created") {
+ t.Fatal("SUP must not create ADM-rated user after demotion overlay")
+ }
+}
+
+func TestLoginShowsAccountDeletedBanner(t *testing.T) {
+ ts := newTestServer(t)
+ req := httptest.NewRequest(http.MethodGet, "/login?account=deleted", nil)
+ w := httptest.NewRecorder()
+ ts.engine.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("status %d", w.Code)
+ }
+ if !strings.Contains(w.Body.String(), "Your account has been deleted.") {
+ t.Fatalf("expected deleted banner, body=%s", clip(w.Body.String(), 400))
+ }
+
+ req = httptest.NewRequest(http.MethodGet, "/login?account=deleted&permanent=disabled", nil)
+ w = httptest.NewRecorder()
+ ts.engine.ServeHTTP(w, req)
+ if !strings.Contains(w.Body.String(), "Permanent delete is not enabled") {
+ t.Fatalf("expected permanent disabled message, body=%s", clip(w.Body.String(), 400))
+ }
+}
+
+func TestRefreshRejectsInactiveUser(t *testing.T) {
+ ts := newTestServer(t)
+ user := createTestUser(t, ts, "refresh1x", int(protocol.NetworkRatingObserver))
+
+ // JSON login for refresh token
+ body := `{"cid":` + itoa(user.CID) + `,"password":"refresh1x"}`
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ ts.engine.ServeHTTP(w, req)
+ if w.Code != http.StatusOK {
+ t.Fatalf("login %d %s", w.Code, w.Body.String())
+ }
+ var res APIV1Response
+ if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
+ t.Fatal(err)
+ }
+ data, _ := json.Marshal(res.Data)
+ var tokens struct {
+ RefreshToken string `json:"refresh_token"`
+ }
+ if err := json.Unmarshal(data, &tokens); err != nil {
+ t.Fatal(err)
+ }
+
+ // Soft-delete
+ u, err := ts.dbRepo.UserRepo.GetUserByCID(user.CID)
+ if err != nil {
+ t.Fatal(err)
+ }
+ u.NetworkRating = int(protocol.NetworkRatingInactive)
+ u.Password = ""
+ if err := ts.dbRepo.UserRepo.UpdateUser(u); err != nil {
+ t.Fatal(err)
+ }
+
+ refBody := `{"refresh_token":"` + tokens.RefreshToken + `"}`
+ req = httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", strings.NewReader(refBody))
+ req.Header.Set("Content-Type", "application/json")
+ w = httptest.NewRecorder()
+ ts.engine.ServeHTTP(w, req)
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("refresh after inactive status %d want 401 body %s", w.Code, w.Body.String())
+ }
+}
diff --git a/internal/web/pe_admin_test.go b/internal/web/pe_admin_test.go
index e851b53..884dde2 100644
--- a/internal/web/pe_admin_test.go
+++ b/internal/web/pe_admin_test.go
@@ -5,6 +5,7 @@ import (
"net/http"
"net/http/httptest"
"net/url"
+ "strconv"
"strings"
"testing"
@@ -640,76 +641,85 @@ func TestSupervisorCannotUpdateHigherRatedUserViaForm(t *testing.T) {
}
}
-func TestInstructorCanAdjustRatingsButNotCreate(t *testing.T) {
+func TestInstructorCannotAccessUserEditor(t *testing.T) {
ts := newTestServer(t)
- // Give instructor a pilot ceiling (IR=3) so they can assign P0/PPL/IR.
inst := createTestUser(t, ts, "inst-pass", int(protocol.NetworkRatingInstructor1))
- inst.PilotRating = int(protocol.PilotRatingIR)
- inst.Password = ""
- if err := ts.dbRepo.UserRepo.UpdateUser(inst); err != nil {
- t.Fatal(err)
- }
- obs := createTestUser(t, ts, "obs-pass", int(protocol.NetworkRatingObserver))
cookies := formLogin(t, ts, inst.CID, "inst-pass")
- // Directory accessible.
- w, cookies := authedGET(t, ts, "/usereditor", cookies)
- if w.Code != http.StatusOK {
- t.Fatalf("GET /usereditor as I1 status %d", w.Code)
+ w, _ := authedGET(t, ts, "/usereditor", cookies)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("GET /usereditor as I1 status %d want 303", w.Code)
}
- if strings.Contains(w.Body.String(), `action="/usereditor/create"`) {
- t.Fatal("instructor must not see create form")
+ if loc := w.Header().Get("Location"); loc != "/dashboard" {
+ t.Fatalf("Location=%q want /dashboard", loc)
}
- // Create forbidden.
+ // POST create also gated by middleware.
form := url.Values{}
form.Set("first_name", "Nope")
form.Set("password", "password99")
form.Set("network_rating", "1")
form.Set("pilot_rating", "0")
- w, cookies = formPOST(t, ts, "/usereditor/create", form, cookies)
+ w, _ = formPOST(t, ts, "/usereditor/create", form, cookies)
if w.Code == http.StatusSeeOther && strings.Contains(w.Header().Get("Location"), "flash=created") {
t.Fatal("instructor must not create users")
}
-
- // Rating adjust allowed (network S1; pilot PPL=1 ≤ IR ceiling).
- form = url.Values{}
- form.Set("cid", itoa(obs.CID))
- form.Set("network_rating", "2") // S1 ≤ I1
- form.Set("pilot_rating", itoa(int(protocol.PilotRatingPPL)))
- form.Set("password", "")
- w, _ = formPOST(t, ts, "/usereditor/update", form, cookies)
- if w.Code != http.StatusSeeOther {
- t.Fatalf("instructor rating update status %d body %s", w.Code, w.Body.String())
- }
- u, err := ts.dbRepo.UserRepo.GetUserByCID(obs.CID)
- if err != nil {
- t.Fatal(err)
- }
- if u.NetworkRating != 2 {
- t.Fatalf("network = %d want 2", u.NetworkRating)
- }
- if u.PilotRating != int(protocol.PilotRatingPPL) {
- t.Fatalf("pilot = %d want PPL(%d)", u.PilotRating, protocol.PilotRatingPPL)
+ if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/dashboard" {
+ // Middleware redirect to dashboard is the expected gate.
+ if w.Code == http.StatusOK && strings.Contains(w.Body.String(), "created") {
+ t.Fatal("instructor must not create users")
+ }
}
}
-func TestInstructorCannotSetNetworkAboveOwn(t *testing.T) {
+func TestSupervisorUserEditorPilotRatingFullScale(t *testing.T) {
ts := newTestServer(t)
- inst := createTestUser(t, ts, "inst-pass", int(protocol.NetworkRatingInstructor1))
- obs := createTestUser(t, ts, "obs-pass", int(protocol.NetworkRatingObserver))
- cookies := formLogin(t, ts, inst.CID, "inst-pass")
+ // SUP with pilot P0 only — must still see full pilot scale and assign CMEL=15.
+ sup := createTestUser(t, ts, "sup-pass", int(protocol.NetworkRatingSupervisor))
+ if sup.PilotRating != 0 {
+ sup.PilotRating = 0
+ sup.Password = ""
+ if err := ts.dbRepo.UserRepo.UpdateUser(sup); err != nil {
+ t.Fatal(err)
+ }
+ }
+ cookies := formLogin(t, ts, sup.CID, "sup-pass")
+
+ w, cookies := authedGET(t, ts, "/usereditor?new=1", cookies)
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET /usereditor?new=1 status %d", w.Code)
+ }
+ body := w.Body.String()
+ for _, v := range []string{"0", "1", "3", "7", "15", "31", "63"} {
+ if !selectContainsValue(body, "create-pilot-rating", v) {
+ t.Fatalf("create pilot select missing value %s", v)
+ }
+ }
form := url.Values{}
- form.Set("cid", itoa(obs.CID))
- form.Set("network_rating", "11") // SUP — above I1
- form.Set("pilot_rating", "0")
- w, _ := formPOST(t, ts, "/usereditor/update", form, cookies)
- if w.Code == http.StatusSeeOther {
- t.Fatal("must not set network rating above actor")
+ form.Set("first_name", "Pilot")
+ form.Set("last_name", "Full")
+ form.Set("password", "password99")
+ form.Set("network_rating", "1")
+ form.Set("pilot_rating", "15") // CMEL — above actor P0
+ w, _ = formPOST(t, ts, "/usereditor/create", form, cookies)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("create with pilot_rating=15 status %d body %s", w.Code, w.Body.String())
}
- if !strings.Contains(w.Body.String(), "above your own") {
- t.Fatalf("expected ceiling error, body=%s", clip(w.Body.String(), 400))
+ loc := w.Header().Get("Location")
+ assertUserEditorRedirect(t, loc, "", "created")
+ // Load created user by CID from redirect.
+ u, err := url.Parse(loc)
+ if err != nil {
+ t.Fatal(err)
+ }
+ cid, _ := strconv.Atoi(u.Query().Get("cid"))
+ created, err := ts.dbRepo.UserRepo.GetUserByCID(cid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if created.PilotRating != 15 {
+ t.Fatalf("pilot_rating=%d want 15", created.PilotRating)
}
}
diff --git a/internal/web/routes.go b/internal/web/routes.go
index 83f1c17..5d2c490 100644
--- a/internal/web/routes.go
+++ b/internal/web/routes.go
@@ -118,15 +118,32 @@ func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) {
authed.Use(s.requireSessionHTML)
authed.GET("/dashboard", s.handleFrontendDashboard)
- // Instructor1+ user directory: list/load + rating updates (handlers enforce ceilings).
- // Create + full profile mutation require Supervisor+ (checked in handlers).
+ // Account self-service — any DB-valid session (OBS+).
+ authed.GET("/account", s.handleFrontendAccount)
+ authed.POST("/account/password", s.handleFrontendAccountPassword)
+ authed.POST("/account/delete", s.handleFrontendAccountDelete)
+
+ // User editor — Supervisor+ (create + full profile + ratings).
userAdmin := authed.Group("")
- userAdmin.Use(s.requireMinRatingHTML(protocol.NetworkRatingInstructor1))
+ userAdmin.Use(s.requireMinRatingHTML(protocol.NetworkRatingSupervisor))
userAdmin.GET("/usereditor", s.handleFrontendUserEditor)
userAdmin.POST("/usereditor/create", s.handleFrontendUserCreate)
userAdmin.POST("/usereditor/update", s.handleFrontendUserUpdate)
- // Admin config: form POST mutations with CSRF; no JS required.
+ // Sweatbox instructor UI: Instructor1+ (HTML forms; proxies FSD /sweatbox/*).
+ instructor := authed.Group("")
+ instructor.Use(s.requireMinRatingHTML(protocol.NetworkRatingInstructor1))
+ instructor.GET("/sweatbox", s.handleFrontendSweatbox)
+ instructor.GET("/sweatbox/manual", s.handleFrontendSweatboxManual)
+ instructor.POST("/sweatbox/airport", s.handleFrontendSweatboxAirport)
+ instructor.POST("/sweatbox/scenario", s.handleFrontendSweatboxScenario)
+ instructor.POST("/sweatbox/command", s.handleFrontendSweatboxCommand)
+ instructor.POST("/sweatbox/pause", s.handleFrontendSweatboxPause)
+ instructor.POST("/sweatbox/unpause", s.handleFrontendSweatboxUnpause)
+ instructor.POST("/sweatbox/delete", s.handleFrontendSweatboxDelete)
+ instructor.POST("/sweatbox/delete-all", s.handleFrontendSweatboxDeleteAll)
+
+ // Admin config + airport editor: form POST mutations with CSRF; no JS required.
admin := authed.Group("")
admin.Use(s.requireMinRatingHTML(protocol.NetworkRatingAdministator))
admin.GET("/configeditor", s.handleFrontendConfigEditor)
@@ -134,17 +151,6 @@ func (s *Server) setupFrontendRoutes(parent *gin.RouterGroup) {
admin.POST("/configeditor/reset-secret", s.handleFrontendConfigResetSecret)
admin.POST("/configeditor/create-token", s.handleFrontendConfigCreateToken)
- // Sweatbox instructor UI: server-rendered forms; proxies FSD /sweatbox/* service HTTP.
- admin.GET("/sweatbox", s.handleFrontendSweatbox)
- admin.GET("/sweatbox/manual", s.handleFrontendSweatboxManual)
- admin.POST("/sweatbox/airport", s.handleFrontendSweatboxAirport)
- admin.POST("/sweatbox/scenario", s.handleFrontendSweatboxScenario)
- admin.POST("/sweatbox/command", s.handleFrontendSweatboxCommand)
- admin.POST("/sweatbox/pause", s.handleFrontendSweatboxPause)
- admin.POST("/sweatbox/unpause", s.handleFrontendSweatboxUnpause)
- admin.POST("/sweatbox/delete", s.handleFrontendSweatboxDelete)
- admin.POST("/sweatbox/delete-all", s.handleFrontendSweatboxDeleteAll)
-
// Airport editor: HTML shell + no-JS echo-download (no disk/DB persistence).
admin.GET("/airport-editor", s.handleFrontendAirportEditor)
admin.POST("/airport-editor/download-apt", s.handleFrontendAirportEditorDownloadAPT)
diff --git a/internal/web/templates.go b/internal/web/templates.go
index 6bcc819..290717c 100644
--- a/internal/web/templates.go
+++ b/internal/web/templates.go
@@ -22,6 +22,7 @@ var pageTemplateKeys = []string{
"landing",
"login",
"dashboard",
+ "account",
"usereditor",
"configeditor",
"sweatbox",
diff --git a/internal/web/templates/account.html b/internal/web/templates/account.html
new file mode 100644
index 0000000..351942e
--- /dev/null
+++ b/internal/web/templates/account.html
@@ -0,0 +1,115 @@
+{{ define "title" }}Account{{ end }}
+
+{{ define "body" }}
+
+ Account
+
+ {{ if .FlashSuccess }}
+ {{ .FlashSuccess }}
+ {{ end }}
+ {{ if .FlashError }}
+ {{ .FlashError }}
+ {{ end }}
+ {{ if .FormError }}
+ {{ .FormError }}
+ {{ end }}
+
+
+ Profile
+
+ - CID
+ - {{ .CID }}
+ - Name
+ - {{ .FirstName }} {{ .LastName }}
+ - Network rating
+ - {{ .NetworkLabel }}
+ - Pilot rating
+ - {{ .PilotLabel }}
+
+
+ Network and pilot ratings can only be changed by a supervisor.
+
+
+
+
+ Change password
+
+ Changing your password does not sign out other devices until their session expires.
+ Deleting or disabling the account ends other sessions on their next request.
+
+
+
+
+
+ Delete my account
+
+ This deactivates your certificate. You will not be able to log in again.
+ A supervisor can restore a soft-deleted account. Hard delete is irreversible
+ and only available when enabled on this server.
+
+ {{ if .DeleteError }}
+ {{ .DeleteError }}
+ {{ end }}
+
+
+
+ ← Back to dashboard
+
+{{ end }}
diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html
index 69752a1..3dd7421 100644
--- a/internal/web/templates/dashboard.html
+++ b/internal/web/templates/dashboard.html
@@ -16,6 +16,40 @@
data-js="dashboard">
Dashboard
+
+ Your account
+
+ {{ if .User.FirstName }}Welcome, {{ .User.FirstName }}!{{ else }}Welcome!{{ end }}
+
+ CID: {{ .User.CID }}
+ Network Rating: {{ .User.NetworkRatingLabel }}
+ {{ if .PilotRatingLabel }}
+ Pilot Rating: {{ .PilotRatingLabel }}
+ {{ end }}
+
+
+
+
+
Connections
{{ if .SummaryUnavailable }}
@@ -68,25 +102,6 @@
-
-
-
-
- {{ if .User.FirstName }}Welcome, {{ .User.FirstName }}!{{ else }}Welcome!{{ end }}
-
-
CID: {{ .User.CID }}
-
Network Rating: {{ .User.NetworkRatingLabel }}
-
-
- {{ if .User.CanEditUsers }}
-
- {{ end }}
- {{ if .User.CanEditConfig }}
-
-
- {{ end }}
-
-
diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html
index 888f9d2..9fc55ab 100644
--- a/internal/web/templates/layout.html
+++ b/internal/web/templates/layout.html
@@ -46,12 +46,15 @@
{{ if .User }}
Dashboard
+ Account
{{ if .User.CanEditUsers }}
Users
{{ end }}
+ {{ if .User.CanAccessSweatbox }}
+ Sweatbox
+ {{ end }}
{{ if .User.CanEditConfig }}
Config
- Sweatbox
Airport Editor
{{ end }}