0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(ui): add EmailSignIn container (#2308)

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

View file

@ -17,6 +17,7 @@ import ResetPassword from './pages/ResetPassword';
import SecondaryRegister from './pages/SecondaryRegister';
import SecondarySignIn from './pages/SecondarySignIn';
import SignIn from './pages/SignIn';
import SignInPassword from './pages/SignInPassword';
import SocialLanding from './pages/SocialLanding';
import SocialRegister from './pages/SocialRegister';
import SocialSignIn from './pages/SocialSignInCallback';
@ -66,6 +67,7 @@ const App = () => {
<Route path="/sign-in" element={<SignIn />} />
<Route path="/sign-in/social/:connector" element={<SocialSignIn />} />
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
<Route path="/sign-in/:method/password" element={<SignInPassword />} />
{/* register */}
<Route path="/register" element={<Register />} />

View file

@ -1,5 +1,6 @@
import classNames from 'classnames';
import { useCallback } from 'react';
import type { TFuncKey } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
@ -20,6 +21,7 @@ type Props = {
hasTerms?: boolean;
hasSwitch?: boolean;
errorMessage?: string;
submitButtonText?: TFuncKey;
clearErrorMessage?: () => void;
onSubmit: (email: string) => Promise<void>;
};
@ -35,8 +37,9 @@ const EmailForm = ({
hasTerms = true,
hasSwitch = false,
errorMessage,
clearErrorMessage,
className,
submitButtonText = 'action.continue',
clearErrorMessage,
onSubmit,
}: Props) => {
const { t } = useTranslation();
@ -86,7 +89,7 @@ const EmailForm = ({
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{hasSwitch && <PasswordlessSwitch target="sms" className={styles.switch} />}
{hasTerms && <TermsOfUse className={styles.terms} />}
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
<Button title={submitButtonText} onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>

View file

@ -0,0 +1,49 @@
import { fireEvent, waitFor, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { sendRegisterEmailPasscode } from '@/apis/register';
import EmailRegister from './EmailRegister';
const mockedNavigate = jest.fn();
jest.mock('@/apis/register', () => ({
sendRegisterEmailPasscode: jest.fn(() => ({ success: true })),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
describe('EmailRegister', () => {
const email = 'foo@logto.io';
test('register form submit', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailRegister />
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: email } });
}
const submitButton = getByText('action.create_account');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(sendRegisterEmailPasscode).toBeCalledWith(email);
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/register/email/passcode-validation', search: '' },
{ state: { email } }
);
});
});
});

View file

@ -14,6 +14,7 @@ const EmailRegister = (props: Props) => {
<EmailForm
onSubmit={onSubmit}
{...props}
submitButtonText="action.create_account"
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
/>

View file

@ -0,0 +1,165 @@
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 { sendSignInEmailPasscode } from '@/apis/sign-in';
import EmailSignIn from './EmailSignIn';
const mockedNavigate = jest.fn();
jest.mock('@/apis/sign-in', () => ({
sendSignInEmailPasscode: jest.fn(() => ({ success: true })),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
describe('EmailSignIn', () => {
const email = 'foo@logto.io';
afterEach(() => {
jest.clearAllMocks();
});
test('EmailSignIn form with password as primary method', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailSignIn
signInMethod={{
identifier: SignInIdentifier.Email,
password: true,
verificationCode: true,
isPasswordPrimary: true,
}}
/>
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: email } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(sendSignInEmailPasscode).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/email/password', search: '' },
{ state: { email } }
);
});
});
test('EmailSignIn form with password true but not primary verification code false', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailSignIn
signInMethod={{
identifier: SignInIdentifier.Email,
password: true,
verificationCode: false,
isPasswordPrimary: true,
}}
/>
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: email } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(sendSignInEmailPasscode).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/email/password', search: '' },
{ state: { email } }
);
});
});
test('EmailSignIn form with password true but not primary verification code true', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailSignIn
signInMethod={{
identifier: SignInIdentifier.Email,
password: true,
verificationCode: true,
isPasswordPrimary: false,
}}
/>
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: email } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(sendSignInEmailPasscode).toBeCalledWith(email);
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/email/passcode-validation', search: '' },
{ state: { email } }
);
});
});
test('EmailSignIn form with password false but primary verification code true', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailSignIn
signInMethod={{
identifier: SignInIdentifier.Email,
password: false,
verificationCode: true,
isPasswordPrimary: true,
}}
/>
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: email } });
}
const submitButton = getByText('action.sign_in');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(sendSignInEmailPasscode).toBeCalledWith(email);
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/email/passcode-validation', search: '' },
{ state: { email } }
);
});
});
});

View file

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

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 { sendSignInEmailPasscode } 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_email');
},
}),
[setErrorMessage]
);
const clearErrorMessage = useCallback(() => {
setErrorMessage('');
}, []);
const { run: asyncSendSignInEmailPasscode } = useApi(sendSignInEmailPasscode, errorHandlers);
const navigateToPasswordPage = useCallback(
(email: string) => {
navigate(
{
pathname: `/${UserFlow.signIn}/${SignInIdentifier.Email}/password`,
search: location.search,
},
{ state: { email } }
);
},
[navigate]
);
const sendPasscode = useCallback(
async (email: string) => {
const result = await asyncSendSignInEmailPasscode(email);
if (!result) {
return;
}
navigate(
{
pathname: `/${UserFlow.signIn}/${SignInIdentifier.Email}/passcode-validation`,
search: location.search,
},
{ state: { email } }
);
},
[asyncSendSignInEmailPasscode, navigate]
);
const onSubmit = useCallback(
async (email: string) => {
// Email Password SignIn Flow
if (password && (isPasswordPrimary || !verificationCode)) {
navigateToPasswordPage(email);
return;
}
// Email Passwordless SignIn Flow
if (verificationCode) {
await sendPasscode(email);
}
},
[isPasswordPrimary, navigateToPasswordPage, password, sendPasscode, verificationCode]
);
return {
errorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useEmailSignIn;

View file

@ -9,7 +9,6 @@ import ConfirmModalProvider from '@/containers/ConfirmModalProvider';
import EmailPassword from '.';
jest.mock('@/apis/sign-in', () => ({ signInWithEmailPassword: jest.fn(async () => 0) }));
// Terms Iframe Modal only shown on mobile device
jest.mock('react-device-detect', () => ({
isMobile: true,
}));

View file

@ -55,8 +55,6 @@ const EmailPassword = ({ className, autoFocus }: Props) => {
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
setErrorMessage(undefined);
if (!validateForm()) {
return;
}

View file

@ -1,5 +1,6 @@
import classNames from 'classnames';
import { useCallback, useEffect } from 'react';
import type { TFuncKey } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
@ -20,6 +21,7 @@ type Props = {
hasTerms?: boolean;
hasSwitch?: boolean;
errorMessage?: string;
submitButtonText?: TFuncKey;
clearErrorMessage?: () => void;
onSubmit: (phone: string) => Promise<void>;
};
@ -36,6 +38,7 @@ const PhoneForm = ({
hasSwitch = false,
className,
errorMessage,
submitButtonText = 'action.continue',
clearErrorMessage,
onSubmit,
}: Props) => {
@ -105,7 +108,7 @@ const PhoneForm = ({
{hasTerms && <TermsOfUse className={styles.terms} />}
<Button title="action.continue" onClick={async () => onSubmitHandler()} />
<Button title={submitButtonText} onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>

View file

@ -14,6 +14,7 @@ const SmsRegister = (props: Props) => {
<PhoneForm
onSubmit={onSubmit}
{...props}
submitButtonText="action.create_account"
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
/>

View file

@ -41,7 +41,7 @@ describe('<Register />', () => {
</SettingsProvider>
);
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
expect(queryByText('action.create_account')).not.toBeNull();
});
test('renders with sms passwordless as primary', async () => {
@ -58,7 +58,7 @@ describe('<Register />', () => {
</SettingsProvider>
);
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
expect(queryByText('action.create_account')).not.toBeNull();
});
test('render with email and sms passwordless', async () => {

View file

@ -13,7 +13,7 @@ jest.mock('i18next', () => ({
describe('<SecondaryRegister />', () => {
test('renders phone', async () => {
const { queryByText, container } = renderWithPageContext(
const { queryAllByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/register/sms']}>
<Routes>
<Route
@ -35,12 +35,12 @@ describe('<SecondaryRegister />', () => {
</Routes>
</MemoryRouter>
);
expect(queryByText('action.create_account')).not.toBeNull();
expect(queryAllByText('action.create_account')).toHaveLength(2);
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
});
test('renders email', async () => {
const { queryByText, container } = renderWithPageContext(
const { queryAllByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/register/email']}>
<Routes>
<Route
@ -62,7 +62,7 @@ describe('<SecondaryRegister />', () => {
</Routes>
</MemoryRouter>
);
expect(queryByText('action.create_account')).not.toBeNull();
expect(queryAllByText('action.create_account')).toHaveLength(2);
expect(container.querySelector('input[name="email"]')).not.toBeNull();
});

View file

@ -1,6 +1,9 @@
import { render } from '@testing-library/react';
import { SignInIdentifier } from '@logto/schemas';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import SecondarySignIn from '@/pages/SecondarySignIn';
jest.mock('@/apis/register', () => ({ register: jest.fn(async () => 0) }));
@ -10,19 +13,35 @@ jest.mock('i18next', () => ({
describe('<SecondarySignIn />', () => {
test('renders without exploding', async () => {
const { queryAllByText } = render(
const { queryAllByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/username']}>
<SecondarySignIn />
<Routes>
<Route
path="/sign-in/:method"
element={
<SettingsProvider>
<SecondarySignIn />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryAllByText('action.sign_in')).toHaveLength(2);
});
test('renders phone', async () => {
const { queryByText, container } = render(
const { queryByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/sms']}>
<Routes>
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
<Route
path="/sign-in/:method"
element={
<SettingsProvider>
<SecondarySignIn />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
@ -31,22 +50,64 @@ describe('<SecondarySignIn />', () => {
});
test('renders email', async () => {
const { queryByText, container } = render(
const { queryAllByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/email']}>
<Routes>
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
<Route
path="/sign-in/:method"
element={
<SettingsProvider>
<SecondarySignIn />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('action.sign_in')).not.toBeNull();
expect(queryAllByText('action.sign_in')).toHaveLength(2);
expect(container.querySelector('input[name="email"]')).not.toBeNull();
});
test('render un-recognized method', async () => {
const { queryByText } = render(
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/test']}>
<Routes>
<Route path="/sign-in/:method" element={<SecondarySignIn />} />
<Route
path="/sign-in/:method"
element={
<SettingsProvider>
<SecondarySignIn />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('action.sign_in')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
test('render un-supported method', async () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/sign-in/email']}>
<Routes>
<Route
path="/sign-in/:method"
element={
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signIn: {
methods: mockSignInExperienceSettings.signIn.methods.filter(
({ identifier }) => identifier !== SignInIdentifier.Email
),
},
}}
>
<SecondarySignIn />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);

View file

@ -2,8 +2,10 @@ import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless';
import EmailSignIn from '@/containers/EmailForm/EmailSignIn';
import { PhonePasswordless } from '@/containers/Passwordless';
import UsernameSignIn from '@/containers/UsernameSignIn';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
import { UserFlow } from '@/types';
@ -13,6 +15,7 @@ type Props = {
const SecondarySignIn = () => {
const { method = 'username' } = useParams<Props>();
const { signInMethods } = useSieMethods();
const signInForm = useMemo(() => {
if (method === 'sms') {
@ -21,18 +24,24 @@ const SecondarySignIn = () => {
}
if (method === 'email') {
const signInMethod = signInMethods.find(({ identifier }) => identifier === method);
// eslint-disable-next-line jsx-a11y/no-autofocus
return <EmailPasswordless autoFocus type={UserFlow.signIn} />;
return signInMethod && <EmailSignIn autoFocus signInMethod={signInMethod} />;
}
// eslint-disable-next-line jsx-a11y/no-autofocus
return <UsernameSignIn autoFocus />;
}, [method]);
}, [method, signInMethods]);
if (!['email', 'sms', 'username'].includes(method)) {
return <ErrorPage />;
}
if (!signInMethods.some(({ identifier }) => identifier === method)) {
return <ErrorPage />;
}
return <SecondaryPageWrapper title="action.sign_in">{signInForm}</SecondaryPageWrapper>;
};

View file

@ -1,7 +1,9 @@
import { SignInIdentifier } from '@logto/schemas';
import type { SignIn as SignInType, ConnectorMetadata } from '@logto/schemas';
import EmailSignIn from '@/containers/EmailForm/EmailSignIn';
import EmailPassword from '@/containers/EmailPassword';
import { EmailPasswordless, PhonePasswordless } from '@/containers/Passwordless';
import { PhonePasswordless } from '@/containers/Passwordless';
import PhonePassword from '@/containers/PhonePassword';
import SocialSignIn from '@/containers/SocialSignIn';
import UsernameSignIn from '@/containers/UsernameSignIn';
@ -17,15 +19,15 @@ type Props = {
const Main = ({ signInMethod, socialConnectors }: Props) => {
switch (signInMethod?.identifier) {
case 'email': {
case SignInIdentifier.Email: {
if (signInMethod.password && !signInMethod.verificationCode) {
return <EmailPassword className={styles.main} />;
}
return <EmailPasswordless type={UserFlow.signIn} className={styles.main} />;
return <EmailSignIn signInMethod={signInMethod} className={styles.main} />;
}
case 'sms': {
case SignInIdentifier.Sms: {
if (signInMethod.password && !signInMethod.verificationCode) {
return <PhonePassword className={styles.main} />;
}
@ -33,7 +35,7 @@ const Main = ({ signInMethod, socialConnectors }: Props) => {
return <PhonePasswordless type={UserFlow.signIn} className={styles.main} />;
}
case 'username': {
case SignInIdentifier.Username: {
return <UsernameSignIn className={styles.main} />;
}

View file

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

View file

@ -22,7 +22,12 @@ const SignIn = () => {
{
// Other sign-in methods
otherMethods.length > 0 && (
<OtherMethodsLink methods={otherMethods} template="sign_in_with" flow={UserFlow.signIn} />
<OtherMethodsLink
methods={otherMethods}
template="sign_in_with"
flow={UserFlow.signIn}
search={location.search}
/>
)
}
{

View file

@ -0,0 +1,5 @@
const SignInPassword = () => {
return <div>sign in password</div>;
};
export default SignInPassword;