Skip to content

Commit 003b1cd

Browse files
authored
feat(experience): email phone MFA verification (#7696)
1 parent c89c732 commit 003b1cd

File tree

28 files changed

+621
-0
lines changed

28 files changed

+621
-0
lines changed

packages/experience/src/App.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import TotpBinding from './pages/MfaBinding/TotpBinding';
2626
import WebAuthnBinding from './pages/MfaBinding/WebAuthnBinding';
2727
import MfaVerification from './pages/MfaVerification';
2828
import BackupCodeVerification from './pages/MfaVerification/BackupCodeVerification';
29+
import EmailVerificationCode from './pages/MfaVerification/EmailVerificationCode';
30+
import PhoneVerificationCode from './pages/MfaVerification/PhoneVerificationCode';
2931
import TotpVerification from './pages/MfaVerification/TotpVerification';
3032
import WebAuthnVerification from './pages/MfaVerification/WebAuthnVerification';
3133
import OneTimeToken from './pages/OneTimeToken';
@@ -125,6 +127,14 @@ const App = () => {
125127
<Route path={MfaFactor.TOTP} element={<TotpVerification />} />
126128
<Route path={MfaFactor.WebAuthn} element={<WebAuthnVerification />} />
127129
<Route path={MfaFactor.BackupCode} element={<BackupCodeVerification />} />
130+
<Route
131+
path={MfaFactor.EmailVerificationCode}
132+
element={<EmailVerificationCode />}
133+
/>
134+
<Route
135+
path={MfaFactor.PhoneVerificationCode}
136+
element={<PhoneVerificationCode />}
137+
/>
128138
</Route>
129139

130140
{/* Continue set up missing profile */}

packages/experience/src/apis/experience/mfa.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
MfaFactor,
3+
type SignInIdentifier,
34
type WebAuthnRegistrationOptions,
45
type WebAuthnAuthenticationOptions,
56
type BindMfaPayload,
@@ -130,3 +131,24 @@ export const verifyMfa = async (payload: VerifyMfaPayload, verificationId?: stri
130131

131132
return submitInteraction();
132133
};
134+
135+
// Email/Phone MFA verification code
136+
export const sendMfaVerificationCode = async (
137+
identifierType: SignInIdentifier.Email | SignInIdentifier.Phone
138+
) =>
139+
api
140+
.post(`${experienceApiRoutes.verification}/mfa-verification-code`, {
141+
json: { identifierType },
142+
})
143+
.json<{ verificationId: string }>();
144+
145+
export const verifyMfaByVerificationCode = async (
146+
verificationId: string,
147+
code: string,
148+
identifierType: SignInIdentifier.Email | SignInIdentifier.Phone
149+
) => {
150+
await api.post(`${experienceApiRoutes.verification}/mfa-verification-code/verify`, {
151+
json: { verificationId, code, identifierType },
152+
});
153+
return submitInteraction();
154+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
@use '@/scss/underscore' as _;
2+
3+
.codeInput {
4+
margin-top: _.unit(4);
5+
}
6+
7+
.continueButton {
8+
margin-top: _.unit(6);
9+
}
10+
11+
.message {
12+
margin-top: _.unit(3);
13+
}
14+
15+
.link {
16+
cursor: pointer;
17+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { type SignInIdentifier } from '@logto/schemas';
2+
import { useCallback, useEffect, useState } from 'react';
3+
import { Trans, useTranslation } from 'react-i18next';
4+
5+
import Button from '@/components/Button';
6+
import TextLink from '@/components/TextLink';
7+
import VerificationCodeInput from '@/components/VerificationCode';
8+
9+
import styles from './index.module.scss';
10+
import useMfaCodeVerification from './use-mfa-code-verification';
11+
import useResendMfaVerificationCode from './use-resend-mfa-verification-code';
12+
13+
const codeLength = 6;
14+
15+
const isCodeReady = (code: string[]) => {
16+
return code.length === codeLength && code.every(Boolean);
17+
};
18+
19+
type Props = {
20+
readonly identifierType: SignInIdentifier.Email | SignInIdentifier.Phone;
21+
readonly verificationId: string;
22+
};
23+
24+
const MfaCodeVerification = ({ identifierType, verificationId }: Props) => {
25+
const { t } = useTranslation();
26+
const [codeInput, setCodeInput] = useState<string[]>([]);
27+
const [inputErrorMessage, setInputErrorMessage] = useState<string>();
28+
const [currentVerificationId, setCurrentVerificationId] = useState(verificationId);
29+
30+
useEffect(() => {
31+
setCurrentVerificationId(verificationId);
32+
}, [verificationId]);
33+
34+
const errorCallback = useCallback(() => {
35+
setCodeInput([]);
36+
setInputErrorMessage(undefined);
37+
}, []);
38+
39+
const { errorMessage: submitErrorMessage, onSubmit } = useMfaCodeVerification(
40+
identifierType,
41+
currentVerificationId,
42+
errorCallback
43+
);
44+
45+
const [isSubmitting, setIsSubmitting] = useState(false);
46+
47+
const errorMessage = inputErrorMessage ?? submitErrorMessage;
48+
49+
const { seconds, isRunning, onResendVerificationCode } =
50+
useResendMfaVerificationCode(identifierType);
51+
52+
const handleSubmit = useCallback(
53+
async (code: string[]) => {
54+
if (isSubmitting) {
55+
return;
56+
}
57+
58+
setInputErrorMessage(undefined);
59+
setIsSubmitting(true);
60+
61+
await onSubmit(code.join(''));
62+
setIsSubmitting(false);
63+
},
64+
[onSubmit, isSubmitting]
65+
);
66+
67+
return (
68+
<>
69+
<VerificationCodeInput
70+
name="mfaCode"
71+
value={codeInput}
72+
className={styles.codeInput}
73+
error={errorMessage}
74+
onChange={(code) => {
75+
setCodeInput(code);
76+
if (isCodeReady(code)) {
77+
void handleSubmit(code);
78+
}
79+
}}
80+
/>
81+
<div className={styles.message}>
82+
{isRunning ? (
83+
<Trans components={{ span: <span key="counter" /> }}>
84+
{t('description.resend_after_seconds', { seconds })}
85+
</Trans>
86+
) : (
87+
<Trans
88+
components={{
89+
a: (
90+
<TextLink
91+
className={styles.link}
92+
onClick={async () => {
93+
setInputErrorMessage(undefined);
94+
setCodeInput([]);
95+
const newId = await onResendVerificationCode();
96+
if (newId) {
97+
setCurrentVerificationId(newId);
98+
}
99+
}}
100+
/>
101+
),
102+
}}
103+
>
104+
{t('description.resend_passcode')}
105+
</Trans>
106+
)}
107+
</div>
108+
<Button
109+
title="action.continue"
110+
type="primary"
111+
className={styles.continueButton}
112+
isLoading={isSubmitting}
113+
onClick={() => {
114+
if (!isCodeReady(codeInput)) {
115+
setInputErrorMessage(t('error.invalid_passcode'));
116+
return;
117+
}
118+
119+
void handleSubmit(codeInput);
120+
}}
121+
/>
122+
</>
123+
);
124+
};
125+
126+
export default MfaCodeVerification;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { InteractionEvent, type SignInIdentifier } from '@logto/schemas';
2+
import { useCallback, useMemo, useState } from 'react';
3+
4+
import { verifyMfaByVerificationCode } from '@/apis/experience';
5+
import useApi from '@/hooks/use-api';
6+
import type { ErrorHandlers } from '@/hooks/use-error-handler';
7+
import useErrorHandler from '@/hooks/use-error-handler';
8+
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
9+
import useSubmitInteractionErrorHandler from '@/hooks/use-submit-interaction-error-handler';
10+
11+
import useGeneralVerificationCodeErrorHandler from '../VerificationCode/use-general-verification-code-error-handler';
12+
13+
const useMfaCodeVerification = (
14+
identifierType: SignInIdentifier.Email | SignInIdentifier.Phone,
15+
verificationId: string,
16+
errorCallback?: () => void
17+
) => {
18+
const [errorMessage, setErrorMessage] = useState<string>();
19+
const asyncVerify = useApi(verifyMfaByVerificationCode);
20+
const handleError = useErrorHandler();
21+
const redirectTo = useGlobalRedirectTo();
22+
23+
const { generalVerificationCodeErrorHandlers, errorMessage: generalErrorMessage } =
24+
useGeneralVerificationCodeErrorHandler();
25+
26+
// In sign-in event, submitting interaction shares same error handling
27+
const submitInteractionErrorHandler = useSubmitInteractionErrorHandler(InteractionEvent.SignIn, {
28+
replace: true,
29+
});
30+
31+
const errorHandlers: ErrorHandlers = useMemo(
32+
() => ({
33+
...generalVerificationCodeErrorHandlers,
34+
...submitInteractionErrorHandler,
35+
}),
36+
[generalVerificationCodeErrorHandlers, submitInteractionErrorHandler]
37+
);
38+
39+
const onSubmit = useCallback(
40+
async (code: string) => {
41+
const [error, result] = await asyncVerify(verificationId, code, identifierType);
42+
43+
if (error) {
44+
await handleError(error, errorHandlers);
45+
setErrorMessage(generalErrorMessage);
46+
errorCallback?.();
47+
return;
48+
}
49+
50+
if (result?.redirectTo) {
51+
await redirectTo(result.redirectTo);
52+
}
53+
},
54+
[
55+
asyncVerify,
56+
errorCallback,
57+
errorHandlers,
58+
generalErrorMessage,
59+
handleError,
60+
identifierType,
61+
redirectTo,
62+
verificationId,
63+
]
64+
);
65+
66+
return {
67+
errorMessage: errorMessage ?? generalErrorMessage,
68+
onSubmit,
69+
};
70+
};
71+
72+
export default useMfaCodeVerification;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { type SignInIdentifier } from '@logto/schemas';
2+
import { t } from 'i18next';
3+
import { useCallback } from 'react';
4+
import { useTimer } from 'react-timer-hook';
5+
6+
import { sendMfaVerificationCode } from '@/apis/experience';
7+
import useApi from '@/hooks/use-api';
8+
import useErrorHandler from '@/hooks/use-error-handler';
9+
import useToast from '@/hooks/use-toast';
10+
11+
export const timeRange = 59;
12+
13+
const getTimeout = () => {
14+
const now = new Date();
15+
now.setSeconds(now.getSeconds() + timeRange);
16+
return now;
17+
};
18+
19+
const useResendMfaVerificationCode = (
20+
identifierType: SignInIdentifier.Email | SignInIdentifier.Phone
21+
) => {
22+
const { setToast } = useToast();
23+
const handleError = useErrorHandler();
24+
const resend = useApi(sendMfaVerificationCode);
25+
26+
const { seconds, isRunning, restart } = useTimer({
27+
autoStart: true,
28+
expiryTimestamp: getTimeout(),
29+
});
30+
31+
const onResendVerificationCode = useCallback(async () => {
32+
const [error, result] = await resend(identifierType);
33+
34+
if (error) {
35+
await handleError(error);
36+
return;
37+
}
38+
39+
if (result) {
40+
setToast(t('description.passcode_sent'));
41+
restart(getTimeout(), true);
42+
}
43+
44+
return result?.verificationId;
45+
}, [handleError, identifierType, resend, restart, setToast]);
46+
47+
return {
48+
seconds,
49+
isRunning,
50+
onResendVerificationCode,
51+
};
52+
};
53+
54+
export default useResendMfaVerificationCode;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
@use '@/scss/underscore' as _;
2+
3+
.switchFactorLink {
4+
margin-top: _.unit(6);
5+
}
6+
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { SignInIdentifier } from '@logto/schemas';
2+
import { useEffect, useState } from 'react';
3+
4+
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
5+
import SectionLayout from '@/Layout/SectionLayout';
6+
import { sendMfaVerificationCode } from '@/apis/experience';
7+
import SwitchMfaFactorsLink from '@/components/SwitchMfaFactorsLink';
8+
import MfaCodeVerification from '@/containers/MfaCodeVerification';
9+
import useErrorHandler from '@/hooks/use-error-handler';
10+
import useMfaFlowState from '@/hooks/use-mfa-factors-state';
11+
import ErrorPage from '@/pages/ErrorPage';
12+
import { UserMfaFlow } from '@/types';
13+
14+
import styles from './index.module.scss';
15+
16+
const EmailVerificationCode = () => {
17+
const flowState = useMfaFlowState();
18+
const [verificationId, setVerificationId] = useState<string>();
19+
const handleError = useErrorHandler();
20+
21+
useEffect(() => {
22+
void (async () => {
23+
try {
24+
const { verificationId } = await sendMfaVerificationCode(SignInIdentifier.Email);
25+
setVerificationId(verificationId);
26+
} catch (error) {
27+
await handleError(error);
28+
}
29+
})();
30+
}, [handleError]);
31+
32+
if (!flowState) {
33+
return <ErrorPage title="error.invalid_session" />;
34+
}
35+
36+
return (
37+
<SecondaryPageLayout title="mfa.verify_mfa_factors">
38+
<SectionLayout
39+
title="mfa.enter_email_verification_code"
40+
description="mfa.enter_email_verification_code_description"
41+
>
42+
{verificationId ? (
43+
<MfaCodeVerification
44+
identifierType={SignInIdentifier.Email}
45+
verificationId={verificationId}
46+
/>
47+
) : null}
48+
</SectionLayout>
49+
<SwitchMfaFactorsLink
50+
flow={UserMfaFlow.MfaVerification}
51+
flowState={flowState}
52+
className={styles.switchFactorLink}
53+
/>
54+
</SecondaryPageLayout>
55+
);
56+
};
57+
58+
export default EmailVerificationCode;

0 commit comments

Comments
 (0)