0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -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:
'Enter the phone number associated with your account, and well text you the verification code to reset your password.',
new_password: 'New password',
password_changed: 'Password Changed',
},
error: {
username_password_mismatch: 'Username and password do not match',

View file

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

View file

@ -57,6 +57,7 @@ const translation = {
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.',
new_password: 'Nova Senha',
password_changed: 'Password Changed', // UNTRANSLATED
},
error: {
username_password_mismatch: 'O Utilizador e a password não correspondem',

View file

@ -58,6 +58,7 @@ const translation = {
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.',
new_password: 'Yeni Şifre',
password_changed: 'Password Changed', // UNTRANSLATED
},
error: {
username_password_mismatch: 'Kullanıcı adı ve şifre eşleşmiyor.',

View file

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

View file

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

View file

@ -51,7 +51,11 @@ export const getSendPasscodeApi = (
export const getVerifyPasscodeApi = (
type: UserFlow,
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') {
return verifyForgotPasswordEmailPasscode;
}

View file

@ -23,13 +23,28 @@ jest.mock('@/apis/utils', () => ({
describe('<PasscodeValidation />', () => {
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(() => {
jest.clearAllMocks();
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
});
it('render counter', () => {
const { queryByText, debug } = renderWithPageContext(
const { queryByText } = renderWithPageContext(
<PasscodeValidation type="sign-in" method="email" target={email} />
);
@ -74,4 +89,53 @@ describe('<PasscodeValidation />', () => {
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(() => {
if (verifyPasscodeResult?.redirectTo) {
window.location.replace(verifyPasscodeResult.redirectTo);
return;
}
}, [verifyPasscodeResult]);
if (verifyPasscodeResult && type === 'forgot-password') {
navigate('/forgot-password/reset', { replace: true });
}
}, [navigate, type, verifyPasscodeResult]);
return (
<form className={classNames(styles.form, className)}>

View file

@ -33,7 +33,10 @@ describe('<PasswordlessSwitch />', () => {
const link = getByText('action.switch_to');
fireEvent.click(link);
expect(mockedNavigate).toBeCalledWith({ pathname: '/forgot-password/email' });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/forgot-password/email' },
{ replace: true }
);
});
test('render email passwordless switch', () => {
@ -50,7 +53,7 @@ describe('<PasswordlessSwitch />', () => {
const link = getByText('action.switch_to');
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', () => {

View file

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

View file

@ -10,4 +10,9 @@
.inputField {
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 '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/forgot-password', () => ({
resetPassword: jest.fn(async () => ({ redirectTo: '/' })),
}));

View file

@ -1,12 +1,16 @@
import classNames from 'classnames';
import { useEffect, useCallback } from 'react';
import { useEffect, useCallback, useMemo, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { resetPassword } from '@/apis/forgot-password';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
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 { PageContext } from '@/hooks/use-page-context';
import { passwordValidation, confirmPasswordValidation } from '@/utils/field-validations';
import * as styles from './index.module.scss';
@ -29,29 +33,58 @@ const defaultState: FieldState = {
const ResetPassword = ({ className, autoFocus }: Props) => {
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(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
setFormErrorMessage(undefined);
if (!validateForm()) {
return;
}
void asyncRegister(fieldValue.password);
},
[validateForm, asyncRegister, fieldValue]
[setFormErrorMessage, validateForm, asyncRegister, fieldValue.password]
);
useEffect(() => {
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
if (result) {
setToast(t('description.password_changed'));
navigate('/sign-in', { replace: true });
}
}, [result]);
}, [navigate, result, setToast, t]);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
@ -80,6 +113,9 @@ const ResetPassword = ({ className, autoFocus }: Props) => {
setFieldValue((state) => ({ ...state, confirmPassword: '' }));
}}
/>
{formErrorMessage && (
<ErrorMessage className={styles.formErrors}>{formErrorMessage}</ErrorMessage>
)}
<Button title="action.confirm" onClick={async () => onSubmitHandler()} />