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')} + + + + + + + + + + + + {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', +});