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:
parent
8651c06f93
commit
afa2ac47ee
15 changed files with 165 additions and 22 deletions
|
@ -57,6 +57,7 @@ const translation = {
|
||||||
reset_password_description_sms:
|
reset_password_description_sms:
|
||||||
'Enter the phone number associated with your account, and we’ll text you the verification code to reset your password.',
|
'Enter the phone number associated with your account, and we’ll 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',
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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: '사용자 이름 또는 비밀번호가 일치하지 않아요.',
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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.',
|
||||||
|
|
|
@ -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: '用户名和密码不匹配',
|
||||||
|
|
|
@ -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 };
|
||||||
|
};
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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)}>
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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', {
|
||||||
|
|
|
@ -10,4 +10,9 @@
|
||||||
.inputField {
|
.inputField {
|
||||||
margin-bottom: _.unit(4);
|
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 '.';
|
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: '/' })),
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -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()} />
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue