diff --git a/src/CONST.ts b/src/CONST.ts index 08c6a2509207..6dcf165c4ec7 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -757,6 +757,9 @@ const CONST = { // 6 numeric digits VALIDATE_CODE_REGEX_STRING: /^\d{6}$/, + // 8 alphanumeric characters + RECOVERY_CODE_REGEX_STRING: /^[a-zA-Z0-9]{8}$/, + // The server has a WAF (Web Application Firewall) which will strip out HTML/XML tags using this regex pattern. // It's copied here so that the same regex pattern can be used in form validations to be consistent with the server. VALIDATE_FOR_HTML_TAG_REGEX: /<([^>\s]+)(?:[^>]*?)>/g, @@ -806,6 +809,8 @@ const CONST = { MAGIC_CODE_LENGTH: 6, MAGIC_CODE_EMPTY_CHAR: ' ', + RECOVERY_CODE_LENGTH: 8, + KEYBOARD_TYPE: { PHONE_PAD: 'phone-pad', NUMBER_PAD: 'number-pad', diff --git a/src/languages/en.ts b/src/languages/en.ts index b0f5801a30c3..210d82b28a7d 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -740,6 +740,15 @@ export default { copy: 'Copy', disable: 'Disable', }, + recoveryCodeForm: { + error: { + pleaseFillRecoveryCode: 'Please enter your recovery code', + incorrectRecoveryCode: 'Incorrect recovery code. Please try again.', + }, + useRecoveryCode: 'Use recovery code', + recoveryCode: 'Recovery code', + use2fa: 'Use two-factor authentication code', + }, twoFactorAuthForm: { error: { pleaseFillTwoFactorAuth: 'Please enter your two-factor authentication code', @@ -893,6 +902,7 @@ export default { validateCodeForm: { magicCodeNotReceived: "Didn't receive a magic code?", enterAuthenticatorCode: 'Please enter your authenticator code', + enterRecoveryCode: 'Please enter your recovery code', requiredWhen2FAEnabled: 'Required when 2FA is enabled', requestNewCode: 'Request a new code in ', requestNewCodeAfterErrorOccurred: 'Request a new code', diff --git a/src/languages/es.ts b/src/languages/es.ts index 1984624e8683..0048cfbb9e23 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -735,6 +735,15 @@ export default { copy: 'Copiar', disable: 'Deshabilitar', }, + recoveryCodeForm: { + error: { + pleaseFillRecoveryCode: 'Por favor, introduce tu código de recuperación', + incorrectRecoveryCode: 'Código de recuperación incorrecto. Por favor, inténtalo de nuevo', + }, + useRecoveryCode: 'Usar código de recuperación', + recoveryCode: 'Código de recuperación', + use2fa: 'Usar autenticación de dos factores', + }, twoFactorAuthForm: { error: { pleaseFillTwoFactorAuth: 'Por favor, introduce tu código de autenticación de dos factores', @@ -889,6 +898,7 @@ export default { validateCodeForm: { magicCodeNotReceived: '¿No recibiste un código mágico?', enterAuthenticatorCode: 'Por favor, introduce el código de autenticador', + enterRecoveryCode: 'Por favor, introduce tu código de recuperación', requiredWhen2FAEnabled: 'Obligatorio cuando A2F está habilitado', requestNewCode: 'Pedir un código nuevo en ', requestNewCodeAfterErrorOccurred: 'Solicitar un nuevo código', diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js index 7aded82fb0a9..a85a623bd3ec 100644 --- a/src/libs/ValidationUtils.js +++ b/src/libs/ValidationUtils.js @@ -314,6 +314,10 @@ function isValidValidateCode(validateCode) { return validateCode.match(CONST.VALIDATE_CODE_REGEX_STRING); } +function isValidRecoveryCode(recoveryCode) { + return recoveryCode.match(CONST.RECOVERY_CODE_REGEX_STRING); +} + /** * @param {String} code * @returns {Boolean} @@ -484,4 +488,5 @@ export { doesContainReservedWord, isNumeric, isValidAccountRoute, + isValidRecoveryCode, }; diff --git a/src/pages/signin/SignInPage.js b/src/pages/signin/SignInPage.js index 3e27e6cd253a..290c528672d2 100644 --- a/src/pages/signin/SignInPage.js +++ b/src/pages/signin/SignInPage.js @@ -1,4 +1,4 @@ -import React, {useEffect, useRef} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import PropTypes from 'prop-types'; import _ from 'underscore'; import {withOnyx} from 'react-native-onyx'; @@ -87,6 +87,9 @@ function SignInPage({credentials, account, isInModal}) { const shouldShowSmallScreen = isSmallScreenWidth || isInModal; const safeAreaInsets = useSafeAreaInsets(); const signInPageLayoutRef = useRef(); + /** This state is needed to keep track of if user is using recovery code instead of 2fa code, + * and we need it here since welcome text(`welcomeText`) also depends on it */ + const [isUsingRecoveryCode, setIsUsingRecoveryCode] = useState(false); useEffect(() => Performance.measureTTI(), []); useEffect(() => { @@ -114,7 +117,7 @@ function SignInPage({credentials, account, isInModal}) { if (account.requiresTwoFactorAuth) { // We will only know this after a user signs in successfully, without their 2FA code welcomeHeader = isSmallScreenWidth ? '' : translate('welcomeText.welcomeBack'); - welcomeText = translate('validateCodeForm.enterAuthenticatorCode'); + welcomeText = isUsingRecoveryCode ? translate('validateCodeForm.enterRecoveryCode') : translate('validateCodeForm.enterAuthenticatorCode'); } else { const userLogin = Str.removeSMSDomain(credentials.login || ''); @@ -162,7 +165,12 @@ function SignInPage({credentials, account, isInModal}) { blurOnSubmit={account.validated === false} scrollPageToTop={signInPageLayoutRef.current && signInPageLayoutRef.current.scrollPageToTop} /> - {shouldShowValidateCodeForm && } + {shouldShowValidateCodeForm && ( + + )} {shouldShowUnlinkLoginForm && } {shouldShowEmailDeliveryFailurePage && } diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js index 51cb287f9564..7815976609c5 100755 --- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js +++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js @@ -26,6 +26,7 @@ import Terms from '../Terms'; import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback'; import usePrevious from '../../../hooks/usePrevious'; import * as StyleUtils from '../../../styles/StyleUtils'; +import TextInput from '../../../components/TextInput'; const propTypes = { /* Onyx Props */ @@ -60,6 +61,12 @@ const propTypes = { /** Specifies autocomplete hints for the system, so it can provide autofill */ autoComplete: PropTypes.oneOf(['sms-otp', 'one-time-code']).isRequired, + /** Determines if user is switched to using recovery code instead of 2fa code */ + isUsingRecoveryCode: PropTypes.bool.isRequired, + + /** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */ + setIsUsingRecoveryCode: PropTypes.func.isRequired, + ...withLocalizePropTypes, }; @@ -77,6 +84,7 @@ function BaseValidateCodeForm(props) { const [validateCode, setValidateCode] = useState(props.credentials.validateCode || ''); const [twoFactorAuthCode, setTwoFactorAuthCode] = useState(''); const [timeRemaining, setTimeRemaining] = useState(30); + const [recoveryCode, setRecoveryCode] = useState(''); const prevRequiresTwoFactorAuth = usePrevious(props.account.requiresTwoFactorAuth); const prevValidateCode = usePrevious(props.credentials.validateCode); @@ -149,7 +157,17 @@ function BaseValidateCodeForm(props) { * @param {String} key */ const onTextInput = (text, key) => { - const setInput = key === 'validateCode' ? setValidateCode : setTwoFactorAuthCode; + let setInput; + if (key === 'validateCode') { + setInput = setValidateCode; + } + if (key === 'twoFactorAuthCode') { + setInput = setTwoFactorAuthCode; + } + if (key === 'recoveryCode') { + setInput = setRecoveryCode; + } + setInput(text); setFormError((prevError) => ({...prevError, [key]: ''})); @@ -174,6 +192,8 @@ function BaseValidateCodeForm(props) { setTwoFactorAuthCode(''); setFormError({}); setValidateCode(''); + props.setIsUsingRecoveryCode(false); + setRecoveryCode(''); }; /** @@ -184,11 +204,30 @@ function BaseValidateCodeForm(props) { Session.clearSignInData(); }; + /** + * Switches between 2fa and recovery code, clears inputs and errors + */ + const switchBetween2faAndRecoveryCode = () => { + props.setIsUsingRecoveryCode(!props.isUsingRecoveryCode); + + setRecoveryCode(''); + setTwoFactorAuthCode(''); + + setFormError((prevError) => ({...prevError, recoveryCode: '', twoFactorAuthCode: ''})); + + if (props.account.errors) { + Session.clearAccountMessages(); + } + }; + useEffect(() => { if (!isLoadingResendValidationForm) { return; } clearLocalSignInData(); + // `clearLocalSignInData` is not required as a dependency, and adding it + // overcomplicates things requiring clearLocalSignInData function to use useCallback + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoadingResendValidationForm]); /** @@ -203,13 +242,27 @@ function BaseValidateCodeForm(props) { if (input2FARef.current) { input2FARef.current.blur(); } - if (!twoFactorAuthCode.trim()) { - setFormError({twoFactorAuthCode: 'validateCodeForm.error.pleaseFillTwoFactorAuth'}); - return; - } - if (!ValidationUtils.isValidTwoFactorCode(twoFactorAuthCode)) { - setFormError({twoFactorAuthCode: 'passwordForm.error.incorrect2fa'}); - return; + /** + * User could be using either recovery code or 2fa code + */ + if (!props.isUsingRecoveryCode) { + if (!twoFactorAuthCode.trim()) { + setFormError({twoFactorAuthCode: 'validateCodeForm.error.pleaseFillTwoFactorAuth'}); + return; + } + if (!ValidationUtils.isValidTwoFactorCode(twoFactorAuthCode)) { + setFormError({twoFactorAuthCode: 'passwordForm.error.incorrect2fa'}); + return; + } + } else { + if (!recoveryCode.trim()) { + setFormError({recoveryCode: 'recoveryCodeForm.error.pleaseFillRecoveryCode'}); + return; + } + if (!ValidationUtils.isValidRecoveryCode(recoveryCode)) { + setFormError({recoveryCode: 'recoveryCodeForm.error.incorrectRecoveryCode'}); + return; + } } } else { if (inputValidateCodeRef.current) { @@ -226,33 +279,61 @@ function BaseValidateCodeForm(props) { } setFormError({}); + const recoveryCodeOr2faCode = props.isUsingRecoveryCode ? recoveryCode : twoFactorAuthCode; + const accountID = lodashGet(props.credentials, 'accountID'); if (accountID) { - Session.signInWithValidateCode(accountID, validateCode, props.preferredLocale, twoFactorAuthCode); + Session.signInWithValidateCode(accountID, validateCode, props.preferredLocale, recoveryCodeOr2faCode); } else { - Session.signIn(validateCode, twoFactorAuthCode, props.preferredLocale); + Session.signIn(validateCode, recoveryCodeOr2faCode, props.preferredLocale); } - }, [props.account, props.credentials, props.preferredLocale, twoFactorAuthCode, validateCode]); + }, [props.account, props.credentials, props.preferredLocale, twoFactorAuthCode, validateCode, props.isUsingRecoveryCode, recoveryCode]); return ( <> {/* At this point, if we know the account requires 2FA we already successfully authenticated */} {props.account.requiresTwoFactorAuth ? ( - onTextInput(text, 'twoFactorAuthCode')} - onFulfill={validateAndSubmitForm} - maxLength={CONST.TFA_CODE_LENGTH} - errorText={formError.twoFactorAuthCode ? props.translate(formError.twoFactorAuthCode) : ''} - hasError={hasError} - autoFocus - /> + {props.isUsingRecoveryCode ? ( + onTextInput(text, 'recoveryCode')} + maxLength={CONST.RECOVERY_CODE_LENGTH} + label={props.translate('recoveryCodeForm.recoveryCode')} + errorText={formError.recoveryCode ? props.translate(formError.recoveryCode) : ''} + hasError={hasError} + autoFocus + /> + ) : ( + onTextInput(text, 'twoFactorAuthCode')} + onFulfill={validateAndSubmitForm} + maxLength={CONST.TFA_CODE_LENGTH} + errorText={formError.twoFactorAuthCode ? props.translate(formError.twoFactorAuthCode) : ''} + hasError={hasError} + autoFocus + /> + )} {hasError && } + + {props.isUsingRecoveryCode ? props.translate('recoveryCodeForm.use2fa') : props.translate('recoveryCodeForm.useRecoveryCode')} + ) : ( diff --git a/src/pages/signin/ValidateCodeForm/index.android.js b/src/pages/signin/ValidateCodeForm/index.android.js index 7ff81357725d..1e888d24bc60 100644 --- a/src/pages/signin/ValidateCodeForm/index.android.js +++ b/src/pages/signin/ValidateCodeForm/index.android.js @@ -1,11 +1,24 @@ import React from 'react'; +import PropTypes from 'prop-types'; import BaseValidateCodeForm from './BaseValidateCodeForm'; const defaultProps = {}; -const propTypes = {}; -function ValidateCodeForm() { - return ; +const propTypes = { + /** Determines if user is switched to using recovery code instead of 2fa code */ + isUsingRecoveryCode: PropTypes.bool.isRequired, + + /** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */ + setIsUsingRecoveryCode: PropTypes.func.isRequired, +}; +function ValidateCodeForm(props) { + return ( + + ); } ValidateCodeForm.displayName = 'ValidateCodeForm'; diff --git a/src/pages/signin/ValidateCodeForm/index.js b/src/pages/signin/ValidateCodeForm/index.js index 6b01c7d4dec2..540b6a3e3ed6 100644 --- a/src/pages/signin/ValidateCodeForm/index.js +++ b/src/pages/signin/ValidateCodeForm/index.js @@ -1,11 +1,24 @@ import React from 'react'; +import PropTypes from 'prop-types'; import BaseValidateCodeForm from './BaseValidateCodeForm'; const defaultProps = {}; -const propTypes = {}; -function ValidateCodeForm() { - return ; +const propTypes = { + /** Determines if user is switched to using recovery code instead of 2fa code */ + isUsingRecoveryCode: PropTypes.bool.isRequired, + + /** Function to change `isUsingRecoveryCode` state when user toggles between 2fa code and recovery code */ + setIsUsingRecoveryCode: PropTypes.func.isRequired, +}; +function ValidateCodeForm(props) { + return ( + + ); } ValidateCodeForm.displayName = 'ValidateCodeForm';