feat(auth): offer email/password sign-in alongside Google [SIDE-133] - #6
Merged
ben-everly merged 132 commits intoJul 2, 2026
Merged
Conversation
…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.
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
deleted the
SIDE-133/offer-emailpassword-sign-in-alongside-google
branch
July 2, 2026 00:19
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.
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
/login(now with an email/password form + links),/register,/forgot-password,/reset-password— plus loading skeletons.lib/auth/schemas.ts(shared client + a singleMIN_PASSWORD_LENGTH, mirrored inconfig.toml).lib/auth/auth-errors.ts./reset-passwordverifies the single-use token viaverifyOtp(token presence, not session presence, is the gate), with a dead-session bounce to/forgot-password.AUTH_PATHS;withNexthelper for destination preservation.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 afterverifyOtp(history hygiene).test(auth): pin all fourDEAD_SESSION_CODESto the recovery_invalid bounce viait.each(was 1 of 4).refactor(auth): derive the duplicate-signup copy fromauthErrorMessage(single source).refactor(auth): route remaining path literals throughAUTH_PATHS.Investigated and dismissed: session-eviction-on-reset (GoTrue auto-revokes other sessions — verified empirically) and a CI rate-limit gate (
config.tomlis dev-only;db pushdoesn'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