Skip to content

Require email confirmation on signup#188

Merged
emsqrd merged 26 commits into
mainfrom
email-confirmation-signup-issue-164
May 15, 2026
Merged

Require email confirmation on signup#188
emsqrd merged 26 commits into
mainfrom
email-confirmation-signup-issue-164

Conversation

@emsqrd
Copy link
Copy Markdown
Owner

@emsqrd emsqrd commented May 15, 2026

Closes #164.

Summary

Gates signup on email confirmation. Supabase returns session: null until the user confirms via either a magic link or a typed 6-digit OTP, and the UI handles every branch of that flow.

  • <CheckEmailNotice> pending UI with both magic-link and typed-OTP completion paths (new <OtpInput> primitive).
  • Resend with rate-limit + generic-failure UX (Sentry capture only on the unknown-failure branch).
  • Failed confirmation links (otp_expired etc.) route to /sign-up with an inline error above the form — driven by route context, no AuthContext state churn.
  • emailRedirectTo carries the post-confirm destination, so /sign-up?redirect=/join/<token> survives cross-browser confirmation.
  • indexRoute.beforeLoad bounces authed users from / to /leagues or /create-team based on team state — single home for post-auth routing.
  • Implicit flow (Supabase defaults) — PKCE was attempted first but its resend path doesn't write code_challenge, breaking resend; implicit sidesteps the asymmetry.

Plan: docs/plans/164-email-confirmation-signup.md.

Out of scope

Test plan

  • npm run test:all green
  • npm run web:lint + npm run web:format:check green
  • npm run e2e green (covers magic-link, OTP, cross-browser invite preservation, resend)
  • Manual: expired-link path routes to /sign-up with the inline error
  • Manual: authed user visiting / bounces to /leagues or /create-team

Production deployment checklist (post-merge, manual)

Local config.toml flips don't propagate to the hosted Supabase project — these have to be applied in the dashboard before this ships to users. See docs/plans/164-email-confirmation-signup.md → "Production deployment notes" for full context. Order matters: SMTP (step 3) must be configured before the rate-limit raise (step 5) is selectable.

  • Enable email confirmations under Authentication → Providers → Email (toggle "Confirm email").
  • Tighten the Redirect URLs allowlist under Authentication → URL Configuration. Dev uses http://localhost:5173/** wildcards; production should list specific surfaces: https://<prod-domain>/, https://<prod-domain>/join/**, plus any other paths users land on post-confirm. (** matches any sequence including path separators; * matches non-separator only.)
  • Configure custom SMTP under Authentication → SMTP Settings. Supabase's built-in email service is documented as not for production (30 messages/hour, deliverable only to project team members).
  • Paste the custom confirmation template (api/supabase/templates/confirmation.html) into Authentication → Email Templates → Confirm signup. The hosted project reads templates from the dashboard, not from api/supabase/config.toml.
  • Raise the email send rate limit under Authentication → Rate Limits (the "Rate limit for sending emails" field — same control as the local email_sent setting, which is 2/hour for dev). Note: this field only unlocks after custom SMTP is configured above.

emsqrd added 24 commits May 9, 2026 14:09
Plans a 3-commit sequence to require email confirmation on signup:
foundation (PKCE flow, callback route, OTP verification), resend
support, and friendly handling of unconfirmed sign-in attempts.
- Re-sequence Commit 1 into four self-contained commits: PKCE+AuthCallback,
  CheckEmailNotice vendoring, config flip + SignUpForm wiring, e2e.
- Replace Inbucket REST paths with verified Mailpit endpoints
  (search?query=to:..., /message/{ID}, DELETE /messages) and embed real
  response shapes captured from the running container.
- Note Supabase kept the [inbucket] config block and container name for
  back-compat even though the image is now supabase/mailpit.
Supabase swapped the local email-testing binary to Mailpit while leaving
the [inbucket] config block name and supabase_inbucket_* container name in
place for back-compat. The runtime, REST API, and UI are 100% Mailpit, so
the README port table now labels the column accordingly. Convert the
CLAUDE.md pointer into a proper markdown link to the README section.
Switches the Supabase client to `flowType: 'pkce'` so confirmation emails
return `?code=` instead of `#access_token=`, and adds a new
`/auth/callback` route that exchanges the code for a session via a route
loader. On success the loader redirects to the `redirect` search param
(else `/create-team`); on failure it captures to Sentry and rethrows so
the inline `errorComponent` renders the back-to-sign-in affordance.

Safe to land while `enable_confirmations` remains `false` — the route
exists but nothing emits a magic link yet, and PKCE is fully compatible
with the existing implicit-flow happy path.

Adds `getPostSignupDestination` helper, integration coverage over loader
branches (default destination, redirect param, missing code, supabase
error, supabase rejection), and unit coverage for the helper.

Refs #164
Updates the #164 plan to match what actually landed in commit 1: the
auth-callback work uses a route loader and inline `errorComponent`
rather than a dedicated `AuthCallback` component with `useEffect`.

- New Decisions row documents the loader+inline-errorComponent choice
  (idiomatic for this codebase, avoids `useEffect` + `cancelled`-flag
  anti-pattern, `ErrorFallback` doesn't fit).
- New Decisions row records the manual Sentry-capture pattern in the
  loader and points at #180 for the broader observability gap.
- Commit 1 section rewritten: drops the AuthCallback component bullets,
  describes the loader/pendingComponent/errorComponent wiring directly,
  and replaces unit-test cases with integration-test coverage.
- Critical files updated to remove `AuthCallback/` and add the new
  integration test.
- Removes the redundant cross-issue ordering paragraph from Context
  (already captured in the Decisions table).
- Adds the loader-error observability gap to "Known preexisting
  concerns" with a pointer to #180.

Refs #164
Replaces the shadcn input-otp vendoring with a custom OtpInput at
components/OtpInput/, fixing the slot-retargeting bug from shadcn/ui
#4046 and dropping the input-otp dependency. Updates props/styling/a11y
specs, restricts the test list to behavioral coverage per web/CLAUDE.md,
and spells out the consumer-side and dev-artifact cleanups (REGEXP_ONLY_DIGITS,
elementFromPoint polyfill, /dev/check-email scratch route).
Hand-rolled numeric OTP at components/OtpInput/ (single-input + overlay
slot row via CSS Grid; fixes the shadcn/ui #4046 slot-retargeting bug
where clicking a non-final slot focuses the last cell), plus the
pending-state UI at components/auth/CheckEmailNotice/ that consumes it.
Lands the building blocks for #164's signup-confirmation flow ahead of
wiring SignUpForm into it (commit 3).
Captures the structural drifts from commit 2's implementation as
Decisions rows, rewrites commit 2 to describe the end state in HEAD,
and updates commits 3/5/6 wiring instructions for the new starting
point. Notable: <CheckEmailNotice> is Card-only (parent provides page
chrome), no <form> wrapper, no <StatusLine>, generic failure copy,
centered Verify button, error below the slots. Commit 5 now marks the
CardFooter as a reintroduction; commits 3 and 6 swap in place of <Card>
rather than the whole form.
Move e2e coverage to commit 6 so it lands after every flow it asserts.
Add two frontend integration tests (signup-resend in commit 4,
signin-unconfirmed in commit 5) to cover the router + Supabase SDK
wiring that unit tests and the targeted e2e suite don't reach. Pin
down commit 6's OTP-input selector and Mailpit polling approach.
Flip enable_confirmations on in both Supabase stacks, ship a custom
confirmation template that includes both magic link and OTP code, and
wire SignUpForm into the existing <CheckEmailNotice> pending UI when
signUp returns no session.

- AuthContext.signUp gains an options.redirect param and returns
  { session } so the form can branch on auto-confirm vs. pending; the
  redirect threads through to emailRedirectTo so deep-links survive
  the email gap on the magic-link path.
- supabase client disables detectSessionInUrl so the auth-callback
  loader owns the PKCE code exchange without racing the SDK's
  default URL watcher.
- requireAuth route guard falls back to supabase.auth.getSession()
  before redirecting; otherwise the moment between
  exchangeCodeForSession and the next AuthContext render bounces a
  freshly-confirmed user back to /.
- <OtpInput> gains an onComplete callback plus an onBeforeInput
  splice handler. The handler fixes the same-digit-replacement case
  in a full code (React's value tracker skips onChange when the new
  character matches the old). Slot sizing goes responsive so the row
  fits on ~320px phones.
- config-sync.spec.ts now ignores site_url and additional_redirect_urls
  since they encode the web port (5173 dev, 5273 e2e) which differs by
  the +100 e2e convention.
- e2e/supabase/templates symlinks to api/supabase/templates so both
  stacks read the same confirmation.html.
Adds the detectSessionInUrl flag to commit 1's PKCE description, the
OtpInput onComplete + onBeforeInput design to commit 2, and the
route-guards / config-sync / symlink details to commit 3. Adds
decision rows for the requireAuth getSession fallback and the
onComplete callback. Updates the critical-files list to cover the
new touch points.
Supersede commits 1-3's PKCE + custom callback design with the
documented Supabase SPA pattern (implicit flow + detectSessionInUrl).
Adds commits 4-8 covering revert, routing, error UX, resend, and e2e.
Replace the PKCE + /auth/callback infrastructure with Supabase's
implicit-flow defaults so the SDK detects the URL fragment and
establishes the session on whatever page the email link lands on.

- supabase client: drop flowType/detectSessionInUrl overrides
- router: delete authCallbackRoute, its Zod schema, and orphaned imports
- AuthContext.signUp: option shape becomes { emailRedirectTo } and
  passes through to Supabase verbatim; default is \${origin}/
- SignUpForm: hardcode emailRedirectTo to \${origin}/ for this commit
  (commit 5 makes it dynamic); navigate to search.redirect ?? '/' on
  typed-OTP verify, letting indexRoute.beforeLoad own the routing call
- delete auth-destination helper + auth-callback integration test
- relax local redirect allowlist to a wildcard host; production
  allowlist is managed in the Supabase dashboard

Plan: docs/plans/164-email-confirmation-signup.md commit 4.
Signed-in users hitting / now bounce to /leagues or /create-team via
indexRoute.beforeLoad — mirrors the existing pattern on /sign-in and
/sign-up and eliminates the marketing-page-inside-authenticated-shell
visual when users return to /.

SignUpForm threads search.redirect through emailRedirectTo so a user
signing up at /sign-up?redirect=/join/<token> on desktop can confirm
via the email link on mobile and land directly at /join/<token>
signed in. Cross-browser invite preservation falls out of this.
Mirrors the Testing Strategy section's shape: default-to-none principle,
the test before writing, categories of comments that earn their place vs.
ones that don't, and practical heuristics. Available as a referenceable
standard for prompts like "audit comments against our commenting strategy."
When Supabase's /verify rejects an email confirmation link (expired,
already-used, or invalid token), bounce the user to /sign-up and surface
a friendly message above the form. Recovery action — sign up again to
receive a new link — lives next to where it can be taken, instead of a
banner over the marketing landing page.

- web/src/lib/auth-redirect.ts: new readConfirmationLinkError() helper
  that reads supabase.auth.initialize()'s cached error result and maps
  it to 'expired' | 'generic' | null based on error.details.code.
- web/src/router.tsx: indexRoute.beforeLoad redirects to /sign-up with
  replace:true when the helper returns a code for a signed-out user.
  signUpRoute.beforeLoad calls the same helper (same cached promise) and
  returns the code as route context.
- web/src/components/auth/SignUpForm/SignUpForm.tsx: reads confirmationError
  from useRouteContext, maps to user-facing message via a small lookup,
  renders <InlineError> above the form.

Matches on the typed otp_expired code (verified in gotrue source) rather
than parsing URL hash strings. Route context (vs. AuthContext state)
keeps the message tied to the route lifecycle and avoids a cleanup
effect on SignUpForm unmount.
Resend button on CheckEmailNotice triggers supabase.auth.resend via a new
lib/auth-resend free function. Discriminates rate-limit (over_email_send_rate_limit)
from generic failures using isAuthApiError; rate-limit shows a friendly message,
generic shows a fallback message and reports to Sentry. Layout restructure groups
OTP, error, and Verify in a fit-content column so Verify naturally matches the
OTP slot row's width without magic numbers.
Resend ships as a free function in lib/auth-resend.ts (not an AuthContext
method), with Sentry capture on unexpected failures, a typed isAuthApiError
guard for rate-limit detection, and a fit-content layout that ties the Verify
button to the OTP slot row without magic numbers. Plan now records the decision
trail for these departures from the original commit 7 design.
Adds magic-link, OTP, and cross-browser preservation tests via a new
Mailpit HTTP fixture, and migrates two pre-existing UI-signup tests off
the now-confirmation-gated path so the suite stays green.
Unit and integration coverage exists for the resend wiring and the
rate-limit UX path, but neither proves `supabase.auth.resend` actually
produces a second email against the real Supabase + Mailpit stack. The
new E2E owns that failure mode.

Raise the e2e `email_sent` rate cap above the tight dev default so the
suite has headroom for signup + resend in a single run. Drop the e2e
per-recipient `max_frequency` below `1s` so the test doesn't have to
sleep past gotrue's throttle. gotrue treats `"0s"` as unset and falls
back to a hardcoded ~60s floor, so use `"1ms"` instead.

Extend the dev/e2e config-sync regex to ignore both keys.
Add commit 9 section for the resend E2E, flip verification item 4
from manual to automated, and update the cross-cutting "Critical
files" annotations.
@netlify
Copy link
Copy Markdown

netlify Bot commented May 15, 2026

Deploy Preview for f1-fantasy ready!

Name Link
🔨 Latest commit d127a2b
🔍 Latest deploy log https://app.netlify.com/projects/f1-fantasy/deploys/6a0717598896bc00082e113b
😎 Deploy Preview https://deploy-preview-188--f1-fantasy.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

emsqrd added 2 commits May 14, 2026 23:44
completeSignUp now wraps navigate in try/finally so completeAuthTransition
runs even when navigation rejects, preventing the post-confirmation overlay
from getting stuck. CheckEmailNotice.verify splits its catch: AuthApiError
keeps the "code didn't match" copy; everything else gets a generic message
and is captured to Sentry, mirroring the resend path.
@emsqrd emsqrd merged commit 5323704 into main May 15, 2026
10 checks passed
@emsqrd emsqrd deleted the email-confirmation-signup-issue-164 branch May 15, 2026 13:10
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.

Require email confirmation on signup

1 participant