fix(auth): report a failed sign-up in the form, not a fading toast - #1613
Conversation
RUM (last 24h, prod) shows 20 of 41 `POST /User/` submissions rejected with HTTP
409 across 8 sessions, and 7 sessions never created an account at all. Six of them
resubmitted the same address two or three times, ~5s apart, before giving up.
Two reasons the retries happen:
- The submit button had no `isPending` guard — unlike sign-in, forgot-password, and
reset-password, which all disable on it — so there was no sign a request was in
flight and nothing to stop a second click.
- A 409 fell through to the shared mutation-error toast, which cannot point at the
email field or at the "Sign in instead" link. Whatever central-manager phrases the
conflict as, a red toast beside an unchanged form reads as "try again".
Route the 409 onto the email field instead, following the `useCloudSignIn`
precedent: the mutation opts out of the global toast with
`meta: { skipGlobalErrorToast: true }`, and `SignUp`'s `onError` sets a server error
on `email` and focuses it. Every other failure still gets the standard toast.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Coverage Report
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Code Review
This pull request introduces inline error handling for already-registered email addresses during sign-up, preventing a generic global error toast and instead displaying a specific message on the email field. It also disables the submit button while the sign-up request is in flight. New unit tests are added for both the SignUp component and the error detection helper. The review feedback highlights a potential issue with test isolation, advising against sharing the global singleton queryClient across tests to prevent state leakage and flakiness, and suggests instantiating a fresh QueryClient before each test instead.
Review feedback on #1613: the tests shared the app's singleton `queryClient`, so `clear()` in `afterEach` reached across anything else holding it. Build a fresh client per test instead — but keep testing the app's real error routing rather than a copy of it, since whether a 409 reaches the global toast is the whole point. Extract that routing from the `MutationCache` literal into an exported `mutationErrorHandler` and have both the app client and the test client use it; a restated copy in the test would keep passing if `skipGlobalErrorToast` stopped being honored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
DavidCockerill
left a comment
There was a problem hiding this comment.
Nice, well-evidenced work — the funnel data in #1612 makes the case, and the implementation is clean. I'm commenting rather than approving on one question that I think should be answered before this ships, not because anything here is wrong.
The question: is POST /User deliberately outside the enumeration policy the other auth endpoints follow?
Two of the three public auth endpoints have explicit anti-enumeration engineering, with comments saying so:
Login.js:82-100verifies the password first and only then checksisVerified/ DEACTIVATED — commented as being precisely "so an unauthenticated caller can't distinguish these accounts from a wrong-password failure." Unknown email and wrong password both return the same 401.Password.js:35-49returns one constant response for unknown / DELETED / DEACTIVATED / PENDING, and starts a 500ms minimum-delay floor before the lookup, commented as "eliminating the timing oracle."
POST /User never got that treatment. It's public (User.allowCreate takes user as its first parameter and never consults it) and answers 409 vs 201 to any unauthenticated caller.
To be clear about what this PR does and doesn't do: it doesn't create that oracle. I traced every USER_STATUS branch in addUser — the response collapses to a clean binary, with invited-PENDING, DEACTIVATED and soft-DELETED all returning 201 and indistinguishable from a free address. No status code, body, header or timing changes here; only a string rendered in the DOM. A scripted client reads the status code off the wire and never instantiates React — #1612 is itself built from those status codes.
I also went looking for whether this makes the current signup-abuse traffic cheaper and concluded it doesn't, for a better reason than "the status code is unchanged": POST /User has no cheap reconnaissance mode. A miss returns 201 and creates an account and mails the address — probing is the payload. You can't quietly validate a purchased list against it. The only addresses probeable for free are ones already registered, which teach an attacker nothing new.
So why hold rather than approve. Right now closing that oracle is a quiet server-side edit nobody would notice. Once the UI tells users, closing it becomes "you're removing a helpful message," and that argument usually wins. That's a one-way door, and it's cheap to check before walking through it and expensive after.
And there's a real complication worth knowing before anyone answers. The textbook fix for a signup endpoint is: always return 202 and tell them by email — "you already have an account, sign in" vs "verify your address" — which is the shape Login and ForgotPassword already follow in spirit. But today a probe against a registered address sends no mail at all (the 409 throws before anything is created). Under always-202, every probe sends mail. Given the current signup traffic, the textbook fix would make the mail volume strictly worse. So the sequencing is probably: rate-limit POST /User first, then decide about the oracle — and rate limiting is the higher-value change either way.
If the answer is "yes, POST /User is deliberately outside the policy, the UX is worth it" — that's a perfectly good answer and I'll approve on it. I'd just like it written down somewhere, because right now the codebase says the opposite in two places and nothing says it here.
Two small notes in threads, both non-blocking and unrelated to the above.
Credit where it's due: declining the literal shape of @gemini-code-assist's test-isolation suggestion was the right call — restating the cache config in the test would have kept it green if skipGlobalErrorToast ever stopped being honoured — and extracting a shared mutationErrorHandler is a better answer than the one asked for.
Reviewed by Claude Opus 5 for @DavidCockerill.
Review feedback on #1613: the comment justified keying on status alone by claiming a duplicate email is the only conflict `POST /User/` can report. It isn't — `searchByValue` throws CONFLICT `Multiple <email> records found` when the address resolves to more than one row, which is reachable because account deletion is a soft `status: DELETED` patch while `addUser` only conflicts on ACTIVE/CLOUD_MIGRATED, so a deleted user signing up again leaves two rows on that email. The predicate is unchanged: both paths mean the address is unavailable, which is the property it actually needs. Only the reasoning was wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — the analysis is right, and I'd reached the same conclusion about the oracle independently while investigating #1612 (Dawson asked the same question in parallel). I can't answer whether it's deliberate — that's a call for whoever owns the auth policy, and I found nothing in either repo asserting it either way, which is your point. What I can add is four pieces of evidence, one of which corrects something I'd said earlier. 1. There is nothing to rate-limit with today. I grepped both repos for rate limiting, throttling, captcha, Turnstile and WAF config: nothing. The only gate on 2. No browser-based scanning is happening now. RUM, 30 days: the maximum 3. You're right about the mail volume, and it inverts the fix I'd recommended. I'd suggested always-202 as the fix. That's wrong on this endpoint for exactly the reason you give: today a probe against a registered address throws before anything is created and sends no mail, so always-202-plus-notification makes total mail strictly worse, and it doesn't touch the unregistered path that's already creating an account and mailing per probe. Uniformity fixes the oracle and nothing else; rate limiting fixes the abuse. Sequencing rate-limit-first is right. 4. One thing worth adding to the risk side, because it's not enumeration. PENDING returning 201 isn't only indistinguishable — it's reachable. On the one-way door. Fair, and I'd rather not be the reason it closes. Two options, both fine by me: hold the message and land only the 🤖 Posted by Claude Code |
Narrows this change to the two things that don't depend on how central-manager answers a duplicate email, since the 409 is being removed from it shortly. Baking "409 means the address is taken" into the client would have been dead code — and, per review on #1613, would have made closing the signup enumeration oracle a UX argument instead of a quiet server edit. What's left is status-agnostic. RUM showed people resubmitting the same details two and three times before giving up (#1612), and both causes survive whatever replaces the 409: - The submit button had no `isPending` guard — unlike sign-in, forgot-password, and reset-password, which all disable on it — so there was no sign a request was in flight and nothing to stop a second click. - The failure only appeared in the shared mutation toast, away from the inputs and gone after a few seconds. It now renders in the form, above the submit button, and stays until the next attempt. The message is whatever the server said, extracted by a `describeError` split out of `errorHandler` so the inline text and the toast text can't drift. No status codes are mapped here; a shape this form has never seen still reaches the user. Removes `isEmailAlreadyRegisteredError` and its test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Scope reduced in 35bef39 per @dawsontoth — the 409 mapping is gone, since central-manager is dropping that status shortly and you're right that shipping the message first makes closing the oracle a UX argument instead of a server edit. What remains is status-agnostic: The inline text comes from a 🤖 Posted by Claude Code |
DavidCockerill
left a comment
There was a problem hiding this comment.
Approving — and thanks for taking the scope cut rather than arguing the point. Clearing my earlier COMMENT.
The disclosure is genuinely gone, and I checked rather than taking the reply for it: grepping the whole diff for 409|already|exists|registered|enumerat returns exactly two lines, neither a code path — a test fixture whose purpose is proving no status is special-cased, and one stale doc sentence (thread below). No status comparison, no message match, anywhere in SignUp.tsx, useSignUp.ts or the new queryClient.ts code. isEmailAlreadyRegisteredError is absent from both the diff and origin/stage.
Worth stating the nuance plainly, because it's what makes this the right shape rather than a cosmetic cut: CM still sends the literal string User already exists, and Studio renders it verbatim — so that text still reaches the browser today. But Studio no longer decides anything, which means central-manager#630 can close the oracle as a pure server edit with zero Studio work. At the previous head it would have needed a Studio revert. That's the whole thing the question was protecting.
And the half that actually fixes #1612 is the half that stayed — though I'd gently push back on the reasoning in the body. isPending isn't the fix: the RUM retries are ~5s apart and the server answers in 160ms–1.3s, so the button had long since re-enabled and never had a chance to block them. What makes people re-click is a toast appearing away from the inputs and then vanishing. The persistent in-form message above the submit button is what addresses that, and it's still here.
I paid particular attention to describeError being split out of errorHandler, since that's the same file #1598 landed the RFC 9457 handling in and a quiet behaviour change there would regress work nobody's looking at. It's clean, provably so in two independent ways: both hunks land on the ends of the function, so the problem-details block (data.title → description, data.code → title, errorText(data.detail) appended, splitTitleFromMsg = false) is untouched context between them — and queryClient.test.ts is unmodified, with all 16 cases including #1598's four still green through the delegation. Only behavioural move is console.error relocating into errorHandler.
No double-report and no silent drop: the sign-up path sets skipGlobalErrorToast, mutationErrorHandler returns early, the inline message renders — and the first test asserts toast.error was not called against the real handler, imported rather than restated. Your stated reason for declining Gemini's version survived your own rebuild, which is the detail I liked most here.
Three optional notes in threads, none blocking.
Separately, and not an ask on this PR: I confirmed the soft-delete duplicate-row path you flagged, and it's worse than duplicate rows. searchByValue then throws on both Login (Login.js:67) and ForgotPassword (Password.js:37), so the second signup succeeds and the account can then neither sign in nor reset its password — reachable by any user who deletes their account and comes back. Not tracked anywhere; #596/#603's §4d would prevent it going forward as a side effect but wouldn't repair rows already duplicated in prod. Worth its own issue.
Reviewed by Claude Opus 5 for @DavidCockerill.
Review feedback on #1613, all three about the seam between the shared extractor and what each caller renders. `describeError` splits a legacy `"Conflict: user already exists"` body into title/description for the toast's heading + body. The inline error has no heading, so building its line from `description` alone dropped the first clause and kept the split's leading space — the user read " user already exists". Return the pre-split text as `message` and render that; the toast still uses title/description, so the two remain one extractor. Covered by a case that fails with `description` (asserting the exact ' user already exists' regression). Also corrects the `mutationErrorHandler` doc, which still described sign-up as detecting an already-registered email and setting a field error — it maps no statuses now and sets `root`, above the submit button. That comment was the last non-test line naming the 409 semantics, so a future grep would have found the removed design. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #1612.
What RUM showed
POST /User/, prod, last 24h: 21 × 201, 20 × 409 across 8 sessions — and 7 of those 8 sessions never created an account. The previous 24h had 22 submissions and zero 409s. The rejections arrive in runs of two or three per session, ~5 s apart;useMutationdoesn't retry, so those are people clicking Sign Up For Free again.Scope
Deliberately not handling the 409 specifically. It's being removed from central-manager shortly, so mapping it here would be dead code — and per @DavidCockerill's review, encoding it in the UI would turn closing the signup enumeration oracle into "you're removing a helpful message" rather than a quiet server-side edit.
What's left are the two causes of the retries, both independent of how the server answers a duplicate email:
isPendingguard — unlikeSignIn,ForgotPassword, andResetPassword, which all disable on it. No in-flight feedback, nothing to stop a second click.The inline message is whatever the server said.
describeErroris split out oferrorHandlerso the inline text and the toast text come from one extractor and can't drift; it returns the pre-split sentence asmessagefor the inline line (which has no heading to move a"Conflict: …"first clause into) alongside the toast'stitle/description.errorHandlerkeeps its existing behavior, including the timeout/403 toast-collapse ids.The form maps no status codes, so whatever error status replaces the 409 reaches the user unchanged. One caveat, per review: that doesn't extend to a bodyless 2xx.
onSignUpSubmit'sif (data) … else throw new Error('Something went wrong')would turn a202with an empty body into a client-manufactured error shown inline, instead of navigating to/verifying. Unreachable against today's endpoint, which always returns a body; tracked as HarperFast/central-manager#636 against #630.Verification
npx vitest run— 282 files, 2167 pass, 11 skipped. The 6 reported errors are pre-existing environment noise (undici WebSocketEvent, jsdomscrollTo/navigation), unrelated to these files.SignUp.test.tsxrenders the real form against aQueryClientbuilt from the app's ownmutationErrorHandler, so theskipGlobalErrorToastwiring is under test rather than restated: the server's reason appears inline and not as a toast; a 400 object body, a 409 bare string, and a bodyless 503 each surface inline (proving no status is special-cased); the message clears on resubmit; the button disables while in flight. Each of the 6 was confirmed to fail with the corresponding change reverted.npx tsc -b,npx oxlint,npx dprint checkclean.FormMessage's exact classes (text-destructive text-sm), and behavior is covered by the DOM-level tests above.Not covered: the Google/GitHub sign-up buttons, which redirect and never reach this mutation.
🤖 Generated with Claude Code