Skip to content

Commit

Permalink
feat(ui): add reset password form (#1964)
Browse files Browse the repository at this point in the history
add reset password form
  • Loading branch information
simeng-li authored Sep 20, 2022
1 parent ff81b0f commit f97ec56
Show file tree
Hide file tree
Showing 8 changed files with 331 additions and 8 deletions.
3 changes: 3 additions & 0 deletions packages/ui/src/apis/api.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { verifyRegisterEmailPasscode, verifyRegisterSmsPasscode } from './register';
import { verifyResetPasswordEmailPasscode, verifyResetPasswordSmsPasscode } from './reset-password';
import { verifySignInEmailPasscode, verifySignInSmsPasscode } from './sign-in';
import { getVerifyPasscodeApi } from './utils';

Expand All @@ -8,5 +9,7 @@ describe('api', () => {
expect(getVerifyPasscodeApi('register', 'email')).toBe(verifyRegisterEmailPasscode);
expect(getVerifyPasscodeApi('sign-in', 'sms')).toBe(verifySignInSmsPasscode);
expect(getVerifyPasscodeApi('sign-in', 'email')).toBe(verifySignInEmailPasscode);
expect(getVerifyPasscodeApi('reset-password', 'email')).toBe(verifyResetPasswordEmailPasscode);
expect(getVerifyPasscodeApi('reset-password', 'sms')).toBe(verifyResetPasswordSmsPasscode);
});
});
44 changes: 44 additions & 0 deletions packages/ui/src/apis/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ import {
verifyRegisterEmailPasscode,
verifyRegisterSmsPasscode,
} from './register';
import {
verifyResetPasswordEmailPasscode,
verifyResetPasswordSmsPasscode,
sendResetPasswordEmailPasscode,
sendResetPasswordSmsPasscode,
} from './reset-password';
import {
signInBasic,
sendSignInSmsPasscode,
Expand Down Expand Up @@ -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 () => {
await invokeSocialSignIn('connectorId', 'state', 'redirectUri');
expect(ky.post).toBeCalledWith('/api/session/sign-in/social', {
Expand Down
56 changes: 56 additions & 0 deletions packages/ui/src/apis/reset-password.ts
Original file line number Diff line number Diff line change
@@ -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>();
18 changes: 10 additions & 8 deletions packages/ui/src/apis/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import {
sendRegisterEmailPasscode,
sendRegisterSmsPasscode,
} from './register';
import {
sendResetPasswordEmailPasscode,
sendResetPasswordSmsPasscode,
verifyResetPasswordEmailPasscode,
verifyResetPasswordSmsPasscode,
} from './reset-password';
import {
verifySignInEmailPasscode,
verifySignInSmsPasscode,
Expand All @@ -20,13 +26,11 @@ export const getSendPasscodeApi = (
method: PasscodeChannel
): ((_address: string) => Promise<{ success: boolean }>) => {
if (type === 'reset-password' && method === 'email') {
// TODO: update using reset-password verification api
return async () => ({ success: true });
return sendResetPasswordEmailPasscode;
}

if (type === 'reset-password' && method === 'sms') {
// TODO: update using reset-password verification api
return async () => ({ success: true });
return sendResetPasswordSmsPasscode;
}

if (type === 'sign-in' && method === 'email') {
Expand All @@ -49,13 +53,11 @@ export const getVerifyPasscodeApi = (
method: PasscodeChannel
): ((_address: string, code: string, socialToBind?: string) => Promise<{ redirectTo: string }>) => {
if (type === 'reset-password' && method === 'email') {
// TODO: update using reset-password verification api
return async () => ({ redirectTo: '' });
return verifyResetPasswordEmailPasscode;
}

if (type === 'reset-password' && method === 'sms') {
// TODO: update using reset-password verification api
return async () => ({ redirectTo: '' });
return verifyResetPasswordSmsPasscode;
}

if (type === 'sign-in' && method === 'email') {
Expand Down
13 changes: 13 additions & 0 deletions packages/ui/src/containers/ResetPassword/index.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@use '@/scss/underscore' as _;

.form {
@include _.flex-column;

> * {
width: 100%;
}

.inputField {
margin-bottom: _.unit(4);
}
}
112 changes: 112 additions & 0 deletions packages/ui/src/containers/ResetPassword/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
91 changes: 91 additions & 0 deletions packages/ui/src/containers/ResetPassword/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit f97ec56

Please sign in to comment.