From 9bb70e451f6b7b10fa58c87f3f59d1d7773709c9 Mon Sep 17 00:00:00 2001 From: Vijay Budhram Date: Thu, 11 Jun 2026 12:04:53 -0400 Subject: [PATCH] fix(settings): disable confirm-code buttons while throttled --- .../src/components/FormVerifyCode/index.tsx | 5 +- .../src/lib/hooks/useThrottle/index.test.tsx | 54 +++++++++++++++++++ .../src/lib/hooks/useThrottle/index.tsx | 41 ++++++++++++++ .../SigninPasswordlessCode/index.test.tsx | 39 ++++++++++++++ .../Signin/SigninPasswordlessCode/index.tsx | 7 ++- .../Signin/SigninTokenCode/index.test.tsx | 44 +++++++++++++++ .../pages/Signin/SigninTokenCode/index.tsx | 29 ++++++---- .../Signup/ConfirmSignupCode/index.test.tsx | 23 ++++++++ .../pages/Signup/ConfirmSignupCode/index.tsx | 11 +++- 9 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 packages/fxa-settings/src/lib/hooks/useThrottle/index.test.tsx create mode 100644 packages/fxa-settings/src/lib/hooks/useThrottle/index.tsx diff --git a/packages/fxa-settings/src/components/FormVerifyCode/index.tsx b/packages/fxa-settings/src/components/FormVerifyCode/index.tsx index 2f2f339a66a..fdb4a27256a 100644 --- a/packages/fxa-settings/src/components/FormVerifyCode/index.tsx +++ b/packages/fxa-settings/src/components/FormVerifyCode/index.tsx @@ -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 = { @@ -71,6 +73,7 @@ const FormVerifyCode = ({ onEngageCb, onChangeCb, className = 'flex flex-col gap-4 my-6', + isThrottled = false, }: FormVerifyCodeProps) => { const [isFocused, setIsFocused] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); @@ -184,7 +187,7 @@ const FormVerifyCode = ({ { + 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); + }); +}); diff --git a/packages/fxa-settings/src/lib/hooks/useThrottle/index.tsx b/packages/fxa-settings/src/lib/hooks/useThrottle/index.tsx new file mode 100644 index 00000000000..26eecdb4534 --- /dev/null +++ b/packages/fxa-settings/src/lib/hooks/useThrottle/index.tsx @@ -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>(); + + 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); + }, []); + + useEffect(() => () => clearTimeout(timerRef.current), []); + + return { isThrottled, startThrottle }; +} + +export default useThrottle; diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.test.tsx index 918b2707b23..a3c4bfb280b 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.test.tsx @@ -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() @@ -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() diff --git a/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.tsx index 0c136806aef..116c4d40622 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninPasswordlessCode/index.tsx @@ -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'; @@ -107,6 +108,7 @@ const SigninPasswordlessCode = ({ const [resendCountdown, setResendCountdown] = useState( resendCountdownSeconds ); + const throttle = useThrottle(); const gleanOtp = isSignup ? GleanMetrics.passwordlessReg @@ -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) ); @@ -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 @@ -589,6 +593,7 @@ const SigninPasswordlessCode = ({ } }, className: `flex flex-col gap-4 mt-6 ${showPasskeySignin ? 'mb-2' : 'mb-6'}`, + isThrottled: throttle.isThrottled, }} /> @@ -632,7 +637,7 @@ const SigninPasswordlessCode = ({ diff --git a/packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.test.tsx b/packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.test.tsx index 22690c84542..bdd67d23bbc 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.test.tsx @@ -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()), @@ -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 diff --git a/packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.tsx b/packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.tsx index 91a708efe0c..23f60d6e2e4 100644 --- a/packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signin/SigninTokenCode/index.tsx @@ -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'; @@ -59,6 +60,7 @@ const SigninTokenCode = ({ const [codeErrorMessage, setCodeErrorMessage] = useState(''); const [resendCodeLoading, setResendCodeLoading] = useState(false); const [resendCountdown, setResendCountdown] = useState(0); + const { isThrottled, startThrottle } = useThrottle(); const webRedirectCheck = useWebRedirect(integration.data.redirectTo); const redirectTo = @@ -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 @@ -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 @@ -220,6 +227,7 @@ const SigninTokenCode = ({ verificationReason, showInlineRecoveryKeySetup, onSessionVerified, + startThrottle, ] ); @@ -289,6 +297,7 @@ const SigninTokenCode = ({ cmsButton: { color: cmsInfo?.shared.buttonColor, }, + isThrottled, }} /> @@ -313,7 +322,7 @@ const SigninTokenCode = ({ diff --git a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.test.tsx b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.test.tsx index b6d70875d52..a055eac3d67 100644 --- a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.test.tsx +++ b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.test.tsx @@ -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(); + }); }); diff --git a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx index 7bd244a3c24..b6d70129f7f 100644 --- a/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx +++ b/packages/fxa-settings/src/pages/Signup/ConfirmSignupCode/index.tsx @@ -43,6 +43,7 @@ import { import Banner, { ResendCodeSuccessBanner } from '../../../components/Banner'; import { isFirefoxService } from '../../../models/integrations/utils'; import { getSyncNavigate } from '../../Signin/utils'; +import useThrottle from '../../../lib/hooks/useThrottle'; export const viewName = 'confirm-signup-code'; @@ -76,6 +77,7 @@ const ConfirmSignupCode = ({ ); const [resendCodeLoading, setResendCodeLoading] = useState(false); const [resendCountdown, setResendCountdown] = useState(0); + const throttle = useThrottle(); const navigateWithQuery = useNavigateWithQuery(); const webRedirectCheck = useWebRedirect(integration.data.redirectTo); @@ -145,6 +147,9 @@ const ConfirmSignupCode = ({ setResendStatus(ResendStatus.sent); } catch (error) { setResendStatus(ResendStatus.error); + if (error?.errno === AuthUiErrors.THROTTLED.errno) { + throttle.startThrottle(error); + } setLocalizedErrorBannerHeading( getLocalizedErrorMessage(ftlMsgResolver, error) ); @@ -349,6 +354,9 @@ const ConfirmSignupCode = ({ ) { setCodeErrorMessage(localizedErrorMessage); } else { + if (error.errno === AuthUiErrors.THROTTLED.errno) { + throttle.startThrottle(error); + } // Clear resend link success banner (if displayed) before rendering an error banner setResendStatus(ResendStatus.none); // Any other error messages should be displayed in an error banner @@ -439,6 +447,7 @@ const ConfirmSignupCode = ({ text: cmsInfo?.SignupConfirmCodePage.primaryButtonText, color: cmsInfo?.shared.buttonColor, }, + isThrottled: throttle.isThrottled, }} /> @@ -465,7 +474,7 @@ const ConfirmSignupCode = ({ id="resend" className="link-blue" onClick={handleResendCode} - disabled={resendCodeLoading} + disabled={resendCodeLoading || throttle.isThrottled} > Email new code.