Repository navigation
fix(auth): verify email with the sign-up browser proof or the password - #2050
dawsontoth wants to merge 6 commits into
Conversation
central-manager#785 stops a verification link from finishing sign-up on its own: the request must also prove its sender chose the account's password. Sign-up now keeps the X-Signup-Browser-Proof header from the 202 in localStorage for a day, and the verify page sends it with the token. Without it, the page asks for the sign-up password, or a new one in its place, instead of submitting the token by itself. A session central-manager opens is taken into the app the way a sign-in is. Against a central-manager without #785 the page degrades to today's behavior: verified, then sign in (automatically with the password typed here). The page no longer submits a token-only verify on load. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign-up proofs are now stored per address. A link sends the newest one; when central-manager answers password_required naming an account this browser holds a proof for, the page resends with that account's proof. Only the answered account's proof is cleared, and the password form is labelled with the account central-manager names. Taking a verified session into the app now drops a late answer for a dropped link or after a sign-out or newer sign-in, and a failure while completing it asks the user to sign in instead of hanging. A dismissed wrong-password error no longer comes back, Try again shows Verifying, and a failed sign-in's error is cleared on the next submit. The e2e round trip follows the new flow (password prompt or proof, then signed in), and its set-password mail helper, now unused, is removed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The verify page's Sign In button and its sign-in and forgot-password links now carry the address in router history state, which SignIn and ForgotPassword read alongside ?me=, instead of putting it in the URL that every analytics pixel reads (AGENTS.md). Picking up a verified session now reads the user with fetchCurrentUser, which writes nothing to the auth store until the page is still mounted and on the same link with no sign-out or newer sign-in since. Sign-in bookkeeping (UTM cleanup, Datadog, Reo) can no longer stop navigation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every submit and every change of link bumps an attempt id, and the verify, session pickup and today's-central-manager /Login/ callbacks act only while their attempt is current and the page is mounted. The login is called directly, so a stale answer can no longer sign in, show an error or redirect after the link moved on, and a failure while taking its user into the app asks them to sign in instead of hanging. useCompleteCloudSignIn does not navigate after a sign-out that landed while it was invalidating the user query. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a sign-out or another sign-in lands while the verify page is loading or completing a verified session, the page now falls back to "Sign in to continue" instead of staying on "Signing you in...". useCompleteCloudSignIn resolves false when it skipped navigating. Today's-central-manager path no longer toasts "verified" twice. The e2e round trip again follows a set-password email when the target central-manager answers reset_email_sent, and keeps mail.ts's helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces a "browser proof" mechanism to streamline the signup and email verification flow. When a user signs up, a browser proof is stored in local storage. During email verification, if this proof is present, the user is verified and signed in automatically without needing to re-enter their password. If no proof is found (e.g., if the link is opened in a different browser or by a mail scanner), the page falls back to asking for the signup password or allowing them to set a new one. Additionally, the PR implements email prefilling via history state for sign-in and forgot-password pages to avoid exposing emails in URLs. The review feedback suggests improving accessibility for the loading spinner in VerifyEmail.tsx by adding screen reader text.
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
…e URL Sign-in, sign-up, forgot-password, the check-your-email page and the verify page's resend form linked to each other with `?me=<address>`, and sign-up and an unverified sign-in navigated to `/verifying?email=`. Every analytics pixel reads the page URL, so they all carry the address in router history state now. Each page still reads `?me=`/`?email=`, for links made before this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
⊙ Problem
HarperFast/central-manager#785 changes
PUT /VerifyEmail/: the link's token alone no longer verifies an account. It must also carry the sign-up's browser proof (a sign-up response header) or the sign-up password, or replace that password. Studio sends the token alone, on page load.🤔 Is this the right requirement?
Yes, as specified. It supersedes the plan in #1697, which asked every user to choose a new password while verifying. Here the browser that signed up usually types nothing; the password is asked for only when it has no usable proof. #1697 stays open for its owner to close.
💡 Solution
reset_email_sent,password_reset_required,already_verified, retries and expired links is unchanged.🚦 Rollout
This ships to stage and prod before central-manager#785 deploys. Today's central-manager ignores the new fields, verifies on the token and answers with no
outcome. The page keeps today's behavior: verified, then sign in, automatically through/Login/when a password was typed. Once #785 deploys, the proof and password checks are enforced.⚖️ Alternatives
password_requiredanswer names the account, and the page resends with that account's proof.🤔 Keep the proof where page script can read it?
Yes, as #785 designed it: a response header Studio keeps in its own localStorage, which other sites can't read. An HttpOnly cookie would hide it from injected script, but needs a protocol change in both repos. The proof lasts 24 hours and is cleared once answered.
🔧 Changes
signUpBrowserProof.ts— one proof per address for 24 hours, keeping the first for each address. It reads the newest or one address's, and clears one address plus any expired. Every storage call is wrapped.localStorageKeys.tsadds its key.useSignUp.ts— readsx-signup-browser-prooffrom the 202 and stores it.useVerifyEmail.ts— sends{ token, browserProof | password | newPassword }and types every outcome.VerifyEmail.tsx— the two password forms and each outcome. A link sends the newest proof, then resends once with the named account's own proof onpassword_required. The form is labelled with that account. Each submit or new link starts an attempt, and a late answer from an older one is dropped. Loading the session writes nothing to the store unless the attempt is current, with no sign-out or newer sign-in since loading began. A dismissed wrong-password error stays dismissed, and Try again shows Verifying.useCloudSignIn.ts— the sign-in completion is nowuseCompleteCloudSignIn, shared with the verify page. UTM cleanup and analytics can no longer stop it before it navigates, and it doesn't navigate after a sign-out that lands meanwhile (it resolvesfalse, and the verify page asks them to sign in).useLoginMutationis exported for the verify page's guarded fallback.prefillEmail.tsand the auth pages — every hand-over between sign-in, sign-up, forgot-password, check-your-email and the verify page carries the address in router history state, not?me=or?email=, which the analytics pixels would read: the sign-in, forgot-password and sign-up links, sign-up's and an unverified sign-in's move to/verifying, and the resend forms' return to sign-in. Sign-in, forgot-password, sign-up and check-your-email read it, and still read?me=/?email=from older links.password.ts,ResetPassword.tsx,SignUp.tsx— one new-password schema for sign-up, reset and verify.AuthLayout.css— link styling for the two text buttons.signup-verification.anon.spec.ts— the live round trip follows the new flow: proof or password prompt, then signed in, a Sign In button, or a set-password email.🤔 History state rather than
?me=for the address?AGENTS.md forbids addresses in URLs, since analytics pixels read them. History state survives a reload but not a new tab or a copied link: the form starts empty, and the check-your-email page says "your email" and can't resend. Both pages are reached in the same tab. The pages keep reading
?me=/?email=, so bookmarks made before this still work; that read can go once they age out.🤔 Keep the first proof for an address?
A repeat sign-up for an existing unverified account gets a throwaway proof, so the store keeps the first. The cost: if the first sign-up failed inside central-manager and the second created the account, this browser has to type the password.
🤔 A new password sent to today's central-manager
It ignores
newPasswordand verifies with the sign-up password. The page says the new password wasn't saved and does not try to sign in with it.✅ Verification
npx vitest run: 620 files, 6,542 tests pass.tsc -b,oxlintanddprintare clean; the e2e package typechecks and its unit project passes.{ token, browserProof }; stage answered 401 for the fake token, the page kept the proof and showed the new-link form.What the tests cover
/verifying?email=link handed its address on to sign-in the same way.VerifyEmail.test.tsx(every outcome, proof and password paths, today's central-manager),useVerifyEmail.test.tsx(request bodies),SignUp.test.tsx(header stored, absent, storage refusing),signUpBrowserProof.test.ts,SignIn.test.tsxandForgotPassword.test.tsx(history-state prefill),AuthStatus.test.tsx(mocks for the new hooks).verified+signedInsigns in and forgets the proof; a session that can't be loaded asks them to sign in;signedIn: falseasks them to sign in with the address filled in;password_requiredshows the form and forgets the proof; another sign-up's newer proof is resent with the named account's own, keeping the other; the form is labelled with the named account; a late session load for a dropped link, a link that left and came back, after unmount or after a sign-out is ignored; a sign-out during completion stops navigation and asks them to sign in; a new link shows its own form, not the replaced link's answer; analytics that throw still sign in; a failing sign-in completion asks them to sign in; no outcome asks them to sign in; once per token under StrictMode; 503 retries; Try again resends the same proof; 401/403 keep the proof.password_mismatchflags the field and labels the account, the error stays dismissed after switching forms, then the right password verifies; Try again on a new password shows Verifying; new password validation;password_setwith and without a session;reset_email_sentandpassword_reset_requiredafter a new password; today's central-manager:/Login/with the typed password, its failure shown, a late answer after the link changed ignored, a failure taking its user in asks them to sign in, a new password reported as not saved; a failed sign-in's error cleared for the next link./verifying, pass the address in state with none in the URL;Verifying.test.tsxshows and resends to an address from state or an old?email=, and resends nothing without one.🤖 Generated with Claude Code (Claude Opus 5.5); posted via @dawsontoth.
Related PRs: #2030 overlaps (merged; this builds on its outcome handling, retries and once-per-token latch)
Complexity: medium
Deferred findings
38e8cdad9fc1d715nit reliability —src/features/auth/hooks/useSignUp.ts— proof header readable only if CM exposes it cross-origin; casing check — NOT FILEDLate Grok findings
4 surviving (highest minor) — Grok reviewed 9c1b231
src/features/auth/VerifyEmail.tsx:469— a verify that has already been superseded can still sign the user in after the token changes whilegetCurrentUseris in flightsrc/features/auth/VerifyEmail.tsx:530— the password form shows the stored proof's address after central-manager answers with the token's own accountsrc/features/auth/VerifyEmail.tsx:376— the new?me=<address>links break the repo rule against putting addresses in URLssrc/features/auth/signUpBrowserProof.ts:45— keeping the first proof that arrives can keep the throwaway oneNot standing: 2 merged. Grok ran 20m (20m from launch to report); the fold adjudication (
cursor-grok@9c1b231e0e4e-79cb0d58, claude) took 1m.Late Grok findings
11 surviving (highest minor) — Grok reviewed afe2ca8
src/features/auth/VerifyEmail.tsx:504— if the auth generation moves while the session is loading, the page shows "Signing you in..." with no way forwarde2e/tests/signup-verification.anon.spec.ts:122— the round-trip times out against a central-manager that answersreset_email_sentsrc/features/auth/VerifyEmail.tsx:540— the legacy/Login/callbacks have no attempt guard, a token that comes back (a → none → a) matches the stale attempt, andnavigatestill runs after a mid-completion sign-outsrc/features/auth/VerifyEmail.tsx:71— the "Send Verification Email" success navigates with?me=<address>src/features/auth/hooks/useCloudSignIn.ts:28— acompleteSignInrejection is unhandled on the legacy path, and theenterApppath shows "Sign in to continue" while the store is already signed insrc/features/auth/VerifyEmail.tsx:555— disputed: a token change during an in-flight PUT might render the old token's resultsrc/features/auth/VerifyEmail.tsx:171— after Try again, apassword_mismatchremounts the form and flags an empty fieldsrc/features/auth/signUpBrowserProof.ts:65— first-proof-wins can keep a throwaway proof, and two tabs can lose each other's updatesrc/features/auth/hooks/useSignUp.ts:6— new local imports have no file extensionssrc/features/auth/signUpBrowserProof.ts:20— added comments narrate the code:signUpBrowserProof.ts:20,signUpBrowserProof.ts:33andsignUpBrowserProof.ts:93,e2e/tests/signup-verification.anon.spec.ts:117-118, andsrc/features/auth/SignIn.test.tsx:77src/features/auth/hooks/useSignUp.ts:24— the client can read the proof header only if central-manager exposes it cross-originNot standing: none. Grok ran 14m (14m from launch to report); the fold adjudication (
cursor-grok@afe2ca844507-70363db7, claude) took 1m.Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex,cursor-grok; late=cursor-grok:adjudicated; adjudicated=domain; declined=muse; rounds=6; full=2 @ 1b1d068
Review-Attention: deep ~65m (critical: localStorageKeys.ts; sensitive: AuthLayout.css, ForgotPassword.tsx +10; decisions: history-state-prefill, legacy-query-read, legacy-cm-login-fallback, do-less-alternative) @ 1b1d068