From b7c1a500ecc8f12ef3cd1bf94591c142616bb49e Mon Sep 17 00:00:00 2001
From: rhahao <26148770+rhahao@users.noreply.github.com>
Date: Wed, 8 Mar 2023 20:04:46 +0300
Subject: [PATCH] feat(startup): send signin verification code to email
---
src/api/auth.js | 123 +++++++++++++++
src/api/index.js | 1 +
src/features/startup/vip/EmailOTP.jsx | 202 +++++++++++++++++++++++++
src/features/startup/vip/SetupMFA.jsx | 17 ++-
src/features/startup/vip/VerifyMFA.jsx | 22 ++-
src/features/startup/vip/index.jsx | 11 ++
src/locales/en/ui.json | 8 +-
src/states/main.js | 10 ++
8 files changed, 383 insertions(+), 11 deletions(-)
create mode 100644 src/features/startup/vip/EmailOTP.jsx
diff --git a/src/api/auth.js b/src/api/auth.js
index 425f5f5600..d6ddd0e4c5 100644
--- a/src/api/auth.js
+++ b/src/api/auth.js
@@ -154,6 +154,129 @@ export const apiHandleVerifyOTP = async (userOTP, isSetup, trustedDevice) => {
}
};
+export const apiHandleVerifyEmailOTP = async (userOTP) => {
+ try {
+ const { t } = getI18n();
+
+ const { apiHost, visitorID } = await getProfile();
+
+ const auth = await getAuth();
+ const user = auth.currentUser;
+
+ if (userOTP.length === 6) {
+ if (apiHost !== '') {
+ const res = await fetch(`${apiHost}verify-otp-code`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ visitorid: visitorID,
+ uid: user.uid,
+ },
+ body: JSON.stringify({ code: userOTP }),
+ });
+
+ const data = await res.json();
+
+ if (res.status !== 200) {
+ if (data.message) {
+ if (data.message === 'TOKEN_INVALID') data.message = t('mfaTokenInvalidExpired', { ns: 'ui' });
+ if (data.message === 'EMAIL_OTP_INVALID') data.message = t('emailOTPInvalidExpired', { ns: 'ui' });
+ await promiseSetRecoil(appMessageState, data.message);
+ await promiseSetRecoil(appSeverityState, 'warning');
+ await promiseSetRecoil(appSnackOpenState, true);
+ return {};
+ }
+ }
+
+ const { id, cong_id, cong_name, cong_role, cong_number, pocket_members } = data;
+
+ if (cong_name.length === 0) return { createCongregation: true };
+
+ if (cong_role.length === 0) return { unauthorized: true };
+
+ if (!cong_role.includes('lmmo') && !cong_role.includes('lmmo-backup')) return { unauthorized: true };
+
+ backupWorkerInstance.setCongID(cong_id);
+ await promiseSetRecoil(congIDState, cong_id);
+
+ const settings = await dbGetAppSettings();
+ if (settings.isCongUpdated2 === undefined) {
+ return { updateCongregation: true };
+ }
+
+ if (cong_role.includes('admin')) {
+ await promiseSetRecoil(isAdminCongState, true);
+ }
+
+ const isMainDb = await isDbExist('cpe_sws');
+ if (!isMainDb) await initAppDb();
+
+ // save congregation update if any
+ let obj = {};
+ obj.username = data.username;
+ obj.cong_name = cong_name;
+ obj.cong_number = cong_number;
+ obj.isLoggedOut = false;
+ obj.pocket_members = pocket_members;
+ obj.cong_role = cong_role;
+ obj.account_type = 'vip';
+ await dbUpdateAppSettings(obj);
+
+ await promiseSetRecoil(userIDState, id);
+ await promiseSetRecoil(pocketMembersState, pocket_members);
+ await promiseSetRecoil(accountTypeState, 'vip');
+ await promiseSetRecoil(congRoleState, cong_role);
+
+ await loadApp();
+
+ return { success: true };
+ }
+ }
+ } catch (err) {
+ await promiseSetRecoil(appMessageState, err.message);
+ await promiseSetRecoil(appSeverityState, 'error');
+ await promiseSetRecoil(appSnackOpenState, true);
+ return {};
+ }
+};
+
+export const apiRequestTempOTPCode = async (uid) => {
+ const { t } = getI18n();
+
+ try {
+ const { apiHost, appLang, visitorID } = await getProfile();
+
+ if (apiHost !== '' && uid !== '') {
+ const res = await fetch(`${apiHost}request-otp-code`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ applanguage: appLang,
+ uid: uid,
+ visitorid: visitorID,
+ },
+ });
+
+ const data = await res.json();
+ if (res.status === 200) {
+ return { success: true };
+ } else {
+ if (data.message) {
+ await promiseSetRecoil(appMessageState, data.message);
+ await promiseSetRecoil(appSeverityState, 'warning');
+ await promiseSetRecoil(appSnackOpenState, true);
+ return {};
+ }
+ }
+ }
+ } catch (err) {
+ await promiseSetRecoil(appMessageState, t('sendEmailError', { ns: 'ui' }));
+ await promiseSetRecoil(appSeverityState, 'error');
+ await promiseSetRecoil(appSnackOpenState, true);
+ return {};
+ }
+};
+
export const apiRequestPasswordlesssLink = async (email, uid) => {
const { t } = getI18n();
diff --git a/src/api/index.js b/src/api/index.js
index 6e8c2e09c4..f2c98a7034 100644
--- a/src/api/index.js
+++ b/src/api/index.js
@@ -7,6 +7,7 @@ export const {
apiPocketValidate,
apiFetchPocketSessions,
apiPocketDeviceDelete,
+ apiRequestTempOTPCode,
} = await import('./auth.js');
export const { apiFetchCountries, apiFetchCongregations, apiCreateCongregation, apiUpdateCongregation } = await import(
diff --git a/src/features/startup/vip/EmailOTP.jsx b/src/features/startup/vip/EmailOTP.jsx
new file mode 100644
index 0000000000..00d99ef92d
--- /dev/null
+++ b/src/features/startup/vip/EmailOTP.jsx
@@ -0,0 +1,202 @@
+import { useEffect, useRef, useState } from 'react';
+import { useRecoilValue, useSetRecoilState } from 'recoil';
+import { useTranslation } from 'react-i18next';
+import { MuiOtpInput } from 'mui-one-time-password-input';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import CircularProgress from '@mui/material/CircularProgress';
+import Container from '@mui/material/Container';
+import Link from '@mui/material/Link';
+import Typography from '@mui/material/Typography';
+import {
+ currentMFAStageState,
+ isAppLoadState,
+ isCongAccountCreateState,
+ isSetupState,
+ isUnauthorizedRoleState,
+ isUserEmailOTPState,
+ isUserMfaSetupState,
+ isUserMfaVerifyState,
+ offlineOverrideState,
+ visitorIDState,
+} from '../../../states/main';
+import { apiHandleVerifyEmailOTP } from '../../../api/auth';
+import { appMessageState, appSeverityState, appSnackOpenState } from '../../../states/notification';
+import { congAccountConnectedState } from '../../../states/congregation';
+import { runUpdater } from '../../../utils/updater';
+import { apiRequestTempOTPCode } from '../../../api';
+import useFirebaseAuth from '../../../hooks/useFirebaseAuth';
+
+const matchIsNumeric = (text) => {
+ return !isNaN(Number(text));
+};
+
+const validateChar = (value, index) => {
+ return matchIsNumeric(value);
+};
+
+const EmailOTP = () => {
+ const cancel = useRef();
+
+ const { t } = useTranslation('ui');
+
+ const setAppSnackOpen = useSetRecoilState(appSnackOpenState);
+ const setAppSeverity = useSetRecoilState(appSeverityState);
+ const setAppMessage = useSetRecoilState(appMessageState);
+ const setIsSetup = useSetRecoilState(isSetupState);
+ const setIsAppLoad = useSetRecoilState(isAppLoadState);
+ const setCongAccountConnected = useSetRecoilState(congAccountConnectedState);
+ const setOfflineOverride = useSetRecoilState(offlineOverrideState);
+ const setIsUnauthorizedRole = useSetRecoilState(isUnauthorizedRoleState);
+ const setIsCongAccountCreate = useSetRecoilState(isCongAccountCreateState);
+ const setIsUserEmailOTP = useSetRecoilState(isUserEmailOTPState);
+ const setIsUserMfaSetup = useSetRecoilState(isUserMfaSetupState);
+ const setIsUserMfaVerify = useSetRecoilState(isUserMfaVerifyState);
+
+ const visitorID = useRecoilValue(visitorIDState);
+ const currentMFAStage = useRecoilValue(currentMFAStageState);
+
+ const [userOTP, setUserOTP] = useState('');
+ const [isProcessing, setIsProcessing] = useState(false);
+ const [isProcessingOTP, setIsProcessingOTP] = useState(false);
+
+ const { user } = useFirebaseAuth();
+
+ const handleOtpChange = async (newValue) => {
+ setUserOTP(newValue);
+ };
+
+ const handleSendEmailOTP = async () => {
+ try {
+ setIsProcessingOTP(true);
+ cancel.current = false;
+
+ const response = await apiRequestTempOTPCode(user.uid);
+
+ if (!cancel.current) {
+ if (response.success) {
+ setAppMessage(t('emailOTPCodeSent'));
+ setAppSeverity('success');
+ setAppSnackOpen(true);
+ }
+ setIsProcessingOTP(false);
+ }
+ } catch (err) {
+ if (!cancel.current) {
+ setIsProcessingOTP(false);
+ setAppMessage(err.message);
+ setAppSeverity('error');
+ setAppSnackOpen(true);
+ }
+ }
+ };
+
+ const handleVerifyEmailOTP = async () => {
+ try {
+ setIsProcessing(true);
+ cancel.current = false;
+
+ const response = await apiHandleVerifyEmailOTP(userOTP);
+
+ if (!cancel.current) {
+ if (response.success) {
+ setIsSetup(false);
+
+ await runUpdater();
+ setTimeout(() => {
+ setOfflineOverride(false);
+ setCongAccountConnected(true);
+ setIsAppLoad(false);
+ }, [2000]);
+ }
+
+ if (response.unauthorized) {
+ setIsUserEmailOTP(false);
+ setIsUnauthorizedRole(true);
+ }
+
+ if (response.createCongregation) {
+ setIsUserEmailOTP(false);
+ setIsCongAccountCreate(true);
+ }
+
+ setIsProcessing(false);
+ }
+ } catch (err) {
+ if (!cancel.current) {
+ setIsProcessing(false);
+ setAppMessage(err.message);
+ setAppSeverity('error');
+ setAppSnackOpen(true);
+ }
+ }
+ };
+
+ const handleAuthenticatorApp = () => {
+ setIsUserEmailOTP(false);
+ if (currentMFAStage === 'setup') setIsUserMfaSetup(true);
+ if (currentMFAStage === 'verify') setIsUserMfaVerify(true);
+ };
+
+ useEffect(() => {
+ return () => {
+ cancel.current = true;
+ };
+ }, []);
+
+ return (
+
+
+ {t('sendOTPEmail')}
+
+
+ {t('sendOTPEmailDesc')}
+
+ : null}
+ >
+ {t('sendOTPCode')}
+
+
+
+
+
+
+
+ : null}
+ >
+ {t('mfaVerify')}
+
+
+
+ {t('useAuthenticatorApp')}
+
+
+
+ );
+};
+
+export default EmailOTP;
diff --git a/src/features/startup/vip/SetupMFA.jsx b/src/features/startup/vip/SetupMFA.jsx
index 6f789e0fd0..3d1676758c 100644
--- a/src/features/startup/vip/SetupMFA.jsx
+++ b/src/features/startup/vip/SetupMFA.jsx
@@ -22,6 +22,7 @@ import {
isReEnrollMFAState,
isSetupState,
isUnauthorizedRoleState,
+ isUserEmailOTPState,
isUserMfaSetupState,
offlineOverrideState,
qrCodePathState,
@@ -69,6 +70,7 @@ const SetupMFA = () => {
const setIsAppLoad = useSetRecoilState(isAppLoadState);
const setCongAccountConnected = useSetRecoilState(congAccountConnectedState);
const setOfflineOverride = useSetRecoilState(offlineOverrideState);
+ const setIsUserEmailOTP = useSetRecoilState(isUserEmailOTPState);
const qrCodePath = useRecoilValue(qrCodePathState);
const token = useRecoilValue(secretTokenPathState);
@@ -139,6 +141,11 @@ const SetupMFA = () => {
setUserOTP(newValue);
};
+ const handleEmailOTP = () => {
+ setIsUserEmailOTP(true);
+ setIsUserMfaSetup(false);
+ };
+
useEffect(() => {
if (userOTP.length === 6) {
handleVerifyOTP();
@@ -270,9 +277,9 @@ const SetupMFA = () => {
sx={{
marginTop: '20px',
display: 'flex',
- flexDirection: 'column',
- alignItems: 'flex-start',
- gap: '10px',
+ alignItems: 'flex-end',
+ gap: '20px',
+ flexWrap: 'wrap',
}}
>
+
+
+ {t('sendOTPEmail')}
+
);
diff --git a/src/features/startup/vip/VerifyMFA.jsx b/src/features/startup/vip/VerifyMFA.jsx
index a993b7f68b..2e8f6bb104 100644
--- a/src/features/startup/vip/VerifyMFA.jsx
+++ b/src/features/startup/vip/VerifyMFA.jsx
@@ -8,6 +8,7 @@ import Checkbox from '@mui/material/Checkbox';
import CircularProgress from '@mui/material/CircularProgress';
import Container from '@mui/material/Container';
import FormControlLabel from '@mui/material/FormControlLabel';
+import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography';
import { appMessageState, appSeverityState, appSnackOpenState } from '../../../states/notification';
import {
@@ -16,6 +17,7 @@ import {
isReEnrollMFAState,
isSetupState,
isUnauthorizedRoleState,
+ isUserEmailOTPState,
isUserMfaSetupState,
isUserMfaVerifyState,
offlineOverrideState,
@@ -54,6 +56,7 @@ const VerifyMFA = () => {
const setOfflineOverride = useSetRecoilState(offlineOverrideState);
const setIsReEnrollMFA = useSetRecoilState(isReEnrollMFAState);
const setIsUserMfaSetup = useSetRecoilState(isUserMfaSetupState);
+ const setIsUserEmailOTP = useSetRecoilState(isUserEmailOTPState);
const visitorID = useRecoilValue(visitorIDState);
@@ -97,7 +100,6 @@ const VerifyMFA = () => {
}
setIsProcessing(false);
- setOfflineOverride(false);
}
} catch (err) {
if (!cancel.current) {
@@ -123,6 +125,11 @@ const VerifyMFA = () => {
userOTP,
]);
+ const handleEmailOTP = () => {
+ setIsUserEmailOTP(true);
+ setIsUserMfaVerify(false);
+ };
+
useEffect(() => {
if (userOTP.length === 6) {
handleVerifyOTP();
@@ -174,14 +181,11 @@ const VerifyMFA = () => {
+
+
+ {t('sendOTPEmail')}
+
);
diff --git a/src/features/startup/vip/index.jsx b/src/features/startup/vip/index.jsx
index 9c95bec411..bf91472ec7 100644
--- a/src/features/startup/vip/index.jsx
+++ b/src/features/startup/vip/index.jsx
@@ -4,6 +4,7 @@ import useFirebaseAuth from '../../../hooks/useFirebaseAuth';
import { loadApp } from '../../../utils/app';
import { runUpdater } from '../../../utils/updater';
import {
+ currentMFAStageState,
isAppLoadState,
isAuthProcessingState,
isCongAccountCreateState,
@@ -13,6 +14,7 @@ import {
isOnlineState,
isSetupState,
isShowTermsUseState,
+ isUserEmailOTPState,
isUserMfaSetupState,
isUserMfaVerifyState,
isUserSignInState,
@@ -35,6 +37,7 @@ const EmailAuth = lazy(() => import('./EmailAuth'));
const EmailBlocked = lazy(() => import('./EmailBlocked'));
const CongregationCreate = lazy(() => import('./CongregationCreate'));
const TermsUse = lazy(() => import('./TermsUse'));
+const EmailOTP = lazy(() => import('./EmailOTP'));
const VipStartup = () => {
const { isAuthenticated } = useFirebaseAuth();
@@ -43,6 +46,7 @@ const VipStartup = () => {
const [isUserSignIn, setIsUserSignIn] = useRecoilState(isUserSignInState);
const [isUserMfaVerify, setUserMfaVerify] = useRecoilState(isUserMfaVerifyState);
const [isUserMfaSetup, setUserMfaSetup] = useRecoilState(isUserMfaSetupState);
+ const [isUserEmailOTP, setUserEmailOTP] = useRecoilState(isUserEmailOTPState);
const [isCongAccountCreate, setIsCongAccountCreate] = useRecoilState(isCongAccountCreateState);
const [isAuthProcessing, setIsAuthProcessing] = useRecoilState(isAuthProcessingState);
@@ -51,6 +55,7 @@ const VipStartup = () => {
const setAppSnackOpen = useSetRecoilState(appSnackOpenState);
const setAppSeverity = useSetRecoilState(appSeverityState);
const setAppMessage = useSetRecoilState(appMessageState);
+ const setCurrentMFAStage = useSetRecoilState(currentMFAStageState);
const showTermsUse = useRecoilValue(isShowTermsUseState);
const isEmailNotVerified = useRecoilValue(isEmailNotVerifiedState);
@@ -69,6 +74,7 @@ const VipStartup = () => {
setIsCongAccountCreate(false);
setUserMfaVerify(false);
setUserMfaSetup(false);
+ setUserEmailOTP(false);
};
const runNotAuthenticatedStep = async () => {
@@ -116,6 +122,7 @@ const VipStartup = () => {
setIsUserSignUp,
setUserMfaSetup,
setUserMfaVerify,
+ setUserEmailOTP,
showTermsUse,
]);
@@ -149,10 +156,12 @@ const VipStartup = () => {
if (result.isSetupMFA || result.isVerifyMFA) {
if (result.isVerifyMFA) {
+ setCurrentMFAStage('verify');
setIsUserSignUp(false);
setUserMfaVerify(true);
}
if (result.isSetupMFA) {
+ setCurrentMFAStage('setup');
setIsUserSignUp(false);
setUserMfaSetup(true);
}
@@ -183,6 +192,7 @@ const VipStartup = () => {
setUserMfaSetup,
setUserMfaVerify,
setIsCongAccountCreate,
+ setCurrentMFAStage,
showTermsUse,
visitorID,
]);
@@ -193,6 +203,7 @@ const VipStartup = () => {
{showTermsUse && }
{isUserSignIn && }
{isUserSignUp && }
+ {isUserEmailOTP && }
{isEmailNotVerified && }
{isUserMfaSetup && }
{isUserMfaVerify && }
diff --git a/src/locales/en/ui.json b/src/locales/en/ui.json
index 3ad362e52c..43020bc3ab 100644
--- a/src/locales/en/ui.json
+++ b/src/locales/en/ui.json
@@ -466,5 +466,11 @@
"mwbIssueMonth": "Issue month",
"scheduleSettings": "Schedule settings",
"autoAssignOpeningPrayer": "Auto-assign Chairman for Opening Prayer",
- "personsNoAssignment": "Not assigned yet"
+ "personsNoAssignment": "Not assigned yet",
+ "sendOTPEmail": "Send code to my email address",
+ "sendOTPEmailDesc": "If you are not yet able to use an authenticator app, send a temporary verification code to your email address. This code will only be valid for 5 minutes.",
+ "useAuthenticatorApp": "Use Authenticator app",
+ "sendOTPCode": "Send code",
+ "emailOTPCodeSent": "The verification code has been sent to your email address",
+ "emailOTPInvalidExpired": "This code is invalid or has expired. Send a new code and try again"
}
diff --git a/src/states/main.js b/src/states/main.js
index 4ab257c8e6..ae5a8d9478 100644
--- a/src/states/main.js
+++ b/src/states/main.js
@@ -391,3 +391,13 @@ export const isFetchingScheduleState = atom({
key: 'isFetchingSchedule',
default: true,
});
+
+export const isUserEmailOTPState = atom({
+ key: 'isUserEmailOTP',
+ default: false,
+});
+
+export const currentMFAStageState = atom({
+ key: 'currentMFAStage',
+ default: 'setup',
+});