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 table — verification_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.
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.tsxalready ships ashowEditButtonprop 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 anew_emailpayload.Motivation
email_change_tokenstable, 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
/settings/accountand/settings/security(separate pages, not a single tabbed page)./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, viacheckPassword/validatePassword).FloatingSaveBar(existing component from profile edit) rather than save-on-blur or per-field buttons.ProfileHeader.tsxedit button wired to/settings/account(currently hardcoded dead link).ProfileCard,ProfileQuestion,Input/Button/Modal(sizeprop),useAuth(), andlib/auth.tsvalidators (validateEmail,validatePhone,validateDateOfBirth,formatPhone).authApimethods: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 table —
verification_tokens(replaces JWT-based signup verification and backs email-change; extensible to future password-reset):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, validatesexpires_at/used_at, marks consumed, returns row or raises.New/updated endpoints, all under the existing
/authrouter:POST /auth/verify-email/requestGET /auth/verify-email/confirm?token=email_verified=truePOST /auth/email/request-changeemail_changerow, emails new addressGET /auth/email/confirm-change?token=email = new_email,email_verified = truePOST /auth/password/changecheckPassword), then updates. No token table involved.POST /auth/password/reset/requestpassword_resetrow, sends reset link. Always returns success regardless of whether the email exists, to avoid account enumeration.POST /auth/password/reset/confirmEmail-change flow specifics:
emailandemail_verifiedare untouched until the new address is confirmed.new_emaillives only on the token row until consumed./settings/accountuntil 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_atset) as part ofcreate_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_tokenchecks 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.