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

feat(ui): add PhonePassword container (#2304)

This commit is contained in:
simeng-li 2022-11-03 19:03:02 +08:00 committed by GitHub
parent 5e571936c9
commit d60e1e8267
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 391 additions and 3 deletions

View file

@ -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: () => ({

View file

@ -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<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

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

View file

@ -55,6 +55,8 @@ const EmailPassword = ({ className, autoFocus }: Props) => {
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
setErrorMessage(undefined);
if (!validateForm()) {
return;
}

View file

@ -10,6 +10,7 @@ import PhoneForm from './PhoneForm';
const onSubmit = jest.fn();
const clearErrorMessage = jest.fn();
// PhoneNum CountryCode detection
jest.mock('i18next', () => ({
language: 'en',
}));

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

View file

@ -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 <div className={className}>Phone password form</div>;
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<string>();
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<HTMLFormElement>) => {
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 (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<PhoneInput
name="phone"
placeholder={t('input.phone_number')}
className={styles.inputField}
countryCallingCode={phoneNumber.countryCallingCode}
nationalNumber={phoneNumber.nationalNumber}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
countryList={countryList}
{...register('phone', phoneNumberValidation)}
onChange={(data) => {
setPhoneNumber((previous) => ({ ...previous, ...data }));
}}
/>
<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 PhonePassword;

View file

@ -73,6 +73,7 @@ describe('<SignIn />', () => {
</SettingsProvider>
);
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(container.querySelector('input[name="password"]')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
});
@ -111,7 +112,9 @@ describe('<SignIn />', () => {
</MemoryRouter>
</SettingsProvider>
);
expect(queryByText('Phone password form')).not.toBeNull();
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(container.querySelector('input[name="password"]')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
});
test('renders with social as primary', async () => {