feat(auth): email verification with magic-link auto-login#46
Open
feat(auth): email verification with magic-link auto-login#46
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #16. Replaces the current register → redirect-to-login flow with a mandatory-verify + magic-link-login flow:
/auth/verify/[key]page POSTs to a newverify-and-loginendpoint that confirms the email and issues a DRF token in one round-trip./dashboard. One click from inbox to practicing.What's in this PR
Backend
POST /api/v1/dj-rest-auth/registration/verify-and-login/endpoint wrapping allauth'sEmailConfirmation.confirm()+ issuing a DRFTokenin one response (same shape as/login/).CustomAccountAdapterroutes 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_API_KEY. Console backend is the dev default — emails print to the web logs.ImproperlyConfiguredifFRONTEND_URLis missing whenDEBUG=False.0002_grandfather_existing_usersidempotently marks pre-existingEmailAddressrowsverified=True, primary=True, creating rows for users who don't have one. Case-insensitive email matching, demotes stale primaries, and normalizesCustomUser.emailto 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.templates/account/email/— single CTA button, email-client-safe inline CSS.Frontend
/auth/check-email?email=...post-register screen with manual resend button./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-liveon all status regions.RegisterPagenow redirects to/auth/check-email?email=...on success.LoginPagedetects unverified-email error, shows inline banner, auto-fires resend once on first arrival (guarded withuseRef), exposes a manual "Resend" button. Only fires the auto-resend when the typed username is email-shaped.Test coverage
npx tsc --noEmitcleanPlus 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;dangrandfather 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 whenDEBUG=False.Frontend service (should already be set from the metronome PR):
NEXT_PUBLIC_API_URL=https://<your-backend>.railway.app/api/v1Post-deploy smoke
From: dandiggasmusic@gmail.com,Subject: [The Shed] Verify your email and get into The Shed./dashboard.Known follow-ups (not this PR)
/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./dashboardfor day-zero users (empty state polish).🤖 Generated with Claude Code