## What Completes the `/user/account` redesign behind the `accountSettingsV2` flag. The earlier commits on this branch built the two-pane shell and converted three panes; this finishes the other five, then polishes the result against the Pencil canvas (`designs/user-account.pen`). **OFF serves the legacy single-column page byte-identically.** See "Rollout" below. ## Panes | Pane | Change | |---|---| | Overview | Tier badge art in the Membership tile (links `/user/membership`); Standing replaces the creator-score figure; username renders its nameplate + badge cosmetics; identity card stacks on mobile | | Profile & Account | `ProfileCard` / `SocialProfileCard` flattened; Account standing shows the exact score; session refresh and delete are pointer rows, grouped | | Preferences | Regrouped to Media playback / Generation / File preferences / Features; image format moved to File preferences; assistant folded into Features | | Content & Browsing | Eye callout; mature-content rows; Topics as chips; hidden tags/users flattened | | Creator | Placement, remix and metric-visibility sections; sticker inventory pointer moved inside Stickers | | Membership & Billing | Subscription / payment methods / payouts flattened; gifts point at `/pricing/gift`; membership row stacks on mobile; empty states when the user has neither a membership nor a Creator Program payout config | | Security & Apps | Sign-in methods, API keys, OAuth apps, connected apps flattened; create buttons on the section heading | | Notifications | Delivery section; per-category icons; more room in an open category; `Other` sorted last | ## Decisions worth a reviewer's attention - **Cards take a `flat` prop rather than being forked.** The legacy page mounts the same components while the flag is alive; two copies of a settings form is how one of them silently loses a field. - **One rule per section.** Eight rows had nine dividers and read as a table. Rows are spaced instead. - **`/user/account/overview` is a new URL.** On mobile the index renders the section *menu*, so an overview reachable only at the index has no way in. `AccountLayout` takes `isIndex` from the route now; inferring it from `section.path` rendered the menu at both URLs. Covered by a test — deleting the alias 404s that URL. - **Standing thresholds moved to `accountStandingFromPoints`** (`strike.schema.ts`). Two surfaces show standing and it derives from active *points*, not the strike count. - **The sticker-inventory pointer survives its host section's bail paths.** It is not gated on placement, so nesting it inside that section would drop it whenever the placement controls cannot render (flag off, or a failed spaces read). - **`BrowsingCategories` switched to chips outright**, including the legacy card, rather than growing a variant prop — one rendering, no fork. - **First use of a Tailwind `has-[…]` variant in this repo** (`SettingRow`, to keep switch rows inline at every width). Tailwind is 3.4.17, so it is supported. - **Billing empty states are `flat`-only.** `SubscriptionCard` and `UserPaymentConfigurationCard` both returned `null` with nothing to show, which left the whole pane blank. They now offer the plans / the Creator Program instead — but only in the flat panes, so the legacy page keeps hiding them and stays byte-identical. Both reuse the metric-visibility upsell, extracted as `UpsellPanel`. ## Verification Typecheck clean; no new lint warnings. Covering suites green: `account-sections` (17), `strike.service` + `process-strikes` (75), the four Account browser suites (24), and the notification suites (79). `SettingsCard.earlyAdopter.browser` needed one assertion updated — it pinned the literal early-adopter copy. Kept its intent (the opt-in must explain itself) and split it into the promise and the caveat rather than loosening it. Walked every pane at 1440px and 390px as a subscribed Creator Program account, and the billing/notification changes on a free account with no Creator Program. ## Not in this PR - `designs/user-account.pen` has uncommitted local changes that predate this work; left alone deliberately. ## Rollout `accountSettingsV2` → Flipt key `account-settings-v2`. `availability: ['mod']` is the STATIC FALLBACK only — it decides nothing while Flipt answers, so it matters solely during a Flipt outage, where mods get the new shell and everyone else keeps the legacy page. The Flipt rollout is the on-switch: `account-settings-v2` is `enabled: false` with no rollouts today, so the page is off for everyone until a segment or threshold rollout is merged in `flipt-state`. Instant rollback = drop that rollout / set the threshold to 0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ
11 KiB
OAuth-first-party migration — post-deploy checklist
Scope: what to verify + do right after the auth-hub / OAuth-first-party cutover goes live. This is the
verification + watch + cleanup list — the pre-deploy env/infra setup lives in
auth-hub-launch-checklist.md, the deferred security hardening in the
private infra repo, and the NEXT_PUBLIC_BASE_URL
cleanup in post-deploy-domain-env-consolidation.md.
Legend: 🛠️ devops/config · 🧪 smoke test · 👁️ monitor · 🧹 cleanup · ⏭️ deferred follow-up.
Phase 1 — Config sanity (first 15 min, before announcing)
- 🛠️
NEXTAUTH_SECRETis IDENTICAL on the hub and the main app. A mismatch silently breaks ALL token auth (validation just returns 401, no error). Confirm by comparing a fingerprint (SHA256(secret)[:8]) on both. - 🛠️
AUTH_JWT_ISSUER/AUTH_JWKS_URIpoint at the hub (https://auth.civitai.com) on the main app + every spoke, and the hub can actually be reached from each app's server context (the spoke does a server-side JWKS + identity fetch). A wrong/unreachable value degrades sessions to anonymous (fails open), not a 500. - 🛠️
AUTH_ADMIN_USER_IDSis set on the hub (comma-separated, e.g.1,5). It's fail-closed — unset =/admin(the TrustedSpokeDomain editor) is locked for everyone. - 🛠️
AUTH_INTERNAL_TOKENis IDENTICAL on the hub and the main app. Besides cache-invalidation it now also gates the new legacy-exchange upgrade-on-read endpoint (POST /api/auth/oauth/legacy-exchange). Missing/mismatched is not a hard break — the main app silently skips the upgrade and legacy users keep working via the read-only legacy decode — but legacy cookies then won't actively migrate to civ-token. - 🛠️
AUTH_JWT_AUDIENCEis UNSET everywhere (esp. on consumers likeadvertising.civitai.com). The hub emits noaud; if a consumer sets it, jose rejects every hub token. - 🛠️
AUTH_COOKIE_DOMAINis correct per env (prod hub.civitai.com; a single-color/preview env its own parent). The hub default self-derives from its own host, so acivitaic.comstaging hub works without override. - 🛠️ Dead env vars removed from deploy secrets/manifests:
AUTH_SPOKE_ORIGINS,AUTH_SWAP_MAX_AGE(and any lingeringAUTH_JWT_AUDIENCE,AUTH_SESSION_COOKIE). Harmless to leave (ignored), but clean them.
Phase 1b — Database
- 🛠️
TrustedSpokeDomaintable created + seeded — already live in prod (verified 2026-06-23): rowscivitai.com/civitai.red(exact),civitaic.com(wildcard),localhost,test-auth.civitai.{com,red}. Migrations are applied manually (noprisma migrate deploy). If deploying to a fresh env, apply20260622180000_add_trusted_spoke_domain(CREATE TABLE is idempotent) + seed the env's login hosts. - 🛠️ When enabling a new login host, add one row to
TrustedSpokeDomain(via the hub/adminUI). KeepincludeSubdomainstocivitaic.comonly (review M4).
Phase 2 — First-party login smoke tests (per color/domain)
- 🧪 Login on each served host —
civitai.com,civitai.red, and any test alias (test-auth.*). Confirm you land back on the spoke authenticated (not bounced to the hub root). - 🧪 The
civ-tokencookie actually STICKS on each host (open devtools → it's present +__Secure-in prod). A cookie that doesn't stick = the cross-domain misconfig the suffix-guard + loop-breaker guard against. If you hit the terminal "We couldn't sign you in" page, the cookie'sDomain/Secureis wrong for that host — checkcookieDomainForHost/AUTH_COOKIE_DOMAIN. - 🧪 Add-account / account switch (the device-set flow).
- 🧪 Cross-site shared device set — log in on
civitai.com, then opencivitai.red(it auto-SSOs via the bridge). Confirm BOTH hosts carry the sameciv-devicevalue (devtools → Application → Cookies) and the account switcher shows the identical account set on each. A.redciv-tokenpresent but a missing or differentciv-devicemeans the bridge isn't propagating the hub's shared device id — the authorize→callback→/sessionpath stashes it at/authorizeand returns it from/session(setSessionCookie(..., { deviceCookie })). Verify the reverse too (sign in first on.red). - 🧪 Moderator impersonate → then EXIT impersonation (the browser-client exit path — recently fixed to
POST /api/auth/impersonate/exit). - 🧪 Connected accounts (
/user/account/security, or/user/accounton the legacy page): link + unlink each provider (Discord/Google/GitHub/Reddit) — routes through the hub's?link=trueflow. - 🧪 Discord Linked-Roles (
/discord/link-role): connect, then confirm roles actually sync. - 🧪 Same-site spokes (
moderator.civitai.com,advertising.civitai.com): they read the shared.civitai.comcookie directly. Confirm they see the session after a hub login.
Phase 3 — Third-party OAuth
- 🧪 Existing access tokens still work — call
/api/v1/mewith a known production token (hash is backward-compatible, so this should just work as long asNEXTAUTH_SECRETwasn't rotated). - 🧪 Legacy endpoint forwarding —
curl -i https://civitai.com/api/auth/oauth/token(and/authorize,/userinfo,/revoke,/device) 308-redirects to the hub. - 🧪 Legacy OIDC discovery + JWKS forward —
curl -i https://civitai.com/.well-known/openid-configurationandhttps://civitai.com/api/auth/jwks308 to the hub. Confirm the edge maps the public root/.well-known/openid-configuration→ the/api/.well-known/...route (it's not innext.config; it's an edge rule, same as pre-migration). If it 404s, the edge mapping needs restoring. - 🧪 Full third-party flow on the hub — authorize (consent shown) → token →
/api/v1with the Bearer. - ⏭️ Notify any legacy OIDC RP pinned to the
civitai.comissuer to switch toauth.civitai.com(forwarding fixes transport, not theissidentity — see the dev-docs note). Likely a handful at most; handle reactively if a login-broke ticket appears.
Phase 4 — Monitor (first 24–48h)
- 👁️ 401 spike on
/api/v1→ would indicate a token-hash/secret mismatch (H2). Should be flat. - 👁️ Redirect loops / the loop-breaker terminal page (the
civ_postloginmarker path) → a cross-domain cookie misconfig on some host. - 👁️
invalid_clienton/session→ a first-party login host missing fromTrustedSpokeDomain. - 👁️ Hub-unreachable from spokes (JWKS/identity fetch failures) → sessions silently dropping to anonymous.
- 👁️
fetch failed500s on search endpoints (/api/v1/images,/api/v1/models) → this is the search backend (SEARCH_HOST/FEED_IMAGE_HOST), not auth — verify search reachability per env. - 👁️ Legacy-cookie migration is happening → the count of requests still authenticating via the legacy
cookie should TREND DOWN as upgrade-on-read swaps each one for a civ-token on first page load. A flat
legacy count = the exchange is failing (check
AUTH_INTERNAL_TOKENparity + hub reachability); legacy auth still works regardless (read-only decode), it just isn't migrating.
Phase 5 — Cleanup
- 🧹 Delete the junk/probe OAuth clients with apex-Civitai redirect URIs (
"1","df","tttt","Civitai","IDM"— see the OauthClient review). Eyeball in Retool first. - 🧹 Sunset the legacy forwarders (
/api/auth/oauth/[...path], the discovery/JWKS forwarders) once legacy clients have re-pointed at the hub. - 🧹 Drop the whole legacy-cookie path together once old cookies have aged out (≥30d past cutover, when
the legacy-auth count from Phase 4 hits ~zero): the hub
legacy-exchangeroute +legacy-cookie.tsdecode, the main app'sgetLegacySession/maybeUpgradeLegacySession, and theclearLegacy*cookie helpers. - 🧹 Deleted the dead
civ-tokenAES endpoint (src/server/auth/civ-token.ts+src/pages/api/auth/civ-token.ts) — caller-less legacy swap helper. KeptcivToken.schema.ts(EncryptedDataSchemais still type-referenced byAccountProvider.tsx).NEXTAUTH_SECRETstays — it's the active token-hash salt, not legacy-only (see H2).
Auth-authority consolidation (post-release architectural follow-ups)
From the main-app audit — the same class as the dropped getSessionUser (auth-authority work the hub should
own, currently in the main app). All work today (shared DB), so these are relocations for ownership
clarity, not correctness fixes. Each mixes hub-authority writes with main-app side-effects (orchestrator cache
busts, analytics), so the pattern is "move the write behind a hub endpoint; keep/emit the side-effect."
- ⏭️ OAuth client management → hub.
src/server/routers/oauth-client.router.tscreate/update/rotateSecret/deletewrite the sharedOauthClienttable (generate + hash client secrets, cascade-revoke tokens) — and the hub has no client-management endpoints. The hub is the OAuth provider; its client registry is provider authority. Reads (getAll/getById) +OAuthAppsCardUI stay. - ⏭️ OAuth consent lifecycle → hub.
oauth-consent.router.tsrevokeApp(deletes a user's tokens +OauthConsentrow) is grant-revocation authority.setBuzzLimitwrites the hub-ownedOauthConsentrow — a buzz concern living on hub state (layering smell either way). - ⏭️
VerificationTokencleanup → hub.src/server/jobs/next-auth-cleanup.tscron deletes expiredverificationTokenrows — a table the hub now owns (it creates them for email-login). Move the sweep to the hub (or delete if the hub adds its own). Minor: itsdeleteManyis also notawaited.
Deferred hardening (post-deploy, not blockers — tracked in the private infra repo)
- ⏭️ H1 belt-and-suspenders: reject
redirect_uri/allowedOriginunder an owned/trusted domain at client registration (the identity-gating fix already shipped closes the hole; this prevents the junk-client pattern at the source). - ⏭️ H2: split the token-hash salt off
NEXTAUTH_SECRET(dedicatedAPI_KEY_HASH_SECRET) so the legacy secret can finally sunset; give Forgejo (dev-git-access) its own key. - ⏭️ M2: refresh-token reuse detection (family-cascade) for public PKCE clients.
- ⏭️ Orchestrator cache-bust on
/revoke(main addedinvalidateCivitaiUseron token revoke; the hub/revokehas no equivalent yet). - ⏭️ Discord Linked-Roles token persistence (Phase-2 gap above): persist the Discord
access_token/refresh_token/expires_aton link so role-metadata sync works.