diff --git a/packages/ui/src/apis/api.test.ts b/packages/ui/src/apis/api.test.ts index bc607bac8..504e01176 100644 --- a/packages/ui/src/apis/api.test.ts +++ b/packages/ui/src/apis/api.test.ts @@ -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'; @@ -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); }); }); diff --git a/packages/ui/src/apis/index.test.ts b/packages/ui/src/apis/index.test.ts index 9640ba59c..2904498ab 100644 --- a/packages/ui/src/apis/index.test.ts +++ b/packages/ui/src/apis/index.test.ts @@ -8,6 +8,12 @@ import { verifyRegisterEmailPasscode, verifyRegisterSmsPasscode, } from './register'; +import { + verifyResetPasswordEmailPasscode, + verifyResetPasswordSmsPasscode, + sendResetPasswordEmailPasscode, + sendResetPasswordSmsPasscode, +} from './reset-password'; import { signInBasic, 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 () => { await invokeSocialSignIn('connectorId', 'state', 'redirectUri'); expect(ky.post).toBeCalledWith('/api/session/sign-in/social', { diff --git a/packages/ui/src/apis/reset-password.ts b/packages/ui/src/apis/reset-password.ts new file mode 100644 index 000000000..5abcda7e7 --- /dev/null +++ b/packages/ui/src/apis/reset-password.ts @@ -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(); + +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(); + +export const resetPassword = async (password: string) => + api + .post('/api/session/reset-password', { + json: { password }, + }) + .json(); diff --git a/packages/ui/src/apis/utils.ts b/packages/ui/src/apis/utils.ts index 70dc8df57..47918b3c4 100644 --- a/packages/ui/src/apis/utils.ts +++ b/packages/ui/src/apis/utils.ts @@ -6,6 +6,12 @@ import { sendRegisterEmailPasscode, sendRegisterSmsPasscode, } from './register'; +import { + sendResetPasswordEmailPasscode, + sendResetPasswordSmsPasscode, + verifyResetPasswordEmailPasscode, + verifyResetPasswordSmsPasscode, +} from './reset-password'; import { verifySignInEmailPasscode, verifySignInSmsPasscode, @@ -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') { @@ -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') { diff --git a/packages/ui/src/containers/ResetPassword/index.module.scss b/packages/ui/src/containers/ResetPassword/index.module.scss new file mode 100644 index 000000000..2fe7832d3 --- /dev/null +++ b/packages/ui/src/containers/ResetPassword/index.module.scss @@ -0,0 +1,13 @@ +@use '@/scss/underscore' as _; + +.form { + @include _.flex-column; + + > * { + width: 100%; + } + + .inputField { + margin-bottom: _.unit(4); + } +} diff --git a/packages/ui/src/containers/ResetPassword/index.test.tsx b/packages/ui/src/containers/ResetPassword/index.test.tsx new file mode 100644 index 000000000..8cd664861 --- /dev/null +++ b/packages/ui/src/containers/ResetPassword/index.test.tsx @@ -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('', () => { + test('default render', () => { + const { queryByText, container } = renderWithPageContext(); + 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(); + 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(); + 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(); + 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(); + 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(); + }); + }); +}); diff --git a/packages/ui/src/containers/ResetPassword/index.tsx b/packages/ui/src/containers/ResetPassword/index.tsx new file mode 100644 index 000000000..1f634fa68 --- /dev/null +++ b/packages/ui/src/containers/ResetPassword/index.tsx @@ -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) => { + event?.preventDefault(); + + if (!validateForm()) { + return; + } + + void asyncRegister(fieldValue.password); + }, + [validateForm, asyncRegister, fieldValue] + ); + + useEffect(() => { + if (result?.redirectTo) { + window.location.replace(result.redirectTo); + } + }, [result]); + + return ( +
+ { + setFieldValue((state) => ({ ...state, password: '' })); + }} + /> + + confirmPasswordValidation(fieldValue.password, confirmPassword) + )} + errorStyling={false} + onClear={() => { + setFieldValue((state) => ({ ...state, confirmPassword: '' })); + }} + /> + + + + +
+ ); +}; + +export default ResetPassword; diff --git a/packages/ui/src/pages/ResetPassword/index.tsx b/packages/ui/src/pages/ResetPassword/index.tsx index fa293883d..1d40f8cd0 100644 --- a/packages/ui/src/pages/ResetPassword/index.tsx +++ b/packages/ui/src/pages/ResetPassword/index.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import NavBar from '@/components/NavBar'; +import ResetPasswordForm from '@/containers/ResetPassword'; import * as styles from './index.module.scss'; @@ -12,6 +13,7 @@ const ResetPassword = () => {
{t('description.new_password')}
+
);