Skip to content

Commit

Permalink
feat(ui): add passwordless switch (#1976)
Browse files Browse the repository at this point in the history
add passwordless switch
  • Loading branch information
simeng-li authored Sep 23, 2022
1 parent 9a89c1a commit ddb0e47
Show file tree
Hide file tree
Showing 25 changed files with 423 additions and 165 deletions.
8 changes: 7 additions & 1 deletion packages/core/src/middleware/koa-spa-session-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/phrases-ui/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/phrases-ui/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/phrases-ui/src/locales/ko-kr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const translation = {
got_it: '알겠습니다',
sign_in_with: '{{name}} 로그인',
forgot_password: '비밀번호를 잊어버리셨나요?',
switch_to: 'Switch to {{method}}', // TODO: untranslated
},
description: {
email: '이메일',
Expand Down
1 change: 1 addition & 0 deletions packages/phrases-ui/src/locales/pt-pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/phrases-ui/src/locales/tr-tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions packages/phrases-ui/src/locales/zh-cn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const translation = {
got_it: '知道了',
sign_in_with: '通过 {{name}} 登录',
forgot_password: '忘记密码?',
switch_to: '切换到{{method}}',
},
description: {
email: '邮箱',
Expand Down
12 changes: 11 additions & 1 deletion packages/ui/src/containers/CreateAccount/index.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
73 changes: 38 additions & 35 deletions packages/ui/src/containers/CreateAccount/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()} />
Expand Down
58 changes: 50 additions & 8 deletions packages/ui/src/containers/Passwordless/EmailPasswordless.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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>
Expand All @@ -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) {
Expand All @@ -88,7 +97,33 @@ 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" 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>
Expand All @@ -102,6 +137,9 @@ describe('<EmailPasswordless/>', () => {
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
}

const termsButton = getByText('description.agree_with_terms');
fireEvent.click(termsButton);

const submitButton = getByText('action.continue');

act(() => {
Expand All @@ -113,7 +151,7 @@ describe('<EmailPasswordless/>', () => {
});
});

test('should call register method properly', async () => {
test('should call register method properly if type is register', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
Expand All @@ -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(() => {
Expand Down
55 changes: 34 additions & 21 deletions packages/ui/src/containers/Passwordless/EmailPasswordless.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,26 @@ 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 = {
type: UserFlow;
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
onSubmitValidation?: () => Promise<boolean>;
children?: React.ReactNode;
hasTerms?: boolean;
hasSwitch?: boolean;
};

type FieldState = {
Expand All @@ -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);

Expand Down Expand Up @@ -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(() => {
Expand All @@ -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" />
Expand Down
Loading

0 comments on commit ddb0e47

Please sign in to comment.