diff --git a/packages/ui/src/apis/index.test.ts b/packages/ui/src/apis/index.test.ts index 3095b97d5..f73b98471 100644 --- a/packages/ui/src/apis/index.test.ts +++ b/packages/ui/src/apis/index.test.ts @@ -28,6 +28,7 @@ import { verifySignInEmailPasscode, verifySignInSmsPasscode, signInWithEmailPassword, + signInWithPhonePassword, } from './sign-in'; import { invokeSocialSignIn, @@ -107,6 +108,41 @@ describe('api', () => { }); }); + it('signInWithPhonePassword', async () => { + mockKyPost.mockReturnValueOnce({ + json: () => ({ + redirectTo: '/', + }), + }); + await signInWithPhonePassword(phone, password); + expect(ky.post).toBeCalledWith('/api/session/sign-in/password/phone', { + json: { + phone, + password, + }, + }); + }); + + it('signInWithPhonePassword with bind social account', async () => { + mockKyPost.mockReturnValueOnce({ + json: () => ({ + redirectTo: '/', + }), + }); + await signInWithPhonePassword(phone, password, 'github'); + expect(ky.post).toHaveBeenNthCalledWith(1, '/api/session/sign-in/password/phone', { + json: { + phone, + password, + }, + }); + expect(ky.post).toHaveBeenNthCalledWith(2, '/api/session/bind-social', { + json: { + connectorId: 'github', + }, + }); + }); + it('signInWithSms', async () => { mockKyPost.mockReturnValueOnce({ json: () => ({ diff --git a/packages/ui/src/apis/sign-in.ts b/packages/ui/src/apis/sign-in.ts index ce8da0a4e..3c63479e5 100644 --- a/packages/ui/src/apis/sign-in.ts +++ b/packages/ui/src/apis/sign-in.ts @@ -51,6 +51,27 @@ export const signInWithEmailPassword = async ( return result; }; +export const signInWithPhonePassword = async ( + phone: string, + password: string, + socialToBind?: string +) => { + const result = await api + .post(`${apiPrefix}/sign-in/password/phone`, { + json: { + phone, + password, + }, + }) + .json(); + + if (result.redirectTo && socialToBind) { + await bindSocialAccount(socialToBind); + } + + return result; +}; + export const signInWithSms = async (socialToBind?: string) => { const result = await api.post(`${apiPrefix}/sign-in/passwordless/sms`).json(); diff --git a/packages/ui/src/containers/EmailPassword/index.test.tsx b/packages/ui/src/containers/EmailPassword/index.test.tsx index 758f12ae4..d7e30484e 100644 --- a/packages/ui/src/containers/EmailPassword/index.test.tsx +++ b/packages/ui/src/containers/EmailPassword/index.test.tsx @@ -9,6 +9,7 @@ import ConfirmModalProvider from '@/containers/ConfirmModalProvider'; import EmailPassword from '.'; jest.mock('@/apis/sign-in', () => ({ signInWithEmailPassword: jest.fn(async () => 0) })); +// Terms Iframe Modal only shown on mobile device jest.mock('react-device-detect', () => ({ isMobile: true, })); diff --git a/packages/ui/src/containers/EmailPassword/index.tsx b/packages/ui/src/containers/EmailPassword/index.tsx index 1eaaa945b..62b08deab 100644 --- a/packages/ui/src/containers/EmailPassword/index.tsx +++ b/packages/ui/src/containers/EmailPassword/index.tsx @@ -55,6 +55,8 @@ const EmailPassword = ({ className, autoFocus }: Props) => { async (event?: React.FormEvent) => { event?.preventDefault(); + setErrorMessage(undefined); + if (!validateForm()) { return; } diff --git a/packages/ui/src/containers/PhoneForm/PhoneForm.test.tsx b/packages/ui/src/containers/PhoneForm/PhoneForm.test.tsx index 59c74ba31..c73faed9c 100644 --- a/packages/ui/src/containers/PhoneForm/PhoneForm.test.tsx +++ b/packages/ui/src/containers/PhoneForm/PhoneForm.test.tsx @@ -10,6 +10,7 @@ import PhoneForm from './PhoneForm'; const onSubmit = jest.fn(); const clearErrorMessage = jest.fn(); +// PhoneNum CountryCode detection jest.mock('i18next', () => ({ language: 'en', })); diff --git a/packages/ui/src/containers/PhonePassword/index.module.scss b/packages/ui/src/containers/PhonePassword/index.module.scss new file mode 100644 index 000000000..fe937e867 --- /dev/null +++ b/packages/ui/src/containers/PhonePassword/index.module.scss @@ -0,0 +1,19 @@ +@use '@/scss/underscore' as _; + +.form { + @include _.flex-column; + + > * { + width: 100%; + } + + .inputField, + .terms { + margin-bottom: _.unit(4); + } + + .formErrors { + margin-top: _.unit(-2); + margin-bottom: _.unit(4); + } +} diff --git a/packages/ui/src/containers/PhonePassword/index.test.tsx b/packages/ui/src/containers/PhonePassword/index.test.tsx new file mode 100644 index 000000000..767822325 --- /dev/null +++ b/packages/ui/src/containers/PhonePassword/index.test.tsx @@ -0,0 +1,178 @@ +import { fireEvent, waitFor } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; + +import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; +import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; +import { signInWithPhonePassword } from '@/apis/sign-in'; +import ConfirmModalProvider from '@/containers/ConfirmModalProvider'; + +import PhonePassword from '.'; + +jest.mock('@/apis/sign-in', () => ({ signInWithPhonePassword: jest.fn(async () => 0) })); +// Terms Iframe Modal only shown on mobile device +jest.mock('react-device-detect', () => ({ + isMobile: true, +})); +// PhoneNum CountryCode detection +jest.mock('i18next', () => ({ + language: 'en', +})); + +describe('', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + const phoneNumber = '8573333333'; + + test('render', () => { + const { queryByText, container } = renderWithPageContext(); + expect(container.querySelector('input[name="phone"]')).not.toBeNull(); + expect(container.querySelector('input[name="password"]')).not.toBeNull(); + expect(queryByText('action.sign_in')).not.toBeNull(); + }); + + test('render with terms settings enabled', () => { + const { queryByText } = renderWithPageContext( + + + + ); + expect(queryByText('description.agree_with_terms')).not.toBeNull(); + }); + + test('required inputs with error message', () => { + const { queryByText, getByText, container } = renderWithPageContext(); + const submitButton = getByText('action.sign_in'); + + fireEvent.click(submitButton); + + expect(queryByText('invalid_phone')).not.toBeNull(); + expect(queryByText('password_required')).not.toBeNull(); + + const phoneInput = container.querySelector('input[name="phone"]'); + const passwordInput = container.querySelector('input[name="password"]'); + + expect(phoneInput).not.toBeNull(); + expect(passwordInput).not.toBeNull(); + + if (phoneInput) { + fireEvent.change(phoneInput, { target: { value: phoneNumber } }); + } + + if (passwordInput) { + fireEvent.change(passwordInput, { target: { value: 'password' } }); + } + + expect(queryByText('invalid_phone')).toBeNull(); + expect(queryByText('password_required')).toBeNull(); + }); + + test('should show terms confirm modal', async () => { + const { queryByText, getByText, container } = renderWithPageContext( + + + + + + ); + const submitButton = getByText('action.sign_in'); + + const phoneInput = container.querySelector('input[name="phone"]'); + const passwordInput = container.querySelector('input[name="password"]'); + + if (phoneInput) { + fireEvent.change(phoneInput, { target: { value: phoneNumber } }); + } + + if (passwordInput) { + fireEvent.change(passwordInput, { target: { value: 'password' } }); + } + + act(() => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(queryByText('description.agree_with_terms_modal')).not.toBeNull(); + }); + }); + + test('should show terms detail modal', async () => { + const { getByText, queryByText, container, queryByRole } = renderWithPageContext( + + + + + + ); + const submitButton = getByText('action.sign_in'); + + const phoneInput = container.querySelector('input[name="phone"]'); + const passwordInput = container.querySelector('input[name="password"]'); + + if (phoneInput) { + fireEvent.change(phoneInput, { target: { value: phoneNumber } }); + } + + if (passwordInput) { + fireEvent.change(passwordInput, { target: { value: 'password' } }); + } + + act(() => { + fireEvent.click(submitButton); + }); + + await waitFor(() => { + expect(queryByText('description.agree_with_terms_modal')).not.toBeNull(); + }); + + const termsLink = getByText('description.terms_of_use'); + + act(() => { + fireEvent.click(termsLink); + }); + + await waitFor(() => { + expect(queryByText('action.agree')).not.toBeNull(); + expect(queryByRole('article')).not.toBeNull(); + }); + }); + + test('submit form', async () => { + const { getByText, container } = renderWithPageContext( + + + + ); + const submitButton = getByText('action.sign_in'); + + const phoneInput = container.querySelector('input[name="phone"]'); + const passwordInput = container.querySelector('input[name="password"]'); + + if (phoneInput) { + fireEvent.change(phoneInput, { target: { value: 'phone' } }); + } + + if (passwordInput) { + fireEvent.change(passwordInput, { target: { value: 'password' } }); + } + + const termsButton = getByText('description.agree_with_terms'); + + act(() => { + fireEvent.click(termsButton); + }); + + act(() => { + fireEvent.click(submitButton); + }); + + act(() => { + void waitFor(() => { + expect(signInWithPhonePassword).toBeCalledWith('phone', 'password', undefined); + }); + }); + }); +}); diff --git a/packages/ui/src/containers/PhonePassword/index.tsx b/packages/ui/src/containers/PhonePassword/index.tsx index 070004683..477261cf1 100644 --- a/packages/ui/src/containers/PhonePassword/index.tsx +++ b/packages/ui/src/containers/PhonePassword/index.tsx @@ -1,9 +1,136 @@ +import classNames from 'classnames'; +import { useMemo, useCallback, useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { signInWithPhonePassword } from '@/apis/sign-in'; +import Button from '@/components/Button'; +import ErrorMessage from '@/components/ErrorMessage'; +import { PhoneInput, PasswordInput } from '@/components/Input'; +import TermsOfUse from '@/containers/TermsOfUse'; +import type { ErrorHandlers } from '@/hooks/use-api'; +import useApi from '@/hooks/use-api'; +import useForm from '@/hooks/use-form'; +import usePhoneNumber from '@/hooks/use-phone-number'; +import useTerms from '@/hooks/use-terms'; +import { SearchParameters } from '@/types'; +import { getSearchParameters } from '@/utils'; +import { requiredValidation } 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; }; -const PhonePassword = ({ className }: Props) => { - return
Phone password form
; +type FieldState = { + phone: string; + password: string; +}; + +const defaultState: FieldState = { + phone: '', + password: '', +}; + +const PhonePassword = ({ className, autoFocus }: Props) => { + const { t } = useTranslation(); + const { termsValidation } = useTerms(); + + const [errorMessage, setErrorMessage] = useState(); + const { countryList, phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber(); + const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState); + + // Validate phoneNumber with given country code + const phoneNumberValidation = useCallback( + (phoneNumber: string) => { + if (!isValidPhoneNumber(phoneNumber)) { + return 'invalid_phone'; + } + }, + [isValidPhoneNumber] + ); + + // Sync phoneNumber + useEffect(() => { + setFieldValue((previous) => ({ + ...previous, + phone: `${phoneNumber.countryCallingCode}${phoneNumber.nationalNumber}`, + })); + }, [phoneNumber, setFieldValue]); + + const errorHandlers: ErrorHandlers = useMemo( + () => ({ + 'session.invalid_credentials': (error) => { + setErrorMessage(error.message); + }, + }), + [setErrorMessage] + ); + + const { run: asyncSignInWithPhonePassword } = useApi(signInWithPhonePassword, errorHandlers); + + const onSubmitHandler = useCallback( + async (event?: React.FormEvent) => { + event?.preventDefault(); + + setErrorMessage(undefined); + + if (!validateForm()) { + return; + } + + if (!(await termsValidation())) { + return; + } + + const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial); + + void asyncSignInWithPhonePassword(fieldValue.phone, fieldValue.password, socialToBind); + }, + [ + validateForm, + termsValidation, + asyncSignInWithPhonePassword, + fieldValue.phone, + fieldValue.password, + ] + ); + + return ( +
+ { + setPhoneNumber((previous) => ({ ...previous, ...data })); + }} + /> + requiredValidation('password', value))} + /> + + {errorMessage && {errorMessage}} + + + +