Skip to content

Require email verification for new signups and email changes#130

Open
hendriebeats wants to merge 9 commits into
masterfrom
require-email-verification
Open

Require email verification for new signups and email changes#130
hendriebeats wants to merge 9 commits into
masterfrom
require-email-verification

Conversation

@hendriebeats

Copy link
Copy Markdown
Contributor

Summary

New users must verify their email before they can log in. Existing users are grandfathered as verified. Users who change their email on the profile page trigger a new verification flow — the old address stays active until the new one is confirmed.

Reviewer notes

DB schema

  • emailVerified added to user with DEFAULT TRUE so existing accounts aren't locked out. A TODO in the migration notes this default should eventually flip to FALSE; for now all inserts in application code explicitly pass False.
  • Separate user_email_verification table tracks tokens — same plaintext pattern as password reset and session tokens already in the codebase.

Token lifecycle

  • createVerificationToken deletes any existing unused token for that user before inserting a new one — one pending token per user, always fresh on resend.
  • verifyEmailToken updates both user.email and user.emailVerified atomically, then marks the token used; replaying a used/expired token is a no-op returning Nothing.
  • Rate limit is checked against sentAt of the current pending row (reset each time a token is created), so it correctly reflects when the last email was actually sent.

Login flow

  • getPasswordHash now returns (UserId, PasswordHash, Bool) — the Bool is emailVerified. Password comparison happens first; the verified check only runs after a correct password, so error responses don't distinguish "wrong password" from "unverified".
  • loginFormUnverified re-renders the form with unverified = True, which shows the resend button — the email is pre-filled so the HTMX POST can send it without a visible input.

Resend UI

  • The resend button is a <button type="button"> with HTMX attrs rather than a nested <form> (nested forms are invalid HTML and browsers silently discard the inner element).
  • Client-side countdown timer is cosmetic only — the server enforces the 5-minute window authoritatively.

Profile / email change

  • When email is unchanged, updateUser runs as before. When it changes, only the name is updated immediately via updateUserName; the email swap waits until the verification link is clicked. The old address receives an informational notice; the new address receives the verification link.

Signup

  • After successful signup, no session cookie is issued — user sees a "check your inbox" partial and must verify before logging in.
  • mRedirect is still read and passed through to the error-path form so the redirect is preserved across validation failures.

New signups will require email verification before login is allowed.
This lays the groundwork by adding the necessary schema and types.

- Add emailVerified boolean column to user table (DEFAULT TRUE so
  existing users are grandfathered in as verified)
- Add user_email_verification table to store one-time tokens with
  expiry, rate-limit sentAt timestamp, and the email being verified
- Add EmailVerificationToken type and generator to Types.hs
- Add UserEmailVerificationT beam table to Database.hs
- Set emailVerified = False when creating new users in Entity/User.hs
Implements the core backend functions needed to issue and validate
email verification tokens, and adds the two transactional emails
sent during verification flows.

- Add createVerificationToken: invalidates any pending token for the
  user and issues a new 24-hour token
- Add verifyEmailToken: validates token freshness and marks it used,
  then atomically updates user.email and sets emailVerified = true
- Add checkVerificationRateLimit: returns seconds remaining in the
  5-minute cooldown window so the API can enforce resend throttling
- Add updateVerificationSentAt: refreshes sentAt on resend so the
  cooldown resets
- Add Emails.EmailVerification: HTML email with verify button
- Add Emails.EmailChangeNotification: plain informational notice
  sent to the old address when an email change is requested
Wires up the two new public endpoints needed for the email
verification flow.

- GET /verify_email?token=...: validates the token, sets
  emailVerified = true and updates the user's email, then redirects
  to /login?verified=1. Shows an expired-link page if the token is
  invalid or already used.
- POST /resend_verification: looks up the unverified user by email,
  enforces the 5-minute resend cooldown (returns a countdown message
  on violation), then issues a fresh token and sends the verification
  email. Silently no-ops if the address has no pending verification
  to avoid leaking account existence.
- Add getUnverifiedUserId helper to Entity.User for the resend lookup
- Add verifyEmail.html (expired-link page) and
  signup/checkEmail.html (post-signup check-your-inbox partial)
New accounts were immediately logged in after signup, making it
impossible to enforce verified emails. Now signup sends a
verification email instead of creating a session.

- Replace session creation and HX-Redirect with a call to
  createVerificationToken and Emails.EmailVerification.mail
- Render the signup/checkEmail.html partial on success so the user
  sees "Check your inbox" instead of being redirected to /studies
- Add HasUrl env constraint to the signup handler
- Remove the welcome email (replaced by the verification email)
- Add a note to the signup form: "You'll receive a verification
  email after signing up"
Users who registered but haven't verified their email were
previously able to log in freely. This enforces the verification
requirement at login time.

- Extend getPasswordHash to return emailVerified alongside the hash
- In the login handler, check emailVerified after a correct password;
  redirect to studies only if verified, otherwise re-render the form
  with an unverified error state
- Add loginFormUnverified which passes unverified=true to the template
- Login form now shows "Please verify your email" error, a resend
  button, and a 5-minute client-side countdown timer that disables
  the button after clicking to prevent spam
- getLogin reads ?verified=1 and passes justVerified to login.html
- login.html shows a <p-notification> success banner when
  justVerified is true (set after clicking the verification link)
Previously, a user could silently update their email to any address
with no confirmation, risking typos and account takeover.

- In putProfile, compare the submitted email to the current one;
  if it differs, defer the email swap and trigger a verification flow
- Name changes still save immediately in both branches
- Add updateUserName to Entity.User for name-only DB updates
- On email change: send an informational notice to the old address
  and a verification link to the new address; the swap happens only
  when the link is clicked (via the existing verifyEmailToken path)
- Profile form shows a 6-second notification: "Verification email
  sent to <new address>. Your email will update once confirmed."
- `updateVerificationSentAt` was never called: every resend goes through
  `createVerificationToken`, which replaces the token and sets a fresh
  `sentAt` at insert time. Remove the dead function.
- Add a comment to `checkVerificationRateLimit` explaining that `Nothing`
  means both "no pending token" and "cooldown expired" — both allow a
  resend, so the caller treats them identically.
- Add a TODO comment in the migration noting that `DEFAULT TRUE` should
  be changed to `FALSE` in a follow-up migration once existing users are
  grandfathered in.
- Replace nested <form> inside login form with a <button type="button">
  carrying HTMX attrs directly; nested forms are invalid HTML and browsers
  silently discard the inner element, breaking the resend POST entirely
- Add id="resend-result" to the not-found response div so HTMX can swap
  it into the correct target (the other two branches already had it)
- Add id="email" to login form email input so <label for="email"> works
- Fix profile form: add id attrs to both inputs; correct email label's
  for="name" typo to for="email"
- Trim verbose checkVerificationRateLimit doc comment
@hendriebeats

Copy link
Copy Markdown
Contributor Author

Note: We'll have to update the DB migration. I set the value at "true" which would automatically verify everyone. Instead, it should migrate once & then be set to false (something like this)

@hendriebeats hendriebeats added Size: Large 401–1,000 lines: Higher risk; usually should be split unless it’s a mechanical refactor. Size: Medium 101–400 lines: Multi-file change, needs focused review but still manageable. and removed Size: Large 401–1,000 lines: Higher risk; usually should be split unless it’s a mechanical refactor. Size: Medium 101–400 lines: Multi-file change, needs focused review but still manageable. labels Feb 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Size: Large 401–1,000 lines: Higher risk; usually should be split unless it’s a mechanical refactor.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant