0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(ui): add EmailPassword container (#2302)

This commit is contained in:
simeng-li 2022-11-03 18:05:15 +08:00 committed by GitHub
parent 14091cc8fc
commit 19024719ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 457 additions and 105 deletions

View file

@ -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');

View file

@ -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<Response>();
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<Response>();

View file

@ -86,44 +86,42 @@ const CreateAccount = ({ className, autoFocus }: Props) => {
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<div className={styles.formFields}>
<Input
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={styles.inputField}
name="new-username"
placeholder={t('input.username')}
{...fieldRegister('username', usernameValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, username: '' }));
}}
/>
<Input
className={styles.inputField}
name="new-password"
type="password"
autoComplete="new-password"
placeholder={t('input.password')}
{...fieldRegister('password', passwordValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, password: '' }));
}}
/>
<Input
className={styles.inputField}
name="confirm-new-password"
type="password"
autoComplete="new-password"
placeholder={t('input.confirm_password')}
{...fieldRegister('confirmPassword', (confirmPassword) =>
confirmPasswordValidation(fieldValue.password, confirmPassword)
)}
isErrorStyling={false}
onClear={() => {
setFieldValue((state) => ({ ...state, confirmPassword: '' }));
}}
/>
</div>
<Input
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={styles.inputField}
name="new-username"
placeholder={t('input.username')}
{...fieldRegister('username', usernameValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, username: '' }));
}}
/>
<Input
className={styles.inputField}
name="new-password"
type="password"
autoComplete="new-password"
placeholder={t('input.password')}
{...fieldRegister('password', passwordValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, password: '' }));
}}
/>
<Input
className={styles.inputField}
name="confirm-new-password"
type="password"
autoComplete="new-password"
placeholder={t('input.confirm_password')}
{...fieldRegister('confirmPassword', (confirmPassword) =>
confirmPasswordValidation(fieldValue.password, confirmPassword)
)}
isErrorStyling={false}
onClear={() => {
setFieldValue((state) => ({ ...state, confirmPassword: '' }));
}}
/>
<TermsOfUse className={styles.terms} />

View file

@ -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);
}
}

View file

@ -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('<EmailPassword>', () => {
afterEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
test('render', () => {
const { queryByText, container } = renderWithPageContext(<EmailPassword />);
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(
<SettingsProvider>
<EmailPassword />
</SettingsProvider>
);
expect(queryByText('description.agree_with_terms')).not.toBeNull();
});
test('required inputs with error message', () => {
const { queryByText, getByText, container } = renderWithPageContext(<EmailPassword />);
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(
<SettingsProvider>
<ConfirmModalProvider>
<EmailPassword />
</ConfirmModalProvider>
</SettingsProvider>
);
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(
<SettingsProvider>
<ConfirmModalProvider>
<EmailPassword />
</ConfirmModalProvider>
</SettingsProvider>
);
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(
<SettingsProvider>
<EmailPassword />
</SettingsProvider>
);
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);
});
});
});
});

View file

@ -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 <div className={className}>email password form</div>;
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<string>();
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<HTMLFormElement>) => {
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 (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input
type="email"
name="email"
autoComplete="email"
inputMode="email"
placeholder={t('input.email')}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={styles.inputField}
{...register('email', emailValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, email: '' }));
}}
/>
<PasswordInput
className={styles.inputField}
name="password"
autoComplete="current-password"
placeholder={t('input.password')}
{...register('password', (value) => requiredValidation('password', value))}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
<TermsOfUse className={styles.terms} />
<Button title="action.sign_in" onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>
);
};
export default EmailPassword;

View file

@ -7,10 +7,7 @@
width: 100%;
}
.formFields {
margin-bottom: _.unit(4);
}
.inputField,
.terms {
margin-bottom: _.unit(4);
}

View file

@ -84,16 +84,15 @@ const UsernameRegister = ({ className }: Props) => {
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<div className={styles.formFields}>
<Input
name="new-username"
placeholder={t('input.username')}
{...fieldRegister('username', usernameValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, username: '' }));
}}
/>
</div>
<Input
name="new-username"
className={styles.inputField}
placeholder={t('input.username')}
{...fieldRegister('username', usernameValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, username: '' }));
}}
/>
<TermsOfUse className={styles.terms} />

View file

@ -7,14 +7,7 @@
width: 100%;
}
.inputField {
margin-bottom: _.unit(4);
}
.formFields {
margin-bottom: _.unit(8);
}
.inputField,
.terms {
margin-bottom: _.unit(4);
}
@ -24,9 +17,3 @@
margin-bottom: _.unit(4);
}
}
:global(body.desktop) {
.formFields {
margin-bottom: _.unit(2);
}
}

View file

@ -3,12 +3,12 @@ import { act } from 'react-dom/test-utils';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { signInBasic } from '@/apis/sign-in';
import { signInWithUsername } from '@/apis/sign-in';
import ConfirmModalProvider from '@/containers/ConfirmModalProvider';
import UsernameSignIn from '.';
jest.mock('@/apis/sign-in', () => ({ signInBasic: jest.fn(async () => 0) }));
jest.mock('@/apis/sign-in', () => ({ signInWithUsername: jest.fn(async () => 0) }));
jest.mock('react-device-detect', () => ({
isMobile: true,
}));
@ -163,7 +163,7 @@ describe('<UsernameSignIn>', () => {
act(() => {
void waitFor(() => {
expect(signInBasic).toBeCalledWith('username', 'password', undefined);
expect(signInWithUsername).toBeCalledWith('username', 'password', undefined);
});
});
});

View file

@ -2,7 +2,7 @@ import classNames from 'classnames';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { signInBasic } from '@/apis/sign-in';
import { signInWithUsername } from '@/apis/sign-in';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import Input, { PasswordInput } from '@/components/Input';
@ -49,7 +49,7 @@ const UsernameSignIn = ({ className, autoFocus }: Props) => {
[setErrorMessage]
);
const { result, run: asyncSignInBasic } = useApi(signInBasic, errorHandlers);
const { result, run: asyncSignInWithUsername } = useApi(signInWithUsername, errorHandlers);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
@ -67,9 +67,15 @@ const UsernameSignIn = ({ className, autoFocus }: Props) => {
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
void asyncSignInBasic(fieldValue.username, fieldValue.password, socialToBind);
void asyncSignInWithUsername(fieldValue.username, fieldValue.password, socialToBind);
},
[validateForm, termsValidation, asyncSignInBasic, fieldValue.username, fieldValue.password]
[
validateForm,
termsValidation,
asyncSignInWithUsername,
fieldValue.username,
fieldValue.password,
]
);
useEffect(() => {
@ -80,28 +86,26 @@ const UsernameSignIn = ({ className, autoFocus }: Props) => {
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<div className={styles.formFields}>
<Input
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={styles.inputField}
name="username"
autoComplete="username"
placeholder={t('input.username')}
{...register('username', (value) => requiredValidation('username', value))}
onClear={() => {
setFieldValue((state) => ({ ...state, username: '' }));
}}
/>
<PasswordInput
className={styles.inputField}
name="password"
autoComplete="current-password"
placeholder={t('input.password')}
{...register('password', (value) => requiredValidation('password', value))}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
</div>
<Input
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={styles.inputField}
name="username"
autoComplete="username"
placeholder={t('input.username')}
{...register('username', (value) => requiredValidation('username', value))}
onClear={() => {
setFieldValue((state) => ({ ...state, username: '' }));
}}
/>
<PasswordInput
className={styles.inputField}
name="password"
autoComplete="current-password"
placeholder={t('input.password')}
{...register('password', (value) => requiredValidation('password', value))}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
<TermsOfUse className={styles.terms} />

View file

@ -52,7 +52,7 @@ describe('<SignIn />', () => {
});
test('render with email password as primary', async () => {
const { queryByText } = renderWithPageContext(
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
@ -72,7 +72,8 @@ describe('<SignIn />', () => {
</MemoryRouter>
</SettingsProvider>
);
expect(queryByText('email password form')).not.toBeNull();
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
});
test('renders with sms passwordless as primary', async () => {