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:
parent
4f4c444442
commit
d4182efc8b
43 changed files with 503 additions and 1187 deletions
|
@ -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
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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]');
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
|
|
|
@ -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;)}}로 로그인 해보세요!',
|
||||
},
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
|
|
|
@ -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.',
|
||||
},
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
|
|
|
@ -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}} 登录并绑定。',
|
||||
},
|
||||
|
|
|
@ -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 */}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -1,3 +1,2 @@
|
|||
export { default as EmailRegister } from './EmailRegister';
|
||||
export { default as EmailResetPassword } from './EmailResetPassword';
|
||||
export { default as EmailContinue } from './EmailContinue';
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -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 } }
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -1,3 +1,2 @@
|
|||
export { default as PhoneRegister } from './PhoneRegister';
|
||||
export { default as PhoneResetPassword } from './PhoneResetPassword';
|
||||
export { default as PhoneContinue } from './PhoneContinue';
|
||||
|
|
|
@ -54,7 +54,6 @@ const SetPassword = ({
|
|||
|
||||
void handleSubmit((data, event) => {
|
||||
onSubmit(data.newPassword);
|
||||
event?.preventDefault();
|
||||
})(event);
|
||||
},
|
||||
[clearErrorMessage, handleSubmit, onSubmit]
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -1,2 +1 @@
|
|||
export { default as UsernameRegister } from './UsernameRegister';
|
||||
export { default as SetUsername } from './SetUsername';
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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`,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
106
packages/ui/src/pages/Register/IdentifierRegisterForm/index.tsx
Normal file
106
packages/ui/src/pages/Register/IdentifierRegisterForm/index.tsx
Normal 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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -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;
|
|
@ -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);
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
|
@ -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}
|
||||
|
|
|
@ -57,8 +57,6 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
|
|||
clearErrorMessage();
|
||||
|
||||
void handleSubmit(async ({ identifier, password }, event) => {
|
||||
event?.preventDefault();
|
||||
|
||||
if (!(await termsValidation())) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -50,8 +50,6 @@ const PasswordForm = ({
|
|||
|
||||
const onSubmitHandler = useCallback(
|
||||
async (event?: React.FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
|
||||
clearErrorMessage();
|
||||
|
||||
void handleSubmit(async ({ password }, event) => {
|
||||
|
|
Loading…
Add table
Reference in a new issue