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:
parent
8b27f0dc09
commit
8815b0b048
43 changed files with 419 additions and 1637 deletions
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { default as EmailContinue } from './EmailContinue';
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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}` });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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;
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { default as PhoneContinue } from './PhoneContinue';
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -1 +0,0 @@
|
|||
export { default as SetUsername } from './SetUsername';
|
|
@ -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;
|
|
@ -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 } }
|
||||
|
|
|
@ -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;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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);
|
||||
}
|
||||
}
|
107
packages/ui/src/pages/Continue/IdentifierProfileForm/index.tsx
Normal file
107
packages/ui/src/pages/Continue/IdentifierProfileForm/index.tsx
Normal 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;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1,6 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.description {
|
||||
margin-top: _.unit(6);
|
||||
@include _.text-hint;
|
||||
}
|
101
packages/ui/src/pages/Continue/SetEmailOrPhone/index.test.tsx
Normal file
101
packages/ui/src/pages/Continue/SetEmailOrPhone/index.test.tsx
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
85
packages/ui/src/pages/Continue/SetEmailOrPhone/index.tsx
Normal file
85
packages/ui/src/pages/Continue/SetEmailOrPhone/index.tsx
Normal 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;
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 />;
|
||||
|
|
|
@ -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 });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -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 });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -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 });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -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 });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
Loading…
Reference in a new issue