Tell the user when registration is being rate limited (#34519)
* Tell the user when registration is being rate limited * Disable the submit button while registration is rate limited The warning now sits with the button it is disabling, below the server picker, rather than above it, so it reads as the reason you cannot continue. The rate limit message moves out of errorText into its own state field to get there, which also leaves the position of every other registration error alone. When the server tells us how long to wait, we ask again ourselves once that time is up, so the form comes back without the user having to guess when a reload is worth it. * Set the rate limit warning to medium weight Scoped to the registration rate limit warning so the other auth errors keep the bold they have always had.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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<IProps, IState> {
|
||||
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<typeof setTimeout>;
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
@@ -164,6 +170,7 @@ export default class Registration extends React.Component<IProps, IState> {
|
||||
|
||||
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<IProps, IState> {
|
||||
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<IProps, IState> {
|
||||
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<IProps, IState> {
|
||||
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 (
|
||||
<Fragment>
|
||||
<div className="mx_Login_error mx_Registration_rateLimitError">{this.state.rateLimitError}</div>
|
||||
<Button className="mx_Login_fullWidthButton" kind="primary" size="md" disabled={true}>
|
||||
{_t("action|continue")}
|
||||
</Button>
|
||||
</Fragment>
|
||||
);
|
||||
} else if (!this.state.matrixClient && !this.state.busy) {
|
||||
return null;
|
||||
} else if (this.state.busy || !this.state.flows) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user