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

Your account

+ + Manage account +
+ +
+

Tools

+ +
+ + +``` + +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 }} + + {{ end }} + {{ if .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. +

+
+ +
+ + + {{ if .CurrentPassError }} +
{{ .CurrentPassError }}
+ {{ end }} +
+
+ + + {{ if .NewPassError }} +
{{ .NewPassError }}
+ {{ end }} +
+
+ + + {{ if .ConfirmPassError }} +
{{ .ConfirmPassError }}
+ {{ end }} +
+ +
+
+ +
+

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 }} + + {{ end }} +
+ +
+ + + {{ if .DeletePassError }} +
{{ .DeletePassError }}
+ {{ end }} +
+
+ + +
+ {{ if .AllowPermanentDelete }} +
+ + +
+ {{ 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 }} +
+ Manage account +
+
+ +
+

Tools

+ +
+

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 }}
diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html index 4a44d38..48aea19 100644 --- a/internal/web/templates/login.html +++ b/internal/web/templates/login.html @@ -4,6 +4,9 @@