0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

refactor(ui): refactor the register flow (#3083)

This commit is contained in:
simeng-li 2023-02-14 15:45:15 +08:00 committed by GitHub
parent 4f4c444442
commit d4182efc8b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
43 changed files with 503 additions and 1187 deletions

View file

@ -3,24 +3,16 @@ import fr from '@logto/phrases-ui/lib/locales/fr.js';
import { isStrictlyPartial } from '#src/utils/translation.js';
const customizedFrTranslation = {
secondary: {
sign_in_with: 'Customized value A',
social_bind_with: 'Customized value B',
},
};
describe('isStrictlyPartial', () => {
it('should be true when its structure is valid', () => {
expect(isStrictlyPartial(en.translation, fr.translation)).toBeTruthy();
expect(isStrictlyPartial(en.translation, customizedFrTranslation)).toBeTruthy();
});
it('should be true when the structure is partial and the existing key-value pairs are correct', () => {
expect(
isStrictlyPartial(en.translation, {
secondary: {
sign_in_with: 'Se connecter avec {{methods, list(type: disjunction;)}}',
social_bind_with: 'Se connecter avec {{methods, list(type: disjunction;)}}',
// Missing 'secondary.social_bind_with' key-value pair
},
})
@ -31,9 +23,6 @@ describe('isStrictlyPartial', () => {
expect(
isStrictlyPartial(en.translation, {
secondary: {
sign_in_with: 'Se connecter avec {{methods, list(type: disjunction;)}}',
social_bind_with:
'Vous avez déjà un compte ? Connectez-vous pour lier {{methods, list(type: disjunction;)}} avec votre identité sociale.',
foo: 'bar', // Unexpected key-value pair
},
})

View file

@ -24,8 +24,8 @@ describe('smoke testing', () => {
expect(page.url()).toBe(new URL('register', logtoConsoleUrl).href);
const usernameField = await page.waitForSelector('input[name=new-username]');
const submitButton = await page.waitForSelector('button');
const usernameField = await page.waitForSelector('input[name=identifier]');
const submitButton = await page.waitForSelector('button[name=submit]');
await usernameField.type(consoleUsername);
@ -33,7 +33,7 @@ describe('smoke testing', () => {
await submitButton.click();
await navigateToSignIn;
expect(page.url()).toBe(new URL('register/username/password', logtoConsoleUrl).href);
expect(page.url()).toBe(new URL('register/password', logtoConsoleUrl).href);
const passwordField = await page.waitForSelector('input[name=newPassword]');
const confirmPasswordField = await page.waitForSelector('input[name=confirmPassword]');

View file

@ -9,8 +9,6 @@ const translation = {
confirm_password: 'Passwort bestätigen',
},
secondary: {
sign_in_with: 'Anmelden mit {{methods, list(type: disjunction;)}}',
register_with: 'Konto mit {{methods, list(type: disjunction;)}} erstellen',
social_bind_with:
'Besitzt du schon ein Konto? Melde dich an, um {{methods, list(type: disjunction;)}} mit deiner Identität zu verbinden.',
},

View file

@ -7,8 +7,6 @@ const translation = {
confirm_password: 'Confirm password',
},
secondary: {
sign_in_with: 'Sign in with {{methods, list(type: disjunction;)}}',
register_with: 'Create account with {{methods, list(type: disjunction;)}}',
social_bind_with:
'Already had an account? Sign in to link {{methods, list(type: disjunction;)}} with your social identity.',
},

View file

@ -9,8 +9,6 @@ const translation = {
confirm_password: 'Confirmer le mot de passe',
},
secondary: {
sign_in_with: 'Se connecter avec {{methods, list(type: disjunction;)}}',
register_with: 'Create account with {{methods, list(type: disjunction;)}}', // UNTRANSLATED
social_bind_with:
'Vous avez déjà un compte ? Connectez-vous pour lier {{methods, list(type: disjunction;)}} avec votre identité sociale.',
},

View file

@ -9,8 +9,6 @@ const translation = {
confirm_password: '비밀번호 확인',
},
secondary: {
sign_in_with: '{{methods, list(type: disjunction;)}} 로그인',
register_with: '{{methods, list(type: disjunction;)}} 회원가입',
social_bind_with:
'이미 계정이 있으신가요? {{methods, list(type: disjunction;)}}로 로그인 해보세요!',
},

View file

@ -9,8 +9,6 @@ const translation = {
confirm_password: 'Confirme a senha',
},
secondary: {
sign_in_with: 'Entrar com {{methods, list(type: disjunction;)}}',
register_with: 'Criar conta com {{methods, list(type: disjunction;)}}',
social_bind_with:
'Já tinha uma conta? Faça login no link {{methods, list(type: disjunction;)}} com sua identidade social.',
},

View file

@ -9,8 +9,6 @@ const translation = {
confirm_password: 'Confirmar password',
},
secondary: {
sign_in_with: 'Entrar com {{methods, list(type: disjunction;)}}',
register_with: 'Create account with {{methods, list(type: disjunction;)}}', // UNTRANSLATED
social_bind_with:
'Já tem uma conta? Faça login para agregar {{methods, list(type: disjunction;)}} com a sua identidade social.',
},

View file

@ -9,8 +9,6 @@ const translation = {
confirm_password: 'Şifreyi Doğrula',
},
secondary: {
sign_in_with: '{{methods, list(type: disjunction;)}} ile giriş yapınız',
register_with: 'Create account with {{methods, list(type: disjunction;)}}', // UNTRANSLATED
social_bind_with:
'Hesabınız zaten var mı? {{methods, list(type: disjunction;)}} bağlantısına tıklayarak giriş yapabilirsiniz',
},

View file

@ -9,8 +9,6 @@ const translation = {
confirm_password: '确认密码',
},
secondary: {
sign_in_with: '通过 {{methods, list(type: disjunction;), zhOrSpaces}} 登录',
register_with: '通过 {{methods, list(type: disjunction;)}} 注册',
social_bind_with:
'绑定到已有账户? 使用 {{methods, list(type: disjunction;), zhOrSpaces}} 登录并绑定。',
},

View file

@ -14,10 +14,9 @@ import Continue from './pages/Continue';
import ContinueWithEmailOrPhone from './pages/Continue/EmailOrPhone';
import ErrorPage from './pages/ErrorPage';
import ForgotPassword from './pages/ForgotPassword';
import PasswordRegisterWithUsername from './pages/PasswordRegisterWithUsername';
import Register from './pages/Register';
import RegisterPassword from './pages/RegisterPassword';
import ResetPassword from './pages/ResetPassword';
import SecondaryRegister from './pages/SecondaryRegister';
import SignIn from './pages/SignIn';
import SignInPassword from './pages/SignInPassword';
import SocialLanding from './pages/SocialLanding';
@ -88,8 +87,7 @@ const App = () => {
index
element={isSignInOnly ? <Navigate replace to="/sign-in" /> : <Register />}
/>
<Route path="username/password" element={<PasswordRegisterWithUsername />} />
<Route path=":method" element={<SecondaryRegister />} />
<Route path="password" element={<RegisterPassword />} />
</Route>
{/* Forgot password */}

View file

@ -1,27 +0,0 @@
@use '@/scss/underscore' as _;
.form {
@include _.flex-column;
> * {
width: 100%;
}
.inputField {
margin-bottom: _.unit(4);
}
.formFields {
margin-bottom: _.unit(8);
}
.terms {
margin-bottom: _.unit(4);
}
}
:global(body.desktop) {
.formFields {
margin-bottom: _.unit(2);
}
}

View file

@ -1,229 +0,0 @@
import { fireEvent, waitFor, act } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { registerWithUsernamePassword } from '@/apis/interaction';
import CreateAccount from '.';
jest.mock('@/apis/interaction', () => ({
registerWithUsernamePassword: jest.fn(async () => ({ redirectTo: '/' })),
}));
describe('<CreateAccount/>', () => {
test('default render', () => {
const { queryByText, container } = renderWithPageContext(<CreateAccount />);
expect(container.querySelector('input[name="new-username"]')).not.toBeNull();
expect(container.querySelector('input[name="new-password"]')).not.toBeNull();
expect(container.querySelector('input[name="confirm-new-password"]')).not.toBeNull();
expect(queryByText('action.create_account')).not.toBeNull();
});
test('render with terms settings enabled', () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider>
<CreateAccount />
</SettingsProvider>
);
expect(queryByText('description.terms_of_use')).not.toBeNull();
});
test('username and password are required', () => {
const { queryByText, getByText } = renderWithPageContext(<CreateAccount />);
const submitButton = getByText('action.create_account');
fireEvent.click(submitButton);
expect(queryByText('username_required')).not.toBeNull();
expect(queryByText('password_required')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled();
});
test('username with initial numeric char should throw', () => {
const { queryByText, getByText, container } = renderWithPageContext(<CreateAccount />);
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(registerWithUsernamePassword).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(<CreateAccount />);
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(registerWithUsernamePassword).not.toBeCalled();
// Clear error
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
expect(queryByText('username_invalid_charset')).toBeNull();
});
test('password less than 6 chars should throw', () => {
const { queryByText, getByText, container } = renderWithPageContext(<CreateAccount />);
const submitButton = getByText('action.create_account');
const passwordInput = container.querySelector('input[name="new-password"]');
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '12345' } });
}
fireEvent.click(submitButton);
expect(queryByText('password_min_length')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled();
// Clear error
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '123456' } });
}
expect(queryByText('password_min_length')).toBeNull();
});
test('password mismatch with confirmPassword should throw', () => {
const { queryByText, getByText, container } = renderWithPageContext(<CreateAccount />);
const submitButton = getByText('action.create_account');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
const usernameInput = container.querySelector('input[name="username"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '123456' } });
}
if (confirmPasswordInput) {
fireEvent.change(confirmPasswordInput, { target: { value: '012345' } });
}
fireEvent.click(submitButton);
expect(queryByText('passwords_do_not_match')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled();
// Clear Error
if (confirmPasswordInput) {
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
}
expect(queryByText('passwords_do_not_match')).toBeNull();
});
test('should clear value when click clear button', async () => {
const { queryByText, getByText, container } = renderWithPageContext(<CreateAccount />);
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
const usernameInput = container.querySelector('input[name="new-username"]');
const submitButton = getByText('action.create_account');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '123456' } });
}
if (confirmPasswordInput) {
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
}
const confirmClearButton = confirmPasswordInput?.parentElement?.querySelector('svg');
const usernameClearButton = usernameInput?.parentElement?.querySelector('svg');
const passwordClearButton = passwordInput?.parentElement?.querySelector('svg');
if (confirmClearButton) {
fireEvent.mouseDown(confirmClearButton);
}
await waitFor(() => {
fireEvent.click(submitButton);
});
expect(queryByText('passwords_do_not_match')).not.toBeNull();
if (usernameClearButton) {
fireEvent.mouseDown(usernameClearButton);
}
if (passwordClearButton) {
fireEvent.mouseDown(passwordClearButton);
}
await waitFor(() => {
fireEvent.click(submitButton);
});
expect(queryByText('username_required')).not.toBeNull();
expect(queryByText('password_required')).not.toBeNull();
});
test('submit form properly with terms settings enabled', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<CreateAccount />
</SettingsProvider>
);
const submitButton = getByText('action.create_account');
const passwordInput = container.querySelector('input[name="new-password"]');
const confirmPasswordInput = container.querySelector('input[name="confirm-new-password"]');
const usernameInput = container.querySelector('input[name="new-username"]');
if (usernameInput) {
fireEvent.change(usernameInput, { target: { value: 'username' } });
}
if (passwordInput) {
fireEvent.change(passwordInput, { target: { value: '123456' } });
}
if (confirmPasswordInput) {
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
}
const termsButton = getByText('description.agree_with_terms');
fireEvent.click(termsButton);
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(registerWithUsernamePassword).toBeCalledWith('username', '123456');
});
});
});

View file

@ -1,140 +0,0 @@
import classNames from 'classnames';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { registerWithUsernamePassword } from '@/apis/interaction';
import Button from '@/components/Button';
import Input from '@/components/Input';
import TermsOfUse from '@/containers/TermsOfUse';
import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-error-handler';
import useErrorHandler from '@/hooks/use-error-handler';
import useForm from '@/hooks/use-form';
import useTerms from '@/hooks/use-terms';
import {
validateUsername,
passwordValidation,
confirmPasswordValidation,
} 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;
};
type FieldState = {
username: string;
password: string;
confirmPassword: string;
};
const defaultState: FieldState = {
username: '',
password: '',
confirmPassword: '',
};
const CreateAccount = ({ className, autoFocus }: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const {
fieldValue,
setFieldValue,
setFieldErrors,
register: fieldRegister,
validateForm,
} = useForm(defaultState);
const asyncRegister = useApi(registerWithUsernamePassword);
const handleError = useErrorHandler();
const registerErrorHandlers: ErrorHandlers = useMemo(
() => ({
'user.username_already_in_use': () => {
setFieldErrors((state) => ({
...state,
username: 'username_exists',
}));
},
}),
[setFieldErrors]
);
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
if (!validateForm()) {
return;
}
if (!(await termsValidation())) {
return;
}
const [error, result] = await asyncRegister(fieldValue.username, fieldValue.password);
if (error) {
await handleError(error, registerErrorHandlers);
return;
}
if (result?.redirectTo) {
window.location.replace(result.redirectTo);
}
},
[validateForm, termsValidation, asyncRegister, fieldValue, handleError, registerErrorHandlers]
);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<Input
autoFocus={autoFocus}
className={styles.inputField}
name="new-username"
placeholder={t('input.username')}
{...fieldRegister('username', validateUsername)}
onClear={() => {
setFieldValue((state) => ({ ...state, username: '' }));
}}
/>
<Input
className={styles.inputField}
name="new-password"
type="password"
autoComplete="new-password"
placeholder={t('input.password')}
{...fieldRegister('password', passwordValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, password: '' }));
}}
/>
<Input
className={styles.inputField}
name="confirm-new-password"
type="password"
autoComplete="new-password"
placeholder={t('input.confirm_password')}
{...fieldRegister('confirmPassword', (confirmPassword) =>
confirmPasswordValidation(fieldValue.password, confirmPassword)
)}
isErrorStyling={false}
onClear={() => {
setFieldValue((state) => ({ ...state, confirmPassword: '' }));
}}
/>
<TermsOfUse className={styles.terms} />
<Button title="action.create_account" onClick={async () => onSubmitHandler()} />
<input hidden type="submit" />
</form>
);
};
export default CreateAccount;

View file

@ -1,52 +0,0 @@
import { InteractionEvent } from '@logto/schemas';
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 EmailRegister from './EmailRegister';
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('EmailRegister', () => {
const email = 'foo@logto.io';
test('register form submit', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<EmailRegister />
</MemoryRouter>
);
const emailInput = container.querySelector('input[name="email"]');
if (emailInput) {
fireEvent.change(emailInput, { target: { value: email } });
}
const submitButton = getByText('action.create_account');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(putInteraction).toBeCalledWith(InteractionEvent.Register);
expect(sendVerificationCode).toBeCalledWith({ email });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/register/email/verification-code', search: '' },
{ state: { email } }
);
});
});
});

View file

@ -1,31 +0,0 @@
import { SignInIdentifier } from '@logto/schemas';
import useSendVerificationCode from '@/hooks/use-send-verification-code-legacy';
import { UserFlow } from '@/types';
import EmailForm from './EmailForm';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
};
const EmailRegister = (props: Props) => {
const { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode(
UserFlow.register,
SignInIdentifier.Email
);
return (
<EmailForm
onSubmit={onSubmit}
{...props}
submitButtonText="action.create_account"
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
/>
);
};
export default EmailRegister;

View file

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

View file

@ -1,71 +0,0 @@
@use '@/scss/underscore' as _;
@mixin link-split {
&::after {
content: '';
width: 1px;
height: 12px;
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
background-color: var(--color-brand-default);
opacity: 60%;
}
}
.textLink {
text-align: center;
.signInMethodLink {
font-size: inherit;
}
}
.methodsLinkList {
@include _.flex-row;
justify-content: center;
.signInMethodLink {
padding: 0 _.unit(4);
position: relative;
@include link-split;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
&::after {
content: none;
}
}
}
}
:global(body.desktop) {
.methodsLinkList {
.signInMethodLink {
padding: 0 _.unit(5);
position: relative;
@include link-split;
&:first-child {
padding-left: 0;
}
&:last-child {
padding-right: 0;
&::after {
content: none;
}
}
}
}
}

View file

@ -1,64 +0,0 @@
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import type { TFuncKey } from 'react-i18next';
import { useTranslation } from 'react-i18next';
import reactStringReplace from 'react-string-replace';
import TextLink from '@/components/TextLink';
import type { UserFlow } from '@/types';
import * as styles from './index.module.scss';
type Props = {
methods: SignInIdentifier[];
flow: Exclude<UserFlow, 'forgot-password'>;
className?: string;
template: TFuncKey<'translation', 'secondary'>;
};
const SignInMethodsKeyMap: {
[key in SignInIdentifier]: TFuncKey<'translation', 'input'>;
} = {
[SignInIdentifier.Username]: 'username',
[SignInIdentifier.Email]: 'email',
[SignInIdentifier.Phone]: 'phone_number',
};
const OtherMethodsLink = ({ methods, template, flow, className }: Props) => {
const { t } = useTranslation();
const methodsLink = useMemo(
() =>
methods.map((identifier) => (
<TextLink
key={identifier}
className={styles.signInMethodLink}
type="inlinePrimary"
text={`input.${SignInMethodsKeyMap[identifier]}`}
to={{ pathname: `/${flow}/${identifier}` }}
/>
)),
[flow, methods]
);
if (methodsLink.length === 0) {
return null;
}
// Raw i18n text
const rawText = t(`secondary.${template}`, { methods });
// Replace with link element
const textWithLink: ReactNode = methods.reduce<ReactNode>(
(content, identifier, index) =>
// @ts-expect-error: reactStringReplace type bug, using deprecated ReactNodeArray as its input type
reactStringReplace(content, identifier, () => methodsLink[index]),
rawText
);
return <div className={classNames(styles.textLink, className)}>{textWithLink}</div>;
};
export default OtherMethodsLink;

View file

@ -1,60 +0,0 @@
import { InteractionEvent } from '@logto/schemas';
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 PhoneRegister from './PhoneRegister';
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('PhoneRegister', () => {
const phone = '8573333333';
const defaultCountryCallingCode = getDefaultCountryCallingCode();
const fullPhoneNumber = `${defaultCountryCallingCode}${phone}`;
test('register form submit', async () => {
const { container, getByText } = renderWithPageContext(
<MemoryRouter>
<PhoneRegister />
</MemoryRouter>
);
const phoneInput = container.querySelector('input[name="phone"]');
if (phoneInput) {
fireEvent.change(phoneInput, { target: { value: phone } });
}
const submitButton = getByText('action.create_account');
act(() => {
fireEvent.click(submitButton);
});
await waitFor(() => {
expect(putInteraction).toBeCalledWith(InteractionEvent.Register);
expect(sendVerificationCode).toBeCalledWith({ phone: fullPhoneNumber });
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/register/phone/verification-code', search: '' },
{ state: { phone: fullPhoneNumber } }
);
});
});
});

View file

@ -1,31 +0,0 @@
import { SignInIdentifier } from '@logto/schemas';
import useSendVerificationCode from '@/hooks/use-send-verification-code-legacy';
import { UserFlow } from '@/types';
import PhoneForm from './PhoneForm';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
};
const PhoneRegister = (props: Props) => {
const { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode(
UserFlow.register,
SignInIdentifier.Phone
);
return (
<PhoneForm
onSubmit={onSubmit}
{...props}
submitButtonText="action.create_account"
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
/>
);
};
export default PhoneRegister;

View file

@ -1,3 +1,2 @@
export { default as PhoneRegister } from './PhoneRegister';
export { default as PhoneResetPassword } from './PhoneResetPassword';
export { default as PhoneContinue } from './PhoneContinue';

View file

@ -54,7 +54,6 @@ const SetPassword = ({
void handleSubmit((data, event) => {
onSubmit(data.newPassword);
event?.preventDefault();
})(event);
},
[clearErrorMessage, handleSubmit, onSubmit]

View file

@ -1,51 +0,0 @@
import { fireEvent, act, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { registerWithUsernamePassword } from '@/apis/interaction';
import UsernameRegister from '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/interaction', () => ({
registerWithUsernamePassword: jest.fn(async () => ({})),
}));
describe('<UsernameRegister />', () => {
test('default render', () => {
const { queryByText, container } = renderWithPageContext(<UsernameRegister />);
expect(container.querySelector('input[name="new-username"]')).not.toBeNull();
expect(queryByText('action.create_account')).not.toBeNull();
});
test('submit form properly', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<UsernameRegister />
</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(registerWithUsernamePassword).toBeCalledWith('username');
});
});
});

View file

@ -1,21 +0,0 @@
import UsernameForm from '../UsernameForm';
import useUsernameRegister from './use-username-register';
type Props = {
className?: string;
};
const UsernameRegister = ({ className }: Props) => {
const { errorMessage, clearErrorMessage, onSubmit } = useUsernameRegister();
return (
<UsernameForm
className={className}
errorMessage={errorMessage}
clearErrorMessage={clearErrorMessage}
onSubmit={onSubmit}
/>
);
};
export default UsernameRegister;

View file

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

View file

@ -7,8 +7,7 @@ import { useNavigate } from 'react-router-dom';
import { sendVerificationCodeApi } from '@/apis/utils';
import useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler';
import type { VerificationCodeIdentifier } from '@/types';
import { UserFlow } from '@/types';
import type { VerificationCodeIdentifier, UserFlow } from '@/types';
const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) => {
const [errorMessage, setErrorMessage] = useState<string>();
@ -28,7 +27,7 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
const onSubmit = useCallback(
async ({ identifier, value }: Payload) => {
const [error, result] = await asyncSendVerificationCode(UserFlow.signIn, {
const [error, result] = await asyncSendVerificationCode(flow, {
[identifier]: value,
});
@ -57,7 +56,7 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
);
}
},
[asyncSendVerificationCode, handleError, navigate, replaceCurrentPage]
[asyncSendVerificationCode, flow, handleError, navigate, replaceCurrentPage]
);
return {

View file

@ -0,0 +1,19 @@
@use '@/scss/underscore' as _;
.form {
@include _.flex-column;
> * {
width: 100%;
}
.inputField,
.terms,
.formErrors {
margin-bottom: _.unit(4);
}
.formErrors {
margin-top: _.unit(-3);
}
}

View file

@ -0,0 +1,268 @@
import { SignInIdentifier } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { fireEvent, act, waitFor } from '@testing-library/react';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { registerWithUsernamePassword } from '@/apis/interaction';
import { sendVerificationCodeApi } from '@/apis/utils';
import { UserFlow } from '@/types';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
import IdentifierRegisterForm from '.';
const mockedNavigate = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate,
}));
jest.mock('i18next', () => ({
...jest.requireActual('i18next'),
language: 'en',
t: (key: string) => key,
}));
jest.mock('@/apis/utils', () => ({
sendVerificationCodeApi: jest.fn(),
}));
jest.mock('@/apis/interaction', () => ({
registerWithUsernamePassword: jest.fn(async () => ({})),
}));
const renderForm = (signUpMethods: SignInIdentifier[] = [SignInIdentifier.Username]) => {
return renderWithPageContext(
<SettingsProvider>
<IdentifierRegisterForm signUpMethods={signUpMethods} />
</SettingsProvider>
);
};
describe('<IdentifierRegisterForm />', () => {
afterEach(() => {
jest.clearAllMocks();
});
describe.each([
[SignInIdentifier.Username],
[SignInIdentifier.Email],
[SignInIdentifier.Phone],
[SignInIdentifier.Email, SignInIdentifier.Phone],
])('username %o register form', (...signUpMethods) => {
test('default render', () => {
const { queryByText, container } = renderForm(signUpMethods);
expect(container.querySelector('input[name="identifier"]')).not.toBeNull();
expect(queryByText('action.create_account')).not.toBeNull();
expect(queryByText('description.terms_of_use')).not.toBeNull();
});
test('identifier are required', async () => {
const { queryByText, getByText } = renderForm(signUpMethods);
const submitButton = getByText('action.create_account');
act(() => {
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(queryByText('error.general_required')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled();
expect(sendVerificationCodeApi).not.toBeCalled();
});
});
});
describe('username register form', () => {
test('username with initial numeric char should throw', async () => {
const { queryByText, getByText, container } = renderForm();
const submitButton = getByText('action.create_account');
const usernameInput = container.querySelector('input[name="identifier"]');
assert(usernameInput, new Error('username input not found'));
act(() => {
fireEvent.change(usernameInput, { target: { value: '1username' } });
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(queryByText('error.username_should_not_start_with_number')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled();
});
act(() => {
fireEvent.change(usernameInput, { target: { value: 'username' } });
});
await waitFor(() => {
expect(queryByText('error.username_should_not_start_with_number')).toBeNull();
});
});
test('username with special character should throw', async () => {
const { queryByText, getByText, container } = renderForm();
const submitButton = getByText('action.create_account');
const usernameInput = container.querySelector('input[name="identifier"]');
assert(usernameInput, new Error('username input not found'));
act(() => {
fireEvent.change(usernameInput, { target: { value: '@username' } });
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(queryByText('error.username_invalid_charset')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled();
});
act(() => {
fireEvent.change(usernameInput, { target: { value: 'username' } });
});
await waitFor(() => {
expect(queryByText('error.username_invalid_charset')).toBeNull();
});
});
test('submit properly', async () => {
const { getByText, container } = renderForm();
const submitButton = getByText('action.create_account');
const termsButton = getByText('description.agree_with_terms');
const usernameInput = container.querySelector('input[name="identifier"]');
assert(usernameInput, new Error('username input not found'));
act(() => {
fireEvent.change(usernameInput, { target: { value: 'username' } });
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(registerWithUsernamePassword).not.toBeCalled();
});
act(() => {
fireEvent.click(termsButton);
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(registerWithUsernamePassword).toBeCalledWith('username');
});
});
});
describe.each([[SignInIdentifier.Email], [SignInIdentifier.Email, SignInIdentifier.Phone]])(
'email register form with sign up settings %o',
(...signUpMethods) => {
test('email with invalid format should throw', async () => {
const { queryByText, getByText, container } = renderForm(signUpMethods);
const submitButton = getByText('action.create_account');
const emailInput = container.querySelector('input[name="identifier"]');
assert(emailInput, new Error('email input not found'));
act(() => {
fireEvent.change(emailInput, { target: { value: 'invalid' } });
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(queryByText('error.invalid_email')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled();
expect(sendVerificationCodeApi).not.toBeCalled();
});
act(() => {
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
});
await waitFor(() => {
expect(queryByText('error.invalid_email')).toBeNull();
});
});
test('submit properly', async () => {
const { getByText, container } = renderForm(signUpMethods);
const submitButton = getByText('action.create_account');
const termsButton = getByText('description.agree_with_terms');
const emailInput = container.querySelector('input[name="identifier"]');
assert(emailInput, new Error('email input not found'));
act(() => {
fireEvent.change(emailInput, { target: { value: 'foo@logto.io' } });
fireEvent.click(termsButton);
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(registerWithUsernamePassword).not.toBeCalled();
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.register, {
email: 'foo@logto.io',
});
});
});
}
);
describe.each([[SignInIdentifier.Phone], [SignInIdentifier.Email, SignInIdentifier.Phone]])(
'phone register form with sign up settings %o',
(...signUpMethods) => {
test('phone with invalid format should throw', async () => {
const { queryByText, getByText, container } = renderForm(signUpMethods);
const submitButton = getByText('action.create_account');
const phoneInput = container.querySelector('input[name="identifier"]');
assert(phoneInput, new Error('phone input not found'));
act(() => {
fireEvent.change(phoneInput, { target: { value: '1234' } });
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(queryByText('error.invalid_phone')).not.toBeNull();
expect(registerWithUsernamePassword).not.toBeCalled();
expect(sendVerificationCodeApi).not.toBeCalled();
});
act(() => {
fireEvent.change(phoneInput, { target: { value: '8573333333' } });
});
await waitFor(() => {
expect(queryByText('error.invalid_phone')).toBeNull();
});
});
test('submit properly', async () => {
const { getByText, container } = renderForm(signUpMethods);
const submitButton = getByText('action.create_account');
const termsButton = getByText('description.agree_with_terms');
const phoneInput = container.querySelector('input[name="identifier"]');
assert(phoneInput, new Error('phone input not found'));
act(() => {
fireEvent.change(phoneInput, { target: { value: '8573333333' } });
fireEvent.click(termsButton);
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(registerWithUsernamePassword).not.toBeCalled();
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.register, {
phone: `${getDefaultCountryCallingCode()}8573333333`,
});
});
});
}
);
});

View file

@ -0,0 +1,106 @@
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useState, useCallback } from 'react';
import { useForm } 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 TermsOfUse from '@/containers/TermsOfUse';
import useTerms from '@/hooks/use-terms';
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
import * as styles from './index.module.scss';
import useOnSubmit from './use-on-submit';
type Props = {
className?: string;
// eslint-disable-next-line react/boolean-prop-naming
autoFocus?: boolean;
signUpMethods: SignInIdentifier[];
};
type FormState = {
identifier: string;
};
const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const [inputType, setInputType] = useState<IdentifierInputType>(
signUpMethods[0] ?? SignInIdentifier.Username
);
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit();
const {
register,
setValue,
handleSubmit,
formState: { errors, isSubmitted },
} = useForm<FormState>({
reValidateMode: 'onChange',
defaultValues: { identifier: '' },
});
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage();
void handleSubmit(async ({ identifier }, event) => {
if (!(await termsValidation())) {
return;
}
await onSubmit(inputType, identifier);
})(event);
},
[clearErrorMessage, handleSubmit, inputType, onSubmit, termsValidation]
);
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<SmartInputField
required
autoComplete="new-identifier"
autoFocus={autoFocus}
className={styles.inputField}
currentType={inputType}
isDanger={!!errors.identifier || !!errorMessage}
errorMessage={errors.identifier?.message}
enabledTypes={signUpMethods}
onTypeChange={setInputType}
{...register('identifier', {
required: getGeneralIdentifierErrorMessage(signUpMethods, '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;
},
})}
/* Overwrite default input onChange handler */
onChange={(value) => {
setValue('identifier', value, { shouldValidate: isSubmitted, shouldDirty: true });
}}
/>
{errorMessage && <ErrorMessage className={styles.formErrors}>{errorMessage}</ErrorMessage>}
<TermsOfUse className={styles.terms} />
<Button name="submit" title="action.create_account" htmlType="submit" />
<input hidden type="submit" />
</form>
);
};
export default IdentifierRegisterForm;

View file

@ -0,0 +1,50 @@
import { SignInIdentifier } from '@logto/schemas';
import { useCallback } from 'react';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import { UserFlow } from '@/types';
import useRegisterWithUsername from './use-register-with-username';
// TODO: extract the errorMessage and clear method from useRegisterWithUsername and useSendVerificationCode
const useOnSubmit = () => {
const {
errorMessage: usernameRegisterErrorMessage,
clearErrorMessage: clearUsernameRegisterErrorMessage,
onSubmit: registerWithUsername,
} = useRegisterWithUsername();
const {
errorMessage: sendVerificationCodeErrorMessage,
clearErrorMessage: clearSendVerificationCodeErrorMessage,
onSubmit: sendVerificationCode,
} = useSendVerificationCode(UserFlow.register);
const clearErrorMessage = useCallback(() => {
clearUsernameRegisterErrorMessage();
clearSendVerificationCodeErrorMessage();
}, [clearSendVerificationCodeErrorMessage, clearUsernameRegisterErrorMessage]);
const onSubmit = useCallback(
async (identifier: SignInIdentifier, value: string) => {
if (identifier === SignInIdentifier.Username) {
await registerWithUsername(value);
return;
}
await sendVerificationCode({ identifier, value });
},
[registerWithUsername, sendVerificationCode]
);
return {
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
errorMessage: usernameRegisterErrorMessage || sendVerificationCodeErrorMessage,
clearErrorMessage,
onSubmit,
};
};
export default useOnSubmit;

View file

@ -1,4 +1,3 @@
import { SignInIdentifier } from '@logto/schemas';
import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
@ -6,9 +5,8 @@ import { registerWithUsernamePassword } from '@/apis/interaction';
import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-error-handler';
import useErrorHandler from '@/hooks/use-error-handler';
import { UserFlow } from '@/types';
const useUsernameRegister = () => {
const useRegisterWithUsername = () => {
const navigate = useNavigate();
const [errorMessage, setErrorMessage] = useState<string>();
@ -22,7 +20,7 @@ const useUsernameRegister = () => {
setErrorMessage(error.message);
},
'user.missing_profile': () => {
navigate(`/${UserFlow.register}/${SignInIdentifier.Username}/password`);
navigate('password');
},
}),
[navigate]
@ -45,4 +43,4 @@ const useUsernameRegister = () => {
return { errorMessage, clearErrorMessage, onSubmit };
};
export default useUsernameRegister;
export default useRegisterWithUsername;

View file

@ -1,36 +0,0 @@
import type { SignInIdentifier, ConnectorMetadata } from '@logto/schemas';
import { EmailRegister } from '@/containers/EmailForm';
import { PhoneRegister } from '@/containers/PhoneForm';
import SocialSignIn from '@/containers/SocialSignIn';
import { UsernameRegister } from '@/containers/UsernameForm';
import * as styles from './index.module.scss';
type Props = {
signUpMethod?: SignInIdentifier;
socialConnectors: ConnectorMetadata[];
};
const Main = ({ signUpMethod, socialConnectors }: Props) => {
switch (signUpMethod) {
case 'email':
return <EmailRegister className={styles.main} />;
case 'phone':
return <PhoneRegister className={styles.main} />;
case 'username':
return <UsernameRegister className={styles.main} />;
default: {
if (socialConnectors.length > 0) {
return <SocialSignIn />;
}
return null;
}
}
};
export default Main;

View file

@ -1,3 +1,4 @@
import type { SignUp } from '@logto/schemas';
import { SignInMode, SignInIdentifier } from '@logto/schemas';
import { MemoryRouter } from 'react-router-dom';
@ -5,22 +6,39 @@ import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import Register from '@/pages/Register';
import type { SignInExperienceResponse } from '@/types';
jest.mock('i18next', () => ({
language: 'en',
t: (key: string) => key,
}));
describe('<Register />', () => {
test('renders with username as primary', async () => {
const { queryAllByText, container } = renderWithPageContext(
<SettingsProvider>
const renderRegisterPage = (settings?: Partial<SignInExperienceResponse>) =>
renderWithPageContext(
<SettingsProvider settings={{ ...mockSignInExperienceSettings, ...settings }}>
<MemoryRouter>
<Register />
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelector('input[name="new-username"]')).not.toBeNull();
const signUpTestCases: SignUp[] = [
[SignInIdentifier.Username],
[SignInIdentifier.Email],
[SignInIdentifier.Phone],
[SignInIdentifier.Phone, SignInIdentifier.Email],
].map((identifiers) => ({
identifiers,
password: true,
verify: true,
}));
test.each(signUpTestCases)('renders with %o sign up settings', async (...signUp) => {
const { queryByText, queryAllByText, container } = renderRegisterPage();
expect(container.querySelector('input[name="identifier"]')).not.toBeNull();
expect(queryByText('action.create_account')).not.toBeNull();
// Social
expect(queryAllByText('action.sign_in_with')).toHaveLength(
@ -28,96 +46,21 @@ describe('<Register />', () => {
);
});
test('renders with email passwordless as primary', async () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
...mockSignInExperienceSettings.signUp,
identifiers: [SignInIdentifier.Email],
},
}}
>
<MemoryRouter>
<Register />
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelector('input[name="email"]')).not.toBeNull();
expect(queryByText('action.create_account')).not.toBeNull();
});
test('renders with phone passwordless as primary', async () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
...mockSignInExperienceSettings.signUp,
identifiers: [SignInIdentifier.Phone],
},
}}
>
<MemoryRouter>
<Register />
</MemoryRouter>
</SettingsProvider>
);
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
expect(queryByText('action.create_account')).not.toBeNull();
});
test('render with email and phone passwordless', async () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
...mockSignInExperienceSettings.signUp,
identifiers: [SignInIdentifier.Email, SignInIdentifier.Phone],
},
}}
>
<MemoryRouter>
<Register />
</MemoryRouter>
</SettingsProvider>
);
expect(queryByText('secondary.register_with')).not.toBeNull();
expect(container.querySelector('input[name="email"]')).not.toBeNull();
});
test('renders with social as primary', async () => {
const { queryAllByText } = renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: { ...mockSignInExperienceSettings.signUp, identifiers: [] },
}}
>
<MemoryRouter>
<Register />
</MemoryRouter>
</SettingsProvider>
);
const { queryByText, queryAllByText, container } = renderRegisterPage({
signUp: { ...mockSignInExperienceSettings.signUp, identifiers: [] },
});
expect(queryAllByText('action.sign_in_with')).toHaveLength(
mockSignInExperienceSettings.socialConnectors.length
);
expect(container.querySelector('input[name="identifier"]')).toBeNull();
expect(queryByText('action.create_account')).toBeNull();
});
test('render with sign-in only mode should return ErrorPage', () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider
settings={{ ...mockSignInExperienceSettings, signInMode: SignInMode.SignIn }}
>
<MemoryRouter>
<Register />
</MemoryRouter>
</SettingsProvider>
);
const { queryByText } = renderRegisterPage({ signInMode: SignInMode.SignIn });
expect(queryByText('description.not_found')).not.toBeNull();
});
});

View file

@ -4,18 +4,15 @@ import { useTranslation } from 'react-i18next';
import Divider from '@/components/Divider';
import TextLink from '@/components/TextLink';
import LandingPageContainer from '@/containers/LandingPageContainer';
import OtherMethodsLink from '@/containers/OtherMethodsLink';
import { SocialSignInList } from '@/containers/SocialSignIn';
import SocialSignIn, { SocialSignInList } from '@/containers/SocialSignIn';
import { useSieMethods } from '@/hooks/use-sie';
import { UserFlow } from '@/types';
import ErrorPage from '../ErrorPage';
import Main from './Main';
import IdentifierRegisterForm from './IdentifierRegisterForm';
import * as styles from './index.module.scss';
const Register = () => {
const { signUpMethods, socialConnectors, signInMode } = useSieMethods();
const otherMethods = signUpMethods.slice(1);
const { signUpMethods, socialConnectors, signInMode, signInMethods } = useSieMethods();
const { t } = useTranslation();
if (!signInMode || signInMode === SignInMode.SignIn) {
@ -24,18 +21,10 @@ const Register = () => {
return (
<LandingPageContainer>
<Main signUpMethod={signUpMethods[0]} socialConnectors={socialConnectors} />
{
// Other create account methods
otherMethods.length > 0 && (
<OtherMethodsLink
className={styles.otherMethods}
methods={otherMethods}
template="register_with"
flow={UserFlow.register}
/>
)
}
{signUpMethods.length > 0 && (
<IdentifierRegisterForm signUpMethods={signUpMethods} className={styles.main} />
)}
{signUpMethods.length === 0 && socialConnectors.length > 0 && <SocialSignIn />}
{
// Social sign-in methods
signUpMethods.length > 0 && socialConnectors.length > 0 && (
@ -47,7 +36,7 @@ const Register = () => {
}
{
// SignIn footer
signInMode === SignInMode.SignInAndRegister && signUpMethods.length > 0 && (
signInMode === SignInMode.SignInAndRegister && signInMethods.length > 0 && (
<>
<div className={styles.placeHolder} />
<div className={styles.createAccount}>

View file

@ -7,7 +7,7 @@ import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import { setUserPassword } from '@/apis/interaction';
import PasswordRegisterWithUsername from '.';
import RegisterPassword from '.';
const mockedNavigate = jest.fn();
@ -23,7 +23,7 @@ jest.mock('@/apis/interaction', () => ({
const useLocationMock = useLocation as jest.Mock;
describe('<PasswordRegisterWithUsername />', () => {
describe('<RegisterPassword />', () => {
afterEach(() => {
jest.clearAllMocks();
useLocationMock.mockImplementation(() => ({ state: { username: 'username' } }));
@ -32,7 +32,7 @@ describe('<PasswordRegisterWithUsername />', () => {
it('render PasswordRegister page properly', () => {
const { queryByText, container } = renderWithPageContext(
<SettingsProvider>
<PasswordRegisterWithUsername />
<RegisterPassword />
</SettingsProvider>
);
@ -52,7 +52,7 @@ describe('<PasswordRegisterWithUsername />', () => {
},
}}
>
<PasswordRegisterWithUsername />
<RegisterPassword />
</SettingsProvider>
);
@ -63,7 +63,7 @@ describe('<PasswordRegisterWithUsername />', () => {
it('submit properly', async () => {
const { getByText, container } = renderWithPageContext(
<SettingsProvider>
<PasswordRegisterWithUsername />
<RegisterPassword />
</SettingsProvider>
);

View file

@ -7,7 +7,7 @@ import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '../ErrorPage';
import useUsernamePasswordRegister from './use-username-password-register';
const PasswordRegisterWithUsername = () => {
const RegisterPassword = () => {
const { signUpMethods } = useSieMethods();
const setPassword = useUsernamePasswordRegister();
@ -27,4 +27,4 @@ const PasswordRegisterWithUsername = () => {
);
};
export default PasswordRegisterWithUsername;
export default RegisterPassword;

View file

@ -16,6 +16,7 @@ const useUsernamePasswordRegister = () => {
const errorHandlers: ErrorHandlers = useMemo(
() => ({
// Incase previous page submitted username has been taken
'user.username_already_in_use': async (error) => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);

View file

@ -1,157 +0,0 @@
import { SignInIdentifier, SignInMode } from '@logto/schemas';
import { Routes, Route, MemoryRouter } from 'react-router-dom';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import SecondaryRegister from '@/pages/SecondaryRegister';
jest.mock('i18next', () => ({
language: 'en',
}));
describe('<SecondaryRegister />', () => {
test('renders phone', async () => {
const { queryAllByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/register/phone']}>
<Routes>
<Route
path="/register/:method"
element={
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
...mockSignInExperienceSettings.signUp,
identifiers: [SignInIdentifier.Phone],
},
}}
>
<SecondaryRegister />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryAllByText('action.create_account')).toHaveLength(2);
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
});
test('renders email', async () => {
const { queryAllByText, container } = renderWithPageContext(
<MemoryRouter initialEntries={['/register/email']}>
<Routes>
<Route
path="/register/:method"
element={
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
...mockSignInExperienceSettings.signUp,
identifiers: [SignInIdentifier.Email],
},
}}
>
<SecondaryRegister />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryAllByText('action.create_account')).toHaveLength(2);
expect(container.querySelector('input[name="email"]')).not.toBeNull();
});
test('renders non-recognized method should return error page', async () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/register/test']}>
<Routes>
<Route
path="/register/:method"
element={
<SettingsProvider>
<SecondaryRegister />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('action.create_account')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
test('renders non-supported signUp methods should return error page', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/register/email']}>
<Routes>
<Route
path="/register/:method"
element={
<SettingsProvider>
<SecondaryRegister />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('action.create_account')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
test('render non-verified passwordless methods should return error page', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/register/email']}>
<Routes>
<Route
path="/register/:method"
element={
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signUp: {
identifiers: [SignInIdentifier.Email],
password: true,
verify: false,
},
}}
>
<SecondaryRegister />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('action.create_account')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
test('render with sign-in only mode', () => {
const { queryByText } = renderWithPageContext(
<MemoryRouter initialEntries={['/register/email']}>
<Routes>
<Route
path="/register/:method"
element={
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
signInMode: SignInMode.SignIn,
}}
>
<SecondaryRegister />
</SettingsProvider>
}
/>
</Routes>
</MemoryRouter>
);
expect(queryByText('action.create_account')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull();
});
});

View file

@ -1,48 +0,0 @@
import { SignInMode, SignInIdentifier } from '@logto/schemas';
import { useParams } from 'react-router-dom';
import { is } from 'superstruct';
import SecondaryPageWrapper from '@/components/SecondaryPageWrapper';
import CreateAccount from '@/containers/CreateAccount';
import { EmailRegister } from '@/containers/EmailForm';
import { PhoneRegister } from '@/containers/PhoneForm';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
import { SignInMethodGuard, verificationCodeMethodGuard } from '@/types/guard';
type Parameters = {
method?: string;
};
const SecondaryRegister = () => {
const { method = '' } = useParams<Parameters>();
const { signUpMethods, signUpSettings, signInMode } = useSieMethods();
if (!signInMode || signInMode === SignInMode.SignIn) {
return <ErrorPage />;
}
// Validate the signUp method
if (!is(method, SignInMethodGuard) || !signUpMethods.includes(method)) {
return <ErrorPage />;
}
// Validate the verify settings
if (is(method, verificationCodeMethodGuard) && !signUpSettings.verify) {
return <ErrorPage />;
}
return (
<SecondaryPageWrapper title="action.create_account">
{method === SignInIdentifier.Phone ? (
<PhoneRegister autoFocus />
) : method === SignInIdentifier.Email ? (
<EmailRegister autoFocus />
) : (
<CreateAccount autoFocus />
)}
</SecondaryPageWrapper>
);
};
export default SecondaryRegister;

View file

@ -3,7 +3,6 @@ import type { SignIn } from '@logto/schemas';
import classNames from 'classnames';
import { useState, useCallback, useMemo } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
@ -28,7 +27,6 @@ type FormState = {
};
const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
const { t } = useTranslation();
const { termsValidation } = useTerms();
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(signInMethods);
@ -53,11 +51,11 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
clearErrorMessage();
void handleSubmit(async ({ identifier }, event) => {
event?.preventDefault();
clearErrorMessage();
if (!(await termsValidation())) {
return;
}
@ -71,7 +69,7 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<SmartInputField
autoComplete="identifier"
autoComplete="new-identifier"
autoFocus={autoFocus}
className={styles.inputField}
currentType={inputType}

View file

@ -57,8 +57,6 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
clearErrorMessage();
void handleSubmit(async ({ identifier, password }, event) => {
event?.preventDefault();
if (!(await termsValidation())) {
return;
}

View file

@ -50,8 +50,6 @@ const PasswordForm = ({
const onSubmitHandler = useCallback(
async (event?: React.FormEvent<HTMLFormElement>) => {
event?.preventDefault();
clearErrorMessage();
void handleSubmit(async ({ password }, event) => {