mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
feat(ui): add email and sms password sign-in page (#2312)
This commit is contained in:
parent
9f23e13e2d
commit
5b2bbd801b
36 changed files with 896 additions and 726 deletions
|
@ -29,6 +29,7 @@ const translation = {
|
|||
sign_in_with: 'Continue with {{name}}',
|
||||
forgot_password: 'Forgot your password?',
|
||||
switch_to: 'Switch to {{method}}',
|
||||
sign_in_via_passcode: 'Sign in via verification code',
|
||||
},
|
||||
description: {
|
||||
email: 'email',
|
||||
|
@ -63,6 +64,8 @@ const translation = {
|
|||
password_changed: 'Password Changed',
|
||||
no_account: "Don't have an account?",
|
||||
have_account: 'Already have an account?',
|
||||
enter_password: 'Enter Password',
|
||||
enter_password_for: 'Enter the password of {{method}} {{value}}',
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: 'Username and password do not match',
|
||||
|
|
|
@ -31,6 +31,7 @@ const translation = {
|
|||
sign_in_with: 'Continuer avec {{name}}',
|
||||
forgot_password: 'Mot de passe oublié ?',
|
||||
switch_to: 'Passer au {{method}}',
|
||||
sign_in_via_passcode: 'Sign in via verification code', // UNTRANSLATED
|
||||
},
|
||||
description: {
|
||||
email: 'email',
|
||||
|
@ -67,6 +68,8 @@ const translation = {
|
|||
password_changed: 'Password Changed', // UNTRANSLATED
|
||||
no_account: "Don't have an account?", // UNTRANSLATED
|
||||
have_account: 'Already have an account?', // UNTRANSLATED
|
||||
enter_password: 'Enter Password', // UNTRANSLATED
|
||||
enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: "Le nom d'utilisateur et le mot de passe ne correspondent pas",
|
||||
|
|
|
@ -31,6 +31,7 @@ const translation = {
|
|||
sign_in_with: '{{name}} 계속',
|
||||
forgot_password: '비밀번호를 잊어버리셨나요?',
|
||||
switch_to: 'Switch to {{method}}', // UNTRANSLATED
|
||||
sign_in_via_passcode: 'Sign in via verification code', // UNTRANSLATED
|
||||
},
|
||||
description: {
|
||||
email: '이메일',
|
||||
|
@ -63,6 +64,8 @@ const translation = {
|
|||
password_changed: 'Password Changed', // UNTRANSLATED
|
||||
no_account: "Don't have an account?", // UNTRANSLATED
|
||||
have_account: 'Already have an account?', // UNTRANSLATED
|
||||
enter_password: 'Enter Password', // UNTRANSLATED
|
||||
enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: '사용자 이름 또는 비밀번호가 일치하지 않아요.',
|
||||
|
|
|
@ -31,6 +31,7 @@ const translation = {
|
|||
sign_in_with: 'Continuar com {{name}}',
|
||||
forgot_password: 'Esqueceu a password?',
|
||||
switch_to: 'Mudar para {{method}}',
|
||||
sign_in_via_passcode: 'Sign in via verification code', // UNTRANSLATED
|
||||
},
|
||||
description: {
|
||||
email: 'email',
|
||||
|
@ -63,6 +64,8 @@ const translation = {
|
|||
password_changed: 'Password Changed', // UNTRANSLATED
|
||||
no_account: "Don't have an account?", // UNTRANSLATED
|
||||
have_account: 'Already have an account?', // UNTRANSLATED
|
||||
enter_password: 'Enter Password', // UNTRANSLATED
|
||||
enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: 'O Utilizador e a password não correspondem',
|
||||
|
|
|
@ -31,6 +31,7 @@ const translation = {
|
|||
sign_in_with: '{{name}} ile ilerle',
|
||||
forgot_password: 'Şifremi Unuttum?',
|
||||
switch_to: 'Switch to {{method}}', // UNTRANSLATED
|
||||
sign_in_via_passcode: 'Sign in via verification code', // UNTRANSLATED
|
||||
},
|
||||
description: {
|
||||
email: 'e-posta adresi',
|
||||
|
@ -64,6 +65,8 @@ const translation = {
|
|||
password_changed: 'Password Changed', // UNTRANSLATED
|
||||
no_account: "Don't have an account?", // UNTRANSLATED
|
||||
have_account: 'Already have an account?', // UNTRANSLATED
|
||||
enter_password: 'Enter Password', // UNTRANSLATED
|
||||
enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: 'Kullanıcı adı ve şifre eşleşmiyor.',
|
||||
|
|
|
@ -31,6 +31,7 @@ const translation = {
|
|||
sign_in_with: '通过 {{name}} 登录',
|
||||
forgot_password: '重置密码',
|
||||
switch_to: '切换到{{method}}',
|
||||
sign_in_via_passcode: 'Sign in via verification code', // UNTRANSLATED
|
||||
},
|
||||
description: {
|
||||
email: '邮箱',
|
||||
|
@ -61,6 +62,8 @@ const translation = {
|
|||
password_changed: '已重置密码!',
|
||||
no_account: "Don't have an account?", // UNTRANSLATED
|
||||
have_account: 'Already have an account?', // UNTRANSLATED
|
||||
enter_password: 'Enter Password', // UNTRANSLATED
|
||||
enter_password_for: 'Enter the password of {{method}} {{value}}', // UNTRANSLATED
|
||||
},
|
||||
error: {
|
||||
username_password_mismatch: '用户名和密码不匹配',
|
||||
|
|
|
@ -227,4 +227,8 @@ export const mockSignInExperienceSettings: SignInExperienceSettings = {
|
|||
},
|
||||
socialConnectors,
|
||||
signInMode: SignInMode.SignInAndRegister,
|
||||
forgotPassword: {
|
||||
email: true,
|
||||
sms: true,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -0,0 +1,54 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import { sendForgotPasswordEmailPasscode } from '@/apis/forgot-password';
|
||||
import { UserFlow } from '@/types';
|
||||
|
||||
import EmailResetPassword from './EmailResetPassword';
|
||||
|
||||
const mockedNavigate = jest.fn();
|
||||
|
||||
jest.mock('@/apis/forgot-password', () => ({
|
||||
sendForgotPasswordEmailPasscode: jest.fn(() => ({ success: true })),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockedNavigate,
|
||||
}));
|
||||
|
||||
describe('EmailRegister', () => {
|
||||
const email = 'foo@logto.io';
|
||||
|
||||
test('register form submit', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<EmailResetPassword />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const emailInput = container.querySelector('input[name="email"]');
|
||||
|
||||
if (emailInput) {
|
||||
fireEvent.change(emailInput, { target: { value: email } });
|
||||
}
|
||||
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendForgotPasswordEmailPasscode).toBeCalledWith(email);
|
||||
expect(mockedNavigate).toBeCalledWith(
|
||||
{
|
||||
pathname: `/${UserFlow.forgotPassword}/${SignInIdentifier.Email}/passcode-validation`,
|
||||
search: '',
|
||||
},
|
||||
{ state: { email } }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
33
packages/ui/src/containers/EmailForm/EmailResetPassword.tsx
Normal file
33
packages/ui/src/containers/EmailForm/EmailResetPassword.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import usePasswordlessSendCode from '@/hooks/use-passwordless-send-code';
|
||||
import { UserFlow } from '@/types';
|
||||
|
||||
import EmailForm from './EmailForm';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
autoFocus?: boolean;
|
||||
hasSwitch?: boolean;
|
||||
};
|
||||
|
||||
const EmailResetPassword = (props: Props) => {
|
||||
const { onSubmit, errorMessage, clearErrorMessage } = usePasswordlessSendCode(
|
||||
UserFlow.forgotPassword,
|
||||
SignInIdentifier.Email
|
||||
);
|
||||
|
||||
return (
|
||||
<EmailForm
|
||||
onSubmit={onSubmit}
|
||||
{...props}
|
||||
hasTerms={false}
|
||||
submitButtonText="action.continue"
|
||||
errorMessage={errorMessage}
|
||||
clearErrorMessage={clearErrorMessage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailResetPassword;
|
|
@ -1,2 +1,3 @@
|
|||
export { default as EmailRegister } from './EmailRegister';
|
||||
export { default as EmailSignIn } from './EmailSignIn';
|
||||
export { default as EmailResetPassword } from './EmailResetPassword';
|
||||
|
|
|
@ -1,18 +1,15 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useMemo, useCallback, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { signInWithEmailPassword } from '@/apis/sign-in';
|
||||
import Button from '@/components/Button';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
import Input, { PasswordInput } from '@/components/Input';
|
||||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import type { ErrorHandlers } from '@/hooks/use-api';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useForm from '@/hooks/use-form';
|
||||
import usePasswordSignIn from '@/hooks/use-password-sign-in';
|
||||
import useTerms from '@/hooks/use-terms';
|
||||
import { SearchParameters } from '@/types';
|
||||
import { getSearchParameters } from '@/utils';
|
||||
import { emailValidation, requiredValidation } from '@/utils/field-validations';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -36,25 +33,16 @@ const defaultState: FieldState = {
|
|||
const EmailPassword = ({ className, autoFocus }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { termsValidation } = useTerms();
|
||||
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn(SignInIdentifier.Email);
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'session.invalid_credentials': (error) => {
|
||||
setErrorMessage(error.message);
|
||||
},
|
||||
}),
|
||||
[setErrorMessage]
|
||||
);
|
||||
|
||||
const { run: asyncSignInWithEmailPassword } = useApi(signInWithEmailPassword, errorHandlers);
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
|
||||
clearErrorMessage();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
@ -63,14 +51,13 @@ const EmailPassword = ({ className, autoFocus }: Props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
|
||||
|
||||
void asyncSignInWithEmailPassword(fieldValue.email, fieldValue.password, socialToBind);
|
||||
void onSubmit(fieldValue.email, fieldValue.password);
|
||||
},
|
||||
[
|
||||
clearErrorMessage,
|
||||
validateForm,
|
||||
termsValidation,
|
||||
asyncSignInWithEmailPassword,
|
||||
onSubmit,
|
||||
fieldValue.email,
|
||||
fieldValue.password,
|
||||
]
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import type { SignInIdentifier } from '@logto/schemas';
|
||||
import { useContext, useEffect } from 'react';
|
||||
|
||||
import TextLink from '@/components/TextLink';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
import usePasswordlessSendCode from '@/hooks/use-passwordless-send-code';
|
||||
import { UserFlow } from '@/types';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
method: SignInIdentifier.Email | SignInIdentifier.Sms;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const PasswordlessSignInLink = ({ className, method, value }: Props) => {
|
||||
const { setToast } = useContext(PageContext);
|
||||
|
||||
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordlessSendCode(
|
||||
UserFlow.signIn,
|
||||
method,
|
||||
true
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (errorMessage) {
|
||||
setToast(errorMessage);
|
||||
}
|
||||
}, [errorMessage, setToast]);
|
||||
|
||||
return (
|
||||
<TextLink
|
||||
className={className}
|
||||
text="action.sign_in_via_passcode"
|
||||
onClick={() => {
|
||||
clearErrorMessage();
|
||||
void onSubmit(value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordlessSignInLink;
|
|
@ -8,23 +8,16 @@
|
|||
}
|
||||
|
||||
.inputField,
|
||||
.terms,
|
||||
.switch {
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
|
||||
.switch {
|
||||
margin-top: _.unit(-1);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.formFields {
|
||||
margin-bottom: _.unit(8);
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.desktop) {
|
||||
.formFields {
|
||||
margin-bottom: _.unit(2);
|
||||
.formErrors {
|
||||
margin-top: _.unit(-2);
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
}
|
144
packages/ui/src/containers/PasswordSignInForm/index.test.tsx
Normal file
144
packages/ui/src/containers/PasswordSignInForm/index.test.tsx
Normal file
|
@ -0,0 +1,144 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { fireEvent, waitFor, act } from '@testing-library/react';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import {
|
||||
signInWithEmailPassword,
|
||||
signInWithPhonePassword,
|
||||
sendSignInEmailPasscode,
|
||||
sendSignInSmsPasscode,
|
||||
} from '@/apis/sign-in';
|
||||
import { UserFlow } from '@/types';
|
||||
|
||||
import PasswordSignInForm from '.';
|
||||
|
||||
jest.mock('@/apis/sign-in', () => ({
|
||||
signInWithEmailPassword: jest.fn(() => ({ redirectTo: '/' })),
|
||||
signInWithPhonePassword: jest.fn(() => ({ redirectTo: '/' })),
|
||||
sendSignInEmailPasscode: jest.fn(() => ({ success: true })),
|
||||
sendSignInSmsPasscode: jest.fn(() => ({ success: true })),
|
||||
}));
|
||||
|
||||
const mockedNavigate = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockedNavigate,
|
||||
}));
|
||||
|
||||
describe('PasswordSignInForm', () => {
|
||||
const email = 'foo@logto.io';
|
||||
const phone = '18573333333';
|
||||
const password = '111222';
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Password is required', async () => {
|
||||
const { getByText, queryByText } = renderWithPageContext(
|
||||
<PasswordSignInForm method={SignInIdentifier.Email} value={email} />
|
||||
);
|
||||
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signInWithEmailPassword).not.toBeCalled();
|
||||
expect(queryByText('password_required')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('EmailPasswordSignForm', async () => {
|
||||
const { getByText, container } = renderWithPageContext(
|
||||
<PasswordSignInForm hasPasswordlessButton method={SignInIdentifier.Email} value={email} />
|
||||
);
|
||||
|
||||
const passwordInput = container.querySelector('input[name="password"]');
|
||||
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: password } });
|
||||
}
|
||||
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signInWithEmailPassword).toBeCalledWith(email, password, undefined);
|
||||
});
|
||||
|
||||
const sendPasscodeLink = getByText('action.sign_in_via_passcode');
|
||||
|
||||
expect(sendPasscodeLink).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(sendPasscodeLink);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendSignInEmailPasscode).toBeCalledWith(email);
|
||||
});
|
||||
|
||||
expect(mockedNavigate).toBeCalledWith(
|
||||
{
|
||||
pathname: `/${UserFlow.signIn}/${SignInIdentifier.Email}/passcode-validation`,
|
||||
search: '',
|
||||
},
|
||||
{
|
||||
state: { email },
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('SmsPasswordSignForm', async () => {
|
||||
const { getByText, container } = renderWithPageContext(
|
||||
<PasswordSignInForm hasPasswordlessButton method={SignInIdentifier.Sms} value={phone} />
|
||||
);
|
||||
|
||||
const passwordInput = container.querySelector('input[name="password"]');
|
||||
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: password } });
|
||||
}
|
||||
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signInWithPhonePassword).toBeCalledWith(phone, password, undefined);
|
||||
});
|
||||
|
||||
const sendPasscodeLink = getByText('action.sign_in_via_passcode');
|
||||
|
||||
expect(sendPasscodeLink).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(sendPasscodeLink);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendSignInSmsPasscode).toBeCalledWith(phone);
|
||||
});
|
||||
|
||||
expect(mockedNavigate).toBeCalledWith(
|
||||
{
|
||||
pathname: `/${UserFlow.signIn}/${SignInIdentifier.Sms}/passcode-validation`,
|
||||
search: '',
|
||||
},
|
||||
{
|
||||
state: { phone },
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
83
packages/ui/src/containers/PasswordSignInForm/index.tsx
Normal file
83
packages/ui/src/containers/PasswordSignInForm/index.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import type { 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 { PasswordInput } from '@/components/Input';
|
||||
import useForm from '@/hooks/use-form';
|
||||
import usePasswordSignIn from '@/hooks/use-password-sign-in';
|
||||
import { requiredValidation } from '@/utils/field-validations';
|
||||
|
||||
import PasswordlessSignInLink from './PasswordlessSignInLink';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
method: SignInIdentifier.Email | SignInIdentifier.Sms;
|
||||
value: string;
|
||||
hasPasswordlessButton?: boolean;
|
||||
className?: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
|
||||
type FieldState = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
const defaultState: FieldState = {
|
||||
password: '',
|
||||
};
|
||||
|
||||
const PasswordSignInForm = ({
|
||||
className,
|
||||
autoFocus,
|
||||
hasPasswordlessButton = false,
|
||||
method,
|
||||
value,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn(method);
|
||||
|
||||
const { fieldValue, register, validateForm } = useForm(defaultState);
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
|
||||
clearErrorMessage();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
void onSubmit(value, fieldValue.password);
|
||||
},
|
||||
[clearErrorMessage, validateForm, onSubmit, value, fieldValue.password]
|
||||
);
|
||||
|
||||
return (
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<PasswordInput
|
||||
autoFocus={autoFocus}
|
||||
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>}
|
||||
|
||||
{hasPasswordlessButton && (
|
||||
<PasswordlessSignInLink className={styles.switch} method={method} value={value} />
|
||||
)}
|
||||
|
||||
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordSignInForm;
|
|
@ -1,182 +0,0 @@
|
|||
import { fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { sendRegisterEmailPasscode } from '@/apis/register';
|
||||
import { sendSignInEmailPasscode } from '@/apis/sign-in';
|
||||
import { UserFlow } from '@/types';
|
||||
|
||||
import EmailPasswordless from './EmailPasswordless';
|
||||
|
||||
jest.mock('@/apis/sign-in', () => ({
|
||||
sendSignInEmailPasscode: jest.fn(async () => 0),
|
||||
}));
|
||||
jest.mock('@/apis/register', () => ({
|
||||
sendRegisterEmailPasscode: jest.fn(async () => 0),
|
||||
}));
|
||||
|
||||
describe('<EmailPasswordless/>', () => {
|
||||
test('render', () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<EmailPasswordless type={UserFlow.signIn} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(container.querySelector('input[name="email"]')).not.toBeNull();
|
||||
expect(queryByText('action.continue')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('render with terms settings', () => {
|
||||
const { queryByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<EmailPasswordless type={UserFlow.signIn} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(queryByText('description.terms_of_use')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('render with terms settings but hasTerms param set to false', () => {
|
||||
const { queryByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<EmailPasswordless type={UserFlow.signIn} hasTerms={false} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(queryByText('description.terms_of_use')).toBeNull();
|
||||
});
|
||||
|
||||
test('required email with error message', () => {
|
||||
const { queryByText, container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<EmailPasswordless type={UserFlow.signIn} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
expect(queryByText('invalid_email')).not.toBeNull();
|
||||
expect(sendSignInEmailPasscode).not.toBeCalled();
|
||||
|
||||
const emailInput = container.querySelector('input[name="email"]');
|
||||
|
||||
if (emailInput) {
|
||||
fireEvent.change(emailInput, { target: { value: 'foo' } });
|
||||
expect(queryByText('invalid_email')).not.toBeNull();
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
|
||||
expect(queryByText('invalid_email')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('should blocked by terms validation with terms settings enabled', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<EmailPasswordless type={UserFlow.signIn} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const emailInput = container.querySelector('input[name="email"]');
|
||||
|
||||
if (emailInput) {
|
||||
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
|
||||
}
|
||||
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendSignInEmailPasscode).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should call sign-in method properly with terms settings enabled but hasTerms param set to false', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<EmailPasswordless type={UserFlow.signIn} hasTerms={false} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const emailInput = container.querySelector('input[name="email"]');
|
||||
|
||||
if (emailInput) {
|
||||
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
|
||||
}
|
||||
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendSignInEmailPasscode).toBeCalledWith('foo@logto.io');
|
||||
});
|
||||
});
|
||||
|
||||
test('should call sign-in method properly with terms settings enabled and checked', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<EmailPasswordless type={UserFlow.signIn} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const emailInput = container.querySelector('input[name="email"]');
|
||||
|
||||
if (emailInput) {
|
||||
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
|
||||
}
|
||||
|
||||
const termsButton = getByText('description.agree_with_terms');
|
||||
fireEvent.click(termsButton);
|
||||
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendSignInEmailPasscode).toBeCalledWith('foo@logto.io');
|
||||
});
|
||||
});
|
||||
|
||||
test('should call register method properly if type is register', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<EmailPasswordless type={UserFlow.register} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const emailInput = container.querySelector('input[name="email"]');
|
||||
|
||||
if (emailInput) {
|
||||
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
|
||||
}
|
||||
|
||||
const termsButton = getByText('description.agree_with_terms');
|
||||
fireEvent.click(termsButton);
|
||||
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendRegisterEmailPasscode).toBeCalledWith('foo@logto.io');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,118 +0,0 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { getSendPasscodeApi } from '@/apis/utils';
|
||||
import Button from '@/components/Button';
|
||||
import Input from '@/components/Input';
|
||||
import PasswordlessSwitch from '@/containers/PasswordlessSwitch';
|
||||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import type { ErrorHandlers } from '@/hooks/use-api';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useForm from '@/hooks/use-form';
|
||||
import useTerms from '@/hooks/use-terms';
|
||||
import type { UserFlow } from '@/types';
|
||||
import { emailValidation } from '@/utils/field-validations';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
type: UserFlow;
|
||||
className?: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
autoFocus?: boolean;
|
||||
hasTerms?: boolean;
|
||||
hasSwitch?: boolean;
|
||||
};
|
||||
|
||||
type FieldState = {
|
||||
email: string;
|
||||
};
|
||||
|
||||
const defaultState: FieldState = { email: '' };
|
||||
|
||||
const EmailPasswordless = ({
|
||||
type,
|
||||
autoFocus,
|
||||
hasTerms = true,
|
||||
hasSwitch = false,
|
||||
className,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { termsValidation } = useTerms();
|
||||
const { fieldValue, setFieldValue, setFieldErrors, register, validateForm } =
|
||||
useForm(defaultState);
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'guard.invalid_input': () => {
|
||||
setFieldErrors({ email: 'invalid_email' });
|
||||
},
|
||||
}),
|
||||
[setFieldErrors]
|
||||
);
|
||||
|
||||
const sendPasscode = getSendPasscodeApi(type, SignInIdentifier.Email);
|
||||
const { result, run: asyncSendPasscode } = useApi(sendPasscode, errorHandlers);
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTerms && !(await termsValidation())) {
|
||||
return;
|
||||
}
|
||||
|
||||
void asyncSendPasscode(fieldValue.email);
|
||||
},
|
||||
[validateForm, hasTerms, termsValidation, asyncSendPasscode, fieldValue.email]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (result) {
|
||||
navigate(
|
||||
{
|
||||
pathname: `/${type}/email/passcode-validation`,
|
||||
search: location.search,
|
||||
},
|
||||
{ state: { email: fieldValue.email } }
|
||||
);
|
||||
}
|
||||
}, [fieldValue.email, navigate, result, type]);
|
||||
|
||||
return (
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<div className={styles.formFields}>
|
||||
<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: '' }));
|
||||
}}
|
||||
/>
|
||||
{hasSwitch && <PasswordlessSwitch target="sms" className={styles.switch} />}
|
||||
</div>
|
||||
|
||||
{hasTerms && <TermsOfUse className={styles.terms} />}
|
||||
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailPasswordless;
|
|
@ -1,187 +0,0 @@
|
|||
import { fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { sendRegisterSmsPasscode } from '@/apis/register';
|
||||
import { sendSignInSmsPasscode } from '@/apis/sign-in';
|
||||
import { UserFlow } from '@/types';
|
||||
import { getDefaultCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
import PhonePasswordless from './PhonePasswordless';
|
||||
|
||||
jest.mock('@/apis/sign-in', () => ({
|
||||
sendSignInSmsPasscode: jest.fn(async () => 0),
|
||||
}));
|
||||
jest.mock('@/apis/register', () => ({
|
||||
sendRegisterSmsPasscode: jest.fn(async () => 0),
|
||||
}));
|
||||
jest.mock('i18next', () => ({
|
||||
language: 'en',
|
||||
}));
|
||||
|
||||
describe('<PhonePasswordless/>', () => {
|
||||
const phoneNumber = '8573333333';
|
||||
const defaultCountryCallingCode = getDefaultCountryCallingCode();
|
||||
|
||||
test('render', () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<PhonePasswordless type={UserFlow.signIn} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
|
||||
expect(queryByText('action.continue')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('render with terms settings', () => {
|
||||
const { queryByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<PhonePasswordless type={UserFlow.signIn} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(queryByText('description.terms_of_use')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('render with terms settings but hasTerms param set to false', () => {
|
||||
const { queryByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<PhonePasswordless type={UserFlow.signIn} hasTerms={false} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(queryByText('description.terms_of_use')).toBeNull();
|
||||
});
|
||||
|
||||
test('required phone with error message', () => {
|
||||
const { queryByText, container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<PhonePasswordless type={UserFlow.signIn} />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
expect(queryByText('invalid_phone')).not.toBeNull();
|
||||
expect(sendSignInSmsPasscode).not.toBeCalled();
|
||||
|
||||
const phoneInput = container.querySelector('input[name="phone"]');
|
||||
|
||||
if (phoneInput) {
|
||||
fireEvent.change(phoneInput, { target: { value: '1113' } });
|
||||
expect(queryByText('invalid_phone')).not.toBeNull();
|
||||
|
||||
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
|
||||
expect(queryByText('invalid_phone')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('should blocked by terms validation with terms settings enabled', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<PhonePasswordless type={UserFlow.signIn} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const phoneInput = container.querySelector('input[name="phone"]');
|
||||
|
||||
if (phoneInput) {
|
||||
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
|
||||
}
|
||||
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendSignInSmsPasscode).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should call sign-in method properly with terms settings enabled but hasTerms param set to false', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<PhonePasswordless type={UserFlow.signIn} hasTerms={false} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const phoneInput = container.querySelector('input[name="phone"]');
|
||||
|
||||
if (phoneInput) {
|
||||
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
|
||||
}
|
||||
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendSignInSmsPasscode).toBeCalledWith(`${defaultCountryCallingCode}${phoneNumber}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('should call sign-in method properly with terms settings enabled and checked', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<PhonePasswordless type={UserFlow.signIn} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const phoneInput = container.querySelector('input[name="phone"]');
|
||||
|
||||
if (phoneInput) {
|
||||
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
|
||||
}
|
||||
|
||||
const termsButton = getByText('description.agree_with_terms');
|
||||
fireEvent.click(termsButton);
|
||||
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendSignInSmsPasscode).toBeCalledWith(`${defaultCountryCallingCode}${phoneNumber}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('should call register method properly if type is register', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<PhonePasswordless type={UserFlow.register} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const phoneInput = container.querySelector('input[name="phone"]');
|
||||
|
||||
if (phoneInput) {
|
||||
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
|
||||
}
|
||||
|
||||
const termsButton = getByText('description.agree_with_terms');
|
||||
fireEvent.click(termsButton);
|
||||
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendRegisterSmsPasscode).toBeCalledWith(`${defaultCountryCallingCode}${phoneNumber}`);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,134 +0,0 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { getSendPasscodeApi } from '@/apis/utils';
|
||||
import Button from '@/components/Button';
|
||||
import { PhoneInput } from '@/components/Input';
|
||||
import PasswordlessSwitch from '@/containers/PasswordlessSwitch';
|
||||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import type { ErrorHandlers } from '@/hooks/use-api';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useForm from '@/hooks/use-form';
|
||||
import usePhoneNumber from '@/hooks/use-phone-number';
|
||||
import useTerms from '@/hooks/use-terms';
|
||||
import type { UserFlow } from '@/types';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
type: UserFlow;
|
||||
className?: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
autoFocus?: boolean;
|
||||
hasTerms?: boolean;
|
||||
hasSwitch?: boolean;
|
||||
};
|
||||
|
||||
type FieldState = {
|
||||
phone: string;
|
||||
};
|
||||
|
||||
const defaultState: FieldState = { phone: '' };
|
||||
|
||||
const PhonePasswordless = ({
|
||||
type,
|
||||
autoFocus,
|
||||
hasTerms = true,
|
||||
hasSwitch = false,
|
||||
className,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { termsValidation } = useTerms();
|
||||
const { countryList, phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
|
||||
const navigate = useNavigate();
|
||||
const { fieldValue, setFieldValue, setFieldErrors, validateForm, register } =
|
||||
useForm(defaultState);
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'guard.invalid_input': () => {
|
||||
setFieldErrors({ phone: 'invalid_phone' });
|
||||
},
|
||||
}),
|
||||
[setFieldErrors]
|
||||
);
|
||||
|
||||
const sendPasscode = getSendPasscodeApi(type, SignInIdentifier.Sms);
|
||||
const { result, run: asyncSendPasscode } = useApi(sendPasscode, errorHandlers);
|
||||
|
||||
const phoneNumberValidation = useCallback(
|
||||
(phoneNumber: string) => {
|
||||
if (!isValidPhoneNumber(phoneNumber)) {
|
||||
return 'invalid_phone';
|
||||
}
|
||||
},
|
||||
[isValidPhoneNumber]
|
||||
);
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasTerms && !(await termsValidation())) {
|
||||
return;
|
||||
}
|
||||
|
||||
void asyncSendPasscode(fieldValue.phone);
|
||||
},
|
||||
[validateForm, hasTerms, termsValidation, asyncSendPasscode, fieldValue.phone]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Sync phoneNumber
|
||||
setFieldValue((previous) => ({
|
||||
...previous,
|
||||
phone: `${phoneNumber.countryCallingCode}${phoneNumber.nationalNumber}`,
|
||||
}));
|
||||
}, [phoneNumber, setFieldValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (result) {
|
||||
navigate(
|
||||
{ pathname: `/${type}/sms/passcode-validation`, search: location.search },
|
||||
{ state: { sms: fieldValue.phone } }
|
||||
);
|
||||
}
|
||||
}, [fieldValue.phone, navigate, result, type]);
|
||||
|
||||
return (
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<div className={styles.formFields}>
|
||||
<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 }));
|
||||
}}
|
||||
/>
|
||||
{hasSwitch && <PasswordlessSwitch target="email" className={styles.switch} />}
|
||||
</div>
|
||||
|
||||
{hasTerms && <TermsOfUse className={styles.terms} />}
|
||||
|
||||
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
|
||||
|
||||
<input hidden type="submit" />
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhonePasswordless;
|
|
@ -1,2 +0,0 @@
|
|||
export { default as EmailPasswordless } from './EmailPasswordless';
|
||||
export { default as PhonePasswordless } from './PhonePasswordless';
|
|
@ -0,0 +1,62 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import { sendForgotPasswordSmsPasscode } from '@/apis/forgot-password';
|
||||
import { UserFlow } from '@/types';
|
||||
import { getDefaultCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
import SmsResetPassword from './SmsResetPassword';
|
||||
|
||||
const mockedNavigate = jest.fn();
|
||||
|
||||
// PhoneNum CountryCode detection
|
||||
jest.mock('i18next', () => ({
|
||||
language: 'en',
|
||||
}));
|
||||
|
||||
jest.mock('@/apis/forgot-password', () => ({
|
||||
sendForgotPasswordSmsPasscode: jest.fn(() => ({ success: true })),
|
||||
}));
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockedNavigate,
|
||||
}));
|
||||
|
||||
describe('SmsRegister', () => {
|
||||
const phone = '8573333333';
|
||||
const defaultCountryCallingCode = getDefaultCountryCallingCode();
|
||||
const fullPhoneNumber = `${defaultCountryCallingCode}${phone}`;
|
||||
|
||||
test('register form submit', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SmsResetPassword />
|
||||
</MemoryRouter>
|
||||
);
|
||||
const phoneInput = container.querySelector('input[name="phone"]');
|
||||
|
||||
if (phoneInput) {
|
||||
fireEvent.change(phoneInput, { target: { value: phone } });
|
||||
}
|
||||
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(sendForgotPasswordSmsPasscode).toBeCalledWith(fullPhoneNumber);
|
||||
expect(mockedNavigate).toBeCalledWith(
|
||||
{
|
||||
pathname: `/${UserFlow.forgotPassword}/${SignInIdentifier.Sms}/passcode-validation`,
|
||||
search: '',
|
||||
},
|
||||
{ state: { phone: fullPhoneNumber } }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
33
packages/ui/src/containers/PhoneForm/SmsResetPassword.tsx
Normal file
33
packages/ui/src/containers/PhoneForm/SmsResetPassword.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
|
||||
import usePasswordlessSendCode from '@/hooks/use-passwordless-send-code';
|
||||
import { UserFlow } from '@/types';
|
||||
|
||||
import PhoneForm from './PhoneForm';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
autoFocus?: boolean;
|
||||
hasSwitch?: boolean;
|
||||
};
|
||||
|
||||
const SmsResetPassword = (props: Props) => {
|
||||
const { onSubmit, errorMessage, clearErrorMessage } = usePasswordlessSendCode(
|
||||
UserFlow.forgotPassword,
|
||||
SignInIdentifier.Sms
|
||||
);
|
||||
|
||||
return (
|
||||
<PhoneForm
|
||||
hasTerms={false}
|
||||
onSubmit={onSubmit}
|
||||
{...props}
|
||||
submitButtonText="action.continue"
|
||||
errorMessage={errorMessage}
|
||||
clearErrorMessage={clearErrorMessage}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SmsResetPassword;
|
|
@ -1,2 +1,3 @@
|
|||
export { default as SmsRegister } from './SmsRegister';
|
||||
export { default as SmsSignIn } from './SmsSignIn';
|
||||
export { default as SmsResetPassword } from './SmsResetPassword';
|
||||
|
|
|
@ -1,19 +1,16 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useMemo, useCallback, useState, useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { signInWithPhonePassword } from '@/apis/sign-in';
|
||||
import Button from '@/components/Button';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
import { PhoneInput, PasswordInput } from '@/components/Input';
|
||||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import type { ErrorHandlers } from '@/hooks/use-api';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useForm from '@/hooks/use-form';
|
||||
import usePasswordSignIn from '@/hooks/use-password-sign-in';
|
||||
import usePhoneNumber from '@/hooks/use-phone-number';
|
||||
import useTerms from '@/hooks/use-terms';
|
||||
import { SearchParameters } from '@/types';
|
||||
import { getSearchParameters } from '@/utils';
|
||||
import { requiredValidation } from '@/utils/field-validations';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -37,8 +34,8 @@ const defaultState: FieldState = {
|
|||
const PhonePassword = ({ className, autoFocus }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { termsValidation } = useTerms();
|
||||
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn(SignInIdentifier.Sms);
|
||||
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const { countryList, phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
|
||||
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
|
||||
|
||||
|
@ -60,22 +57,11 @@ const PhonePassword = ({ className, autoFocus }: Props) => {
|
|||
}));
|
||||
}, [phoneNumber, setFieldValue]);
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'session.invalid_credentials': (error) => {
|
||||
setErrorMessage(error.message);
|
||||
},
|
||||
}),
|
||||
[setErrorMessage]
|
||||
);
|
||||
|
||||
const { run: asyncSignInWithPhonePassword } = useApi(signInWithPhonePassword, errorHandlers);
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
|
||||
setErrorMessage(undefined);
|
||||
clearErrorMessage();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
|
@ -85,14 +71,13 @@ const PhonePassword = ({ className, autoFocus }: Props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
|
||||
|
||||
void asyncSignInWithPhonePassword(fieldValue.phone, fieldValue.password, socialToBind);
|
||||
void onSubmit(fieldValue.phone, fieldValue.password);
|
||||
},
|
||||
[
|
||||
clearErrorMessage,
|
||||
validateForm,
|
||||
termsValidation,
|
||||
asyncSignInWithPhonePassword,
|
||||
onSubmit,
|
||||
fieldValue.phone,
|
||||
fieldValue.password,
|
||||
]
|
||||
|
|
|
@ -1,18 +1,15 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { signInWithUsername } from '@/apis/sign-in';
|
||||
import Button from '@/components/Button';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
import Input, { PasswordInput } from '@/components/Input';
|
||||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import type { ErrorHandlers } from '@/hooks/use-api';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import useForm from '@/hooks/use-form';
|
||||
import usePasswordSignIn from '@/hooks/use-password-sign-in';
|
||||
import useTerms from '@/hooks/use-terms';
|
||||
import { SearchParameters } from '@/types';
|
||||
import { getSearchParameters } from '@/utils';
|
||||
import { requiredValidation } from '@/utils/field-validations';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -36,26 +33,17 @@ const defaultState: FieldState = {
|
|||
const UsernameSignIn = ({ className, autoFocus }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { termsValidation } = useTerms();
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
|
||||
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'session.invalid_credentials': (error) => {
|
||||
setErrorMessage(error.message);
|
||||
},
|
||||
}),
|
||||
[setErrorMessage]
|
||||
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn(
|
||||
SignInIdentifier.Username
|
||||
);
|
||||
|
||||
const { result, run: asyncSignInWithUsername } = useApi(signInWithUsername, errorHandlers);
|
||||
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
|
||||
|
||||
const onSubmitHandler = useCallback(
|
||||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
|
||||
setErrorMessage(undefined);
|
||||
clearErrorMessage();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
|
@ -65,25 +53,18 @@ const UsernameSignIn = ({ className, autoFocus }: Props) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
|
||||
|
||||
void asyncSignInWithUsername(fieldValue.username, fieldValue.password, socialToBind);
|
||||
void onSubmit(fieldValue.username, fieldValue.password);
|
||||
},
|
||||
[
|
||||
clearErrorMessage,
|
||||
validateForm,
|
||||
termsValidation,
|
||||
asyncSignInWithUsername,
|
||||
onSubmit,
|
||||
fieldValue.username,
|
||||
fieldValue.password,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (result?.redirectTo) {
|
||||
window.location.replace(result.redirectTo);
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
return (
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<Input
|
||||
|
|
59
packages/ui/src/hooks/use-password-sign-in.ts
Normal file
59
packages/ui/src/hooks/use-password-sign-in.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
|
||||
import {
|
||||
signInWithUsername,
|
||||
signInWithEmailPassword,
|
||||
signInWithPhonePassword,
|
||||
} from '@/apis/sign-in';
|
||||
import type { ErrorHandlers } from '@/hooks/use-api';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { SearchParameters } from '@/types';
|
||||
import { getSearchParameters } from '@/utils';
|
||||
|
||||
const apiMap = {
|
||||
[SignInIdentifier.Username]: signInWithUsername,
|
||||
[SignInIdentifier.Email]: signInWithEmailPassword,
|
||||
[SignInIdentifier.Sms]: signInWithPhonePassword,
|
||||
};
|
||||
|
||||
const usePasswordSignIn = (method: SignInIdentifier) => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
|
||||
const clearErrorMessage = useCallback(() => {
|
||||
setErrorMessage('');
|
||||
}, []);
|
||||
|
||||
const errorHandlers: ErrorHandlers = useMemo(
|
||||
() => ({
|
||||
'session.invalid_credentials': (error) => {
|
||||
setErrorMessage(error.message);
|
||||
},
|
||||
}),
|
||||
[setErrorMessage]
|
||||
);
|
||||
|
||||
const { result, run: asyncSignIn } = useApi(apiMap[method], errorHandlers);
|
||||
|
||||
useEffect(() => {
|
||||
if (result?.redirectTo) {
|
||||
window.location.replace(result.redirectTo);
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (identifier: string, password: string) => {
|
||||
const socialToBind = getSearchParameters(location.search, SearchParameters.bindWithSocial);
|
||||
await asyncSignIn(identifier, password, socialToBind);
|
||||
},
|
||||
[asyncSignIn]
|
||||
);
|
||||
|
||||
return {
|
||||
errorMessage,
|
||||
clearErrorMessage,
|
||||
onSubmit,
|
||||
};
|
||||
};
|
||||
|
||||
export default usePasswordSignIn;
|
|
@ -9,7 +9,8 @@ import type { UserFlow } from '@/types';
|
|||
|
||||
const usePasswordlessSendCode = (
|
||||
flow: UserFlow,
|
||||
method: SignInIdentifier.Email | SignInIdentifier.Sms
|
||||
method: SignInIdentifier.Email | SignInIdentifier.Sms,
|
||||
replaceCurrentPage?: boolean
|
||||
) => {
|
||||
const [errorMessage, setErrorMessage] = useState<string>();
|
||||
const navigate = useNavigate();
|
||||
|
@ -44,10 +45,13 @@ const usePasswordlessSendCode = (
|
|||
pathname: `/${flow}/${method}/passcode-validation`,
|
||||
search: location.search,
|
||||
},
|
||||
{ state: method === SignInIdentifier.Email ? { email: value } : { phone: value } }
|
||||
{
|
||||
state: method === SignInIdentifier.Email ? { email: value } : { phone: value },
|
||||
replace: replaceCurrentPage,
|
||||
}
|
||||
);
|
||||
},
|
||||
[asyncSendPasscode, flow, method, navigate]
|
||||
[asyncSendPasscode, flow, method, navigate, replaceCurrentPage]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
@ -12,5 +12,6 @@ export const useSieMethods = () => {
|
|||
signInMethods: experienceSettings?.signIn.methods ?? [],
|
||||
socialConnectors: experienceSettings?.socialConnectors ?? [],
|
||||
signInMode: experienceSettings?.signInMode,
|
||||
forgotPassword: experienceSettings?.forgotPassword,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { Routes, Route, MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
|
||||
|
||||
import ForgotPassword from '.';
|
||||
|
||||
|
@ -9,29 +11,78 @@ jest.mock('i18next', () => ({
|
|||
}));
|
||||
|
||||
describe('ForgotPassword', () => {
|
||||
it('render email forgot password properly', () => {
|
||||
const { queryByText } = renderWithPageContext(
|
||||
it('render email forgot password properly with sms enabled as well', () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<MemoryRouter initialEntries={['/forgot-password/email']}>
|
||||
<Routes>
|
||||
<Route path="/forgot-password/:method" element={<ForgotPassword />} />
|
||||
<Route
|
||||
path="/forgot-password/:method"
|
||||
element={
|
||||
<SettingsProvider>
|
||||
<ForgotPassword />
|
||||
</SettingsProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('description.reset_password')).not.toBeNull();
|
||||
expect(queryByText('description.reset_password_description_email')).not.toBeNull();
|
||||
expect(container.querySelector('input[name="email"]')).not.toBeNull();
|
||||
expect(queryByText('action.switch_to')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('render sms forgot password properly', () => {
|
||||
const { queryByText } = renderWithPageContext(
|
||||
it('render sms forgot password properly with email disabled', () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<MemoryRouter initialEntries={['/forgot-password/sms']}>
|
||||
<Routes>
|
||||
<Route path="/forgot-password/:method" element={<ForgotPassword />} />
|
||||
<Route
|
||||
path="/forgot-password/:method"
|
||||
element={
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
forgotPassword: { email: false, sms: true },
|
||||
}}
|
||||
>
|
||||
<ForgotPassword />
|
||||
</SettingsProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('description.reset_password')).not.toBeNull();
|
||||
expect(queryByText('description.reset_password_description_sms')).not.toBeNull();
|
||||
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
|
||||
expect(queryByText('action.switch_to')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error page if forgot password is not enabled', () => {
|
||||
const { queryByText } = renderWithPageContext(
|
||||
<MemoryRouter initialEntries={['/forgot-password/sms']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/forgot-password/:method"
|
||||
element={
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
forgotPassword: { email: true, sms: false },
|
||||
}}
|
||||
>
|
||||
<ForgotPassword />
|
||||
</SettingsProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('description.reset_password')).toBeNull();
|
||||
expect(queryByText('description.reset_password_description_sms')).toBeNull();
|
||||
expect(queryByText('description.not_found')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { is } from 'superstruct';
|
||||
|
||||
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
|
||||
import { EmailPasswordless, PhonePasswordless } from '@/containers/Passwordless';
|
||||
import { EmailResetPassword } from '@/containers/EmailForm';
|
||||
import { SmsResetPassword } from '@/containers/PhoneForm';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
import { UserFlow } from '@/types';
|
||||
import { passcodeMethodGuard } from '@/types/guard';
|
||||
|
||||
type Props = {
|
||||
method?: string;
|
||||
|
@ -11,20 +15,35 @@ type Props = {
|
|||
|
||||
const ForgotPassword = () => {
|
||||
const { method = '' } = useParams<Props>();
|
||||
const { forgotPassword } = useSieMethods();
|
||||
|
||||
// TODO: @simeng LOG-4486 apply supported method guard validation. Including the form hasSwitch validation bellow
|
||||
if (!['email', 'sms'].includes(method)) {
|
||||
if (!is(method, passcodeMethodGuard)) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
const PasswordlessForm = method === 'email' ? EmailPasswordless : PhonePasswordless;
|
||||
// Forgot password with target identifier method is not supported
|
||||
if (!forgotPassword?.[method]) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
const PasswordlessForm =
|
||||
method === SignInIdentifier.Email ? EmailResetPassword : SmsResetPassword;
|
||||
|
||||
return (
|
||||
<SecondaryPageWrapper
|
||||
title="description.reset_password"
|
||||
description={`description.reset_password_description_${method === 'email' ? 'email' : 'sms'}`}
|
||||
description={`description.reset_password_description_${
|
||||
method === SignInIdentifier.Email ? 'email' : 'sms'
|
||||
}`}
|
||||
>
|
||||
<PasswordlessForm autoFocus hasSwitch type={UserFlow.forgotPassword} hasTerms={false} />
|
||||
<PasswordlessForm
|
||||
autoFocus
|
||||
hasSwitch={
|
||||
forgotPassword[
|
||||
method === SignInIdentifier.Email ? SignInIdentifier.Sms : SignInIdentifier.Email
|
||||
]
|
||||
}
|
||||
/>
|
||||
</SecondaryPageWrapper>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { useParams, useLocation } from 'react-router-dom';
|
||||
import { is } from 'superstruct';
|
||||
|
||||
|
@ -24,7 +25,7 @@ const Passcode = () => {
|
|||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
const target = !invalidState && state[method];
|
||||
const target = !invalidState && state[method === SignInIdentifier.Email ? 'email' : 'phone'];
|
||||
|
||||
if (!target) {
|
||||
return <ErrorPage title={method === 'email' ? 'error.invalid_email' : 'error.invalid_phone'} />;
|
||||
|
|
171
packages/ui/src/pages/SignInPassword/index.test.tsx
Normal file
171
packages/ui/src/pages/SignInPassword/index.test.tsx
Normal file
|
@ -0,0 +1,171 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { Routes, Route, MemoryRouter, useLocation } from 'react-router-dom';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
|
||||
|
||||
import SignInPassword from '.';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
describe('SignInPassword', () => {
|
||||
const mockUseLocation = useLocation as jest.Mock;
|
||||
const email = 'email@logto.io';
|
||||
const phone = '18571111111';
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseLocation.mockImplementation(() => ({ state: { email, phone } }));
|
||||
});
|
||||
|
||||
test('Show error page with unknown method', () => {
|
||||
const { queryByText } = renderWithPageContext(
|
||||
<MemoryRouter initialEntries={['/sign-in/test/password']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/sign-in/:method/password"
|
||||
element={
|
||||
<SettingsProvider>
|
||||
<SignInPassword />
|
||||
</SettingsProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('description.enter_password')).toBeNull();
|
||||
expect(queryByText('description.not_found')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('Show error page with unsupported method', () => {
|
||||
const { queryByText } = renderWithPageContext(
|
||||
<MemoryRouter initialEntries={['/sign-in/email/password']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/sign-in/:method/password"
|
||||
element={
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
signIn: {
|
||||
methods: mockSignInExperienceSettings.signIn.methods.filter(
|
||||
({ identifier }) => identifier !== SignInIdentifier.Email
|
||||
),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SignInPassword />
|
||||
</SettingsProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('description.enter_password')).toBeNull();
|
||||
expect(queryByText('description.not_found')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('Show error page if no address value found', () => {
|
||||
mockUseLocation.mockClear();
|
||||
mockUseLocation.mockImplementation(() => ({}));
|
||||
const { queryByText } = renderWithPageContext(
|
||||
<MemoryRouter initialEntries={['/sign-in/email/password']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/sign-in/:method/password"
|
||||
element={
|
||||
<SettingsProvider>
|
||||
<SignInPassword />
|
||||
</SettingsProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('description.enter_password')).toBeNull();
|
||||
expect(queryByText('error.invalid_email')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('/sign-in/email/password', () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<MemoryRouter initialEntries={['/sign-in/email/password']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/sign-in/:method/password"
|
||||
element={
|
||||
<SettingsProvider>
|
||||
<SignInPassword />
|
||||
</SettingsProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('description.enter_password')).not.toBeNull();
|
||||
expect(container.querySelector('input[name="password"]')).not.toBeNull();
|
||||
expect(queryByText('action.sign_in_via_passcode')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('/sign-in/sms/password', () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<MemoryRouter initialEntries={['/sign-in/sms/password']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/sign-in/:method/password"
|
||||
element={
|
||||
<SettingsProvider>
|
||||
<SignInPassword />
|
||||
</SettingsProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('description.enter_password')).not.toBeNull();
|
||||
expect(container.querySelector('input[name="password"]')).not.toBeNull();
|
||||
expect(queryByText('action.sign_in_via_passcode')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('should not render passwordless link if verificationCode is disabled', () => {
|
||||
const { queryByText, container } = renderWithPageContext(
|
||||
<MemoryRouter initialEntries={['/sign-in/email/password']}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/sign-in/:method/password"
|
||||
element={
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
signIn: {
|
||||
methods: [
|
||||
{
|
||||
identifier: SignInIdentifier.Email,
|
||||
password: true,
|
||||
verificationCode: false,
|
||||
isPasswordPrimary: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SignInPassword />
|
||||
</SettingsProvider>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('description.enter_password')).not.toBeNull();
|
||||
expect(container.querySelector('input[name="password"]')).not.toBeNull();
|
||||
expect(queryByText('action.sign_in_via_passcode')).toBeNull();
|
||||
});
|
||||
});
|
|
@ -1,5 +1,62 @@
|
|||
import { SignInIdentifier } from '@logto/schemas';
|
||||
import { useParams, useLocation } from 'react-router-dom';
|
||||
import { is } from 'superstruct';
|
||||
|
||||
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
|
||||
import PasswordSignInForm from '@/containers/PasswordSignInForm';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
import { passcodeStateGuard } from '@/types/guard';
|
||||
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
||||
import { isEmailOrSmsMethod } from '@/utils/sign-in-experience';
|
||||
|
||||
type Parameters = {
|
||||
method?: string;
|
||||
};
|
||||
|
||||
const SignInPassword = () => {
|
||||
return <div>sign in password</div>;
|
||||
const { method } = useParams<Parameters>();
|
||||
const { state } = useLocation();
|
||||
const { signInMethods } = useSieMethods();
|
||||
const methodSetting = signInMethods.find(({ identifier }) => identifier === method);
|
||||
|
||||
// Only Email and Sms method should use this page
|
||||
if (!methodSetting || !isEmailOrSmsMethod(methodSetting.identifier) || !methodSetting.password) {
|
||||
return <ErrorPage />;
|
||||
}
|
||||
|
||||
const invalidState = !is(state, passcodeStateGuard);
|
||||
const value =
|
||||
!invalidState && state[methodSetting.identifier === SignInIdentifier.Email ? 'email' : 'phone'];
|
||||
|
||||
if (!value) {
|
||||
return (
|
||||
<ErrorPage
|
||||
title={method === SignInIdentifier.Email ? 'error.invalid_email' : 'error.invalid_phone'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SecondaryPageWrapper
|
||||
title="description.enter_password"
|
||||
description="description.enter_password_for"
|
||||
descriptionProps={{
|
||||
method: `description.${method === SignInIdentifier.Email ? 'email' : 'phone_number'}`,
|
||||
value:
|
||||
method === SignInIdentifier.Email
|
||||
? value
|
||||
: formatPhoneNumberWithCountryCallingCode(value),
|
||||
}}
|
||||
>
|
||||
<PasswordSignInForm
|
||||
autoFocus
|
||||
method={methodSetting.identifier}
|
||||
value={value}
|
||||
hasPasswordlessButton={methodSetting.verificationCode}
|
||||
/>
|
||||
</SecondaryPageWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignInPassword;
|
||||
|
|
|
@ -7,7 +7,7 @@ export const bindSocialStateGuard = s.object({
|
|||
|
||||
export const passcodeStateGuard = s.object({
|
||||
email: s.optional(s.string()),
|
||||
sms: s.optional(s.string()),
|
||||
phone: s.optional(s.string()),
|
||||
});
|
||||
|
||||
export const passcodeMethodGuard = s.union([
|
||||
|
|
|
@ -30,6 +30,10 @@ export type SignInExperienceResponse = Omit<
|
|||
> & {
|
||||
socialConnectors: ConnectorMetadata[];
|
||||
notification?: string;
|
||||
forgotPassword: {
|
||||
sms: boolean;
|
||||
email: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type SignInExperienceSettings = Omit<SignInExperienceResponse, 'signUp'> & {
|
||||
|
|
|
@ -38,3 +38,8 @@ export const getSignInExperienceSettings = async (): Promise<SignInExperienceSet
|
|||
|
||||
return parseSignInExperienceResponse(response);
|
||||
};
|
||||
|
||||
export const isEmailOrSmsMethod = (
|
||||
method: SignInIdentifier
|
||||
): method is SignInIdentifier.Email | SignInIdentifier.Sms =>
|
||||
[SignInIdentifier.Email, SignInIdentifier.Sms].includes(method);
|
||||
|
|
Loading…
Add table
Reference in a new issue