mirror of
https://github.com/logto-io/logto.git
synced 2025-03-03 22:15:32 -05:00
feat(ui): add passwordless container (#469)
* feat(ui): add passwordless container add email & phone passwordless container add secondary sign-in page * feat(ui): uncomment code after rebase latest code uncomment code after rebase latest code
This commit is contained in:
parent
67939ea2bb
commit
6a61ea29f5
15 changed files with 632 additions and 211 deletions
|
@ -9,13 +9,17 @@ const translation = {
|
|||
cancel: 'Cancel',
|
||||
done: 'Done',
|
||||
search: 'Search',
|
||||
continue: 'Continue',
|
||||
},
|
||||
sign_in: {
|
||||
sign_in: 'Sign In',
|
||||
action: 'Sign In',
|
||||
loading: 'Signing in...',
|
||||
error: 'Username or password is invalid.',
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
email: 'Email',
|
||||
phone_number: 'Phone Number',
|
||||
terms_of_use: 'Terms of Use',
|
||||
terms_agreement_prefix: 'I agree with ',
|
||||
continue_with: 'Continue With',
|
||||
|
|
|
@ -11,13 +11,17 @@ const translation = {
|
|||
cancel: '取消',
|
||||
done: '完成',
|
||||
search: '搜索',
|
||||
continue: '继续',
|
||||
},
|
||||
sign_in: {
|
||||
sign_in: '登录',
|
||||
action: '登录',
|
||||
loading: '登录中...',
|
||||
error: '用户名或密码错误。',
|
||||
username: '用户名',
|
||||
password: '密码',
|
||||
email: '邮箱',
|
||||
phone_number: '手机',
|
||||
terms_of_use: '用户协议',
|
||||
terms_agreement_prefix: '登录即表明您已经同意',
|
||||
continue_with: '更多',
|
||||
|
|
|
@ -6,6 +6,7 @@ import useTheme from './hooks/use-theme';
|
|||
import initI18n from './i18n/init';
|
||||
import Consent from './pages/Consent';
|
||||
import Register from './pages/Register';
|
||||
import SecondarySignIn from './pages/SecondarySignIn';
|
||||
import SignIn from './pages/SignIn';
|
||||
import './scss/normalized.scss';
|
||||
|
||||
|
@ -21,6 +22,7 @@ const App = () => {
|
|||
<Route exact path="/sign-in" component={SignIn} />
|
||||
<Route exact path="/sign-in/consent" component={Consent} />
|
||||
<Route exact path="/register" component={Register} />
|
||||
<Route exact path="/sign-in-secondary" component={SecondarySignIn} />
|
||||
</Switch>
|
||||
</BrowserRouter>
|
||||
</AppContent>
|
||||
|
|
|
@ -3,6 +3,7 @@ import React, { useState, useMemo, useRef } from 'react';
|
|||
|
||||
import { CountryCallingCode, CountryMetaData } from '@/hooks/use-phone-number';
|
||||
|
||||
import ErrorMessage, { ErrorType } from '../ErrorMessage';
|
||||
import { ClearIcon, DownArrowIcon } from '../Icons';
|
||||
import * as styles from './index.module.scss';
|
||||
import * as phoneInputStyles from './phoneInput.module.scss';
|
||||
|
@ -12,26 +13,24 @@ type Value = { countryCallingCode?: CountryCallingCode; nationalNumber?: string
|
|||
export type Props = {
|
||||
name: string;
|
||||
autoComplete?: AutoCompleteType;
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
countryCallingCode?: CountryCallingCode;
|
||||
nationalNumber: string;
|
||||
countryList?: CountryMetaData[];
|
||||
hasError?: boolean;
|
||||
error?: ErrorType;
|
||||
onChange: (value: Value) => void;
|
||||
};
|
||||
|
||||
const PhoneInput = ({
|
||||
name,
|
||||
autoComplete,
|
||||
isDisabled,
|
||||
className,
|
||||
placeholder,
|
||||
countryCallingCode,
|
||||
nationalNumber,
|
||||
countryList,
|
||||
hasError = false,
|
||||
error,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const [onFocus, setOnFocus] = useState(false);
|
||||
|
@ -69,43 +68,38 @@ const PhoneInput = ({
|
|||
}, [countryCallingCode, countryList, onChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.wrapper,
|
||||
onFocus && styles.focus,
|
||||
hasError && styles.error,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{countrySelector}
|
||||
<input
|
||||
ref={inputReference}
|
||||
name={name}
|
||||
disabled={isDisabled}
|
||||
placeholder={placeholder}
|
||||
value={nationalNumber}
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoComplete={autoComplete}
|
||||
onFocus={() => {
|
||||
setOnFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setOnFocus(false);
|
||||
}}
|
||||
onChange={({ target: { value } }) => {
|
||||
onChange({ nationalNumber: value });
|
||||
}}
|
||||
/>
|
||||
{nationalNumber && onFocus && (
|
||||
<ClearIcon
|
||||
className={styles.actionButton}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onChange({ nationalNumber: '' });
|
||||
<div className={className}>
|
||||
<div className={classNames(styles.wrapper, onFocus && styles.focus, error && styles.error)}>
|
||||
{countrySelector}
|
||||
<input
|
||||
ref={inputReference}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={nationalNumber}
|
||||
type="tel"
|
||||
inputMode="numeric"
|
||||
autoComplete={autoComplete}
|
||||
onFocus={() => {
|
||||
setOnFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setOnFocus(false);
|
||||
}}
|
||||
onChange={({ target: { value } }) => {
|
||||
onChange({ nationalNumber: value.replaceAll(/\D/g, '') });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{nationalNumber && onFocus && (
|
||||
<ClearIcon
|
||||
className={styles.actionButton}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onChange({ nationalNumber: '' });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{error && <ErrorMessage error={error} className={styles.errorMessage} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -0,0 +1,89 @@
|
|||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { sendEmailPasscode as sendRegisterEmailPasscode } from '@/apis/register';
|
||||
import { sendEmailPasscode as sendSignInEmailPasscode } from '@/apis/sign-in';
|
||||
|
||||
import EmailPasswordless from './EmailPasswordless';
|
||||
|
||||
jest.mock('@/apis/sign-in', () => ({ sendEmailPasscode: jest.fn(async () => Promise.resolve()) }));
|
||||
jest.mock('@/apis/register', () => ({ sendEmailPasscode: jest.fn(async () => Promise.resolve()) }));
|
||||
|
||||
describe('<EmailPasswordless/>', () => {
|
||||
test('render', () => {
|
||||
const { queryByText, container } = render(<EmailPasswordless type="sign-in" />);
|
||||
expect(container.querySelector('input[name="email"]')).not.toBeNull();
|
||||
expect(queryByText('general.continue')).not.toBeNull();
|
||||
expect(queryByText('sign_in.terms_of_use')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('required email with error message', () => {
|
||||
const { queryByText, container, getByText } = render(<EmailPasswordless type="sign-in" />);
|
||||
const submitButton = getByText('general.continue');
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
expect(queryByText('errors:user.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('errors:user.invalid_email')).not.toBeNull();
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
|
||||
expect(queryByText('errors:user.invalid_email')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('required terms of agreement with error message', () => {
|
||||
const { queryByText, container, getByText } = render(<EmailPasswordless type="sign-in" />);
|
||||
const submitButton = getByText('general.continue');
|
||||
const emailInput = container.querySelector('input[name="email"]');
|
||||
|
||||
if (emailInput) {
|
||||
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
|
||||
}
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
expect(queryByText('errors:form.terms_required')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('signin method properly', async () => {
|
||||
const { container, getByText } = render(<EmailPasswordless type="sign-in" />);
|
||||
const emailInput = container.querySelector('input[name="email"]');
|
||||
|
||||
if (emailInput) {
|
||||
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
|
||||
}
|
||||
const termsButton = getByText('sign_in.terms_agreement_prefix');
|
||||
fireEvent.click(termsButton);
|
||||
|
||||
const submitButton = getByText('general.continue');
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(sendSignInEmailPasscode).toBeCalledWith('foo@logto.io');
|
||||
});
|
||||
|
||||
test('register method properly', async () => {
|
||||
const { container, getByText } = render(<EmailPasswordless type="register" />);
|
||||
const emailInput = container.querySelector('input[name="email"]');
|
||||
|
||||
if (emailInput) {
|
||||
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
|
||||
}
|
||||
const termsButton = getByText('sign_in.terms_agreement_prefix');
|
||||
fireEvent.click(termsButton);
|
||||
|
||||
const submitButton = getByText('general.continue');
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(sendRegisterEmailPasscode).toBeCalledWith('foo@logto.io');
|
||||
});
|
||||
});
|
157
packages/ui/src/containers/Passwordless/EmailPasswordless.tsx
Normal file
157
packages/ui/src/containers/Passwordless/EmailPasswordless.tsx
Normal file
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* TODO:
|
||||
* 1. API redesign handle api error and loading status globally in PageContext
|
||||
* 2. Input field validation, should move the validation rule to the input field scope
|
||||
* 4. Read terms of use settings from SignInExperience Settings
|
||||
*/
|
||||
import { LogtoErrorI18nKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import React, { useState, useCallback, useMemo, useEffect, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { sendEmailPasscode as sendRegisterEmailPasscode } from '@/apis/register';
|
||||
import { sendEmailPasscode as sendSignInEmailPasscode } from '@/apis/sign-in';
|
||||
import Button from '@/components/Button';
|
||||
import { ErrorType } from '@/components/ErrorMessage';
|
||||
import Input from '@/components/Input';
|
||||
import TermsOfUse from '@/components/TermsOfUse';
|
||||
import PageContext from '@/hooks/page-context';
|
||||
import useApi from '@/hooks/use-api';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
type: 'sign-in' | 'register';
|
||||
};
|
||||
|
||||
type FieldState = {
|
||||
email: string;
|
||||
termsAgreement: boolean;
|
||||
};
|
||||
|
||||
type ErrorState = {
|
||||
[key in keyof FieldState]?: ErrorType;
|
||||
};
|
||||
|
||||
type FieldValidations = {
|
||||
[key in keyof FieldState]: (state: FieldState) => ErrorType | undefined;
|
||||
};
|
||||
|
||||
const defaultState: FieldState = { email: '', termsAgreement: false };
|
||||
|
||||
const emailRegEx = /^\S+@\S+\.\S+$/;
|
||||
|
||||
const EmailPasswordless = ({ type }: Props) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [fieldState, setFieldState] = useState<FieldState>(defaultState);
|
||||
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
|
||||
const { setToast } = useContext(PageContext);
|
||||
|
||||
const sendPasscode = type === 'sign-in' ? sendSignInEmailPasscode : sendRegisterEmailPasscode;
|
||||
|
||||
const { loading, error, result, run: asyncSendPasscode } = useApi(sendPasscode);
|
||||
|
||||
const validations = useMemo<FieldValidations>(
|
||||
() => ({
|
||||
email: ({ email }) => {
|
||||
if (!emailRegEx.test(email)) {
|
||||
return 'user.invalid_email';
|
||||
}
|
||||
},
|
||||
termsAgreement: ({ termsAgreement }) => {
|
||||
if (!termsAgreement) {
|
||||
return 'form.terms_required';
|
||||
}
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const onSubmitHandler = useCallback(() => {
|
||||
// Should be removed after api redesign
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const emailError = validations.email(fieldState);
|
||||
|
||||
if (emailError) {
|
||||
setFieldErrors((previous) => ({ ...previous, email: emailError }));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const termsAgreementError = validations.termsAgreement(fieldState);
|
||||
|
||||
if (termsAgreementError) {
|
||||
setFieldErrors((previous) => ({ ...previous, termsAgreement: termsAgreementError }));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
void asyncSendPasscode(fieldState.email);
|
||||
}, [loading, validations, fieldState, asyncSendPasscode]);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: navigate to the passcode page
|
||||
console.log(result);
|
||||
}, [result]);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear errors
|
||||
for (const key of Object.keys(fieldState) as [keyof FieldState]) {
|
||||
if (fieldState[key]) {
|
||||
setFieldErrors((previous) => {
|
||||
if (!previous[key]) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return { ...previous, [key]: validations[key](fieldState) };
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [fieldState, validations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setToast(i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`));
|
||||
}
|
||||
}, [error, i18n, setToast]);
|
||||
|
||||
return (
|
||||
<form className={styles.form}>
|
||||
<Input
|
||||
className={classNames(styles.inputField, fieldErrors.email && styles.withError)}
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
placeholder={t('sign_in.email')}
|
||||
value={fieldState.email}
|
||||
error={fieldErrors.email}
|
||||
onChange={({ target }) => {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const { value } = target;
|
||||
setFieldState((state) => ({ ...state, email: value }));
|
||||
}
|
||||
}}
|
||||
onClear={() => {
|
||||
setFieldState((state) => ({ ...state, email: '' }));
|
||||
}}
|
||||
/>
|
||||
|
||||
<TermsOfUse
|
||||
name="termsAgreement"
|
||||
className={classNames(styles.terms, fieldErrors.termsAgreement && styles.withError)}
|
||||
termsOfUse={{ enabled: true, contentUrl: '/' }}
|
||||
isChecked={fieldState.termsAgreement}
|
||||
error={fieldErrors.termsAgreement}
|
||||
onChange={(checked) => {
|
||||
setFieldState((state) => ({ ...state, termsAgreement: checked }));
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button onClick={onSubmitHandler}>{t('general.continue')}</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmailPasswordless;
|
|
@ -0,0 +1,92 @@
|
|||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { sendPhonePasscode as sendRegisterPhonePasscode } from '@/apis/register';
|
||||
import { sendPhonePasscode as sendSignInPhonePasscode } from '@/apis/sign-in';
|
||||
import { defaultCountryCallingCode } from '@/hooks/use-phone-number';
|
||||
|
||||
import PhonePasswordless from './PhonePasswordless';
|
||||
|
||||
jest.mock('@/apis/sign-in', () => ({ sendPhonePasscode: jest.fn(async () => Promise.resolve()) }));
|
||||
jest.mock('@/apis/register', () => ({ sendPhonePasscode: jest.fn(async () => Promise.resolve()) }));
|
||||
|
||||
describe('<PhonePasswordless/>', () => {
|
||||
const phoneNumber = '18888888888';
|
||||
|
||||
test('render', () => {
|
||||
const { queryByText, container } = render(<PhonePasswordless type="sign-in" />);
|
||||
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
|
||||
expect(queryByText('general.continue')).not.toBeNull();
|
||||
expect(queryByText('sign_in.terms_of_use')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('required phone with error message', () => {
|
||||
const { queryByText, container, getByText } = render(<PhonePasswordless type="sign-in" />);
|
||||
const submitButton = getByText('general.continue');
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
expect(queryByText('errors:user.invalid_phone')).not.toBeNull();
|
||||
expect(sendSignInPhonePasscode).not.toBeCalled();
|
||||
|
||||
const phoneInput = container.querySelector('input[name="phone"]');
|
||||
|
||||
if (phoneInput) {
|
||||
fireEvent.change(phoneInput, { target: { value: '1113' } });
|
||||
expect(queryByText('errors:user.invalid_phone')).not.toBeNull();
|
||||
|
||||
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
|
||||
expect(queryByText('errors:user.invalid_phone')).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('required terms of agreement with error message', () => {
|
||||
const { queryByText, container, getByText } = render(<PhonePasswordless type="sign-in" />);
|
||||
const submitButton = getByText('general.continue');
|
||||
const phoneInput = container.querySelector('input[name="phone"]');
|
||||
|
||||
if (phoneInput) {
|
||||
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
|
||||
}
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
expect(queryByText('errors:form.terms_required')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('signin method properly', async () => {
|
||||
const { container, getByText } = render(<PhonePasswordless type="sign-in" />);
|
||||
const phoneInput = container.querySelector('input[name="phone"]');
|
||||
|
||||
if (phoneInput) {
|
||||
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
|
||||
}
|
||||
const termsButton = getByText('sign_in.terms_agreement_prefix');
|
||||
fireEvent.click(termsButton);
|
||||
|
||||
const submitButton = getByText('general.continue');
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(sendSignInPhonePasscode).toBeCalledWith(`${defaultCountryCallingCode}${phoneNumber}`);
|
||||
});
|
||||
|
||||
test('register method properly', async () => {
|
||||
const { container, getByText } = render(<PhonePasswordless type="register" />);
|
||||
const phoneInput = container.querySelector('input[name="phone"]');
|
||||
|
||||
if (phoneInput) {
|
||||
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
|
||||
}
|
||||
const termsButton = getByText('sign_in.terms_agreement_prefix');
|
||||
fireEvent.click(termsButton);
|
||||
|
||||
const submitButton = getByText('general.continue');
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(sendRegisterPhonePasscode).toBeCalledWith(`${defaultCountryCallingCode}${phoneNumber}`);
|
||||
});
|
||||
});
|
159
packages/ui/src/containers/Passwordless/PhonePasswordless.tsx
Normal file
159
packages/ui/src/containers/Passwordless/PhonePasswordless.tsx
Normal file
|
@ -0,0 +1,159 @@
|
|||
/**
|
||||
* TODO:
|
||||
* 1. API redesign handle api error and loading status globally in PageContext
|
||||
* 2. Input field validation, should move the validation rule to the input field scope
|
||||
* 4. Read terms of use settings from SignInExperience Settings
|
||||
*/
|
||||
import { LogtoErrorI18nKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import React, { useState, useCallback, useMemo, useEffect, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { sendPhonePasscode as sendRegisterPhonePasscode } from '@/apis/register';
|
||||
import { sendPhonePasscode as sendSignInPhonePasscode } from '@/apis/sign-in';
|
||||
import Button from '@/components/Button';
|
||||
import { ErrorType } from '@/components/ErrorMessage';
|
||||
import PhoneInput from '@/components/Input/PhoneInput';
|
||||
import TermsOfUse from '@/components/TermsOfUse';
|
||||
import PageContext from '@/hooks/page-context';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import usePhoneNumber, { countryList } from '@/hooks/use-phone-number';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type Props = {
|
||||
type: 'sign-in' | 'register';
|
||||
};
|
||||
|
||||
type FieldState = {
|
||||
phone: string;
|
||||
termsAgreement: boolean;
|
||||
};
|
||||
|
||||
type ErrorState = {
|
||||
[key in keyof FieldState]?: ErrorType;
|
||||
};
|
||||
|
||||
type FieldValidations = {
|
||||
[key in keyof FieldState]: (state: FieldState) => ErrorType | undefined;
|
||||
};
|
||||
|
||||
const defaultState: FieldState = { phone: '', termsAgreement: false };
|
||||
|
||||
const PhonePasswordless = ({ type }: Props) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [fieldState, setFieldState] = useState<FieldState>(defaultState);
|
||||
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
|
||||
const { setToast } = useContext(PageContext);
|
||||
|
||||
const { phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
|
||||
|
||||
const sendPasscode = type === 'sign-in' ? sendSignInPhonePasscode : sendRegisterPhonePasscode;
|
||||
const { loading, error, result, run: asyncSendPasscode } = useApi(sendPasscode);
|
||||
|
||||
const validations = useMemo<FieldValidations>(
|
||||
() => ({
|
||||
phone: ({ phone }) => {
|
||||
if (!isValidPhoneNumber(phone)) {
|
||||
return 'user.invalid_phone';
|
||||
}
|
||||
},
|
||||
termsAgreement: ({ termsAgreement }) => {
|
||||
if (!termsAgreement) {
|
||||
return 'form.terms_required';
|
||||
}
|
||||
},
|
||||
}),
|
||||
[isValidPhoneNumber]
|
||||
);
|
||||
|
||||
const onSubmitHandler = useCallback(() => {
|
||||
// Should be removed after api redesign
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phoneError = validations.phone(fieldState);
|
||||
|
||||
if (phoneError) {
|
||||
setFieldErrors((previous) => ({ ...previous, phone: phoneError }));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const termsAgreementError = validations.termsAgreement(fieldState);
|
||||
|
||||
if (termsAgreementError) {
|
||||
setFieldErrors((previous) => ({ ...previous, termsAgreement: termsAgreementError }));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
void asyncSendPasscode(fieldState.phone);
|
||||
}, [loading, validations, fieldState, asyncSendPasscode]);
|
||||
|
||||
useEffect(() => {
|
||||
setFieldState((previous) => ({
|
||||
...previous,
|
||||
phone: `${phoneNumber.countryCallingCode}${phoneNumber.nationalNumber}`,
|
||||
}));
|
||||
}, [phoneNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: navigate to the passcode page
|
||||
console.log(result);
|
||||
}, [result]);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear errors
|
||||
for (const key of Object.keys(fieldState) as [keyof FieldState]) {
|
||||
if (fieldState[key]) {
|
||||
setFieldErrors((previous) => {
|
||||
if (!previous[key]) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return { ...previous, [key]: validations[key](fieldState) };
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [fieldState, validations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
setToast(i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`));
|
||||
}
|
||||
}, [error, i18n, setToast]);
|
||||
|
||||
return (
|
||||
<form className={styles.form}>
|
||||
<PhoneInput
|
||||
name="phone"
|
||||
className={classNames(styles.inputField, fieldErrors.phone && styles.withError)}
|
||||
autoComplete="mobile"
|
||||
placeholder={t('sign_in.phone_number')}
|
||||
countryCallingCode={phoneNumber.countryCallingCode}
|
||||
nationalNumber={phoneNumber.nationalNumber}
|
||||
countryList={countryList}
|
||||
error={fieldErrors.phone}
|
||||
onChange={(data) => {
|
||||
setPhoneNumber((previous) => ({ ...previous, ...data }));
|
||||
}}
|
||||
/>
|
||||
<TermsOfUse
|
||||
name="termsAgreement"
|
||||
className={classNames(styles.terms, fieldErrors.termsAgreement && styles.withError)}
|
||||
termsOfUse={{ enabled: true, contentUrl: '/' }}
|
||||
isChecked={fieldState.termsAgreement}
|
||||
error={fieldErrors.termsAgreement}
|
||||
onChange={(checked) => {
|
||||
setFieldState((state) => ({ ...state, termsAgreement: checked }));
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button onClick={onSubmitHandler}>{t('general.continue')}</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhonePasswordless;
|
27
packages/ui/src/containers/Passwordless/index.module.scss
Normal file
27
packages/ui/src/containers/Passwordless/index.module.scss
Normal file
|
@ -0,0 +1,27 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
@include _.flex-column;
|
||||
|
||||
> * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputField {
|
||||
margin-bottom: _.unit(11);
|
||||
|
||||
&.withError {
|
||||
margin-bottom: _.unit(9);
|
||||
}
|
||||
}
|
||||
|
||||
.terms {
|
||||
margin-bottom: _.unit(6);
|
||||
|
||||
&.withError {
|
||||
margin-bottom: _.unit(5);
|
||||
}
|
||||
}
|
||||
}
|
2
packages/ui/src/containers/Passwordless/index.tsx
Normal file
2
packages/ui/src/containers/Passwordless/index.tsx
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { default as EmailPasswordless } from './EmailPasswordless';
|
||||
export { default as PhonePasswordless } from './PhonePasswordless';
|
|
@ -1,79 +0,0 @@
|
|||
import { render, fireEvent } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { defaultCountryCallingCode } from '@/hooks/use-phone-number';
|
||||
|
||||
import PhoneInputProvider from '.';
|
||||
|
||||
describe('Phone Input Provider', () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
onChange.mockClear();
|
||||
});
|
||||
|
||||
it('render with empty input', () => {
|
||||
const { queryByText } = render(
|
||||
<PhoneInputProvider name="phone" value="" onChange={onChange} />
|
||||
);
|
||||
|
||||
expect(queryByText(`+${defaultCountryCallingCode}`)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('render with input', () => {
|
||||
const { queryByText, container } = render(
|
||||
<PhoneInputProvider name="phone" value="+1911" onChange={onChange} />
|
||||
);
|
||||
|
||||
expect(queryByText('+1')).not.toBeNull();
|
||||
expect(container.querySelector('input')?.value).toEqual('911');
|
||||
});
|
||||
|
||||
it('update country code', () => {
|
||||
const { container } = render(
|
||||
<PhoneInputProvider name="phone" value="+1911" onChange={onChange} />
|
||||
);
|
||||
|
||||
const selector = container.querySelector('select');
|
||||
|
||||
if (selector) {
|
||||
fireEvent.change(selector, { target: { value: '86' } });
|
||||
expect(onChange).toBeCalledWith('+86911');
|
||||
}
|
||||
});
|
||||
|
||||
it('update national code', () => {
|
||||
const { container } = render(
|
||||
<PhoneInputProvider name="phone" value="+1911" onChange={onChange} />
|
||||
);
|
||||
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (input) {
|
||||
fireEvent.change(input, { target: { value: '119' } });
|
||||
expect(onChange).toBeCalledWith('+1119');
|
||||
}
|
||||
});
|
||||
|
||||
it('clear national code', () => {
|
||||
const { container } = render(
|
||||
<PhoneInputProvider name="phone" value="+1911" onChange={onChange} />
|
||||
);
|
||||
|
||||
const input = container.querySelector('input');
|
||||
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
fireEvent.focus(input);
|
||||
|
||||
const clearButton = container.querySelectorAll('svg');
|
||||
expect(clearButton).toHaveLength(2);
|
||||
|
||||
if (clearButton[1]) {
|
||||
fireEvent.mouseDown(clearButton[1]);
|
||||
expect(onChange).toBeCalledWith('+1');
|
||||
}
|
||||
});
|
||||
});
|
|
@ -1,38 +0,0 @@
|
|||
import React from 'react';
|
||||
|
||||
import PhoneInput from '@/components/Input/PhoneInput';
|
||||
import usePhoneNumber, { countryList } from '@/hooks/use-phone-number';
|
||||
|
||||
export type Props = {
|
||||
name: string;
|
||||
autoComplete?: AutoCompleteType;
|
||||
isDisabled?: boolean;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const PhoneInputProvider = ({ value, onChange, ...inputProps }: Props) => {
|
||||
// TODO: error message
|
||||
const {
|
||||
error,
|
||||
phoneNumber: { countryCallingCode, nationalNumber, interacted },
|
||||
setPhoneNumber,
|
||||
} = usePhoneNumber(value, onChange);
|
||||
|
||||
return (
|
||||
<PhoneInput
|
||||
{...inputProps}
|
||||
countryCallingCode={countryCallingCode}
|
||||
nationalNumber={nationalNumber}
|
||||
countryList={countryList}
|
||||
hasError={Boolean(error && interacted)}
|
||||
onChange={(data) => {
|
||||
setPhoneNumber((phoneNumber) => ({ ...phoneNumber, ...data, interacted: true }));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhoneInputProvider;
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
parsePhoneNumber as _parsePhoneNumber,
|
||||
parsePhoneNumberWithError,
|
||||
getCountries,
|
||||
getCountryCallingCode,
|
||||
CountryCallingCode,
|
||||
|
@ -12,20 +12,12 @@ import {
|
|||
E164Number,
|
||||
ParseError,
|
||||
} from 'libphonenumber-js';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
// Should not need the react-phone-number-input package, but we use its locale country name for now
|
||||
import en from 'react-phone-number-input/locale/en.json';
|
||||
|
||||
export type { CountryCallingCode } from 'libphonenumber-js';
|
||||
|
||||
/**
|
||||
* TODO: Get Default Country Code
|
||||
*/
|
||||
const defaultCountryCode: CountryCode = 'CN';
|
||||
|
||||
export const defaultCountryCallingCode: CountryCallingCode =
|
||||
getCountryCallingCode(defaultCountryCode);
|
||||
|
||||
/**
|
||||
* Provide Country Code Options
|
||||
* TODO: Country Name i18n
|
||||
|
@ -52,9 +44,6 @@ type PhoneNumberData = {
|
|||
nationalNumber: string;
|
||||
};
|
||||
|
||||
// Add interact status to prevent the initial onUpdate useEffect call
|
||||
type PhoneNumberState = PhoneNumberData & { interacted: boolean };
|
||||
|
||||
const parseE164Number = (value: string): E164Number | '' => {
|
||||
if (!value || value.startsWith('+')) {
|
||||
return value;
|
||||
|
@ -63,65 +52,33 @@ const parseE164Number = (value: string): E164Number | '' => {
|
|||
return `+${value}`;
|
||||
};
|
||||
|
||||
export const parsePhoneNumber = (value: string): [ParseError?, PhoneNumberData?] => {
|
||||
const isValidPhoneNumber = (value: string): boolean => {
|
||||
try {
|
||||
const phoneNumber = _parsePhoneNumber(parseE164Number(value));
|
||||
const { countryCallingCode, nationalNumber } = phoneNumber;
|
||||
const phoneNumber = parsePhoneNumberWithError(parseE164Number(value));
|
||||
|
||||
return [undefined, { countryCallingCode, nationalNumber }];
|
||||
return phoneNumber.isValid();
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof ParseError) {
|
||||
return [error];
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const usePhoneNumber = (value: string, onChangeCallback: (value: string) => void) => {
|
||||
// TODO: phoneNumber format based on country
|
||||
export const defaultCountryCode: CountryCode = 'CN';
|
||||
export const defaultCountryCallingCode = getCountryCallingCode(defaultCountryCode);
|
||||
|
||||
const [phoneNumber, setPhoneNumber] = useState<PhoneNumberState>({
|
||||
const usePhoneNumber = () => {
|
||||
// TODO: Get Default Country Code
|
||||
const [phoneNumber, setPhoneNumber] = useState<PhoneNumberData>({
|
||||
countryCallingCode: defaultCountryCallingCode,
|
||||
nationalNumber: '',
|
||||
interacted: false,
|
||||
});
|
||||
const [error, setError] = useState<ParseError>();
|
||||
|
||||
useEffect(() => {
|
||||
// Only run on data initialization
|
||||
if (phoneNumber.interacted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [parseError, result] = parsePhoneNumber(value);
|
||||
setError(parseError);
|
||||
|
||||
if (result) {
|
||||
const { countryCallingCode, nationalNumber } = result;
|
||||
setPhoneNumber((previous) => ({
|
||||
...previous,
|
||||
countryCallingCode,
|
||||
nationalNumber,
|
||||
}));
|
||||
}
|
||||
}, [phoneNumber.interacted, value]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only run after data initialization
|
||||
if (!phoneNumber.interacted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { countryCallingCode, nationalNumber } = phoneNumber;
|
||||
const [parseError] = parsePhoneNumber(`${countryCallingCode}${nationalNumber}`);
|
||||
setError(parseError);
|
||||
onChangeCallback(`+${countryCallingCode}${nationalNumber}`);
|
||||
}, [onChangeCallback, phoneNumber]);
|
||||
|
||||
return {
|
||||
error,
|
||||
phoneNumber,
|
||||
setPhoneNumber,
|
||||
isValidPhoneNumber,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
22
packages/ui/src/pages/SecondarySignIn/index.module.scss
Normal file
22
packages/ui/src/pages/SecondarySignIn/index.module.scss
Normal file
|
@ -0,0 +1,22 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
padding: _.unit(8);
|
||||
@include _.flex-column;
|
||||
}
|
||||
|
||||
.navBar {
|
||||
width: 100%;
|
||||
margin-bottom: _.unit(6);
|
||||
|
||||
svg {
|
||||
margin-left: _.unit(-2);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
width: 100%;
|
||||
@include _.title;
|
||||
margin-bottom: _.unit(9);
|
||||
}
|
29
packages/ui/src/pages/SecondarySignIn/index.tsx
Normal file
29
packages/ui/src/pages/SecondarySignIn/index.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import NavArrowIcon from '@/components/Icons/NavArrowIcon';
|
||||
import { PhonePasswordless } from '@/containers/Passwordless';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const SecondarySignIn = () => {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.navBar}>
|
||||
<NavArrowIcon
|
||||
onClick={() => {
|
||||
history.goBack();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.title}>{t('sign_in.sign_in')}</div>
|
||||
<PhonePasswordless type="sign-in" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecondarySignIn;
|
Loading…
Add table
Reference in a new issue