Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export type FormVerifyCodeProps = {
onEngageCb?: () => void;
onChangeCb?: () => void;
className?: string;
/** Disables the submit button during a server-side throttle window. */
isThrottled?: boolean;
};

type FormData = {
Expand All @@ -71,6 +73,7 @@ const FormVerifyCode = ({
onEngageCb,
onChangeCb,
className = 'flex flex-col gap-4 my-6',
isThrottled = false,
}: FormVerifyCodeProps) => {
const [isFocused, setIsFocused] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
Expand Down Expand Up @@ -184,7 +187,7 @@ const FormVerifyCode = ({
<CmsButtonWithFallback
type="submit"
className="cta-primary cta-xl"
disabled={isSubmitting || isDisabled}
disabled={isSubmitting || isDisabled || isThrottled}
data-glean-id={gleanDataAttrs?.id}
data-glean-label={gleanDataAttrs?.label}
data-glean-type={gleanDataAttrs?.type}
Expand Down
54 changes: 54 additions & 0 deletions packages/fxa-settings/src/lib/hooks/useThrottle/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { act, renderHook } from '@testing-library/react-hooks';
import useThrottle from '.';

describe('useThrottle', () => {
beforeEach(() => {
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('starts in a not-throttled state', () => {
const { result } = renderHook(() => useThrottle());
expect(result.current.isThrottled).toBe(false);
});

it('throttles for retryAfter milliseconds, then re-enables', () => {
const { result } = renderHook(() => useThrottle());

act(() => {
result.current.startThrottle({ retryAfter: 3000 });
});
expect(result.current.isThrottled).toBe(true);

act(() => {
jest.advanceTimersByTime(2999);
});
expect(result.current.isThrottled).toBe(true);

act(() => {
jest.advanceTimersByTime(1);
});
expect(result.current.isThrottled).toBe(false);
});

it('does not throttle when retryAfter is missing or non-positive', () => {
const { result } = renderHook(() => useThrottle());

act(() => {
result.current.startThrottle({});
});
expect(result.current.isThrottled).toBe(false);

act(() => {
result.current.startThrottle({ retryAfter: 0 });
});
expect(result.current.isThrottled).toBe(false);
});
});
41 changes: 41 additions & 0 deletions packages/fxa-settings/src/lib/hooks/useThrottle/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { useCallback, useEffect, useRef, useState } from 'react';

type ThrottleErrorInput = {
retryAfter?: number;
};

type UseThrottleResult = {
isThrottled: boolean;
startThrottle: (error: ThrottleErrorInput) => void;
};

/**
* Disables retry controls for the server's retryAfter window after a THROTTLED
* (errno 114) response, then re-enables them. retryAfter is the v2 rate-limit
* value in milliseconds. A missing or invalid retryAfter is ignored: there's no
* window to wait out, and the error banner already covers it.
*/
function useThrottle(): UseThrottleResult {
const [isThrottled, setIsThrottled] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout>>();

const startThrottle = useCallback((error: ThrottleErrorInput) => {
const retryAfterMs = error.retryAfter ?? 0;
if (!Number.isFinite(retryAfterMs) || retryAfterMs <= 0) {
return;
}
clearTimeout(timerRef.current);
setIsThrottled(true);
timerRef.current = setTimeout(() => setIsThrottled(false), retryAfterMs);
}, []);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this really be empty?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea, the values inside are either passed in params or stable, so there's nothing reactive.


useEffect(() => () => clearTimeout(timerRef.current), []);

return { isThrottled, startThrottle };
}

export default useThrottle;
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,30 @@ describe('SigninPasswordlessCode page', () => {
await screen.findByText(/tried too many times/);
});

it('on throttled resend, disables the submit button', async () => {
mockAuthClient.passwordlessResendCode = jest
.fn()
.mockRejectedValue({ ...AuthUiErrors.THROTTLED, retryAfter: 60_000 });

render();

// Enter a valid code so the submit button is enabled absent throttling;
// this isolates the throttle disable from the empty-input disable.
const user = userEvent.setup();
await user.type(
screen.getByLabelText('Enter 6-digit code'),
MOCK_PASSWORDLESS_CODE
);
expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled();

fireEvent.click(screen.getByRole('button', { name: 'Email new code.' }));
await screen.findByText(/tried too many times/);

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled();
});
});

it('on other error, renders banner with expected default error message', async () => {
mockAuthClient.passwordlessResendCode = jest
.fn()
Expand Down Expand Up @@ -509,6 +533,21 @@ describe('SigninPasswordlessCode page', () => {
await screen.findByText(/tried too many times/);
});

it('on throttled submit with retryAfter, disables submit and resend', async () => {
mockAuthClient.passwordlessConfirmCode = jest
.fn()
.mockRejectedValue({ ...AuthUiErrors.THROTTLED, retryAfter: 60_000 });

render();
await submitCode();

await screen.findByText(/tried too many times/);
expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled();
expect(
screen.getByRole('button', { name: 'Email new code.' })
).toBeDisabled();
});

it('on invalid code error, renders error message in tooltip', async () => {
mockAuthClient.passwordlessConfirmCode = jest
.fn()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import { useWebRedirect } from '../../../lib/hooks/useWebRedirect';
import VerificationMethods from '../../../constants/verification-methods';
import VerificationReasons from '../../../constants/verification-reasons';
import GleanMetrics from '../../../lib/glean';
import useThrottle from '../../../lib/hooks/useThrottle';

export const viewName = 'signin-passwordless-code';

Expand Down Expand Up @@ -107,6 +108,7 @@ const SigninPasswordlessCode = ({
const [resendCountdown, setResendCountdown] = useState<number>(
resendCountdownSeconds
);
const throttle = useThrottle();

const gleanOtp = isSignup
? GleanMetrics.passwordlessReg
Expand Down Expand Up @@ -205,6 +207,7 @@ const SigninPasswordlessCode = ({
setShowResendSuccessBanner(false);
if (error.errno === AuthUiErrors.THROTTLED.errno) {
gleanOtp.error({ event: { reason: 'too many times' } });
throttle.startThrottle(error);
setLocalizedErrorBannerMessage(
getLocalizedErrorMessage(ftlMsgResolver, error)
);
Expand Down Expand Up @@ -446,6 +449,7 @@ const SigninPasswordlessCode = ({
if (error.errno === AuthUiErrors.THROTTLED.errno) {
gleanOtp.error({ event: { reason: 'too many times' } });
setShowResendSuccessBanner(false);
throttle.startThrottle(error);
setLocalizedErrorBannerMessage(localizedErrorMessage);
} else {
// Note: The backend OTP manager doesn't distinguish expired vs invalid
Expand Down Expand Up @@ -589,6 +593,7 @@ const SigninPasswordlessCode = ({
}
},
className: `flex flex-col gap-4 mt-6 ${showPasskeySignin ? 'mb-2' : 'mb-6'}`,
isThrottled: throttle.isThrottled,
}}
/>

Expand Down Expand Up @@ -632,7 +637,7 @@ const SigninPasswordlessCode = ({
<button
className="link-blue"
onClick={handleResendCode}
disabled={resendCodeLoading}
disabled={resendCodeLoading || throttle.isThrottled}
>
Email new code.
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,33 @@ describe('SigninTokenCode page', () => {
await renderAndResend();
screen.getByText('You’ve tried too many times. Please try again later.');
});

it('on throttled resend, disables the submit button', async () => {
session = {
sendVerificationCode: jest
.fn()
.mockRejectedValue({ ...AuthUiErrors.THROTTLED, retryAfter: 60_000 }),
} as unknown as Session;
render();

// Enter a valid code so the submit button is enabled absent throttling;
// this isolates the throttle disable from the empty-input disable.
const user = userEvent.setup();
await user.type(
screen.getByLabelText('Enter 6-digit code'),
MOCK_SIGNUP_CODE
);
expect(screen.getByRole('button', { name: 'Confirm' })).toBeEnabled();

fireEvent.click(screen.getByRole('button', { name: 'Email new code.' }));
await waitFor(() => {
expect(session.sendVerificationCode).toHaveBeenCalled();
});

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled();
});
});
it('on other error, renders banner with expected default error message', async () => {
session = {
sendVerificationCode: jest.fn().mockRejectedValue(new Error()),
Expand Down Expand Up @@ -288,6 +315,23 @@ describe('SigninTokenCode page', () => {
expect(GleanMetrics.loginConfirmation.submit).toHaveBeenCalledTimes(1);
expect(GleanMetrics.loginConfirmation.success).not.toHaveBeenCalled();
});

it('on throttled submit with retryAfter, disables both buttons', async () => {
session = {
verifySession: jest
.fn()
.mockRejectedValue({ ...AuthUiErrors.THROTTLED, retryAfter: 60_000 }),
} as unknown as Session;
render();
await submitCode();

await waitFor(() => {
expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled();
});
expect(
screen.getByRole('button', { name: 'Email new code.' })
).toBeDisabled();
});
it('on other error, renders expected default error message in tooltip', async () => {
session = {
verifySession: jest
Expand Down
29 changes: 19 additions & 10 deletions packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { handleNavigation } from '../utils';
import { getLocalizedErrorMessage } from '../../../lib/error-utils';
import { useWebRedirect } from '../../../lib/hooks/useWebRedirect';
import Banner, { ResendCodeSuccessBanner } from '../../../components/Banner';
import useThrottle from '../../../lib/hooks/useThrottle';

export const viewName = 'signin-token-code';

Expand Down Expand Up @@ -59,6 +60,7 @@ const SigninTokenCode = ({
const [codeErrorMessage, setCodeErrorMessage] = useState<string>('');
const [resendCodeLoading, setResendCodeLoading] = useState<boolean>(false);
const [resendCountdown, setResendCountdown] = useState<number>(0);
const { isThrottled, startThrottle } = useThrottle();

const webRedirectCheck = useWebRedirect(integration.data.redirectTo);
const redirectTo =
Expand Down Expand Up @@ -120,15 +122,19 @@ const SigninTokenCode = ({
}
} catch (error) {
setShowResendSuccessBanner(false);
// TODO in FXA-9714 - verify if we only want to display a specific message for throttled errors
setLocalizedErrorBannerMessage(
error.errno === AuthUiErrors.THROTTLED.errno
? getLocalizedErrorMessage(ftlMsgResolver, error)
: ftlMsgResolver.getMsg(
'signin-token-code-resend-error',
'Something went wrong. A new code could not be sent.'
)
);
if (error.errno === AuthUiErrors.THROTTLED.errno) {
startThrottle(error);
setLocalizedErrorBannerMessage(
getLocalizedErrorMessage(ftlMsgResolver, error)
);
} else {
setLocalizedErrorBannerMessage(
ftlMsgResolver.getMsg(
'signin-token-code-resend-error',
'Something went wrong. A new code could not be sent.'
)
);
}
} finally {
setResendCodeLoading(false);
// Start countdown
Expand Down Expand Up @@ -197,6 +203,7 @@ const SigninTokenCode = ({
);
if (error.errno === AuthUiErrors.THROTTLED.errno) {
setShowResendSuccessBanner(false);
startThrottle(error);
setLocalizedErrorBannerMessage(localizedErrorMessage);
} else {
// TODO in FXA-9714 - show in tooltip or banner? we might end up with unexpected errors shown in tooltip with current pattern
Expand All @@ -220,6 +227,7 @@ const SigninTokenCode = ({
verificationReason,
showInlineRecoveryKeySetup,
onSessionVerified,
startThrottle,
]
);

Expand Down Expand Up @@ -289,6 +297,7 @@ const SigninTokenCode = ({
cmsButton: {
color: cmsInfo?.shared.buttonColor,
},
isThrottled,
}}
/>

Expand All @@ -313,7 +322,7 @@ const SigninTokenCode = ({
<button
className="link-blue"
onClick={handleResendCode}
disabled={resendCodeLoading}
disabled={resendCodeLoading || isThrottled}
>
Email new code.
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -633,4 +633,27 @@ describe('Resending a new code from ConfirmSignupCode page', () => {

jest.useRealTimers();
});

it('disables submit and resend when resend hits THROTTLED', async () => {
session = {
sendVerificationCode: jest.fn().mockRejectedValue({
...AuthUiErrors.THROTTLED,
retryAfter: 60_000,
}),
} as unknown as Session;

renderWithSession({ session, integration: mockWebIntegration });

const resendButton = screen.getByRole('button', {
name: 'Email new code.',
});
fireEvent.click(resendButton);

await waitFor(() => {
expect(
screen.getByRole('button', { name: 'Email new code.' })
).toBeDisabled();
});
expect(screen.getByRole('button', { name: 'Confirm' })).toBeDisabled();
});
});
Loading
Loading