From b3dc37ff44695f1dc170378b4e1fb25ea83e5f43 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 2 Jul 2026 08:49:54 +1000 Subject: [PATCH] AP-9370 # Added resending mfa code --- CHANGELOG.md | 2 + src/apps/services/AWSCognitoClient.ts | 44 +++++++++++-- src/components/mfa/MfaPhoneNumberDialog.tsx | 41 +----------- src/hooks/useLogin.ts | 69 +++++++++++++++++++++ src/hooks/useResendCoolDown.ts | 37 +++++++++++ src/index.ts | 1 + 6 files changed, 151 insertions(+), 43 deletions(-) create mode 100644 src/hooks/useResendCoolDown.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 99f49657..24343d7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - sms MFA setup support to `useMfa()` and `` - `mfaService` for MFA setup APIs and MFA requirement helpers - export `LoadingWithMessage` component +- `useResendCoolDown()` hook +- `isResendingMfaCode` and `resendMfaCode` to result of `useLogin()` hook ### Changed diff --git a/src/apps/services/AWSCognitoClient.ts b/src/apps/services/AWSCognitoClient.ts index 0f3c3468..3f975312 100644 --- a/src/apps/services/AWSCognitoClient.ts +++ b/src/apps/services/AWSCognitoClient.ts @@ -77,12 +77,15 @@ export function resolveMfaPreferredFlags({ } } +export type LoginAttemptMfa = { + method: MfaMethod + codeCallback: (code: string) => Promise + resendCodeCallback?: () => Promise +} + export type LoginAttemptResponse = { resetPasswordCallback?: (newPassword: string) => Promise - mfa?: { - codeCallback: (code: string) => Promise - method: MfaMethod - } + mfa?: LoginAttemptMfa } export default class AWSCognitoClient { @@ -254,6 +257,7 @@ export default class AWSCognitoClient { async responseToAuthChallenge( username: string, initiateAuthResponse: InitiateAuthResponse, + password?: string, ): Promise { if (initiateAuthResponse.AuthenticationResult) { this._storeAuthenticationResult(initiateAuthResponse.AuthenticationResult) @@ -280,6 +284,7 @@ export default class AWSCognitoClient { return await this.responseToAuthChallenge( username, resetPasswordResult, + newPassword, ) }, } @@ -304,8 +309,10 @@ export default class AWSCognitoClient { return await this.responseToAuthChallenge( username, resetPasswordResult, + password, ) }, + resendCodeCallback: undefined, }, } } @@ -313,6 +320,12 @@ export default class AWSCognitoClient { throw new Error('Email OTP is not supported') } case 'SMS_MFA': { + if (!initiateAuthResponse.Session) { + throw new Error( + 'An unexpected error occurred while attempting to process your login. Please try again or contact support if the problem persists.', + ) + } + return { mfa: { method: 'sms', @@ -332,8 +345,29 @@ export default class AWSCognitoClient { return await this.responseToAuthChallenge( username, smsChallengeResult, + password, ) }, + resendCodeCallback: async () => { + if (!password) { + throw new Error( + 'Unable to resend the SMS verification code. Please try signing in again.', + ) + } + + const loginAttemptResponse = await this.loginUsernamePassword( + username, + password, + ) + + if (loginAttemptResponse.mfa?.method !== 'sms') { + throw new Error( + 'Unable to resend the SMS verification code. Please try signing in again.', + ) + } + + return loginAttemptResponse + }, }, } } @@ -363,7 +397,7 @@ export default class AWSCognitoClient { }), ) - return await this.responseToAuthChallenge(username, loginResult) + return await this.responseToAuthChallenge(username, loginResult, password) } async loginHostedUI(identityProviderName?: string): Promise { diff --git a/src/components/mfa/MfaPhoneNumberDialog.tsx b/src/components/mfa/MfaPhoneNumberDialog.tsx index 32920ebf..06c93a06 100644 --- a/src/components/mfa/MfaPhoneNumberDialog.tsx +++ b/src/components/mfa/MfaPhoneNumberDialog.tsx @@ -10,47 +10,11 @@ import { } from '@mui/material' import useBooleanState from '../../hooks/useBooleanState' import useMfa from '../../hooks/useMfa' +import useResendCoolDown from '../../hooks/useResendCoolDown' import InputField from '../InputField' const PHONE_VERIFICATION_RESEND_COOL_DOWN_SECONDS = 60 -function getResendCoolDownRemainingSeconds( - sentAt: number | undefined, - now: number, -) { - if (!sentAt) { - return 0 - } - - const elapsedSeconds = Math.max(0, Math.floor((now - sentAt) / 1000)) - return Math.max( - 0, - PHONE_VERIFICATION_RESEND_COOL_DOWN_SECONDS - elapsedSeconds, - ) -} - -function usePhoneVerificationResendCoolDown(sentAt: number | undefined) { - const [now, setNow] = React.useState(() => Date.now()) - - const remainingSeconds = getResendCoolDownRemainingSeconds(sentAt, now) - - React.useEffect(() => { - if (!sentAt) { - return - } - - setNow(Date.now()) - - const intervalId = window.setInterval(() => { - setNow(Date.now()) - }, 1000) - - return () => window.clearInterval(intervalId) - }, [sentAt]) - - return remainingSeconds -} - function MfaPhoneNumberDialog() { const { isPhoneNumberDialogOpen, @@ -65,8 +29,9 @@ function MfaPhoneNumberDialog() { const [phoneNumber, setPhoneNumber] = React.useState('') const [verificationCode, setVerificationCode] = React.useState('') - const resendCoolDownSeconds = usePhoneVerificationResendCoolDown( + const resendCoolDownSeconds = useResendCoolDown( phoneVerificationCodeSentAt, + PHONE_VERIFICATION_RESEND_COOL_DOWN_SECONDS, ) const [isSaving, startSaving, stopSaving] = useBooleanState(false) diff --git a/src/hooks/useLogin.ts b/src/hooks/useLogin.ts index dcf64284..bd6120d5 100644 --- a/src/hooks/useLogin.ts +++ b/src/hooks/useLogin.ts @@ -40,6 +40,8 @@ import useBooleanState from './useBooleanState' * mfaMethod, * isSubmittingMfaCode, * submitMfaCode, + * isResendingMfaCode, + * resendMfaCode, * // Login Errors * loginError, * clearLoginError, @@ -393,6 +395,8 @@ export default function useLogin({ loginError, loginAttemptResponse, isSubmittingMfaCode, + isResendingMfaCode, + mfaCodeSentAt, }, setLoginState, ] = React.useState<{ @@ -401,12 +405,16 @@ export default function useLogin({ loginError: null | Error loginAttemptResponse: authService.LoginAttemptResponse | undefined isSubmittingMfaCode: boolean + isResendingMfaCode: boolean + mfaCodeSentAt: number | undefined }>({ isResettingTemporaryPassword: false, isLoggingIn: false, loginError: null, loginAttemptResponse: undefined, isSubmittingMfaCode: false, + isResendingMfaCode: false, + mfaCodeSentAt: undefined, }) const clearLoginError = React.useCallback( () => @@ -448,6 +456,9 @@ export default function useLogin({ ...currentState, isLoggingIn: false, loginAttemptResponse: newLoginAttemptResponse, + mfaCodeSentAt: newLoginAttemptResponse.mfa + ? Date.now() + : undefined, })) } } catch (error) { @@ -505,6 +516,9 @@ export default function useLogin({ ...currentState, isResettingTemporaryPassword: false, loginAttemptResponse: resetPasswordResponse, + mfaCodeSentAt: resetPasswordResponse.mfa + ? Date.now() + : undefined, })) } } catch (error) { @@ -544,6 +558,9 @@ export default function useLogin({ ...currentState, isSubmittingMfaCode: false, loginAttemptResponse: mfaResponse, + mfaCodeSentAt: mfaResponse.mfa + ? currentState.mfaCodeSentAt + : undefined, })) } } catch (error) { @@ -558,6 +575,43 @@ export default function useLogin({ } }, [code, isMounted, loginAttemptResponse?.mfa?.codeCallback]) + const resendMfaCodeCallback = loginAttemptResponse?.mfa?.resendCodeCallback + + const resendMfaCode = React.useMemo(() => { + if (!resendMfaCodeCallback) { + return + } + + return async () => { + setLoginState((current) => ({ + ...current, + isResendingMfaCode: true, + loginError: null, + })) + + try { + const mfaResponse = await resendMfaCodeCallback() + if (isMounted.current) { + setLoginState((currentState) => ({ + ...currentState, + isResendingMfaCode: false, + loginAttemptResponse: mfaResponse, + mfaCodeSentAt: Date.now(), + })) + } + } catch (error) { + Sentry.captureException(error) + if (isMounted.current) { + setLoginState((current) => ({ + ...current, + isResendingMfaCode: false, + loginError: error as Error, + })) + } + } + } + }, [isMounted, resendMfaCodeCallback]) + // Forgot Password const [isShowingForgotPassword, showForgotPassword, hideForgotPassword] = useBooleanState(false) @@ -716,6 +770,9 @@ export default function useLogin({ mfaMethod: loginAttemptResponse?.mfa?.method || null, isSubmittingMfaCode, submitMfaCode, + isResendingMfaCode, + resendMfaCode, + mfaCodeSentAt, // Showing Forgot Password isShowingForgotPassword, showForgotPassword, @@ -853,4 +910,16 @@ export interface UseLoginValue { * Will call `onLogin()` if successful, otherwise will set `loginError`. */ submitMfaCode: () => void + /** `true` while processing `resendMfaCode()`, when available. */ + isResendingMfaCode: boolean + /** + * Request a new one-time verification code during sign-in. Defined when the + * active MFA step supports resending a code (e.g. SMS). + */ + resendMfaCode?: () => void + /** + * Timestamp when the current MFA code was sent. Use with `useResendCoolDown()` + * to enforce a resend cooldown. + */ + mfaCodeSentAt?: number } diff --git a/src/hooks/useResendCoolDown.ts b/src/hooks/useResendCoolDown.ts new file mode 100644 index 00000000..970aa2f3 --- /dev/null +++ b/src/hooks/useResendCoolDown.ts @@ -0,0 +1,37 @@ +import * as React from 'react' + +/** + * Returns the number of seconds remaining before a resend action can be + * performed again. + */ +export default function useResendCoolDown( + sentAt: number | undefined, + coolDownSeconds: number, +) { + const [now, setNow] = React.useState(() => Date.now()) + + const remainingSeconds = React.useMemo(() => { + if (!sentAt) { + return 0 + } + + const elapsedSeconds = Math.max(0, Math.floor((now - sentAt) / 1000)) + return Math.max(0, coolDownSeconds - elapsedSeconds) + }, [coolDownSeconds, now, sentAt]) + + React.useEffect(() => { + if (!sentAt) { + return + } + + setNow(Date.now()) + + const intervalId = window.setInterval(() => { + setNow(Date.now()) + }, 1000) + + return () => window.clearInterval(intervalId) + }, [sentAt]) + + return remainingSeconds +} diff --git a/src/index.ts b/src/index.ts index 29f96038..0897f1c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ export { default as OneBlinkFormStoreRefreshButton } from './components/formStor export { default as OneBlinkFormStoreTable } from './components/formStore/OneBlinkFormStoreTable' export { default as useIsMounted } from './hooks/useIsMounted' +export { default as useResendCoolDown } from './hooks/useResendCoolDown' export { default as useBooleanState } from './hooks/useBooleanState' export { default as useNullableState } from './hooks/useNullableState' export { default as useClickOutsideElement } from './hooks/useClickOutsideElement'