User settings
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).
Routes
All routes live under /settings and require an authenticated user (chi RequireUser middleware on the group).
| Route | Method | Source | Notes |
|---|---|---|---|
/settings/profile |
GET/POST | auth.profile.go |
Display name, bio, location, website, company, pronouns. Right-side avatar card. |
/settings/profile/avatar |
POST | auth.avatar.go |
Multipart upload. 5 MiB cap, EXIF stripped, 460/200/40 PNG variants. |
/settings/profile/avatar/remove |
POST | auth.avatar.go |
Clears users.avatar_object_key; objects are kept (orphan sweep is post-MVP). |
/settings/account |
GET | auth.account.go |
Username change form. |
/settings/account/username |
POST | auth.account.go |
Inserts username_redirects, renames users, audit + notify. |
/settings/password |
GET/POST | auth.password_change.go |
Current pw + 2FA gate, bumps session_epoch. |
/settings/emails |
GET/POST | auth.emails.go |
List and add a new address (sends a verify email). |
/settings/emails/{id}/resend |
POST | auth.emails.go |
Re-issues a verification token for an unverified row. |
/settings/emails/{id}/primary |
POST | auth.emails.go |
Atomically swaps the primary; refuses unverified rows. |
/settings/emails/{id}/remove |
POST | auth.emails.go |
Refuses to delete the primary address. |
/settings/appearance |
GET/POST | auth.appearance.go |
Theme picker. Writes both users.theme and the theme cookie. |
/settings/notifications |
GET/POST | auth.notifications.go |
Generic k/v editor over user_notification_prefs. |
/settings/sessions |
GET | auth.sessions.go |
Current session info. |
/settings/sessions/logout-everywhere |
POST | auth.sessions.go |
Bumps users.session_epoch. |
/settings/security/2fa/* |
GET/POST | auth.twofactor.go |
(Wired in S06.) |
/settings/keys and /settings/keys/{id}/delete |
GET/POST | auth.sshkeys.go |
(Wired in S07.) |
/settings/tokens, /settings/tokens/{id}/revoke |
GET/POST | auth.tokens.go |
(Wired in S08.) |
/settings/danger |
GET/POST | auth.danger.go |
Soft-delete with 14-day grace; restore-on-login. |
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.
Session epoch — log-out-everywhere
users.session_epoch (integer, default 0) is bumped to invalidate every cookie that carries an older value.
- The login handler snapshots the user's current epoch into
Session.Epochat issue time. middleware.OptionalUser'sUserLookupreturns(username, epoch); when the session's stored epoch ≠ the DB's, the binding is skipped andRequireUserwill redirect the next protected hit to/login.- Three handlers bump:
- Password change (
/settings/password) — security best practice; explicitly mentioned in the success flash. - Log out everywhere (
/settings/sessions/logout-everywhere) — the explicit user-driven action. - Account deletion (
/settings/danger) — kicks every live session of the soon-to-be-deleted user.
- Password change (
- After every bump, the current session is re-issued with the new epoch so the user staying on this browser is not signed out.
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).
Username change — rate limit + redirect
Three rules:
- Shape —
^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$. Input is lowercased server-side, so users who typeBobgetbob. - Reservation —
internal/auth/reserved.IsReserved. - Cap — at most 3 changes per rolling 60-day window. Counted by
username_redirects.changed_atrows for that user, so the redirect IS the audit trail; no separate counter table.
Tx body:
INSERT INTO username_redirects (old_username, user_id) VALUES ($1, $2);
UPDATE users SET username = $3 WHERE id = $2;
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."
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.
Avatar pipeline
internal/avatars.Process(io.Reader):
- Reads up to
MaxUploadBytes + 1(5 MiB + sentinel) and rejects oversized payloads. image.DecodeConfigfirst — checks the format whitelist (PNG / JPEG / GIF) AND rejects images whose pixel area exceedsMaxPixelArea(24 megapixels) before allocating the full pixel buffer. Defends against decompression-bomb files that are tiny on disk but enormous when decoded.- Decode for real.
- Center-square crop so the resize never squashes.
- CatmullRom resize via
golang.org/x/image/drawto eachVariantSizestarget (460 / 200 / 40). - Re-encode each as PNG (which strips any EXIF the source carried — we never read it).
- Hash the largest variant; return that hash for the storage key.
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.
Variants 200 and 40 are written today but not yet served — a future sprint can add /avatars/{username}/{size} without re-resizing.
Email management
The 4-action surface:
- Add — creates a
user_emailsrow withis_primary=false, verified=falseand a newemail_verificationsrow. The verification email is sent post-commit. - Resend — only for unverified rows; rotates the token + sets a fresh expiry.
- Set primary — refuses unverified rows. Atomic swap is
SetUserEmailPrimary(UPDATE … SET is_primary = (id = $2) WHERE user_id = $1); the partial unique indexuser_emails_one_primary_per_userkeeps the invariant. - Remove — refuses the primary address (DB query has
AND is_primary = false); user must promote a different verified address first.
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.
Notification preferences
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):
| Key | Default | Toggleable | Carries |
|---|---|---|---|
security_alerts |
on | no (Required) | password_changed, log_out_everywhere, 2FA changes |
account_changes |
on | yes | username_changed, primary_email_changed, account_deletion_initiated |
product_news |
off | yes | reserved for future broadcast emails |
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."
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.
Soft-delete + 14-day grace
/settings/danger requires the user to retype their username and current password. On confirmation:
SoftDeleteUser—UPDATE users SET deleted_at = now() WHERE id = $1.BumpUserSessionEpoch— invalidates every cookie.- (post-commit) Audit row + notification email (built directly via
email.NoticeMessagewith the captured primary address —notifyState's usual lookup filters out soft-deleted users). SessionStore.Clear(w)and a 303 to/?notice=account-deleted.
Restore-on-login (in the login handler):
user, err := h.q.GetUserByUsernameIncludingDeleted(ctx, pool, username) // see deleted rows
if user.DeletedAt.Valid && time.Since(user.DeletedAt.Time) >= deletionGraceWindow {
// Past the 14-day window — treat as nonexistent.
password.VerifyAgainstDummy(pw)
render("Incorrect username or password.")
return
}
// Verify password; if OK, RestoreUserAccount() (sets deleted_at = NULL).
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.
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.
State-change notifications — kinds
internal/auth/email/messages.go::noticeBodies plus auth/notify.go::kindToChannel:
| Kind | Channel | Trigger |
|---|---|---|
password_changed |
security_alerts | POST /settings/password success |
log_out_everywhere |
security_alerts | POST /settings/sessions/logout-everywhere |
username_changed |
account_changes | POST /settings/account/username success |
primary_email_changed |
account_changes | POST /settings/emails/{id}/primary |
account_deletion_initiated |
account_changes | POST /settings/danger success |
2fa_enabled / 2fa_disabled / recovery_regenerated / admin_cleared_2fa |
security_alerts | (Wired in S06.) |
Send is best-effort: failures are logged but never break the flow.
Theme + first-paint flash avoidance
Set on POST /settings/appearance:
users.theme— source of truth across devices.themecookie (SameSite=Lax,MaxAge1 year, NOTHttpOnly— the_layout.htmlinline script reads it before any CSS computes).
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.
Reference visual conformance
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:
- Sidebar grouped headers (
Access,Danger) with uppercase letter-spaced labels. - Active link with left-edge accent border + subtle canvas-subtle background.
- Card sections separated by hairline
--border-mutedborders. - Danger-zone red-bordered card and red-text headline.
- Two-column inner layout for the profile editor (form left, profile-picture aside right).
- Pill chips for email row state (
primary/verified/unverified). - Theme picker as 5 radio cards in a responsive grid.
CSS lives in internal/web/static/css/shithub.css under /* ----- settings shell (S10) ----- */ and the topic-specific blocks below it.
Audit trail
Each security-relevant settings action records an audit_log row via internal/auth/audit.Recorder. Actions added in S10:
username_changed(withfrom/tometa).account_deleted(withgrace_daysmeta).account_restored(recorded by login restore-on-login when it fires).
The pre-existing password_changed, 2FA actions, and SSH/PAT actions are reused.
Pitfalls / what to remember
- Epoch bumps must re-issue the current session. Otherwise the user signs themselves out. See
password_change.go::settingsPasswordSubmitandsessions.go::settingsSessionsLogoutAllfor the pattern. - 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, immutableon the avatar response is safe. Don't add a "no-cache" override. - Username regex is server-side normalization, not validation only. "Bob" lowercases to "bob" before any other check; tests assume this.
- Password change requires 2FA recency when 2FA is enrolled. Users without 2FA bypass it (
recentAuthOKreturns true). Audit/notify still fire. - Soft-deleted users are invisible to
GetUserByUsername, so the post-deletion notification email is built using the captured primary address (notifyDeletedAccount) rather than the standardnotifyStatepath. - Don't delete object-store keys on avatar removal. Old objects accumulate; an orphan-sweep job is post-MVP.
- 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.
Related docs
docs/internal/auth.md— login, sessions, recent-2FA gate.docs/internal/2fa.md— 2FA enrollment flow + secretbox.docs/internal/profile.md— public/{username}page (S09).docs/internal/storage.md— object-store conventions used by avatar uploads.docs/internal/tokens.md—/settings/tokens(S08).
View source
| 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). |