0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

feat(ui): add reset password form (#1964)

add reset password form
This commit is contained in:
simeng-li 2022-09-20 10:33:53 +08:00 committed by GitHub
parent ff81b0f83e
commit f97ec56fbf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 331 additions and 8 deletions

View file

@ -1,4 +1,5 @@
import { verifyRegisterEmailPasscode, verifyRegisterSmsPasscode } from './register'; import { verifyRegisterEmailPasscode, verifyRegisterSmsPasscode } from './register';
import { verifyResetPasswordEmailPasscode, verifyResetPasswordSmsPasscode } from './reset-password';
import { verifySignInEmailPasscode, verifySignInSmsPasscode } from './sign-in'; import { verifySignInEmailPasscode, verifySignInSmsPasscode } from './sign-in';
import { getVerifyPasscodeApi } from './utils'; import { getVerifyPasscodeApi } from './utils';
@ -8,5 +9,7 @@ describe('api', () => {
expect(getVerifyPasscodeApi('register', 'email')).toBe(verifyRegisterEmailPasscode); expect(getVerifyPasscodeApi('register', 'email')).toBe(verifyRegisterEmailPasscode);
expect(getVerifyPasscodeApi('sign-in', 'sms')).toBe(verifySignInSmsPasscode); expect(getVerifyPasscodeApi('sign-in', 'sms')).toBe(verifySignInSmsPasscode);
expect(getVerifyPasscodeApi('sign-in', 'email')).toBe(verifySignInEmailPasscode); expect(getVerifyPasscodeApi('sign-in', 'email')).toBe(verifySignInEmailPasscode);
expect(getVerifyPasscodeApi('reset-password', 'email')).toBe(verifyResetPasswordEmailPasscode);
expect(getVerifyPasscodeApi('reset-password', 'sms')).toBe(verifyResetPasswordSmsPasscode);
}); });
}); });

View file

@ -8,6 +8,12 @@ import {
verifyRegisterEmailPasscode, verifyRegisterEmailPasscode,
verifyRegisterSmsPasscode, verifyRegisterSmsPasscode,
} from './register'; } from './register';
import {
verifyResetPasswordEmailPasscode,
verifyResetPasswordSmsPasscode,
sendResetPasswordEmailPasscode,
sendResetPasswordSmsPasscode,
} from './reset-password';
import { import {
signInBasic, signInBasic,
sendSignInSmsPasscode, sendSignInSmsPasscode,
@ -179,6 +185,44 @@ describe('api', () => {
}); });
}); });
it('sendResetPasswordSmsPasscode', async () => {
await sendResetPasswordSmsPasscode(phone);
expect(ky.post).toBeCalledWith('/api/session/reset-password/sms/send-passcode', {
json: {
phone,
},
});
});
it('verifyResetPasswordSmsPasscode', async () => {
await verifyResetPasswordSmsPasscode(phone, code);
expect(ky.post).toBeCalledWith('/api/session/reset-password/sms/verify-passcode', {
json: {
phone,
code,
},
});
});
it('sendResetPasswordEmailPasscode', async () => {
await sendResetPasswordEmailPasscode(email);
expect(ky.post).toBeCalledWith('/api/session/reset-password/email/send-passcode', {
json: {
email,
},
});
});
it('verifyResetPasswordEmailPasscode', async () => {
await verifyResetPasswordEmailPasscode(email, code);
expect(ky.post).toBeCalledWith('/api/session/reset-password/email/verify-passcode', {
json: {
email,
code,
},
});
});
it('invokeSocialSignIn', async () => { it('invokeSocialSignIn', async () => {
await invokeSocialSignIn('connectorId', 'state', 'redirectUri'); await invokeSocialSignIn('connectorId', 'state', 'redirectUri');
expect(ky.post).toBeCalledWith('/api/session/sign-in/social', { expect(ky.post).toBeCalledWith('/api/session/sign-in/social', {

View file

@ -0,0 +1,56 @@
import api from './api';
type Response = {
redirectTo: string;
};
export const sendResetPasswordSmsPasscode = async (phone: string) => {
await api
.post('/api/session/reset-password/sms/send-passcode', {
json: {
phone,
},
})
.json();
return { success: true };
};
export const verifyResetPasswordSmsPasscode = async (phone: string, code: string) =>
api
.post('/api/session/reset-password/sms/verify-passcode', {
json: {
phone,
code,
},
})
.json<Response>();
export const sendResetPasswordEmailPasscode = async (email: string) => {
await api
.post('/api/session/reset-password/email/send-passcode', {
json: {
email,
},
})
.json();
return { success: true };
};
export const verifyResetPasswordEmailPasscode = async (email: string, code: string) =>
api
.post('/api/session/reset-password/email/verify-passcode', {
json: {
email,
code,
},
})
.json<Response>();
export const resetPassword = async (password: string) =>
api
.post('/api/session/reset-password', {
json: { password },
})
.json<Response>();

View file

@ -6,6 +6,12 @@ import {
sendRegisterEmailPasscode, sendRegisterEmailPasscode,
sendRegisterSmsPasscode, sendRegisterSmsPasscode,
} from './register'; } from './register';
import {
sendResetPasswordEmailPasscode,
sendResetPasswordSmsPasscode,
verifyResetPasswordEmailPasscode,
verifyResetPasswordSmsPasscode,
} from './reset-password';
import { import {
verifySignInEmailPasscode, verifySignInEmailPasscode,
verifySignInSmsPasscode, verifySignInSmsPasscode,
@ -20,13 +26,11 @@ export const getSendPasscodeApi = (
method: PasscodeChannel method: PasscodeChannel
): ((_address: string) => Promise<{ success: boolean }>) => { ): ((_address: string) => Promise<{ success: boolean }>) => {
if (type === 'reset-password' && method === 'email') { if (type === 'reset-password' && method === 'email') {
// TODO: update using reset-password verification api return sendResetPasswordEmailPasscode;
return async () => ({ success: true });
} }
if (type === 'reset-password' && method === 'sms') { if (type === 'reset-password' && method === 'sms') {
// TODO: update using reset-password verification api return sendResetPasswordSmsPasscode;
return async () => ({ success: true });
} }
if (type === 'sign-in' && method === 'email') { if (type === 'sign-in' && method === 'email') {
@ -49,13 +53,11 @@ export const getVerifyPasscodeApi = (
method: PasscodeChannel method: PasscodeChannel
): ((_address: string, code: string, socialToBind?: string) => Promise<{ redirectTo: string }>) => { ): ((_address: string, code: string, socialToBind?: string) => Promise<{ redirectTo: string }>) => {
if (type === 'reset-password' && method === 'email') { if (type === 'reset-password' && method === 'email') {
// TODO: update using reset-password verification api return verifyResetPasswordEmailPasscode;
return async () => ({ redirectTo: '' });
} }
if (type === 'reset-password' && method === 'sms') { if (type === 'reset-password' && method === 'sms') {
// TODO: update using reset-password verification api return verifyResetPasswordSmsPasscode;
return async () => ({ redirectTo: '' });
} }
if (type === 'sign-in' && method === 'email') { if (type === 'sign-in' && method === 'email') {

View file

@ -0,0 +1,13 @@
@use '@/scss/underscore' as _;
.form {
@include _.flex-column;
> * {
width: 100%;
}
.inputField {
margin-bottom: _.unit(4);
}
}

View file

@ -0,0 +1,112 @@
import { fireEvent, act, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { resetPassword } from '@/apis/reset-password';
import ResetPassword from '.';
jest.mock('@/apis/reset-password', () => ({
resetPassword: jest.fn(async () => ({ redirectTo: '/' })),
}));
describe('<ResetPassword />', () => {
test('default render', () => {
const { queryByText, container } = renderWithPageContext(<ResetPassword />);
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
expect(queryByText('action.confirm')).not.toBeNull();
});
test('password are required', () => {
const { queryByText, getByText } = renderWithPageContext(<ResetPassword />);
const submitButton = getByText('action.confirm');
fireEvent.click(submitButton);
expect(queryByText('password_required')).not.toBeNull();
expect(resetPassword).not.toBeCalled();
});
test('password less than 6 chars should throw', () => {
const { queryByText, getByText, container } = renderWithPageContext(<ResetPassword />);
const submitButton = getByText('action.confirm');
const passwordInput = container.querySelector('input[name="new-password"]');
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '12345' } });
}
act(() => {
fireEvent.click(submitButton);
});
expect(queryByText('password_min_length')).not.toBeNull();
expect(resetPassword).not.toBeCalled();
act(() => {
// Clear error
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '123456' } });
}
});
expect(queryByText('password_min_length')).toBeNull();
});
test('password mismatch with confirmPassword should throw', () => {
const { queryByText, getByText, container } = renderWithPageContext(<ResetPassword />);
const submitButton = getByText('action.confirm');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
act(() => {
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '123456' } });
}
if (confirmPasswordInput) {
fireEvent.change(confirmPasswordInput, { target: { value: '012345' } });
}
fireEvent.click(submitButton);
});
expect(queryByText('passwords_do_not_match')).not.toBeNull();
expect(resetPassword).not.toBeCalled();
act(() => {
// Clear Error
if (confirmPasswordInput) {
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
}
});
expect(queryByText('passwords_do_not_match')).toBeNull();
});
test('should submit properly', async () => {
const { queryByText, getByText, container } = renderWithPageContext(<ResetPassword />);
const submitButton = getByText('action.confirm');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
act(() => {
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '123456' } });
}
if (confirmPasswordInput) {
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
}
fireEvent.click(submitButton);
});
expect(queryByText('passwords_do_not_match')).toBeNull();
await waitFor(() => {
expect(resetPassword).toBeCalled();
});
});
});

View file

@ -0,0 +1,91 @@
import classNames from 'classnames';
import { useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { resetPassword } from '@/apis/reset-password';
import Button from '@/components/Button';
import Input from '@/components/Input';
import useApi from '@/hooks/use-api';
import useForm from '@/hooks/use-form';
import { passwordValidation, confirmPasswordValidation } from '@/utils/field-validations';
import * as styles from './index.module.scss';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
};
type FieldState = {
password: string;
confirmPassword: string;
};
const defaultState: FieldState = {
password: '',
confirmPassword: '',
};
const ResetPassword = ({ className, autoFocus }: Props) => {
const { t } = useTranslation();
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
const { result, run: asyncRegister } = useApi(resetPassword);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!validateForm()) {
return;
}
void asyncRegister(fieldValue.password);
},
[validateForm, asyncRegister, fieldValue]
);
useEffect(() => {
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
}, [result]);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input
className={styles.inputField}
name="new-password"
type="password"
autoComplete="new-password"
placeholder={t('input.password')}
autoFocus={autoFocus}
{...register('password', passwordValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, password: '' }));
}}
/>
<Input
className={styles.inputField}
name="confirm-new-password"
type="password"
placeholder={t('input.confirm_password')}
{...register('confirmPassword', (confirmPassword) =>
confirmPasswordValidation(fieldValue.password, confirmPassword)
)}
errorStyling={false}
onClear={() => {
setFieldValue((state) => ({ ...state, confirmPassword: '' }));
}}
/>
<Button onClick={async () => onSubmitHandler()}>{t('action.confirm')}</Button>
<input hidden type="submit" />
</form>
);
};
export default ResetPassword;

View file

@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import NavBar from '@/components/NavBar'; import NavBar from '@/components/NavBar';
import ResetPasswordForm from '@/containers/ResetPassword';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
@ -12,6 +13,7 @@ const ResetPassword = () => {
<NavBar /> <NavBar />
<div className={styles.container}> <div className={styles.container}>
<div className={styles.title}>{t('description.new_password')}</div> <div className={styles.title}>{t('description.new_password')}</div>
<ResetPasswordForm autoFocus />
</div> </div>
</div> </div>
); );