Skip to content

feat(auth): email verification with magic-link auto-login#46

Open
Dandiggas wants to merge 15 commits intomainfrom
feature/email-verification-magic-link
Open

feat(auth): email verification with magic-link auto-login#46
Dandiggas wants to merge 15 commits intomainfrom
feature/email-verification-magic-link

Conversation

@Dandiggas
Copy link
Copy Markdown
Owner

Summary

Closes #16. Replaces the current register → redirect-to-login flow with a mandatory-verify + magic-link-login flow:

  1. User registers → account created unverified, email sent via SendGrid (console backend in dev).
  2. User sees a dedicated "check your email" screen with a resend affordance.
  3. User clicks the link on any device → /auth/verify/[key] page POSTs to a new verify-and-login endpoint that confirms the email and issues a DRF token in one round-trip.
  4. Frontend stores the token + userId and redirects to /dashboard. One click from inbox to practicing.

What's in this PR

Backend

  • New POST /api/v1/dj-rest-auth/registration/verify-and-login/ endpoint wrapping allauth's EmailConfirmation.confirm() + issuing a DRF Token in one response (same shape as /login/).
  • CustomAccountAdapter routes confirmation URLs to ${FRONTEND_URL}/auth/verify/<key>.
  • ACCOUNT_EMAIL_VERIFICATION = "mandatory" flips the gate so unverified accounts can't log in. ACCOUNT_EMAIL_SUBJECT_PREFIX = "[The Shed] " replaces Django's default [example.com] prefix.
  • SendGrid SMTP gated on SENDGRID_API_KEY. Console backend is the dev default — emails print to the web logs.
  • Startup check raises ImproperlyConfigured if FRONTEND_URL is missing when DEBUG=False.
  • Data migration 0002_grandfather_existing_users idempotently marks pre-existing EmailAddress rows verified=True, primary=True, creating rows for users who don't have one. Case-insensitive email matching, demotes stale primaries, and normalizes CustomUser.email to lowercase so dj-rest-auth's case-sensitive login filter matches the verified row. Forward-only (reversing would re-unverify everyone). Lands before the mandatory-verify gate so existing users aren't locked out.
  • Email templates (subject/text/HTML) overridden under templates/account/email/ — single CTA button, email-client-safe inline CSS.

Frontend

  • New /auth/check-email?email=... post-register screen with manual resend button.
  • New /auth/verify/[key] callback page with 6 render states: loading, success (auto-redirect), expired (resend form), invalid (resend form), already-verified (go-to-login link), error (retry). aria-live on all status regions.
  • RegisterPage now redirects to /auth/check-email?email=... on success.
  • LoginPage detects unverified-email error, shows inline banner, auto-fires resend once on first arrival (guarded with useRef), exposes a manual "Resend" button. Only fires the auto-resend when the typed username is email-shaped.

Test coverage

Layer Count Notes
Backend 78 tests 7 grandfather (idempotency, multi-primary, mixed-case, CustomUser normalization), 2 adapter, 7 settings + 3 startup-check, 8 verify (all 4 status codes including HMAC-expired path), + 53 pre-existing
Frontend 58 tests 4 check-email, 5 verify callback (every render state), 1 RegisterPage redirect, 3 LoginPage unverified, + 45 pre-existing
Typecheck npx tsc --noEmit clean

Plus full end-to-end integration smoke against the live backend (register → capture activate_url → POST verify-and-login → confirm 200 + token; re-click same key → 409 already_verified; login with verified user → token; dan grandfather check still passes).

Deploy checklist (Railway)

Before merging, set these env vars on the backend Railway service:

  • SENDGRID_API_KEY=<your-SendGrid-Mail-Send-key> — created via SendGrid Settings → API Keys → Restricted Access (Mail Send only).
  • DEFAULT_FROM_EMAIL=dandiggasmusic@gmail.com — the address verified via SendGrid Settings → Sender Authentication → Single Sender Verification.
  • FRONTEND_URL=https://<your-frontend>.railway.app — must be the public frontend URL, no trailing slash. The startup check will refuse to boot the container without this when DEBUG=False.

Frontend service (should already be set from the metronome PR):

  • NEXT_PUBLIC_API_URL=https://<your-backend>.railway.app/api/v1

Post-deploy smoke

  • Register a throwaway account on the live URL.
  • Confirm the email arrives from SendGrid (not console), From: dandiggasmusic@gmail.com, Subject: [The Shed] Verify your email and get into The Shed.
  • Click the link on a different device. Expect auto-login + /dashboard.
  • Try logging in with that user normally — should succeed (token-based login works post-verify).

Known follow-ups (not this PR)

  • Rate-limit /registration/resend-email/ and /verify-and-login/ #45 — Rate-limit /verify-and-login/ and /resend-email/. Not a launch blocker with unguessable HMAC keys + low volume, but should land before the app has real traffic.
  • Domain authentication on SendGrid (for better deliverability) once a custom domain is available. Current single-sender setup ships via SendGrid's shared IPs.
  • Post-verify onboarding UX on /dashboard for day-zero users (empty state polish).

🤖 Generated with Claude Code

Dandiggas and others added 15 commits April 20, 2026 22:25
One-off data migration that marks pre-existing users' EmailAddress
rows verified=True, primary=True. Creates rows for users who don't
already have one. Idempotent on re-run (update_or_create).

Forward-only: reversing would re-unverify everyone, which is the
opposite of the intent. The reverse_noop documents this explicitly.

Lands before the mandatory-verify gate (Task 3) so existing users
aren't locked out when the gate flips.

Refs #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review follow-up on b21295a. Two defensive additions before a
data migration hits a DB state we haven't seen:

1. Demote other primary=True rows on the same user before setting
   the target row primary. Without this, a user who already has a
   primary=True row on a different email trips allauth's partial
   unique constraint (user, primary=True) and aborts the migration
   inside its transaction. Happens with leftover admin edits or
   reverted-migration debris.

2. Normalize email to lowercase before lookup. Allauth's canonical
   form is lowercase; CustomUser.email can hold mixed-case. Without
   .lower(), update_or_create looks for "Dan@Example.com" and misses
   an existing "dan@example.com" row, then attempts a duplicate
   INSERT that trips the partial unique-verified-email index.

Two new tests cover both paths: demote-other-primaries and
normalize-case.

Refs #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Overrides allauth's default get_email_confirmation_url to point at
the Next.js /auth/verify/<key> page instead of Django's built-in
confirm URL. Enables the magic-link-on-verify auto-login flow
(Task 4 wires the backend endpoint the page calls).

FRONTEND_URL env var drives the link in prod; localhost:3000
fallback for local dev.

Refs #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ACCOUNT_EMAIL_VERIFICATION=mandatory flips the gate: unverified
  accounts can't log in.
- ACCOUNT_ADAPTER points at CustomAccountAdapter from Task 2 so
  confirm URLs go to the Next.js frontend.
- ACCOUNT_CONFIRM_EMAIL_ON_GET=False because we handle confirmation
  via the custom POST endpoint (coming in Task 4), not allauth's
  built-in GET-based confirm view.
- ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS=1 — 24-hour TTL per spec.
- ACCOUNT_LOGIN_METHODS replaces deprecated ACCOUNT_AUTHENTICATION_METHOD
  (allauth 65.x API).
- SendGrid SMTP activates only when SENDGRID_API_KEY is set; the
  console backend stays the default for local dev (emails print to
  the docker-compose web logs).
- TEMPLATES.DIRS includes BASE_DIR/templates so repo-root template
  overrides (Task 5) win over allauth's own defaults.
- New startup check raises ImproperlyConfigured if FRONTEND_URL is
  missing when DEBUG=False — prevents the "broken confirmation
  links in prod" failure mode flagged in spec §7.

The grandfather migration from Task 1 ensures existing users aren't
locked out when this gate flips.

Refs #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
POST /api/v1/dj-rest-auth/registration/verify-and-login/

Takes an email-confirmation key, calls allauth's
EmailConfirmation.confirm(), fetches-or-creates a DRF Token for the
confirmed user, and returns {key, user} — same shape as /login/ so
the frontend uses the same storage code.

Status codes:
- 200 confirmed + token issued
- 404 invalid / missing key
- 409 already verified (HMAC re-click)
- 410 key expired past ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS

Tries EmailConfirmationHMAC (allauth 65.x default, stateless) first,
falls back to DB-stored EmailConfirmation for compatibility.

HMAC already-verified detection: EmailConfirmationHMAC.from_key()
filters verified=False at the DB level, so we decode the PK via
django.core.signing directly to detect the 409 case.

DB expired detection: queries without all_valid() filter to reach
expired rows before returning 404.

Endpoint is AllowAny — the confirmation key itself is the credential.

Test fix: EmailConfirmation.objects.create() without key= leaves the
key as an empty string. The expired-key test now uses get_adapter()
.generate_emailconfirmation_key() to create a proper DB key.

Refs #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 4's test_expired_key_returns_410 only exercised the DB fallback
path, not the HMAC path that production traffic actually hits (allauth
65.x defaults to HMAC keys; the CustomAccountAdapter embeds them in
confirmation emails).

New test_expired_hmac_key_returns_410 patches time.time to jump 8
days forward so signing.loads() raises SignatureExpired, exercising
the view's HMAC-expired branch. Also asserts email stays unverified
on expiry (expired keys must not confirm silently).

Refs #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Subject, text, and HTML body for the email sent on registration.
HTML uses inline-style table layout (email-client-safe) with a
single CTA button linking to {{ activate_url }}, which the custom
adapter populates with the Next.js /auth/verify/<key> path.

The activate_url works from any device — click on phone, browser
opens, /auth/verify/[key] page POSTs to the backend, auto-login.

Template DIRS updated in Task 3's settings commit so repo-root
templates/ wins over allauth's defaults.

Refs #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SENDGRID_API_KEY, DEFAULT_FROM_EMAIL, FRONTEND_URL — all three
required in prod; SENDGRID_API_KEY optional in dev (console backend
fallback), DEFAULT_FROM_EMAIL has a reasonable default, FRONTEND_URL
falls back to http://localhost:3000 in dev but raises
ImproperlyConfigured in prod (Task 3's startup check).

Refs #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Simple screen showing the email the verification link was sent to
plus a manual "Resend link" button that POSTs to
/registration/resend-email/. Renders success/error states inline.

Uses Suspense boundary for useSearchParams (Next.js 15 requirement).

RegisterPage redirect will target this page in Task 9.

Refs #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six render states driven by the backend response:
- loading → spinner while the POST is in flight
- success → token+userId stored in localStorage, redirect to /dashboard
- 410 expired → inline resend form (email input + button)
- 404 invalid → same resend form with different headline
- 409 already-verified → "Go to login" link
- error (network/unknown) → "Try again" button re-fires the POST

useRef boolean guards the POST from firing twice under React strict-mode
double-invoke. aria-live regions on all dynamic status messages.

Works from any device — no stored credentials required.

Refs #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Instead of dropping the user at /login (where they'd see a generic
login form and wonder what just happened), register success now
sends them to /auth/check-email?email=... which shows the dedicated
"check your email" screen (Task 7) with resend affordance.

Refs #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When dj-rest-auth returns 400 {non_field_errors: ["E-mail is not
verified."]} (the standard mandatory-verify response), the login
page now:

1. Hides the generic error.
2. Shows an inline banner: "Your email isn't verified yet — we sent
   you a fresh link to <email>."
3. Auto-fires /registration/resend-email/ exactly once (guarded
   with useRef boolean across re-renders).
4. Exposes a manual "Resend" button for on-demand re-fires.

The resend address is inferred from the typed username value;
if the user logged in with a plain username the auto-resend may
be rejected server-side but fails silently, and the manual resend
button remains available. Users can also visit /auth/check-email
to resend directly.

aria-live="polite" on the banner for screen-reader announcement.

Closes #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review follow-up on 75aebaa. The original Task 10 implementer
dropped the spec's `includes("@")` guard on unverifiedEmail because
the tests used a non-email username ("unverified") — without the
guard, the banner rendered "We sent a fresh link to **unverified**"
literally and the auto-resend POSTed {email: "unverified"} which
the backend silently rejected.

Fix:
- Restore the spec's guard: unverifiedEmail is only set when the
  typed username contains "@". For non-email usernames, banner
  stays hidden and auto-resend skips — user can resend from
  /auth/check-email directly.
- Update LoginPage test's submitLogin helper to use
  "unverified@example.com" so the happy path is actually exercised
  in tests.
- Add an assertion on the auto-resend POST body to lock in that
  the correct email is sent (not just that the endpoint is hit).

Refs #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two final-review criticals before shipping #16:

1. ACCOUNT_EMAIL_SUBJECT_PREFIX = "[The Shed] " — without it, allauth
   builds the subject as "[{site.name}] ..." and site.name defaults
   to "example.com", meaning every launch email would ship as
   "Subject: [example.com] Verify your email ...". Highly embarrassing
   and would hurt deliverability.

2. Grandfather migration now also lowercases CustomUser.email, not
   just the EmailAddress row. Without this, a legacy user with
   mixed-case user.email (e.g. from createsuperuser or admin) gets a
   verified lowercase EmailAddress row, but dj-rest-auth's login
   serializer does case-sensitive filter(email=user.email, verified=True)
   and misses the row — permanent lockout. New test documents the
   expected post-migration state.

Refs #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy-pasting the activate_url out of a terminal log sometimes drags
a trailing newline or space into the URL bar (invisible in the
address bar, but present in the dynamic [key] segment). The backend
treats any whitespace-containing key as invalid → 404, so users see
"This link isn't valid" on a perfectly good link they just pasted.

One-line defensive fix: .trim() the key before it goes into the POST
body. No test change needed — unit tests already post trimmed keys
via mocks; this only changes behavior for real paste edge cases.

Refs #16.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Email verification on registration

1 participant