0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-13 21:30:30 -05:00

feat(ui): add passwordless switch (#1976)

add passwordless switch
This commit is contained in:
simeng-li 2022-09-23 10:33:00 +08:00 committed by GitHub
parent 9a89c1a200
commit ddb0e47950
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 447 additions and 189 deletions

View file

@ -7,7 +7,13 @@ import { appendPath } from '@/utils/url';
// Need To Align With UI // Need To Align With UI
export const sessionNotFoundPath = '/unknown-session'; 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< export default function koaSpaSessionGuard<
StateT, StateT,

View file

@ -26,6 +26,7 @@ const translation = {
got_it: 'Got it', got_it: 'Got it',
sign_in_with: 'Sign in with {{name}}', sign_in_with: 'Sign in with {{name}}',
forgot_password: 'Forgot Password?', forgot_password: 'Forgot Password?',
switch_to: 'Switch to {{method}}',
}, },
description: { description: {
email: 'email', email: 'email',

View file

@ -28,6 +28,7 @@ const translation = {
got_it: 'Compris', got_it: 'Compris',
sign_in_with: 'Connexion avec {{name}}', sign_in_with: 'Connexion avec {{name}}',
forgot_password: 'Mot de passe oublié ?', forgot_password: 'Mot de passe oublié ?',
switch_to: 'Passer au {{method}}',
}, },
description: { description: {
email: 'email', email: 'email',

View file

@ -28,6 +28,7 @@ const translation = {
got_it: '알겠습니다', got_it: '알겠습니다',
sign_in_with: '{{name}} 로그인', sign_in_with: '{{name}} 로그인',
forgot_password: '비밀번호를 잊어버리셨나요?', forgot_password: '비밀번호를 잊어버리셨나요?',
switch_to: 'Switch to {{method}}', // TODO: untranslated
}, },
description: { description: {
email: '이메일', email: '이메일',

View file

@ -28,6 +28,7 @@ const translation = {
got_it: 'Entendi', got_it: 'Entendi',
sign_in_with: 'Entrar com {{name}}', sign_in_with: 'Entrar com {{name}}',
forgot_password: 'Esqueceu a password?', forgot_password: 'Esqueceu a password?',
switch_to: 'Mudar para {{method}}',
}, },
description: { description: {
email: 'email', email: 'email',

View file

@ -28,6 +28,7 @@ const translation = {
got_it: 'Anladım', got_it: 'Anladım',
sign_in_with: '{{name}} ile giriş yap', sign_in_with: '{{name}} ile giriş yap',
forgot_password: 'Şifremi Unuttum?', forgot_password: 'Şifremi Unuttum?',
switch_to: 'Switch to {{method}}', // TODO: not translated
}, },
description: { description: {
email: 'e-posta adresi', email: 'e-posta adresi',

View file

@ -28,6 +28,7 @@ const translation = {
got_it: '知道了', got_it: '知道了',
sign_in_with: '通过 {{name}} 登录', sign_in_with: '通过 {{name}} 登录',
forgot_password: '忘记密码?', forgot_password: '忘记密码?',
switch_to: '切换到{{method}}',
}, },
description: { description: {
email: '邮箱', email: '邮箱',

View file

@ -11,7 +11,17 @@
margin-bottom: _.unit(4); margin-bottom: _.unit(4);
} }
.formFields {
margin-bottom: _.unit(8);
}
.terms { .terms {
margin: _.unit(8) 0 _.unit(4); margin-bottom: _.unit(4);
}
}
:global(body.desktop) {
.formFields {
margin-bottom: _.unit(2);
} }
} }

View file

@ -85,41 +85,44 @@ const CreateAccount = ({ className, autoFocus }: Props) => {
return ( return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}> <form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input <div className={styles.formFields}>
autoFocus={autoFocus} <Input
className={styles.inputField} autoFocus={autoFocus}
name="new-username" className={styles.inputField}
placeholder={t('input.username')} name="new-username"
{...fieldRegister('username', usernameValidation)} placeholder={t('input.username')}
onClear={() => { {...fieldRegister('username', usernameValidation)}
setFieldValue((state) => ({ ...state, username: '' })); onClear={() => {
}} setFieldValue((state) => ({ ...state, username: '' }));
/> }}
<Input />
className={styles.inputField} <Input
name="new-password" className={styles.inputField}
type="password" name="new-password"
autoComplete="new-password" type="password"
placeholder={t('input.password')} autoComplete="new-password"
{...fieldRegister('password', passwordValidation)} placeholder={t('input.password')}
onClear={() => { {...fieldRegister('password', passwordValidation)}
setFieldValue((state) => ({ ...state, password: '' })); onClear={() => {
}} setFieldValue((state) => ({ ...state, password: '' }));
/> }}
<Input />
className={styles.inputField} <Input
name="confirm-new-password" className={styles.inputField}
type="password" name="confirm-new-password"
autoComplete="new-password" type="password"
placeholder={t('input.confirm_password')} autoComplete="new-password"
{...fieldRegister('confirmPassword', (confirmPassword) => placeholder={t('input.confirm_password')}
confirmPasswordValidation(fieldValue.password, confirmPassword) {...fieldRegister('confirmPassword', (confirmPassword) =>
)} confirmPasswordValidation(fieldValue.password, confirmPassword)
errorStyling={false} )}
onClear={() => { errorStyling={false}
setFieldValue((state) => ({ ...state, confirmPassword: '' })); onClear={() => {
}} setFieldValue((state) => ({ ...state, confirmPassword: '' }));
/> }}
/>
</div>
<TermsOfUse className={styles.terms} /> <TermsOfUse className={styles.terms} />
<Button title="action.create" onClick={async () => onSubmitHandler()} /> <Button title="action.create" onClick={async () => onSubmitHandler()} />

View file

@ -5,7 +5,6 @@ import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { sendRegisterEmailPasscode } from '@/apis/register'; import { sendRegisterEmailPasscode } from '@/apis/register';
import { sendSignInEmailPasscode } from '@/apis/sign-in'; import { sendSignInEmailPasscode } from '@/apis/sign-in';
import TermsOfUse from '@/containers/TermsOfUse';
import EmailPasswordless from './EmailPasswordless'; import EmailPasswordless from './EmailPasswordless';
@ -31,15 +30,24 @@ describe('<EmailPasswordless/>', () => {
const { queryByText } = renderWithPageContext( const { queryByText } = renderWithPageContext(
<MemoryRouter> <MemoryRouter>
<SettingsProvider> <SettingsProvider>
<EmailPasswordless type="sign-in"> <EmailPasswordless type="sign-in" />
<TermsOfUse />
</EmailPasswordless>
</SettingsProvider> </SettingsProvider>
</MemoryRouter> </MemoryRouter>
); );
expect(queryByText('description.terms_of_use')).not.toBeNull(); 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', () => { test('required email with error message', () => {
const { queryByText, container, getByText } = renderWithPageContext( const { queryByText, container, getByText } = renderWithPageContext(
<MemoryRouter> <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( const { container, getByText } = renderWithPageContext(
<MemoryRouter> <MemoryRouter>
<SettingsProvider> <SettingsProvider>
<EmailPasswordless type="sign-in" onSubmitValidation={async () => false} /> <EmailPasswordless type="sign-in" />
</SettingsProvider> </SettingsProvider>
</MemoryRouter> </MemoryRouter>
); );
const emailInput = container.querySelector('input[name="email"]'); const emailInput = container.querySelector('input[name="email"]');
if (emailInput) { 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( const { container, getByText } = renderWithPageContext(
<MemoryRouter> <MemoryRouter>
<SettingsProvider> <SettingsProvider>
<EmailPasswordless type="sign-in" /> <EmailPasswordless type="sign-in" hasTerms={false} />
</SettingsProvider> </SettingsProvider>
</MemoryRouter> </MemoryRouter>
); );
const emailInput = container.querySelector('input[name="email"]'); const emailInput = container.querySelector('input[name="email"]');
if (emailInput) { 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( const { container, getByText } = renderWithPageContext(
<MemoryRouter> <MemoryRouter>
<SettingsProvider> <SettingsProvider>
@ -126,6 +164,10 @@ describe('<EmailPasswordless/>', () => {
if (emailInput) { if (emailInput) {
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } }); fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
} }
const termsButton = getByText('description.agree_with_terms');
fireEvent.click(termsButton);
const submitButton = getByText('action.continue'); const submitButton = getByText('action.continue');
act(() => { act(() => {

View file

@ -6,14 +6,17 @@ import { useNavigate } from 'react-router-dom';
import { getSendPasscodeApi } from '@/apis/utils'; import { getSendPasscodeApi } from '@/apis/utils';
import Button from '@/components/Button'; import Button from '@/components/Button';
import Input from '@/components/Input'; import Input from '@/components/Input';
import TermsOfUse from '@/containers/TermsOfUse';
import useApi, { ErrorHandlers } from '@/hooks/use-api'; import useApi, { ErrorHandlers } from '@/hooks/use-api';
import useForm from '@/hooks/use-form'; import useForm from '@/hooks/use-form';
import { PageContext } from '@/hooks/use-page-context'; import { PageContext } from '@/hooks/use-page-context';
import useTerms from '@/hooks/use-terms';
import { UserFlow, SearchParameters } from '@/types'; import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils'; import { getSearchParameters } from '@/utils';
import { emailValidation } from '@/utils/field-validations'; import { emailValidation } from '@/utils/field-validations';
import PasswordlessConfirmModal from './PasswordlessConfirmModal'; import PasswordlessConfirmModal from './PasswordlessConfirmModal';
import PasswordlessSwitch from './PasswordlessSwitch';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
type Props = { type Props = {
@ -21,8 +24,8 @@ type Props = {
className?: string; className?: string;
// eslint-disable-next-line react/boolean-prop-naming // eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean; autoFocus?: boolean;
onSubmitValidation?: () => Promise<boolean>; hasTerms?: boolean;
children?: React.ReactNode; hasSwitch?: boolean;
}; };
type FieldState = { type FieldState = {
@ -31,10 +34,18 @@ type FieldState = {
const defaultState: FieldState = { email: '' }; 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 { setToast } = useContext(PageContext);
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const { termsValidation } = useTerms();
const { fieldValue, setFieldValue, setFieldErrors, register, validateForm } = const { fieldValue, setFieldValue, setFieldErrors, register, validateForm } =
useForm(defaultState); useForm(defaultState);
@ -75,13 +86,13 @@ const EmailPasswordless = ({ type, autoFocus, onSubmitValidation, children, clas
return; return;
} }
if (onSubmitValidation && !(await onSubmitValidation())) { if (hasTerms && !(await termsValidation())) {
return; return;
} }
void asyncSendPasscode(fieldValue.email); void asyncSendPasscode(fieldValue.email);
}, },
[validateForm, onSubmitValidation, asyncSendPasscode, fieldValue.email] [validateForm, hasTerms, termsValidation, asyncSendPasscode, fieldValue.email]
); );
const onModalCloseHandler = useCallback(() => { const onModalCloseHandler = useCallback(() => {
@ -103,22 +114,24 @@ const EmailPasswordless = ({ type, autoFocus, onSubmitValidation, children, clas
return ( return (
<> <>
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}> <form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input <div className={styles.formFields}>
type="email" <Input
name="email" type="email"
autoComplete="email" name="email"
inputMode="email" autoComplete="email"
placeholder={t('input.email')} inputMode="email"
autoFocus={autoFocus} placeholder={t('input.email')}
className={styles.inputField} autoFocus={autoFocus}
{...register('email', emailValidation)} className={styles.inputField}
onClear={() => { {...register('email', emailValidation)}
setFieldValue((state) => ({ ...state, email: '' })); onClear={() => {
}} setFieldValue((state) => ({ ...state, email: '' }));
/> }}
/>
{children && <div className={styles.childWrapper}>{children}</div>} {hasSwitch && <PasswordlessSwitch target="sms" className={styles.switch} />}
</div>
{hasTerms && <TermsOfUse className={styles.terms} />}
<Button title="action.continue" onClick={async () => onSubmitHandler()} /> <Button title="action.continue" onClick={async () => onSubmitHandler()} />
<input hidden type="submit" /> <input hidden type="submit" />

View file

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

View file

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

View file

@ -5,7 +5,6 @@ import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { sendRegisterSmsPasscode } from '@/apis/register'; import { sendRegisterSmsPasscode } from '@/apis/register';
import { sendSignInSmsPasscode } from '@/apis/sign-in'; import { sendSignInSmsPasscode } from '@/apis/sign-in';
import TermsOfUse from '@/containers/TermsOfUse';
import { getDefaultCountryCallingCode } from '@/utils/country-code'; import { getDefaultCountryCallingCode } from '@/utils/country-code';
import PhonePasswordless from './PhonePasswordless'; import PhonePasswordless from './PhonePasswordless';
@ -38,15 +37,24 @@ describe('<PhonePasswordless/>', () => {
const { queryByText } = renderWithPageContext( const { queryByText } = renderWithPageContext(
<MemoryRouter> <MemoryRouter>
<SettingsProvider> <SettingsProvider>
<PhonePasswordless type="sign-in"> <PhonePasswordless type="sign-in" />
<TermsOfUse />
</PhonePasswordless>
</SettingsProvider> </SettingsProvider>
</MemoryRouter> </MemoryRouter>
); );
expect(queryByText('description.terms_of_use')).not.toBeNull(); 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', () => { test('required phone with error message', () => {
const { queryByText, container, getByText } = renderWithPageContext( const { queryByText, container, getByText } = renderWithPageContext(
<MemoryRouter> <MemoryRouter>
@ -70,31 +78,7 @@ describe('<PhonePasswordless/>', () => {
} }
}); });
test('should block if extra validation failed', async () => { test('should blocked by terms validation with terms settings enabled', 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 () => {
const { container, getByText } = renderWithPageContext( const { container, getByText } = renderWithPageContext(
<MemoryRouter> <MemoryRouter>
<SettingsProvider> <SettingsProvider>
@ -107,6 +91,32 @@ describe('<PhonePasswordless/>', () => {
if (phoneInput) { if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phoneNumber } }); 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'); const submitButton = getByText('action.continue');
act(() => { 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( const { container, getByText } = renderWithPageContext(
<MemoryRouter> <MemoryRouter>
<SettingsProvider> <SettingsProvider>
@ -132,6 +170,9 @@ describe('<PhonePasswordless/>', () => {
fireEvent.change(phoneInput, { target: { value: phoneNumber } }); fireEvent.change(phoneInput, { target: { value: phoneNumber } });
} }
const termsButton = getByText('description.agree_with_terms');
fireEvent.click(termsButton);
const submitButton = getByText('action.continue'); const submitButton = getByText('action.continue');
act(() => { act(() => {

View file

@ -6,14 +6,17 @@ import { useNavigate } from 'react-router-dom';
import { getSendPasscodeApi } from '@/apis/utils'; import { getSendPasscodeApi } from '@/apis/utils';
import Button from '@/components/Button'; import Button from '@/components/Button';
import { PhoneInput } from '@/components/Input'; import { PhoneInput } from '@/components/Input';
import TermsOfUse from '@/containers/TermsOfUse';
import useApi, { ErrorHandlers } from '@/hooks/use-api'; import useApi, { ErrorHandlers } from '@/hooks/use-api';
import useForm from '@/hooks/use-form'; import useForm from '@/hooks/use-form';
import { PageContext } from '@/hooks/use-page-context'; import { PageContext } from '@/hooks/use-page-context';
import usePhoneNumber from '@/hooks/use-phone-number'; import usePhoneNumber from '@/hooks/use-phone-number';
import useTerms from '@/hooks/use-terms';
import { UserFlow, SearchParameters } from '@/types'; import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils'; import { getSearchParameters } from '@/utils';
import PasswordlessConfirmModal from './PasswordlessConfirmModal'; import PasswordlessConfirmModal from './PasswordlessConfirmModal';
import PasswordlessSwitch from './PasswordlessSwitch';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
type Props = { type Props = {
@ -21,8 +24,8 @@ type Props = {
className?: string; className?: string;
// eslint-disable-next-line react/boolean-prop-naming // eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean; autoFocus?: boolean;
onSubmitValidation?: () => Promise<boolean>; hasTerms?: boolean;
children?: React.ReactNode; hasSwitch?: boolean;
}; };
type FieldState = { type FieldState = {
@ -31,9 +34,17 @@ type FieldState = {
const defaultState: FieldState = { phone: '' }; 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 { setToast } = useContext(PageContext);
const { t } = useTranslation(); const { t } = useTranslation();
const { termsValidation } = useTerms();
const { countryList, phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber(); const { countryList, phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
const navigate = useNavigate(); const navigate = useNavigate();
const { fieldValue, setFieldValue, setFieldErrors, validateForm, register } = const { fieldValue, setFieldValue, setFieldErrors, validateForm, register } =
@ -85,13 +96,13 @@ const PhonePasswordless = ({ type, autoFocus, onSubmitValidation, children, clas
return; return;
} }
if (onSubmitValidation && !(await onSubmitValidation())) { if (hasTerms && !(await termsValidation())) {
return; return;
} }
void asyncSendPasscode(fieldValue.phone); void asyncSendPasscode(fieldValue.phone);
}, },
[validateForm, onSubmitValidation, asyncSendPasscode, fieldValue.phone] [validateForm, hasTerms, termsValidation, asyncSendPasscode, fieldValue.phone]
); );
const onModalCloseHandler = useCallback(() => { const onModalCloseHandler = useCallback(() => {
@ -118,20 +129,24 @@ const PhonePasswordless = ({ type, autoFocus, onSubmitValidation, children, clas
return ( return (
<> <>
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}> <form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<PhoneInput <div className={styles.formFields}>
name="phone" <PhoneInput
placeholder={t('input.phone_number')} name="phone"
className={styles.inputField} placeholder={t('input.phone_number')}
countryCallingCode={phoneNumber.countryCallingCode} className={styles.inputField}
nationalNumber={phoneNumber.nationalNumber} countryCallingCode={phoneNumber.countryCallingCode}
autoFocus={autoFocus} nationalNumber={phoneNumber.nationalNumber}
countryList={countryList} autoFocus={autoFocus}
{...register('phone', phoneNumberValidation)} countryList={countryList}
onChange={(data) => { {...register('phone', phoneNumberValidation)}
setPhoneNumber((previous) => ({ ...previous, ...data })); onChange={(data) => {
}} setPhoneNumber((previous) => ({ ...previous, ...data }));
/> }}
{children && <div className={styles.childWrapper}>{children}</div>} />
{hasSwitch && <PasswordlessSwitch target="email" className={styles.switch} />}
</div>
{hasTerms && <TermsOfUse className={styles.terms} />}
<Button title="action.continue" onClick={async () => onSubmitHandler()} /> <Button title="action.continue" onClick={async () => onSubmitHandler()} />

View file

@ -7,11 +7,24 @@
width: 100%; width: 100%;
} }
.inputField { .inputField,
margin-bottom: _.unit(12); .terms,
} .switch {
.childWrapper {
margin-bottom: _.unit(4); margin-bottom: _.unit(4);
} }
.switch {
margin-top: _.unit(-1);
display: block;
}
.formFields {
margin-bottom: _.unit(8);
}
}
:global(body.desktop) {
.formFields {
margin-bottom: _.unit(2);
}
} }

View file

@ -0,0 +1,5 @@
@use '@/scss/underscore' as _;
.terms {
margin-bottom: _.unit(4);
}

View file

@ -1,7 +1,9 @@
import TermsOfUse from '@/containers/TermsOfUse';
import useNativeMessageListener from '@/hooks/use-native-message-listener'; import useNativeMessageListener from '@/hooks/use-native-message-listener';
import useSocial from '@/hooks/use-social'; import useSocial from '@/hooks/use-social';
import SocialSignInList from '../SocialSignInList'; import SocialSignInList from '../SocialSignInList';
import * as styles from './index.module.scss';
export const defaultSize = 3; export const defaultSize = 3;
@ -13,7 +15,12 @@ const PrimarySocialSignIn = ({ className }: Props) => {
const { socialConnectors } = useSocial(); const { socialConnectors } = useSocial();
useNativeMessageListener(); useNativeMessageListener();
return <SocialSignInList className={className} socialConnectors={socialConnectors} />; return (
<>
<TermsOfUse className={styles.terms} />
<SocialSignInList className={className} socialConnectors={socialConnectors} />
</>
);
}; };
export default PrimarySocialSignIn; export default PrimarySocialSignIn;

View file

@ -11,16 +11,22 @@
margin-bottom: _.unit(4); margin-bottom: _.unit(4);
} }
.formFields {
margin-bottom: _.unit(8);
}
.terms { .terms {
margin: _.unit(8) 0 _.unit(4); margin-bottom: _.unit(4);
} }
.formErrors { .formErrors {
margin-top: _.unit(-2); margin-top: _.unit(-2);
margin-bottom: _.unit(4); margin-bottom: _.unit(4);
}
+ .terms { }
margin-top: _.unit(4);
} :global(body.desktop) {
.formFields {
margin-bottom: _.unit(2);
} }
} }

View file

@ -91,27 +91,30 @@ const UsernameSignIn = ({ className, autoFocus }: Props) => {
return ( return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}> <form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input <div className={styles.formFields}>
autoFocus={autoFocus} <Input
className={styles.inputField} autoFocus={autoFocus}
name="username" className={styles.inputField}
autoComplete="username" name="username"
placeholder={t('input.username')} autoComplete="username"
{...register('username', (value) => requiredValidation('username', value))} placeholder={t('input.username')}
onClear={() => { {...register('username', (value) => requiredValidation('username', value))}
setFieldValue((state) => ({ ...state, username: '' })); onClear={() => {
}} setFieldValue((state) => ({ ...state, username: '' }));
/> }}
<PasswordInput />
className={styles.inputField} <PasswordInput
name="password" className={styles.inputField}
autoComplete="current-password" name="password"
placeholder={t('input.password')} autoComplete="current-password"
{...register('password', (value) => requiredValidation('password', value))} placeholder={t('input.password')}
/> {...register('password', (value) => requiredValidation('password', value))}
{formErrorMessage && ( />
<ErrorMessage className={styles.formErrors}>{formErrorMessage}</ErrorMessage> {formErrorMessage && (
)} <ErrorMessage className={styles.formErrors}>{formErrorMessage}</ErrorMessage>
)}
</div>
<TermsOfUse className={styles.terms} /> <TermsOfUse className={styles.terms} />
<Button title="action.sign_in" onClick={async () => onSubmitHandler()} /> <Button title="action.sign_in" onClick={async () => onSubmitHandler()} />

View file

@ -14,16 +14,15 @@ type Props = {
const ForgotPassword = () => { const ForgotPassword = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { method = '' } = useParams<Props>(); const { method = '' } = useParams<Props>();
const forgotPasswordForm = useMemo(() => { const forgotPasswordForm = useMemo(() => {
if (method === 'sms') { if (method === 'sms') {
return <PhonePasswordless autoFocus type="reset-password" />; return <PhonePasswordless autoFocus hasSwitch type="reset-password" hasTerms={false} />;
} }
if (method === 'email') { if (method === 'email') {
return <EmailPasswordless autoFocus type="reset-password" />; return <EmailPasswordless autoFocus hasSwitch type="reset-password" hasTerms={false} />;
} }
}, [method]); }, [method]);

View file

@ -5,8 +5,6 @@ import { useParams } from 'react-router-dom';
import NavBar from '@/components/NavBar'; import NavBar from '@/components/NavBar';
import CreateAccount from '@/containers/CreateAccount'; import CreateAccount from '@/containers/CreateAccount';
import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless'; import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless';
import TermsOfUse from '@/containers/TermsOfUse';
import useTerms from '@/hooks/use-terms';
import ErrorPage from '@/pages/ErrorPage'; import ErrorPage from '@/pages/ErrorPage';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
@ -19,27 +17,17 @@ const Register = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { method = 'username' } = useParams<Parameters>(); const { method = 'username' } = useParams<Parameters>();
const { termsValidation } = useTerms();
const registerForm = useMemo(() => { const registerForm = useMemo(() => {
if (method === 'sms') { if (method === 'sms') {
return ( return <PhonePasswordless autoFocus type="register" />;
<PhonePasswordless autoFocus type="register" onSubmitValidation={termsValidation}>
<TermsOfUse />
</PhonePasswordless>
);
} }
if (method === 'email') { if (method === 'email') {
return ( return <EmailPasswordless autoFocus type="register" />;
<EmailPasswordless autoFocus type="register" onSubmitValidation={termsValidation}>
<TermsOfUse />
</EmailPasswordless>
);
} }
return <CreateAccount autoFocus />; return <CreateAccount autoFocus />;
}, [method, termsValidation]); }, [method]);
if (!['email', 'sms', 'username'].includes(method)) { if (!['email', 'sms', 'username'].includes(method)) {
return <ErrorPage />; return <ErrorPage />;

View file

@ -4,9 +4,7 @@ import { useParams } from 'react-router-dom';
import NavBar from '@/components/NavBar'; import NavBar from '@/components/NavBar';
import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless'; import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless';
import TermsOfUse from '@/containers/TermsOfUse';
import UsernameSignIn from '@/containers/UsernameSignIn'; import UsernameSignIn from '@/containers/UsernameSignIn';
import useTerms from '@/hooks/use-terms';
import ErrorPage from '@/pages/ErrorPage'; import ErrorPage from '@/pages/ErrorPage';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
@ -19,27 +17,17 @@ const SecondarySignIn = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { method = 'username' } = useParams<Props>(); const { method = 'username' } = useParams<Props>();
const { termsValidation } = useTerms();
const signInForm = useMemo(() => { const signInForm = useMemo(() => {
if (method === 'sms') { if (method === 'sms') {
return ( return <PhonePasswordless autoFocus type="sign-in" />;
<PhonePasswordless autoFocus type="sign-in" onSubmitValidation={termsValidation}>
<TermsOfUse />
</PhonePasswordless>
);
} }
if (method === 'email') { if (method === 'email') {
return ( return <EmailPasswordless autoFocus type="sign-in" />;
<EmailPasswordless autoFocus type="sign-in" onSubmitValidation={termsValidation}>
<TermsOfUse />
</EmailPasswordless>
);
} }
return <UsernameSignIn autoFocus />; return <UsernameSignIn autoFocus />;
}, [method, termsValidation]); }, [method]);
if (!['email', 'sms', 'username'].includes(method)) { if (!['email', 'sms', 'username'].includes(method)) {
return <ErrorPage />; return <ErrorPage />;

View file

@ -5,10 +5,6 @@
@include _.flex-column(normal, normal); @include _.flex-column(normal, normal);
@include _.full-width; @include _.full-width;
.terms {
margin-bottom: _.unit(4);
}
.primarySignIn { .primarySignIn {
margin-bottom: _.unit(5); margin-bottom: _.unit(5);
} }

View file

@ -6,7 +6,6 @@ import CreateAccount from '@/containers/CreateAccount';
import { EmailPasswordless, PhonePasswordless } from '@/containers/Passwordless'; import { EmailPasswordless, PhonePasswordless } from '@/containers/Passwordless';
import SignInMethodsLink from '@/containers/SignInMethodsLink'; import SignInMethodsLink from '@/containers/SignInMethodsLink';
import { PrimarySocialSignIn, SecondarySocialSignIn } from '@/containers/SocialSignIn'; import { PrimarySocialSignIn, SecondarySocialSignIn } from '@/containers/SocialSignIn';
import TermsOfUse from '@/containers/TermsOfUse';
import UsernameSignIn from '@/containers/UsernameSignIn'; import UsernameSignIn from '@/containers/UsernameSignIn';
import { SignInMethod, LocalSignInMethod } from '@/types'; import { SignInMethod, LocalSignInMethod } from '@/types';
@ -44,10 +43,7 @@ export const PrimarySection = ({
); );
case 'social': case 'social':
return socialConnectors.length > 0 ? ( return socialConnectors.length > 0 ? (
<> <PrimarySocialSignIn className={styles.primarySocial} />
<TermsOfUse className={styles.terms} />
<PrimarySocialSignIn className={styles.primarySocial} />
</>
) : null; ) : null;
default: default:
return null; return null;