You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add email verification flow so users confirm ownership of their email address after registration.
Background
Users can register and log in without verifying their email. Verification is a nudge, not a blocker — unverified users can access the app but see a banner prompting them to verify.
Tasks
Backend
email_verified column — add Boolean, nullable=False, default=False to User; migration; keep separate from is_active (which is used for admin-activation flow)
app/core/email_verification.py — two helpers:
generate_verification_token(user_id: int) -> str — signed JWT with user_id, exp (24h), purpose: "email_verification"
verify_verification_token(token: str) -> int | None — returns user_id if valid, None if expired/invalid/wrong purpose
app/services/email_service.py — thin async wrapper around Resend API (resend[async], send_async); send_verification_email(to: str, token: str); from address: noreply@nexus.ethanshih.com; no reply_to header
Hook /auth/register/ — send verification email after user created; fire-and-forget (wrap in try/except, log error, don't fail registration)
GET /auth/verify-email/?token= — public; validates token, sets email_verified=True; idempotent; returns 400 with clear message on invalid/expired
POST /auth/resend-verification/ — authenticated; returns 400 if already verified; # TODO(temp): add rate limiting
RESEND_API_KEY — add to Settings in app/core/config.py, .env, and Render env vars
FRONTEND_URL — confirm exists in Settings or add; used to build verify link in email body
Frontend
/verify-email page — reads token from query params on mount; calls authApi.verifyEmail(token); shows loading → success or error state; success: button to dashboard; error: "link expired or invalid" message
"Check your email" state in sign-up — shown after step 1 before step 2; "We sent a verification email to {email}. You can verify later — let's finish your profile."; auto-advance or "Continue" button
authApi.verifyEmail(token) — GET /auth/verify-email/?token=
authApi.resendVerification() — POST /auth/resend-verification/
Notes
Sending domain noreply@nexus.ethanshih.com verified in Resend; SPF/DKIM DNS records set in Vercel; no MX records (no inbound)
Token uses purpose claim so login tokens cannot be reused as verification tokens
Add email verification flow so users confirm ownership of their email address after registration.
Background
Users can register and log in without verifying their email. Verification is a nudge, not a blocker — unverified users can access the app but see a banner prompting them to verify.
Tasks
Backend
email_verifiedcolumn — addBoolean, nullable=False, default=FalsetoUser; migration; keep separate fromis_active(which is used for admin-activation flow)app/core/email_verification.py— two helpers:generate_verification_token(user_id: int) -> str— signed JWT withuser_id,exp(24h),purpose: "email_verification"verify_verification_token(token: str) -> int | None— returnsuser_idif valid,Noneif expired/invalid/wrong purposeapp/services/email_service.py— thin async wrapper around Resend API (resend[async],send_async);send_verification_email(to: str, token: str); from address:noreply@nexus.ethanshih.com; noreply_toheader/auth/register/— send verification email after user created; fire-and-forget (wrap in try/except, log error, don't fail registration)GET /auth/verify-email/?token=— public; validates token, setsemail_verified=True; idempotent; returns 400 with clear message on invalid/expiredPOST /auth/resend-verification/— authenticated; returns 400 if already verified;# TODO(temp): add rate limitingRESEND_API_KEY— add toSettingsinapp/core/config.py,.env, and Render env varsFRONTEND_URL— confirm exists inSettingsor add; used to build verify link in email bodyFrontend
/verify-emailpage — readstokenfrom query params on mount; callsauthApi.verifyEmail(token); shows loading → success or error state; success: button to dashboard; error: "link expired or invalid" messageauthApi.verifyEmail(token)—GET /auth/verify-email/?token=authApi.resendVerification()—POST /auth/resend-verification/Notes
noreply@nexus.ethanshih.comverified in Resend; SPF/DKIM DNS records set in Vercel; no MX records (no inbound)purposeclaim so login tokens cannot be reused as verification tokens