mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
parent
9a89c1a200
commit
ddb0e47950
25 changed files with 447 additions and 189 deletions
|
@ -7,7 +7,13 @@ import { appendPath } from '@/utils/url';
|
|||
|
||||
// Need To Align With UI
|
||||
export const sessionNotFoundPath = '/unknown-session';
|
||||
export const guardedPath = ['/sign-in', '/register', '/social-register'];
|
||||
export const guardedPath = [
|
||||
'/sign-in',
|
||||
'/register',
|
||||
'/social/register',
|
||||
'/reset-password',
|
||||
'/forgot-password',
|
||||
];
|
||||
|
||||
export default function koaSpaSessionGuard<
|
||||
StateT,
|
||||
|
|
|
@ -26,6 +26,7 @@ const translation = {
|
|||
got_it: 'Got it',
|
||||
sign_in_with: 'Sign in with {{name}}',
|
||||
forgot_password: 'Forgot Password?',
|
||||
switch_to: 'Switch to {{method}}',
|
||||
},
|
||||
description: {
|
||||
email: 'email',
|
||||
|
|
|
@ -28,6 +28,7 @@ const translation = {
|
|||
got_it: 'Compris',
|
||||
sign_in_with: 'Connexion avec {{name}}',
|
||||
forgot_password: 'Mot de passe oublié ?',
|
||||
switch_to: 'Passer au {{method}}',
|
||||
},
|
||||
description: {
|
||||
email: 'email',
|
||||
|
|
|
@ -28,6 +28,7 @@ const translation = {
|
|||
got_it: '알겠습니다',
|
||||
sign_in_with: '{{name}} 로그인',
|
||||
forgot_password: '비밀번호를 잊어버리셨나요?',
|
||||
switch_to: 'Switch to {{method}}', // TODO: untranslated
|
||||
},
|
||||
description: {
|
||||
email: '이메일',
|
||||
|
|
|
@ -28,6 +28,7 @@ const translation = {
|
|||
got_it: 'Entendi',
|
||||
sign_in_with: 'Entrar com {{name}}',
|
||||
forgot_password: 'Esqueceu a password?',
|
||||
switch_to: 'Mudar para {{method}}',
|
||||
},
|
||||
description: {
|
||||
email: 'email',
|
||||
|
|
|
@ -28,6 +28,7 @@ const translation = {
|
|||
got_it: 'Anladım',
|
||||
sign_in_with: '{{name}} ile giriş yap',
|
||||
forgot_password: 'Şifremi Unuttum?',
|
||||
switch_to: 'Switch to {{method}}', // TODO: not translated
|
||||
},
|
||||
description: {
|
||||
email: 'e-posta adresi',
|
||||
|
|
|
@ -28,6 +28,7 @@ const translation = {
|
|||
got_it: '知道了',
|
||||
sign_in_with: '通过 {{name}} 登录',
|
||||
forgot_password: '忘记密码?',
|
||||
switch_to: '切换到{{method}}',
|
||||
},
|
||||
description: {
|
||||
email: '邮箱',
|
||||
|
|
|
@ -11,7 +11,17 @@
|
|||
margin-bottom: _.unit(4);
|
||||
}
|
||||
|
||||
.formFields {
|
||||
margin-bottom: _.unit(8);
|
||||
}
|
||||
|
||||
.terms {
|
||||
margin: _.unit(8) 0 _.unit(4);
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.desktop) {
|
||||
.formFields {
|
||||
margin-bottom: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -85,41 +85,44 @@ const CreateAccount = ({ className, autoFocus }: Props) => {
|
|||
|
||||
return (
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<Input
|
||||
autoFocus={autoFocus}
|
||||
className={styles.inputField}
|
||||
name="new-username"
|
||||
placeholder={t('input.username')}
|
||||
{...fieldRegister('username', usernameValidation)}
|
||||
onClear={() => {
|
||||
setFieldValue((state) => ({ ...state, username: '' }));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className={styles.inputField}
|
||||
name="new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('input.password')}
|
||||
{...fieldRegister('password', passwordValidation)}
|
||||
onClear={() => {
|
||||
setFieldValue((state) => ({ ...state, password: '' }));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className={styles.inputField}
|
||||
name="confirm-new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('input.confirm_password')}
|
||||
{...fieldRegister('confirmPassword', (confirmPassword) =>
|
||||
confirmPasswordValidation(fieldValue.password, confirmPassword)
|
||||
)}
|
||||
errorStyling={false}
|
||||
onClear={() => {
|
||||
setFieldValue((state) => ({ ...state, confirmPassword: '' }));
|
||||
}}
|
||||
/>
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
autoFocus={autoFocus}
|
||||
className={styles.inputField}
|
||||
name="new-username"
|
||||
placeholder={t('input.username')}
|
||||
{...fieldRegister('username', usernameValidation)}
|
||||
onClear={() => {
|
||||
setFieldValue((state) => ({ ...state, username: '' }));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className={styles.inputField}
|
||||
name="new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('input.password')}
|
||||
{...fieldRegister('password', passwordValidation)}
|
||||
onClear={() => {
|
||||
setFieldValue((state) => ({ ...state, password: '' }));
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
className={styles.inputField}
|
||||
name="confirm-new-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={t('input.confirm_password')}
|
||||
{...fieldRegister('confirmPassword', (confirmPassword) =>
|
||||
confirmPasswordValidation(fieldValue.password, confirmPassword)
|
||||
)}
|
||||
errorStyling={false}
|
||||
onClear={() => {
|
||||
setFieldValue((state) => ({ ...state, confirmPassword: '' }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TermsOfUse className={styles.terms} />
|
||||
|
||||
<Button title="action.create" onClick={async () => onSubmitHandler()} />
|
||||
|
|
|
@ -5,7 +5,6 @@ import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
|||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { sendRegisterEmailPasscode } from '@/apis/register';
|
||||
import { sendSignInEmailPasscode } from '@/apis/sign-in';
|
||||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
|
||||
import EmailPasswordless from './EmailPasswordless';
|
||||
|
||||
|
@ -31,15 +30,24 @@ describe('<EmailPasswordless/>', () => {
|
|||
const { queryByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<EmailPasswordless type="sign-in">
|
||||
<TermsOfUse />
|
||||
</EmailPasswordless>
|
||||
<EmailPasswordless type="sign-in" />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(queryByText('description.terms_of_use')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('ender with terms settings but hasTerms param set to false', () => {
|
||||
const { queryByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<EmailPasswordless type="sign-in" hasTerms={false} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(queryByText('description.terms_of_use')).toBeNull();
|
||||
});
|
||||
|
||||
test('required email with error message', () => {
|
||||
const { queryByText, container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
|
@ -63,14 +71,15 @@ describe('<EmailPasswordless/>', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('should block in extra validation failed', async () => {
|
||||
test('should blocked by terms validation with terms settings enabled', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<EmailPasswordless type="sign-in" onSubmitValidation={async () => false} />
|
||||
<EmailPasswordless type="sign-in" />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const emailInput = container.querySelector('input[name="email"]');
|
||||
|
||||
if (emailInput) {
|
||||
|
@ -88,14 +97,15 @@ describe('<EmailPasswordless/>', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('should call sign-in method properly', async () => {
|
||||
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="sign-in" />
|
||||
<EmailPasswordless type="sign-in" hasTerms={false} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const emailInput = container.querySelector('input[name="email"]');
|
||||
|
||||
if (emailInput) {
|
||||
|
@ -113,7 +123,35 @@ describe('<EmailPasswordless/>', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('should call register method properly', async () => {
|
||||
test('should call sign-in method properly with terms settings enabled and checked', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<EmailPasswordless type="sign-in" />
|
||||
</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>
|
||||
|
@ -126,6 +164,10 @@ describe('<EmailPasswordless/>', () => {
|
|||
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(() => {
|
||||
|
|
|
@ -6,14 +6,17 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { getSendPasscodeApi } from '@/apis/utils';
|
||||
import Button from '@/components/Button';
|
||||
import Input from '@/components/Input';
|
||||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import useApi, { ErrorHandlers } from '@/hooks/use-api';
|
||||
import useForm from '@/hooks/use-form';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
import useTerms from '@/hooks/use-terms';
|
||||
import { UserFlow, SearchParameters } from '@/types';
|
||||
import { getSearchParameters } from '@/utils';
|
||||
import { emailValidation } from '@/utils/field-validations';
|
||||
|
||||
import PasswordlessConfirmModal from './PasswordlessConfirmModal';
|
||||
import PasswordlessSwitch from './PasswordlessSwitch';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
|
@ -21,8 +24,8 @@ type Props = {
|
|||
className?: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
autoFocus?: boolean;
|
||||
onSubmitValidation?: () => Promise<boolean>;
|
||||
children?: React.ReactNode;
|
||||
hasTerms?: boolean;
|
||||
hasSwitch?: boolean;
|
||||
};
|
||||
|
||||
type FieldState = {
|
||||
|
@ -31,10 +34,18 @@ type FieldState = {
|
|||
|
||||
const defaultState: FieldState = { email: '' };
|
||||
|
||||
const EmailPasswordless = ({ type, autoFocus, onSubmitValidation, children, className }: Props) => {
|
||||
const EmailPasswordless = ({
|
||||
type,
|
||||
autoFocus,
|
||||
hasTerms = true,
|
||||
hasSwitch = false,
|
||||
className,
|
||||
}: Props) => {
|
||||
const { setToast } = useContext(PageContext);
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { termsValidation } = useTerms();
|
||||
const { fieldValue, setFieldValue, setFieldErrors, register, validateForm } =
|
||||
useForm(defaultState);
|
||||
|
||||
|
@ -75,13 +86,13 @@ const EmailPasswordless = ({ type, autoFocus, onSubmitValidation, children, clas
|
|||
return;
|
||||
}
|
||||
|
||||
if (onSubmitValidation && !(await onSubmitValidation())) {
|
||||
if (hasTerms && !(await termsValidation())) {
|
||||
return;
|
||||
}
|
||||
|
||||
void asyncSendPasscode(fieldValue.email);
|
||||
},
|
||||
[validateForm, onSubmitValidation, asyncSendPasscode, fieldValue.email]
|
||||
[validateForm, hasTerms, termsValidation, asyncSendPasscode, fieldValue.email]
|
||||
);
|
||||
|
||||
const onModalCloseHandler = useCallback(() => {
|
||||
|
@ -103,22 +114,24 @@ const EmailPasswordless = ({ type, autoFocus, onSubmitValidation, children, clas
|
|||
return (
|
||||
<>
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<Input
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
inputMode="email"
|
||||
placeholder={t('input.email')}
|
||||
autoFocus={autoFocus}
|
||||
className={styles.inputField}
|
||||
{...register('email', emailValidation)}
|
||||
onClear={() => {
|
||||
setFieldValue((state) => ({ ...state, email: '' }));
|
||||
}}
|
||||
/>
|
||||
|
||||
{children && <div className={styles.childWrapper}>{children}</div>}
|
||||
<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" />
|
||||
|
|
|
@ -0,0 +1,73 @@
|
|||
import { fireEvent } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
|
||||
|
||||
import PasswordlessSwitch from './PasswordlessSwitch';
|
||||
|
||||
const mockedNavigate = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useNavigate: () => mockedNavigate,
|
||||
}));
|
||||
|
||||
describe('<PasswordlessSwitch />', () => {
|
||||
afterEach(() => {
|
||||
mockedNavigate.mockClear();
|
||||
});
|
||||
|
||||
test('render sms passwordless switch', () => {
|
||||
const { queryByText, getByText } = renderWithPageContext(
|
||||
<MemoryRouter initialEntries={['/forgot-password/sms']}>
|
||||
<SettingsProvider>
|
||||
<PasswordlessSwitch target="email" />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('action.switch_to')).not.toBeNull();
|
||||
|
||||
const link = getByText('action.switch_to');
|
||||
fireEvent.click(link);
|
||||
|
||||
expect(mockedNavigate).toBeCalledWith({ pathname: '/forgot-password/email' });
|
||||
});
|
||||
|
||||
test('render email passwordless switch', () => {
|
||||
const { queryByText, getByText } = renderWithPageContext(
|
||||
<MemoryRouter initialEntries={['/forgot-password/email']}>
|
||||
<SettingsProvider>
|
||||
<PasswordlessSwitch target="sms" />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('action.switch_to')).not.toBeNull();
|
||||
|
||||
const link = getByText('action.switch_to');
|
||||
fireEvent.click(link);
|
||||
|
||||
expect(mockedNavigate).toBeCalledWith({ pathname: '/forgot-password/sms' });
|
||||
});
|
||||
|
||||
test('should not render the switch if SIE setting does not has the supported sign in method', () => {
|
||||
const { queryByText, getByText } = renderWithPageContext(
|
||||
<MemoryRouter initialEntries={['/forgot-password/email']}>
|
||||
<SettingsProvider
|
||||
settings={{
|
||||
...mockSignInExperienceSettings,
|
||||
primarySignInMethod: 'username',
|
||||
secondarySignInMethods: ['email', 'social'],
|
||||
}}
|
||||
>
|
||||
<PasswordlessSwitch target="sms" />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('action.switch_to')).toBeNull();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,48 @@
|
|||
import { useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
|
||||
import TextLink from '@/components/TextLink';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
|
||||
type Props = {
|
||||
target: 'sms' | 'email';
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const PasswordlessSwitch = ({ target, className }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { experienceSettings } = useContext(PageContext);
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!experienceSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
experienceSettings.primarySignInMethod !== target &&
|
||||
!experienceSettings.secondarySignInMethods.includes(target)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targetPathname = pathname.replace(target === 'email' ? 'sms' : 'email', target);
|
||||
|
||||
return (
|
||||
<TextLink
|
||||
className={className}
|
||||
onClick={() => {
|
||||
navigate({
|
||||
pathname: targetPathname,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('action.switch_to', {
|
||||
method: t(`description.${target === 'email' ? 'email' : 'phone_number'}`),
|
||||
})}
|
||||
</TextLink>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordlessSwitch;
|
|
@ -5,7 +5,6 @@ import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
|||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||
import { sendRegisterSmsPasscode } from '@/apis/register';
|
||||
import { sendSignInSmsPasscode } from '@/apis/sign-in';
|
||||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import { getDefaultCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
import PhonePasswordless from './PhonePasswordless';
|
||||
|
@ -38,15 +37,24 @@ describe('<PhonePasswordless/>', () => {
|
|||
const { queryByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<PhonePasswordless type="sign-in">
|
||||
<TermsOfUse />
|
||||
</PhonePasswordless>
|
||||
<PhonePasswordless type="sign-in" />
|
||||
</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="sign-in" hasTerms={false} />
|
||||
</SettingsProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
expect(queryByText('description.terms_of_use')).toBeNull();
|
||||
});
|
||||
|
||||
test('required phone with error message', () => {
|
||||
const { queryByText, container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
|
@ -70,31 +78,7 @@ describe('<PhonePasswordless/>', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('should block if extra validation failed', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<PhonePasswordless type="sign-in" onSubmitValidation={async () => 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).not.toBeCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test('should call sign-in method properly', async () => {
|
||||
test('should blocked by terms validation with terms settings enabled', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
|
@ -107,6 +91,32 @@ describe('<PhonePasswordless/>', () => {
|
|||
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="sign-in" 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(() => {
|
||||
|
@ -118,7 +128,35 @@ describe('<PhonePasswordless/>', () => {
|
|||
});
|
||||
});
|
||||
|
||||
test('should call register method properly', async () => {
|
||||
test('should call sign-in method properly with terms settings enabled and checked', async () => {
|
||||
const { container, getByText } = renderWithPageContext(
|
||||
<MemoryRouter>
|
||||
<SettingsProvider>
|
||||
<PhonePasswordless type="sign-in" />
|
||||
</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>
|
||||
|
@ -132,6 +170,9 @@ describe('<PhonePasswordless/>', () => {
|
|||
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
|
||||
}
|
||||
|
||||
const termsButton = getByText('description.agree_with_terms');
|
||||
fireEvent.click(termsButton);
|
||||
|
||||
const submitButton = getByText('action.continue');
|
||||
|
||||
act(() => {
|
||||
|
|
|
@ -6,14 +6,17 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { getSendPasscodeApi } from '@/apis/utils';
|
||||
import Button from '@/components/Button';
|
||||
import { PhoneInput } from '@/components/Input';
|
||||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import useApi, { ErrorHandlers } from '@/hooks/use-api';
|
||||
import useForm from '@/hooks/use-form';
|
||||
import { PageContext } from '@/hooks/use-page-context';
|
||||
import usePhoneNumber from '@/hooks/use-phone-number';
|
||||
import useTerms from '@/hooks/use-terms';
|
||||
import { UserFlow, SearchParameters } from '@/types';
|
||||
import { getSearchParameters } from '@/utils';
|
||||
|
||||
import PasswordlessConfirmModal from './PasswordlessConfirmModal';
|
||||
import PasswordlessSwitch from './PasswordlessSwitch';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
|
@ -21,8 +24,8 @@ type Props = {
|
|||
className?: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
autoFocus?: boolean;
|
||||
onSubmitValidation?: () => Promise<boolean>;
|
||||
children?: React.ReactNode;
|
||||
hasTerms?: boolean;
|
||||
hasSwitch?: boolean;
|
||||
};
|
||||
|
||||
type FieldState = {
|
||||
|
@ -31,9 +34,17 @@ type FieldState = {
|
|||
|
||||
const defaultState: FieldState = { phone: '' };
|
||||
|
||||
const PhonePasswordless = ({ type, autoFocus, onSubmitValidation, children, className }: Props) => {
|
||||
const PhonePasswordless = ({
|
||||
type,
|
||||
autoFocus,
|
||||
hasTerms = true,
|
||||
hasSwitch = false,
|
||||
className,
|
||||
}: Props) => {
|
||||
const { setToast } = useContext(PageContext);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { termsValidation } = useTerms();
|
||||
const { countryList, phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
|
||||
const navigate = useNavigate();
|
||||
const { fieldValue, setFieldValue, setFieldErrors, validateForm, register } =
|
||||
|
@ -85,13 +96,13 @@ const PhonePasswordless = ({ type, autoFocus, onSubmitValidation, children, clas
|
|||
return;
|
||||
}
|
||||
|
||||
if (onSubmitValidation && !(await onSubmitValidation())) {
|
||||
if (hasTerms && !(await termsValidation())) {
|
||||
return;
|
||||
}
|
||||
|
||||
void asyncSendPasscode(fieldValue.phone);
|
||||
},
|
||||
[validateForm, onSubmitValidation, asyncSendPasscode, fieldValue.phone]
|
||||
[validateForm, hasTerms, termsValidation, asyncSendPasscode, fieldValue.phone]
|
||||
);
|
||||
|
||||
const onModalCloseHandler = useCallback(() => {
|
||||
|
@ -118,20 +129,24 @@ const PhonePasswordless = ({ type, autoFocus, onSubmitValidation, children, clas
|
|||
return (
|
||||
<>
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<PhoneInput
|
||||
name="phone"
|
||||
placeholder={t('input.phone_number')}
|
||||
className={styles.inputField}
|
||||
countryCallingCode={phoneNumber.countryCallingCode}
|
||||
nationalNumber={phoneNumber.nationalNumber}
|
||||
autoFocus={autoFocus}
|
||||
countryList={countryList}
|
||||
{...register('phone', phoneNumberValidation)}
|
||||
onChange={(data) => {
|
||||
setPhoneNumber((previous) => ({ ...previous, ...data }));
|
||||
}}
|
||||
/>
|
||||
{children && <div className={styles.childWrapper}>{children}</div>}
|
||||
<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()} />
|
||||
|
||||
|
|
|
@ -7,11 +7,24 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.inputField {
|
||||
margin-bottom: _.unit(12);
|
||||
}
|
||||
|
||||
.childWrapper {
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.terms {
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
|
@ -1,7 +1,9 @@
|
|||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import useNativeMessageListener from '@/hooks/use-native-message-listener';
|
||||
import useSocial from '@/hooks/use-social';
|
||||
|
||||
import SocialSignInList from '../SocialSignInList';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export const defaultSize = 3;
|
||||
|
||||
|
@ -13,7 +15,12 @@ const PrimarySocialSignIn = ({ className }: Props) => {
|
|||
const { socialConnectors } = useSocial();
|
||||
useNativeMessageListener();
|
||||
|
||||
return <SocialSignInList className={className} socialConnectors={socialConnectors} />;
|
||||
return (
|
||||
<>
|
||||
<TermsOfUse className={styles.terms} />
|
||||
<SocialSignInList className={className} socialConnectors={socialConnectors} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PrimarySocialSignIn;
|
||||
|
|
|
@ -11,16 +11,22 @@
|
|||
margin-bottom: _.unit(4);
|
||||
}
|
||||
|
||||
.formFields {
|
||||
margin-bottom: _.unit(8);
|
||||
}
|
||||
|
||||
.terms {
|
||||
margin: _.unit(8) 0 _.unit(4);
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
|
||||
.formErrors {
|
||||
margin-top: _.unit(-2);
|
||||
margin-bottom: _.unit(4);
|
||||
|
||||
+ .terms {
|
||||
margin-top: _.unit(4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:global(body.desktop) {
|
||||
.formFields {
|
||||
margin-bottom: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -91,27 +91,30 @@ const UsernameSignIn = ({ className, autoFocus }: Props) => {
|
|||
|
||||
return (
|
||||
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
|
||||
<Input
|
||||
autoFocus={autoFocus}
|
||||
className={styles.inputField}
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
placeholder={t('input.username')}
|
||||
{...register('username', (value) => requiredValidation('username', value))}
|
||||
onClear={() => {
|
||||
setFieldValue((state) => ({ ...state, username: '' }));
|
||||
}}
|
||||
/>
|
||||
<PasswordInput
|
||||
className={styles.inputField}
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
placeholder={t('input.password')}
|
||||
{...register('password', (value) => requiredValidation('password', value))}
|
||||
/>
|
||||
{formErrorMessage && (
|
||||
<ErrorMessage className={styles.formErrors}>{formErrorMessage}</ErrorMessage>
|
||||
)}
|
||||
<div className={styles.formFields}>
|
||||
<Input
|
||||
autoFocus={autoFocus}
|
||||
className={styles.inputField}
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
placeholder={t('input.username')}
|
||||
{...register('username', (value) => requiredValidation('username', value))}
|
||||
onClear={() => {
|
||||
setFieldValue((state) => ({ ...state, username: '' }));
|
||||
}}
|
||||
/>
|
||||
<PasswordInput
|
||||
className={styles.inputField}
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
placeholder={t('input.password')}
|
||||
{...register('password', (value) => requiredValidation('password', value))}
|
||||
/>
|
||||
{formErrorMessage && (
|
||||
<ErrorMessage className={styles.formErrors}>{formErrorMessage}</ErrorMessage>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TermsOfUse className={styles.terms} />
|
||||
|
||||
<Button title="action.sign_in" onClick={async () => onSubmitHandler()} />
|
||||
|
|
|
@ -14,16 +14,15 @@ type Props = {
|
|||
|
||||
const ForgotPassword = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { method = '' } = useParams<Props>();
|
||||
|
||||
const forgotPasswordForm = useMemo(() => {
|
||||
if (method === 'sms') {
|
||||
return <PhonePasswordless autoFocus type="reset-password" />;
|
||||
return <PhonePasswordless autoFocus hasSwitch type="reset-password" hasTerms={false} />;
|
||||
}
|
||||
|
||||
if (method === 'email') {
|
||||
return <EmailPasswordless autoFocus type="reset-password" />;
|
||||
return <EmailPasswordless autoFocus hasSwitch type="reset-password" hasTerms={false} />;
|
||||
}
|
||||
}, [method]);
|
||||
|
||||
|
|
|
@ -5,8 +5,6 @@ import { useParams } from 'react-router-dom';
|
|||
import NavBar from '@/components/NavBar';
|
||||
import CreateAccount from '@/containers/CreateAccount';
|
||||
import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless';
|
||||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import useTerms from '@/hooks/use-terms';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -19,27 +17,17 @@ const Register = () => {
|
|||
const { t } = useTranslation();
|
||||
const { method = 'username' } = useParams<Parameters>();
|
||||
|
||||
const { termsValidation } = useTerms();
|
||||
|
||||
const registerForm = useMemo(() => {
|
||||
if (method === 'sms') {
|
||||
return (
|
||||
<PhonePasswordless autoFocus type="register" onSubmitValidation={termsValidation}>
|
||||
<TermsOfUse />
|
||||
</PhonePasswordless>
|
||||
);
|
||||
return <PhonePasswordless autoFocus type="register" />;
|
||||
}
|
||||
|
||||
if (method === 'email') {
|
||||
return (
|
||||
<EmailPasswordless autoFocus type="register" onSubmitValidation={termsValidation}>
|
||||
<TermsOfUse />
|
||||
</EmailPasswordless>
|
||||
);
|
||||
return <EmailPasswordless autoFocus type="register" />;
|
||||
}
|
||||
|
||||
return <CreateAccount autoFocus />;
|
||||
}, [method, termsValidation]);
|
||||
}, [method]);
|
||||
|
||||
if (!['email', 'sms', 'username'].includes(method)) {
|
||||
return <ErrorPage />;
|
||||
|
|
|
@ -4,9 +4,7 @@ import { useParams } from 'react-router-dom';
|
|||
|
||||
import NavBar from '@/components/NavBar';
|
||||
import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless';
|
||||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import UsernameSignIn from '@/containers/UsernameSignIn';
|
||||
import useTerms from '@/hooks/use-terms';
|
||||
import ErrorPage from '@/pages/ErrorPage';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
@ -19,27 +17,17 @@ const SecondarySignIn = () => {
|
|||
const { t } = useTranslation();
|
||||
const { method = 'username' } = useParams<Props>();
|
||||
|
||||
const { termsValidation } = useTerms();
|
||||
|
||||
const signInForm = useMemo(() => {
|
||||
if (method === 'sms') {
|
||||
return (
|
||||
<PhonePasswordless autoFocus type="sign-in" onSubmitValidation={termsValidation}>
|
||||
<TermsOfUse />
|
||||
</PhonePasswordless>
|
||||
);
|
||||
return <PhonePasswordless autoFocus type="sign-in" />;
|
||||
}
|
||||
|
||||
if (method === 'email') {
|
||||
return (
|
||||
<EmailPasswordless autoFocus type="sign-in" onSubmitValidation={termsValidation}>
|
||||
<TermsOfUse />
|
||||
</EmailPasswordless>
|
||||
);
|
||||
return <EmailPasswordless autoFocus type="sign-in" />;
|
||||
}
|
||||
|
||||
return <UsernameSignIn autoFocus />;
|
||||
}, [method, termsValidation]);
|
||||
}, [method]);
|
||||
|
||||
if (!['email', 'sms', 'username'].includes(method)) {
|
||||
return <ErrorPage />;
|
||||
|
|
|
@ -5,10 +5,6 @@
|
|||
@include _.flex-column(normal, normal);
|
||||
@include _.full-width;
|
||||
|
||||
.terms {
|
||||
margin-bottom: _.unit(4);
|
||||
}
|
||||
|
||||
.primarySignIn {
|
||||
margin-bottom: _.unit(5);
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ import CreateAccount from '@/containers/CreateAccount';
|
|||
import { EmailPasswordless, PhonePasswordless } from '@/containers/Passwordless';
|
||||
import SignInMethodsLink from '@/containers/SignInMethodsLink';
|
||||
import { PrimarySocialSignIn, SecondarySocialSignIn } from '@/containers/SocialSignIn';
|
||||
import TermsOfUse from '@/containers/TermsOfUse';
|
||||
import UsernameSignIn from '@/containers/UsernameSignIn';
|
||||
import { SignInMethod, LocalSignInMethod } from '@/types';
|
||||
|
||||
|
@ -44,10 +43,7 @@ export const PrimarySection = ({
|
|||
);
|
||||
case 'social':
|
||||
return socialConnectors.length > 0 ? (
|
||||
<>
|
||||
<TermsOfUse className={styles.terms} />
|
||||
<PrimarySocialSignIn className={styles.primarySocial} />
|
||||
</>
|
||||
<PrimarySocialSignIn className={styles.primarySocial} />
|
||||
) : null;
|
||||
default:
|
||||
return null;
|
||||
|
|
Loading…
Reference in a new issue