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:
parent
8651c06f93
commit
afa2ac47ee
15 changed files with 165 additions and 22 deletions
|
@ -57,6 +57,7 @@ const translation = {
|
|||
reset_password_description_sms:
|
||||
'Enter the phone number associated with your account, and we’ll 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',
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -57,6 +57,7 @@ const translation = {
|
|||
reset_password_description_sms:
|
||||
'계정과 연결된 전화번호를 입력하면 비밀번호 재설정을 위한 인증 코드를 문자로 보내드립니다.',
|
||||
new_password: '새 비밀번호',
|
||||
password_changed: 'Password Changed', // UNTRANSLATED
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: '사용자 이름 또는 비밀번호가 일치하지 않아요.',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -57,6 +57,7 @@ const translation = {
|
|||
reset_password_description_sms:
|
||||
'输入与你的帐户关联的电话号码,我们将向您发送验证码以重置你的密码。',
|
||||
new_password: '新密码',
|
||||
password_changed: 'Password Changed', // UNTRANSLATED
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: '用户名和密码不匹配',
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)}>
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -10,4 +10,9 @@
|
|||
.inputField {
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
|
||||
.formErrors {
|
||||
margin-top: _.unit(-2);
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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: '/' })),
|
||||
}));
|
||||
|
|
|
@ -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()} />
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue