diff --git a/assets/images/expensify-logo-round-dark.png b/assets/images/expensify-logo-round-dark.png
new file mode 100644
index 000000000000..106d28274fd3
Binary files /dev/null and b/assets/images/expensify-logo-round-dark.png differ
diff --git a/src/ROUTES.js b/src/ROUTES.js
index ec4730289e06..272b0f9b6c70 100644
--- a/src/ROUTES.js
+++ b/src/ROUTES.js
@@ -55,6 +55,11 @@ export default {
SETTINGS_CONTACT_METHOD_DETAILS: `${SETTINGS_CONTACT_METHODS}/:contactMethod/details`,
getEditContactMethodRoute: (contactMethod) => `${SETTINGS_CONTACT_METHODS}/${encodeURIComponent(contactMethod)}/details`,
SETTINGS_NEW_CONTACT_METHOD: `${SETTINGS_CONTACT_METHODS}/new`,
+ SETTINGS_2FA_IS_ENABLED: 'settings/security/two-factor-auth/enabled',
+ SETTINGS_2FA_DISABLE: 'settings/security/two-factor-auth/disable',
+ SETTINGS_2FA_CODES: 'settings/security/two-factor-auth/codes',
+ SETTINGS_2FA_VERIFY: 'settings/security/two-factor-auth/verify',
+ SETTINGS_2FA_SUCCESS: 'settings/security/two-factor-auth/success',
NEW_GROUP: 'new/group',
NEW_CHAT: 'new/chat',
NEW_TASK,
diff --git a/src/components/HeaderWithCloseButton.js b/src/components/HeaderWithCloseButton.js
index e1c87c0c4728..78f9d9a04d85 100755
--- a/src/components/HeaderWithCloseButton.js
+++ b/src/components/HeaderWithCloseButton.js
@@ -77,6 +77,7 @@ const propTypes = {
stepCounter: PropTypes.shape({
step: PropTypes.number,
total: PropTypes.number,
+ text: PropTypes.string,
}),
/** Whether we should show an avatar */
diff --git a/src/components/Pressable/PressableWithFeedback.js b/src/components/Pressable/PressableWithFeedback.js
index 0f4c07c63f64..9e475b7169cf 100644
--- a/src/components/Pressable/PressableWithFeedback.js
+++ b/src/components/Pressable/PressableWithFeedback.js
@@ -46,10 +46,9 @@ const PressableWithFeedback = forwardRef((props, ref) => {
setDisabled(props.disabled);
return;
}
- onPress
- .finally(() => {
- setDisabled(props.disabled);
- });
+ onPress.finally(() => {
+ setDisabled(props.disabled);
+ });
});
}}
>
diff --git a/src/components/QRCode/index.js b/src/components/QRCode/index.js
new file mode 100644
index 000000000000..41686f7c8502
--- /dev/null
+++ b/src/components/QRCode/index.js
@@ -0,0 +1,63 @@
+import React from 'react';
+import QRCodeLibrary from 'react-native-qrcode-svg';
+import PropTypes from 'prop-types';
+import defaultTheme from '../../styles/themes/default';
+
+const propTypes = {
+ /**
+ * The QR code URL
+ */
+ url: PropTypes.string.isRequired,
+ /**
+ * The logo which will be displayed in the middle of the QR code.
+ * Follows `ImageSourcePropType` from react-native.
+ */
+ logo: PropTypes.oneOfType([PropTypes.shape({uri: PropTypes.string}), PropTypes.number]),
+ /**
+ * The QRCode size
+ */
+ size: PropTypes.number,
+ /**
+ * The QRCode color
+ */
+ color: PropTypes.string,
+ /**
+ * The QRCode background color
+ */
+ backgroundColor: PropTypes.string,
+ /**
+ * Function to retrieve the internal component ref and be able to call it's
+ * methods
+ */
+ getRef: PropTypes.func,
+};
+
+const defaultProps = {
+ logo: undefined,
+ size: 120,
+ color: defaultTheme.text,
+ backgroundColor: defaultTheme.highlightBG,
+ getRef: undefined,
+};
+
+function QRCode(props) {
+ return (
+
+ );
+}
+
+QRCode.displayName = 'QRCode';
+QRCode.propTypes = propTypes;
+QRCode.defaultProps = defaultProps;
+
+export default QRCode;
diff --git a/src/components/QRShare/index.js b/src/components/QRShare/index.js
index 66ffaea692f3..d6999952a4d6 100644
--- a/src/components/QRShare/index.js
+++ b/src/components/QRShare/index.js
@@ -1,5 +1,4 @@
import React, {Component} from 'react';
-import QrCode from 'react-native-qrcode-svg';
import {View} from 'react-native';
import withLocalize, {withLocalizePropTypes} from '../withLocalize';
import defaultTheme from '../../styles/themes/default';
@@ -10,6 +9,7 @@ import compose from '../../libs/compose';
import variables from '../../styles/variables';
import ExpensifyWordmark from '../../../assets/images/expensify-wordmark.svg';
import {qrSharePropTypes, qrShareDefaultProps} from './propTypes';
+import QRCode from '../QRCode';
const propTypes = {
...qrSharePropTypes,
@@ -61,16 +61,11 @@ class QRShare extends Component {
/>
- (this.svg = svg)}
- logoBackgroundColor="transparent"
- logoSize={this.state.qrCodeSize * 0.3}
- logoBorderRadius={this.state.qrCodeSize}
+ url={this.props.url}
+ logo={this.props.logo}
size={this.state.qrCodeSize}
- backgroundColor={defaultTheme.highlightBG}
- color={defaultTheme.text}
/>
{
@@ -45,7 +50,7 @@ const Section = (props) => {
{props.title}
-
+
{Boolean(props.icon) && (
`Step ${step} of ${total}`,
+ stepCounter: ({step, total, text}) => {
+ let result = `Step ${step}`;
+
+ if (total) {
+ result = `${result} of ${total}`;
+ }
+
+ if (text) {
+ result = `${result}: ${text}`;
+ }
+ return result;
+ },
bankAccount: {
accountNumber: 'Account number',
routingNumber: 'Routing number',
@@ -742,7 +782,6 @@ export default {
'In order to finish setting up your bank account, you must validate your account. Please check your email to validate your account, and return here to finish up!',
hasPhoneLoginError: 'To add a verified bank account please ensure your primary login is a valid email and try again. You can add your phone number as a secondary login.',
hasBeenThrottledError: 'There was an error adding your bank account. Please wait a few minutes and try again.',
- buttonConfirm: 'Got it',
error: {
noBankAccountAvailable: 'Sorry, no bank account is available',
noBankAccountSelected: 'Please choose an account',
diff --git a/src/languages/es.js b/src/languages/es.js
index 43ccdfcdf2db..427e2ff00f05 100644
--- a/src/languages/es.js
+++ b/src/languages/es.js
@@ -8,6 +8,7 @@ export default {
yes: 'Sí',
no: 'No',
ok: 'OK',
+ buttonConfirm: 'Ok, entendido',
attachment: 'Archivo adjunto',
to: 'A',
optional: 'Opcional',
@@ -24,6 +25,7 @@ export default {
zoom: 'Zoom',
password: 'Contraseña',
magicCode: 'Código mágico',
+ twoFactorCode: 'Autenticación de dos factores',
workspaces: 'Espacios de trabajo',
profile: 'Perfil',
payments: 'Pagos',
@@ -495,10 +497,40 @@ export default {
newPassword: 'Su contraseña debe tener al menos 8 caracteres, 1 letra mayúscula, 1 letra minúscula y 1 número.',
},
},
+ twoFactorAuth: {
+ headerTitle: 'Autenticación de dos factores',
+ twoFactorAuthEnabled: 'Autenticación de dos factores habilitada',
+ whatIsTwoFactorAuth:
+ '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',
+ disableTwoFactorAuthConfirmation: 'La autenticación de dos factores mantiene tu cuenta más segura. ¿Estás seguro de que quieres desactivarla?',
+ disabled: 'La autenticación de dos factores ahora está deshabilitada',
+ noAuthenticatorApp: 'Ya no necesitarás una aplicación de autenticación para iniciar sesión en Expensify.',
+ stepCodes: 'Códigos de recuperación',
+ keepCodesSafe: '¡Guarda los códigos de recuperación en un lugar seguro!',
+ codesLoseAccess:
+ 'Si pierdes el acceso a tu aplicación de autenticación y no tienes estos códigos, perderás el acceso a tu cuenta. \n\nNota: Configurar la autenticación de dos factores cerrará la sesión de todas las demás sesiones activas.',
+ stepVerify: 'Verificar',
+ scanCode: 'Escanea el código QR usando tu',
+ authenticatorApp: 'aplicación de autenticación',
+ addKey: 'O agrega esta clave secreta a su aplicación de autenticación:',
+ enterCode: 'Luego ingresa el código de seis dígitos generado por su aplicación de autenticación.',
+ stepSuccess: 'Finalizado',
+ enabled: '¡La autenticación de dos factores ahora está habilitada!',
+ congrats: 'Felicidades, ahora tienes esa seguridad adicional.',
+ copyCodes: 'Copiar códigos',
+ copy: 'Copiar',
+ disable: 'Deshabilitar',
+ },
+ twoFactorAuthForm: {
+ error: {
+ pleaseFillTwoFactorAuth: 'Por favor, introduce tu código 2 factores',
+ incorrect2fa: 'Código de autenticación de dos factores incorrecto. Por favor, inténtalo de nuevo',
+ },
+ },
passwordConfirmationScreen: {
passwordUpdated: 'Contraseña actualizada!',
allSet: 'Todo está listo. Guarda tu contraseña en un lugar seguro.',
- gotIt: 'Ok, entendido',
},
addPayPalMePage: {
enterYourUsernameToGetPaidViaPayPal: 'Recibe pagos vía PayPal.',
@@ -626,7 +658,6 @@ export default {
validateCodeForm: {
magicCodeNotReceived: '¿No recibiste un código mágico?',
enterAuthenticatorCode: 'Por favor, introduce el código de autenticador',
- twoFactorCode: 'Autenticación de 2 factores',
requiredWhen2FAEnabled: 'Obligatorio cuando A2F está habilitado',
codeSent: '¡Código mágico enviado!',
error: {
@@ -641,12 +672,11 @@ export default {
pleaseFillTwoFactorAuth: 'Por favor, introduce tu código 2 factores',
enterYourTwoFactorAuthenticationCodeToContinue: 'Introduce el código de autenticación de dos factores para continuar',
forgot: '¿Has olvidado la contraseña?',
- twoFactorCode: 'Autenticación de 2 factores',
requiredWhen2FAEnabled: 'Obligatorio cuando A2F está habilitado',
error: {
incorrectPassword: 'Contraseña incorrecta. Por favor, inténtalo de nuevo.',
incorrectLoginOrPassword: 'Usuario o contraseña incorrectos. Por favor, inténtalo de nuevo',
- incorrect2fa: 'Código de autenticación de 2 factores incorrecto. Por favor, inténtalo de nuevo',
+ incorrect2fa: 'Código de autenticación de dos factores incorrecto. Por favor, inténtalo de nuevo',
twoFactorAuthenticationEnabled: 'Tienes autenticación de 2 factores activada en esta cuenta. Por favor, conéctate usando tu email o número de teléfono',
invalidLoginOrPassword: 'Usuario o clave incorrectos. Por favor, inténtalo de nuevo o restablece la contraseña',
unableToResetPassword:
@@ -725,7 +755,18 @@ export default {
setPasswordLinkInvalid: 'El enlace para configurar tu contraseña ha expirado. Te hemos enviado un nuevo enlace a tu correo.',
validateAccount: 'Verificar cuenta',
},
- stepCounter: ({step, total}) => `Paso ${step} de ${total}`,
+ stepCounter: ({step, total, text}) => {
+ let result = `Paso ${step}`;
+
+ if (total) {
+ result = `${result} de ${total}`;
+ }
+
+ if (text) {
+ result = `${result}: ${text}`;
+ }
+ return result;
+ },
bankAccount: {
accountNumber: 'Número de cuenta',
routingNumber: 'Número de ruta',
@@ -743,7 +784,6 @@ export default {
hasPhoneLoginError:
'Para agregar una cuenta bancaria verificada, asegúrate de que tu nombre de usuario principal sea un correo electrónico válido y vuelve a intentarlo. Puedes agregar tu número de teléfono como nombre de usuario secundario.',
hasBeenThrottledError: 'Se produjo un error al intentar agregar tu cuenta bancaria. Por favor, espera unos minutos e inténtalo de nuevo.',
- buttonConfirm: 'OK',
error: {
noBankAccountAvailable: 'Lo sentimos, no hay ninguna cuenta bancaria disponible',
noBankAccountSelected: 'Por favor, elige una cuenta bancaria',
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
index 7b33335fb86d..20332e12916d 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
@@ -598,6 +598,41 @@ const SettingsModalStackNavigator = createModalStackNavigator([
},
name: 'GetAssistance',
},
+ {
+ getComponent: () => {
+ const SettingsTwoFactorAuthIsEnabled = require('../../../pages/settings/Security/TwoFactorAuth/IsEnabledPage').default;
+ return SettingsTwoFactorAuthIsEnabled;
+ },
+ name: 'Settings_TwoFactorAuthIsEnabled',
+ },
+ {
+ getComponent: () => {
+ const SettingsTwoFactorAuthDisable = require('../../../pages/settings/Security/TwoFactorAuth/DisablePage').default;
+ return SettingsTwoFactorAuthDisable;
+ },
+ name: 'Settings_TwoFactorAuthDisable',
+ },
+ {
+ getComponent: () => {
+ const SettingsTwoFactorAuthCodes = require('../../../pages/settings/Security/TwoFactorAuth/CodesPage').default;
+ return SettingsTwoFactorAuthCodes;
+ },
+ name: 'Settings_TwoFactorAuthCodes',
+ },
+ {
+ getComponent: () => {
+ const SettingsTwoFactorAuthVerify = require('../../../pages/settings/Security/TwoFactorAuth/VerifyPage').default;
+ return SettingsTwoFactorAuthVerify;
+ },
+ name: 'Settings_TwoFactorAuthVerify',
+ },
+ {
+ getComponent: () => {
+ const SettingsTwoFactorAuthSuccess = require('../../../pages/settings/Security/TwoFactorAuth/SuccessPage').default;
+ return SettingsTwoFactorAuthSuccess;
+ },
+ name: 'Settings_TwoFactorAuthSuccess',
+ },
]);
const EnablePaymentsStackNavigator = createModalStackNavigator([
diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js
index 1a7c7398caf0..6c1da26c6058 100644
--- a/src/libs/Navigation/linkingConfig.js
+++ b/src/libs/Navigation/linkingConfig.js
@@ -145,6 +145,26 @@ export default {
path: ROUTES.SETTINGS_PERSONAL_DETAILS_ADDRESS,
exact: true,
},
+ Settings_TwoFactorAuthIsEnabled: {
+ path: ROUTES.SETTINGS_2FA_IS_ENABLED,
+ exact: true,
+ },
+ Settings_TwoFactorAuthDisable: {
+ path: ROUTES.SETTINGS_2FA_DISABLE,
+ exact: true,
+ },
+ Settings_TwoFactorAuthCodes: {
+ path: ROUTES.SETTINGS_2FA_CODES,
+ exact: true,
+ },
+ Settings_TwoFactorAuthVerify: {
+ path: ROUTES.SETTINGS_2FA_VERIFY,
+ exact: true,
+ },
+ Settings_TwoFactorAuthSuccess: {
+ path: ROUTES.SETTINGS_2FA_SUCCESS,
+ exact: true,
+ },
Workspace_Initial: {
path: ROUTES.WORKSPACE_INITIAL,
},
diff --git a/src/libs/actions/Session/index.js b/src/libs/actions/Session/index.js
index 9f8a70a0cd47..348cafc13d8f 100644
--- a/src/libs/actions/Session/index.js
+++ b/src/libs/actions/Session/index.js
@@ -772,7 +772,91 @@ function unlinkLogin(accountID, validateCode) {
},
];
- API.write('UnlinkLogin', {accountID, validateCode}, {optimisticData, successData, failureData});
+ API.write(
+ 'UnlinkLogin',
+ {
+ accountID,
+ validateCode,
+ },
+ {
+ optimisticData,
+ successData,
+ failureData,
+ },
+ );
+}
+
+/**
+ * Toggles two-factor authentication based on the `enable` parameter
+ *
+ * @param {Boolean} enable
+ */
+function toggleTwoFactorAuth(enable) {
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ isLoading: true,
+ },
+ },
+ ];
+
+ const successData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ isLoading: false,
+ },
+ },
+ ];
+
+ const failureData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ isLoading: false,
+ },
+ },
+ ];
+
+ API.write(enable ? 'EnableTwoFactorAuth' : 'DisableTwoFactorAuth', {}, {optimisticData, successData, failureData});
+}
+
+function validateTwoFactorAuth(twoFactorAuthCode) {
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ isLoading: true,
+ },
+ },
+ ];
+
+ const successData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ isLoading: false,
+ },
+ },
+ ];
+
+ const failureData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: ONYXKEYS.ACCOUNT,
+ value: {
+ isLoading: false,
+ },
+ },
+ ];
+
+ API.write('TwoFactorAuth_Validate', {twoFactorAuthCode}, {optimisticData, successData, failureData});
}
export {
@@ -799,4 +883,6 @@ export {
reauthenticatePusher,
invalidateCredentials,
invalidateAuthToken,
+ toggleTwoFactorAuth,
+ validateTwoFactorAuth,
};
diff --git a/src/libs/actions/TwoFactorAuthActions.js b/src/libs/actions/TwoFactorAuthActions.js
new file mode 100644
index 000000000000..f778b1f2ceae
--- /dev/null
+++ b/src/libs/actions/TwoFactorAuthActions.js
@@ -0,0 +1,14 @@
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '../../ONYXKEYS';
+
+/**
+ * Clear 2FA data if the flow is interrupted without finishing
+ */
+function clearTwoFactorAuthData() {
+ Onyx.merge(ONYXKEYS.ACCOUNT, {recoveryCodes: '', twoFactorAuthSecretKey: ''});
+}
+
+export {
+ // eslint-disable-next-line import/prefer-default-export
+ clearTwoFactorAuthData,
+};
diff --git a/src/libs/localFileDownload/index.android.js b/src/libs/localFileDownload/index.android.js
new file mode 100644
index 000000000000..acdc9c89e6a4
--- /dev/null
+++ b/src/libs/localFileDownload/index.android.js
@@ -0,0 +1,36 @@
+import RNFetchBlob from 'react-native-blob-util';
+import * as FileUtils from '../fileDownload/FileUtils';
+
+/**
+ * Writes a local file to the app's internal directory with the given fileName
+ * and textContent, so we're able to copy it to the Android public download dir.
+ * After the file is copied, it is removed from the internal dir.
+ *
+ * @param {String} fileName
+ * @param {String} textContent
+ */
+export default function localFileDownload(fileName, textContent) {
+ const dir = RNFetchBlob.fs.dirs.DocumentDir;
+ const path = `${dir}/${fileName}.txt`;
+
+ RNFetchBlob.fs.writeFile(path, textContent, 'utf8').then(() => {
+ RNFetchBlob.MediaCollection.copyToMediaStore(
+ {
+ name: fileName,
+ parentFolder: '', // subdirectory in the Media Store, empty goes to 'Downloads'
+ mimeType: 'text/plain',
+ },
+ 'Download',
+ path,
+ )
+ .then(() => {
+ FileUtils.showSuccessAlert();
+ })
+ .catch(() => {
+ FileUtils.showGeneralErrorAlert();
+ })
+ .finally(() => {
+ RNFetchBlob.fs.unlink(path);
+ });
+ });
+}
diff --git a/src/libs/localFileDownload/index.ios.js b/src/libs/localFileDownload/index.ios.js
new file mode 100644
index 000000000000..7113db49ac8b
--- /dev/null
+++ b/src/libs/localFileDownload/index.ios.js
@@ -0,0 +1,21 @@
+import {Share} from 'react-native';
+import RNFetchBlob from 'react-native-blob-util';
+
+/**
+ * Writes a local file to the app's internal directory with the given fileName
+ * and textContent, so we're able to share it using iOS' share API.
+ * After the file is shared, it is removed from the internal dir.
+ *
+ * @param {String} fileName
+ * @param {String} textContent
+ */
+export default function localFileDownload(fileName, textContent) {
+ const dir = RNFetchBlob.fs.dirs.DocumentDir;
+ const path = `${dir}/${fileName}.txt`;
+
+ RNFetchBlob.fs.writeFile(path, textContent, 'utf8').then(() => {
+ Share.share({url: path, title: fileName}).finally(() => {
+ RNFetchBlob.fs.unlink(path);
+ });
+ });
+}
diff --git a/src/libs/localFileDownload/index.js b/src/libs/localFileDownload/index.js
new file mode 100644
index 000000000000..f52d2fa0115e
--- /dev/null
+++ b/src/libs/localFileDownload/index.js
@@ -0,0 +1,16 @@
+/**
+ * Creates a Blob with the given fileName and textContent, then dynamically
+ * creates a temporary anchor, just to programmatically click it, so the file
+ * is downloaded by the browser.
+ *
+ * @param {String} fileName
+ * @param {String} textContent
+ */
+export default function localFileDownload(fileName, textContent) {
+ const blob = new Blob([textContent], {type: 'text/plain'});
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.download = `${fileName}.txt`;
+ link.href = url;
+ link.click();
+}
diff --git a/src/pages/settings/PasswordPage.js b/src/pages/settings/PasswordPage.js
index a2d740dc97b9..0d5826a2e00d 100755
--- a/src/pages/settings/PasswordPage.js
+++ b/src/pages/settings/PasswordPage.js
@@ -158,7 +158,7 @@ class PasswordPage extends Component {
heading={this.props.translate('passwordConfirmationScreen.passwordUpdated')}
shouldShowButton
onButtonPress={Navigation.goBack}
- buttonText={this.props.translate('passwordConfirmationScreen.gotIt')}
+ buttonText={this.props.translate('common.buttonConfirm')}
description={this.props.translate('passwordConfirmationScreen.allSet')}
/>
) : (
diff --git a/src/pages/settings/Security/SecuritySettingsPage.js b/src/pages/settings/Security/SecuritySettingsPage.js
index d6d4b0104bee..ae7dac591ac5 100644
--- a/src/pages/settings/Security/SecuritySettingsPage.js
+++ b/src/pages/settings/Security/SecuritySettingsPage.js
@@ -1,6 +1,8 @@
import _ from 'underscore';
import React from 'react';
import {View, ScrollView} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import PropTypes from 'prop-types';
import HeaderWithCloseButton from '../../../components/HeaderWithCloseButton';
import Navigation from '../../../libs/Navigation/Navigation';
import ROUTES from '../../../ROUTES';
@@ -9,13 +11,40 @@ import * as Expensicons from '../../../components/Icon/Expensicons';
import ScreenWrapper from '../../../components/ScreenWrapper';
import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize';
import MenuItem from '../../../components/MenuItem';
+import compose from '../../../libs/compose';
+import ONYXKEYS from '../../../ONYXKEYS';
+import * as Session from '../../../libs/actions/Session';
const propTypes = {
...withLocalizePropTypes,
+
+ /* Onyx Props */
+
+ /** Holds information about the users account that is logging in */
+ account: PropTypes.shape({
+ /** Whether this account has 2FA enabled or not */
+ requiresTwoFactorAuth: PropTypes.bool,
+ }),
};
-const SecuritySettingsPage = (props) => {
+const defaultProps = {
+ account: {},
+};
+
+function SecuritySettingsPage(props) {
const menuItems = [
+ {
+ translationKey: 'twoFactorAuth.headerTitle',
+ icon: Expensicons.Shield,
+ action: () => {
+ if (props.account.requiresTwoFactorAuth) {
+ Navigation.navigate(ROUTES.SETTINGS_2FA_IS_ENABLED);
+ } else {
+ Session.toggleTwoFactorAuth(true);
+ Navigation.navigate(ROUTES.SETTINGS_2FA_CODES);
+ }
+ },
+ },
{
translationKey: 'passwordPage.changePassword',
icon: Expensicons.Key,
@@ -56,9 +85,15 @@ const SecuritySettingsPage = (props) => {
);
-};
+}
SecuritySettingsPage.propTypes = propTypes;
+SecuritySettingsPage.defaultProps = defaultProps;
SecuritySettingsPage.displayName = 'SettingSecurityPage';
-export default withLocalize(SecuritySettingsPage);
+export default compose(
+ withLocalize,
+ withOnyx({
+ account: {key: ONYXKEYS.ACCOUNT},
+ }),
+)(SecuritySettingsPage);
diff --git a/src/pages/settings/Security/TwoFactorAuth/CodesPage.js b/src/pages/settings/Security/TwoFactorAuth/CodesPage.js
new file mode 100644
index 000000000000..915d2534fa0d
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/CodesPage.js
@@ -0,0 +1,139 @@
+import React, {useEffect, useState} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import {ActivityIndicator, View} from 'react-native';
+import _ from 'underscore';
+import PropTypes from 'prop-types';
+import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton';
+import Navigation from '../../../../libs/Navigation/Navigation';
+import ScreenWrapper from '../../../../components/ScreenWrapper';
+import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
+import compose from '../../../../libs/compose';
+import ROUTES from '../../../../ROUTES';
+import FullPageOfflineBlockingView from '../../../../components/BlockingViews/FullPageOfflineBlockingView';
+import * as Illustrations from '../../../../components/Icon/Illustrations';
+import styles from '../../../../styles/styles';
+import FixedFooter from '../../../../components/FixedFooter';
+import Button from '../../../../components/Button';
+import Text from '../../../../components/Text';
+import Section from '../../../../components/Section';
+import ONYXKEYS from '../../../../ONYXKEYS';
+import Clipboard from '../../../../libs/Clipboard';
+import themeColors from '../../../../styles/themes/default';
+import localFileDownload from '../../../../libs/localFileDownload';
+import * as TwoFactorAuthActions from '../../../../libs/actions/TwoFactorAuthActions';
+
+const propTypes = {
+ ...withLocalizePropTypes,
+ account: PropTypes.shape({
+ /** User recovery codes for setting up 2-FA */
+ recoveryCodes: PropTypes.string,
+
+ /** If recovery codes are loading */
+ isLoading: PropTypes.bool,
+ }),
+};
+
+const defaultProps = {
+ account: {
+ recoveryCodes: '',
+ },
+};
+
+function CodesPage(props) {
+ const [isNextButtonDisabled, setIsNextButtonDisabled] = useState(true);
+
+ // Here, this eslint rule will make the unmount effect unreadable, possibly confusing with mount
+ // eslint-disable-next-line arrow-body-style
+ useEffect(() => {
+ return () => {
+ TwoFactorAuthActions.clearTwoFactorAuthData();
+ };
+ }, []);
+
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS_SECURITY)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+
+
+ {props.translate('twoFactorAuth.codesLoseAccess')}
+
+
+ {props.account.isLoading ? (
+
+
+
+ ) : (
+ <>
+
+ {Boolean(props.account.recoveryCodes) &&
+ _.map(props.account.recoveryCodes.split(', '), (code) => (
+
+ {code}
+
+ ))}
+
+
+
+ >
+ )}
+
+
+
+ Navigation.navigate(ROUTES.SETTINGS_2FA_VERIFY)}
+ isDisabled={isNextButtonDisabled}
+ />
+
+
+
+ );
+}
+
+CodesPage.propTypes = propTypes;
+CodesPage.defaultProps = defaultProps;
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ account: {key: ONYXKEYS.ACCOUNT},
+ }),
+)(CodesPage);
diff --git a/src/pages/settings/Security/TwoFactorAuth/DisablePage.js b/src/pages/settings/Security/TwoFactorAuth/DisablePage.js
new file mode 100644
index 000000000000..6088a079ce45
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/DisablePage.js
@@ -0,0 +1,59 @@
+import React, {useEffect} from 'react';
+import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton';
+import Navigation from '../../../../libs/Navigation/Navigation';
+import ScreenWrapper from '../../../../components/ScreenWrapper';
+import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
+import ROUTES from '../../../../ROUTES';
+import FullPageOfflineBlockingView from '../../../../components/BlockingViews/FullPageOfflineBlockingView';
+import * as Illustrations from '../../../../components/Icon/Illustrations';
+import styles from '../../../../styles/styles';
+import BlockingView from '../../../../components/BlockingViews/BlockingView';
+import FixedFooter from '../../../../components/FixedFooter';
+import Button from '../../../../components/Button';
+import * as Session from '../../../../libs/actions/Session';
+import variables from '../../../../styles/variables';
+
+const propTypes = {
+ ...withLocalizePropTypes,
+};
+
+const defaultProps = {};
+
+function DisablePage(props) {
+ useEffect(() => {
+ Session.toggleTwoFactorAuth(false);
+ }, []);
+
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS_SECURITY)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+
+
+
+ Navigation.navigate(ROUTES.SETTINGS_SECURITY)}
+ />
+
+
+
+ );
+}
+
+DisablePage.propTypes = propTypes;
+DisablePage.defaultProps = defaultProps;
+
+export default withLocalize(DisablePage);
diff --git a/src/pages/settings/Security/TwoFactorAuth/IsEnabledPage.js b/src/pages/settings/Security/TwoFactorAuth/IsEnabledPage.js
new file mode 100644
index 000000000000..07ae34e9f00b
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/IsEnabledPage.js
@@ -0,0 +1,70 @@
+import React, {useState} from 'react';
+import {Text, View} from 'react-native';
+import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton';
+import Navigation from '../../../../libs/Navigation/Navigation';
+import ScreenWrapper from '../../../../components/ScreenWrapper';
+import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
+import ROUTES from '../../../../ROUTES';
+import Section from '../../../../components/Section';
+import * as Illustrations from '../../../../components/Icon/Illustrations';
+import * as Expensicons from '../../../../components/Icon/Expensicons';
+import themeColors from '../../../../styles/themes/default';
+import styles from '../../../../styles/styles';
+import ConfirmModal from '../../../../components/ConfirmModal';
+
+const defaultProps = {};
+
+function IsEnabledPage(props) {
+ const [isConfirmModalVisible, setIsConfirmModalVisible] = useState(false);
+
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS_SECURITY)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+ {
+ setIsConfirmModalVisible(true);
+ },
+ icon: Expensicons.Close,
+ iconFill: themeColors.danger,
+ wrapperStyle: [styles.cardMenuItem],
+ },
+ ]}
+ containerStyles={[styles.twoFactorAuthSection]}
+ >
+
+ {props.translate('twoFactorAuth.whatIsTwoFactorAuth')}
+
+
+ {
+ setIsConfirmModalVisible(false);
+ Navigation.navigate(ROUTES.SETTINGS_2FA_DISABLE);
+ }}
+ onCancel={() => setIsConfirmModalVisible(false)}
+ onModalHide={() => setIsConfirmModalVisible(false)}
+ isVisible={isConfirmModalVisible}
+ prompt={props.translate('twoFactorAuth.disableTwoFactorAuthConfirmation')}
+ confirmText={props.translate('twoFactorAuth.disable')}
+ cancelText={props.translate('common.cancel')}
+ shouldShowCancelButton
+ danger
+ />
+
+ );
+}
+
+IsEnabledPage.propTypes = withLocalizePropTypes;
+IsEnabledPage.defaultProps = defaultProps;
+
+export default withLocalize(IsEnabledPage);
diff --git a/src/pages/settings/Security/TwoFactorAuth/SuccessPage.js b/src/pages/settings/Security/TwoFactorAuth/SuccessPage.js
new file mode 100644
index 000000000000..92db82e91aae
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/SuccessPage.js
@@ -0,0 +1,44 @@
+import React from 'react';
+import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton';
+import Navigation from '../../../../libs/Navigation/Navigation';
+import ScreenWrapper from '../../../../components/ScreenWrapper';
+import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
+import ROUTES from '../../../../ROUTES';
+import FullPageOfflineBlockingView from '../../../../components/BlockingViews/FullPageOfflineBlockingView';
+import FireworksAnimation from '../../../../../assets/animations/Fireworks.json';
+import ConfirmationPage from '../../../../components/ConfirmationPage';
+
+const defaultProps = {};
+
+function SuccessPage(props) {
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS_SECURITY)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+ Navigation.navigate(ROUTES.SETTINGS_2FA_IS_ENABLED)}
+ />
+
+
+ );
+}
+
+SuccessPage.propTypes = withLocalizePropTypes;
+SuccessPage.defaultProps = defaultProps;
+
+export default withLocalize(SuccessPage);
diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthForm/BaseTwoFactorAuthForm.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthForm/BaseTwoFactorAuthForm.js
new file mode 100644
index 000000000000..ca78219178ef
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthForm/BaseTwoFactorAuthForm.js
@@ -0,0 +1,93 @@
+import React, {useCallback, useState} from 'react';
+import PropTypes from 'prop-types';
+import {withOnyx} from 'react-native-onyx';
+import MagicCodeInput from '../../../../../components/MagicCodeInput';
+import * as ErrorUtils from '../../../../../libs/ErrorUtils';
+import withLocalize, {withLocalizePropTypes} from '../../../../../components/withLocalize';
+import ONYXKEYS from '../../../../../ONYXKEYS';
+import compose from '../../../../../libs/compose';
+import * as ValidationUtils from '../../../../../libs/ValidationUtils';
+import * as Session from '../../../../../libs/actions/Session';
+
+const propTypes = {
+ ...withLocalizePropTypes,
+
+ /* Onyx Props */
+
+ /** The details about the account that the user is signing in with */
+ account: PropTypes.shape({
+ /** Whether two-factor authentication is required */
+ requiresTwoFactorAuth: PropTypes.bool,
+ }),
+
+ /** Specifies autocomplete hints for the system, so it can provide autofill */
+ autoComplete: PropTypes.oneOf(['sms-otp', 'one-time-code']).isRequired,
+};
+
+const defaultProps = {
+ account: {},
+};
+
+function BaseTwoFactorAuthForm(props) {
+ const [formError, setFormError] = useState({});
+ const [twoFactorAuthCode, setTwoFactorAuthCode] = useState('');
+
+ /**
+ * Handle text input and clear formError upon text change
+ *
+ * @param {String} text
+ */
+ const onTextInput = useCallback(
+ (text) => {
+ setTwoFactorAuthCode(text);
+ setFormError({});
+
+ if (props.account.errors) {
+ Session.clearAccountMessages();
+ }
+ },
+ [props.account.errors],
+ );
+
+ /**
+ * Check that all the form fields are valid, then trigger the submit callback
+ */
+ const validateAndSubmitForm = useCallback(() => {
+ if (!twoFactorAuthCode.trim()) {
+ setFormError({twoFactorAuthCode: 'twoFactorAuthForm.error.pleaseFillTwoFactorAuth'});
+ return;
+ }
+
+ if (!ValidationUtils.isValidTwoFactorCode(twoFactorAuthCode)) {
+ setFormError({twoFactorAuthCode: 'twoFactorAuthForm.error.incorrect2fa'});
+ return;
+ }
+
+ setFormError({});
+ Session.validateTwoFactorAuth(twoFactorAuthCode);
+ }, [twoFactorAuthCode]);
+
+ return (
+
+ );
+}
+
+BaseTwoFactorAuthForm.propTypes = propTypes;
+BaseTwoFactorAuthForm.defaultProps = defaultProps;
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ account: {key: ONYXKEYS.ACCOUNT},
+ }),
+)(BaseTwoFactorAuthForm);
diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthForm/index.android.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthForm/index.android.js
new file mode 100644
index 000000000000..925f6d429fa1
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthForm/index.android.js
@@ -0,0 +1,8 @@
+import React from 'react';
+import BaseTwoFactorAuthForm from './BaseTwoFactorAuthForm';
+
+const TwoFactorAuthForm = () => ;
+
+TwoFactorAuthForm.displayName = 'TwoFactorAuthForm';
+
+export default TwoFactorAuthForm;
diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthForm/index.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthForm/index.js
new file mode 100644
index 000000000000..c204978554ff
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthForm/index.js
@@ -0,0 +1,8 @@
+import React from 'react';
+import BaseTwoFactorAuthForm from './BaseTwoFactorAuthForm';
+
+const TwoFactorAuthForm = () => ;
+
+TwoFactorAuthForm.displayName = 'TwoFactorAuthForm';
+
+export default TwoFactorAuthForm;
diff --git a/src/pages/settings/Security/TwoFactorAuth/VerifyPage.js b/src/pages/settings/Security/TwoFactorAuth/VerifyPage.js
new file mode 100644
index 000000000000..395faa1816bf
--- /dev/null
+++ b/src/pages/settings/Security/TwoFactorAuth/VerifyPage.js
@@ -0,0 +1,151 @@
+import React, {useEffect} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import {ScrollView, View} from 'react-native';
+import PropTypes from 'prop-types';
+import HeaderWithCloseButton from '../../../../components/HeaderWithCloseButton';
+import Navigation from '../../../../libs/Navigation/Navigation';
+import ScreenWrapper from '../../../../components/ScreenWrapper';
+import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
+import compose from '../../../../libs/compose';
+import ROUTES from '../../../../ROUTES';
+import FullPageOfflineBlockingView from '../../../../components/BlockingViews/FullPageOfflineBlockingView';
+import styles from '../../../../styles/styles';
+import Button from '../../../../components/Button';
+import Text from '../../../../components/Text';
+import ONYXKEYS from '../../../../ONYXKEYS';
+import TextLink from '../../../../components/TextLink';
+import Clipboard from '../../../../libs/Clipboard';
+import FixedFooter from '../../../../components/FixedFooter';
+import TwoFactorAuthForm from './TwoFactorAuthForm';
+import QRCode from '../../../../components/QRCode';
+import expensifyLogo from '../../../../../assets/images/expensify-logo-round-dark.png';
+
+const propTypes = {
+ ...withLocalizePropTypes,
+ account: PropTypes.shape({
+ /** Whether this account has 2FA enabled or not */
+ requiresTwoFactorAuth: PropTypes.bool,
+
+ /** Secret key to enable 2FA within the authenticator app */
+ twoFactorAuthSecretKey: PropTypes.string,
+
+ /** User primary login to attach to the authenticator QRCode */
+ primaryLogin: PropTypes.string,
+
+ /** User is submitting the authentication code */
+ isLoading: PropTypes.bool,
+
+ /** Server-side errors in the submitted authentication code */
+ errors: PropTypes.objectOf(PropTypes.string),
+ }),
+};
+
+const defaultProps = {
+ account: {
+ requiresTwoFactorAuth: false,
+ twoFactorAuthSecretKey: '',
+ primaryLogin: '',
+ isLoading: false,
+ errors: {},
+ },
+};
+
+function VerifyPage(props) {
+ useEffect(() => {
+ if (!props.account.requiresTwoFactorAuth) {
+ return;
+ }
+ Navigation.navigate(ROUTES.SETTINGS_2FA_SUCCESS);
+ }, [props.account.requiresTwoFactorAuth]);
+
+ /**
+ * Splits the two-factor auth secret key in 4 chunks
+ *
+ * @param {String} secret
+ * @returns {string}
+ */
+ function splitSecretInChunks(secret) {
+ if (secret.length !== 16) {
+ return secret;
+ }
+
+ return `${secret.slice(0, 4)} ${secret.slice(4, 8)} ${secret.slice(8, 12)} ${secret.slice(12, secret.length)}`;
+ }
+
+ /**
+ * Builds the URL string to generate the QRCode, using the otpauth:// protocol,
+ * so it can be detected by authenticator apps
+ *
+ * @returns {string}
+ */
+ function buildAuthenticatorUrl() {
+ return `otpauth://totp/Expensify:${props.account.primaryLogin}?secret=${props.account.twoFactorAuthSecretKey}&issuer=Expensify`;
+ }
+
+ return (
+
+ Navigation.navigate(ROUTES.SETTINGS_2FA_CODES)}
+ onCloseButtonPress={() => Navigation.dismissModal(true)}
+ />
+
+
+
+
+ {props.translate('twoFactorAuth.scanCode')}
+
+ {' '}
+ {props.translate('twoFactorAuth.authenticatorApp')}
+
+ .
+
+
+
+
+ {props.translate('twoFactorAuth.addKey')}
+
+ {Boolean(props.account.twoFactorAuthSecretKey) && {splitSecretInChunks(props.account.twoFactorAuthSecretKey)}}
+ Clipboard.setString(props.account.twoFactorAuthSecretKey)}
+ medium
+ />
+
+ {props.translate('twoFactorAuth.enterCode')}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+VerifyPage.propTypes = propTypes;
+VerifyPage.defaultProps = defaultProps;
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ account: {key: ONYXKEYS.ACCOUNT},
+ }),
+)(VerifyPage);
diff --git a/src/pages/signin/PasswordForm.js b/src/pages/signin/PasswordForm.js
index d9e43db68387..6bbe1d9585ad 100755
--- a/src/pages/signin/PasswordForm.js
+++ b/src/pages/signin/PasswordForm.js
@@ -204,7 +204,7 @@ class PasswordForm extends React.Component {
(this.input2FA = el)}
- label={this.props.translate('passwordForm.twoFactorCode')}
+ label={this.props.translate('common.twoFactorCode')}
value={this.state.twoFactorAuthCode}
placeholder={this.props.translate('passwordForm.requiredWhen2FAEnabled')}
placeholderTextColor={themeColors.placeholderText}
diff --git a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
index 83b709ab55bf..04fbe20cca61 100755
--- a/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
+++ b/src/pages/signin/ValidateCodeForm/BaseValidateCodeForm.js
@@ -203,7 +203,7 @@ class BaseValidateCodeForm extends React.Component {
(this.input2FA = el)}
- label={this.props.translate('validateCodeForm.twoFactorCode')}
+ label={this.props.translate('common.twoFactorCode')}
value={this.state.twoFactorAuthCode}
placeholder={this.props.translate('validateCodeForm.requiredWhen2FAEnabled')}
placeholderTextColor={themeColors.placeholderText}
diff --git a/src/styles/styles.js b/src/styles/styles.js
index 8763ecc765dd..e7b20f849636 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -2108,6 +2108,58 @@ const styles = {
width: '100%',
},
+ twoFactorAuthSection: {
+ backgroundColor: themeColors.appBG,
+ padding: 0,
+ },
+
+ twoFactorAuthCodesBox: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ backgroundColor: themeColors.highlightBG,
+ paddingVertical: 28,
+ paddingHorizontal: 60,
+ borderRadius: 16,
+ marginTop: 32,
+ },
+
+ twoFactorLoadingContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ height: 210,
+ },
+
+ twoFactorAuthCodesContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: 12,
+ height: 148,
+ },
+
+ twoFactorAuthCode: {
+ fontFamily: fontFamily.MONOSPACE,
+ width: 100,
+ height: 18,
+ textAlign: 'center',
+ },
+
+ twoFactorAuthCodesButtonsContainer: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ gap: 12,
+ marginTop: 20,
+ },
+
+ twoFactorAuthCodesButton: {
+ minWidth: 100,
+ },
+
+ twoFactorAuthFooter: {
+ marginTop: 'auto',
+ },
+
roomHeaderAvatarSize: {
height: variables.componentSizeLarge,
width: variables.componentSizeLarge,
diff --git a/src/styles/utilities/spacing.js b/src/styles/utilities/spacing.js
index 8c876094f338..79e9f8a4723f 100644
--- a/src/styles/utilities/spacing.js
+++ b/src/styles/utilities/spacing.js
@@ -133,6 +133,10 @@ export default {
marginLeft: 20,
},
+ ml6: {
+ marginLeft: 24,
+ },
+
ml8: {
marginLeft: 32,
},
@@ -181,6 +185,10 @@ export default {
marginTop: 32,
},
+ mt11: {
+ marginTop: 44,
+ },
+
mb0: {
marginBottom: 0,
},