Skip to content

feat: account settings, session-based auth, and onboarding extraction - #54

Merged
ethnjs merged 55 commits into
mainfrom
feat/account-settings
Jul 30, 2026
Merged

feat: account settings, session-based auth, and onboarding extraction#54
ethnjs merged 55 commits into
mainfrom
feat/account-settings

Conversation

@ethnjs

@ethnjs ethnjs commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Summary

Adds a full account settings surface (/settings/account, /settings/security) that was previously just a dead link, replaces stateless JWT auth with a revocable, DB-backed session system, and extracts name/phone/DOB collection out of sign-up into a shared /onboarding step used by both sign-up and admin-invited account setup.

This is a large PR spanning backend and frontend across several work sessions — flagging that up front in case it makes sense to review in chunks by section below rather than as one pass.

Why

  • The profile edit page intentionally excluded sensitive fields (email, password, phone, DOB) and pushed them to a settings page that didn't exist yet — this closes that gap.
  • The existing JWT auth had no way to revoke access before natural token expiry (up to 7 days). That meant no real "log out everywhere," and no way for an admin to actually cut off a compromised account's active session — locking the account wouldn't touch a session already in progress.
  • Real security gaps got found and fixed along the way — notably, the "your email was changed" notice had a self-service recovery link that silently failed for the one person it mattered most for (see the email-change revert item below).

What changed

Backend

  • New tables: verification_tokens (centralizes signup-verify, email-change, email-change-revert, password-reset, and account-setup tokens under one polymorphic table/purpose column, replacing a standalone JWT-based signup-verification scheme) and sessions (SHA-256-hashed opaque tokens, fixed 7-day expiry, IP + user-agent tracked for the device list).
  • Migration reverts first_name/last_name to nullable. Register and account-setup are now credentials-only (see below), so a user can exist with no name until they complete /onboarding. This undoes a NOT NULL constraint added in an earlier migration on main.
  • Auth is no longer JWT-basedaccess_token cookie now holds a random session token, checked against the sessions table on every request. python-jose usage removed from core/auth.py entirely.
  • status field (active / invited / deactivated / locked) replaces the old is_active boolean, which conflated three different situations (normal, pending-invite, and any other reason for being inactive) into one flag.
  • New/changed endpoints: email change (request/confirm/revert), password change (authenticated) and reset (request/confirm), account setup (confirm/resend), session listing + "log out everywhere else," self-deactivate/delete, pending-email-change status.
  • register and account-setup-confirm are now credentials-only — name/phone moved to onboarding (see below).
  • Session revocation wired into every credential-changing path: password reset revokes everything, authenticated password change revokes all other sessions (keeps the current one), admin-locking revokes everything, email-change-revert revokes everything.
  • Email-change revert: the "Secure your account" link in the email-changed notice used to point at /forgot-password, which looks up by current email — meaningless once an attacker has already changed it. Now a dedicated token-based revert flow that works whether the change was confirmed or still pending, and forces a new password + full session revocation either way.
  • PATCH /users/me/ previously accepted an email field, bypassing the verify-before-apply flow entirely (not exploitable through the shipped UI, but the API itself didn't enforce the boundary). Removed from the schema.
  • Password-reset and password-change now both revoke sessions on success — previously the notice email implied the account was secured without that actually being true.
  • 6 branded transactional emails, styled to match the app's actual design tokens (Georgia/Geist/Geist Mono, real color palette) rather than generic placeholder styling.

Frontend

  • /settings/account — name/phone/DOB (floating save bar), email as its own verify-before-apply flow with a persistent (server-backed, not local-state) pending-change banner + modal.
  • /settings/security — password change, session/device list, "log out everywhere else."
  • /onboarding — extracted sign-up's old phase-2 question flow, plus new required (non-skippable) name/phone/DOB steps. A redirect guard sends any authenticated user with is_onboarding_complete=false here on every page load, not just right after signup.
  • Sign-up phase 1 shrunk to email + password only; /account-setup shares that same shrunk component, password-only (no email field — the invite link never carries the email in the URL, and there's no route to look an address up from just the token, so there's nothing to render or pre-fill without adding an API call I don't want to build for this).
  • New public pages: /forgot-password, /reset-password, /confirm-email-change, /revert-email-change.
  • Settings visual system (SettingsSection, SettingsPageHeading, fixed-position SettingsNav) and a shared (auth) layout shell for every pre-login/token-based page.
  • SettingsNav collapses into a hamburger-triggered drawer below 640px — the fixed-width sidebar was squeezing account/security form fields into unusable slivers on a phone-sized viewport. Desktop layout is unchanged; scoped only to /settings since the rest of the app isn't mobile-optimized yet.
  • Deactivate/delete UI (password + typed-confirmation before either action fires).

Testing

Automated backend suite plus a manual QA pass across every new page and flow; email sending is mocked at the lowest-level send function throughout so none of this hits real Resend.

Automated (backend):

  • Login/logout/me, register/admin-register (incl. full password-strength rules and invited-status gating), email verification, admin user management (list/get/update/delete, role + status changes), self-service deactivate/delete (password required, session revoked, membership rows cascade for a plain member — see Notes for the tournament-owner exception), sessions (device list, second-login creates a separate session, "log out others" keeps current), profile fields via /users/me/ (email silently ignored, null rejected on required fields, phone normalization, completeness logic).

Manual QA, weighted toward the security-relevant paths:

  • Sign-up / onboarding — phase-1 form behavior; name/phone/DOB steps are non-skippable while the rest still are; redirect guard fires on direct navigation and survives reload, doesn't loop for an already-onboarded user, and never fires for a logged-out visitor.
  • Login/logout/locking — locked and deactivated users blocked from login; logout revokes the session server-side, not just the cookie; admin lock revokes sessions immediately, not on next request.
  • Account-setup — token flow end-to-end, resend + stale-token invalidation. (No email field is shown on this page at all — the invite link doesn't carry the email and there's no lookup-by-token route, so there's nothing to lock or pre-fill; see What Changed.)
  • Settings/account — email-change request through confirm, with the pending banner being server-backed (survives reload); old-address security notice sent at request time, not confirmation time; new email never shown as active pre-confirmation.
  • Settings/security — other sessions revoked on an authenticated password change while the current one survives; session/device list accuracy; "log out everywhere else" behavior.
  • Forgot/reset password — enumeration-safe generic response (same status/timing/body for real vs. unknown emails); full session revocation on reset (vs. the current-session exception on an authenticated change); invited-status accounts excluded.
  • Email-change revert — both branches tested separately: pre-confirmation (cancels the pending change, dead token afterward) and post-confirmation (reverts the live email). Both end in a new password, full session revocation, and a login check with the restored/original email.
  • Deactivate/delete — password + typed-confirmation gating, session revocation on deactivate, hard delete confirmed at the DB level for a non-owner account.
  • Cross-cutting — existing profile pages unaffected, nav/link wiring, all 6 email templates spot-checked in a real client (Gmail, Apple Mail), console-error pass. Mobile viewport spot-check found the settings sidebar unusable below 640px (fixed-width, non-responsive); fixed with a mobile drawer, re-verified on both the account and security pages.

Notes

  • Self-delete crashes for tournament owners. Tournament.owner_id is NOT NULL with no ondelete rule, so a TD who owns a tournament and hits Delete Account on /settings/account will get a DB IntegrityError (500) instead of a clean result. Self-delete works for everyone else (verified in tests via a plain member). Needs a decision before it's fully safe to ship: block deletion with a 409 while the user owns tournaments, or cascade-delete owned tournaments along with the user. Not fixed in this PR.

ethnjs added 30 commits July 27, 2026 06:32
…for email verification, email change, and password resets
…he verification token helpers in core/auth alr do the same job
…ion, email change, password reset, and account setup
…t-setup

- login, register, and confirm_account_setup now call create_session() and set the raw session token as the cookie, replacing the deleted create_access_token JWT helper
- logout now revokes the session server-side instead of only clearing the cookie, so a leaked cookie can't be replayed after logout
- chose to read the cookie and call get_active_session directly in the logout route rather than adding a get_current_session_optional dependency, since tolerating a missing/invalid cookie is only needed there
- new migration adds status (active/invited/deactivated/locked), backfills from is_active + hashed_password, and drops is_active in the same migration
- migration refuses to guess (raises) if it finds is_active=false with a password set, since no current code path produces that combination
- updated every is_active call site: login, get_current_user, create_user, admin_register, confirm_account_setup, request_password_reset, resend_account_setup
- AdminUserUpdate and UserSlimResponse schemas now expose status instead of is_active
- test fixtures/assertions updated; inactive_user fixture (has password, was is_active=False) mapped to status='deactivated' as a placeholder pending Step 3's deactivate/lock design
- PATCH /admin/users/{id} now calls revoke_all_sessions when status is set to 'locked', so locking cuts off already-logged-in devices immediately instead of only blocking future logins
- reused the existing generic admin-update endpoint rather than adding a dedicated /lock route, since status was already a field on it
- admin recovery feature dropped per discussion (low-likelihood scenario, no clean way to verify identity once email access is lost)
- added test_locking_revokes_existing_session covering the session-invalidation behavior
ethnjs added 21 commits July 28, 2026 12:58
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
nexus Ready Ready Preview Jul 30, 2026 3:07pm

@railway-app

railway-app Bot commented Jul 29, 2026

Copy link
Copy Markdown

🚅 Deployed to the nexus-pr-54 environment in nexus

Service Status Web Updated (UTC)
nexus ✅ Success (View Logs) Jul 30, 2026 at 3:09 pm

@railway-app
railway-app Bot temporarily deployed to nexus / nexus-pr-54 July 29, 2026 19:35 Destroyed
@ethnjs ethnjs linked an issue Jul 29, 2026 that may be closed by this pull request
@railway-app
railway-app Bot temporarily deployed to nexus / nexus-pr-54 July 30, 2026 15:06 Destroyed
@ethnjs
ethnjs merged commit 481479f into main Jul 30, 2026
3 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: account settings page and centralize verification tokens

1 participant