0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-06 20:40:08 -05:00

feat(ui): add SmsSignIn container (#2309)

This commit is contained in:
simeng-li 2022-11-03 19:22:32 +08:00 committed by GitHub
parent 96ade39498
commit 6242907271
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 386 additions and 71 deletions

View file

@ -75,7 +75,8 @@
"eslintConfig": {
"extends": "@silverhand/react",
"rules": {
"complexity": "off"
"complexity": "off",
"jsx-a11y/no-autofocus": "off"
}
},
"stylelint": {

View file

@ -80,7 +80,6 @@ const PhoneInput = ({
type="tel"
inputMode="tel"
autoComplete="tel-national"
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
name={name}
placeholder={placeholder}

View file

@ -87,7 +87,6 @@ const CreateAccount = ({ className, autoFocus }: Props) => {
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={styles.inputField}
name="new-username"

View file

@ -74,7 +74,6 @@ const EmailForm = ({
autoComplete="email"
inputMode="email"
placeholder={t('input.email')}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={styles.inputField}
onChange={(event) => {

View file

@ -1 +1,2 @@
export { default as EmailRegister } from './EmailRegister';
export { default as EmailSignIn } from './EmailSignIn';

View file

@ -17,7 +17,7 @@ const useEmailRegister = () => {
setErrorMessage('invalid_email');
},
}),
[setErrorMessage]
[]
);
const clearErrorMessage = useCallback(() => {

View file

@ -21,7 +21,7 @@ const useEmailSignIn = ({ password, isPasswordPrimary, verificationCode }: Metho
setErrorMessage('invalid_email');
},
}),
[setErrorMessage]
[]
);
const clearErrorMessage = useCallback(() => {

View file

@ -84,7 +84,6 @@ const EmailPassword = ({ className, autoFocus }: Props) => {
autoComplete="email"
inputMode="email"
placeholder={t('input.email')}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={styles.inputField}
{...register('email', emailValidation)}

View file

@ -96,7 +96,6 @@ const EmailPasswordless = ({
autoComplete="email"
inputMode="email"
placeholder={t('input.email')}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={styles.inputField}
{...register('email', emailValidation)}

View file

@ -111,7 +111,6 @@ const PhonePasswordless = ({
className={styles.inputField}
countryCallingCode={phoneNumber.countryCallingCode}
nationalNumber={phoneNumber.nationalNumber}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
countryList={countryList}
{...register('phone', phoneNumberValidation)}

View file

@ -92,7 +92,6 @@ const PhoneForm = ({
className={styles.inputField}
countryCallingCode={phoneNumber.countryCallingCode}
nationalNumber={phoneNumber.nationalNumber}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
countryList={countryList}
{...register('phone', phoneNumberValidation)}

View file

@ -0,0 +1,57 @@
import { fireEvent, waitFor, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { sendRegisterSmsPasscode } from '@/apis/register';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
import SmsRegister from './SmsRegister';
const mockedNavigate = jest.fn();
// PhoneNum CountryCode detection
jest.mock('i18next', () => ({
language: 'en',
}));
jest.mock('@/apis/register', () => ({
sendRegisterSmsPasscode: jest.fn(() => ({ success: true })),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
describe('SmsRegister', () => {
const phone = '8573333333';
const defaultCountryCallingCode = getDefaultCountryCallingCode();
const fullPhoneNumber = `${defaultCountryCallingCode}${phone}`;
test('register form submit', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SmsRegister />
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phone } });
}
const submitButton = getByText('action.create_account');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(sendRegisterSmsPasscode).toBeCalledWith(fullPhoneNumber);
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/register/sms/passcode-validation', search: '' },
{ state: { phone: fullPhoneNumber } }
);
});
});
});

View file

@ -0,0 +1,173 @@
import { SignInIdentifier } from '@logto/schemas';
import { fireEvent, waitFor, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { sendSignInSmsPasscode } from '@/apis/sign-in';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
import SmsSignIn from './SmsSignIn';
const mockedNavigate = jest.fn();
// PhoneNum CountryCode detection
jest.mock('i18next', () => ({
language: 'en',
}));
jest.mock('@/apis/sign-in', () => ({
sendSignInSmsPasscode: jest.fn(() => ({ success: true })),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
describe('SmsSignIn', () => {
const phone = '8573333333';
const defaultCountryCallingCode = getDefaultCountryCallingCode();
const fullPhoneNumber = `${defaultCountryCallingCode}${phone}`;
afterEach(() => {
jest.clearAllMocks();
});
test('SmsSignIn form with password as primary method', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SmsSignIn
signInMethod={{
identifier: SignInIdentifier.Sms,
password: true,
verificationCode: true,
isPasswordPrimary: true,
}}
/>
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phone } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(sendSignInSmsPasscode).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/sms/password', search: '' },
{ state: { phone: fullPhoneNumber } }
);
});
});
test('SmsSignIn form with password true, primary true but verification code false', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SmsSignIn
signInMethod={{
identifier: SignInIdentifier.Sms,
password: true,
verificationCode: false,
isPasswordPrimary: true,
}}
/>
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phone } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(sendSignInSmsPasscode).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/sms/password', search: '' },
{ state: { phone: fullPhoneNumber } }
);
});
});
test('SmsSignIn form with password true but is primary false and verification code true', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SmsSignIn
signInMethod={{
identifier: SignInIdentifier.Sms,
password: true,
verificationCode: true,
isPasswordPrimary: false,
}}
/>
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phone } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(sendSignInSmsPasscode).toBeCalledWith(fullPhoneNumber);
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/sms/passcode-validation', search: '' },
{ state: { phone: fullPhoneNumber } }
);
});
});
test('SmsSignIn form with password false but primary verification code true', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SmsSignIn
signInMethod={{
identifier: SignInIdentifier.Sms,
password: false,
verificationCode: true,
isPasswordPrimary: true,
}}
/>
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phone } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(sendSignInSmsPasscode).toBeCalledWith(fullPhoneNumber);
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/sms/passcode-validation', search: '' },
{ state: { phone: fullPhoneNumber } }
);
});
});
});

View file

@ -0,0 +1,26 @@
import PhoneForm from './PhoneForm';
import type { MethodProps } from './use-sms-sign-in';
import useSmsSignIn from './use-sms-sign-in';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
signInMethod: MethodProps;
};
const SmsSignIn = ({ signInMethod, ...props }: Props) => {
const { onSubmit, errorMessage, clearErrorMessage } = useSmsSignIn(signInMethod);
return (
<PhoneForm
onSubmit={onSubmit}
{...props}
submitButtonText="action.sign_in"
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
/>
);
};
export default SmsSignIn;

View file

@ -1 +1,2 @@
export { default as SmsRegister } from './SmsRegister';
export { default as SmsSignIn } from './SmsSignIn';

View file

@ -17,7 +17,7 @@ const useSmsRegister = () => {
setErrorMessage('invalid_phone');
},
}),
[setErrorMessage]
[]
);
const clearErrorMessage = useCallback(() => {

View file

@ -0,0 +1,89 @@
import type { SignIn } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { sendSignInSmsPasscode } from '@/apis/sign-in';
import type { ErrorHandlers } from '@/hooks/use-api';
import useApi from '@/hooks/use-api';
import type { ArrayElement } from '@/types';
import { UserFlow } from '@/types';
export type MethodProps = ArrayElement<SignIn['methods']>;
const useEmailSignIn = ({ password, isPasswordPrimary, verificationCode }: MethodProps) => {
const [errorMessage, setErrorMessage] = useState<string>();
const navigate = useNavigate();
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'guard.invalid_input': () => {
setErrorMessage('invalid_phone');
},
}),
[]
);
const clearErrorMessage = useCallback(() => {
setErrorMessage('');
}, []);
const { run: asyncSendSignInEmailPasscode } = useApi(sendSignInSmsPasscode, errorHandlers);
const navigateToPasswordPage = useCallback(
(phone: string) => {
navigate(
{
pathname: `/${UserFlow.signIn}/${SignInIdentifier.Sms}/password`,
search: location.search,
},
{ state: { phone } }
);
},
[navigate]
);
const sendPasscode = useCallback(
async (phone: string) => {
const result = await asyncSendSignInEmailPasscode(phone);
if (!result) {
return;
}
navigate(
{
pathname: `/${UserFlow.signIn}/${SignInIdentifier.Sms}/passcode-validation`,
search: location.search,
},
{ state: { phone } }
);
},
[asyncSendSignInEmailPasscode, navigate]
);
const onSubmit = useCallback(
async (phone: string) => {
// Sms Password SignIn Flow
if (password && (isPasswordPrimary || !verificationCode)) {
navigateToPasswordPage(phone);
return;
}
// Sms Passwordless SignIn Flow
if (verificationCode) {
await sendPasscode(phone);
}
},
[isPasswordPrimary, navigateToPasswordPage, password, sendPasscode, verificationCode]
);
return {
errorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useEmailSignIn;

View file

@ -106,7 +106,6 @@ const PhonePassword = ({ className, autoFocus }: Props) => {
className={styles.inputField}
countryCallingCode={phoneNumber.countryCallingCode}
nationalNumber={phoneNumber.nationalNumber}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
countryList={countryList}
{...register('phone', phoneNumberValidation)}

View file

@ -62,7 +62,6 @@ const SetPassword = ({
type="password"
autoComplete="new-password"
placeholder={t('input.password')}
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
{...register('password', passwordValidation)}
onClear={() => {

View file

@ -87,7 +87,6 @@ const UsernameSignIn = ({ className, autoFocus }: Props) => {
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
className={styles.inputField}
name="username"

View file

@ -24,7 +24,6 @@ const ForgotPassword = () => {
title="description.reset_password"
description={`description.reset_password_description_${method === 'email' ? 'email' : 'sms'}`}
>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<PasswordlessForm autoFocus hasSwitch type={UserFlow.forgotPassword} hasTerms={false} />
</SecondaryPageWrapper>
);

View file

@ -28,7 +28,6 @@ const PasswordRegisterWithUsername = () => {
return (
<SecondaryPageWrapper title="description.new_password">
<SetPassword
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
onSubmit={(password) => {
void register(state.username, password);

View file

@ -9,7 +9,6 @@ const ResetPassword = () => {
return (
<SecondaryPageWrapper title="description.new_password">
<SetPassword
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}

View file

@ -1,5 +1,4 @@
import { SignInIdentifier } from '@logto/schemas';
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { is } from 'superstruct';
@ -19,23 +18,6 @@ const SecondaryRegister = () => {
const { method = '' } = useParams<Parameters>();
const { signUpMethods, signUpSettings } = useSieMethods();
const registerForm = useMemo(() => {
if (method === SignInIdentifier.Sms) {
// eslint-disable-next-line jsx-a11y/no-autofocus
return <SmsRegister autoFocus />;
}
if (method === SignInIdentifier.Email) {
// eslint-disable-next-line jsx-a11y/no-autofocus
return <EmailRegister autoFocus />;
}
if (method === SignInIdentifier.Username) {
// eslint-disable-next-line jsx-a11y/no-autofocus
return <CreateAccount autoFocus />;
}
}, [method]);
// Validate the signUp method
if (!is(method, SignInMethodGuard) || !signUpMethods.includes(method)) {
return <ErrorPage />;
@ -46,7 +28,17 @@ const SecondaryRegister = () => {
return <ErrorPage />;
}
return <SecondaryPageWrapper title="action.create_account">{registerForm}</SecondaryPageWrapper>;
return (
<SecondaryPageWrapper title="action.create_account">
{method === SignInIdentifier.Sms ? (
<SmsRegister autoFocus />
) : method === SignInIdentifier.Email ? (
<EmailRegister autoFocus />
) : (
<CreateAccount autoFocus />
)}
</SecondaryPageWrapper>
);
};
export default SecondaryRegister;

View file

@ -31,7 +31,7 @@ describe('<SecondarySignIn />', () => {
});
test('renders phone', async () => {
const { queryByText, container } = renderWithPageContext(
const { queryAllByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/sms']}>
<Routes>
<Route
@ -45,7 +45,7 @@ describe('<SecondarySignIn />', () => {
</Routes>
</MemoryRouter>
);
expect(queryByText('action.sign_in')).not.toBeNull();
expect(queryAllByText('action.sign_in')).toHaveLength(2);
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
});

View file

@ -1,48 +1,37 @@
import { useMemo } from 'react';
import { SignInIdentifier } from '@logto/schemas';
import { useParams } from 'react-router-dom';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import EmailSignIn from '@/containers/EmailForm/EmailSignIn';
import { PhonePasswordless } from '@/containers/Passwordless';
import { EmailSignIn } from '@/containers/EmailForm';
import { SmsSignIn } from '@/containers/PhoneForm';
import UsernameSignIn from '@/containers/UsernameSignIn';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
import { UserFlow } from '@/types';
type Props = {
method?: string;
};
const SecondarySignIn = () => {
const { method = 'username' } = useParams<Props>();
const { method = '' } = useParams<Props>();
const { signInMethods } = useSieMethods();
const signInMethod = signInMethods.find(({ identifier }) => identifier === method);
const signInForm = useMemo(() => {
if (method === 'sms') {
// eslint-disable-next-line jsx-a11y/no-autofocus
return <PhonePasswordless autoFocus type={UserFlow.signIn} />;
}
if (method === 'email') {
const signInMethod = signInMethods.find(({ identifier }) => identifier === method);
// eslint-disable-next-line jsx-a11y/no-autofocus
return signInMethod && <EmailSignIn autoFocus signInMethod={signInMethod} />;
}
// eslint-disable-next-line jsx-a11y/no-autofocus
return <UsernameSignIn autoFocus />;
}, [method, signInMethods]);
if (!['email', 'sms', 'username'].includes(method)) {
if (!signInMethod) {
return <ErrorPage />;
}
if (!signInMethods.some(({ identifier }) => identifier === method)) {
return <ErrorPage />;
}
return <SecondaryPageWrapper title="action.sign_in">{signInForm}</SecondaryPageWrapper>;
return (
<SecondaryPageWrapper title="action.sign_in">
{signInMethod.identifier === SignInIdentifier.Sms ? (
<SmsSignIn autoFocus signInMethod={signInMethod} />
) : signInMethod.identifier === SignInIdentifier.Email ? (
<EmailSignIn autoFocus signInMethod={signInMethod} />
) : (
<UsernameSignIn autoFocus />
)}
</SecondaryPageWrapper>
);
};
export default SecondarySignIn;

View file

@ -1,14 +1,13 @@
import { SignInIdentifier } from '@logto/schemas';
import type { SignIn as SignInType, ConnectorMetadata } from '@logto/schemas';
import EmailSignIn from '@/containers/EmailForm/EmailSignIn';
import { EmailSignIn } from '@/containers/EmailForm';
import EmailPassword from '@/containers/EmailPassword';
import { PhonePasswordless } from '@/containers/Passwordless';
import { SmsSignIn } from '@/containers/PhoneForm';
import PhonePassword from '@/containers/PhonePassword';
import SocialSignIn from '@/containers/SocialSignIn';
import UsernameSignIn from '@/containers/UsernameSignIn';
import type { ArrayElement } from '@/types';
import { UserFlow } from '@/types';
import * as styles from './index.module.scss';
@ -32,7 +31,7 @@ const Main = ({ signInMethod, socialConnectors }: Props) => {
return <PhonePassword className={styles.main} />;
}
return <PhonePasswordless type={UserFlow.signIn} className={styles.main} />;
return <SmsSignIn signInMethod={signInMethod} className={styles.main} />;
}
case SignInIdentifier.Username: {

View file

@ -88,7 +88,7 @@ describe('<SignIn />', () => {
</SettingsProvider>
);
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
expect(queryByText('action.sign_in')).not.toBeNull();
});
test('renders with phone password as primary', async () => {