From 5f8875c688aa4fff32717288bcb7c10502aa201c Mon Sep 17 00:00:00 2001 From: simeng-li Date: Thu, 13 Oct 2022 19:05:21 +0800 Subject: [PATCH] refactor(ui): refactor passwordless flow refactor passwordless flow --- .../session/middleware/passwordless-action.ts | 2 +- packages/phrases-ui/src/locales/en.ts | 1 + packages/phrases-ui/src/locales/fr.ts | 1 + packages/phrases-ui/src/locales/ko-kr.ts | 1 + packages/phrases-ui/src/locales/pt-pt.ts | 1 + packages/phrases-ui/src/locales/tr-tr.ts | 1 + packages/phrases-ui/src/locales/zh-cn.ts | 1 + packages/ui/src/apis/forgot-password.ts | 18 +- packages/ui/src/apis/index.test.ts | 46 ++++- packages/ui/src/apis/register.ts | 64 +++--- packages/ui/src/apis/sign-in.ts | 76 ++++--- .../containers/PasscodeValidation/index.tsx | 16 +- .../use-passwordless-error-handler.ts | 190 ++++++++++++++++++ .../Passwordless/EmailPasswordless.tsx | 84 +++----- .../Passwordless/PasswordlessConfirmModal.tsx | 67 ------ .../Passwordless/PhonePasswordless.tsx | 84 +++----- 16 files changed, 381 insertions(+), 272 deletions(-) create mode 100644 packages/ui/src/containers/PasscodeValidation/use-passwordless-error-handler.ts delete mode 100644 packages/ui/src/containers/Passwordless/PasswordlessConfirmModal.tsx diff --git a/packages/core/src/routes/session/middleware/passwordless-action.ts b/packages/core/src/routes/session/middleware/passwordless-action.ts index a03543e44..bf54023d3 100644 --- a/packages/core/src/routes/session/middleware/passwordless-action.ts +++ b/packages/core/src/routes/session/middleware/passwordless-action.ts @@ -73,7 +73,7 @@ export const emailSignInAction = { return { success: true }; }; -export const verifyForgotPasswordSmsPasscode = async (phone: string, code: string) => - api +export const verifyForgotPasswordSmsPasscode = async (phone: string, code: string) => { + await api .post('/api/session/passwordless/sms/verify', { json: { phone, @@ -30,7 +30,10 @@ export const verifyForgotPasswordSmsPasscode = async (phone: string, code: strin flow: PasscodeType.ForgotPassword, }, }) - .json(); + .json(); + + return { success: true }; +}; export const sendForgotPasswordEmailPasscode = async (email: string) => { await api @@ -45,8 +48,8 @@ export const sendForgotPasswordEmailPasscode = async (email: string) => { return { success: true }; }; -export const verifyForgotPasswordEmailPasscode = async (email: string, code: string) => - api +export const verifyForgotPasswordEmailPasscode = async (email: string, code: string) => { + await api .post('/api/session/passwordless/email/verify', { json: { email, @@ -54,7 +57,10 @@ export const verifyForgotPasswordEmailPasscode = async (email: string, code: str flow: PasscodeType.ForgotPassword, }, }) - .json(); + .json(); + + return { success: true }; +}; export const resetPassword = async (password: string) => { await api diff --git a/packages/ui/src/apis/index.test.ts b/packages/ui/src/apis/index.test.ts index f5ed44d29..2e625a86b 100644 --- a/packages/ui/src/apis/index.test.ts +++ b/packages/ui/src/apis/index.test.ts @@ -11,6 +11,8 @@ import { } from './forgot-password'; import { register, + registerWithSms, + registerWithEmail, sendRegisterEmailPasscode, sendRegisterSmsPasscode, verifyRegisterEmailPasscode, @@ -18,6 +20,8 @@ import { } from './register'; import { signInBasic, + signInWithSms, + signInWithEmail, sendSignInSmsPasscode, sendSignInEmailPasscode, verifySignInEmailPasscode, @@ -66,6 +70,26 @@ describe('api', () => { }); }); + it('signInWithSms', async () => { + mockKyPost.mockReturnValueOnce({ + json: () => ({ + redirectTo: '/', + }), + }); + await signInWithSms(); + expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/sms'); + }); + + it('signInWithEmail', async () => { + mockKyPost.mockReturnValueOnce({ + json: () => ({ + redirectTo: '/', + }), + }); + await signInWithEmail(); + expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/email'); + }); + it('signInBasic with bind social account', async () => { mockKyPost.mockReturnValueOnce({ json: () => ({ @@ -97,12 +121,14 @@ describe('api', () => { }); it('verifySignInSmsPasscode', async () => { - mockKyPost.mockReturnValueOnce({}).mockReturnValueOnce({ + mockKyPost.mockReturnValueOnce({ json: () => ({ redirectTo: '/', }), }); + await verifySignInSmsPasscode(phone, code); + expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/verify', { json: { phone, @@ -110,7 +136,6 @@ describe('api', () => { flow: PasscodeType.SignIn, }, }); - expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/sms'); }); it('sendSignInEmailPasscode', async () => { @@ -124,12 +149,14 @@ describe('api', () => { }); it('verifySignInEmailPasscode', async () => { - mockKyPost.mockReturnValueOnce({}).mockReturnValueOnce({ + mockKyPost.mockReturnValueOnce({ json: () => ({ redirectTo: '/', }), }); + await verifySignInEmailPasscode(email, code); + expect(ky.post).toBeCalledWith('/api/session/passwordless/email/verify', { json: { email, @@ -137,7 +164,6 @@ describe('api', () => { flow: PasscodeType.SignIn, }, }); - expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/email'); }); it('consent', async () => { @@ -155,6 +181,16 @@ describe('api', () => { }); }); + it('registerWithSms', async () => { + await registerWithSms(); + expect(ky.post).toBeCalledWith('/api/session/register/passwordless/sms'); + }); + + it('registerWithEmail', async () => { + await registerWithEmail(); + expect(ky.post).toBeCalledWith('/api/session/register/passwordless/email'); + }); + it('sendRegisterSmsPasscode', async () => { await sendRegisterSmsPasscode(phone); expect(ky.post).toBeCalledWith('/api/session/passwordless/sms/send', { @@ -174,7 +210,6 @@ describe('api', () => { flow: PasscodeType.Register, }, }); - expect(ky.post).toBeCalledWith('/api/session/register/passwordless/sms'); }); it('sendRegisterEmailPasscode', async () => { @@ -196,7 +231,6 @@ describe('api', () => { flow: PasscodeType.Register, }, }); - expect(ky.post).toBeCalledWith('/api/session/register/passwordless/email'); }); it('sendForgotPasswordSmsPasscode', async () => { diff --git a/packages/ui/src/apis/register.ts b/packages/ui/src/apis/register.ts index 85d262bcc..d2e80aed1 100644 --- a/packages/ui/src/apis/register.ts +++ b/packages/ui/src/apis/register.ts @@ -4,11 +4,11 @@ import api from './api'; const apiPrefix = '/api/session'; -export const register = async (username: string, password: string) => { - type Response = { - redirectTo: string; - }; +type Response = { + redirectTo: string; +}; +export const register = async (username: string, password: string) => { return api .post(`${apiPrefix}/register/username-password`, { json: { @@ -19,6 +19,12 @@ export const register = async (username: string, password: string) => { .json(); }; +export const registerWithSms = async () => + api.post(`${apiPrefix}/register/passwordless/sms`).json(); + +export const registerWithEmail = async () => + api.post(`${apiPrefix}/register/passwordless/email`).json(); + export const sendRegisterSmsPasscode = async (phone: string) => { await api .post(`${apiPrefix}/passwordless/sms/send`, { @@ -32,21 +38,16 @@ export const sendRegisterSmsPasscode = async (phone: string) => { return { success: true }; }; -export const verifyRegisterSmsPasscode = async (phone: string, code: string) => { - type Response = { - redirectTo: string; - }; - - await api.post(`${apiPrefix}/passwordless/sms/verify`, { - json: { - phone, - code, - flow: PasscodeType.Register, - }, - }); - - return api.post(`${apiPrefix}/register/passwordless/sms`).json(); -}; +export const verifyRegisterSmsPasscode = async (phone: string, code: string) => + api + .post(`${apiPrefix}/passwordless/sms/verify`, { + json: { + phone, + code, + flow: PasscodeType.Register, + }, + }) + .json(); export const sendRegisterEmailPasscode = async (email: string) => { await api @@ -61,18 +62,13 @@ export const sendRegisterEmailPasscode = async (email: string) => { return { success: true }; }; -export const verifyRegisterEmailPasscode = async (email: string, code: string) => { - type Response = { - redirectTo: string; - }; - - await api.post(`${apiPrefix}/passwordless/email/verify`, { - json: { - email, - code, - flow: PasscodeType.Register, - }, - }); - - return api.post(`${apiPrefix}/register/passwordless/email`).json(); -}; +export const verifyRegisterEmailPasscode = async (email: string, code: string) => + api + .post(`${apiPrefix}/passwordless/email/verify`, { + json: { + email, + code, + flow: PasscodeType.Register, + }, + }) + .json(); diff --git a/packages/ui/src/apis/sign-in.ts b/packages/ui/src/apis/sign-in.ts index c4a0dffc0..b83dc3081 100644 --- a/packages/ui/src/apis/sign-in.ts +++ b/packages/ui/src/apis/sign-in.ts @@ -3,13 +3,15 @@ import { PasscodeType } from '@logto/schemas'; import api from './api'; import { bindSocialAccount } from './social'; -export const signInBasic = async (username: string, password: string, socialToBind?: string) => { - type Response = { - redirectTo: string; - }; +const apiPrefix = '/api/session'; +type Response = { + redirectTo: string; +}; + +export const signInBasic = async (username: string, password: string, socialToBind?: string) => { const result = await api - .post('/api/session/sign-in/username-password', { + .post(`${apiPrefix}/sign-in/username-password`, { json: { username, password, @@ -24,9 +26,29 @@ export const signInBasic = async (username: string, password: string, socialToBi return result; }; +export const signInWithSms = async (socialToBind?: string) => { + const result = await api.post(`${apiPrefix}/sign-in/passwordless/sms`).json(); + + if (result.redirectTo && socialToBind) { + await bindSocialAccount(socialToBind); + } + + return result; +}; + +export const signInWithEmail = async (socialToBind?: string) => { + const result = await api.post(`${apiPrefix}/sign-in/passwordless/email`).json(); + + if (result.redirectTo && socialToBind) { + await bindSocialAccount(socialToBind); + } + + return result; +}; + export const sendSignInSmsPasscode = async (phone: string) => { await api - .post('/api/session/passwordless/sms/send', { + .post(`${apiPrefix}/passwordless/sms/send`, { json: { phone, flow: PasscodeType.SignIn, @@ -42,19 +64,15 @@ export const verifySignInSmsPasscode = async ( code: string, socialToBind?: string ) => { - type Response = { - redirectTo: string; - }; - - await api.post('/api/session/passwordless/sms/verify', { - json: { - phone, - code, - flow: PasscodeType.SignIn, - }, - }); - - const result = await api.post('/api/session/sign-in/passwordless/sms').json(); + const result = await api + .post(`${apiPrefix}/passwordless/sms/verify`, { + json: { + phone, + code, + flow: PasscodeType.SignIn, + }, + }) + .json(); if (result.redirectTo && socialToBind) { await bindSocialAccount(socialToBind); @@ -65,7 +83,7 @@ export const verifySignInSmsPasscode = async ( export const sendSignInEmailPasscode = async (email: string) => { await api - .post('/api/session/passwordless/email/send', { + .post(`${apiPrefix}/passwordless/email/send`, { json: { email, flow: PasscodeType.SignIn, @@ -85,15 +103,15 @@ export const verifySignInEmailPasscode = async ( redirectTo: string; }; - await api.post('/api/session/passwordless/email/verify', { - json: { - email, - code, - flow: PasscodeType.SignIn, - }, - }); - - const result = await api.post('/api/session/sign-in/passwordless/email').json(); + const result = await api + .post(`${apiPrefix}/passwordless/email/verify`, { + json: { + email, + code, + flow: PasscodeType.SignIn, + }, + }) + .json(); if (result.redirectTo && socialToBind) { await bindSocialAccount(socialToBind); diff --git a/packages/ui/src/containers/PasscodeValidation/index.tsx b/packages/ui/src/containers/PasscodeValidation/index.tsx index edcfa4913..38363ac36 100644 --- a/packages/ui/src/containers/PasscodeValidation/index.tsx +++ b/packages/ui/src/containers/PasscodeValidation/index.tsx @@ -7,8 +7,8 @@ import { useTimer } from 'react-timer-hook'; import { getSendPasscodeApi, getVerifyPasscodeApi } from '@/apis/utils'; import Passcode, { defaultLength } from '@/components/Passcode'; import TextLink from '@/components/TextLink'; +import usePasswordlessErrorHandler from '@/containers/PasscodeValidation/use-passwordless-error-handler'; import useApi, { ErrorHandlers } from '@/hooks/use-api'; -import { useConfirmModal } from '@/hooks/use-confirm-modal'; import { PageContext } from '@/hooks/use-page-context'; import { UserFlow, SearchParameters } from '@/types'; import { getSearchParameters } from '@/utils'; @@ -36,7 +36,6 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => { const [error, setError] = useState(); const { setToast } = useContext(PageContext); const { t } = useTranslation(); - const { show } = useConfirmModal(); const navigate = useNavigate(); const { seconds, isRunning, restart } = useTimer({ @@ -44,27 +43,22 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => { expiryTimestamp: getTimeout(), }); + const { passwordlessErrorHandlers } = usePasswordlessErrorHandler(type, target); + const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo( () => ({ 'passcode.expired': (error) => { setError(error.message); }, - 'user.phone_not_exists': async (error) => { - await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' }); - navigate(-1); - }, - 'user.email_not_exists': async (error) => { - await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' }); - navigate(-1); - }, 'passcode.code_mismatch': (error) => { setError(error.message); }, + ...passwordlessErrorHandlers, callback: () => { setCode([]); }, }), - [navigate, show] + [passwordlessErrorHandlers] ); const { result: verifyPasscodeResult, run: verifyPassCode } = useApi( diff --git a/packages/ui/src/containers/PasscodeValidation/use-passwordless-error-handler.ts b/packages/ui/src/containers/PasscodeValidation/use-passwordless-error-handler.ts new file mode 100644 index 000000000..7732ad9d1 --- /dev/null +++ b/packages/ui/src/containers/PasscodeValidation/use-passwordless-error-handler.ts @@ -0,0 +1,190 @@ +import { useCallback, useMemo, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router-dom'; + +import { registerWithEmail, registerWithSms } from '@/apis/register'; +import { signInWithEmail, signInWithSms } from '@/apis/sign-in'; +import useApi, { ErrorHandlers } from '@/hooks/use-api'; +import { useConfirmModal } from '@/hooks/use-confirm-modal'; +import { PageContext } from '@/hooks/use-page-context'; +import { UserFlow, SearchParameters } from '@/types'; +import { getSearchParameters } from '@/utils'; +import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code'; + +const usePasswordlessErrorHandler = (type: UserFlow, target: string) => { + const { t } = useTranslation(); + const { show } = useConfirmModal(); + const navigate = useNavigate(); + const { setToast } = useContext(PageContext); + + const { run: registerWithSmsAsync } = useApi(registerWithSms); + const { run: registerWithEmailAsync } = useApi(registerWithEmail); + const { run: signInWithSmsAsync } = useApi(signInWithSms); + const { run: signInWithEmailAsync } = useApi(signInWithEmail); + + const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial); + + const phoneNotExistSignInHandler = useCallback(async () => { + const [confirm] = await show({ + ModalContent: t('description.sign_in_id_does_not_exists', { + type: t(`description.phone_number`), + value: formatPhoneNumberWithCountryCallingCode(target), + }), + }); + + if (!confirm) { + navigate(-1); + + return; + } + + const result = await registerWithSmsAsync(); + + if (result?.redirectTo) { + window.location.replace(result.redirectTo); + } + }, [navigate, registerWithSmsAsync, show, t, target]); + + const emailNotExistSignInHandler = useCallback(async () => { + const [confirm] = await show({ + ModalContent: t('description.sign_in_id_does_not_exists', { + type: t(`description.email`), + value: target, + }), + }); + + if (!confirm) { + navigate(-1); + + return; + } + + const result = await registerWithEmailAsync(); + + if (result?.redirectTo) { + window.location.replace(result.redirectTo); + } + }, [navigate, registerWithEmailAsync, show, t, target]); + + const phoneExistRegisterHandler = useCallback(async () => { + const [confirm] = await show({ + ModalContent: t('description.create_account_id_exists', { + type: t(`description.phone_number`), + value: formatPhoneNumberWithCountryCallingCode(target), + }), + }); + + if (!confirm) { + navigate(-1); + + return; + } + + const result = await signInWithSmsAsync(); + + if (result?.redirectTo) { + window.location.replace(result.redirectTo); + } + }, [navigate, show, signInWithSmsAsync, t, target]); + + const emailExistRegisterHandler = useCallback(async () => { + const [confirm] = await show({ + ModalContent: t('description.create_account_id_exists', { + type: t(`description.email`), + value: target, + }), + }); + + if (!confirm) { + navigate(-1); + + return; + } + + const result = await signInWithEmailAsync(); + + if (result?.redirectTo) { + window.location.replace(result.redirectTo); + } + }, [navigate, show, signInWithEmailAsync, t, target]); + + const phoneNotExistForgotPasswordHandler = useCallback(async () => { + await show({ + type: 'alert', + ModalContent: t('description.forgot_password_id_does_not_exits', { + type: t(`description.phone_number`), + value: formatPhoneNumberWithCountryCallingCode(target), + }), + cancelText: 'action.got_it', + }); + navigate(-1); + }, [navigate, show, t, target]); + + const emailNotExistForgotPasswordHandler = useCallback(async () => { + await show({ + type: 'alert', + ModalContent: t('description.forgot_password_id_does_not_exits', { + type: t(`description.email`), + value: target, + }), + cancelText: 'action.got_it', + }); + navigate(-1); + }, [navigate, show, t, target]); + + const passwordlessErrorHandlers = useMemo( + () => ({ + 'user.phone_not_exists': async (error) => { + if (type === 'forgot-password') { + await phoneNotExistForgotPasswordHandler(); + + return; + } + + // Directly display the error if user is trying to bind with social + if (socialToBind) { + setToast(error.message); + } + + await phoneNotExistSignInHandler(); + }, + 'user.email_not_exists': async (error) => { + if (type === 'forgot-password') { + await emailNotExistForgotPasswordHandler(); + + return; + } + + // Directly display the error if user is trying to bind with social + if (socialToBind) { + setToast(error.message); + } + + await emailNotExistSignInHandler(); + }, + 'user.phone_exists_register': async () => { + await phoneExistRegisterHandler(); + }, + 'user.email_exists_register': async () => { + await emailExistRegisterHandler(); + }, + }), + [ + emailExistRegisterHandler, + emailNotExistForgotPasswordHandler, + emailNotExistSignInHandler, + phoneExistRegisterHandler, + phoneNotExistForgotPasswordHandler, + phoneNotExistSignInHandler, + setToast, + socialToBind, + type, + ] + ); + + return { + passwordlessErrorHandlers, + }; +}; + +export default usePasswordlessErrorHandler; diff --git a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx index 3eb44685a..7d34d4d3e 100644 --- a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx +++ b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames'; -import { useCallback, useEffect, useState, useMemo, useContext } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; @@ -9,13 +9,10 @@ import Input from '@/components/Input'; import TermsOfUse from '@/containers/TermsOfUse'; import useApi, { ErrorHandlers } from '@/hooks/use-api'; import useForm from '@/hooks/use-form'; -import { PageContext } from '@/hooks/use-page-context'; import useTerms from '@/hooks/use-terms'; -import { UserFlow, SearchParameters } from '@/types'; -import { getSearchParameters } from '@/utils'; +import { UserFlow } from '@/types'; import { emailValidation } from '@/utils/field-validations'; -import PasswordlessConfirmModal from './PasswordlessConfirmModal'; import PasswordlessSwitch from './PasswordlessSwitch'; import * as styles from './index.module.scss'; @@ -41,7 +38,6 @@ const EmailPasswordless = ({ hasSwitch = false, className, }: Props) => { - const { setToast } = useContext(PageContext); const { t } = useTranslation(); const navigate = useNavigate(); @@ -49,30 +45,13 @@ const EmailPasswordless = ({ const { fieldValue, setFieldValue, setFieldErrors, register, validateForm } = useForm(defaultState); - const [showPasswordlessConfirmModal, setShowPasswordlessConfirmModal] = useState(false); - const errorHandlers: ErrorHandlers = useMemo( () => ({ - 'user.email_not_exists': (error) => { - const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial); - - // Directly display the error if user is trying to bind with social - if (socialToBind) { - setToast(error.message); - - return; - } - - setShowPasswordlessConfirmModal(true); - }, - 'user.email_exists_register': () => { - setShowPasswordlessConfirmModal(true); - }, 'guard.invalid_input': () => { setFieldErrors({ email: 'invalid_email' }); }, }), - [setFieldErrors, setToast] + [setFieldErrors] ); const sendPasscode = getSendPasscodeApi(type, 'email'); @@ -95,10 +74,6 @@ const EmailPasswordless = ({ [validateForm, hasTerms, termsValidation, asyncSendPasscode, fieldValue.email] ); - const onModalCloseHandler = useCallback(() => { - setShowPasswordlessConfirmModal(false); - }, []); - useEffect(() => { if (result) { navigate( @@ -112,39 +87,30 @@ const EmailPasswordless = ({ }, [fieldValue.email, navigate, result, type]); return ( - <> -
-
- { - setFieldValue((state) => ({ ...state, email: '' })); - }} - /> - {hasSwitch && } -
+ +
+ { + setFieldValue((state) => ({ ...state, email: '' })); + }} + /> + {hasSwitch && } +
- {hasTerms && } -