fix: unbreak the invitation flow and email-shaped-username login - #6927
Merged
Conversation
Code Review CompleteThe automated review ran but did not post an updated summary — this usually means no new issues were found since the previous review. If you've pushed changes and want a fresh pass, comment |
Chrome's auto-translation moves each text node into a <font> wrapper. The text node survives, so React keeps writing to it, but it is no longer a child of the element React recorded as its parent, and the next insertBefore or removeChild against that parent throws NotFoundError. The top-level ErrorBoundary then replaces the page. A customer hit this on /accept-invite and could not accept her workspace invitation. The website app gains vite-env.d.ts because it was missing the standard Vite type reference, so tsc could not resolve the ?raw import the new test needs.
React materialises a text node as a fiber only when text and elements are siblings. Such a node is fatal under a translated DOM whenever React must insert a sibling before it or remove it, because translation has moved it inside a <font> wrapper. On AcceptInvite this crashed for real: advancing from the sign-up branch to the joined branch removes the description's bare text nodes. Login's countdown only ever appends, so it never crashed, but the shape is one reorder away from doing so. Wrapping each bare text in a span gives React an element anchor, which translation leaves in place. The translate="no" opt-out already covers Chrome; this covers anything else that rewrites the DOM, such as an extension or an in-page translator.
Usernames may legally contain "@" -- the username rule in pkg/validator has always allowed it -- so an email-shaped identifier is not necessarily an email. Resolving such an identifier only by email locked those accounts out of their own username behind a permanent 401. In production 149 of 6914 users have "@" in the username. The lookup now tries the email column first, which keeps the common case unchanged, then falls back to the username column. Lockout accounting is untouched: it keys on user.ID and runs after resolution. The extra query happens only on the path that already returned 401 immediately, so it opens no new timing channel. The rule lives in store.UserResolveByAuthIdentifier because cloud repeats it in three more flows.
otavio
force-pushed
the
fix/invite-translation-and-login-identifier
branch
from
August 19, 2026 20:26
c38f23c to
a725866
Compare
otavio
enabled auto-merge (rebase)
August 19, 2026 20:27
otavio
disabled auto-merge
August 19, 2026 20:27
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.
What
Fixes the three defects behind a customer being unable to accept a workspace invitation, and unblocks the 149 production accounts whose username is an email address.
Why
The customer's browser showed the top-level
ErrorBoundaryon/accept-invite:The backend was healthy — the user was confirmed, the invitation pending and unexpired, the cache entry present. The failure was in the browser, and separately in login identifier resolution.
Changes
ui, translation opt-out:
translate="no"plus<meta name="google" content="notranslate">on the console, its migrate entry, and the website. Chrome's auto-translation moves each text node into a<font>wrapper; the node survives, so React keeps writing to it, but it is no longer a child of the parent React recorded. The nextinsertBeforeorremoveChildthrowsNotFoundError. Nothing inui/used the HTMLtranslateattribute, so the opt-out breaks no intentional per-element usage.ui, bare text nodes: wrapped the bare text in the
AcceptInviteandLoginalert subtrees in<span>. React materialises a text node as a fiber only when text and elements are siblings; such a node is fatal under a translated DOM whenever React must insert a sibling before it or remove it. An element anchor survives translation.server, identifier resolution:
AuthLocalUsernow tries the email column first and falls back to the username column. The username rule inpkg/validatorhas always permitted@(^([a-z0-9-_.@]){3,32}$), so an email-shaped identifier is not necessarily an email. Resolving only by email returned a permanent 401. The rule is extracted tostore.UserResolveByAuthIdentifierbecause cloud repeats it in three recovery flows.website: added the missing
src/vite-env.d.tssotsc -bresolves the?rawimport the new test needs.Testing
simulateBrowserTranslation()(src/tests/simulateBrowserTranslation.ts) reproduces Chrome's rewrite in jsdom. TheAcceptInvitetest fails on the unpatched page withNotFoundError: The node to be removed is not a child of this node.Worth knowing while reviewing:
Login.tsxnever crashed. Its conditional is the last child, so React appends, which is safe. It is hardened, not fixed — no test can go red for it.translate="no"already protects all of them in Chrome. They remain exposed to anything else that rewrites the DOM. Not fixed here to keep the blast radius small.The store integration tests (
api/store/pg,TestEnrollmentE2E_*) need testcontainers and were not run.