The registration form doesn't render a username pinned by the invite,
and shows a pinned email address as text, so errors the server reports
on either field weren't shown at all: the form just came back. The
invitee never chose these values, so "This username is already taken"
would mean nothing to them either. Each pinned field with an error now
gets its own message above the form, saying the invite can't be used
and to let whoever sent it know: a policy which refuses the address or
the username would refuse a new invite just the same.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
With password registration disabled, opening an invite link whose token
is unknown, revoked, expired or used redirected to the upstream
provider or to `/login`, so the invitee never learned that the link was
the problem. The page now stays, with the "invite invalid" alert above
the provider buttons and the sign-in link. A valid token which isn't
passwordless can't register there, so it still redirects.
Posting a token which has become invalid re-renders the page with only
the token's error, instead of validating the rest of the form, which
would tell whether a username exists even with registration closed. The
page submits a token only when it resolved, and the re-rendered form
drops it, so that the next submission is an ordinary registration
rather than the same error.
A blank token is no token, on the link and on the form alike, and
spaces around one are ignored.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
`/register?token=…` now resolves the invite code server-side and hands what it
was issued for to the form island through a `data-invite` attribute: a pinned
email address is shown as the line announcing where the verification code goes
rather than as an input, a pinned username isn't shown at all, and a
`passwordless` code replaces the password fields with a notice and relabels the
submit button. A code the server refused only reports that, as an error above
the form.
Because a passwordless code is a way to register on its own, the shortcuts
which send the page to the upstream provider or to `/login` when password
registration is disabled are skipped when the link carries one.
The code itself travels back on submission through a hidden field, so a form
which comes back with errors keeps its shape, and so does the redirect the page
sends the user to when the CSRF token has expired.
The registration form can now carry an invite code, in a hidden `token` field,
and the code is resolved before anything else, so that:
- an unknown, revoked, expired or exhausted code is rejected with an error on
the field, and re-rendering the form keeps the code
- a code which pins a username or an email address only lets that identity
register, an empty field meaning the user accepted what the code pins
- a `passwordless` code skips the password entirely and requires an email
address to verify, even when the server otherwise doesn't, and is a way to
register on its own when password registration is disabled
The code is also consumed whenever one was used, not only when the server
requires one.
Once the username input settles, the field queries `usernameAvailable`
and reports next to it whether the name is available, taken or rejected
by the registration policy. In the two-step layout, continuing is held
back while a settled verdict says the name can't be used. The island now
declares the GraphQL endpoint on its mount node and brings its own query
client.
Exposes a new query telling whether a given username is available for
registration, and if not, why: already taken, or rejected by the
registration policy. A username reserved on the homeserver side is
deliberately reported as taken, mirroring the ambiguity of the
registration form, which doesn't tell the two apart either.
Because the query reveals whether a username exists, it is rate-limited
per requester, configurable through the new
`rate_limiting.username_availability` option.
The server-rendered registration form is replaced by a React island mounted
on `/register`, which renders the CAPTCHA in-page, gives live password
complexity feedback, and lays out the upstream provider buttons and the
sign-in link the way `/login` does. The island owns the whole form, so every
variant of the page is rendered from the mount data alone, and the user fills
it in one step. Server-side validation errors are threaded back into it
through the mount node, so a rejected submission re-renders with the errors
attached to the fields they belong to, until the user edits them.
`RegisterContext` resolves the provider names and the sign-in link server-side
so that the island only has to render them. The sign-in link is derived from
the post-auth action in the query string instead of the `PostAuthContext`
loaded from the database, which drops a round trip from both the GET and the
invalid-form re-render.
Registering now requires JavaScript, including on the variant with upstream
providers only and on the one without a CAPTCHA: the <noscript> block shows a
notice instead of a duplicate form, which could not work anyway when a CAPTCHA
is configured. While the script loads, the page shows a skeleton of the form
to avoid layout shifts, and a hidden error notice is revealed if it fails to
boot. The server-rendered CAPTCHA component and its entrypoint are removed
with their only consumer.
The class becomes `or-separator`, as the global `.separator` also matched the
footer's separator. The module is imported by `templates.css` for the SSR
`field.separator()` macro.
The helper parses the mount node's dataset, waits for the i18n setup
before the first render, and swaps the page to a server-rendered error
notice when booting fails. This fixes the account app staying on its
loading screen forever when fetching the locale data fails, and gives
upcoming server-rendered pages with React islands a common entry point.
Waiting on the setup requires `setupI18n` to return the initialisation
promise, so it now does.
The strength meter, the improvement hints and the too-weak error move to
a `PasswordComplexityFeedback` component backed by a
`usePasswordComplexity` hook, so that forms which lay out their password
fields differently can reuse them. `PasswordCreationDoubleInput` takes
the minimum complexity as a plain number instead of a `SiteConfig`
fragment; its two callers already query `siteConfig`, so they pass the
field directly.
Instead of provider-specific markup and script tags in the head, the
captcha.form() template macro now emits a placeholder element carrying
the CAPTCHA configuration in data attributes, on which a new entrypoint
mounts the React Captcha component. The widget still injects its hidden
response field inside the form, so the server-side verification flow is
unchanged.
This is the first use of inline React components to progressively
enhance server-rendered pages.
The previous commit grew a load timeout, script-tag eviction, an error
boundary and a retry button, and swapped the imperative `CaptchaHandle`
ref for an `onValidChange` callback. None of that is needed to mount a
widget on a server-rendered page, and some of it does not work: removing
the `<script>` tag does not stop a load the browser has already started,
and the timeout deletes the global onload callback, so an SDK which is
merely slow ends up calling into nothing.
Go back to exposing validity through a ref, and keep only the
per-provider placeholder heights, which are what stops the page jumping
once the widget renders. Whatever a React form needs to react to the
captcha being solved belongs with that form.
Adds a reusable Captcha React component along with the type
definitions for the supported providers (hCaptcha, Cloudflare
Turnstile, Google reCAPTCHA). The provider SDKs are lazy-loaded by the
component itself, suspending the tree while they load, and evicted on
failure so a retry can re-attempt the load. The widgets report
solved/expired state through an `onValidChange` callback.
`import.meta.glob` now generates lazy loaders for the locale JSON modules
rather than hashed asset URLs, so `Backend.read` awaits an `import()` instead
of doing a `fetch`. This puts translation loading under the `script-src` CSP
directive instead of `connect-src`, and lets the browser preload them as
modules, which the `<link rel="preload" as="fetch">` hint never really did
because of the credentials-mode mismatch.
Dynamic imports are cached for the lifetime of the page, so locale
hot-reloading now triggers a full page reload in dev instead of asking i18next
to re-read its resources.
Instead of injecting configuration through inline <script> tags setting
window globals, server-rendered templates now carry the configuration
as data attributes on the entrypoint mount node, validated client-side
with a valibot schema over element.dataset. This removes all inline
scripts from the templates, which is friendlier to CSP, and gives the
frontend runtime-validated, typed configuration.
The DOM read and the wiring now happen in the entrypoints themselves:
main.tsx parses the mount node dataset, owns the QueryClient, and
injects the basepath and GraphQL endpoint into the router and request
layer instead of those being module-scope singletons.