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',
|
cancel: 'Cancel',
|
||||||
done: 'Done',
|
done: 'Done',
|
||||||
search: 'Search',
|
search: 'Search',
|
||||||
|
continue: 'Continue',
|
||||||
},
|
},
|
||||||
sign_in: {
|
sign_in: {
|
||||||
|
sign_in: 'Sign In',
|
||||||
action: 'Sign In',
|
action: 'Sign In',
|
||||||
loading: 'Signing in...',
|
loading: 'Signing in...',
|
||||||
error: 'Username or password is invalid.',
|
error: 'Username or password is invalid.',
|
||||||
username: 'Username',
|
username: 'Username',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
|
email: 'Email',
|
||||||
|
phone_number: 'Phone Number',
|
||||||
terms_of_use: 'Terms of Use',
|
terms_of_use: 'Terms of Use',
|
||||||
terms_agreement_prefix: 'I agree with ',
|
terms_agreement_prefix: 'I agree with ',
|
||||||
continue_with: 'Continue With',
|
continue_with: 'Continue With',
|
||||||
|
|
|
@ -11,13 +11,17 @@ const translation = {
|
||||||
cancel: '取消',
|
cancel: '取消',
|
||||||
done: '完成',
|
done: '完成',
|
||||||
search: '搜索',
|
search: '搜索',
|
||||||
|
continue: '继续',
|
||||||
},
|
},
|
||||||
sign_in: {
|
sign_in: {
|
||||||
|
sign_in: '登录',
|
||||||
action: '登录',
|
action: '登录',
|
||||||
loading: '登录中...',
|
loading: '登录中...',
|
||||||
error: '用户名或密码错误。',
|
error: '用户名或密码错误。',
|
||||||
username: '用户名',
|
username: '用户名',
|
||||||
password: '密码',
|
password: '密码',
|
||||||
|
email: '邮箱',
|
||||||
|
phone_number: '手机',
|
||||||
terms_of_use: '用户协议',
|
terms_of_use: '用户协议',
|
||||||
terms_agreement_prefix: '登录即表明您已经同意',
|
terms_agreement_prefix: '登录即表明您已经同意',
|
||||||
continue_with: '更多',
|
continue_with: '更多',
|
||||||
|
|
|
@ -6,6 +6,7 @@ import useTheme from './hooks/use-theme';
|
||||||
import initI18n from './i18n/init';
|
import initI18n from './i18n/init';
|
||||||
import Consent from './pages/Consent';
|
import Consent from './pages/Consent';
|
||||||
import Register from './pages/Register';
|
import Register from './pages/Register';
|
||||||
|
import SecondarySignIn from './pages/SecondarySignIn';
|
||||||
import SignIn from './pages/SignIn';
|
import SignIn from './pages/SignIn';
|
||||||
import './scss/normalized.scss';
|
import './scss/normalized.scss';
|
||||||
|
|
||||||
|
@ -21,6 +22,7 @@ const App = () => {
|
||||||
<Route exact path="/sign-in" component={SignIn} />
|
<Route exact path="/sign-in" component={SignIn} />
|
||||||
<Route exact path="/sign-in/consent" component={Consent} />
|
<Route exact path="/sign-in/consent" component={Consent} />
|
||||||
<Route exact path="/register" component={Register} />
|
<Route exact path="/register" component={Register} />
|
||||||
|
<Route exact path="/sign-in-secondary" component={SecondarySignIn} />
|
||||||
</Switch>
|
</Switch>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</AppContent>
|
</AppContent>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import React, { useState, useMemo, useRef } from 'react';
|
||||||
|
|
||||||
import { CountryCallingCode, CountryMetaData } from '@/hooks/use-phone-number';
|
import { CountryCallingCode, CountryMetaData } from '@/hooks/use-phone-number';
|
||||||
|
|
||||||
|
import ErrorMessage, { ErrorType } from '../ErrorMessage';
|
||||||
import { ClearIcon, DownArrowIcon } from '../Icons';
|
import { ClearIcon, DownArrowIcon } from '../Icons';
|
||||||
import * as styles from './index.module.scss';
|
import * as styles from './index.module.scss';
|
||||||
import * as phoneInputStyles from './phoneInput.module.scss';
|
import * as phoneInputStyles from './phoneInput.module.scss';
|
||||||
|
@ -12,26 +13,24 @@ type Value = { countryCallingCode?: CountryCallingCode; nationalNumber?: string
|
||||||
export type Props = {
|
export type Props = {
|
||||||
name: string;
|
name: string;
|
||||||
autoComplete?: AutoCompleteType;
|
autoComplete?: AutoCompleteType;
|
||||||
isDisabled?: boolean;
|
|
||||||
className?: string;
|
className?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
countryCallingCode?: CountryCallingCode;
|
countryCallingCode?: CountryCallingCode;
|
||||||
nationalNumber: string;
|
nationalNumber: string;
|
||||||
countryList?: CountryMetaData[];
|
countryList?: CountryMetaData[];
|
||||||
hasError?: boolean;
|
error?: ErrorType;
|
||||||
onChange: (value: Value) => void;
|
onChange: (value: Value) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PhoneInput = ({
|
const PhoneInput = ({
|
||||||
name,
|
name,
|
||||||
autoComplete,
|
autoComplete,
|
||||||
isDisabled,
|
|
||||||
className,
|
className,
|
||||||
placeholder,
|
placeholder,
|
||||||
countryCallingCode,
|
countryCallingCode,
|
||||||
nationalNumber,
|
nationalNumber,
|
||||||
countryList,
|
countryList,
|
||||||
hasError = false,
|
error,
|
||||||
onChange,
|
onChange,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [onFocus, setOnFocus] = useState(false);
|
const [onFocus, setOnFocus] = useState(false);
|
||||||
|
@ -69,43 +68,38 @@ const PhoneInput = ({
|
||||||
}, [countryCallingCode, countryList, onChange]);
|
}, [countryCallingCode, countryList, onChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={className}>
|
||||||
className={classNames(
|
<div className={classNames(styles.wrapper, onFocus && styles.focus, error && styles.error)}>
|
||||||
styles.wrapper,
|
{countrySelector}
|
||||||
onFocus && styles.focus,
|
<input
|
||||||
hasError && styles.error,
|
ref={inputReference}
|
||||||
className
|
name={name}
|
||||||
)}
|
placeholder={placeholder}
|
||||||
>
|
value={nationalNumber}
|
||||||
{countrySelector}
|
type="tel"
|
||||||
<input
|
inputMode="numeric"
|
||||||
ref={inputReference}
|
autoComplete={autoComplete}
|
||||||
name={name}
|
onFocus={() => {
|
||||||
disabled={isDisabled}
|
setOnFocus(true);
|
||||||
placeholder={placeholder}
|
}}
|
||||||
value={nationalNumber}
|
onBlur={() => {
|
||||||
type="tel"
|
setOnFocus(false);
|
||||||
inputMode="numeric"
|
}}
|
||||||
autoComplete={autoComplete}
|
onChange={({ target: { value } }) => {
|
||||||
onFocus={() => {
|
onChange({ nationalNumber: value.replaceAll(/\D/g, '') });
|
||||||
setOnFocus(true);
|
|
||||||
}}
|
|
||||||
onBlur={() => {
|
|
||||||
setOnFocus(false);
|
|
||||||
}}
|
|
||||||
onChange={({ target: { value } }) => {
|
|
||||||
onChange({ nationalNumber: value });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{nationalNumber && onFocus && (
|
|
||||||
<ClearIcon
|
|
||||||
className={styles.actionButton}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
onChange({ nationalNumber: '' });
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
{nationalNumber && onFocus && (
|
||||||
|
<ClearIcon
|
||||||
|
className={styles.actionButton}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onChange({ nationalNumber: '' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{error && <ErrorMessage error={error} className={styles.errorMessage} />}
|
||||||
</div>
|
</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 {
|
import {
|
||||||
parsePhoneNumber as _parsePhoneNumber,
|
parsePhoneNumberWithError,
|
||||||
getCountries,
|
getCountries,
|
||||||
getCountryCallingCode,
|
getCountryCallingCode,
|
||||||
CountryCallingCode,
|
CountryCallingCode,
|
||||||
|
@ -12,20 +12,12 @@ import {
|
||||||
E164Number,
|
E164Number,
|
||||||
ParseError,
|
ParseError,
|
||||||
} from 'libphonenumber-js';
|
} 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
|
// 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';
|
import en from 'react-phone-number-input/locale/en.json';
|
||||||
|
|
||||||
export type { CountryCallingCode } from 'libphonenumber-js';
|
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
|
* Provide Country Code Options
|
||||||
* TODO: Country Name i18n
|
* TODO: Country Name i18n
|
||||||
|
@ -52,9 +44,6 @@ type PhoneNumberData = {
|
||||||
nationalNumber: string;
|
nationalNumber: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add interact status to prevent the initial onUpdate useEffect call
|
|
||||||
type PhoneNumberState = PhoneNumberData & { interacted: boolean };
|
|
||||||
|
|
||||||
const parseE164Number = (value: string): E164Number | '' => {
|
const parseE164Number = (value: string): E164Number | '' => {
|
||||||
if (!value || value.startsWith('+')) {
|
if (!value || value.startsWith('+')) {
|
||||||
return value;
|
return value;
|
||||||
|
@ -63,65 +52,33 @@ const parseE164Number = (value: string): E164Number | '' => {
|
||||||
return `+${value}`;
|
return `+${value}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const parsePhoneNumber = (value: string): [ParseError?, PhoneNumberData?] => {
|
const isValidPhoneNumber = (value: string): boolean => {
|
||||||
try {
|
try {
|
||||||
const phoneNumber = _parsePhoneNumber(parseE164Number(value));
|
const phoneNumber = parsePhoneNumberWithError(parseE164Number(value));
|
||||||
const { countryCallingCode, nationalNumber } = phoneNumber;
|
|
||||||
|
|
||||||
return [undefined, { countryCallingCode, nationalNumber }];
|
return phoneNumber.isValid();
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (error instanceof ParseError) {
|
if (error instanceof ParseError) {
|
||||||
return [error];
|
return false;
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const usePhoneNumber = (value: string, onChangeCallback: (value: string) => void) => {
|
export const defaultCountryCode: CountryCode = 'CN';
|
||||||
// TODO: phoneNumber format based on country
|
export const defaultCountryCallingCode = getCountryCallingCode(defaultCountryCode);
|
||||||
|
|
||||||
const [phoneNumber, setPhoneNumber] = useState<PhoneNumberState>({
|
const usePhoneNumber = () => {
|
||||||
|
// TODO: Get Default Country Code
|
||||||
|
const [phoneNumber, setPhoneNumber] = useState<PhoneNumberData>({
|
||||||
countryCallingCode: defaultCountryCallingCode,
|
countryCallingCode: defaultCountryCallingCode,
|
||||||
nationalNumber: '',
|
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 {
|
return {
|
||||||
error,
|
|
||||||
phoneNumber,
|
phoneNumber,
|
||||||
setPhoneNumber,
|
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