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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<MultiFactorAuthentication />`
- `mfaService` for MFA setup APIs and MFA requirement helpers
- export `LoadingWithMessage` component
- `useResendCoolDown()` hook
- `isResendingMfaCode` and `resendMfaCode` to result of `useLogin()` hook

### Changed

Expand Down
44 changes: 39 additions & 5 deletions src/apps/services/AWSCognitoClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,15 @@ export function resolveMfaPreferredFlags({
}
}

export type LoginAttemptMfa = {
method: MfaMethod
codeCallback: (code: string) => Promise<LoginAttemptResponse>
resendCodeCallback?: () => Promise<LoginAttemptResponse>
}

export type LoginAttemptResponse = {
resetPasswordCallback?: (newPassword: string) => Promise<LoginAttemptResponse>
mfa?: {
codeCallback: (code: string) => Promise<LoginAttemptResponse>
method: MfaMethod
}
mfa?: LoginAttemptMfa
}

export default class AWSCognitoClient {
Expand Down Expand Up @@ -254,6 +257,7 @@ export default class AWSCognitoClient {
async responseToAuthChallenge(
username: string,
initiateAuthResponse: InitiateAuthResponse,
password?: string,
): Promise<LoginAttemptResponse> {
if (initiateAuthResponse.AuthenticationResult) {
this._storeAuthenticationResult(initiateAuthResponse.AuthenticationResult)
Expand All @@ -280,6 +284,7 @@ export default class AWSCognitoClient {
return await this.responseToAuthChallenge(
username,
resetPasswordResult,
newPassword,
)
},
}
Expand All @@ -304,15 +309,23 @@ export default class AWSCognitoClient {
return await this.responseToAuthChallenge(
username,
resetPasswordResult,
password,
)
},
resendCodeCallback: undefined,
},
}
}
case 'EMAIL_OTP': {
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',
Expand All @@ -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
},
},
}
}
Expand Down Expand Up @@ -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<void> {
Expand Down
41 changes: 3 additions & 38 deletions src/components/mfa/MfaPhoneNumberDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)

Expand Down
69 changes: 69 additions & 0 deletions src/hooks/useLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import useBooleanState from './useBooleanState'
* mfaMethod,
* isSubmittingMfaCode,
* submitMfaCode,
* isResendingMfaCode,
* resendMfaCode,
* // Login Errors
* loginError,
* clearLoginError,
Expand Down Expand Up @@ -393,6 +395,8 @@ export default function useLogin({
loginError,
loginAttemptResponse,
isSubmittingMfaCode,
isResendingMfaCode,
mfaCodeSentAt,
},
setLoginState,
] = React.useState<{
Expand All @@ -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(
() =>
Expand Down Expand Up @@ -448,6 +456,9 @@ export default function useLogin({
...currentState,
isLoggingIn: false,
loginAttemptResponse: newLoginAttemptResponse,
mfaCodeSentAt: newLoginAttemptResponse.mfa
? Date.now()
: undefined,
}))
}
} catch (error) {
Expand Down Expand Up @@ -505,6 +516,9 @@ export default function useLogin({
...currentState,
isResettingTemporaryPassword: false,
loginAttemptResponse: resetPasswordResponse,
mfaCodeSentAt: resetPasswordResponse.mfa
? Date.now()
: undefined,
}))
}
} catch (error) {
Expand Down Expand Up @@ -544,6 +558,9 @@ export default function useLogin({
...currentState,
isSubmittingMfaCode: false,
loginAttemptResponse: mfaResponse,
mfaCodeSentAt: mfaResponse.mfa
? currentState.mfaCodeSentAt
: undefined,
}))
}
} catch (error) {
Expand All @@ -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)
Expand Down Expand Up @@ -716,6 +770,9 @@ export default function useLogin({
mfaMethod: loginAttemptResponse?.mfa?.method || null,
isSubmittingMfaCode,
submitMfaCode,
isResendingMfaCode,
resendMfaCode,
mfaCodeSentAt,
// Showing Forgot Password
isShowingForgotPassword,
showForgotPassword,
Expand Down Expand Up @@ -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
}
37 changes: 37 additions & 0 deletions src/hooks/useResendCoolDown.ts
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading