0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

refactor(ui): refactor passcodevalidation

refactor passcode validation
This commit is contained in:
simeng-li 2022-11-06 20:55:38 +08:00
parent 6f58f30ed0
commit e6aead2fb0
No known key found for this signature in database
GPG key ID: 14EA7BB1541E8075
26 changed files with 829 additions and 472 deletions

View file

@ -50,7 +50,8 @@ const translation = {
'The account with {{type}} {{value}} already exists, would you like to sign in?',
sign_in_id_does_not_exists:
'The account with {{type}} {{value}} does not exist, would you like to create a new account?',
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.',
sign_in_id_does_not_exists_alert: 'The account with {{type}} {{value}} does not exist.',
create_account_id_exists_alert: 'The account with {{type}} {{value}} already exists',
bind_account_title: 'Link account',
social_create_account: 'No account? You can create a new account and link.',
social_bind_account: 'Already have an account? Sign in to link it with your social identity.',

View file

@ -52,7 +52,8 @@ const translation = {
'Le compte avec {{type}} {{value}} existe déjà, voulez-vous vous connecter ?',
sign_in_id_does_not_exists:
"Le compte avec {{type}} {{value}} n'existe pas, voulez-vous créer un nouveau compte ?",
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
sign_in_id_does_not_exists_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
create_account_id_exists_alert: 'The account with {{type}} {{value}} already exists', // UNTRANSLATED
bind_account_title: 'Lier le compte',
social_create_account: 'Pas de compte ? Vous pouvez créer un nouveau compte et un lien.',
social_bind_account:

View file

@ -50,7 +50,8 @@ const translation = {
continue_with: '계속하기',
create_account_id_exists: '{{type}} {{value}} 계정이 이미 존재해요. 로그인하시겠어요?',
sign_in_id_does_not_exists: '{type}} {{value}} 계정이 존재하지 않아요. 새로 만드시겠어요?',
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
sign_in_id_does_not_exists_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
create_account_id_exists_alert: 'The account with {{type}} {{value}} already exists', // UNTRANSLATED
bind_account_title: '계정 연동',
social_create_account: '계정이 없으신가요? 새로운 계정을 만들고 연동해보세요.',
social_bind_account: '계정이 이미 있으신가요? 로그인하여 다른 계정과 연동해보세요.',

View file

@ -50,7 +50,8 @@ const translation = {
continue_with: 'Continuar com',
create_account_id_exists: 'A conta com {{type}} {{value}} já existe, gostaria de fazer login?',
sign_in_id_does_not_exists: 'A conta com {{type}} {{value}} não existe, gostaria de criar uma?',
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
sign_in_id_does_not_exists_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
create_account_id_exists_alert: 'The account with {{type}} {{value}} already exists', // UNTRANSLATED
bind_account_title: 'Agregar conta',
social_create_account: 'Sem conta? Pode criar uma nova e agregar.',
social_bind_account: 'Já tem uma conta? Faça login para agregar a sua identidade social.',

View file

@ -51,7 +51,8 @@ const translation = {
create_account_id_exists: '{{type}} {{value}} ile hesap mevcut, giriş yapmak ister misiniz?',
sign_in_id_does_not_exists:
'{{type}} {{value}} ile hesap mevcut değil, yeni bir hesap oluşturmak ister misiniz?',
forgot_password_id_does_not_exits: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
sign_in_id_does_not_exists_alert: 'The account with {{type}} {{value}} does not exist.', // UNTRANSLATED
create_account_id_exists_alert: 'The account with {{type}} {{value}} already exists', // UNTRANSLATED
bind_account_title: 'Hesap bağla',
social_create_account: 'Hesabınız yok mu? Yeni bir hesap ve bağlantı oluşturabilirsiniz.',
social_bind_account: 'Hesabınız zaten var mı? Hesabınıza bağlanmak için giriş yapınız.',

View file

@ -50,7 +50,8 @@ const translation = {
continue_with: '通过以下方式继续',
create_account_id_exists: '{{ type }}为 {{ value }} 的帐号已存在,你要登录吗?',
sign_in_id_does_not_exists: '{{ type }}为 {{ value }} 的帐号不存在,你要创建一个新帐号吗?',
forgot_password_id_does_not_exits: '{{ type }}为 {{ value }} 的帐号不存在。',
sign_in_id_does_not_exists_alert: '{{ type }}为 {{ value }} 的帐号不存在。',
create_account_id_exists_alert: '{{ type }}为 {{ value }} 的帐号已存在',
bind_account_title: '绑定帐号',
social_create_account: '没有帐号?你可以创建一个帐号并绑定。',
social_bind_account: '已有帐号?登录以绑定社交身份。',

View file

@ -2,6 +2,12 @@ import { SignInIdentifier } from '@logto/schemas';
import { act, fireEvent, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import {
verifyForgotPasswordEmailPasscode,
verifyForgotPasswordSmsPasscode,
} from '@/apis/forgot-password';
import { verifyRegisterEmailPasscode, verifyRegisterSmsPasscode } from '@/apis/register';
import { verifySignInEmailPasscode, verifySignInSmsPasscode } from '@/apis/sign-in';
import { UserFlow } from '@/types';
import PasscodeValidation from '.';
@ -9,7 +15,6 @@ import PasscodeValidation from '.';
jest.useFakeTimers();
const sendPasscodeApi = jest.fn();
const verifyPasscodeApi = jest.fn();
const mockedNavigate = jest.fn();
@ -20,11 +25,26 @@ jest.mock('react-router-dom', () => ({
jest.mock('@/apis/utils', () => ({
getSendPasscodeApi: () => sendPasscodeApi,
getVerifyPasscodeApi: () => verifyPasscodeApi,
}));
jest.mock('@/apis/sign-in', () => ({
verifySignInEmailPasscode: jest.fn(),
verifySignInSmsPasscode: jest.fn(),
}));
jest.mock('@/apis/register', () => ({
verifyRegisterEmailPasscode: jest.fn(),
verifyRegisterSmsPasscode: jest.fn(),
}));
jest.mock('@/apis/forgot-password', () => ({
verifyForgotPasswordEmailPasscode: jest.fn(),
verifyForgotPasswordSmsPasscode: jest.fn(),
}));
describe('<PasscodeValidation />', () => {
const email = 'foo@logto.io';
const phone = '18573333333';
const originalLocation = window.location;
beforeAll(() => {
@ -75,68 +95,170 @@ describe('<PasscodeValidation />', () => {
expect(sendPasscodeApi).toBeCalledWith(email);
});
it('fire validate passcode event', async () => {
const { container } = renderWithPageContext(
<PasscodeValidation type={UserFlow.signIn} method={SignInIdentifier.Email} target={email} />
);
const inputs = container.querySelectorAll('input');
describe('sign-in', () => {
it('fire email sign-in validate passcode event', async () => {
(verifySignInEmailPasscode as jest.Mock).mockImplementationOnce(() => ({
redirectTo: 'foo.com',
}));
const { container } = renderWithPageContext(
<PasscodeValidation type={UserFlow.signIn} method={SignInIdentifier.Email} target={email} />
);
const inputs = container.querySelectorAll('input');
await waitFor(() => {
for (const input of inputs) {
act(() => {
fireEvent.input(input, { target: { value: '1' } });
});
}
expect(verifyPasscodeApi).toBeCalledWith(email, '111111', undefined);
await waitFor(() => {
expect(verifySignInEmailPasscode).toBeCalledWith(email, '111111', undefined);
});
await waitFor(() => {
expect(window.location.replace).toBeCalledWith('foo.com');
});
});
});
it('should redirect with success redirectUri response', async () => {
verifyPasscodeApi.mockImplementationOnce(() => ({ redirectTo: 'foo.com' }));
it('fire sms sign-in validate passcode event', async () => {
(verifySignInSmsPasscode as jest.Mock).mockImplementationOnce(() => ({
redirectTo: 'foo.com',
}));
const { container } = renderWithPageContext(
<PasscodeValidation type={UserFlow.signIn} method={SignInIdentifier.Email} target={email} />
);
const { container } = renderWithPageContext(
<PasscodeValidation type={UserFlow.signIn} method={SignInIdentifier.Sms} target={phone} />
);
const inputs = container.querySelectorAll('input');
const inputs = container.querySelectorAll('input');
await waitFor(() => {
for (const input of inputs) {
act(() => {
fireEvent.input(input, { target: { value: '1' } });
});
}
expect(verifyPasscodeApi).toBeCalledWith(email, '111111', undefined);
});
await waitFor(() => {
expect(verifySignInSmsPasscode).toBeCalledWith(phone, '111111', undefined);
});
await waitFor(() => {
expect(window.location.replace).toBeCalledWith('foo.com');
await waitFor(() => {
expect(window.location.replace).toBeCalledWith('foo.com');
});
});
});
it('should redirect to reset password page if the flow is forgot-password', async () => {
verifyPasscodeApi.mockImplementationOnce(() => ({ success: true }));
describe('register', () => {
it('fire email register validate passcode event', async () => {
(verifyRegisterEmailPasscode as jest.Mock).mockImplementationOnce(() => ({
redirectTo: 'foo.com',
}));
const { container } = renderWithPageContext(
<PasscodeValidation
type={UserFlow.register}
method={SignInIdentifier.Email}
target={email}
/>
);
const inputs = container.querySelectorAll('input');
for (const input of inputs) {
act(() => {
fireEvent.input(input, { target: { value: '1' } });
});
}
await waitFor(() => {
expect(verifyRegisterEmailPasscode).toBeCalledWith(email, '111111');
});
await waitFor(() => {
expect(window.location.replace).toBeCalledWith('foo.com');
});
});
it('fire sms register validate passcode event', async () => {
(verifyRegisterSmsPasscode as jest.Mock).mockImplementationOnce(() => ({
redirectTo: 'foo.com',
}));
const { container } = renderWithPageContext(
<PasscodeValidation type={UserFlow.register} method={SignInIdentifier.Sms} target={phone} />
);
const inputs = container.querySelectorAll('input');
for (const input of inputs) {
act(() => {
fireEvent.input(input, { target: { value: '1' } });
});
}
await waitFor(() => {
expect(verifyRegisterSmsPasscode).toBeCalledWith(phone, '111111');
});
await waitFor(() => {
expect(window.location.replace).toBeCalledWith('foo.com');
});
});
});
describe('forgot password', () => {
it('fire email forgot-password validate passcode event', async () => {
(verifyForgotPasswordEmailPasscode as jest.Mock).mockImplementationOnce(() => ({
success: true,
}));
const { container } = renderWithPageContext(
<PasscodeValidation
type={UserFlow.forgotPassword}
method={SignInIdentifier.Email}
target={email}
/>
);
const inputs = container.querySelectorAll('input');
for (const input of inputs) {
act(() => {
fireEvent.input(input, { target: { value: '1' } });
});
}
await waitFor(() => {
expect(verifyForgotPasswordEmailPasscode).toBeCalledWith(email, '111111');
});
await waitFor(() => {
expect(window.location.replace).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith('/forgot-password/reset', { replace: true });
});
});
});
it('fire Sms forgot-password validate passcode event', async () => {
(verifyForgotPasswordSmsPasscode as jest.Mock).mockImplementationOnce(() => ({
success: true,
}));
const { container } = renderWithPageContext(
<PasscodeValidation
type={UserFlow.forgotPassword}
method={SignInIdentifier.Email}
target={email}
method={SignInIdentifier.Sms}
target={phone}
/>
);
const inputs = container.querySelectorAll('input');
await waitFor(() => {
for (const input of inputs) {
act(() => {
fireEvent.input(input, { target: { value: '1' } });
});
}
for (const input of inputs) {
act(() => {
fireEvent.input(input, { target: { value: '1' } });
});
}
expect(verifyPasscodeApi).toBeCalledWith(email, '111111', undefined);
await waitFor(() => {
expect(verifyForgotPasswordSmsPasscode).toBeCalledWith(phone, '111111');
});
await waitFor(() => {

View file

@ -1,22 +1,15 @@
import type { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useState, useEffect, useContext, useCallback, useMemo } from 'react';
import { useState, useEffect } from 'react';
import { useTranslation, Trans } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useTimer } from 'react-timer-hook';
import { getSendPasscodeApi, getVerifyPasscodeApi } from '@/apis/utils';
import Passcode, { defaultLength } from '@/components/Passcode';
import TextLink from '@/components/TextLink';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { PageContext } from '@/hooks/use-page-context';
import type { UserFlow } from '@/types';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import * as styles from './index.module.scss';
import usePasscodeValidationErrorHandler from './use-passcode-validation-error-handler';
import useResendPasscode from './use-resend-passcode';
import { getPasscodeValidationHook } from './utils';
type Props = {
type: UserFlow;
@ -25,90 +18,30 @@ type Props = {
className?: string;
};
export const timeRange = 59;
const getTimeout = () => {
const now = new Date();
now.setSeconds(now.getSeconds() + timeRange);
return now;
};
const PasscodeValidation = ({ type, method, className, target }: Props) => {
const [code, setCode] = useState<string[]>([]);
const [error, setError] = useState<string>();
const { setToast } = useContext(PageContext);
const { t } = useTranslation();
const navigate = useNavigate();
const usePasscodeValidation = getPasscodeValidationHook(type, method);
const { seconds, isRunning, restart } = useTimer({
autoStart: true,
expiryTimestamp: getTimeout(),
const { errorMessage, clearErrorMessage, onSubmit } = usePasscodeValidation(target, () => {
setCode([]);
});
// Get the flow specific error handler hook
const { errorHandler } = usePasscodeValidationErrorHandler(type, method, target);
const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo(
() => ({
...errorHandler,
'passcode.expired': (error) => {
setError(error.message);
},
'passcode.code_mismatch': (error) => {
setError(error.message);
},
callback: () => {
setCode([]);
},
}),
[errorHandler]
);
const { result: verifyPasscodeResult, run: verifyPassCode } = useApi(
getVerifyPasscodeApi(type, method),
verifyPasscodeErrorHandlers
);
const { run: sendPassCode } = useApi(getSendPasscodeApi(type, method));
const resendPasscodeHandler = useCallback(async () => {
setError(undefined);
const result = await sendPassCode(target);
if (result) {
setToast(t('description.passcode_sent'));
restart(getTimeout(), true);
}
}, [restart, sendPassCode, setToast, t, target]);
const { seconds, isRunning, onResendPasscode } = useResendPasscode(type, method, target);
useEffect(() => {
if (code.length === defaultLength && code.every(Boolean)) {
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
void verifyPassCode(target, code.join(''), socialToBind);
void onSubmit(code.join(''));
}
}, [code, target, verifyPassCode]);
useEffect(() => {
if (verifyPasscodeResult?.redirectTo) {
window.location.replace(verifyPasscodeResult.redirectTo);
return;
}
if (verifyPasscodeResult && type === 'forgot-password') {
navigate('/forgot-password/reset', { replace: true });
}
}, [navigate, type, verifyPasscodeResult]);
}, [code, onSubmit, target]);
return (
<form className={classNames(styles.form, className)}>
<Passcode
name="passcode"
className={classNames(styles.inputField, error && styles.withError)}
className={classNames(styles.inputField, errorMessage && styles.withError)}
value={code}
error={error}
error={errorMessage}
onChange={setCode}
/>
{isRunning ? (
@ -118,7 +51,13 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
</Trans>
</div>
) : (
<TextLink text="description.resend_passcode" onClick={resendPasscodeHandler} />
<TextLink
text="description.resend_passcode"
onClick={() => {
clearErrorMessage();
void onResendPasscode();
}}
/>
)}
</form>
);

View file

@ -0,0 +1,54 @@
import { SignInIdentifier } from '@logto/schemas';
import { useMemo, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { verifyForgotPasswordEmailPasscode } from '@/apis/forgot-password';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { UserFlow } from '@/types';
import useIdentifierErrorAlert from './use-identifier-error-alert';
import useSharedErrorHandler from './use-shared-error-handler';
const useForgotPasswordEmailPasscodeValidation = (email: string, errorCallback?: () => void) => {
const navigate = useNavigate();
const { sharedErrorHandlers, errorMessage, clearErrorMessage } = useSharedErrorHandler();
const identifierNotExistErrorHandler = useIdentifierErrorAlert(
UserFlow.forgotPassword,
SignInIdentifier.Email,
email
);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'user.email_not_exists': identifierNotExistErrorHandler,
...sharedErrorHandlers,
callback: errorCallback,
}),
[identifierNotExistErrorHandler, sharedErrorHandlers, errorCallback]
);
const { result, run: verifyPasscode } = useApi(verifyForgotPasswordEmailPasscode, errorHandlers);
const onSubmit = useCallback(
async (code: string) => {
return verifyPasscode(email, code);
},
[email, verifyPasscode]
);
useEffect(() => {
if (result) {
navigate(`/${UserFlow.forgotPassword}/reset`, { replace: true });
}
}, [navigate, result]);
return {
errorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useForgotPasswordEmailPasscodeValidation;

View file

@ -0,0 +1,53 @@
import { SignInIdentifier } from '@logto/schemas';
import { useMemo, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { verifyForgotPasswordSmsPasscode } from '@/apis/forgot-password';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { UserFlow } from '@/types';
import useIdentifierErrorAlert from './use-identifier-error-alert';
import useSharedErrorHandler from './use-shared-error-handler';
const useForgotPasswordSmsPasscodeValidation = (phone: string, errorCallback?: () => void) => {
const navigate = useNavigate();
const { sharedErrorHandlers, errorMessage, clearErrorMessage } = useSharedErrorHandler();
const identifierNotExistErrorHandler = useIdentifierErrorAlert(
UserFlow.forgotPassword,
SignInIdentifier.Sms,
phone
);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'user.phone_not_exists': identifierNotExistErrorHandler,
...sharedErrorHandlers,
callback: errorCallback,
}),
[sharedErrorHandlers, errorCallback, identifierNotExistErrorHandler]
);
const { result, run: verifyPasscode } = useApi(verifyForgotPasswordSmsPasscode, errorHandlers);
const onSubmit = useCallback(
async (code: string) => {
return verifyPasscode(phone, code);
},
[phone, verifyPasscode]
);
useEffect(() => {
if (result) {
navigate(`/${UserFlow.forgotPassword}/reset`, { replace: true });
}
}, [navigate, result]);
return {
errorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useForgotPasswordSmsPasscodeValidation;

View file

@ -1,37 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import type { ErrorHandlers } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
const useForgotPasswordWithEmailErrorHandler = (email: string) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const emailNotExistForgotPasswordHandler = useCallback(async () => {
await show({
type: 'alert',
ModalContent: t('description.forgot_password_id_does_not_exits', {
type: t(`description.email`),
value: email,
}),
cancelText: 'action.got_it',
});
navigate(-1);
}, [navigate, show, t, email]);
const errorHandler = useMemo<ErrorHandlers>(
() => ({
'user.email_not_exists': emailNotExistForgotPasswordHandler,
}),
[emailNotExistForgotPasswordHandler]
);
return {
errorHandler,
};
};
export default useForgotPasswordWithEmailErrorHandler;

View file

@ -1,38 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import type { ErrorHandlers } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
const useForgotPasswordWithSmsErrorHandler = (phone: string) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const phoneNotExistForgotPasswordHandler = useCallback(async () => {
await show({
type: 'alert',
ModalContent: t('description.forgot_password_id_does_not_exits', {
type: t(`description.phone_number`),
value: formatPhoneNumberWithCountryCallingCode(phone),
}),
cancelText: 'action.got_it',
});
navigate(-1);
}, [navigate, show, t, phone]);
const errorHandler = useMemo<ErrorHandlers>(
() => ({
'user.phone_not_exists': phoneNotExistForgotPasswordHandler,
}),
[phoneNotExistForgotPasswordHandler]
);
return {
errorHandler,
};
};
export default useForgotPasswordWithSmsErrorHandler;

View file

@ -0,0 +1,35 @@
import { SignInIdentifier } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { UserFlow } from '@/types';
const useIdentifierErrorAlert = (
flow: UserFlow,
method: SignInIdentifier.Email | SignInIdentifier.Sms,
value: string
) => {
const { show } = useConfirmModal();
const navigate = useNavigate();
const { t } = useTranslation();
return async () => {
await show({
type: 'alert',
ModalContent: t(
flow === UserFlow.register
? 'description.create_account_id_exists_alert'
: 'description.sign_in_id_does_not_exists_alert',
{
type: t(`description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
value,
}
),
cancelText: 'action.got_it',
});
navigate(-1);
};
};
export default useIdentifierErrorAlert;

View file

@ -1,43 +0,0 @@
import { UserFlow } from '@/types';
import useForgotPasswordWithEmailErrorHandler from './use-forgot-password-with-email-error-handler';
import useForgotPasswordWithSmsErrorHandler from './use-forgot-password-with-sms-error-handler';
import useRegisterWithSmsErrorHandler from './use-register-with-sms-error-handler';
import useSignInWithEmailErrorHandler from './use-sign-in-with-email-error-handler';
import useSignInWithSmsErrorHandler from './use-sign-in-with-sms-error-handler';
import useRegisterWithEmailErrorHandler from './user-register-with-email-error-handler';
type Method = 'email' | 'sms';
const getPasscodeValidationErrorHandlersByFlowAndMethod = (flow: UserFlow, method: Method) => {
if (flow === UserFlow.signIn && method === 'email') {
return useSignInWithEmailErrorHandler;
}
if (flow === UserFlow.signIn && method === 'sms') {
return useSignInWithSmsErrorHandler;
}
if (flow === UserFlow.register && method === 'email') {
return useRegisterWithEmailErrorHandler;
}
if (flow === UserFlow.register && method === 'sms') {
return useRegisterWithSmsErrorHandler;
}
if (flow === UserFlow.forgotPassword && method === 'email') {
return useForgotPasswordWithEmailErrorHandler;
}
return useForgotPasswordWithSmsErrorHandler;
};
const usePasscodeValidationErrorHandler = (type: UserFlow, method: Method, target: string) => {
const useFlowErrorHandler = getPasscodeValidationErrorHandlersByFlowAndMethod(type, method);
const { errorHandler } = useFlowErrorHandler(target);
return { errorHandler };
};
export default usePasscodeValidationErrorHandler;

View file

@ -0,0 +1,95 @@
import { SignInIdentifier, SignInMode } from '@logto/schemas';
import { useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { verifyRegisterEmailPasscode } from '@/apis/register';
import { signInWithEmail } from '@/apis/sign-in';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { useSieMethods } from '@/hooks/use-sie';
import { UserFlow } from '@/types';
import useIdentifierErrorAlert from './use-identifier-error-alert';
import useSharedErrorHandler from './use-shared-error-handler';
const useRegisterWithEmailPasscodeValidation = (email: string, errorCallback?: () => void) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const { errorMessage, clearErrorMessage, sharedErrorHandlers } = useSharedErrorHandler();
const { signInMode } = useSieMethods();
const { run: signInWithEmailAsync } = useApi(signInWithEmail);
const identifierExistErrorHandler = useIdentifierErrorAlert(
UserFlow.register,
SignInIdentifier.Email,
email
);
const emailExistSignInErrorHandler = useCallback(async () => {
const [confirm] = await show({
confirmText: 'action.sign_in',
ModalContent: t('description.create_account_id_exists', {
type: t(`description.email`),
value: email,
}),
});
if (!confirm) {
navigate(-1);
return;
}
const result = await signInWithEmailAsync();
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [email, navigate, show, signInWithEmailAsync, t]);
const errorHandlers = useMemo<ErrorHandlers>(
() => ({
'user.email_exists_register':
signInMode === SignInMode.Register
? identifierExistErrorHandler
: emailExistSignInErrorHandler,
...sharedErrorHandlers,
callback: errorCallback,
}),
[
emailExistSignInErrorHandler,
errorCallback,
identifierExistErrorHandler,
sharedErrorHandlers,
signInMode,
]
);
const { result, run: verifyPasscode } = useApi(verifyRegisterEmailPasscode, errorHandlers);
const onSubmit = useCallback(
async (code: string) => {
return verifyPasscode(email, code);
},
[email, verifyPasscode]
);
useEffect(() => {
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [result]);
return {
errorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useRegisterWithEmailPasscodeValidation;

View file

@ -1,54 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { signInWithSms } from '@/apis/sign-in';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
const useRegisterWithSmsErrorHandler = (phone: string) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const { run: signInWithSmsAsync } = useApi(signInWithSms);
const phoneExistRegisterHandler = useCallback(async () => {
const [confirm] = await show({
confirmText: 'action.sign_in',
ModalContent: t('description.create_account_id_exists', {
type: t(`description.phone_number`),
value: formatPhoneNumberWithCountryCallingCode(phone),
}),
});
if (!confirm) {
navigate(-1);
return;
}
const result = await signInWithSmsAsync();
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [navigate, phone, show, signInWithSmsAsync, t]);
const errorHandler = useMemo<ErrorHandlers>(
() => ({
'user.phone_exists_register': async () => {
await phoneExistRegisterHandler();
},
}),
[phoneExistRegisterHandler]
);
return {
errorHandler,
};
};
export default useRegisterWithSmsErrorHandler;

View file

@ -0,0 +1,95 @@
import { SignInIdentifier, SignInMode } from '@logto/schemas';
import { useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { verifyRegisterSmsPasscode } from '@/apis/register';
import { signInWithSms } from '@/apis/sign-in';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { useSieMethods } from '@/hooks/use-sie';
import { UserFlow } from '@/types';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
import useIdentifierErrorAlert from './use-identifier-error-alert';
import useSharedErrorHandler from './use-shared-error-handler';
const useRegisterWithSmsPasscodeValidation = (phone: string, errorCallback?: () => void) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const { errorMessage, clearErrorMessage, sharedErrorHandlers } = useSharedErrorHandler();
const { signInMode } = useSieMethods();
const { run: signInWithSmsAsync } = useApi(signInWithSms);
const identifierExistErrorHandler = useIdentifierErrorAlert(
UserFlow.register,
SignInIdentifier.Sms,
formatPhoneNumberWithCountryCallingCode(phone)
);
const phoneExistSignInErrorHandler = useCallback(async () => {
const [confirm] = await show({
confirmText: 'action.sign_in',
ModalContent: t('description.create_account_id_exists', {
type: t(`description.phone_number`),
value: phone,
}),
});
if (!confirm) {
navigate(-1);
return;
}
const result = await signInWithSmsAsync();
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [phone, navigate, show, signInWithSmsAsync, t]);
const errorHandlers = useMemo<ErrorHandlers>(
() => ({
'user.phone_exists_register':
signInMode === SignInMode.Register
? identifierExistErrorHandler
: phoneExistSignInErrorHandler,
...sharedErrorHandlers,
callback: errorCallback,
}),
[
phoneExistSignInErrorHandler,
errorCallback,
identifierExistErrorHandler,
sharedErrorHandlers,
signInMode,
]
);
const { result, run: verifyPasscode } = useApi(verifyRegisterSmsPasscode, errorHandlers);
useEffect(() => {
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [result]);
const onSubmit = useCallback(
async (code: string) => {
return verifyPasscode(phone, code);
},
[phone, verifyPasscode]
);
return {
errorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useRegisterWithSmsPasscodeValidation;

View file

@ -0,0 +1,50 @@
import type { SignInIdentifier } from '@logto/schemas';
import { t } from 'i18next';
import { useCallback, useContext } from 'react';
import { useTimer } from 'react-timer-hook';
import { getSendPasscodeApi } from '@/apis/utils';
import useApi from '@/hooks/use-api';
import { PageContext } from '@/hooks/use-page-context';
import type { UserFlow } from '@/types';
export const timeRange = 59;
const getTimeout = () => {
const now = new Date();
now.setSeconds(now.getSeconds() + timeRange);
return now;
};
const useResendPasscode = (
type: UserFlow,
method: SignInIdentifier.Email | SignInIdentifier.Sms,
target: string
) => {
const { setToast } = useContext(PageContext);
const { seconds, isRunning, restart } = useTimer({
autoStart: true,
expiryTimestamp: getTimeout(),
});
const { run: sendPassCode } = useApi(getSendPasscodeApi(type, method));
const onResendPasscode = useCallback(async () => {
const result = await sendPassCode(target);
if (result) {
setToast(t('description.passcode_sent'));
restart(getTimeout(), true);
}
}, [restart, sendPassCode, setToast, target]);
return {
seconds,
isRunning,
onResendPasscode,
};
};
export default useResendPasscode;

View file

@ -0,0 +1,26 @@
import { useState } from 'react';
import type { ErrorHandlers } from '@/hooks/use-api';
const useSharedErrorHandler = () => {
const [errorMessage, setErrorMessage] = useState<string>();
const sharedErrorHandlers: ErrorHandlers = {
'passcode.expired': (error) => {
setErrorMessage(error.message);
},
'passcode.code_mismatch': (error) => {
setErrorMessage(error.message);
},
};
return {
errorMessage,
sharedErrorHandlers,
clearErrorMessage: () => {
setErrorMessage('');
},
};
};
export default useSharedErrorHandler;

View file

@ -1,64 +0,0 @@
import { useCallback, useMemo, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { registerWithEmail } from '@/apis/register';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { PageContext } from '@/hooks/use-page-context';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
const useSignInWithEmailErrorHandler = (email: string) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const { setToast } = useContext(PageContext);
const { run: registerWithEmailAsync } = useApi(registerWithEmail);
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
const emailNotExistSignInHandler = useCallback(async () => {
const [confirm] = await show({
confirmText: 'action.create',
ModalContent: t('description.sign_in_id_does_not_exists', {
type: t(`description.email`),
value: email,
}),
});
if (!confirm) {
navigate(-1);
return;
}
const result = await registerWithEmailAsync();
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [navigate, registerWithEmailAsync, show, t, email]);
const errorHandler = useMemo<ErrorHandlers>(
() => ({
'user.email_not_exists': async (error) => {
// Directly display the error if user is trying to bind with social
if (socialToBind) {
setToast(error.message);
}
await emailNotExistSignInHandler();
},
}),
[emailNotExistSignInHandler, setToast, socialToBind]
);
return {
errorHandler,
};
};
export default useSignInWithEmailErrorHandler;

View file

@ -0,0 +1,100 @@
import { SignInIdentifier, SignInMode } from '@logto/schemas';
import { useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { registerWithEmail } from '@/apis/register';
import { verifySignInEmailPasscode } from '@/apis/sign-in';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { useSieMethods } from '@/hooks/use-sie';
import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import useIdentifierErrorAlert from './use-identifier-error-alert';
import useSharedErrorHandler from './use-shared-error-handler';
const useSignInWithEmailPasscodeValidation = (email: string, errorCallback?: () => void) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const { errorMessage, clearErrorMessage, sharedErrorHandlers } = useSharedErrorHandler();
const { signInMode } = useSieMethods();
const { run: registerWithEmailAsync } = useApi(registerWithEmail);
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
const identifierNotExistErrorHandler = useIdentifierErrorAlert(
UserFlow.signIn,
SignInIdentifier.Email,
email
);
const emailNotExistRegisterErrorHandler = useCallback(async () => {
const [confirm] = await show({
confirmText: 'action.create',
ModalContent: t('description.sign_in_id_does_not_exists', {
type: t(`description.email`),
value: email,
}),
});
if (!confirm) {
navigate(-1);
return;
}
const result = await registerWithEmailAsync();
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [email, navigate, show, registerWithEmailAsync, t]);
const errorHandlers = useMemo<ErrorHandlers>(
() => ({
'user.email_not_exists':
// Block user auto register if is bind social or sign-in only flow
signInMode === SignInMode.SignIn || socialToBind
? identifierNotExistErrorHandler
: emailNotExistRegisterErrorHandler,
...sharedErrorHandlers,
callback: errorCallback,
}),
[
emailNotExistRegisterErrorHandler,
errorCallback,
identifierNotExistErrorHandler,
sharedErrorHandlers,
signInMode,
socialToBind,
]
);
const { result, run: verifyPasscode } = useApi(verifySignInEmailPasscode, errorHandlers);
useEffect(() => {
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [result]);
const onSubmit = useCallback(
async (code: string) => {
return verifyPasscode(email, code, socialToBind);
},
[email, socialToBind, verifyPasscode]
);
return {
errorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useSignInWithEmailPasscodeValidation;

View file

@ -1,65 +0,0 @@
import { useCallback, useMemo, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { registerWithSms } from '@/apis/register';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { PageContext } from '@/hooks/use-page-context';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
const useSignInWithSmsErrorHandler = (phone: string) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const { setToast } = useContext(PageContext);
const { run: registerWithSmsAsync } = useApi(registerWithSms);
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
const phoneNotExistSignInHandler = useCallback(async () => {
const [confirm] = await show({
ModalContent: t('description.sign_in_id_does_not_exists', {
confirmText: 'action.create',
type: t(`description.phone_number`),
value: formatPhoneNumberWithCountryCallingCode(phone),
}),
});
if (!confirm) {
navigate(-1);
return;
}
const result = await registerWithSmsAsync();
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [navigate, registerWithSmsAsync, show, t, phone]);
const errorHandler = useMemo<ErrorHandlers>(
() => ({
'user.phone_not_exists': async (error) => {
// Directly display the error if user is trying to bind with social
if (socialToBind) {
setToast(error.message);
}
await phoneNotExistSignInHandler();
},
}),
[phoneNotExistSignInHandler, setToast, socialToBind]
);
return {
errorHandler,
};
};
export default useSignInWithSmsErrorHandler;

View file

@ -0,0 +1,100 @@
import { SignInIdentifier, SignInMode } from '@logto/schemas';
import { useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { registerWithSms } from '@/apis/register';
import { verifySignInSmsPasscode } from '@/apis/sign-in';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import { useSieMethods } from '@/hooks/use-sie';
import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
import useIdentifierErrorAlert from './use-identifier-error-alert';
import useSharedErrorHandler from './use-shared-error-handler';
const useSignInWithSmsPasscodeValidation = (phone: string, errorCallback?: () => void) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const { errorMessage, clearErrorMessage, sharedErrorHandlers } = useSharedErrorHandler();
const { signInMode } = useSieMethods();
const { run: registerWithSmsAsync } = useApi(registerWithSms);
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
const identifierNotExistErrorHandler = useIdentifierErrorAlert(
UserFlow.signIn,
SignInIdentifier.Sms,
phone
);
const phoneNotExistRegisterErrorHandler = useCallback(async () => {
const [confirm] = await show({
confirmText: 'action.create',
ModalContent: t('description.sign_in_id_does_not_exists', {
type: t(`description.phone_number`),
value: phone,
}),
});
if (!confirm) {
navigate(-1);
return;
}
const result = await registerWithSmsAsync();
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [phone, navigate, show, registerWithSmsAsync, t]);
const errorHandlers = useMemo<ErrorHandlers>(
() => ({
'user.phone_not_exists':
// Block user auto register if is bind social or sign-in only flow
signInMode === SignInMode.SignIn || socialToBind
? identifierNotExistErrorHandler
: phoneNotExistRegisterErrorHandler,
...sharedErrorHandlers,
callback: errorCallback,
}),
[
phoneNotExistRegisterErrorHandler,
errorCallback,
identifierNotExistErrorHandler,
sharedErrorHandlers,
signInMode,
socialToBind,
]
);
const { result, run: verifyPasscode } = useApi(verifySignInSmsPasscode, errorHandlers);
useEffect(() => {
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [result]);
const onSubmit = useCallback(
async (code: string) => {
return verifyPasscode(phone, code, socialToBind);
},
[phone, socialToBind, verifyPasscode]
);
return {
errorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useSignInWithSmsPasscodeValidation;

View file

@ -1,53 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { signInWithEmail } from '@/apis/sign-in';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
const useRegisterWithEmailErrorHandler = (email: string) => {
const { t } = useTranslation();
const { show } = useConfirmModal();
const navigate = useNavigate();
const { run: signInWithEmailAsync } = useApi(signInWithEmail);
const emailExistRegisterHandler = useCallback(async () => {
const [confirm] = await show({
ModalContent: t('description.create_account_id_exists', {
confirmText: 'action.sign_in',
type: t(`description.email`),
value: email,
}),
});
if (!confirm) {
navigate(-1);
return;
}
const result = await signInWithEmailAsync();
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [navigate, show, signInWithEmailAsync, t, email]);
const errorHandler = useMemo<ErrorHandlers>(
() => ({
'user.email_exists_register': async () => {
await emailExistRegisterHandler();
},
}),
[emailExistRegisterHandler]
);
return {
errorHandler,
};
};
export default useRegisterWithEmailErrorHandler;

View file

@ -0,0 +1,35 @@
import { SignInIdentifier } from '@logto/schemas';
import { UserFlow } from '@/types';
import useForgotPasswordEmailPasscodeValidation from './use-forgot-password-email-passcode-validation';
import useForgotPasswordSmsPasscodeValidation from './use-forgot-password-sms-passcode-validation';
import useRegisterWithEmailPasscodeValidation from './use-register-with-email-passcode-validation';
import useRegisterWithSmsPasscodeValidation from './use-register-with-sms-passcode-validation';
import useSignInWithEmailPasscodeValidation from './use-sign-in-with-email-passcode-validation';
import useSignInWithSmsPasscodeValidation from './use-sign-in-with-sms-passcode-validation';
export const getPasscodeValidationHook = (
type: UserFlow,
method: SignInIdentifier.Email | SignInIdentifier.Sms
) => {
switch (type) {
case UserFlow.signIn:
return method === SignInIdentifier.Email
? useSignInWithEmailPasscodeValidation
: useSignInWithSmsPasscodeValidation;
case UserFlow.register:
return method === SignInIdentifier.Email
? useRegisterWithEmailPasscodeValidation
: useRegisterWithSmsPasscodeValidation;
case UserFlow.forgotPassword:
return method === SignInIdentifier.Email
? useForgotPasswordEmailPasscodeValidation
: useForgotPasswordSmsPasscodeValidation;
default:
// TODO: continue flow hook
return method === SignInIdentifier.Email
? useRegisterWithEmailPasscodeValidation
: useRegisterWithSmsPasscodeValidation;
}
};

View file

@ -9,6 +9,7 @@ export enum UserFlow {
signIn = 'sign-in',
register = 'register',
forgotPassword = 'forgot-password',
continue = 'continue',
}
export enum SearchParameters {