Skip to content

fix(auth): verify email with the sign-up browser proof or the password - #2050

Draft
dawsontoth wants to merge 6 commits into
stagefrom
fix/781-verify-email-browser-proof
Draft

dawsontoth wants to merge 6 commits into
stagefrom
fix/781-verify-email-browser-proof

Conversation

@dawsontoth

@dawsontoth dawsontoth commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

⊙ 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

🚦 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

  • sessionStorage for the proof — the email link opens a new tab, which starts with empty sessionStorage.
  • Decode the token to pick the proof for that account — reads a token format Studio doesn't own. central-manager's password_required answer 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

🤔 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 newPassword and 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, oxlint and dprint are clean; the e2e package typechecks and its unit project passes.
  • Every new guard was reverted one at a time; each revert turned at least one test red.
  • Live, dev server against stage's central-manager (today's contract): with no proof the page sent nothing on load. With a stored proof it sent { token, browserProof }; stage answered 401 for the fake token, the page kept the proof and showed the new-link form.
  • fix: Update team in CODEOWNERS #785's outcomes are covered by unit tests only, since fix: Update team in CODEOWNERS #785 isn't deployed anywhere Studio can reach. The e2e round trip needs Mailosaur and was not run.
What the tests cover
  • Live, dev server: an address typed on sign-in reached sign-up, back to sign-in and on to forgot-password in history state, with no address in any URL, and survived a reload. An old /verifying?email= link handed its address on to sign-in the same way.
  • Files: 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.tsx and ForgotPassword.test.tsx (history-state prefill), AuthStatus.test.tsx (mocks for the new hooks).
  • Sign-up: the proof header is stored with a 24-hour expiry; no header stores nothing; storage that throws still signs up and reports nothing.
  • Storage: expiry, first proof kept per address (case-insensitive), replaced once expired, each address kept, newest read first, clearing one address and expired ones, malformed values read as none, throwing storage.
  • Verify page, with a proof: sent with the token; verified + signedIn signs in and forgets the proof; a session that can't be loaded asks them to sign in; signedIn: false asks them to sign in with the address filled in; password_required shows 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.
  • Verify page, without a proof: no PUT on load; expired proof ignored and removed; password required before sending; password_mismatch flags 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_set with and without a session; reset_email_sent and password_reset_required after 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.
  • Hand-overs: an address in history state fills sign-in, forgot-password and sign-up; each page's links and post-submit moves, and an unverified sign-in landing on /verifying, pass the address in state with none in the URL; Verifying.test.tsx shows 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

  • 38e8cdad9fc1d715 nit reliability — src/features/auth/hooks/useSignUp.ts — proof header readable only if CM exposes it cross-origin; casing check — NOT FILED

Late Grok findings

4 surviving (highest minor) — Grok reviewed 9c1b231
  • minor — src/features/auth/VerifyEmail.tsx:469 — a verify that has already been superseded can still sign the user in after the token changes while getCurrentUser is in flight
  • minor — src/features/auth/VerifyEmail.tsx:530 — the password form shows the stored proof's address after central-manager answers with the token's own account
  • minor — src/features/auth/VerifyEmail.tsx:376 — the new ?me=<address> links break the repo rule against putting addresses in URLs
  • nit — src/features/auth/signUpBrowserProof.ts:45 — keeping the first proof that arrives can keep the throwaway one

Not 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
  • minor — 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 forward
  • minor — e2e/tests/signup-verification.anon.spec.ts:122 — the round-trip times out against a central-manager that answers reset_email_sent
  • minor — src/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, and navigate still runs after a mid-completion sign-out
  • minor — src/features/auth/VerifyEmail.tsx:71 — the "Send Verification Email" success navigates with ?me=<address>
  • nit — src/features/auth/hooks/useCloudSignIn.ts:28 — a completeSignIn rejection is unhandled on the legacy path, and the enterApp path shows "Sign in to continue" while the store is already signed in
  • nit — src/features/auth/VerifyEmail.tsx:555 — disputed: a token change during an in-flight PUT might render the old token's result
  • nit — src/features/auth/VerifyEmail.tsx:171 — after Try again, a password_mismatch remounts the form and flags an empty field
  • nit — src/features/auth/signUpBrowserProof.ts:65 — first-proof-wins can keep a throwaway proof, and two tabs can lose each other's update
  • nit — src/features/auth/hooks/useSignUp.ts:6 — new local imports have no file extensions
  • nit — src/features/auth/signUpBrowserProof.ts:20 — added comments narrate the code: signUpBrowserProof.ts:20, signUpBrowserProof.ts:33 and signUpBrowserProof.ts:93, e2e/tests/signup-verification.anon.spec.ts:117-118, and src/features/auth/SignIn.test.tsx:77
  • nit — src/features/auth/hooks/useSignUp.ts:24 — the client can read the proof header only if central-manager exposes it cross-origin

Not 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

dawsontoth and others added 5 commits November 3, 2026 10:20
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>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/features/auth/VerifyEmail.tsx Resolved
@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 82.51% 17106 / 20732
🔵 Statements 82.4% 18164 / 22043
🔵 Functions 78.04% 4681 / 5998
🔵 Branches 77.44% 13680 / 17665
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/features/auth/AuthLayout.css 0% 0% 0% 0%
src/features/auth/ForgotPassword.tsx 96% 100% 87.5% 95.83% 61
src/features/auth/ResetPassword.tsx 77.77% 50% 75% 77.77% 22, 46-53
src/features/auth/SignIn.tsx 84.61% 71.42% 60% 84.61% 103-109
src/features/auth/SignUp.tsx 86.04% 71.42% 86.66% 88.09% 134-138
src/features/auth/VerifyEmail.tsx 98.83% 96.55% 96% 98.8% 69, 459
src/features/auth/Verifying.tsx 83.33% 100% 50% 83.33% 21-25
src/features/auth/prefillEmail.ts 100% 100% 100% 100%
src/features/auth/signUpBrowserProof.ts 100% 96.55% 100% 100%
src/features/auth/hooks/useCloudSignIn.ts 97.72% 75% 100% 97.67% 129
src/features/auth/hooks/useSignUp.ts 91.66% 90% 100% 91.66% 30
src/features/auth/hooks/useVerifyEmail.ts 100% 100% 100% 100%
src/lib/storage/localStorageKeys.ts 100% 100% 100% 100%
src/lib/zod/password.ts 100% 100% 100% 100%
Generated in workflow #2504 for commit 1b1d068 by the Vitest Coverage Report Action

…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>
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