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

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions