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:
parent
888c856ae2
commit
39f35192e0
39 changed files with 575 additions and 1242 deletions
|
@ -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);
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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}} 자리로 이루어져야 해요.',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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}}个字符',
|
||||
|
|
|
@ -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}`, {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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: '' }));
|
||||
}}
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -118,7 +118,7 @@ const SetPassword = ({
|
|||
<Button
|
||||
name="submit"
|
||||
title="action.save_password"
|
||||
onClick={async () => {
|
||||
onClick={() => {
|
||||
onSubmitHandler();
|
||||
}}
|
||||
/>
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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: '' }));
|
||||
}}
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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']}>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
|
|
204
packages/ui/src/pages/SignIn/PasswordSignInForm/index.test.tsx
Normal file
204
packages/ui/src/pages/SignIn/PasswordSignInForm/index.test.tsx
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
146
packages/ui/src/pages/SignIn/PasswordSignInForm/index.tsx
Normal file
146
packages/ui/src/pages/SignIn/PasswordSignInForm/index.tsx
Normal 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;
|
|
@ -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 () => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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:
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue