0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-03 21:48:55 -05:00

feat(ui): add reset password error handling flow (#2079)

This commit is contained in:
simeng-li 2022-10-10 09:51:18 +08:00 committed by GitHub
parent 8651c06f93
commit afa2ac47ee
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 165 additions and 22 deletions

View file

@ -57,6 +57,7 @@ const translation = {
reset_password_description_sms: reset_password_description_sms:
'Enter the phone number associated with your account, and well text you the verification code to reset your password.', 'Enter the phone number associated with your account, and well text you the verification code to reset your password.',
new_password: 'New password', new_password: 'New password',
password_changed: 'Password Changed',
}, },
error: { error: {
username_password_mismatch: 'Username and password do not match', username_password_mismatch: 'Username and password do not match',

View file

@ -61,6 +61,7 @@ const translation = {
reset_password_description_sms: reset_password_description_sms:
'Entrez le numéro de téléphone associé à votre compte et nous vous enverrons le code de vérification par SMS pour réinitialiser votre mot de passe.', 'Entrez le numéro de téléphone associé à votre compte et nous vous enverrons le code de vérification par SMS pour réinitialiser votre mot de passe.',
new_password: 'Nouveau mot de passe', new_password: 'Nouveau mot de passe',
password_changed: 'Password Changed', // UNTRANSLATED
}, },
error: { error: {
username_password_mismatch: "Le nom d'utilisateur et le mot de passe ne correspondent pas", username_password_mismatch: "Le nom d'utilisateur et le mot de passe ne correspondent pas",

View file

@ -57,6 +57,7 @@ const translation = {
reset_password_description_sms: reset_password_description_sms:
'계정과 연결된 전화번호를 입력하면 비밀번호 재설정을 위한 인증 코드를 문자로 보내드립니다.', '계정과 연결된 전화번호를 입력하면 비밀번호 재설정을 위한 인증 코드를 문자로 보내드립니다.',
new_password: '새 비밀번호', new_password: '새 비밀번호',
password_changed: 'Password Changed', // UNTRANSLATED
}, },
error: { error: {
username_password_mismatch: '사용자 이름 또는 비밀번호가 일치하지 않아요.', username_password_mismatch: '사용자 이름 또는 비밀번호가 일치하지 않아요.',

View file

@ -57,6 +57,7 @@ const translation = {
reset_password_description_sms: reset_password_description_sms:
'Digite o número de telefone associado à sua conta e enviaremos uma mensagem de texto com o código de verificação para redefinir sua senha.', 'Digite o número de telefone associado à sua conta e enviaremos uma mensagem de texto com o código de verificação para redefinir sua senha.',
new_password: 'Nova Senha', new_password: 'Nova Senha',
password_changed: 'Password Changed', // UNTRANSLATED
}, },
error: { error: {
username_password_mismatch: 'O Utilizador e a password não correspondem', username_password_mismatch: 'O Utilizador e a password não correspondem',

View file

@ -58,6 +58,7 @@ const translation = {
reset_password_description_sms: reset_password_description_sms:
'Hesabınızla ilişkili telefon numarasını girin, şifrenizi sıfırlamak için size doğrulama kodunu kısa mesajla gönderelim.', 'Hesabınızla ilişkili telefon numarasını girin, şifrenizi sıfırlamak için size doğrulama kodunu kısa mesajla gönderelim.',
new_password: 'Yeni Şifre', new_password: 'Yeni Şifre',
password_changed: 'Password Changed', // UNTRANSLATED
}, },
error: { error: {
username_password_mismatch: 'Kullanıcı adı ve şifre eşleşmiyor.', username_password_mismatch: 'Kullanıcı adı ve şifre eşleşmiyor.',

View file

@ -57,6 +57,7 @@ const translation = {
reset_password_description_sms: reset_password_description_sms:
'输入与你的帐户关联的电话号码,我们将向您发送验证码以重置你的密码。', '输入与你的帐户关联的电话号码,我们将向您发送验证码以重置你的密码。',
new_password: '新密码', new_password: '新密码',
password_changed: 'Password Changed', // UNTRANSLATED
}, },
error: { error: {
username_password_mismatch: '用户名和密码不匹配', username_password_mismatch: '用户名和密码不匹配',

View file

@ -18,8 +18,8 @@ export const sendForgotPasswordSmsPasscode = async (phone: string) => {
return { success: true }; return { success: true };
}; };
export const verifyForgotPasswordSmsPasscode = async (phone: string, code: string) => export const verifyForgotPasswordSmsPasscode = async (phone: string, code: string) => {
api await api
.post(`${forgotPasswordApiPrefix}/sms/verify-passcode`, { .post(`${forgotPasswordApiPrefix}/sms/verify-passcode`, {
json: { json: {
phone, phone,
@ -28,6 +28,9 @@ export const verifyForgotPasswordSmsPasscode = async (phone: string, code: strin
}) })
.json<Response>(); .json<Response>();
return { success: true };
};
export const sendForgotPasswordEmailPasscode = async (email: string) => { export const sendForgotPasswordEmailPasscode = async (email: string) => {
await api await api
.post(`${forgotPasswordApiPrefix}/email/send-passcode`, { .post(`${forgotPasswordApiPrefix}/email/send-passcode`, {
@ -40,8 +43,8 @@ export const sendForgotPasswordEmailPasscode = async (email: string) => {
return { success: true }; return { success: true };
}; };
export const verifyForgotPasswordEmailPasscode = async (email: string, code: string) => export const verifyForgotPasswordEmailPasscode = async (email: string, code: string) => {
api await api
.post(`${forgotPasswordApiPrefix}/email/verify-passcode`, { .post(`${forgotPasswordApiPrefix}/email/verify-passcode`, {
json: { json: {
email, email,
@ -50,9 +53,15 @@ export const verifyForgotPasswordEmailPasscode = async (email: string, code: str
}) })
.json<Response>(); .json<Response>();
export const resetPassword = async (password: string) => return { success: true };
api };
export const resetPassword = async (password: string) => {
await api
.post(`${forgotPasswordApiPrefix}/reset`, { .post(`${forgotPasswordApiPrefix}/reset`, {
json: { password }, json: { password },
}) })
.json<Response>(); .json<Response>();
return { success: true };
};

View file

@ -51,7 +51,11 @@ export const getSendPasscodeApi = (
export const getVerifyPasscodeApi = ( export const getVerifyPasscodeApi = (
type: UserFlow, type: UserFlow,
method: PasscodeChannel method: PasscodeChannel
): ((_address: string, code: string, socialToBind?: string) => Promise<{ redirectTo: string }>) => { ): ((
_address: string,
code: string,
socialToBind?: string
) => Promise<{ redirectTo?: string; success?: boolean }>) => {
if (type === 'forgot-password' && method === 'email') { if (type === 'forgot-password' && method === 'email') {
return verifyForgotPasswordEmailPasscode; return verifyForgotPasswordEmailPasscode;
} }

View file

@ -23,13 +23,28 @@ jest.mock('@/apis/utils', () => ({
describe('<PasscodeValidation />', () => { describe('<PasscodeValidation />', () => {
const email = 'foo@logto.io'; const email = 'foo@logto.io';
const originalLocation = window.location;
beforeAll(() => {
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
Object.defineProperty(window, 'location', {
configurable: true,
value: { replace: jest.fn() },
});
});
afterEach(() => {
jest.resetAllMocks();
});
afterAll(() => { afterAll(() => {
jest.clearAllMocks(); jest.clearAllMocks();
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
}); });
it('render counter', () => { it('render counter', () => {
const { queryByText, debug } = renderWithPageContext( const { queryByText } = renderWithPageContext(
<PasscodeValidation type="sign-in" method="email" target={email} /> <PasscodeValidation type="sign-in" method="email" target={email} />
); );
@ -74,4 +89,53 @@ describe('<PasscodeValidation />', () => {
expect(verifyPasscodeApi).toBeCalledWith(email, '111111', undefined); expect(verifyPasscodeApi).toBeCalledWith(email, '111111', undefined);
}); });
}); });
it('should redirect with success redirectUri response', async () => {
verifyPasscodeApi.mockImplementationOnce(() => ({ redirectTo: 'foo.com' }));
const { container } = renderWithPageContext(
<PasscodeValidation type="sign-in" method="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(window.location.replace).toBeCalledWith('foo.com');
});
});
it('should redirect to reset password page if the flow is forgot-password', async () => {
verifyPasscodeApi.mockImplementationOnce(() => ({ success: true }));
const { container } = renderWithPageContext(
<PasscodeValidation type="forgot-password" method="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(window.location.replace).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith('/forgot-password/reset', { replace: true });
});
});
}); });

View file

@ -95,8 +95,14 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
useEffect(() => { useEffect(() => {
if (verifyPasscodeResult?.redirectTo) { if (verifyPasscodeResult?.redirectTo) {
window.location.replace(verifyPasscodeResult.redirectTo); window.location.replace(verifyPasscodeResult.redirectTo);
return;
} }
}, [verifyPasscodeResult]);
if (verifyPasscodeResult && type === 'forgot-password') {
navigate('/forgot-password/reset', { replace: true });
}
}, [navigate, type, verifyPasscodeResult]);
return ( return (
<form className={classNames(styles.form, className)}> <form className={classNames(styles.form, className)}>

View file

@ -33,7 +33,10 @@ describe('<PasswordlessSwitch />', () => {
const link = getByText('action.switch_to'); const link = getByText('action.switch_to');
fireEvent.click(link); fireEvent.click(link);
expect(mockedNavigate).toBeCalledWith({ pathname: '/forgot-password/email' }); expect(mockedNavigate).toBeCalledWith(
{ pathname: '/forgot-password/email' },
{ replace: true }
);
}); });
test('render email passwordless switch', () => { test('render email passwordless switch', () => {
@ -50,7 +53,7 @@ describe('<PasswordlessSwitch />', () => {
const link = getByText('action.switch_to'); const link = getByText('action.switch_to');
fireEvent.click(link); fireEvent.click(link);
expect(mockedNavigate).toBeCalledWith({ pathname: '/forgot-password/sms' }); expect(mockedNavigate).toBeCalledWith({ pathname: '/forgot-password/sms' }, { replace: true });
}); });
test('should not render the switch if SIE setting does not has the supported sign in method', () => { test('should not render the switch if SIE setting does not has the supported sign in method', () => {

View file

@ -33,9 +33,12 @@ const PasswordlessSwitch = ({ target, className }: Props) => {
<TextLink <TextLink
className={className} className={className}
onClick={() => { onClick={() => {
navigate({ navigate(
pathname: targetPathname, {
}); pathname: targetPathname,
},
{ replace: true }
);
}} }}
> >
{t('action.switch_to', { {t('action.switch_to', {

View file

@ -10,4 +10,9 @@
.inputField { .inputField {
margin-bottom: _.unit(4); margin-bottom: _.unit(4);
} }
.formErrors {
margin-top: _.unit(-2);
margin-bottom: _.unit(4);
}
} }

View file

@ -5,6 +5,13 @@ import { resetPassword } from '@/apis/forgot-password';
import ResetPassword from '.'; import ResetPassword from '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/forgot-password', () => ({ jest.mock('@/apis/forgot-password', () => ({
resetPassword: jest.fn(async () => ({ redirectTo: '/' })), resetPassword: jest.fn(async () => ({ redirectTo: '/' })),
})); }));

View file

@ -1,12 +1,16 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { useEffect, useCallback } from 'react'; import { useEffect, useCallback, useMemo, useContext } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { resetPassword } from '@/apis/forgot-password'; import { resetPassword } from '@/apis/forgot-password';
import Button from '@/components/Button'; import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import Input from '@/components/Input'; import Input from '@/components/Input';
import useApi from '@/hooks/use-api'; import useApi, { ErrorHandlers } from '@/hooks/use-api';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
import useForm from '@/hooks/use-form'; import useForm from '@/hooks/use-form';
import { PageContext } from '@/hooks/use-page-context';
import { passwordValidation, confirmPasswordValidation } from '@/utils/field-validations'; import { passwordValidation, confirmPasswordValidation } from '@/utils/field-validations';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
@ -29,29 +33,58 @@ const defaultState: FieldState = {
const ResetPassword = ({ className, autoFocus }: Props) => { const ResetPassword = ({ className, autoFocus }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setToast } = useContext(PageContext);
const {
fieldValue,
formErrorMessage,
setFieldValue,
register,
validateForm,
setFormErrorMessage,
} = useForm(defaultState);
const { show } = useConfirmModal();
const navigate = useNavigate();
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState); const resetPasswordErrorHandlers: ErrorHandlers = useMemo(
() => ({
'session.forgot_password_session_not_found': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
'session.forgot_password_verification_expired': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
'user.same_password': (error) => {
setFormErrorMessage(error.message);
},
}),
[navigate, setFormErrorMessage, show]
);
const { result, run: asyncRegister } = useApi(resetPassword); const { result, run: asyncRegister } = useApi(resetPassword, resetPasswordErrorHandlers);
const onSubmitHandler = useCallback( const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => { async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault(); event?.preventDefault();
setFormErrorMessage(undefined);
if (!validateForm()) { if (!validateForm()) {
return; return;
} }
void asyncRegister(fieldValue.password); void asyncRegister(fieldValue.password);
}, },
[validateForm, asyncRegister, fieldValue] [setFormErrorMessage, validateForm, asyncRegister, fieldValue.password]
); );
useEffect(() => { useEffect(() => {
if (result?.redirectTo) { if (result) {
window.location.replace(result.redirectTo); setToast(t('description.password_changed'));
navigate('/sign-in', { replace: true });
} }
}, [result]); }, [navigate, result, setToast, t]);
return ( return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}> <form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
@ -80,6 +113,9 @@ const ResetPassword = ({ className, autoFocus }: Props) => {
setFieldValue((state) => ({ ...state, confirmPassword: '' })); setFieldValue((state) => ({ ...state, confirmPassword: '' }));
}} }}
/> />
{formErrorMessage && (
<ErrorMessage className={styles.formErrors}>{formErrorMessage}</ErrorMessage>
)}
<Button title="action.confirm" onClick={async () => onSubmitHandler()} /> <Button title="action.confirm" onClick={async () => onSubmitHandler()} />