Skip to content
Merged
1 change: 1 addition & 0 deletions src/languages/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1856,6 +1856,7 @@ const translations = {
'Ihre Xero-Buchhaltungsverbindung erfordert die Verwendung der Zwei-Faktor-Authentifizierung. Um Expensify weiterhin zu nutzen, aktivieren Sie diese bitte.',
twoFactorAuthCannotDisable: '2FA kann nicht deaktiviert werden.',
twoFactorAuthRequired: 'Die Zwei-Faktor-Authentifizierung (2FA) ist für Ihre Xero-Verbindung erforderlich und kann nicht deaktiviert werden.',
explainProcessToRemoveWithRecovery: 'Um die Zwei-Faktor-Authentifizierung (2FA) zu deaktivieren, geben Sie bitte einen gültigen Wiederherstellungscode ein.',
},
recoveryCodeForm: {
error: {
Expand Down
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1808,6 +1808,7 @@ const translations = {
whatIsTwoFactorAuth: 'Two-factor authentication (2FA) helps keep your account safe. When logging in, you’ll need to enter a code generated by your preferred authenticator app.',
disableTwoFactorAuth: 'Disable two-factor authentication',
explainProcessToRemove: 'In order to disable two-factor authentication (2FA), please enter a valid code from your authentication app.',
explainProcessToRemoveWithRecovery: 'In order to disable two-factor authentication (2FA), please enter a valid recovery code.',
disabled: 'Two-factor authentication is now disabled',
noAuthenticatorApp: 'You’ll no longer require an authenticator app to log into Expensify.',
stepCodes: 'Recovery codes',
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1799,6 +1799,7 @@ const translations = {
'La autenticación de dos factores (2FA) ayuda a mantener tu cuenta segura. Al iniciar sesión, deberás ingresar un código generado por tu aplicación de autenticación preferida.',
disableTwoFactorAuth: 'Deshabilitar la autenticación de dos factores',
explainProcessToRemove: 'Para deshabilitar la autenticación de dos factores (2FA), por favor introduce un código válido de tu aplicación de autenticación.',
explainProcessToRemoveWithRecovery: 'Para deshabilitar la autenticación en dos pasos (2FA), por favor introduce un código de recuperación válido.',
disabled: 'La autenticación de dos factores está ahora deshabilitada',
noAuthenticatorApp: 'Ya no necesitarás una aplicación de autenticación para iniciar sesión en Expensify.',
stepCodes: 'Códigos de recuperación',
Expand Down
1 change: 1 addition & 0 deletions src/languages/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1854,6 +1854,7 @@ const translations = {
"Votre connexion comptable Xero nécessite l'utilisation de l'authentification à deux facteurs. Pour continuer à utiliser Expensify, veuillez l'activer.",
twoFactorAuthCannotDisable: 'Impossible de désactiver la 2FA',
twoFactorAuthRequired: "L'authentification à deux facteurs (2FA) est requise pour votre connexion Xero et ne peut pas être désactivée.",
explainProcessToRemoveWithRecovery: "Pour désactiver l'authentification à deux facteurs (2FA), veuillez entrer un code de récupération valide.",
},
recoveryCodeForm: {
error: {
Expand Down
1 change: 1 addition & 0 deletions src/languages/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1845,6 +1845,7 @@ const translations = {
"La tua connessione contabile Xero richiede l'uso dell'autenticazione a due fattori. Per continuare a utilizzare Expensify, ti preghiamo di abilitarla.",
twoFactorAuthCannotDisable: "Impossibile disabilitare l'autenticazione a due fattori (2FA)",
twoFactorAuthRequired: "L'autenticazione a due fattori (2FA) è necessaria per la tua connessione Xero e non può essere disabilitata.",
explainProcessToRemoveWithRecovery: "Per disabilitare l'autenticazione a due fattori (2FA), inserisci un codice di recupero valido.",
},
recoveryCodeForm: {
error: {
Expand Down
1 change: 1 addition & 0 deletions src/languages/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1842,6 +1842,7 @@ const translations = {
twoFactorAuthIsRequiredForAdminsDescription: 'Xeroの会計接続には二要素認証の使用が必要です。Expensifyを引き続き使用するには、有効にしてください。',
twoFactorAuthCannotDisable: '2FAを無効にできません',
twoFactorAuthRequired: 'Xeroの接続には二要素認証(2FA)が必要であり、無効にすることはできません。',
explainProcessToRemoveWithRecovery: '二要素認証 (2FA) を無効にするには、有効なリカバリーコードを入力してください。',
},
recoveryCodeForm: {
error: {
Expand Down
1 change: 1 addition & 0 deletions src/languages/nl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1845,6 +1845,7 @@ const translations = {
twoFactorAuthIsRequiredForAdminsDescription: 'Uw Xero-boekhoudkoppeling vereist het gebruik van tweefactorauthenticatie. Om Expensify te blijven gebruiken, schakelt u dit in.',
twoFactorAuthCannotDisable: 'Kan 2FA niet uitschakelen',
twoFactorAuthRequired: 'Twee-factor authenticatie (2FA) is vereist voor uw Xero-verbinding en kan niet worden uitgeschakeld.',
explainProcessToRemoveWithRecovery: 'Om tweefactorauthenticatie (2FA) uit te schakelen, voer een geldige herstelcode in.',
},
recoveryCodeForm: {
error: {
Expand Down
1 change: 1 addition & 0 deletions src/languages/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1842,6 +1842,7 @@ const translations = {
twoFactorAuthIsRequiredForAdminsDescription: 'Twoje połączenie z Xero wymaga użycia uwierzytelniania dwuskładnikowego. Aby kontynuować korzystanie z Expensify, proszę je włączyć.',
twoFactorAuthCannotDisable: 'Nie można wyłączyć 2FA',
twoFactorAuthRequired: 'Do połączenia z Xero wymagana jest uwierzytelnianie dwuskładnikowe (2FA) i nie można go wyłączyć.',
explainProcessToRemoveWithRecovery: 'Aby wyłączyć uwierzytelnianie dwuskładnikowe (2FA), wprowadź prawidłowy kod odzyskiwania.',
},
recoveryCodeForm: {
error: {
Expand Down
1 change: 1 addition & 0 deletions src/languages/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1842,6 +1842,7 @@ const translations = {
'Sua conexão de contabilidade com a Xero requer o uso de autenticação de dois fatores. Para continuar usando o Expensify, por favor, ative-a.',
twoFactorAuthCannotDisable: 'Não é possível desativar a 2FA',
twoFactorAuthRequired: 'A autenticação de dois fatores (2FA) é necessária para sua conexão com o Xero e não pode ser desativada.',
explainProcessToRemoveWithRecovery: 'Para desativar a autenticação de dois fatores (2FA), insira um código de recuperação válido.',
},
recoveryCodeForm: {
error: {
Expand Down
1 change: 1 addition & 0 deletions src/languages/zh-hans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1822,6 +1822,7 @@ const translations = {
twoFactorAuthIsRequiredForAdminsDescription: '您的Xero会计连接需要使用双重身份验证。要继续使用Expensify,请启用它。',
twoFactorAuthCannotDisable: '无法禁用双重身份验证',
twoFactorAuthRequired: '您的Xero连接需要双因素认证(2FA),且无法禁用。',
explainProcessToRemoveWithRecovery: '为了禁用双因素认证 (2FA),请输入有效的恢复代码。',
},
recoveryCodeForm: {
error: {
Expand Down
11 changes: 5 additions & 6 deletions src/pages/settings/Security/TwoFactorAuth/DisablePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import Button from '@components/Button';
import ConfirmModal from '@components/ConfirmModal';
import FixedFooter from '@components/FixedFooter';
import ScrollView from '@components/ScrollView';
import Text from '@components/Text';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useThemeStyles from '@hooks/useThemeStyles';
Expand All @@ -21,7 +20,7 @@ import TwoFactorAuthWrapper from './TwoFactorAuthWrapper';
function DisablePage() {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [account] = useOnyx(ONYXKEYS.ACCOUNT);
const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true});

const formRef = useRef<BaseTwoFactorAuthFormRef>(null);

Expand All @@ -45,10 +44,10 @@ function DisablePage() {
stepName={CONST.TWO_FACTOR_AUTH_STEPS.DISABLE}
title={translate('twoFactorAuth.disableTwoFactorAuth')}
>
<ScrollView contentContainerStyle={styles.flexGrow1}>
<View style={[styles.ph5, styles.mt3]}>
<Text>{translate('twoFactorAuth.explainProcessToRemove')}</Text>
</View>
<ScrollView
contentContainerStyle={styles.flexGrow1}
keyboardShouldPersistTaps="handled"
>
<View style={[styles.mh5, styles.mb4, styles.mt3]}>
<TwoFactorAuthForm
innerRef={formRef}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import {useFocusEffect} from '@react-navigation/native';
import React, {useCallback, useImperativeHandle, useRef, useState} from 'react';
import type {ForwardedRef} from 'react';
import React, {useCallback, useImperativeHandle, useRef, useState} from 'react';
import type {AutoCompleteVariant, MagicCodeInputHandle} from '@components/MagicCodeInput';
import MagicCodeInput from '@components/MagicCodeInput';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
import useLocalize from '@hooks/useLocalize';
import useOnyx from '@hooks/useOnyx';
import useThemeStyles from '@hooks/useThemeStyles';
import {isMobileSafari} from '@libs/Browser';
import canFocusInputOnScreenFocus from '@libs/canFocusInputOnScreenFocus';
import {getLatestErrorMessage} from '@libs/ErrorUtils';
import {isValidTwoFactorCode} from '@libs/ValidationUtils';
import {isValidRecoveryCode, isValidTwoFactorCode} from '@libs/ValidationUtils';
import {clearAccountMessages, toggleTwoFactorAuth, validateTwoFactorAuth} from '@userActions/Session';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand All @@ -34,65 +39,124 @@ const isMobile = !canFocusInputOnScreenFocus();

function BaseTwoFactorAuthForm({autoComplete, validateInsteadOfDisable, onFocus, shouldAutoFocusOnMobile = true, ref}: BaseTwoFactorAuthFormProps) {
const {translate} = useLocalize();
const [formError, setFormError] = useState<{twoFactorAuthCode?: string}>({});
const styles = useThemeStyles();
const [formError, setFormError] = useState<{twoFactorAuthCode?: string; recoveryCode?: string}>({});
const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: false});
const [twoFactorAuthCode, setTwoFactorAuthCode] = useState('');
const [recoveryCode, setRecoveryCode] = useState('');
const [isUsingRecoveryCode, setIsUsingRecoveryCode] = useState(false);
const inputRef = useRef<MagicCodeInputHandle | null>(null);
const recoveryInputRef = useRef<BaseTextInputRef | null>(null);
const shouldClearData = account?.needsTwoFactorAuthSetup ?? false;
const shouldAllowRecoveryCode = validateInsteadOfDisable === false;

const focusRecoveryInput = useCallback(() => {
if (!recoveryInputRef.current) {
return;
}

if ('focus' in recoveryInputRef.current && typeof recoveryInputRef.current.focus === 'function') {
recoveryInputRef.current.focus();
}
}, []);

/**
* Handle text input and clear formError upon text change
*/
const onTextInput = useCallback(
const clearAccountErrorsIfPresent = useCallback(() => {
if (!account?.errors) {
return;
}
clearAccountMessages();
}, [account?.errors]);

const onTwoFactorCodeInput = useCallback(
(text: string) => {
setTwoFactorAuthCode(text);
setFormError({});
setFormError((prev) => ({...prev, twoFactorAuthCode: undefined}));
clearAccountErrorsIfPresent();
},
[clearAccountErrorsIfPresent],
);

if (account?.errors) {
clearAccountMessages();
}
const onRecoveryCodeInput = useCallback(
(text: string) => {
setRecoveryCode(text);
setFormError((prev) => ({...prev, recoveryCode: undefined}));
clearAccountErrorsIfPresent();
},
[account?.errors],
[clearAccountErrorsIfPresent],
);

/**
* Check that all the form fields are valid, then trigger the submit callback
*/
const validateAndSubmitForm = useCallback(() => {
const validateAndSubmitAuthAppCode = useCallback(() => {
if (inputRef.current) {
inputRef.current.blur();
}
if (!twoFactorAuthCode.trim()) {
const sanitizedTwoFactorCode = twoFactorAuthCode.trim();
if (!sanitizedTwoFactorCode) {
setFormError({twoFactorAuthCode: translate('twoFactorAuthForm.error.pleaseFillTwoFactorAuth')});
return;
}

if (!isValidTwoFactorCode(twoFactorAuthCode)) {
if (!isValidTwoFactorCode(sanitizedTwoFactorCode)) {
setFormError({twoFactorAuthCode: translate('twoFactorAuthForm.error.incorrect2fa')});
return;
}

setFormError({});

if (validateInsteadOfDisable !== false) {
validateTwoFactorAuth(twoFactorAuthCode, shouldClearData);
validateTwoFactorAuth(sanitizedTwoFactorCode, shouldClearData);
return;
}
toggleTwoFactorAuth(false, sanitizedTwoFactorCode);
}, [translate, twoFactorAuthCode, validateInsteadOfDisable, shouldClearData]);

const validateAndSubmitRecoveryCode = useCallback(() => {
if (recoveryInputRef.current && 'blur' in recoveryInputRef.current && typeof recoveryInputRef.current.blur === 'function') {
recoveryInputRef.current.blur();
}

const sanitizedRecoveryCode = recoveryCode.trim();
if (!sanitizedRecoveryCode) {
setFormError({recoveryCode: translate('recoveryCodeForm.error.pleaseFillRecoveryCode')});
return;
}
toggleTwoFactorAuth(false, twoFactorAuthCode);
}, [twoFactorAuthCode, validateInsteadOfDisable, translate, shouldClearData]);

if (!isValidRecoveryCode(sanitizedRecoveryCode)) {
setFormError({recoveryCode: translate('recoveryCodeForm.error.incorrectRecoveryCode')});
return;
}

setFormError({});
toggleTwoFactorAuth(false, sanitizedRecoveryCode);
}, [recoveryCode, translate]);

/**
* Check that all the form fields are valid, then trigger the submit callback
*/
const validateAndSubmitForm = useCallback(() => {
if (shouldAllowRecoveryCode && isUsingRecoveryCode) {
validateAndSubmitRecoveryCode();
return;
}
validateAndSubmitAuthAppCode();
}, [isUsingRecoveryCode, shouldAllowRecoveryCode, validateAndSubmitAuthAppCode, validateAndSubmitRecoveryCode]);

useImperativeHandle(ref, () => ({
validateAndSubmitForm() {
validateAndSubmitForm();
},
focus() {
if (!inputRef.current) {
if (shouldAllowRecoveryCode && isUsingRecoveryCode) {
focusRecoveryInput();
return;
}
inputRef.current.focus();
inputRef.current?.focus();
},
focusLastSelected() {
if (!inputRef.current) {
if (shouldAllowRecoveryCode && isUsingRecoveryCode) {
focusRecoveryInput();
return;
}
setTimeout(() => {
Expand All @@ -103,6 +167,23 @@ function BaseTwoFactorAuthForm({autoComplete, validateInsteadOfDisable, onFocus,

useFocusEffect(
useCallback(() => {
if (shouldAllowRecoveryCode && isUsingRecoveryCode) {
if (!recoveryInputRef.current || (isMobile && !shouldAutoFocusOnMobile)) {
return;
}

// Keyboard won't show if we focus the input with a delay, so we need to focus immediately.
// This is the same condition as in BaseValidateCodeForm
if (!isMobileSafari()) {
setTimeout(() => {
focusRecoveryInput();
}, CONST.ANIMATED_TRANSITION);
} else {
focusRecoveryInput();
}

return;
}
if (!inputRef.current || (isMobile && !shouldAutoFocusOnMobile)) {
return;
}
Expand All @@ -114,21 +195,80 @@ function BaseTwoFactorAuthForm({autoComplete, validateInsteadOfDisable, onFocus,
} else {
inputRef.current?.focusLastSelected();
}
}, [shouldAutoFocusOnMobile]),
}, [focusRecoveryInput, isUsingRecoveryCode, shouldAllowRecoveryCode, shouldAutoFocusOnMobile]),
);

const errorMessage = getLatestErrorMessage(account);

const handleToggleInputType = useCallback(() => {
if (!shouldAllowRecoveryCode) {
return;
}

setIsUsingRecoveryCode((prev) => {
const nextValue = !prev;
if (nextValue) {
setTwoFactorAuthCode('');
} else {
setRecoveryCode('');
}
return nextValue;
});

setFormError({});
clearAccountErrorsIfPresent();
}, [clearAccountErrorsIfPresent, shouldAllowRecoveryCode]);

const toggleLabelKey = isUsingRecoveryCode ? 'recoveryCodeForm.use2fa' : 'recoveryCodeForm.useRecoveryCode';

return (
<MagicCodeInput
autoComplete={autoComplete}
name="twoFactorAuthCode"
value={twoFactorAuthCode}
onChangeText={onTextInput}
onFocus={onFocus}
onFulfill={validateAndSubmitForm}
errorText={formError.twoFactorAuthCode ?? getLatestErrorMessage(account)}
ref={inputRef}
autoFocus={false}
/>
<>
{shouldAllowRecoveryCode && (
<Text style={[styles.mb3]}>{translate(isUsingRecoveryCode ? 'twoFactorAuth.explainProcessToRemoveWithRecovery' : 'twoFactorAuth.explainProcessToRemove')}</Text>
)}
{shouldAllowRecoveryCode && isUsingRecoveryCode ? (
<TextInput
ref={(input) => {
recoveryInputRef.current = input;
}}
value={recoveryCode}
onChangeText={onRecoveryCodeInput}
onFocus={onFocus}
autoFocus={shouldAllowRecoveryCode && isUsingRecoveryCode && (!isMobile || shouldAutoFocusOnMobile)}
autoCapitalize="characters"
label={translate('recoveryCodeForm.recoveryCode')}
maxLength={CONST.FORM_CHARACTER_LIMIT}
errorText={formError.recoveryCode ?? errorMessage}
onSubmitEditing={validateAndSubmitForm}
accessibilityLabel={translate('recoveryCodeForm.recoveryCode')}
role={CONST.ROLE.PRESENTATION}
testID="recoveryCodeInput"
/>
) : (
<MagicCodeInput
autoComplete={autoComplete}
name="twoFactorAuthCode"
value={twoFactorAuthCode}
onChangeText={onTwoFactorCodeInput}
onFocus={onFocus}
onFulfill={validateAndSubmitForm}
errorText={formError.twoFactorAuthCode ?? errorMessage}
ref={inputRef}
autoFocus={false}
testID="twoFactorAuthCodeInput"
/>
)}
{shouldAllowRecoveryCode && (
<PressableWithFeedback
style={[styles.mt2]}
onPress={handleToggleInputType}
hoverDimmingValue={1}
accessibilityLabel={translate(toggleLabelKey)}
>
<Text style={[styles.link]}>{translate(toggleLabelKey)}</Text>
</PressableWithFeedback>
)}
</>
);
}

Expand Down
Loading
Loading