0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(ui): refactor continue flow (#3108)

This commit is contained in:
simeng-li 2023-02-15 11:46:40 +08:00 committed by GitHub
parent 8b27f0dc09
commit 8815b0b048
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 419 additions and 1637 deletions

View file

@ -11,7 +11,6 @@ import initI18n from './i18n/init';
import Callback from './pages/Callback';
import Consent from './pages/Consent';
import Continue from './pages/Continue';
import ContinueWithEmailOrPhone from './pages/Continue/EmailOrPhone';
import ErrorPage from './pages/ErrorPage';
import ForgotPassword from './pages/ForgotPassword';
import Register from './pages/Register';
@ -98,7 +97,6 @@ const App = () => {
{/* Continue set up missing profile */}
<Route path="continue">
<Route path="email-or-phone/:method" element={<ContinueWithEmailOrPhone />} />
<Route path=":method" element={<Continue />} />
</Route>

View file

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

View file

@ -1,52 +0,0 @@
import { SignInIdentifier } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { is } from 'superstruct';
import useSendVerificationCode from '@/hooks/use-send-verification-code-legacy';
import { UserFlow } from '@/types';
import { registeredSocialIdentityStateGuard } from '@/types/guard';
import { maskEmail } from '@/utils/format';
import EmailForm from './EmailForm';
import * as styles from './index.module.scss';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
hasSwitch?: boolean;
};
const EmailContinue = (props: Props) => {
const { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode(
UserFlow.continue,
SignInIdentifier.Email
);
const { t } = useTranslation();
const { state } = useLocation();
const hasSocialIdentity = is(state, registeredSocialIdentityStateGuard);
return (
<>
<EmailForm
onSubmit={onSubmit}
{...props}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
hasTerms={false}
/>
{hasSocialIdentity && state.registeredSocialIdentity?.email && (
<div className={styles.description}>
{t('description.social_identity_exist', {
type: t('description.email'),
value: maskEmail(state.registeredSocialIdentity.email),
})}
</div>
)}
</>
);
};
export default EmailContinue;

View file

@ -1,172 +0,0 @@
import { fireEvent, waitFor, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import EmailForm from './EmailForm';
const onSubmit = jest.fn();
const clearErrorMessage = jest.fn();
describe('<EmailForm/>', () => {
afterEach(() => {
jest.clearAllMocks();
});
test('render', () => {
const { queryByText, container } = renderWithPageContext(
<MemoryRouter>
<EmailForm onSubmit={onSubmit} />
</MemoryRouter>
);
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
test('render with terms settings', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<EmailForm onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
expect(queryByText('description.terms_of_use')).not.toBeNull();
});
test('render with terms settings but hasTerms param set to false', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<EmailForm hasTerms={false} onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
expect(queryByText('description.terms_of_use')).toBeNull();
});
test('required email with error message', () => {
const { queryByText, container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailForm onSubmit={onSubmit} />
</MemoryRouter>
);
const submitButton = getByText('action.continue');
fireEvent.click(submitButton);
expect(queryByText('invalid_email')).not.toBeNull();
expect(onSubmit).not.toBeCalled();
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: 'foo' } });
expect(queryByText('invalid_email')).not.toBeNull();
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
expect(queryByText('invalid_email')).toBeNull();
}
});
test('should display and clear the form error message as expected', () => {
const { queryByText, container } = renderWithPageContext(
<MemoryRouter>
<EmailForm
errorMessage="form error"
clearErrorMessage={clearErrorMessage}
onSubmit={onSubmit}
/>
</MemoryRouter>
);
expect(queryByText('form error')).not.toBeNull();
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: 'foo' } });
expect(clearErrorMessage).toBeCalled();
}
});
test('should blocked by terms validation with terms settings enabled', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<EmailForm onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
}
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(onSubmit).not.toBeCalled();
});
});
test('should call onSubmit properly with terms settings enabled but hasTerms param set to false', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<EmailForm hasTerms={false} onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
}
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(onSubmit).toBeCalledWith({ email: 'foo@logto.io' });
});
});
test('should call onSubmit method properly with terms settings enabled and checked', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<EmailForm onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
}
const termsButton = getByText('description.agree_with_terms');
fireEvent.click(termsButton);
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(onSubmit).toBeCalledWith({ email: 'foo@logto.io' });
});
});
});

View file

@ -1,99 +0,0 @@
import classNames from 'classnames';
import { useCallback } from 'react';
import type { TFuncKey } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import Input from '@/components/Input';
import PasswordlessSwitch from '@/containers/PasswordlessSwitch';
import TermsOfUse from '@/containers/TermsOfUse';
import useForm from '@/hooks/use-form';
import useTerms from '@/hooks/use-terms';
import { validateEmail } from '@/utils/field-validations';
import * as styles from './index.module.scss';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
hasTerms?: boolean;
hasSwitch?: boolean;
errorMessage?: string;
submitButtonText?: TFuncKey;
clearErrorMessage?: () => void;
onSubmit: (payload: { email: string }) => Promise<void> | void;
};
type FieldState = {
email: string;
};
const defaultState: FieldState = { email: '' };
const EmailForm = ({
autoFocus,
hasTerms = true,
hasSwitch = false,
errorMessage,
className,
submitButtonText = 'action.continue',
clearErrorMessage,
onSubmit,
}: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!validateForm()) {
return;
}
if (hasTerms && !(await termsValidation())) {
return;
}
await onSubmit(fieldValue);
},
[validateForm, hasTerms, termsValidation, onSubmit, fieldValue]
);
const { onChange, ...rest } = register('email', validateEmail);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input
type="email"
name="email"
autoComplete="email"
inputMode="email"
placeholder={t('input.email')}
autoFocus={autoFocus}
className={styles.inputField}
onChange={(event) => {
onChange(event);
clearErrorMessage?.();
}}
{...rest}
onClear={() => {
setFieldValue((state) => ({ ...state, email: '' }));
clearErrorMessage?.();
}}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{hasSwitch && <PasswordlessSwitch target="phone" className={styles.switch} />}
{hasTerms && <TermsOfUse className={styles.terms} />}
<Button title={submitButtonText} onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>
);
};
export default EmailForm;

View file

@ -1,31 +0,0 @@
@use '@/scss/underscore' as _;
.form {
@include _.flex-column;
> * {
width: 100%;
}
.inputField,
.terms,
.switch,
.formErrors {
margin-bottom: _.unit(4);
}
.switch {
width: auto;
align-self: start;
}
.formErrors {
margin-left: _.unit(0.5);
margin-top: _.unit(-3);
}
}
.description {
margin-top: _.unit(6);
@include _.text-hint;
}

View file

@ -1 +0,0 @@
export { default as EmailContinue } from './EmailContinue';

View file

@ -1,29 +0,0 @@
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import PasswordlessSwitch from '.';
describe('<PasswordlessSwitch />', () => {
test('render phone passwordless switch', () => {
const { queryByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/forgot-password/phone']}>
<PasswordlessSwitch target="email" />
</MemoryRouter>
);
expect(queryByText('action.switch_to')).not.toBeNull();
expect(container.querySelector('a')?.getAttribute('href')).toBe('/forgot-password/email');
});
test('render email passwordless switch', () => {
const { queryByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/forgot-password/email']}>
<PasswordlessSwitch target="phone" />
</MemoryRouter>
);
expect(queryByText('action.switch_to')).not.toBeNull();
expect(container.querySelector('a')?.getAttribute('href')).toBe('/forgot-password/phone');
});
});

View file

@ -1,32 +0,0 @@
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import SwitchIcon from '@/assets/icons/switch-icon.svg';
import TextLink from '@/components/TextLink';
type Props = {
target: 'phone' | 'email';
className?: string;
};
const PasswordlessSwitch = ({ target, className }: Props) => {
const { t } = useTranslation();
const { pathname, search } = useLocation();
const targetPathname = pathname.split('/').slice(0, -1).join('/') + `/${target}`;
return (
<TextLink
replace
className={className}
icon={<SwitchIcon />}
to={{ pathname: targetPathname, search }}
>
{t('action.switch_to', {
method: t(`description.${target === 'email' ? 'email' : 'phone_number'}`),
})}
</TextLink>
);
};
export default PasswordlessSwitch;

View file

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

View file

@ -1,52 +0,0 @@
import { SignInIdentifier } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { is } from 'superstruct';
import useSendVerificationCode from '@/hooks/use-send-verification-code-legacy';
import { UserFlow } from '@/types';
import { registeredSocialIdentityStateGuard } from '@/types/guard';
import PhoneForm from './PhoneForm';
import * as styles from './index.module.scss';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
hasSwitch?: boolean;
};
const PhoneContinue = (props: Props) => {
const { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode(
UserFlow.continue,
SignInIdentifier.Phone
);
const { t } = useTranslation();
const { state } = useLocation();
const hasSocialIdentity = is(state, registeredSocialIdentityStateGuard);
return (
<>
<PhoneForm
onSubmit={onSubmit}
{...props}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
hasTerms={false}
/>
{hasSocialIdentity && state.registeredSocialIdentity?.email && (
<div className={styles.description}>
{t('description.social_identity_exist', {
type: t('description.phone_number'),
value: state.registeredSocialIdentity.email,
})}
</div>
)}
</>
);
};
export default PhoneContinue;

View file

@ -1,179 +0,0 @@
import { fireEvent, waitFor, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
import PhoneForm from './PhoneForm';
const onSubmit = jest.fn();
const clearErrorMessage = jest.fn();
// PhoneNum CountryCode detection
jest.mock('i18next', () => ({
language: 'en',
}));
describe('<PhonePasswordless/>', () => {
afterEach(() => {
jest.clearAllMocks();
});
const phoneNumber = '8573333333';
const defaultCountryCallingCode = getDefaultCountryCallingCode();
test('render', () => {
const { queryByText, container } = renderWithPageContext(
<MemoryRouter>
<PhoneForm onSubmit={onSubmit} />
</MemoryRouter>
);
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
test('render with terms settings', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<PhoneForm onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
expect(queryByText('description.terms_of_use')).not.toBeNull();
});
test('render with terms settings but hasTerms param set to false', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<PhoneForm hasTerms={false} onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
expect(queryByText('description.terms_of_use')).toBeNull();
});
test('required phone with error message', () => {
const { queryByText, container, getByText } = renderWithPageContext(
<MemoryRouter>
<PhoneForm onSubmit={onSubmit} />
</MemoryRouter>
);
const submitButton = getByText('action.continue');
fireEvent.click(submitButton);
expect(queryByText('invalid_phone')).not.toBeNull();
expect(onSubmit).not.toBeCalled();
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: '1113' } });
expect(queryByText('invalid_phone')).not.toBeNull();
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
expect(queryByText('invalid_phone')).toBeNull();
}
});
test('should display and clear the form error message as expected', () => {
const { queryByText, container } = renderWithPageContext(
<MemoryRouter>
<PhoneForm
errorMessage="form error"
clearErrorMessage={clearErrorMessage}
onSubmit={onSubmit}
/>
</MemoryRouter>
);
expect(queryByText('form error')).not.toBeNull();
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
expect(clearErrorMessage).toBeCalled();
}
});
test('should blocked by terms validation with terms settings enabled', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<PhoneForm onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
}
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(onSubmit).not.toBeCalled();
});
});
test('should call submit method properly with terms settings enabled but hasTerms param set to false', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<PhoneForm hasTerms={false} onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
}
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(onSubmit).toBeCalledWith({ phone: `${defaultCountryCallingCode}${phoneNumber}` });
});
});
test('should call submit method properly with terms settings enabled and checked', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<SettingsProvider>
<PhoneForm onSubmit={onSubmit} />
</SettingsProvider>
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phoneNumber } });
}
const termsButton = getByText('description.agree_with_terms');
fireEvent.click(termsButton);
const submitButton = getByText('action.continue');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(onSubmit).toBeCalledWith({ phone: `${defaultCountryCallingCode}${phoneNumber}` });
});
});
});

View file

@ -1,117 +0,0 @@
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';
import ErrorMessage from '@/components/ErrorMessage';
import { PhoneInput } from '@/components/Input';
import PasswordlessSwitch from '@/containers/PasswordlessSwitch';
import TermsOfUse from '@/containers/TermsOfUse';
import useForm from '@/hooks/use-form';
import usePhoneNumber from '@/hooks/use-phone-number';
import useTerms from '@/hooks/use-terms';
import * as styles from './index.module.scss';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
hasTerms?: boolean;
hasSwitch?: boolean;
errorMessage?: string;
submitButtonText?: TFuncKey;
clearErrorMessage?: () => void;
onSubmit: (payload: { phone: string }) => Promise<void> | void;
};
type FieldState = {
phone: string;
};
const defaultState: FieldState = { phone: '' };
const PhoneForm = ({
autoFocus,
hasTerms = true,
hasSwitch = false,
className,
errorMessage,
submitButtonText = 'action.continue',
clearErrorMessage,
onSubmit,
}: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const { countryList, phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
const { fieldValue, setFieldValue, validateForm, register } = useForm(defaultState);
// Validate phoneNumber with given country code
const phoneNumberValidation = useCallback(
(phoneNumber: string) => {
if (!isValidPhoneNumber(phoneNumber)) {
return 'invalid_phone';
}
},
[isValidPhoneNumber]
);
// Sync phoneNumber
useEffect(() => {
setFieldValue((previous) => ({
...previous,
phone: `${phoneNumber.countryCallingCode}${phoneNumber.nationalNumber}`,
}));
}, [phoneNumber, setFieldValue]);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!validateForm()) {
return;
}
if (hasTerms && !(await termsValidation())) {
return;
}
await onSubmit(fieldValue);
},
[validateForm, hasTerms, termsValidation, onSubmit, fieldValue]
);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<PhoneInput
name="phone"
placeholder={t('input.phone_number')}
className={styles.inputField}
countryCallingCode={phoneNumber.countryCallingCode}
nationalNumber={phoneNumber.nationalNumber}
autoFocus={autoFocus}
countryList={countryList}
{...register('phone', phoneNumberValidation)}
onChange={(data) => {
setPhoneNumber((previous) => ({ ...previous, ...data }));
clearErrorMessage?.();
}}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{hasSwitch && <PasswordlessSwitch target="email" className={styles.switch} />}
{hasTerms && <TermsOfUse className={styles.terms} />}
<Button title={submitButtonText} onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>
);
};
export default PhoneForm;

View file

@ -1,31 +0,0 @@
@use '@/scss/underscore' as _;
.form {
@include _.flex-column;
> * {
width: 100%;
}
.inputField,
.terms,
.switch,
.formErrors {
margin-bottom: _.unit(4);
}
.switch {
align-self: start;
width: auto;
}
.formErrors {
margin-left: _.unit(0.5);
margin-top: _.unit(-3);
}
}
.description {
margin-top: _.unit(6);
@include _.text-hint;
}

View file

@ -1 +0,0 @@
export { default as PhoneContinue } from './PhoneContinue';

View file

@ -1,43 +0,0 @@
import { fireEvent, act, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { addProfile } from '@/apis/interaction';
import SetUsername from '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/interaction', () => ({
addProfile: jest.fn(async () => ({})),
}));
describe('<UsernameRegister />', () => {
test('default render', () => {
const { queryByText, container } = renderWithPageContext(<SetUsername />);
expect(container.querySelector('input[name="new-username"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
test('submit form properly', async () => {
const { getByText, container } = renderWithPageContext(<SetUsername />);
const submitButton = getByText('action.continue');
const usernameInput = container.querySelector('input[name="new-username"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(addProfile).toBeCalledWith({ username: 'username' });
});
});
});

View file

@ -1,23 +0,0 @@
import UsernameForm from '../UsernameForm';
import useSetUsername from './use-set-username';
type Props = {
className?: string;
};
const SetUsername = ({ className }: Props) => {
const { errorMessage, clearErrorMessage, onSubmit } = useSetUsername();
return (
<UsernameForm
className={className}
hasTerms={false}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
submitText="action.continue"
onSubmit={onSubmit}
/>
);
};
export default SetUsername;

View file

@ -1,136 +0,0 @@
import { fireEvent, act, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import UsernameForm from './UsernameForm';
const onSubmit = jest.fn();
const onClearErrorMessage = jest.fn();
describe('<UsernameRegister />', () => {
test('default render without terms', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider>
<UsernameForm hasTerms={false} onSubmit={onSubmit} />
</SettingsProvider>
);
expect(container.querySelector('input[name="new-username"]')).not.toBeNull();
expect(queryByText('description.terms_of_use')).toBeNull();
expect(queryByText('action.create_account')).not.toBeNull();
});
test('render with terms settings enabled', () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider>
<UsernameForm onSubmit={onSubmit} />
</SettingsProvider>
);
expect(queryByText('description.terms_of_use')).not.toBeNull();
});
test('render with error message', () => {
const { queryByText, getByText } = renderWithPageContext(
<SettingsProvider>
<UsernameForm
errorMessage="error_message"
clearErrorMessage={onClearErrorMessage}
onSubmit={onSubmit}
/>
</SettingsProvider>
);
expect(queryByText('error_message')).not.toBeNull();
const submitButton = getByText('action.create_account');
fireEvent.click(submitButton);
expect(onClearErrorMessage).toBeCalled();
});
test('username are required', () => {
const { queryByText, getByText } = renderWithPageContext(<UsernameForm onSubmit={onSubmit} />);
const submitButton = getByText('action.create_account');
fireEvent.click(submitButton);
expect(queryByText('username_required')).not.toBeNull();
expect(onSubmit).not.toBeCalled();
});
test('username with initial numeric char should throw', () => {
const { queryByText, getByText, container } = renderWithPageContext(
<UsernameForm onSubmit={onSubmit} />
);
const submitButton = getByText('action.create_account');
const usernameInput = container.querySelector('input[name="new-username"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: '1username' } });
}
fireEvent.click(submitButton);
expect(queryByText('username_should_not_start_with_number')).not.toBeNull();
expect(onSubmit).not.toBeCalled();
// Clear error
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
expect(queryByText('username_should_not_start_with_number')).toBeNull();
});
test('username with special character should throw', () => {
const { queryByText, getByText, container } = renderWithPageContext(
<UsernameForm onSubmit={onSubmit} />
);
const submitButton = getByText('action.create_account');
const usernameInput = container.querySelector('input[name="new-username"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: '@username' } });
}
fireEvent.click(submitButton);
expect(queryByText('username_invalid_charset')).not.toBeNull();
expect(onSubmit).not.toBeCalled();
// Clear error
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
expect(queryByText('username_invalid_charset')).toBeNull();
});
test('submit form properly with terms settings enabled', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<UsernameForm onSubmit={onSubmit} />
</SettingsProvider>
);
const submitButton = getByText('action.create_account');
const usernameInput = container.querySelector('input[name="new-username"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
const termsButton = getByText('description.agree_with_terms');
fireEvent.click(termsButton);
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(onSubmit).toBeCalledWith('username');
});
});
});

View file

@ -1,92 +0,0 @@
import type { I18nKey } from '@logto/phrases-ui';
import classNames from 'classnames';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import Input from '@/components/Input';
import TermsOfUse from '@/containers/TermsOfUse';
import useForm from '@/hooks/use-form';
import useTerms from '@/hooks/use-terms';
import { validateUsername } from '@/utils/field-validations';
import * as styles from './index.module.scss';
type Props = {
className?: string;
hasTerms?: boolean;
onSubmit: (username: string) => Promise<void>;
errorMessage?: string;
clearErrorMessage?: () => void;
submitText?: I18nKey;
};
type FieldState = {
username: string;
};
const defaultState: FieldState = {
username: '',
};
const UsernameForm = ({
className,
hasTerms = true,
onSubmit,
errorMessage,
submitText = 'action.create_account',
clearErrorMessage,
}: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const {
fieldValue,
setFieldValue,
register: fieldRegister,
validateForm,
} = useForm(defaultState);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
clearErrorMessage?.();
if (!validateForm()) {
return;
}
if (hasTerms && !(await termsValidation())) {
return;
}
void onSubmit(fieldValue.username);
},
[clearErrorMessage, validateForm, hasTerms, termsValidation, onSubmit, fieldValue.username]
);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input
name="new-username"
className={styles.inputField}
placeholder={t('input.username')}
{...fieldRegister('username', validateUsername)}
onClear={() => {
setFieldValue((state) => ({ ...state, username: '' }));
}}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
{hasTerms && <TermsOfUse className={styles.terms} />}
<Button title={submitText} onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>
);
};
export default UsernameForm;

View file

@ -1 +0,0 @@
export { default as SetUsername } from './SetUsername';

View file

@ -1,72 +0,0 @@
import type { FormEvent } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import type { ErrorType } from '@/components/ErrorMessage';
import type { Entries } from '@/utils';
import { entries, fromEntries } from '@/utils';
const useForm = <T extends Record<string, unknown>>(initialState: T) => {
type ErrorState = {
[key in keyof T]?: ErrorType;
};
type FieldValidations = {
[key in keyof T]?: (value: T[key]) => ErrorType | undefined;
};
const [fieldValue, setFieldValue] = useState<T>(initialState);
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
const fieldValidationsRef = useRef<FieldValidations>({});
const validateForm = useCallback(() => {
const errors: Entries<ErrorState> = entries(fieldValue).map(([key, value]) => [
key,
fieldValidationsRef.current[key]?.(value),
]);
setFieldErrors(fromEntries(errors));
return errors.every(([, error]) => error === undefined);
}, [fieldValidationsRef, fieldValue]);
const register = useCallback(
<K extends keyof T>(field: K, validation: (value: T[K]) => ErrorType | undefined) => {
// eslint-disable-next-line @silverhand/fp/no-mutation
fieldValidationsRef.current[field] = validation;
return {
value: fieldValue[field],
error: fieldErrors[field],
onChange: ({ currentTarget: { value } }: FormEvent<HTMLInputElement>) => {
setFieldValue((previous) => ({ ...previous, [field]: value }));
},
};
},
[fieldErrors, fieldValue]
);
// Revalidate on Input change
useEffect(() => {
setFieldErrors((previous) => {
const errors: Entries<ErrorState> = entries(fieldValue).map(([key, value]) => [
key,
// Only validate field with existing errors
previous[key] && fieldValidationsRef.current[key]?.(value),
]);
return fromEntries(errors);
});
}, [fieldValue, fieldValidationsRef]);
return {
fieldValue,
fieldErrors,
validateForm,
setFieldValue,
setFieldErrors,
register,
};
};
export default useForm;

View file

@ -24,7 +24,11 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial, flow }: Options =
() => ({
'user.missing_profile': (error) => {
const [, data] = validate(error.data, missingProfileErrorDataGuard);
// Required as a sign up method but missing in the user profile
const missingProfile = data?.missingProfile[0];
// Required as a sign up method can be found in Social Identity (email / phone), but registered with a different account
const registeredSocialIdentity = data?.registeredSocialIdentity;
const linkSocialQueryString = linkSocial
@ -43,18 +47,10 @@ const useRequiredProfileErrorHandler = ({ replace, linkSocial, flow }: Options =
break;
case MissingProfile.email:
case MissingProfile.phone:
navigate(
{
pathname: `/${UserFlow.continue}/${missingProfile}`,
search: linkSocialQueryString,
},
{ replace, state: { registeredSocialIdentity, flow } }
);
break;
case MissingProfile.emailOrPhone:
navigate(
{
pathname: `/${UserFlow.continue}/email-or-phone/email`,
pathname: `/${UserFlow.continue}/${missingProfile}`,
search: linkSocialQueryString,
},
{ replace, state: { registeredSocialIdentity, flow } }

View file

@ -1,79 +0,0 @@
/** TODO: to be deprecated */
import { SignInIdentifier } from '@logto/schemas';
import { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { sendVerificationCodeApi } from '@/apis/utils';
import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-error-handler';
import useErrorHandler from '@/hooks/use-error-handler';
import type { UserFlow } from '@/types';
const useSendVerificationCode = <T extends SignInIdentifier.Email | SignInIdentifier.Phone>(
flow: UserFlow,
method: T,
replaceCurrentPage?: boolean
) => {
const [errorMessage, setErrorMessage] = useState<string>();
const navigate = useNavigate();
const handleError = useErrorHandler();
const asyncSendVerificationCode = useApi(sendVerificationCodeApi);
const clearErrorMessage = useCallback(() => {
setErrorMessage('');
}, []);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'guard.invalid_input': () => {
setErrorMessage(method === SignInIdentifier.Email ? 'invalid_email' : 'invalid_phone');
},
}),
[method]
);
type Payload = T extends SignInIdentifier.Email ? { email: string } : { phone: string };
const onSubmit = useCallback(
async (payload: Payload) => {
const [error, result] = await asyncSendVerificationCode(flow, payload);
if (error) {
await handleError(error, errorHandlers);
return;
}
if (result) {
navigate(
{
pathname: `/${flow}/${method}/verification-code`,
search: location.search,
},
{
state: payload,
replace: replaceCurrentPage,
}
);
}
},
[
asyncSendVerificationCode,
errorHandlers,
flow,
handleError,
method,
navigate,
replaceCurrentPage,
]
);
return {
errorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useSendVerificationCode;

View file

@ -1,48 +0,0 @@
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import EmailOrPhone from '.';
jest.mock('i18next', () => ({
language: 'en',
}));
describe('EmailOrPhone', () => {
it('render set phone with email alterations', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider>
<MemoryRouter initialEntries={['/continue/email-or-phone/phone']}>
<Routes>
<Route path="/continue/email-or-phone/:method" element={<EmailOrPhone />} />
</Routes>
</MemoryRouter>
</SettingsProvider>
);
expect(queryByText('description.link_email_or_phone')).not.toBeNull();
expect(queryByText('description.link_email_or_phone_description')).not.toBeNull();
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
expect(queryByText('action.switch_to')).not.toBeNull();
});
it('render set email with phone alterations', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider>
<MemoryRouter initialEntries={['/continue/email-or-phone/email']}>
<Routes>
<Route path="/continue/email-or-phone/:method" element={<EmailOrPhone />} />
</Routes>
</MemoryRouter>
</SettingsProvider>
);
expect(queryByText('description.link_email_or_phone')).not.toBeNull();
expect(queryByText('description.link_email_or_phone_description')).not.toBeNull();
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
expect(queryByText('action.switch_to')).not.toBeNull();
});
});

View file

@ -1,53 +0,0 @@
import { SignInIdentifier } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useParams, useLocation } from 'react-router-dom';
import { validate } from 'superstruct';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import { EmailContinue } from '@/containers/EmailForm';
import { PhoneContinue } from '@/containers/PhoneForm';
import ErrorPage from '@/pages/ErrorPage';
import { UserFlow } from '@/types';
import { continueFlowStateGuard } from '@/types/guard';
type Parameters = {
method?: string;
};
const EmailOrPhone = () => {
const { method = '' } = useParams<Parameters>();
const { state } = useLocation();
const [_, data] = validate(state, continueFlowStateGuard);
const notification = conditional(
data?.flow === UserFlow.signIn && 'description.continue_with_more_information'
);
if (method === SignInIdentifier.Email) {
return (
<SecondaryPageWrapper
title="description.link_email_or_phone"
description="description.link_email_or_phone_description"
notification={notification}
>
<EmailContinue autoFocus hasSwitch />
</SecondaryPageWrapper>
);
}
if (method === SignInIdentifier.Phone) {
return (
<SecondaryPageWrapper
title="description.link_email_or_phone"
description="description.link_email_or_phone_description"
notification={notification}
>
<PhoneContinue autoFocus hasSwitch />
</SecondaryPageWrapper>
);
}
return <ErrorPage />;
};
export default EmailOrPhone;

View file

@ -8,12 +8,12 @@
}
.inputField,
.terms {
.formErrors {
margin-bottom: _.unit(4);
}
.formErrors {
margin-top: _.unit(-2);
margin-bottom: _.unit(4);
margin-left: _.unit(0.5);
margin-top: _.unit(-3);
}
}

View file

@ -0,0 +1,107 @@
import classNames from 'classnames';
import { useState, useCallback } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import type { IdentifierInputType } from '@/components/InputFields';
import { SmartInputField } from '@/components/InputFields';
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
import * as styles from './index.module.scss';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
defaultType: IdentifierInputType;
enabledTypes: IdentifierInputType[];
onSubmit?: (identifier: IdentifierInputType, value: string) => Promise<void> | void;
errorMessage?: string;
clearErrorMessage?: () => void;
};
type FormState = {
identifier: string;
};
const IdentifierProfileForm = ({
className,
autoFocus,
defaultType,
enabledTypes,
onSubmit,
errorMessage,
clearErrorMessage,
}: Props) => {
const { t } = useTranslation();
const [inputType, setInputType] = useState<IdentifierInputType>(defaultType);
const {
handleSubmit,
control,
formState: { errors },
} = useForm<FormState>({
reValidateMode: 'onChange',
defaultValues: { identifier: '' },
});
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage?.();
void handleSubmit(async ({ identifier }, event) => {
event?.preventDefault();
await onSubmit?.(inputType, identifier);
})(event);
},
[clearErrorMessage, handleSubmit, inputType, onSubmit]
);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Controller
control={control}
name="identifier"
rules={{
required: getGeneralIdentifierErrorMessage(enabledTypes, 'required'),
validate: (value) => {
const errorMessage = validateIdentifierField(inputType, value);
if (errorMessage) {
return typeof errorMessage === 'string'
? t(`error.${errorMessage}`)
: t(`error.${errorMessage.code}`, errorMessage.data);
}
return true;
},
}}
render={({ field }) => (
<SmartInputField
autoComplete="new-identifier"
autoFocus={autoFocus}
className={styles.inputField}
{...field}
currentType={inputType}
isDanger={!!errors.identifier}
errorMessage={errors.identifier?.message}
enabledTypes={enabledTypes}
onTypeChange={setInputType}
/>
)}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
<Button title="action.continue" htmlType="submit" />
<input hidden type="submit" />
</form>
);
};
export default IdentifierProfileForm;

View file

@ -1,38 +0,0 @@
import { SignInIdentifier } from '@logto/schemas';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import SetEmail from '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
useLocation: () => ({ pathname: '' }),
}));
describe('SetEmail', () => {
it('render set email', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
...mockSignInExperienceSettings.signUp,
identifiers: [SignInIdentifier.Email],
},
}}
>
<SetEmail />
</SettingsProvider>
);
expect(queryByText('description.link_email')).not.toBeNull();
expect(queryByText('description.link_email_description')).not.toBeNull();
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
});

View file

@ -1,20 +0,0 @@
import type { TFuncKey } from 'react-i18next';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import { EmailContinue } from '@/containers/EmailForm';
type Props = {
notification?: TFuncKey;
};
const SetEmail = (props: Props) => (
<SecondaryPageWrapper
title="description.link_email"
description="description.link_email_description"
{...props}
>
<EmailContinue autoFocus />
</SecondaryPageWrapper>
);
export default SetEmail;

View file

@ -0,0 +1,57 @@
import { SignInIdentifier } from '@logto/schemas';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { is } from 'superstruct';
import type { VerificationCodeIdentifier } from '@/types';
import { registeredSocialIdentityStateGuard } from '@/types/guard';
import { maskEmail } from '@/utils/format';
import * as styles from './index.module.scss';
const SocialIdentityNotification = ({
missingProfileTypes,
}: {
missingProfileTypes: VerificationCodeIdentifier[];
}) => {
const { t } = useTranslation();
const { state } = useLocation();
const hasSocialIdentity = is(state, registeredSocialIdentityStateGuard);
if (!hasSocialIdentity) {
return null;
}
if (
missingProfileTypes.includes(SignInIdentifier.Email) &&
state.registeredSocialIdentity?.email
) {
return (
<div className={styles.description}>
{t('description.social_identity_exist', {
type: t('description.email'),
value: maskEmail(state.registeredSocialIdentity.email),
})}
</div>
);
}
if (
missingProfileTypes.includes(SignInIdentifier.Phone) &&
state.registeredSocialIdentity?.phone
) {
return (
<div className={styles.description}>
{t('description.social_identity_exist', {
type: t('description.phone_number'),
value: state.registeredSocialIdentity.phone,
})}
</div>
);
}
return null;
};
export default SocialIdentityNotification;

View file

@ -0,0 +1,6 @@
@use '@/scss/underscore' as _;
.description {
margin-top: _.unit(6);
@include _.text-hint;
}

View file

@ -0,0 +1,101 @@
import { MissingProfile, SignInIdentifier } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { sendVerificationCodeApi } from '@/apis/utils';
import { UserFlow, VerificationCodeIdentifier } from '@/types';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
import SetEmailOrPhone, { VerificationCodeProfileType, pageContent } from '.';
const mockedNavigate = jest.fn();
// PhoneNum CountryCode detection
jest.mock('i18next', () => ({
language: 'en',
t: (key: string) => key,
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
useLocation: jest.fn(() => ({
state: {
flow: UserFlow.signIn,
registeredSocialIdentity: {
email: 'foo@logto.io',
},
},
})),
}));
jest.mock('@/apis/utils', () => ({
sendVerificationCodeApi: jest.fn(),
}));
describe('continue with email or phone', () => {
const renderPage = (missingProfile: VerificationCodeProfileType) =>
renderWithPageContext(
<SettingsProvider>
<SetEmailOrPhone missingProfile={missingProfile} />
</SettingsProvider>
);
const cases: Array<[VerificationCodeProfileType, { title: string; description: string }]> = [
[MissingProfile.email, pageContent.email],
[MissingProfile.phone, pageContent.phone],
[MissingProfile.emailOrPhone, pageContent.emailOrPhone],
];
test.each(cases)('render set %p', (type, content) => {
const { queryByText, container } = renderPage(type);
expect(queryByText(content.title)).not.toBeNull();
expect(queryByText(content.description)).not.toBeNull();
expect(container.querySelector('input[name="identifier"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
if (type === MissingProfile.email || type === MissingProfile.emailOrPhone) {
expect(queryByText('description.social_identity_exist')).not.toBeNull();
}
});
const email = 'foo@logto.io';
const phone = '8573333333';
const countryCode = getDefaultCountryCallingCode();
test.each([
[MissingProfile.email, SignInIdentifier.Email, email],
[MissingProfile.phone, SignInIdentifier.Phone, phone],
[MissingProfile.emailOrPhone, SignInIdentifier.Email, email],
[MissingProfile.emailOrPhone, SignInIdentifier.Phone, phone],
] satisfies Array<[VerificationCodeProfileType, VerificationCodeIdentifier, string]>)(
'should send verification code properly',
async (type, identifier, input) => {
const { getByLabelText, getByText, container } = renderPage(type);
const inputField = container.querySelector('input[name="identifier"]');
const submitButton = getByText('action.continue');
assert(inputField, new Error('input field not found'));
expect(submitButton).not.toBeNull();
act(() => {
fireEvent.change(inputField, { target: { value: input } });
});
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.continue, {
[identifier]: identifier === SignInIdentifier.Phone ? `${countryCode}${input}` : input,
});
});
}
);
});

View file

@ -0,0 +1,85 @@
import type { MissingProfile } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import type { TFuncKey } from 'react-i18next';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import type { VerificationCodeIdentifier } from '@/types';
import { UserFlow } from '@/types';
import IdentifierProfileForm from '../IdentifierProfileForm';
import SocialIdentityNotification from './SocialIdentityNotification';
export type VerificationCodeProfileType = Exclude<MissingProfile, 'username' | 'password'>;
type Props = {
missingProfile: VerificationCodeProfileType;
notification?: TFuncKey;
};
export const pageContent: Record<
VerificationCodeProfileType,
{
title: TFuncKey;
description: TFuncKey;
}
> = {
email: {
title: 'description.link_email',
description: 'description.link_email_description',
},
phone: {
title: 'description.link_phone',
description: 'description.link_phone_description',
},
emailOrPhone: {
title: 'description.link_email_or_phone',
description: 'description.link_email_or_phone_description',
},
};
const formSettings: Record<
VerificationCodeProfileType,
{ defaultType: VerificationCodeIdentifier; enabledTypes: VerificationCodeIdentifier[] }
> = {
email: {
defaultType: SignInIdentifier.Email,
enabledTypes: [SignInIdentifier.Email],
},
phone: {
defaultType: SignInIdentifier.Phone,
enabledTypes: [SignInIdentifier.Phone],
},
emailOrPhone: {
defaultType: SignInIdentifier.Email,
enabledTypes: [SignInIdentifier.Email, SignInIdentifier.Phone],
},
};
const SetEmailOrPhone = ({ missingProfile, notification }: Props) => {
const { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode(UserFlow.continue);
const handleSubmit = (identifier: SignInIdentifier, value: string) => {
// Only handles email and phone
if (identifier === SignInIdentifier.Username) {
return;
}
return onSubmit({ identifier, value });
};
return (
<SecondaryPageWrapper {...pageContent[missingProfile]} notification={notification}>
<IdentifierProfileForm
autoFocus
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
{...formSettings[missingProfile]}
onSubmit={handleSubmit}
/>
<SocialIdentityNotification missingProfileTypes={formSettings[missingProfile].enabledTypes} />
</SecondaryPageWrapper>
);
};
export default SetEmailOrPhone;

View file

@ -1,42 +0,0 @@
import { SignInIdentifier } from '@logto/schemas';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import SetPhone from '.';
const mockedNavigate = jest.fn();
// PhoneNum CountryCode detection
jest.mock('i18next', () => ({
language: 'en',
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
useLocation: () => ({ pathname: '' }),
}));
describe('SetPhone', () => {
it('render set phone', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
...mockSignInExperienceSettings.signUp,
identifiers: [SignInIdentifier.Phone],
},
}}
>
<SetPhone />
</SettingsProvider>
);
expect(queryByText('description.link_phone')).not.toBeNull();
expect(queryByText('description.link_phone_description')).not.toBeNull();
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
});

View file

@ -1,22 +0,0 @@
import type { TFuncKey } from 'react-i18next';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import { PhoneContinue } from '@/containers/PhoneForm';
type Props = {
notification?: TFuncKey;
};
const SetPhone = (props: Props) => {
return (
<SecondaryPageWrapper
title="description.link_phone"
description="description.link_phone_description"
{...props}
>
<PhoneContinue autoFocus />
</SecondaryPageWrapper>
);
};
export default SetPhone;

View file

@ -8,6 +8,12 @@ import SetUsername from '.';
const mockedNavigate = jest.fn();
// PhoneNum CountryCode detection
jest.mock('i18next', () => ({
language: 'en',
t: (key: string) => key,
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
@ -17,14 +23,14 @@ jest.mock('@/apis/interaction', () => ({
addProfile: jest.fn(async () => ({ redirectTo: '/' })),
}));
describe('SetPassword', () => {
it('render set-password page properly', () => {
describe('SetUsername', () => {
it('render SetUsername page properly', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider>
<SetUsername />
</SettingsProvider>
);
expect(container.querySelector('input[name="new-username"]')).not.toBeNull();
expect(container.querySelector('input[name="identifier"]')).not.toBeNull();
expect(queryByText('action.continue')).not.toBeNull();
});
@ -35,7 +41,7 @@ describe('SetPassword', () => {
</SettingsProvider>
);
const submitButton = getByText('action.continue');
const usernameInput = container.querySelector('input[name="new-username"]');
const usernameInput = container.querySelector('input[name="identifier"]');
act(() => {
if (usernameInput) {

View file

@ -1,20 +1,42 @@
import { SignInIdentifier } from '@logto/schemas';
import type { TFuncKey } from 'react-i18next';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import { SetUsername as SetUsernameForm } from '@/containers/UsernameForm';
import IdentifierProfileForm from '../IdentifierProfileForm';
import useSetUsername from './use-set-username';
type Props = {
notification?: TFuncKey;
};
const SetUsername = (props: Props) => (
<SecondaryPageWrapper
title="description.enter_username"
description="description.enter_username_description"
{...props}
>
<SetUsernameForm />
</SecondaryPageWrapper>
);
const SetUsername = (props: Props) => {
const { onSubmit, errorMessage, clearErrorMessage } = useSetUsername();
const handleSubmit = (identifier: SignInIdentifier, value: string) => {
if (identifier !== SignInIdentifier.Username) {
return;
}
return onSubmit(value);
};
return (
<SecondaryPageWrapper
title="description.enter_username"
description="description.enter_username_description"
{...props}
>
<IdentifierProfileForm
autoFocus
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
defaultType={SignInIdentifier.Username}
enabledTypes={[SignInIdentifier.Username]}
onSubmit={handleSubmit}
/>
</SecondaryPageWrapper>
);
};
export default SetUsername;

View file

@ -1,4 +1,4 @@
import { SignInIdentifier } from '@logto/schemas';
import { MissingProfile } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useLocation, useParams } from 'react-router-dom';
import { validate } from 'superstruct';
@ -7,9 +7,8 @@ import ErrorPage from '@/pages/ErrorPage';
import { UserFlow } from '@/types';
import { continueFlowStateGuard } from '@/types/guard';
import SetEmail from './SetEmail';
import SetEmailOrPhone from './SetEmailOrPhone';
import SetPassword from './SetPassword';
import SetPhone from './SetPhone';
import SetUsername from './SetUsername';
type Parameters = {
@ -26,20 +25,20 @@ const Continue = () => {
data?.flow === UserFlow.signIn && 'description.continue_with_more_information'
);
if (method === 'password') {
if (method === MissingProfile.password) {
return <SetPassword notification={notification} />;
}
if (method === SignInIdentifier.Username) {
if (method === MissingProfile.username) {
return <SetUsername notification={notification} />;
}
if (method === SignInIdentifier.Email) {
return <SetEmail notification={notification} />;
}
if (method === SignInIdentifier.Phone) {
return <SetPhone notification={notification} />;
if (
method === MissingProfile.email ||
method === MissingProfile.phone ||
method === MissingProfile.emailOrPhone
) {
return <SetEmailOrPhone notification={notification} missingProfile={method} />;
}
return <ErrorPage />;

View file

@ -41,7 +41,6 @@ const ForgotPasswordForm = ({
);
const {
setValue,
handleSubmit,
control,
formState: { errors, isSubmitted },
@ -99,10 +98,6 @@ const ForgotPasswordForm = ({
setInputType(type);
}
}}
/* Overwrite default input onChange handler */
onChange={(value) => {
setValue('identifier', value, { shouldValidate: isSubmitted, shouldDirty: true });
}}
/>
)}
/>

View file

@ -36,9 +36,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit();
const {
setValue,
handleSubmit,
formState: { errors, isSubmitted },
formState: { errors },
control,
} = useForm<FormState>({
reValidateMode: 'onChange',
@ -89,10 +88,6 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
errorMessage={errors.identifier?.message}
enabledTypes={signUpMethods}
onTypeChange={setInputType}
/* Overwrite default input onChange handler */
onChange={(value) => {
setValue('identifier', value, { shouldValidate: isSubmitted, shouldDirty: true });
}}
/>
)}
/>

View file

@ -40,10 +40,9 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
);
const {
setValue,
handleSubmit,
control,
formState: { errors, isSubmitted },
formState: { errors },
} = useForm<FormState>({
reValidateMode: 'onChange',
defaultValues: { identifier: '' },
@ -92,10 +91,6 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
errorMessage={errors.identifier?.message}
enabledTypes={enabledSignInMethods}
onTypeChange={setInputType}
/* Overwrite default input onChange handler */
onChange={(value) => {
setValue('identifier', value, { shouldValidate: isSubmitted, shouldDirty: true });
}}
/>
)}
/>

View file

@ -43,10 +43,9 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
const {
watch,
register,
setValue,
handleSubmit,
control,
formState: { errors, isSubmitted },
formState: { errors },
} = useForm<FormState>({
reValidateMode: 'onChange',
defaultValues: { identifier: '', password: '' },
@ -94,10 +93,6 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
errorMessage={errors.identifier?.message}
enabledTypes={signInMethods}
onTypeChange={setInputType}
/* Overwrite default input onChange handler */
onChange={(value) => {
setValue('identifier', value, { shouldValidate: isSubmitted, shouldDirty: true });
}}
/>
)}
/>