diff --git a/packages/ui/src/apis/index.test.ts b/packages/ui/src/apis/index.test.ts index bcfeaee4b..3095b97d5 100644 --- a/packages/ui/src/apis/index.test.ts +++ b/packages/ui/src/apis/index.test.ts @@ -11,6 +11,7 @@ import { } from './forgot-password'; import { register, + checkUsername, registerWithSms, registerWithEmail, sendRegisterEmailPasscode, @@ -19,13 +20,14 @@ import { verifyRegisterSmsPasscode, } from './register'; import { - signInBasic, + signInWithUsername, signInWithSms, signInWithEmail, sendSignInSmsPasscode, sendSignInEmailPasscode, verifySignInEmailPasscode, verifySignInSmsPasscode, + signInWithEmailPassword, } from './sign-in'; import { invokeSocialSignIn, @@ -55,13 +57,13 @@ describe('api', () => { mockKyPost.mockClear(); }); - it('signInBasic', async () => { + it('signInWithUsername', async () => { mockKyPost.mockReturnValueOnce({ json: () => ({ redirectTo: '/', }), }); - await signInBasic(username, password); + await signInWithUsername(username, password); expect(ky.post).toBeCalledWith('/api/session/sign-in/password/username', { json: { username, @@ -70,6 +72,41 @@ describe('api', () => { }); }); + it('signInWithEmailPassword', async () => { + mockKyPost.mockReturnValueOnce({ + json: () => ({ + redirectTo: '/', + }), + }); + await signInWithEmailPassword(email, password); + expect(ky.post).toBeCalledWith('/api/session/sign-in/password/email', { + json: { + email, + password, + }, + }); + }); + + it('signInWithEmailPassword with bind social account', async () => { + mockKyPost.mockReturnValueOnce({ + json: () => ({ + redirectTo: '/', + }), + }); + await signInWithEmailPassword(email, password, 'github'); + expect(ky.post).toHaveBeenNthCalledWith(1, '/api/session/sign-in/password/email', { + json: { + email, + password, + }, + }); + expect(ky.post).toHaveBeenNthCalledWith(2, '/api/session/bind-social', { + json: { + connectorId: 'github', + }, + }); + }); + it('signInWithSms', async () => { mockKyPost.mockReturnValueOnce({ json: () => ({ @@ -90,13 +127,13 @@ describe('api', () => { expect(ky.post).toBeCalledWith('/api/session/sign-in/passwordless/email'); }); - it('signInBasic with bind social account', async () => { + it('signInWithUsername with bind social account', async () => { mockKyPost.mockReturnValueOnce({ json: () => ({ redirectTo: '/', }), }); - await signInBasic(username, password, 'github'); + await signInWithUsername(username, password, 'github'); expect(ky.post).toHaveBeenNthCalledWith(1, '/api/session/sign-in/password/username', { json: { username, @@ -181,6 +218,15 @@ describe('api', () => { }); }); + it('checkUsername', async () => { + await checkUsername(username); + expect(ky.post).toBeCalledWith('/api/session/register/password/check-username', { + json: { + username, + }, + }); + }); + it('registerWithSms', async () => { await registerWithSms(); expect(ky.post).toBeCalledWith('/api/session/register/passwordless/sms'); diff --git a/packages/ui/src/apis/sign-in.ts b/packages/ui/src/apis/sign-in.ts index 1cae284c7..ce8da0a4e 100644 --- a/packages/ui/src/apis/sign-in.ts +++ b/packages/ui/src/apis/sign-in.ts @@ -9,7 +9,11 @@ type Response = { redirectTo: string; }; -export const signInBasic = async (username: string, password: string, socialToBind?: string) => { +export const signInWithUsername = async ( + username: string, + password: string, + socialToBind?: string +) => { const result = await api .post(`${apiPrefix}/sign-in/password/username`, { json: { @@ -26,6 +30,27 @@ export const signInBasic = async (username: string, password: string, socialToBi return result; }; +export const signInWithEmailPassword = async ( + email: string, + password: string, + socialToBind?: string +) => { + const result = await api + .post(`${apiPrefix}/sign-in/password/email`, { + json: { + email, + 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/CreateAccount/index.tsx b/packages/ui/src/containers/CreateAccount/index.tsx index b008a9de1..fa060c73c 100644 --- a/packages/ui/src/containers/CreateAccount/index.tsx +++ b/packages/ui/src/containers/CreateAccount/index.tsx @@ -86,44 +86,42 @@ const CreateAccount = ({ className, autoFocus }: Props) => { return (
-
- { - setFieldValue((state) => ({ ...state, username: '' })); - }} - /> - { - setFieldValue((state) => ({ ...state, password: '' })); - }} - /> - - confirmPasswordValidation(fieldValue.password, confirmPassword) - )} - isErrorStyling={false} - onClear={() => { - setFieldValue((state) => ({ ...state, confirmPassword: '' })); - }} - /> -
+ { + setFieldValue((state) => ({ ...state, username: '' })); + }} + /> + { + setFieldValue((state) => ({ ...state, password: '' })); + }} + /> + + confirmPasswordValidation(fieldValue.password, confirmPassword) + )} + isErrorStyling={false} + onClear={() => { + setFieldValue((state) => ({ ...state, confirmPassword: '' })); + }} + /> diff --git a/packages/ui/src/containers/EmailPassword/index.module.scss b/packages/ui/src/containers/EmailPassword/index.module.scss new file mode 100644 index 000000000..fe937e867 --- /dev/null +++ b/packages/ui/src/containers/EmailPassword/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/EmailPassword/index.test.tsx b/packages/ui/src/containers/EmailPassword/index.test.tsx new file mode 100644 index 000000000..758f12ae4 --- /dev/null +++ b/packages/ui/src/containers/EmailPassword/index.test.tsx @@ -0,0 +1,171 @@ +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 { signInWithEmailPassword } from '@/apis/sign-in'; +import ConfirmModalProvider from '@/containers/ConfirmModalProvider'; + +import EmailPassword from '.'; + +jest.mock('@/apis/sign-in', () => ({ signInWithEmailPassword: jest.fn(async () => 0) })); +jest.mock('react-device-detect', () => ({ + isMobile: true, +})); + +describe('', () => { + afterEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + test('render', () => { + const { queryByText, container } = renderWithPageContext(); + expect(container.querySelector('input[name="email"]')).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_email')).not.toBeNull(); + expect(queryByText('password_required')).not.toBeNull(); + + const emailInput = container.querySelector('input[name="email"]'); + const passwordInput = container.querySelector('input[name="password"]'); + + expect(emailInput).not.toBeNull(); + expect(passwordInput).not.toBeNull(); + + if (emailInput) { + fireEvent.change(emailInput, { target: { value: 'email@logto.io' } }); + } + + if (passwordInput) { + fireEvent.change(passwordInput, { target: { value: 'password' } }); + } + + expect(queryByText('invalid_email')).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 emailInput = container.querySelector('input[name="email"]'); + const passwordInput = container.querySelector('input[name="password"]'); + + if (emailInput) { + fireEvent.change(emailInput, { target: { value: 'email@logto.io' } }); + } + + 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 emailInput = container.querySelector('input[name="email"]'); + const passwordInput = container.querySelector('input[name="password"]'); + + if (emailInput) { + fireEvent.change(emailInput, { target: { value: 'email@logto.io' } }); + } + + 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 emailInput = container.querySelector('input[name="email"]'); + const passwordInput = container.querySelector('input[name="password"]'); + + if (emailInput) { + fireEvent.change(emailInput, { target: { value: 'email' } }); + } + + 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(signInWithEmailPassword).toBeCalledWith('email', 'password', undefined); + }); + }); + }); +}); diff --git a/packages/ui/src/containers/EmailPassword/index.tsx b/packages/ui/src/containers/EmailPassword/index.tsx index 03cfdde08..1eaaa945b 100644 --- a/packages/ui/src/containers/EmailPassword/index.tsx +++ b/packages/ui/src/containers/EmailPassword/index.tsx @@ -1,9 +1,114 @@ +import classNames from 'classnames'; +import { useMemo, useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { signInWithEmailPassword } from '@/apis/sign-in'; +import Button from '@/components/Button'; +import ErrorMessage from '@/components/ErrorMessage'; +import Input, { 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 useTerms from '@/hooks/use-terms'; +import { SearchParameters } from '@/types'; +import { getSearchParameters } from '@/utils'; +import { emailValidation, 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 EmailPassword = ({ className }: Props) => { - return
email password form
; +type FieldState = { + email: string; + password: string; +}; + +const defaultState: FieldState = { + email: '', + password: '', +}; + +const EmailPassword = ({ className, autoFocus }: Props) => { + const { t } = useTranslation(); + const { termsValidation } = useTerms(); + + const [errorMessage, setErrorMessage] = useState(); + const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState); + + const errorHandlers: ErrorHandlers = useMemo( + () => ({ + 'session.invalid_credentials': (error) => { + setErrorMessage(error.message); + }, + }), + [setErrorMessage] + ); + + const { run: asyncSignInWithEmailPassword } = useApi(signInWithEmailPassword, errorHandlers); + + const onSubmitHandler = useCallback( + async (event?: React.FormEvent) => { + event?.preventDefault(); + + if (!validateForm()) { + return; + } + + if (!(await termsValidation())) { + return; + } + + const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial); + + void asyncSignInWithEmailPassword(fieldValue.email, fieldValue.password, socialToBind); + }, + [ + validateForm, + termsValidation, + asyncSignInWithEmailPassword, + fieldValue.email, + fieldValue.password, + ] + ); + + return ( + + { + setFieldValue((state) => ({ ...state, email: '' })); + }} + /> + requiredValidation('password', value))} + /> + + {errorMessage && {errorMessage}} + + + +