Skip to content

feat(auth): offer email/password sign-in alongside Google [SIDE-133] - #6

Merged
ben-everly merged 132 commits into
mainfrom
SIDE-133/offer-emailpassword-sign-in-alongside-google
Jul 2, 2026
Merged

feat(auth): offer email/password sign-in alongside Google [SIDE-133]#6
ben-everly merged 132 commits into
mainfrom
SIDE-133/offer-emailpassword-sign-in-alongside-google

Conversation

@ben-everly

@ben-everly ben-everly commented Jun 22, 2026

Copy link
Copy Markdown
Owner

Adds self-service email/password auth alongside the existing Google OAuth, covering sign in, sign up, and password reset. New accounts are usable immediately (no mandatory email confirmation).

What's included

  • Four routes: /login (now with an email/password form + links), /register, /forgot-password, /reset-password — plus loading skeletons.
  • Validation: Zod schemas in lib/auth/schemas.ts (shared client + a single MIN_PASSWORD_LENGTH, mirrored in config.toml).
  • Error vocabulary: GoTrue error codes → friendly, non-enumerating copy in lib/auth/auth-errors.ts.
  • Recovery flow: token-hash recovery email template; /reset-password verifies the single-use token via verifyOtp (token presence, not session presence, is the gate), with a dead-session bounce to /forgot-password.
  • Routing: auth paths centralized in AUTH_PATHS; withNext helper for destination preservation.
  • DB: profile-drift log gated to non-email providers (a nameless password signup is expected, not Google-schema drift), with a pgTAP test.
  • Tests: unit (schemas, error maps, every form), pgTAP (provider gating), and Playwright E2E (sign-up, sign-in, reset via Mailpit).

Panel review follow-ups (this branch)

A multi-reviewer pass produced 19 findings; the actionable, untracked ones were fixed here:

  • fix(auth): strip the spent recovery token from the URL after verifyOtp (history hygiene).
  • test(auth): pin all four DEAD_SESSION_CODES to the recovery_invalid bounce via it.each (was 1 of 4).
  • refactor(auth): derive the duplicate-signup copy from authErrorMessage (single source).
  • refactor(auth): route remaining path literals through AUTH_PATHS.

Investigated and dismissed: session-eviction-on-reset (GoTrue auto-revokes other sessions — verified empirically) and a CI rate-limit gate (config.toml is dev-only; db push doesn't carry auth config).

Production gate

Email/password must not be exposed in production until prod SMTP + tightened rate limits are configured together — see docs/DEPLOY.md §8.

Follow-up tickets

…ment

- Add .catch() to getSession() in ResetPasswordForm so a rejected promise
  redirects to recovery_invalid instead of hanging on the spinner
- Cover the rejection path with a third unit test (3/3 pass)
- Reword config.toml comment to reference 'the password rule in lib/auth/schemas.ts'
  so grep finds the right symbol name
Add an E2E regression guard asserting a duplicate sign-up surfaces the
non-enumerating copy and creates no session — pinning GoTrue's
confirmations-off behavior so a CLI bump that changed it fails loudly.

Also correct the register-form comment: with confirmations off a duplicate
email returns user_already_exists (handled by the error branch), so the
null-session branch is really the confirmations-on obfuscation path. Note
the footgun that enabling confirmations makes new signups return a null
session too, distinguishable only via result.user.identities.
The /register footer only linked to sign-in, so a returning user who hit the
non-enumerating "if you already have an account" message had no path to reset.
Surface both Sign in and Forgot password links so the recovery path is always
one click away.
Dev-parity rate limits in config.toml and the absence of CAPTCHA must not reach
production. Add a dashboard-driven deploy checklist to set strict rate limits and
enable CAPTCHA on the auth endpoints, consistent with the rest of DEPLOY.md.
Production SMTP for password reset remains tracked in SIDE-135.
If the recovery session is gone by submit time (revoked/reused refresh token or
an already-consumed link), updateUser fails — previously into a generic inline
error. Detect the dead/missing-session cases (AuthSessionMissingError, or an
AuthApiError with a session/refresh-token code) and redirect to
/forgot-password?error=recovery_invalid, matching the mount-time and callback
paths. Other errors still render inline.
getSession can hang on an under-the-hood token refresh, trapping the user on an
indefinite spinner. Add a 10s timeout that swaps the spinner for an actionable
message + a "Request a new link" link to /forgot-password. A late-resolving
session still wins (renders the form); the timer is cleared on resolve and unmount.
GoTrue redirects an expired or replayed recovery link to the callback as
error=access_denied, which the early cancellation branch caught before the
recovery check could run — stranding the user on /login with "sign-in
cancelled" copy. Gate that branch on recoveryFlow so recovery failures reach
/forgot-password?error=recovery_invalid.
GoTrue obfuscates an existing email as a user with an empty identities array.
Keying the "you may already have an account" branch off the null session alone
is correct only while confirmations are off; once SIDE-135 enables them a
genuine new signup is also sessionless and would be mislabeled a duplicate.
Switch to the config-independent identities check the comment already named.
ben-everly and others added 28 commits June 27, 2026 21:35
The TS constants mirror minimum_password_length and max_frequency in
supabase/config.toml by hand-written comment only. Read the config and
assert equality so a drift fails loudly instead of surfacing as a
GoTrue weak_password rejection or a resend button that re-enables
before GoTrue will accept another send.

Scope the max_frequency match to [auth.email] so it can't read
[auth.sms]'s value, and anchor on the key (not line end) so a benign
trailing TOML comment doesn't trip a false drift failure.
A user who registered but never confirmed gets the generic
"email or password is incorrect" on sign-in (non-enumerating), with
no hint that confirming their email is the fix. Give each recovery
action exactly one home: "Forgot password?" beside the password
field (always), and a "Resend confirmation" prompt below the submit
button on any sign-in failure — gated on failure, not the error code,
so it can't reveal that an account exists but is unconfirmed.

The standing footer links are gone, so the expired-link notices
(recovery_invalid / confirmation_invalid) now carry their own inline
"Request a new one" link instead of pointing at footer links below.
FormMessage used role="alert", so every field error interrupted the
screen reader assertively and multiple invalid fields collided. Switch
to a polite live region (aria-live=polite + aria-atomic) rendered from
first paint and populated on error, rather than inserted on error —
screen readers don't reliably announce dynamically added regions, and
RHF's focus-the-invalid-field is a no-op when that field already holds
focus. empty:sr-only keeps the always-present element out of layout
until it holds a message. The assertive region is now FormRootError's.

Field-error tests query by text instead of role=alert.
Arriving at /login?error=… (e.g. confirmation_invalid) renders a
notice; a failed sign-in then stacked the form's own error beneath it.
On a server-rejected attempt, drop the error param via withNext so the
notice doesn't double up. Gated on isLoginError to match the page's
own notice gate, and on the post-validation failure branch so a
validation-rejected submit keeps the notice and its recovery link.
… live resend countdown

Collapse RegisterForm, the RegisterPanel wrapper, and CheckInbox into one
RegisterForm whose heading and footer follow the sent/unsent state. Trim the
confirmation copy and drop the contextually-wrong sign-in / forgot-password
footer once a link is sent.

Replace the static resend-cooldown caption with a live countdown beside the
button, and reset the form when backing out via "Use a different email" so the
screen does not pre-fill the just-sent address.
Extract AuthPanel (titled column whose heading swaps to "Check your email"
once sent) and ConfirmationSent (the shared post-send body: message slot,
resend button, "use a different email", "back to sign in"). Both the register
and resend-confirmation forms now compose them.

This fixes the resend-confirmation stale-header issue — its heading lived in
the server page and never reflected the sent state — by moving the heading and
"back to sign in" footer into the client form, leaving the page a thin shell.
ConfirmationSent now owns the "Check your email" heading; each form owns its
own title heading inline alongside the column wrapper. This removes AuthPanel's
`sent` prop, which expressed sentTo twice — once to drive the heading, once in
the body ternary. The repeated heading scaffold is same-shape/different-text
markup, not duplicated logic.
Add an outline Button variant and use it for the resend control so it reads as
a button at rest rather than only on hover; it's the one stateful control on
the check-inbox screen (disabled during the cooldown, spinner while sending),
while "use a different email" / "back to sign in" stay as links.

Restore cursor:pointer on buttons in the base layer — Tailwind v4's Preflight
dropped the rule v3 applied, so buttons showed the default arrow while anchors
kept the pointer.
A successful resend now fires a sonner toast.success; the inline region
narrows to errors only and adopts the auth error convention (role="alert",
text-destructive). The cooldown countdown still gives the local cue.

Move <Toaster /> from the (app) layout to the root layout so it's in scope for
the public auth pages (register, resend-confirmation), not just the authed
shell; removing it from (app) avoids a double-mount.
The cooldown lived in the resend button's local state, seeded fresh on every
mount, and only governed that button — so backing out via "use a different
email" and resubmitting fired a new send on a brand-new window. The standalone
resend form, being fire-and-forget, then claimed "sent" even when GoTrue had
throttled the request.

Move it into a sessionStorage-backed, per-email store (lib/auth/resend-cooldown)
read through a useResendCooldown hook. The resend button and the resend form's
submit now honor the same persisted window, derived from the real send time so
it survives remounts and reflects true elapsed time; the form's submit disables
with "Resend available in Xs" for a cooling address. UX only — GoTrue's
max_frequency stays the enforcement boundary.
The sent-screen resend button now renders "Resend available in Xs" as its label
while cooling, matching the resend-confirmation form's submit, instead of a
separate aria-hidden badge beside it. One presentation to maintain, and the
remaining time is now part of the button's accessible name rather than hidden
from screen readers.
Cut a stale otp-types header describing an Object.values allowlist that
no longer exists, deduplicate the confirm page comment against the
ConfirmEmail header, and drop a ticket-number prefix and a redundant
component summary.
…numeration

The resend-confirmation flow had no browser coverage. Add e2e for resend
delivering a working link to an unconfirmed account, and pair the UI's
non-enumerating copy with an assertion that no email is sent for an unknown
address (resend and forgot-password) — the property unit tests can't reach
since they mock the send. Also pin that garbage confirmation/recovery tokens
bounce to the right page with the expected notice.

Add expectNoEmail to the Mailpit helper for the negative assertions.
…hout a token

secure_password_change is off, so GoTrue won't distinguish a recovery session
from any other authenticated one — the client token gate is the only thing
keeping a logged-in user out of the reset form. Pin it: an authed session
visiting /reset-password with no recovery token is bounced, never reaching
updateUser.
A sessionStorage cooldown is per-document: a second tab reports zero remaining,
re-enables the resend button, and fires a send GoTrue silently throttles while
the UI claims a link was sent. localStorage closes the cross-tab gap.
email_not_confirmed collapses to the generic "incorrect" for non-enumeration,
which misdirects an honest unconfirmed user typing correct credentials. Drop
the muted styling and reword so the resend path reads as the natural next step,
not a detached afterthought. Still gated on any failure, so it leaks nothing.
…ed users

A re-clicked (already-consumed) confirmation link returns the same otp_expired
as a truly expired one, so the copy can't tell them apart. Reword to cover both:
point an already-confirmed user at sign-in while still offering a fresh link,
instead of flatly asserting the link failed.
Only the token-bearing pages carried no-referrer, leaving the credential-entry
pages (/login, /register, /forgot-password) on the browser default. A single
site-wide header removes the asymmetry and fails safe as the app adds any
third-party requests.
The 1s interval ran for the component's whole life, re-rendering every second
even on a cooled-down screen where remaining time stays zero. Gate it on an
active cooldown so it ticks only while the countdown is live.
The auth-backstop redirect was the one bare '/login' string left after the
AUTH_PATHS sweep. No behavior change.
…without it

The key was optional only to keep secret-less builds green before email/password
shipped, but the app misbehaves without it: a Google-only user setting a first
password gets a working password while the email-identity backfill silently
no-ops (admin client throws, the reset form swallows it), so the provider can
never be unlinked. There is no production yet and the key is present in every
runtime that validates server env (dev, e2e), so require it — a missing key now
fails env validation at boot rather than a user's reset mid-flow.

Unit tests are unaffected (t3-env skips server-var validation under jsdom) and
the build does not validate env, so no CI change is needed.
…ord reset

The forgot-password "sent" screen hand-rolled its own panel while register and
resend shared ConfirmationSent, so the two surfaces drifted. Extract a generic
CheckYourInbox (heading + status message + resend + use-different + back) and a
ResendButton parameterized by the send action — recovery resends go through
resetPasswordForEmail, not auth.resend (which has no 'recovery' type). All three
flows now share the view; ConfirmationSent becomes a thin signup preset.

Forgot-password gains a "Resend reset link" affordance (it had none), seeded into
the cooldown on first send. Its heading/back-link move into the client form so the
page is thin like register, letting the sent view own the "Check your email" heading.
Sweep the tree treating every comment as a liability: drop test-step narration
(Act-as-X, section labels, restated assertions), source↔test duplication of
GoTrue/bcrypt facts already stated at the source, and a few headers that just
name what the code is. Trim the keep-worthy comments that trailed a real why
with a sentence restating the line below. Security/RLS/quirk rationale is left
intact — those carry the non-obvious why.
ensureEmailIdentity is the app's only RLS-bypass write. Log a tagged, info-level
[audit] event when it actually materializes an identity (not on the no-op path),
so the one security-sensitive mutation is recorded apart from the failure path.
The [audit] tag + info level are the migration anchor for real observability.
The drift warning logged new.id alongside the present metadata keys. The key-set
is the actionable schema-drift signal; the per-user id is a durable identifier
not worth keeping in server logs. Re-create handle_new_user to log keys only.
No app-visible behavior change (the message isn't assertable in pgTAP). Retention
and a proper logging abstraction are tracked in SIDE-148.
… pattern

Five auth forms already standardize on react-hook-form + zodResolver over the
shadcn Form primitives. Write it down so the next form doesn't re-litigate the
choice or hand-roll its own state.
next build validates env (now that the key is required) but never uses the
service-role key, so a placeholder satisfies validation without putting the
real secret in the build job.
@ben-everly
ben-everly merged commit a09d3af into main Jul 2, 2026
5 checks passed
@ben-everly
ben-everly deleted the SIDE-133/offer-emailpassword-sign-in-alongside-google branch July 2, 2026 00:19
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.

1 participant