diff --git a/apps/web/res/css/structures/auth/_Registration.pcss b/apps/web/res/css/structures/auth/_Registration.pcss index a1833df1d0..4527e262d8 100644 --- a/apps/web/res/css/structures/auth/_Registration.pcss +++ b/apps/web/res/css/structures/auth/_Registration.pcss @@ -31,6 +31,12 @@ Please see LICENSE files in the repository root for full details. } } +/* Medium rather than the bold every other auth error uses: this one sits directly above the button it + is disabling, and does not need to shout over it. Scoped so the login errors keep their weight. */ +.mx_Login_error.mx_Registration_rateLimitError { + font-weight: var(--cpd-font-weight-medium); +} + .mx_Register_footerActions { display: flex; flex-direction: row; diff --git a/apps/web/src/components/structures/auth/Registration.test.tsx b/apps/web/src/components/structures/auth/Registration.test.tsx index 6ddd01cae7..9a3ddbae98 100644 --- a/apps/web/src/components/structures/auth/Registration.test.tsx +++ b/apps/web/src/components/structures/auth/Registration.test.tsx @@ -130,6 +130,57 @@ describe("Registration", function () { expect(ssoButton).toBeTruthy(); }); + it("should show a rate limit message when the server is rate limiting registration", async () => { + mockClient.registerRequest + .mockReset() + .mockRejectedValue(new MatrixError({ errcode: "M_LIMIT_EXCEEDED", retry_after_ms: 90_000 }, 429)); + + getComponent(); + + await expect( + screen.findAllByText("Too many attempts in a short time. Retry after 01:30."), + ).resolves.not.toHaveLength(0); + expect(await screen.findByRole("button", { name: "Continue" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("should show a rate limit message without a time when the server does not give one", async () => { + mockClient.registerRequest.mockReset().mockRejectedValue(new MatrixError({ errcode: "M_LIMIT_EXCEEDED" }, 429)); + + getComponent(); + + await expect( + screen.findAllByText("Too many attempts in a short time. Wait some time before trying again."), + ).resolves.not.toHaveLength(0); + expect(await screen.findByRole("button", { name: "Continue" })).toHaveAttribute("aria-disabled", "true"); + }); + + it("should re-enable registration once the rate limit has expired", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + mockClient.registerRequest + .mockReset() + .mockRejectedValueOnce(new MatrixError({ errcode: "M_LIMIT_EXCEEDED", retry_after_ms: 90_000 }, 429)) + .mockRejectedValue(new MatrixError({ flows: [{ stages: [] }] }, 401)); + + getComponent(); + + expect(await screen.findByRole("button", { name: "Continue" })).toHaveAttribute("aria-disabled", "true"); + + await vi.advanceTimersByTimeAsync(90_000); + + // The retry succeeds, so the warning and the disabled button give way to the real form. + await waitFor(() => + expect(screen.queryByText("Too many attempts in a short time. Retry after 01:30.")).toBeNull(), + ); + expect(await screen.findByRole("button", { name: "Register" })).not.toHaveAttribute( + "aria-disabled", + "true", + ); + } finally { + vi.useRealTimers(); + } + }); + it("should handle serverConfig updates correctly", async () => { mockClient.loginFlows.mockResolvedValue({ flows: [ diff --git a/apps/web/src/components/structures/auth/Registration.tsx b/apps/web/src/components/structures/auth/Registration.tsx index a7c4cfeed4..a7fd8ecdfc 100644 --- a/apps/web/src/components/structures/auth/Registration.tsx +++ b/apps/web/src/components/structures/auth/Registration.tsx @@ -26,6 +26,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { Button } from "@vector-im/compound-web"; import { _t } from "../../../languageHandler"; +import { formatSeconds } from "../../../DateUtils"; import { adminContactStrings, messageForResourceLimitError, resourceLimitStrings } from "../../../utils/ErrorUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import * as Lifecycle from "../../../Lifecycle"; @@ -125,12 +126,17 @@ interface IState { // the OIDC native login flow, when supported and enabled // if present, must be used for registration oauthNativeFlow?: OAuthNativeFlow; + // Set while the server is rate limiting registration. Kept apart from errorText so the warning + // can sit next to the disabled submit button rather than above the server picker. + rateLimitError?: string; } export default class Registration extends React.Component { private readonly loginLogic: Login; // `replaceClient` tracks latest serverConfig to spot when it changes under the async method which fetches flows private latestServerConfig?: ValidatedServerConfig; + // Pending re-query for after a rate limit expires, so the form comes back on its own + private rateLimitTimer?: ReturnType; public constructor(props: IProps) { super(props); @@ -164,6 +170,7 @@ export default class Registration extends React.Component { public componentWillUnmount(): void { window.removeEventListener("beforeunload", this.unloadCallback); + clearTimeout(this.rateLimitTimer); } private unloadCallback = (event: BeforeUnloadEvent): string | undefined => { @@ -187,8 +194,12 @@ export default class Registration extends React.Component { this.latestServerConfig = serverConfig; const { hsUrl, isUrl } = serverConfig; + clearTimeout(this.rateLimitTimer); + this.rateLimitTimer = undefined; + this.setState({ errorText: null, + rateLimitError: undefined, serverDeadError: null, serverErrorIsFatal: false, // busy while we do live-ness check (we need to avoid trying to render @@ -289,6 +300,27 @@ export default class Registration extends React.Component { flows: [], }); } + } else if (e instanceof MatrixError && e.httpStatus === 429) { + // The server is rate limiting us, which the generic error below does not convey. + const retryAfterMs = parseInt(e.data?.retry_after_ms, 10); + this.setState({ + rateLimitError: isNaN(retryAfterMs) + ? _t("auth|registration_rate_limited") + : _t("auth|registration_rate_limited_with_time", { + timeout: formatSeconds(retryAfterMs / 1000), + }), + // add empty flows array to get rid of spinner + flows: [], + }); + // Ask again once the wait is over so the form comes back by itself, rather than + // leaving the user to guess when it is worth reloading. + if (!isNaN(retryAfterMs)) { + clearTimeout(this.rateLimitTimer); + this.rateLimitTimer = setTimeout(() => { + this.rateLimitTimer = undefined; + this.replaceClient(this.props.serverConfig); + }, retryAfterMs); + } } else { logger.log("Unable to query for supported registration methods.", e); this.setState({ @@ -538,6 +570,18 @@ export default class Registration extends React.Component { poll={true} /> ); + } else if (this.state.rateLimitError) { + // Keep the warning with the submit button it is disabling, below the server picker, so it + // reads as "this is why you cannot continue". Both are replaced by the real form once + // replaceClient runs again after the wait. + return ( + +
{this.state.rateLimitError}
+ +
+ ); } else if (!this.state.matrixClient && !this.state.busy) { return null; } else if (this.state.busy || !this.state.flows) { diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 75af178a49..6e4b92d002 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -285,6 +285,8 @@ }, "registration_disabled": "Registration has been disabled on this homeserver.", "registration_msisdn_field_required_invalid": "Enter phone number (required on this homeserver)", + "registration_rate_limited": "Too many attempts in a short time. Wait some time before trying again.", + "registration_rate_limited_with_time": "Too many attempts in a short time. Retry after %(timeout)s.", "registration_successful": "Registration Successful", "registration_username_in_use": "Someone already has that username. Try another or if it is you, sign in below.", "registration_username_unable_check": "Unable to check if username has been taken. Try again later.",