Skip to content

feat: account settings page and centralize verification tokens #53

Description

@ethnjs

Background

Profile view (/profile/[id]) and profile edit (/profile/[id]/edit) were built this session. Sensitive account fields (email, phone, password, DOB) were intentionally left out of profile edit and deferred to a dedicated settings area.

ProfileHeader.tsx already ships a showEditButton prop rendering an edit icon linking to account settings — currently a dead link pending this work.

Sign-up's existing email verification flow uses a signed JWT ({ user_id, exp }) with no backing DB row. This was viable for a single always-immediate flow but doesn't extend cleanly to email changes, which need revocability, single-use enforcement, and carrying a new_email payload.

Motivation

  • Give users a single place to manage sensitive account fields, kept separate from the public-facing profile edit surface.
  • Email changes need to be verify-before-apply (not immediate), which requires server-side state a stateless JWT can't provide cleanly — specifically: revocation, single-use, and abuse/rate-limit visibility.
  • Rather than bolt on a one-off email_change_tokens table, centralize all verification-style tokens (signup, email change, and future password reset) into one polymorphic table and one set of helpers, migrating sign-up off JWT in the process.

Changes

Frontend

  • New routes: /settings/account and /settings/security (separate pages, not a single tabbed page).
  • Visual direction: left nav rail, section label left / control right rows, subtle dividers, no card borders.
  • /settings/account: first name, last name, email (verify-before-apply), phone number, date of birth. DOB remains exclusive to this page — never shown or editable elsewhere, including admin views.
  • /settings/security: password change (current + new, via checkPassword / validatePassword).
  • Both pages use FloatingSaveBar (existing component from profile edit) rather than save-on-blur or per-field buttons.
  • ProfileHeader.tsx edit button wired to /settings/account (currently hardcoded dead link).
  • Reuses existing pieces: ProfileCard, ProfileQuestion, Input/Button/Modal (size prop), useAuth(), and lib/auth.ts validators (validateEmail, validatePhone, validateDateOfBirth, formatPhone).
  • New authApi methods: requestEmailChange(newEmail), changePassword(currentPassword, newPassword).
  • authApi.sendEmailVerification() signature unchanged — repointed to new backend endpoint under the hood, no signup-flow frontend changes required.

Backend

New tableverification_tokens (replaces JWT-based signup verification and backs email-change; extensible to future password-reset):

verification_tokens
- id
- user_id
- token        (random opaque string, hashed at rest before storage)
- purpose      ('signup_verify' | 'email_change' | 'password_reset')
- new_email    (nullable — only set for 'email_change')
- expires_at
- used_at      (nullable)
- created_at

Shared helpers (single implementation, reused across all purposes):

  • create_verification_token(user_id, purpose, new_email=None) — generates + hashes token, inserts row, returns raw token (raw value only ever leaves the server in the outbound email link).
  • consume_verification_token(raw_token, expected_purpose) — hashes input, looks up row, validates expires_at/used_at, marks consumed, returns row or raises.
    New/updated endpoints, all under the existing /auth router:
Endpoint Purpose
POST /auth/verify-email/request Signup — replaces JWT mint with token-table row
GET /auth/verify-email/confirm?token= Signup — consumes token, sets email_verified=true
POST /auth/email/request-change Settings — validates new email unclaimed, creates email_change row, emails new address
GET /auth/email/confirm-change?token= Settings — consumes token, writes email = new_email, email_verified = true
POST /auth/password/change Settings, authenticated — verifies current password (checkPassword), then updates. No token table involved.
POST /auth/password/reset/request Logged-out — takes email, creates password_reset row, sends reset link. Always returns success regardless of whether the email exists, to avoid account enumeration.
POST /auth/password/reset/confirm Logged-out — takes token + new password, consumes token via shared helper, updates password

Email-change flow specifics:

  • email and email_verified are untouched until the new address is confirmed.
  • No pending state stored on the user row — new_email lives only on the token row until consumed.
  • UI shows a "verification sent to new@address" state on /settings/account until confirmed; current email remains displayed/active in the meantime.
    Stale-token guarding: when a new token is created for a given (user_id, purpose) pair, any prior unconsumed tokens of that same purpose for that user are invalidated (used_at set) as part of create_verification_token. This means requesting a second email change, or a second password reset, silently kills the earlier link — only the most recently issued link for a given purpose is ever valid.

Rate limiting: create_verification_token checks for existing non-expired, non-consumed tokens of the same (user_id, purpose) created within a short window (e.g. last 60s) and rejects with a "too many requests, try again shortly" error rather than issuing a new one. This caps resend spam on all three flows (signup, email change, password reset) through the one shared helper, rather than being implemented per-endpoint.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

  • Status
    Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions