@@ -0,0 +1,196 @@ |
| | 1 | +# User settings |
| | 2 | + |
| | 3 | +S10 ships every page under `/settings/*` for the authenticated user — the public profile editor, avatar upload pipeline, account-management surface (username + password), email management, notification preferences, theme, current-session view + log-out-everywhere, and the danger zone (soft-delete with grace window). State-changing actions write audit-log rows and send notification emails (gated by user prefs except for security alerts). |
| | 4 | + |
| | 5 | +## Routes |
| | 6 | + |
| | 7 | +All routes live under `/settings` and require an authenticated user (chi `RequireUser` middleware on the group). |
| | 8 | + |
| | 9 | +| Route | Method | Source | Notes | |
| | 10 | +|---|---|---|---| |
| | 11 | +| `/settings/profile` | GET/POST | `auth.profile.go` | Display name, bio, location, website, company, pronouns. Right-side avatar card. | |
| | 12 | +| `/settings/profile/avatar` | POST | `auth.avatar.go` | Multipart upload. 5 MiB cap, EXIF stripped, 460/200/40 PNG variants. | |
| | 13 | +| `/settings/profile/avatar/remove` | POST | `auth.avatar.go` | Clears `users.avatar_object_key`; objects are kept (orphan sweep is post-MVP). | |
| | 14 | +| `/settings/account` | GET | `auth.account.go` | Username change form. | |
| | 15 | +| `/settings/account/username` | POST | `auth.account.go` | Inserts `username_redirects`, renames `users`, audit + notify. | |
| | 16 | +| `/settings/password` | GET/POST | `auth.password_change.go` | Current pw + 2FA gate, bumps `session_epoch`. | |
| | 17 | +| `/settings/emails` | GET/POST | `auth.emails.go` | List and add a new address (sends a verify email). | |
| | 18 | +| `/settings/emails/{id}/resend` | POST | `auth.emails.go` | Re-issues a verification token for an unverified row. | |
| | 19 | +| `/settings/emails/{id}/primary` | POST | `auth.emails.go` | Atomically swaps the primary; refuses unverified rows. | |
| | 20 | +| `/settings/emails/{id}/remove` | POST | `auth.emails.go` | Refuses to delete the primary address. | |
| | 21 | +| `/settings/appearance` | GET/POST | `auth.appearance.go` | Theme picker. Writes both `users.theme` and the `theme` cookie. | |
| | 22 | +| `/settings/notifications` | GET/POST | `auth.notifications.go` | Generic k/v editor over `user_notification_prefs`. | |
| | 23 | +| `/settings/sessions` | GET | `auth.sessions.go` | Current session info. | |
| | 24 | +| `/settings/sessions/logout-everywhere` | POST | `auth.sessions.go` | Bumps `users.session_epoch`. | |
| | 25 | +| `/settings/security/2fa/*` | GET/POST | `auth.twofactor.go` | (Wired in S06.) | |
| | 26 | +| `/settings/keys` and `/settings/keys/{id}/delete` | GET/POST | `auth.sshkeys.go` | (Wired in S07.) | |
| | 27 | +| `/settings/tokens`, `/settings/tokens/{id}/revoke` | GET/POST | `auth.tokens.go` | (Wired in S08.) | |
| | 28 | +| `/settings/danger` | GET/POST | `auth.danger.go` | Soft-delete with 14-day grace; restore-on-login. | |
| | 29 | + |
| | 30 | +The settings sidebar (`internal/web/templates/_settings_nav.html`) is shared by every settings template. Each handler passes a `SettingsActive` string so the active link is highlighted. |
| | 31 | + |
| | 32 | +## Session epoch — log-out-everywhere |
| | 33 | + |
| | 34 | +`users.session_epoch` (integer, default 0) is bumped to invalidate every cookie that carries an older value. |
| | 35 | + |
| | 36 | +- The login handler snapshots the user's current epoch into `Session.Epoch` at issue time. |
| | 37 | +- `middleware.OptionalUser`'s `UserLookup` returns `(username, epoch)`; when the session's stored epoch ≠ the DB's, the binding is skipped and `RequireUser` will redirect the next protected hit to `/login`. |
| | 38 | +- Three handlers bump: |
| | 39 | + - **Password change** (`/settings/password`) — security best practice; explicitly mentioned in the success flash. |
| | 40 | + - **Log out everywhere** (`/settings/sessions/logout-everywhere`) — the explicit user-driven action. |
| | 41 | + - **Account deletion** (`/settings/danger`) — kicks every live session of the soon-to-be-deleted user. |
| | 42 | +- After every bump, the *current* session is re-issued with the new epoch so the user staying on this browser is not signed out. |
| | 43 | + |
| | 44 | +The cookie itself is not actively cleared on bump (other than for account deletion). The stale cookie is harmless: its next protected hit lands on `/login`. It also expires via its `MaxAge` window (`session.DefaultMaxAge`, currently 30 days). |
| | 45 | + |
| | 46 | +## Username change — rate limit + redirect |
| | 47 | + |
| | 48 | +Three rules: |
| | 49 | + |
| | 50 | +1. **Shape** — `^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$`. Input is lowercased server-side, so users who type `Bob` get `bob`. |
| | 51 | +2. **Reservation** — `internal/auth/reserved.IsReserved`. |
| | 52 | +3. **Cap** — at most 3 changes per rolling 60-day window. Counted by `username_redirects.changed_at` rows for that user, so the redirect IS the audit trail; no separate counter table. |
| | 53 | + |
| | 54 | +Tx body: |
| | 55 | + |
| | 56 | +```sql |
| | 57 | +INSERT INTO username_redirects (old_username, user_id) VALUES ($1, $2); |
| | 58 | +UPDATE users SET username = $3 WHERE id = $2; |
| | 59 | +``` |
| | 60 | + |
| | 61 | +Post-commit: `audit_log` row + state-change email. The new name is unique by `users_username_unique` (citext); a redirect row clash on the same name (someone else's old name during their grace window) raises 23505 and surfaces "That username is taken." |
| | 62 | + |
| | 63 | +The old name is reserved as a redirect for 30 days (handled by the existing username-redirects logic introduced in S09). The 30-day reservation is implicit — `signup` already consults the same table. |
| | 64 | + |
| | 65 | +## Avatar pipeline |
| | 66 | + |
| | 67 | +`internal/avatars.Process(io.Reader)`: |
| | 68 | + |
| | 69 | +1. Reads up to `MaxUploadBytes + 1` (5 MiB + sentinel) and rejects oversized payloads. |
| | 70 | +2. `image.DecodeConfig` first — checks the format whitelist (PNG / JPEG / GIF) AND rejects images whose pixel area exceeds `MaxPixelArea` (24 megapixels) before allocating the full pixel buffer. Defends against decompression-bomb files that are tiny on disk but enormous when decoded. |
| | 71 | +3. Decode for real. |
| | 72 | +4. Center-square crop so the resize never squashes. |
| | 73 | +5. CatmullRom resize via `golang.org/x/image/draw` to each `VariantSizes` target (460 / 200 / 40). |
| | 74 | +6. Re-encode each as PNG (which strips any EXIF the source carried — we never read it). |
| | 75 | +7. Hash the largest variant; return that hash for the storage key. |
| | 76 | + |
| | 77 | +Object-store layout: `avatars/{user_id}/{hash}.png` for the largest variant; smaller variants get a `-{size}` suffix. The largest variant's key is stored in `users.avatar_object_key` and the public `/avatars/{username}` route serves it directly. |
| | 78 | + |
| | 79 | +Variants 200 and 40 are written today but not yet served — a future sprint can add `/avatars/{username}/{size}` without re-resizing. |
| | 80 | + |
| | 81 | +## Email management |
| | 82 | + |
| | 83 | +The 4-action surface: |
| | 84 | + |
| | 85 | +- **Add** — creates a `user_emails` row with `is_primary=false, verified=false` and a new `email_verifications` row. The verification email is sent post-commit. |
| | 86 | +- **Resend** — only for unverified rows; rotates the token + sets a fresh expiry. |
| | 87 | +- **Set primary** — refuses unverified rows. Atomic swap is `SetUserEmailPrimary` (`UPDATE … SET is_primary = (id = $2) WHERE user_id = $1`); the partial unique index `user_emails_one_primary_per_user` keeps the invariant. |
| | 88 | +- **Remove** — refuses the primary address (DB query has `AND is_primary = false`); user must promote a different verified address first. |
| | 89 | + |
| | 90 | +A primary email change writes an `audit_log` row (TODO: explicitly — currently only the email goes out) and sends a `primary_email_changed` notice on the `account_changes` channel. |
| | 91 | + |
| | 92 | +## Notification preferences |
| | 93 | + |
| | 94 | +`user_notification_prefs` is a tiny key/value/jsonb table. The settings page surfaces a fixed list of "channels" (`internal/web/handlers/auth/notifications.go::notifChannels`): |
| | 95 | + |
| | 96 | +| Key | Default | Toggleable | Carries | |
| | 97 | +|---|---|---|---| |
| | 98 | +| `security_alerts` | on | no (Required) | password_changed, log_out_everywhere, 2FA changes | |
| | 99 | +| `account_changes` | on | yes | username_changed, primary_email_changed, account_deletion_initiated | |
| | 100 | +| `product_news` | off | yes | reserved for future broadcast emails | |
| | 101 | + |
| | 102 | +Storage strategy: when the desired state matches the channel's default, the row is **deleted** rather than upserted. This keeps the table small and makes "reset to defaults" a no-op. The form omits a checkbox to encode "off." |
| | 103 | + |
| | 104 | +`shouldNotify(ctx, userID, kind)` is the single gate: kinds whose channel is `security_alerts` always return true; kinds on `account_changes` consult the persisted row, falling back to the default when absent. |
| | 105 | + |
| | 106 | +## Soft-delete + 14-day grace |
| | 107 | + |
| | 108 | +`/settings/danger` requires the user to retype their username and current password. On confirmation: |
| | 109 | + |
| | 110 | +1. `SoftDeleteUser` — `UPDATE users SET deleted_at = now() WHERE id = $1`. |
| | 111 | +2. `BumpUserSessionEpoch` — invalidates every cookie. |
| | 112 | +3. (post-commit) Audit row + notification email (built directly via `email.NoticeMessage` with the captured primary address — `notifyState`'s usual lookup filters out soft-deleted users). |
| | 113 | +4. `SessionStore.Clear(w)` and a 303 to `/?notice=account-deleted`. |
| | 114 | + |
| | 115 | +Restore-on-login (in the login handler): |
| | 116 | + |
| | 117 | +```go |
| | 118 | +user, err := h.q.GetUserByUsernameIncludingDeleted(ctx, pool, username) // see deleted rows |
| | 119 | +if user.DeletedAt.Valid && time.Since(user.DeletedAt.Time) >= deletionGraceWindow { |
| | 120 | + // Past the 14-day window — treat as nonexistent. |
| | 121 | + password.VerifyAgainstDummy(pw) |
| | 122 | + render("Incorrect username or password.") |
| | 123 | + return |
| | 124 | +} |
| | 125 | +// Verify password; if OK, RestoreUserAccount() (sets deleted_at = NULL). |
| | 126 | +``` |
| | 127 | + |
| | 128 | +A within-grace user therefore restores the moment they prove ownership of the password. Past the window the row stays soft-deleted and the response is indistinguishable from "doesn't exist," same constant-time cost. |
| | 129 | + |
| | 130 | +The public profile (`/{username}`) still uses `GetUserByUsername` (which filters `deleted_at IS NULL`), so a soft-deleted account renders as 404 immediately. Suspended accounts still render the dedicated 410 "unavailable" page introduced in S09. |
| | 131 | + |
| | 132 | +## State-change notifications — kinds |
| | 133 | + |
| | 134 | +`internal/auth/email/messages.go::noticeBodies` plus `auth/notify.go::kindToChannel`: |
| | 135 | + |
| | 136 | +| Kind | Channel | Trigger | |
| | 137 | +|---|---|---| |
| | 138 | +| `password_changed` | security_alerts | POST /settings/password success | |
| | 139 | +| `log_out_everywhere` | security_alerts | POST /settings/sessions/logout-everywhere | |
| | 140 | +| `username_changed` | account_changes | POST /settings/account/username success | |
| | 141 | +| `primary_email_changed` | account_changes | POST /settings/emails/{id}/primary | |
| | 142 | +| `account_deletion_initiated` | account_changes | POST /settings/danger success | |
| | 143 | +| `2fa_enabled` / `2fa_disabled` / `recovery_regenerated` / `admin_cleared_2fa` | security_alerts | (Wired in S06.) | |
| | 144 | + |
| | 145 | +Send is best-effort: failures are logged but never break the flow. |
| | 146 | + |
| | 147 | +## Theme + first-paint flash avoidance |
| | 148 | + |
| | 149 | +Set on POST `/settings/appearance`: |
| | 150 | + |
| | 151 | +- `users.theme` — source of truth across devices. |
| | 152 | +- `theme` cookie (`SameSite=Lax`, `MaxAge` 1 year, NOT `HttpOnly` — the `_layout.html` inline script reads it before any CSS computes). |
| | 153 | + |
| | 154 | +Allowed values: `""` (the cookie wins, defaulting to system preference), `light`, `dark`, `auto`, `high_contrast`. Mirrors the CHECK constraint added in migration 0016. Empty value clears the cookie. |
| | 155 | + |
| | 156 | +## Reference visual conformance |
| | 157 | + |
| | 158 | +Every settings page renders inside `_settings_nav.html` (left sidebar with "Settings" group, "Access" group, "Danger" group) + a content pane of card-style sections. Visual reference: GitHub's own settings pages (kept in `.refs/github/images/user/`). Notable adopted patterns: |
| | 159 | + |
| | 160 | +- Sidebar grouped headers (`Access`, `Danger`) with uppercase letter-spaced labels. |
| | 161 | +- Active link with left-edge accent border + subtle canvas-subtle background. |
| | 162 | +- Card sections separated by hairline `--border-muted` borders. |
| | 163 | +- Danger-zone red-bordered card and red-text headline. |
| | 164 | +- Two-column inner layout for the profile editor (form left, profile-picture aside right). |
| | 165 | +- Pill chips for email row state (`primary` / `verified` / `unverified`). |
| | 166 | +- Theme picker as 5 radio cards in a responsive grid. |
| | 167 | + |
| | 168 | +CSS lives in `internal/web/static/css/shithub.css` under `/* ----- settings shell (S10) ----- */` and the topic-specific blocks below it. |
| | 169 | + |
| | 170 | +## Audit trail |
| | 171 | + |
| | 172 | +Each security-relevant settings action records an `audit_log` row via `internal/auth/audit.Recorder`. Actions added in S10: |
| | 173 | + |
| | 174 | +- `username_changed` (with `from`/`to` meta). |
| | 175 | +- `account_deleted` (with `grace_days` meta). |
| | 176 | +- `account_restored` (recorded by login restore-on-login when it fires). |
| | 177 | + |
| | 178 | +The pre-existing `password_changed`, 2FA actions, and SSH/PAT actions are reused. |
| | 179 | + |
| | 180 | +## Pitfalls / what to remember |
| | 181 | + |
| | 182 | +- **Epoch bumps must re-issue the current session.** Otherwise the user signs themselves out. See `password_change.go::settingsPasswordSubmit` and `sessions.go::settingsSessionsLogoutAll` for the pattern. |
| | 183 | +- **Avatar route is read-mostly.** Uploaded variants are content-addressed by the largest variant's hash; the URL changes when the image changes, so `Cache-Control: public, max-age=31536000, immutable` on the avatar response is safe. Don't add a "no-cache" override. |
| | 184 | +- **Username regex is server-side normalization, not validation only.** "Bob" lowercases to "bob" before any other check; tests assume this. |
| | 185 | +- **Password change requires 2FA recency** when 2FA is enrolled. Users without 2FA bypass it (`recentAuthOK` returns true). Audit/notify still fire. |
| | 186 | +- **Soft-deleted users are invisible to `GetUserByUsername`**, so the post-deletion notification email is built using the captured primary address (`notifyDeletedAccount`) rather than the standard `notifyState` path. |
| | 187 | +- **Don't delete object-store keys on avatar removal.** Old objects accumulate; an orphan-sweep job is post-MVP. |
| | 188 | +- **Notification prefs schema is generic** (k/v/jsonb). Adding a channel is a code change to `notifChannels`, not a migration. Keep the channel list short. |
| | 189 | + |
| | 190 | +## Related docs |
| | 191 | + |
| | 192 | +- `docs/internal/auth.md` — login, sessions, recent-2FA gate. |
| | 193 | +- `docs/internal/2fa.md` — 2FA enrollment flow + secretbox. |
| | 194 | +- `docs/internal/profile.md` — public `/{username}` page (S09). |
| | 195 | +- `docs/internal/storage.md` — object-store conventions used by avatar uploads. |
| | 196 | +- `docs/internal/tokens.md` — `/settings/tokens` (S08). |