0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

refactor(ui): use smart input field to replace all password sign-in form (#3065)

This commit is contained in:
simeng-li 2023-02-13 16:35:33 +08:00 committed by GitHub
parent 888c856ae2
commit 39f35192e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
39 changed files with 575 additions and 1242 deletions

View file

@ -66,9 +66,9 @@ describe('smoke testing', () => {
});
it('signs in to admin console', async () => {
const usernameField = await page.waitForSelector('input[name=username]');
const usernameField = await page.waitForSelector('input[name=identifier]');
const passwordField = await page.waitForSelector('input[name=password]');
const submitButton = await page.waitForSelector('button');
const submitButton = await page.waitForSelector('button[name=submit]');
await usernameField.type(consoleUsername);
await passwordField.type(consolePassword);

View file

@ -43,6 +43,7 @@ const translation = {
description: {
email: 'Email',
phone_number: 'Telefonnummer',
username: 'benutzername',
reminder: 'Erinnerung',
not_found: '404 Nicht gefunden',
agree_with_terms: 'Ich akzeptiere die ',
@ -98,12 +99,13 @@ const translation = {
'Für zusätzliche Sicherheit, vervollständige bitte deine Informationen.',
},
error: {
username_password_mismatch: 'Benutzername oder Passwort ist falsch',
general_required: `{{types, list(type: disjunction;)}} ist erforderlich`,
general_invalid: `Die {{types, list(type: disjunction;)}} is ungültig`,
username_required: 'Benutzername ist erforderlich',
password_required: 'Passwort ist erforderlich',
username_exists: 'Benutzername existiert bereits',
username_should_not_start_with_number: 'Benutzername darf nicht mit einer Zahl beginnen',
username_valid_charset: 'Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten',
username_invalid_charset: 'Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten',
invalid_email: 'Die Email ist ungültig',
invalid_phone: 'Die Telefonnummer ist ungültig',
password_min_length: 'Passwort muss mindestens {{min}} Zeichen lang sein',

View file

@ -41,6 +41,7 @@ const translation = {
description: {
email: 'email',
phone_number: 'phone number',
username: 'username',
reminder: 'Reminder',
not_found: '404 Not Found',
agree_with_terms: 'I have read and agree to the ',
@ -93,12 +94,13 @@ const translation = {
continue_with_more_information: 'For added security, please complete below account details.',
},
error: {
username_password_mismatch: 'Username and password do not match',
general_required: `{{types, list(type: disjunction;)}} is required`,
general_invalid: `The {{types, list(type: disjunction;)}} is invalid`,
username_required: 'Username is required',
password_required: 'Password is required',
username_exists: 'Username already exists',
username_should_not_start_with_number: 'Username should not start with a number',
username_valid_charset: 'Username should only contain letters, numbers, or underscores.',
username_invalid_charset: 'Username should only contain letters, numbers, or underscores.',
invalid_email: 'The email is invalid',
invalid_phone: 'The phone number is invalid',
password_min_length: 'Password requires a minimum of {{min}} characters',

View file

@ -43,6 +43,7 @@ const translation = {
description: {
email: 'email',
phone_number: 'numéro de téléphone',
username: "nom d'utilisateur",
reminder: 'Rappel',
not_found: '404 Non trouvé',
agree_with_terms: "J'ai lu et accepté les ",
@ -96,13 +97,14 @@ const translation = {
continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED
},
error: {
username_password_mismatch: "Le nom d'utilisateur et le mot de passe ne correspondent pas",
general_required: `Le {{types, list(type: disjunction;)}} est requis`,
general_invalid: `Le {{types, list(type: disjunction;)}} n'est pas valide`,
username_required: "Le nom d'utilisateur est requis",
password_required: 'Le mot de passe est requis',
username_exists: "Ce Nom d'utilisateur existe déjà",
username_should_not_start_with_number:
"Le nom d'utilisateur ne doit pas commencer par un chiffre",
username_valid_charset:
username_invalid_charset:
"Le nom d'utilisateur ne doit contenir que des lettres, des chiffres ou des caractères de soulignement.",
invalid_email: "L'email n'est pas valide",
invalid_phone: "Le numéro de téléphone n'est pas valide",

View file

@ -43,6 +43,7 @@ const translation = {
description: {
email: '이메일',
phone_number: '휴대전화번호',
username: '사용자 이름',
reminder: '리마인더',
not_found: '404 찾을 수 없음',
agree_with_terms: '나는 내용을 읽었으며, 이에 동의합니다.',
@ -92,12 +93,13 @@ const translation = {
continue_with_more_information: '더 나은 보안을 위해 아래 자세한 내용을 따라 주세요.',
},
error: {
username_password_mismatch: '사용자 이름 또는 비밀번호가 일치하지 않아요.',
general_required: `{{types, list(type: disjunction;)}} 필수예요.`,
general_invalid: `{{types, list(type: disjunction;)}} 유효하지 않아요.`,
username_required: '사용자 이름은 필수예요.',
password_required: '비밀번호는 필수예요.',
username_exists: '사용자 이름이 이미 존재해요.',
username_should_not_start_with_number: '사용자 이름은 숫자로 시작하면 안 돼요.',
username_valid_charset: '사용자 이름은 문자, 숫자, _(밑줄 문자) 로만 이루어져야 해요.',
username_invalid_charset: '사용자 이름은 문자, 숫자, _(밑줄 문자) 로만 이루어져야 해요.',
invalid_email: '이메일이 유효하지 않아요.',
invalid_phone: '휴대전화번호가 유효하지 않아요.',
password_min_length: '비밀번호는 최소 {{min}} 자리로 이루어져야 해요.',

View file

@ -43,6 +43,7 @@ const translation = {
description: {
email: 'e-mail',
phone_number: 'número de telefone',
username: 'nome de usuário',
reminder: 'Lembrete',
not_found: '404 Não Encontrado',
agree_with_terms: 'Eu li e concordo com os ',
@ -95,12 +96,14 @@ const translation = {
continue_with_more_information: 'Para maior segurança, preencha os detalhes da conta abaixo.',
},
error: {
username_password_mismatch: 'Usuário e senha não correspondem',
general_required: `{{types, list(type: disjunction;)}} é obrigatório`,
general_invalid: `O {{types, list(type: disjunction;)}} é inválido`,
username_required: 'Nome de usuário é obrigatório',
password_required: 'Senha é obrigatório',
username_exists: 'O nome de usuário já existe',
username_should_not_start_with_number: 'O nome de usuário não deve começar com um número',
username_valid_charset: 'O nome de usuário deve conter apenas letras, números ou sublinhados.',
username_invalid_charset:
'O nome de usuário deve conter apenas letras, números ou sublinhados.',
invalid_email: 'O e-mail é inválido',
invalid_phone: 'O número de telefone é inválido',
password_min_length: 'A senha requer um mínimo de {{min}} caracteres',

View file

@ -43,6 +43,7 @@ const translation = {
description: {
email: 'email',
phone_number: 'telefone',
username: 'utilizador',
reminder: 'Lembrete',
not_found: '404 Não encontrado',
agree_with_terms: 'Eu li e concordo com os ',
@ -93,12 +94,13 @@ const translation = {
continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED
},
error: {
username_password_mismatch: 'O Utilizador e a password não correspondem',
general_required: `{{types, list(type: disjunction;)}} is necessário`,
general_invalid: `O {{types, list(type: disjunction;)}} é inválido`,
username_required: 'Utilizador necessário',
password_required: 'Password necessária',
username_exists: 'O nome de utilizador já existe',
username_should_not_start_with_number: 'O nome de utilizador não deve começar com um número',
username_valid_charset:
username_invalid_charset:
'O nome de utilizador deve conter apenas letras, números ou underscores.',
invalid_email: 'O email é inválido',
invalid_phone: 'O número de telefone é inválido',

View file

@ -43,6 +43,7 @@ const translation = {
description: {
email: 'e-posta adresi',
phone_number: 'telefon numarası',
username: 'kullanıcı Adı',
reminder: 'Hatırlatıcı',
not_found: '404 Bulunamadı',
agree_with_terms: 'Okudum ve anladım',
@ -94,12 +95,13 @@ const translation = {
continue_with_more_information: 'For added security, please complete below account details.', // UNTRANSLATED
},
error: {
username_password_mismatch: 'Kullanıcı adı ve şifre eşleşmiyor.',
general_required: `{{types, list(type: disjunction;)}} is required`, // UNTRANSLATED
general_invalid: `The {{types, list(type: disjunction;)}} is invalid`, // UNTRANSLATED
username_required: 'Kullanıcı adı gerekli.',
password_required: 'Şifre gerekli.',
username_exists: 'Kullanıcı adı mevcut.',
username_should_not_start_with_number: 'Kullanıcı adı sayı ile başlayamaz.',
username_valid_charset: 'Kullanıcı adı yalnızca harf,sayı veya alt çizgi içermeli.',
username_invalid_charset: 'Kullanıcı adı yalnızca harf,sayı veya alt çizgi içermeli.',
invalid_email: 'E-posta adresi geçersiz',
invalid_phone: 'Telefon numarası geçersiz',
password_min_length: 'Şifre en az {{min}} karakterden oluşmalıdır',

View file

@ -43,6 +43,7 @@ const translation = {
description: {
email: '邮箱',
phone_number: '手机',
username: '用户名',
reminder: '提示',
not_found: '404 页面不存在',
agree_with_terms: '我已阅读并同意 ',
@ -89,12 +90,13 @@ const translation = {
continue_with_more_information: '为保障您的账号安全,需要您补充以下信息。',
},
error: {
username_password_mismatch: '用户名和密码不匹配',
general_required: `{{types, list(type: disjunction;)}}必填`,
general_invalid: `无效的{{types, list(type: disjunction;)}}`,
username_required: '用户名必填',
password_required: '密码必填',
username_exists: '用户名已存在',
username_should_not_start_with_number: '用户名不能以数字开头',
username_valid_charset: '用户名只能包含英文字母、数字或下划线。',
username_invalid_charset: '用户名只能包含英文字母、数字或下划线。',
invalid_email: '无效的邮箱',
invalid_phone: '无效的手机号',
password_min_length: '密码最少需要{{min}}个字符',

View file

@ -2,9 +2,7 @@
import { InteractionEvent } from '@logto/schemas';
import type {
UsernamePasswordPayload,
EmailPasswordPayload,
PhonePasswordPayload,
SignInIdentifier,
EmailVerificationCodePayload,
PhoneVerificationCodePayload,
SocialConnectorPayload,
@ -22,10 +20,7 @@ type Response = {
redirectTo: string;
};
export type PasswordSignInPayload =
| UsernamePasswordPayload
| EmailPasswordPayload
| PhonePasswordPayload;
export type PasswordSignInPayload = { [K in SignInIdentifier]?: string } & { password: string };
export const signInWithPasswordIdentifier = async (payload: PasswordSignInPayload) => {
await api.put(`${interactionPrefix}`, {

View file

@ -30,7 +30,7 @@ describe('Input Field UI Component', () => {
});
test('render error message', () => {
const errorCode = 'username_password_mismatch';
const errorCode = 'password_required';
const { queryByText } = render(<PasswordInput error={errorCode} />);
expect(queryByText(errorCode)).not.toBeNull();
});

View file

@ -30,7 +30,7 @@ describe('Input Field UI Component', () => {
});
test('render error message', () => {
const errorCode = 'username_password_mismatch';
const errorCode = 'password_required';
const { queryByText } = render(<PasswordInputField error={errorCode} />);
expect(queryByText(errorCode)).not.toBeNull();
});

View file

@ -26,7 +26,12 @@ const useSmartInputField = ({ onChange, currentType, enabledTypes, onTypeChange
const [inputValue, setInputValue] = useState<string>('');
const enabledTypeSet = useMemo(() => new Set(enabledTypes), [enabledTypes]);
assert(enabledTypeSet.has(currentType), new Error('Invalid input type'));
assert(
enabledTypeSet.has(currentType),
new Error(
`Invalid input type. Current inputType ${currentType} is detected but missing in enabledTypes`
)
);
const detectInputType = useCallback(
(value: string) => {

View file

@ -3,13 +3,9 @@ import i18next from 'i18next';
import type { HTMLProps } from 'react';
import type { TFuncKey } from 'react-i18next';
import type { IdentifierInputType, EnabledIdentifierTypes } from './use-smart-input-field';
import { identifierInputPlaceholderMap } from '@/utils/form';
const identifierInputPlaceholderMap: { [K in IdentifierInputType]: TFuncKey } = {
[SignInIdentifier.Phone]: 'input.phone_number',
[SignInIdentifier.Email]: 'input.email',
[SignInIdentifier.Username]: 'input.username',
};
import type { IdentifierInputType, EnabledIdentifierTypes } from './use-smart-input-field';
export const getInputHtmlProps = (
currentType: IdentifierInputType,

View file

@ -74,7 +74,7 @@ describe('<CreateAccount/>', () => {
fireEvent.click(submitButton);
expect(queryByText('username_valid_charset')).not.toBeNull();
expect(queryByText('username_invalid_charset')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled();
@ -83,7 +83,7 @@ describe('<CreateAccount/>', () => {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
expect(queryByText('username_valid_charset')).toBeNull();
expect(queryByText('username_invalid_charset')).toBeNull();
});
test('password less than 6 chars should throw', () => {

View file

@ -11,7 +11,7 @@ import useApi from '@/hooks/use-api';
import useForm from '@/hooks/use-form';
import useTerms from '@/hooks/use-terms';
import {
usernameValidation,
validateUsername,
passwordValidation,
confirmPasswordValidation,
} from '@/utils/field-validations';
@ -94,7 +94,7 @@ const CreateAccount = ({ className, autoFocus }: Props) => {
className={styles.inputField}
name="new-username"
placeholder={t('input.username')}
{...fieldRegister('username', usernameValidation)}
{...fieldRegister('username', validateUsername)}
onClear={() => {
setFieldValue((state) => ({ ...state, username: '' }));
}}

View file

@ -10,7 +10,7 @@ import PasswordlessSwitch from '@/containers/PasswordlessSwitch';
import TermsOfUse from '@/containers/TermsOfUse';
import useForm from '@/hooks/use-form';
import useTerms from '@/hooks/use-terms';
import { emailValidation } from '@/utils/field-validations';
import { validateEmail } from '@/utils/field-validations';
import * as styles from './index.module.scss';
@ -64,7 +64,7 @@ const EmailForm = ({
[validateForm, hasTerms, termsValidation, onSubmit, fieldValue]
);
const { onChange, ...rest } = register('email', emailValidation);
const { onChange, ...rest } = register('email', validateEmail);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>

View file

@ -1,36 +0,0 @@
@use '@/scss/underscore' as _;
.form {
@include _.flex-column;
> * {
width: 100%;
}
.inputField,
.link,
.terms,
.formErrors {
margin-bottom: _.unit(4);
}
.link {
margin-top: _.unit(-1);
width: auto;
align-self: start;
}
.formErrors {
margin-top: _.unit(-3);
margin-left: _.unit(0.5);
}
}
:global(.desktop) {
.form {
.link {
margin-top: _.unit(-2);
}
}
}

View file

@ -1,190 +0,0 @@
import { fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { signInWithPasswordIdentifier } from '@/apis/interaction';
import ConfirmModalProvider from '@/containers/ConfirmModalProvider';
import EmailPassword from '.';
jest.mock('@/apis/interaction', () => ({
signInWithPasswordIdentifier: jest.fn(async () => ({ redirectTo: '/' })),
}));
jest.mock('react-device-detect', () => ({
isMobile: true,
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
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(
<MemoryRouter>
<SettingsProvider>
<EmailPassword />
</SettingsProvider>
</MemoryRouter>
);
expect(queryByText('description.agree_with_terms')).not.toBeNull();
expect(queryByText('action.forgot_password')).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(
<MemoryRouter>
<SettingsProvider>
<ConfirmModalProvider>
<EmailPassword />
</ConfirmModalProvider>
</SettingsProvider>
</MemoryRouter>
);
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(
<MemoryRouter>
<SettingsProvider>
<ConfirmModalProvider>
<EmailPassword />
</ConfirmModalProvider>
</SettingsProvider>
</MemoryRouter>
);
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(
<MemoryRouter>
<SettingsProvider>
<EmailPassword />
</SettingsProvider>
</MemoryRouter>
);
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(signInWithPasswordIdentifier).toBeCalledWith({
email: 'email',
password: 'password',
});
});
});
});
});

View file

@ -1,103 +0,0 @@
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import ForgotPasswordLink from '@/components/ForgotPasswordLink';
import Input, { PasswordInput } from '@/components/Input';
import TermsOfUse from '@/containers/TermsOfUse';
import useForm from '@/hooks/use-form';
import usePasswordSignIn from '@/hooks/use-password-sign-in';
import { useForgotPasswordSettings } from '@/hooks/use-sie';
import useTerms from '@/hooks/use-terms';
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;
};
type FieldState = {
email: string;
password: string;
};
const defaultState: FieldState = {
email: '',
password: '',
};
const EmailPassword = ({ className, autoFocus }: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
const { isForgotPasswordEnabled, email } = useForgotPasswordSettings();
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
clearErrorMessage();
if (!validateForm()) {
return;
}
if (!(await termsValidation())) {
return;
}
void onSubmit(fieldValue);
},
[clearErrorMessage, validateForm, termsValidation, onSubmit, fieldValue]
);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input
type="email"
name="email"
autoComplete="email"
inputMode="email"
placeholder={t('input.email')}
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>}
{isForgotPasswordEnabled && (
<ForgotPasswordLink
className={styles.link}
method={email ? SignInIdentifier.Email : SignInIdentifier.Phone}
/>
)}
<TermsOfUse className={styles.terms} />
<Button title="action.sign_in" onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>
);
};
export default EmailPassword;

View file

@ -1,35 +0,0 @@
@use '@/scss/underscore' as _;
.form {
@include _.flex-column;
> * {
width: 100%;
}
.inputField,
.link,
.terms,
.formErrors {
margin-bottom: _.unit(4);
}
.link {
margin-top: _.unit(-1);
align-self: start;
width: auto;
}
.formErrors {
margin-top: _.unit(-3);
margin-left: _.unit(0.5);
}
}
:global(.desktop) {
.form {
.link {
margin-top: _.unit(-2);
}
}
}

View file

@ -1,197 +0,0 @@
import { fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { signInWithPasswordIdentifier } from '@/apis/interaction';
import ConfirmModalProvider from '@/containers/ConfirmModalProvider';
import PhonePassword from '.';
jest.mock('@/apis/interaction', () => ({
signInWithPasswordIdentifier: jest.fn(async () => ({ redirectTo: '/' })),
}));
// Terms Iframe Modal only shown on mobile device
jest.mock('react-device-detect', () => ({
isMobile: true,
}));
// PhoneNum CountryCode detection
jest.mock('i18next', () => ({
language: 'en',
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
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(
<MemoryRouter>
<SettingsProvider>
<PhonePassword />
</SettingsProvider>
</MemoryRouter>
);
expect(queryByText('description.agree_with_terms')).not.toBeNull();
expect(queryByText('action.forgot_password')).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(
<MemoryRouter>
<SettingsProvider>
<ConfirmModalProvider>
<PhonePassword />
</ConfirmModalProvider>
</SettingsProvider>
</MemoryRouter>
);
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(
<MemoryRouter>
<SettingsProvider>
<ConfirmModalProvider>
<PhonePassword />
</ConfirmModalProvider>
</SettingsProvider>
</MemoryRouter>
);
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(
<MemoryRouter>
<SettingsProvider>
<PhonePassword />
</SettingsProvider>
</MemoryRouter>
);
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(signInWithPasswordIdentifier).toBeCalledWith({
phone: 'phone',
password: 'password',
});
});
});
});
});

View file

@ -1,123 +0,0 @@
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import ForgotPasswordLink from '@/components/ForgotPasswordLink';
import { PhoneInput, PasswordInput } from '@/components/Input';
import TermsOfUse from '@/containers/TermsOfUse';
import useForm from '@/hooks/use-form';
import usePasswordSignIn from '@/hooks/use-password-sign-in';
import usePhoneNumber from '@/hooks/use-phone-number';
import { useForgotPasswordSettings } from '@/hooks/use-sie';
import useTerms from '@/hooks/use-terms';
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;
};
type FieldState = {
phone: string;
password: string;
};
const defaultState: FieldState = {
phone: '',
password: '',
};
const PhonePassword = ({ className, autoFocus }: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
const { isForgotPasswordEnabled, phone } = useForgotPasswordSettings();
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 onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
clearErrorMessage();
if (!validateForm()) {
return;
}
if (!(await termsValidation())) {
return;
}
void onSubmit(fieldValue);
},
[clearErrorMessage, validateForm, termsValidation, onSubmit, fieldValue]
);
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}
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>}
{isForgotPasswordEnabled && (
<ForgotPasswordLink
className={styles.link}
method={phone ? SignInIdentifier.Phone : SignInIdentifier.Email}
/>
)}
<TermsOfUse className={styles.terms} />
<Button title="action.sign_in" onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>
);
};
export default PhonePassword;

View file

@ -118,7 +118,7 @@ const SetPassword = ({
<Button
name="submit"
title="action.save_password"
onClick={async () => {
onClick={() => {
onSubmitHandler();
}}
/>

View file

@ -97,7 +97,7 @@ describe('<UsernameRegister />', () => {
fireEvent.click(submitButton);
expect(queryByText('username_valid_charset')).not.toBeNull();
expect(queryByText('username_invalid_charset')).not.toBeNull();
expect(onSubmit).not.toBeCalled();
@ -106,7 +106,7 @@ describe('<UsernameRegister />', () => {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
expect(queryByText('username_valid_charset')).toBeNull();
expect(queryByText('username_invalid_charset')).toBeNull();
});
test('submit form properly with terms settings enabled', async () => {

View file

@ -9,7 +9,7 @@ import Input from '@/components/Input';
import TermsOfUse from '@/containers/TermsOfUse';
import useForm from '@/hooks/use-form';
import useTerms from '@/hooks/use-terms';
import { usernameValidation } from '@/utils/field-validations';
import { validateUsername } from '@/utils/field-validations';
import * as styles from './index.module.scss';
@ -73,7 +73,7 @@ const UsernameForm = ({
name="new-username"
className={styles.inputField}
placeholder={t('input.username')}
{...fieldRegister('username', usernameValidation)}
{...fieldRegister('username', validateUsername)}
onClear={() => {
setFieldValue((state) => ({ ...state, username: '' }));
}}

View file

@ -1,203 +0,0 @@
import { fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import { signInWithPasswordIdentifier } from '@/apis/interaction';
import ConfirmModalProvider from '@/containers/ConfirmModalProvider';
import UsernameSignIn from '.';
jest.mock('@/apis/interaction', () => ({ signInWithPasswordIdentifier: jest.fn(async () => 0) }));
jest.mock('react-device-detect', () => ({
isMobile: true,
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
describe('<UsernameSignIn>', () => {
afterEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
test('render', () => {
const { queryByText, container } = renderWithPageContext(<UsernameSignIn />);
expect(container.querySelector('input[name="username"]')).not.toBeNull();
expect(container.querySelector('input[name="password"]')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
});
test('render with terms settings enabled and forgot password enabled', () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider>
<MemoryRouter>
<UsernameSignIn />
</MemoryRouter>
</SettingsProvider>
);
expect(queryByText('description.agree_with_terms')).not.toBeNull();
expect(queryByText('action.forgot_password')).not.toBeNull();
});
test('render with forgot password disabled', () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
forgotPassword: { phone: false, email: false },
}}
>
<UsernameSignIn />
</SettingsProvider>
);
expect(queryByText('action.forgot_password')).toBeNull();
});
test('required inputs with error message', () => {
const { queryByText, getByText, container } = renderWithPageContext(<UsernameSignIn />);
const submitButton = getByText('action.sign_in');
fireEvent.click(submitButton);
expect(queryByText('username_required')).not.toBeNull();
expect(queryByText('password_required')).not.toBeNull();
const usernameInput = container.querySelector('input[name="username"]');
const passwordInput = container.querySelector('input[name="password"]');
expect(usernameInput).not.toBeNull();
expect(passwordInput).not.toBeNull();
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: 'password' } });
}
expect(queryByText('required')).toBeNull();
});
test('should show terms confirm modal', async () => {
const { queryByText, getByText, container } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<ConfirmModalProvider>
<UsernameSignIn />
</ConfirmModalProvider>
</SettingsProvider>
</MemoryRouter>
);
const submitButton = getByText('action.sign_in');
const usernameInput = container.querySelector('input[name="username"]');
const passwordInput = container.querySelector('input[name="password"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
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(
<MemoryRouter>
<SettingsProvider>
<ConfirmModalProvider>
<UsernameSignIn />
</ConfirmModalProvider>
</SettingsProvider>
</MemoryRouter>
);
const submitButton = getByText('action.sign_in');
const usernameInput = container.querySelector('input[name="username"]');
const passwordInput = container.querySelector('input[name="password"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
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(
<MemoryRouter>
<SettingsProvider>
<UsernameSignIn />
</SettingsProvider>
</MemoryRouter>
);
const submitButton = getByText('action.sign_in');
const usernameInput = container.querySelector('input[name="username"]');
const passwordInput = container.querySelector('input[name="password"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
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(signInWithPasswordIdentifier).toBeCalledWith({
username: 'username',
password: 'password',
});
});
});
});
});

View file

@ -1,100 +0,0 @@
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import ForgotPasswordLink from '@/components/ForgotPasswordLink';
import Input, { PasswordInput } from '@/components/Input';
import TermsOfUse from '@/containers/TermsOfUse';
import useForm from '@/hooks/use-form';
import usePasswordSignIn from '@/hooks/use-password-sign-in';
import { useForgotPasswordSettings } from '@/hooks/use-sie';
import useTerms from '@/hooks/use-terms';
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;
};
type FieldState = {
username: string;
password: string;
};
const defaultState: FieldState = {
username: '',
password: '',
};
const UsernameSignIn = ({ className, autoFocus }: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const { isForgotPasswordEnabled, email } = useForgotPasswordSettings();
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
clearErrorMessage();
if (!validateForm()) {
return;
}
if (!(await termsValidation())) {
return;
}
void onSubmit(fieldValue);
},
[clearErrorMessage, validateForm, termsValidation, onSubmit, fieldValue]
);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input
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>}
{isForgotPasswordEnabled && (
<ForgotPasswordLink
className={styles.link}
method={email ? SignInIdentifier.Email : SignInIdentifier.Phone}
/>
)}
<TermsOfUse className={styles.terms} />
<Button title="action.sign_in" onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>
);
};
export default UsernameSignIn;

View file

@ -11,24 +11,6 @@ jest.mock('i18next', () => ({
}));
describe('<SecondarySignIn />', () => {
test('renders without exploding', async () => {
const { queryAllByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/username']}>
<Routes>
<Route
path="/sign-in/:method"
element={
<SettingsProvider>
<SecondarySignIn />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryAllByText('action.sign_in')).toHaveLength(2);
});
test('renders phone', async () => {
const { queryAllByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/phone']}>

View file

@ -1,10 +1,11 @@
/** To Be Deprecated */
import { SignInMode, SignInIdentifier } from '@logto/schemas';
import { useParams } from 'react-router-dom';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import { EmailSignIn } from '@/containers/EmailForm';
import { PhoneSignIn } from '@/containers/PhoneForm';
import UsernameSignIn from '@/containers/UsernameSignIn';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
@ -31,9 +32,7 @@ const SecondarySignIn = () => {
<PhoneSignIn autoFocus signInMethod={signInMethod} />
) : signInMethod.identifier === SignInIdentifier.Email ? (
<EmailSignIn autoFocus signInMethod={signInMethod} />
) : (
<UsernameSignIn autoFocus />
)}
) : null}
</SecondaryPageWrapper>
);
};

View file

@ -1,51 +1,39 @@
import { SignInIdentifier } from '@logto/schemas';
import type { SignIn as SignInType, ConnectorMetadata } from '@logto/schemas';
import type { ConnectorMetadata, SignInExperience } from '@logto/schemas';
import { EmailSignIn } from '@/containers/EmailForm';
import EmailPassword from '@/containers/EmailPassword';
import { PhoneSignIn } from '@/containers/PhoneForm';
import PhonePassword from '@/containers/PhonePassword';
import SocialSignIn from '@/containers/SocialSignIn';
import UsernameSignIn from '@/containers/UsernameSignIn';
import type { ArrayElement } from '@/types';
import PasswordSignInForm from './PasswordSignInForm';
import * as styles from './index.module.scss';
type Props = {
signInMethod?: ArrayElement<SignInType['methods']>;
signInMethods: SignInExperience['signIn']['methods'];
socialConnectors: ConnectorMetadata[];
};
const Main = ({ signInMethod, socialConnectors }: Props) => {
switch (signInMethod?.identifier) {
case SignInIdentifier.Email: {
if (signInMethod.password && !signInMethod.verificationCode) {
return <EmailPassword className={styles.main} />;
}
return <EmailSignIn signInMethod={signInMethod} className={styles.main} />;
}
case SignInIdentifier.Phone: {
if (signInMethod.password && !signInMethod.verificationCode) {
return <PhonePassword className={styles.main} />;
}
return <PhoneSignIn signInMethod={signInMethod} className={styles.main} />;
}
case SignInIdentifier.Username: {
return <UsernameSignIn className={styles.main} />;
}
default: {
if (socialConnectors.length > 0) {
return <SocialSignIn />;
}
return null;
}
const Main = ({ signInMethods, socialConnectors }: Props) => {
if (signInMethods.length === 0 && socialConnectors.length > 0) {
return <SocialSignIn className={styles.main} />;
}
const isPasswordOnly =
signInMethods.length > 0 &&
signInMethods.every(({ password, verificationCode }) => password && !verificationCode);
if (isPasswordOnly) {
return (
<PasswordSignInForm
className={styles.main}
signInMethods={signInMethods.map(({ identifier }) => identifier)}
/>
);
}
if (signInMethods.length > 0) {
// TODO: password or validation code signIn
return <div>Working In Progress</div>;
}
return null;
};
export default Main;

View file

@ -0,0 +1,204 @@
import { SignInIdentifier } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import { signInWithPasswordIdentifier } from '@/apis/interaction';
import ConfirmModalProvider from '@/containers/ConfirmModalProvider';
import type { SignInExperienceResponse } from '@/types';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
import PasswordSignInForm from '.';
jest.mock('@/apis/interaction', () => ({ signInWithPasswordIdentifier: jest.fn(async () => 0) }));
jest.mock('react-device-detect', () => ({
isMobile: true,
}));
jest.mock('i18next', () => ({
...jest.requireActual('i18next'),
language: 'en',
}));
describe('UsernamePasswordSignInForm', () => {
afterEach(() => {
jest.clearAllMocks();
});
const renderPasswordSignInForm = (
signInMethods = [SignInIdentifier.Username, SignInIdentifier.Email, SignInIdentifier.Phone],
settings?: Partial<SignInExperienceResponse>
) =>
renderWithPageContext(
<SettingsProvider settings={{ ...mockSignInExperienceSettings, ...settings }}>
<MemoryRouter>
<ConfirmModalProvider>
<PasswordSignInForm signInMethods={signInMethods} />
</ConfirmModalProvider>
</MemoryRouter>
</SettingsProvider>
);
test.each([
[SignInIdentifier.Username],
[SignInIdentifier.Email],
[SignInIdentifier.Phone],
[SignInIdentifier.Username, SignInIdentifier.Email],
[SignInIdentifier.Username, SignInIdentifier.Phone],
[SignInIdentifier.Email, SignInIdentifier.Phone],
[SignInIdentifier.Username, SignInIdentifier.Email, SignInIdentifier.Phone],
])('render %p', (...methods) => {
const { queryByText, container } = renderPasswordSignInForm(methods);
expect(container.querySelector('input[name="identifier"]')).not.toBeNull();
expect(container.querySelector('input[name="password"]')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
expect(queryByText('description.agree_with_terms')).not.toBeNull();
expect(queryByText('action.forgot_password')).not.toBeNull();
});
test('render with forgot password disabled', () => {
const { queryByText } = renderPasswordSignInForm([SignInIdentifier.Username], {
forgotPassword: { phone: false, email: false },
});
expect(queryByText('action.forgot_password')).toBeNull();
});
test('required inputs with error message', async () => {
const { queryByText, getByText, container } = renderPasswordSignInForm();
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(queryByText('general_required')).not.toBeNull();
expect(queryByText('password_required')).not.toBeNull();
});
const identifierInput = container.querySelector('input[name="identifier"]');
const passwordInput = container.querySelector('input[name="password"]');
assert(identifierInput, new Error('identifier input should exist'));
assert(passwordInput, new Error('password input should exist'));
act(() => {
fireEvent.change(identifierInput, { target: { value: 'username' } });
});
act(() => {
fireEvent.change(passwordInput, { target: { value: 'password' } });
});
await waitFor(() => {
expect(queryByText('general_required')).toBeNull();
expect(queryByText('password_required')).toBeNull();
});
});
test.each([
['0username', 'username'],
['foo@logto', 'foo@logto.io'],
['8573', '8573333333'],
])('Invalid input $p should throw error message', async (invalidInput, validInput) => {
const { queryByText, getByText, container } = renderPasswordSignInForm();
const submitButton = getByText('action.sign_in');
const identifierInput = container.querySelector('input[name="identifier"]');
assert(identifierInput, new Error('identifier input should exist'));
act(() => {
fireEvent.change(identifierInput, { target: { value: invalidInput } });
});
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(queryByText('general_invalid')).not.toBeNull();
});
act(() => {
fireEvent.change(identifierInput, { target: { value: validInput } });
});
await waitFor(() => {
expect(queryByText('general_invalid')).toBeNull();
});
});
test('should show terms confirm modal', async () => {
const { queryByText, getByText, container } = renderPasswordSignInForm();
const submitButton = getByText('action.sign_in');
const identifierInput = container.querySelector('input[name="identifier"]');
const passwordInput = container.querySelector('input[name="password"]');
assert(identifierInput, new Error('identifier input should exist'));
assert(passwordInput, new Error('password input should exist'));
act(() => {
fireEvent.change(identifierInput, { target: { value: 'username' } });
});
act(() => {
fireEvent.change(passwordInput, { target: { value: 'password' } });
});
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(queryByText('description.agree_with_terms_modal')).not.toBeNull();
});
});
test.each([
['username', SignInIdentifier.Username],
['foo@logto.io', SignInIdentifier.Email],
['8573333333', SignInIdentifier.Phone],
])('submit form', async (identifier: string, type: SignInIdentifier) => {
const { getByText, container } = renderPasswordSignInForm();
const submitButton = getByText('action.sign_in');
const identifierInput = container.querySelector('input[name="identifier"]');
const passwordInput = container.querySelector('input[name="password"]');
assert(identifierInput, new Error('identifier input should exist'));
assert(passwordInput, new Error('password input should exist'));
fireEvent.change(identifierInput, { target: { value: identifier } });
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(signInWithPasswordIdentifier).toBeCalledWith({
[type]:
type === SignInIdentifier.Phone
? `${getDefaultCountryCallingCode()}${identifier}`
: identifier,
password: 'password',
});
});
});
});
});

View file

@ -0,0 +1,146 @@
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useState, useCallback } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import ForgotPasswordLink from '@/components/ForgotPasswordLink';
import type { IdentifierInputType } from '@/components/InputFields';
import { SmartInputField, PasswordInputField } from '@/components/InputFields';
import TermsOfUse from '@/containers/TermsOfUse';
import usePasswordSignIn from '@/hooks/use-password-sign-in';
import { useForgotPasswordSettings } from '@/hooks/use-sie';
import useTerms from '@/hooks/use-terms';
import {
identifierErrorWatcher,
passwordErrorWatcher,
validateIdentifierField,
} from '@/utils/form';
import * as styles from './index.module.scss';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
signInMethods: SignInIdentifier[];
};
type FormState = {
identifier: string;
password: string;
};
const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
const { isForgotPasswordEnabled, ...passwordlessMethod } = useForgotPasswordSettings();
const [inputType, setInputType] = useState<IdentifierInputType>(
signInMethods[0] ?? SignInIdentifier.Username
);
const {
register,
setValue,
handleSubmit,
formState: { errors, isSubmitted },
} = useForm<FormState>({
reValidateMode: 'onChange',
defaultValues: { identifier: '', password: '' },
});
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage();
void handleSubmit(async ({ identifier, password }, event) => {
event?.preventDefault();
if (!(await termsValidation())) {
return;
}
await onSubmit({
[inputType]: identifier,
password,
});
})(event);
},
[clearErrorMessage, handleSubmit, inputType, onSubmit, termsValidation]
);
const identifierError = identifierErrorWatcher(signInMethods, errors.identifier);
const passwordError = passwordErrorWatcher(errors.password);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<SmartInputField
required
autoComplete="identifier"
autoFocus={autoFocus}
className={styles.inputField}
currentType={inputType}
isDanger={!!identifierError}
error={identifierError}
enabledTypes={signInMethods}
onTypeChange={setInputType}
{...register('identifier', {
required: true,
validate: (value) => {
const errorMessage = validateIdentifierField(inputType, value);
if (errorMessage) {
return typeof errorMessage === 'string'
? t(`error.${errorMessage}`)
: t(`error.${errorMessage.code}`, errorMessage.data);
}
return true;
},
})}
/* Overwrite default input onChange handler */
onChange={(value) => {
setValue('identifier', value, { shouldValidate: isSubmitted, shouldDirty: true });
}}
/>
<PasswordInputField
required
className={styles.inputField}
autoComplete="current-password"
placeholder={t('input.password')}
isDanger={!!passwordError}
error={passwordError}
{...register('password', { required: true })}
/>
{isForgotPasswordEnabled && (
<ForgotPasswordLink
className={styles.link}
method={passwordlessMethod.email ? SignInIdentifier.Email : SignInIdentifier.Phone}
/>
)}
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
<TermsOfUse className={styles.terms} />
<Button
name="submit"
title="action.sign_in"
onClick={() => {
void onSubmitHandler();
}}
/>
<input hidden type="submit" />
</form>
);
};
export default PasswordSignInForm;

View file

@ -1,122 +1,56 @@
import { SignInMode } from '@logto/schemas';
import { SignInIdentifier, SignInMode } from '@logto/schemas';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import {
mockSignInExperienceSettings,
emailSignInMethod,
phoneSignInMethod,
} from '@/__mocks__/logto';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import SignIn from '@/pages/SignIn';
jest.mock('i18next', () => ({
...jest.requireActual('i18next'),
language: 'en',
}));
describe('<SignIn />', () => {
test('renders with username as primary', async () => {
const { queryByText, queryAllByText, container } = renderWithPageContext(
<SettingsProvider>
const renderSignIn = (settings?: Partial<typeof mockSignInExperienceSettings>) =>
renderWithPageContext(
<SettingsProvider settings={{ ...mockSignInExperienceSettings, ...settings }}>
<MemoryRouter>
<SignIn />
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelector('input[name="username"]')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
describe('renders with password only SignIn method settings', () => {
test.each([
[SignInIdentifier.Username],
[SignInIdentifier.Email],
[SignInIdentifier.Phone],
[SignInIdentifier.Username, SignInIdentifier.Email],
[SignInIdentifier.Username, SignInIdentifier.Phone],
[SignInIdentifier.Email, SignInIdentifier.Phone],
[SignInIdentifier.Username, SignInIdentifier.Email, SignInIdentifier.Phone],
])('renders with %p password only mode', async (...methods) => {
const { queryByText, queryAllByText, container } = renderSignIn({
signIn: {
methods: methods.map((method) => ({
identifier: method,
password: true,
verificationCode: false,
isPasswordPrimary: true,
})),
},
});
// Other sign-in methods
expect(queryByText('secondary.sign_in_with')).not.toBeNull();
expect(container.querySelector('input[name="identifier"]')).not.toBeNull();
// Social
expect(queryAllByText('action.sign_in_with')).toHaveLength(
mockSignInExperienceSettings.socialConnectors.length
);
});
expect(queryByText('action.sign_in')).not.toBeNull();
test('renders with email passwordless as primary', async () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signIn: { methods: [emailSignInMethod] },
}}
>
<MemoryRouter>
<SignIn />
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
});
test('render with email password as primary', async () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signIn: {
methods: [
{
...emailSignInMethod,
verificationCode: false,
password: true,
},
],
},
}}
>
<MemoryRouter>
<SignIn />
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(container.querySelector('input[name="password"]')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
});
test('renders with phone passwordless as primary', async () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{ ...mockSignInExperienceSettings, signIn: { methods: [phoneSignInMethod] } }}
>
<MemoryRouter>
<SignIn />
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
});
test('renders with phone password as primary', async () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signIn: {
methods: [
{
...phoneSignInMethod,
verificationCode: false,
password: true,
},
],
},
}}
>
<MemoryRouter>
<SignIn />
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(container.querySelector('input[name="password"]')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
// Social
expect(queryAllByText('action.sign_in_with')).toHaveLength(
mockSignInExperienceSettings.socialConnectors.length
);
});
});
test('renders with social as primary', async () => {

View file

@ -4,10 +4,8 @@ import { useTranslation } from 'react-i18next';
import Divider from '@/components/Divider';
import TextLink from '@/components/TextLink';
import LandingPageContainer from '@/containers/LandingPageContainer';
import OtherMethodsLink from '@/containers/OtherMethodsLink';
import { SocialSignInList } from '@/containers/SocialSignIn';
import { useSieMethods } from '@/hooks/use-sie';
import { UserFlow } from '@/types';
import ErrorPage from '../ErrorPage';
import Main from './Main';
@ -15,7 +13,6 @@ import * as styles from './index.module.scss';
const SignIn = () => {
const { signInMethods, signUpMethods, socialConnectors, signInMode } = useSieMethods();
const otherMethods = signInMethods.slice(1).map(({ identifier }) => identifier);
const { t } = useTranslation();
if (!signInMode || signInMode === SignInMode.Register) {
@ -24,16 +21,14 @@ const SignIn = () => {
return (
<LandingPageContainer>
<Main signInMethod={signInMethods[0]} socialConnectors={socialConnectors} />
<Main signInMethods={signInMethods} socialConnectors={socialConnectors} />
{
// Other sign-in methods
otherMethods.length > 0 && (
<OtherMethodsLink
className={styles.otherMethods}
methods={otherMethods}
template="sign_in_with"
flow={UserFlow.signIn}
/>
// Create Account footer
signInMode === SignInMode.SignInAndRegister && signUpMethods.length > 0 && (
<div className={styles.createAccount}>
{t('description.no_account')}{' '}
<TextLink replace to="/register" text="action.create_account" />
</div>
)
}
{
@ -45,18 +40,6 @@ const SignIn = () => {
</>
)
}
{
// Create Account footer
signInMode === SignInMode.SignInAndRegister && signUpMethods.length > 0 && (
<>
<div className={styles.placeHolder} />
<div className={styles.createAccount}>
{t('description.no_account')}{' '}
<TextLink replace to="/register" text="action.create_account" />
</div>
</>
)
}
</LandingPageContainer>
);
};

View file

@ -1,4 +1,4 @@
import i18nNext from 'i18next';
import i18next from 'i18next';
import {
isValidCountryCode,
@ -9,7 +9,7 @@ import {
} from './country-code';
describe('country-code', () => {
void i18nNext.init();
void i18next.init();
it('isValidCountryCode', () => {
expect(isValidCountryCode('CN')).toBeTruthy();
@ -17,47 +17,48 @@ describe('country-code', () => {
});
it('getDefaultCountryCode', async () => {
await i18nNext.changeLanguage('zh');
await i18next.changeLanguage('zh');
expect(getDefaultCountryCode()).toEqual('CN');
await i18nNext.changeLanguage('en');
await i18next.changeLanguage('en');
expect(getDefaultCountryCode()).toEqual('US');
await i18nNext.changeLanguage('zh-CN');
await i18next.changeLanguage('zh-CN');
expect(getDefaultCountryCode()).toEqual('CN');
await i18nNext.changeLanguage('zh-TW');
await i18next.changeLanguage('zh-TW');
expect(getDefaultCountryCode()).toEqual('TW');
await i18nNext.changeLanguage('en-US');
await i18next.changeLanguage('en-US');
expect(getDefaultCountryCode()).toEqual('US');
await i18nNext.changeLanguage('en-CA');
await i18next.changeLanguage('en-CA');
expect(getDefaultCountryCode()).toEqual('CA');
});
it('getDefaultCountryCallingCode', async () => {
await i18nNext.changeLanguage('zh');
await i18next.changeLanguage('zh');
expect(getDefaultCountryCallingCode()).toEqual('86');
await i18nNext.changeLanguage('en');
await i18next.changeLanguage('en');
expect(getDefaultCountryCallingCode()).toEqual('1');
await i18nNext.changeLanguage('zh-CN');
await i18next.changeLanguage('zh-CN');
expect(getDefaultCountryCallingCode()).toEqual('86');
await i18nNext.changeLanguage('zh-TW');
await i18next.changeLanguage('zh-TW');
expect(getDefaultCountryCallingCode()).toEqual('886');
await i18nNext.changeLanguage('en-US');
await i18next.changeLanguage('en-US');
expect(getDefaultCountryCallingCode()).toEqual('1');
await i18nNext.changeLanguage('en-CA');
await i18next.changeLanguage('en-CA');
expect(getDefaultCountryCallingCode()).toEqual('1');
});
it('getCountryList should sort properly', async () => {
await i18nNext.changeLanguage('zh');
await i18next.changeLanguage('zh');
const countryList = getCountryList();
expect(countryList[0]).toEqual({
@ -69,7 +70,7 @@ describe('country-code', () => {
});
it('getCountryList should remove duplicate', async () => {
await i18nNext.changeLanguage('zh');
await i18next.changeLanguage('zh');
const countryList = getCountryList();
expect(countryList.filter(({ countryCallingCode }) => countryCallingCode === '1')).toHaveLength(

View file

@ -1,7 +1,12 @@
import type { ErrorType } from '@/components/ErrorMessage';
import { parsePhoneNumberWithError, ParseError } from 'libphonenumber-js/mobile';
const usernameRegex = /^[A-Z_a-z-][\w-]*$/;
const emailRegex = /^\S+@\S+\.\S+$/;
import type { ErrorType } from '@/components/ErrorMessage';
import { parseE164Number } from '@/utils/country-code';
// TODO: clean up this file and merge to form utils
export const usernameRegex = /^[A-Z_a-z-][\w-]*$/;
export const emailRegex = /^\S+@\S+\.\S+$/;
export const requiredValidation = (
type: 'username' | 'password',
@ -12,7 +17,7 @@ export const requiredValidation = (
}
};
export const usernameValidation = (username: string): ErrorType | undefined => {
export const validateUsername = (username: string): ErrorType | undefined => {
if (!username) {
return 'username_required';
}
@ -22,7 +27,29 @@ export const usernameValidation = (username: string): ErrorType | undefined => {
}
if (!usernameRegex.test(username)) {
return 'username_valid_charset';
return 'username_invalid_charset';
}
};
export const validateEmail = (email: string): ErrorType | undefined => {
if (!emailRegex.test(email)) {
return 'invalid_email';
}
};
export const validatePhone = (value: string): ErrorType | undefined => {
try {
const phoneNumber = parsePhoneNumberWithError(parseE164Number(value));
if (!phoneNumber.isValid()) {
return 'invalid_phone';
}
} catch (error: unknown) {
if (error instanceof ParseError) {
return 'invalid_phone';
}
throw error;
}
};
@ -44,9 +71,3 @@ export const confirmPasswordValidation = (
return { code: 'passwords_do_not_match' };
}
};
export const emailValidation = (email: string): ErrorType | undefined => {
if (!emailRegex.test(email)) {
return 'invalid_email';
}
};

View file

@ -1,6 +1,21 @@
import { SignInIdentifier } from '@logto/schemas';
import i18next from 'i18next';
import type { FieldError } from 'react-hook-form';
import type { TFuncKey } from 'react-i18next';
import type { ErrorType } from '@/components/ErrorMessage';
import type { IdentifierInputType } from '@/components/InputFields';
import { validateUsername, validateEmail, validatePhone } from './field-validations';
// eslint-disable-next-line id-length
const t = (key: TFuncKey) => i18next.t<'translation', TFuncKey>(key);
export const identifierInputPlaceholderMap: { [K in IdentifierInputType]: TFuncKey } = {
[SignInIdentifier.Phone]: 'input.phone_number',
[SignInIdentifier.Email]: 'input.email',
[SignInIdentifier.Username]: 'input.username',
};
export const passwordErrorWatcher = (error?: FieldError): ErrorType | undefined => {
switch (error?.type) {
@ -11,3 +26,37 @@ export const passwordErrorWatcher = (error?: FieldError): ErrorType | undefined
default:
}
};
export const identifierErrorWatcher = (
enabledFields: IdentifierInputType[],
error?: FieldError
): ErrorType | undefined => {
const data = { types: enabledFields.map((field) => t(identifierInputPlaceholderMap[field])) };
switch (error?.type) {
case 'required':
return {
code: 'general_required',
data,
};
case 'validate':
return {
code: 'general_invalid',
data,
};
default:
}
};
export const validateIdentifierField = (type: IdentifierInputType, value: string) => {
switch (type) {
case 'username':
return validateUsername(value);
case 'email':
return validateEmail(value);
case 'phone':
return validatePhone(value);
default:
}
};