0
Fork 0
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:
simeng-li 2022-11-04 17:13:34 +08:00 committed by GitHub
parent 9f23e13e2d
commit 5b2bbd801b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
36 changed files with 896 additions and 726 deletions

View file

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

View file

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

View file

@ -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: '사용자 이름 또는 비밀번호가 일치하지 않아요.',

View file

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

View file

@ -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.',

View file

@ -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: '用户名和密码不匹配',

View file

@ -227,4 +227,8 @@ export const mockSignInExperienceSettings: SignInExperienceSettings = {
},
socialConnectors,
signInMode: SignInMode.SignInAndRegister,
forgotPassword: {
email: true,
sms: true,
},
};

View file

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

View 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;

View file

@ -1,2 +1,3 @@
export { default as EmailRegister } from './EmailRegister';
export { default as EmailSignIn } from './EmailSignIn';
export { default as EmailResetPassword } from './EmailResetPassword';

View file

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

View file

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

View file

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

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

View 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;

View file

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

View file

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

View file

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

View file

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

View file

@ -1,2 +0,0 @@
export { default as EmailPasswordless } from './EmailPasswordless';
export { default as PhonePasswordless } from './PhonePasswordless';

View file

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

View 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;

View file

@ -1,2 +1,3 @@
export { default as SmsRegister } from './SmsRegister';
export { default as SmsSignIn } from './SmsSignIn';
export { default as SmsResetPassword } from './SmsResetPassword';

View file

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

View file

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

View 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;

View file

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

View file

@ -12,5 +12,6 @@ export const useSieMethods = () => {
signInMethods: experienceSettings?.signIn.methods ?? [],
socialConnectors: experienceSettings?.socialConnectors ?? [],
signInMode: experienceSettings?.signInMode,
forgotPassword: experienceSettings?.forgotPassword,
};
};

View file

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

View file

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

View file

@ -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'} />;

View 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();
});
});

View file

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

View file

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

View file

@ -30,6 +30,10 @@ export type SignInExperienceResponse = Omit<
> & {
socialConnectors: ConnectorMetadata[];
notification?: string;
forgotPassword: {
sms: boolean;
email: boolean;
};
};
export type SignInExperienceSettings = Omit<SignInExperienceResponse, 'signUp'> & {

View file

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