mirror of
https://github.com/logto-io/logto.git
synced 2024-12-23 20:33:16 -05:00
feat(ui): add CreateAccount Container (#457)
* feat(ui): add CreateAccount Container add CreatAccount Container * fix(ui): fix regex * fix(ui): remove useless test change remove useless test update
This commit is contained in:
parent
84f30fe04b
commit
67939ea2bb
16 changed files with 587 additions and 144 deletions
|
@ -23,9 +23,9 @@ const translation = {
|
|||
},
|
||||
register: {
|
||||
create_account: 'Create an Account',
|
||||
action: 'Create',
|
||||
loading: 'Creating Account...',
|
||||
action: 'Create Account',
|
||||
have_account: 'Already have an account?',
|
||||
confirm_password: 'Confirm Password',
|
||||
},
|
||||
admin_console: {
|
||||
title: 'Admin Console',
|
||||
|
@ -287,6 +287,8 @@ const errors = {
|
|||
},
|
||||
user: {
|
||||
username_exists_register: 'The username has been registered.',
|
||||
username_forbidden_initial_number: 'Username start with number is prohibited.',
|
||||
username_invalid_character: 'The username should contain A-Za-z0-9_ only.',
|
||||
email_exists_register: 'The email address has been registered.',
|
||||
phone_exists_register: 'The phone number has been registered.',
|
||||
invalid_email: 'Invalid email address.',
|
||||
|
@ -297,8 +299,10 @@ const errors = {
|
|||
identity_exists: 'The social account has been registered.',
|
||||
},
|
||||
password: {
|
||||
too_short: 'The password length should no less than {{min}}.',
|
||||
unsupported_encryption_method: 'The encryption method {{name}} is not supported.',
|
||||
pepper_not_found: 'Password pepper not found. Please check your core envs.',
|
||||
inconsistent_password: 'Inconsistent password.',
|
||||
},
|
||||
session: {
|
||||
not_found: 'Session not found. Please go back and sign in again.',
|
||||
|
|
|
@ -26,8 +26,8 @@ const translation = {
|
|||
register: {
|
||||
create_account: '创建新账户',
|
||||
action: '创建',
|
||||
loading: '创建中...',
|
||||
have_account: '已经有账户?',
|
||||
confirm_password: '确认密码',
|
||||
},
|
||||
admin_console: {
|
||||
title: '管理面板',
|
||||
|
@ -285,6 +285,8 @@ const errors = {
|
|||
},
|
||||
user: {
|
||||
username_exists_register: '用户名已被注册。',
|
||||
username_forbidden_initial_number: '用户名不能以数字开头。',
|
||||
username_invalid_character: '用户名应只包含 A-Za-z0-9_ 字符。',
|
||||
email_exists_register: '邮箱地址已被注册。',
|
||||
phone_exists_register: '手机号码已被注册。',
|
||||
invalid_email: '邮箱地址不正确。',
|
||||
|
@ -295,8 +297,10 @@ const errors = {
|
|||
identity_exists: '该社交账号已被注册。',
|
||||
},
|
||||
password: {
|
||||
too_short: '密码长度不得小于 {{min}}。',
|
||||
unsupported_encryption_method: '不支持的加密方法 {{name}}。',
|
||||
pepper_not_found: '密码 pepper 未找到。请检查 core 的环境变量。',
|
||||
inconsistent_password: '密码不一致。',
|
||||
},
|
||||
session: {
|
||||
not_found: 'Session not found. Please go back and sign in again.',
|
||||
|
|
|
@ -2,4 +2,10 @@
|
|||
<symbol width="24" height="24" viewBox="0 0 24 24" id="down">
|
||||
<path d="M14.2941 9.29409L12.0041 11.5941L9.71409 9.29409C9.62085 9.20085 9.51016 9.12689 9.38834 9.07643C9.26652 9.02597 9.13595 9 9.00409 9C8.87223 9 8.74166 9.02597 8.61984 9.07643C8.49802 9.12689 8.38733 9.20085 8.29409 9.29409C8.20085 9.38733 8.12689 9.49802 8.07643 9.61984C8.02597 9.74166 8 9.87223 8 10.0041C8 10.136 8.02597 10.2665 8.07643 10.3883C8.12689 10.5102 8.20085 10.6209 8.29409 10.7141L11.2941 13.7141C11.3871 13.8078 11.4977 13.8822 11.6195 13.933C11.7414 13.9838 11.8721 14.0099 12.0041 14.0099C12.1361 14.0099 12.2668 13.9838 12.3887 13.933C12.5105 13.8822 12.6211 13.8078 12.7141 13.7141L15.7141 10.7141C15.9024 10.5258 16.0082 10.2704 16.0082 10.0041C16.0082 9.73779 15.9024 9.48239 15.7141 9.29409C15.5258 9.10579 15.2704 9 15.0041 9C14.7378 9 14.4824 9.10579 14.2941 9.29409Z" />
|
||||
</symbol>
|
||||
<symbol width="24" height="24" viewBox="0 0 24 24" id="prev">
|
||||
<path d="M8.49054 12.7642L14.1505 18.4142C14.2435 18.5079 14.3541 18.5823 14.476 18.6331C14.5978 18.6838 14.7285 18.71 14.8605 18.71C14.9925 18.71 15.1233 18.6838 15.2451 18.6331C15.367 18.5823 15.4776 18.5079 15.5705 18.4142C15.7568 18.2268 15.8613 17.9733 15.8613 17.7092C15.8613 17.445 15.7568 17.1915 15.5705 17.0042L10.6205 12.0042L15.5705 7.05416C15.7568 6.8668 15.8613 6.61335 15.8613 6.34916C15.8613 6.08498 15.7568 5.83153 15.5705 5.64416C15.4779 5.54967 15.3675 5.4745 15.2456 5.42301C15.1237 5.37151 14.9928 5.34471 14.8605 5.34416C14.7282 5.34471 14.5973 5.37151 14.4755 5.42301C14.3536 5.4745 14.2432 5.54967 14.1505 5.64416L8.49054 11.2942C8.38903 11.3878 8.30802 11.5015 8.25261 11.628C8.19721 11.7545 8.1686 11.8911 8.1686 12.0292C8.1686 12.1673 8.19721 12.3039 8.25261 12.4304C8.30802 12.5569 8.38903 12.6705 8.49054 12.7642Z" />
|
||||
</symbol>
|
||||
<symbol width="24" height="24" viewBox="0 0 24 24" id="next">
|
||||
<path d="M15.5397 11.29L9.87974 5.64004C9.78677 5.54631 9.67617 5.47191 9.55431 5.42115C9.43246 5.37038 9.30175 5.34424 9.16974 5.34424C9.03773 5.34424 8.90702 5.37038 8.78516 5.42115C8.6633 5.47191 8.5527 5.54631 8.45974 5.64004C8.27349 5.8274 8.16895 6.08085 8.16895 6.34504C8.16895 6.60922 8.27349 6.86267 8.45974 7.05004L13.4097 12.05L8.45974 17C8.27349 17.1874 8.16895 17.4409 8.16895 17.705C8.16895 17.9692 8.27349 18.2227 8.45974 18.41C8.55235 18.5045 8.6628 18.5797 8.78467 18.6312C8.90655 18.6827 9.03743 18.7095 9.16974 18.71C9.30204 18.7095 9.43293 18.6827 9.5548 18.6312C9.67668 18.5797 9.78712 18.5045 9.87974 18.41L15.5397 12.76C15.6412 12.6664 15.7223 12.5527 15.7777 12.4262C15.8331 12.2997 15.8617 12.1631 15.8617 12.025C15.8617 11.8869 15.8331 11.7503 15.7777 11.6238C15.7223 11.4973 15.6412 11.3837 15.5397 11.29Z" />
|
||||
</symbol>
|
||||
</svg>
|
||||
|
|
Before Width: | Height: | Size: 977 B After Width: | Height: | Size: 2.8 KiB |
17
packages/ui/src/components/Icons/NavArrowIcon.tsx
Normal file
17
packages/ui/src/components/Icons/NavArrowIcon.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import React, { SVGProps } from 'react';
|
||||
|
||||
import Arrow from '@/assets/icons/arrow.svg';
|
||||
|
||||
type Props = {
|
||||
type?: 'prev' | 'next';
|
||||
} & SVGProps<SVGSVGElement>;
|
||||
|
||||
const NavArrowIcon = ({ type = 'prev', ...rest }: Props) => {
|
||||
return (
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" {...rest}>
|
||||
<use href={`${Arrow}#${type}`} />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavArrowIcon;
|
|
@ -8,9 +8,18 @@ import * as styles from './index.module.scss';
|
|||
export type Props = Omit<HTMLProps<HTMLInputElement>, 'type'> & {
|
||||
className?: string;
|
||||
error?: ErrorType;
|
||||
forceHidden?: boolean;
|
||||
};
|
||||
|
||||
const PasswordInput = ({ className, value, error, onFocus, onBlur, ...rest }: Props) => {
|
||||
const PasswordInput = ({
|
||||
className,
|
||||
value,
|
||||
error,
|
||||
forceHidden = false,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...rest
|
||||
}: Props) => {
|
||||
// Toggle the password visibility
|
||||
const [type, setType] = useState('password');
|
||||
const [onInputFocus, setOnInputFocus] = useState(false);
|
||||
|
@ -36,7 +45,7 @@ const PasswordInput = ({ className, value, error, onFocus, onBlur, ...rest }: Pr
|
|||
}}
|
||||
{...rest}
|
||||
/>
|
||||
{value && onInputFocus && (
|
||||
{!forceHidden && value && onInputFocus && (
|
||||
<PrivacyIcon
|
||||
className={styles.actionButton}
|
||||
type={iconType}
|
||||
|
|
31
packages/ui/src/containers/CreateAccount/index.module.scss
Normal file
31
packages/ui/src/containers/CreateAccount/index.module.scss
Normal file
|
@ -0,0 +1,31 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
@include _.flex-column;
|
||||
|
||||
> * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputField {
|
||||
margin-bottom: _.unit(4);
|
||||
|
||||
&.withError {
|
||||
margin-bottom: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
||||
.terms {
|
||||
margin: _.unit(7) 0 _.unit(6);
|
||||
|
||||
&.withError {
|
||||
margin: _.unit(7) 0 _.unit(2) 0;
|
||||
}
|
||||
}
|
||||
|
||||
.inputField.withError + .terms {
|
||||
margin-top: _.unit(9);
|
||||
}
|
||||
}
|
193
packages/ui/src/containers/CreateAccount/index.test.tsx
Normal file
193
packages/ui/src/containers/CreateAccount/index.test.tsx
Normal file
|
@ -0,0 +1,193 @@
|
|||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { register } from '@/apis/register';
|
||||
|
||||
import CreateAccount from '.';
|
||||
|
||||
jest.mock('@/apis/register', () => ({ register: jest.fn(async () => Promise.resolve()) }));
|
||||
|
||||
describe('<CreateAccount/>', () => {
|
||||
test('default render', () => {
|
||||
const { queryByText, container } = render(<CreateAccount />);
|
||||
expect(container.querySelector('input[name="username"]')).not.toBeNull();
|
||||
expect(container.querySelector('input[name="password"]')).not.toBeNull();
|
||||
expect(container.querySelector('input[name="confirm_password"]')).not.toBeNull();
|
||||
expect(queryByText('register.action')).not.toBeNull();
|
||||
expect(queryByText('sign_in.terms_of_use')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('username and password are required', () => {
|
||||
const { queryAllByText, getByText } = render(<CreateAccount />);
|
||||
const submitButton = getByText('register.action');
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(queryAllByText('errors:form.required')).toHaveLength(2);
|
||||
|
||||
expect(register).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('username with initial numeric char should throw', () => {
|
||||
const { queryByText, getByText, container } = render(<CreateAccount />);
|
||||
const submitButton = getByText('register.action');
|
||||
|
||||
const usernameInput = container.querySelector('input[name="username"]');
|
||||
|
||||
if (usernameInput) {
|
||||
fireEvent.change(usernameInput, { target: { value: '1username' } });
|
||||
}
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(queryByText('errors:user.username_forbidden_initial_number')).not.toBeNull();
|
||||
|
||||
expect(register).not.toBeCalled();
|
||||
|
||||
// Clear error
|
||||
if (usernameInput) {
|
||||
fireEvent.change(usernameInput, { target: { value: 'username' } });
|
||||
}
|
||||
|
||||
expect(queryByText('errors:user.username_forbidden_initial_number')).toBeNull();
|
||||
});
|
||||
|
||||
test('username with special character should throw', () => {
|
||||
const { queryByText, getByText, container } = render(<CreateAccount />);
|
||||
const submitButton = getByText('register.action');
|
||||
const usernameInput = container.querySelector('input[name="username"]');
|
||||
|
||||
if (usernameInput) {
|
||||
fireEvent.change(usernameInput, { target: { value: '@username' } });
|
||||
}
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(queryByText('errors:user.username_invalid_character')).not.toBeNull();
|
||||
|
||||
expect(register).not.toBeCalled();
|
||||
|
||||
// Clear error
|
||||
if (usernameInput) {
|
||||
fireEvent.change(usernameInput, { target: { value: 'username' } });
|
||||
}
|
||||
|
||||
expect(queryByText('errors:user.username_invalid_character')).toBeNull();
|
||||
});
|
||||
|
||||
test('password less than 6 chars should throw', () => {
|
||||
const { queryByText, getByText, container } = render(<CreateAccount />);
|
||||
const submitButton = getByText('register.action');
|
||||
const passwordInput = container.querySelector('input[name="password"]');
|
||||
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: '12345' } });
|
||||
}
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(queryByText('errors:password.too_short')).not.toBeNull();
|
||||
|
||||
expect(register).not.toBeCalled();
|
||||
|
||||
// Clear error
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: '123456' } });
|
||||
}
|
||||
|
||||
expect(queryByText('errors:password.too_short')).toBeNull();
|
||||
});
|
||||
|
||||
test('password mismatch with confirmPassword should throw', () => {
|
||||
const { queryByText, getByText, container } = render(<CreateAccount />);
|
||||
const submitButton = getByText('register.action');
|
||||
const passwordInput = container.querySelector('input[name="password"]');
|
||||
const confirmPasswordInput = container.querySelector('input[name="confirm_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('errors:password.inconsistent_password')).not.toBeNull();
|
||||
|
||||
expect(register).not.toBeCalled();
|
||||
|
||||
// Clear Error
|
||||
if (confirmPasswordInput) {
|
||||
fireEvent.change(confirmPasswordInput, { target: { value: '123456' } });
|
||||
}
|
||||
|
||||
expect(queryByText('errors:password.inconsistent_password')).toBeNull();
|
||||
});
|
||||
|
||||
test('terms of use not checked should throw', () => {
|
||||
const { queryByText, getByText, container } = render(<CreateAccount />);
|
||||
const submitButton = getByText('register.action');
|
||||
const passwordInput = container.querySelector('input[name="password"]');
|
||||
const confirmPasswordInput = container.querySelector('input[name="confirm_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: '123456' } });
|
||||
}
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(queryByText('errors:form.terms_required')).not.toBeNull();
|
||||
|
||||
expect(register).not.toBeCalled();
|
||||
|
||||
// Clear Error
|
||||
const termsButton = getByText('sign_in.terms_agreement_prefix');
|
||||
fireEvent.click(termsButton);
|
||||
|
||||
expect(queryByText('errors:form.terms_required')).toBeNull();
|
||||
});
|
||||
|
||||
test('submit form properly', async () => {
|
||||
const { getByText, container } = render(<CreateAccount />);
|
||||
const submitButton = getByText('register.action');
|
||||
const passwordInput = container.querySelector('input[name="password"]');
|
||||
const confirmPasswordInput = container.querySelector('input[name="confirm_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: '123456' } });
|
||||
}
|
||||
|
||||
const termsButton = getByText('sign_in.terms_agreement_prefix');
|
||||
fireEvent.click(termsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(register).toBeCalledWith('username', '123456');
|
||||
});
|
||||
});
|
230
packages/ui/src/containers/CreateAccount/index.tsx
Normal file
230
packages/ui/src/containers/CreateAccount/index.tsx
Normal file
|
@ -0,0 +1,230 @@
|
|||
/**
|
||||
* TODO:
|
||||
* 1. API redesign handle api error and loading status globally in PageContext
|
||||
* 2. Input field validation, should move the validation rule to the input field scope
|
||||
* 3. Forgot password URL
|
||||
* 4. Read terms of use settings from SignInExperience Settings
|
||||
*/
|
||||
|
||||
import { LogtoErrorI18nKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import React, { useState, useEffect, useCallback, useMemo, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { register } from '@/apis/register';
|
||||
import Button from '@/components/Button';
|
||||
import { ErrorType } from '@/components/ErrorMessage';
|
||||
import Input from '@/components/Input';
|
||||
import PasswordInput from '@/components/Input/PasswordInput';
|
||||
import TermsOfUse from '@/components/TermsOfUse';
|
||||
import PageContext from '@/hooks/page-context';
|
||||
import useApi from '@/hooks/use-api';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type FieldState = {
|
||||
username: string;
|
||||
password: string;
|
||||
confirmPassword: string;
|
||||
termsAgreement: boolean;
|
||||
};
|
||||
|
||||
type ErrorState = {
|
||||
[key in keyof FieldState]?: ErrorType;
|
||||
};
|
||||
|
||||
type FieldValidations = {
|
||||
[key in keyof FieldState]?: (state: FieldState) => ErrorType | undefined;
|
||||
};
|
||||
|
||||
const defaultState = {
|
||||
username: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
termsAgreement: false,
|
||||
};
|
||||
|
||||
const usernameRegx = /^[A-Z_a-z-][\w-]*$/;
|
||||
|
||||
const CreateAccount = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [fieldState, setFieldState] = useState<FieldState>(defaultState);
|
||||
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
|
||||
|
||||
const { setToast } = useContext(PageContext);
|
||||
|
||||
const { loading, error, result, run: asyncRegister } = useApi(register);
|
||||
|
||||
const validations = useMemo<FieldValidations>(
|
||||
() => ({
|
||||
username: ({ username }) => {
|
||||
if (!username) {
|
||||
return { code: 'form.required', data: { fieldName: t('sign_in.username') } };
|
||||
}
|
||||
|
||||
if (/\d/.test(username.slice(0, 1))) {
|
||||
return 'user.username_forbidden_initial_number';
|
||||
}
|
||||
|
||||
if (!usernameRegx.test(username)) {
|
||||
return 'user.username_invalid_character';
|
||||
}
|
||||
},
|
||||
password: ({ password }) => {
|
||||
if (!password) {
|
||||
return { code: 'form.required', data: { fieldName: t('sign_in.password') } };
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return { code: 'password.too_short', data: { min: 6 } };
|
||||
}
|
||||
},
|
||||
confirmPassword: ({ password, confirmPassword }) => {
|
||||
if (password !== confirmPassword) {
|
||||
return { code: 'password.inconsistent_password' };
|
||||
}
|
||||
},
|
||||
termsAgreement: ({ termsAgreement }) => {
|
||||
if (!termsAgreement) {
|
||||
return 'form.terms_required';
|
||||
}
|
||||
},
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const onSubmitHandler = useCallback(() => {
|
||||
// Should be removed after api redesign
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validates
|
||||
const usernameError = validations.username?.(fieldState);
|
||||
const passwordError = validations.password?.(fieldState);
|
||||
|
||||
if (usernameError || passwordError) {
|
||||
setFieldErrors((previous) => ({
|
||||
...previous,
|
||||
username: usernameError,
|
||||
password: passwordError,
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmPasswordError = validations.confirmPassword?.(fieldState);
|
||||
|
||||
if (confirmPasswordError) {
|
||||
setFieldErrors((previous) => ({ ...previous, confirmPassword: confirmPasswordError }));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const termsAgreementError = validations.termsAgreement?.(fieldState);
|
||||
|
||||
if (termsAgreementError) {
|
||||
setFieldErrors((previous) => ({
|
||||
...previous,
|
||||
termsAgreement: termsAgreementError,
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
void asyncRegister(fieldState.username, fieldState.password);
|
||||
}, [fieldState, loading, validations, asyncRegister]);
|
||||
|
||||
useEffect(() => {
|
||||
if (result?.redirectTo) {
|
||||
window.location.assign(result.redirectTo);
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear errors
|
||||
for (const key of Object.keys(fieldState) as [keyof FieldState]) {
|
||||
setFieldErrors((previous) => {
|
||||
if (!previous[key]) {
|
||||
return previous;
|
||||
}
|
||||
const error = validations[key]?.(fieldState);
|
||||
|
||||
return { ...previous, [key]: error };
|
||||
});
|
||||
}
|
||||
}, [fieldState, validations]);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: username exist error message
|
||||
if (error) {
|
||||
setToast(i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`));
|
||||
}
|
||||
}, [error, i18n, setToast]);
|
||||
|
||||
return (
|
||||
<form className={styles.form}>
|
||||
<Input
|
||||
className={classNames(styles.inputField, fieldErrors.username && styles.withError)}
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
placeholder={t('sign_in.username')}
|
||||
value={fieldState.username}
|
||||
error={fieldErrors.username}
|
||||
onChange={({ target }) => {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const { value } = target;
|
||||
setFieldState((state) => ({ ...state, username: value }));
|
||||
}
|
||||
}}
|
||||
onClear={() => {
|
||||
setFieldState((state) => ({ ...state, username: '' }));
|
||||
}}
|
||||
/>
|
||||
<PasswordInput
|
||||
forceHidden
|
||||
className={classNames(styles.inputField, fieldErrors.password && styles.withError)}
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
placeholder={t('sign_in.password')}
|
||||
value={fieldState.password}
|
||||
error={fieldErrors.password}
|
||||
onChange={({ target }) => {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const { value } = target;
|
||||
setFieldState((state) => ({ ...state, password: value }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<PasswordInput
|
||||
forceHidden
|
||||
className={classNames(styles.inputField, fieldErrors.confirmPassword && styles.withError)}
|
||||
name="confirm_password"
|
||||
autoComplete="current-password"
|
||||
placeholder={t('register.confirm_password')}
|
||||
value={fieldState.confirmPassword}
|
||||
error={fieldErrors.confirmPassword}
|
||||
onChange={({ target }) => {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const { value } = target;
|
||||
setFieldState((state) => ({ ...state, confirmPassword: value }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TermsOfUse
|
||||
name="termsAgreement"
|
||||
className={classNames(styles.terms, fieldErrors.termsAgreement && styles.withError)}
|
||||
termsOfUse={{ enabled: true, contentUrl: '/' }}
|
||||
isChecked={fieldState.termsAgreement}
|
||||
error={fieldErrors.termsAgreement}
|
||||
onChange={(checked) => {
|
||||
setFieldState((state) => ({ ...state, termsAgreement: checked }));
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button onClick={onSubmitHandler}>{t('register.action')}</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateAccount;
|
|
@ -27,7 +27,7 @@
|
|||
}
|
||||
|
||||
.terms {
|
||||
margin: _.unit(5) 0;
|
||||
margin: _.unit(6) 0;
|
||||
|
||||
&.withError {
|
||||
margin: _.unit(5) 0 _.unit(2) 0;
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { fireEvent, render } from '@testing-library/react';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { signInBasic } from '@/apis/sign-in';
|
||||
|
@ -47,7 +47,7 @@ describe('<UsernameSignin>', () => {
|
|||
expect(signInBasic).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('submit form', () => {
|
||||
test('submit form', async () => {
|
||||
const { getByText, container } = render(<UsernameSignin />);
|
||||
const submitButton = getByText('sign_in.action');
|
||||
|
||||
|
@ -65,7 +65,9 @@ describe('<UsernameSignin>', () => {
|
|||
const termsButton = getByText('sign_in.terms_agreement_prefix');
|
||||
fireEvent.click(termsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(submitButton);
|
||||
});
|
||||
|
||||
expect(signInBasic).toBeCalledWith('username', 'password');
|
||||
});
|
||||
|
|
|
@ -3,12 +3,12 @@
|
|||
* 1. API redesign handle api error and loading status globally in PageContext
|
||||
* 2. Input field validation, should move the validation rule to the input field scope
|
||||
* 3. Forgot password URL
|
||||
* 4. read terms of use settings from SignInExperience Settings
|
||||
* 4. Read terms of use settings from SignInExperience Settings
|
||||
*/
|
||||
|
||||
import { LogtoErrorI18nKey } from '@logto/phrases';
|
||||
import classNames from 'classnames';
|
||||
import React, { FC, useState, useCallback, useEffect, useContext } from 'react';
|
||||
import React, { FC, useState, useCallback, useEffect, useContext, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { signInBasic } from '@/apis/sign-in';
|
||||
|
@ -33,6 +33,10 @@ type ErrorState = {
|
|||
[key in keyof FieldState]?: ErrorType;
|
||||
};
|
||||
|
||||
type FieldValidations = {
|
||||
[key in keyof FieldState]?: (state: FieldState) => ErrorType | undefined;
|
||||
};
|
||||
|
||||
const defaultState: FieldState = {
|
||||
username: '',
|
||||
password: '',
|
||||
|
@ -48,41 +52,60 @@ const UsernameSignin: FC = () => {
|
|||
|
||||
const { error, loading, result, run: asyncSignInBasic } = useApi(signInBasic);
|
||||
|
||||
const validations = useMemo<FieldValidations>(
|
||||
() => ({
|
||||
username: ({ username }) => {
|
||||
if (!username) {
|
||||
return { code: 'form.required', data: { fieldName: t('sign_in.username') } };
|
||||
}
|
||||
},
|
||||
password: ({ password }) => {
|
||||
if (!password) {
|
||||
return { code: 'form.required', data: { fieldName: t('sign_in.password') } };
|
||||
}
|
||||
},
|
||||
termsAgreement: ({ termsAgreement }) => {
|
||||
if (!termsAgreement) {
|
||||
return 'form.terms_required';
|
||||
}
|
||||
},
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const onSubmitHandler = useCallback(async () => {
|
||||
// Should be removed after api redesign
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fieldState.username) {
|
||||
// Validates
|
||||
const usernameError = validations.username?.(fieldState);
|
||||
const passwordError = validations.password?.(fieldState);
|
||||
|
||||
if (usernameError || passwordError) {
|
||||
setFieldErrors((previous) => ({
|
||||
...previous,
|
||||
username: { code: 'form.required', data: { fieldName: t('sign_in.username') } },
|
||||
username: usernameError,
|
||||
password: passwordError,
|
||||
}));
|
||||
}
|
||||
|
||||
if (!fieldState.password) {
|
||||
setFieldErrors((previous) => ({
|
||||
...previous,
|
||||
password: { code: 'form.required', data: { fieldName: t('sign_in.password') } },
|
||||
}));
|
||||
}
|
||||
|
||||
if (!fieldState.username || !fieldState.password) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fieldState.termsAgreement) {
|
||||
const termsAgreementError = validations.termsAgreement?.(fieldState);
|
||||
|
||||
if (termsAgreementError) {
|
||||
setFieldErrors((previous) => ({
|
||||
...previous,
|
||||
termsAgreement: 'form.terms_required',
|
||||
termsAgreement: termsAgreementError,
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
void asyncSignInBasic(fieldState.username, fieldState.password);
|
||||
}, [asyncSignInBasic, loading, t, fieldState]);
|
||||
}, [loading, validations, fieldState, asyncSignInBasic]);
|
||||
|
||||
useEffect(() => {
|
||||
if (result?.redirectTo) {
|
||||
|
@ -93,13 +116,20 @@ const UsernameSignin: FC = () => {
|
|||
useEffect(() => {
|
||||
// Clear errors
|
||||
for (const key of Object.keys(fieldState) as [keyof FieldState]) {
|
||||
if (fieldState[key] && fieldErrors[key]) {
|
||||
setFieldErrors((previous) => ({ ...previous, [key]: undefined }));
|
||||
if (fieldState[key]) {
|
||||
setFieldErrors((previous) => {
|
||||
if (!previous[key]) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
return { ...previous, [key]: undefined };
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [fieldErrors, fieldState]);
|
||||
}, [fieldState]);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: username password not correct error message
|
||||
if (error) {
|
||||
setToast(i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`));
|
||||
}
|
||||
|
|
|
@ -15,11 +15,13 @@ Object.defineProperty(window, 'matchMedia', {
|
|||
})),
|
||||
});
|
||||
|
||||
const translation = (key: string) => key;
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
t: translation,
|
||||
i18n: {
|
||||
t: (key: string) => key,
|
||||
t: translation,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
|
|
@ -3,46 +3,20 @@
|
|||
.wrapper {
|
||||
position: relative;
|
||||
padding: _.unit(8);
|
||||
height: 100%;
|
||||
@include _.flex-column;
|
||||
}
|
||||
|
||||
.form {
|
||||
.navBar {
|
||||
width: 100%;
|
||||
@include _.flex-column;
|
||||
margin-bottom: _.unit(6);
|
||||
|
||||
> * {
|
||||
margin-bottom: _.unit(1.5);
|
||||
svg {
|
||||
margin-left: _.unit(-2);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
.title {
|
||||
width: 100%;
|
||||
@include _.title;
|
||||
margin-bottom: _.unit(9);
|
||||
}
|
||||
|
||||
.box {
|
||||
margin-bottom: _.unit(-6);
|
||||
}
|
||||
|
||||
.box,
|
||||
> input:not([type='button']) {
|
||||
margin-top: _.unit(3);
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
> input[type='button'] {
|
||||
margin-top: _.unit(12);
|
||||
}
|
||||
|
||||
.haveAccount {
|
||||
position: absolute;
|
||||
bottom: _.unit(10);
|
||||
}
|
||||
|
||||
.prefix {
|
||||
font: var(--font-body-bold);
|
||||
color: var(--color-placeholder);
|
||||
margin-right: _.unit(0.5);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,23 +1,14 @@
|
|||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { register } from '@/apis/register';
|
||||
import Register from '@/pages/Register';
|
||||
|
||||
jest.mock('@/apis/register', () => ({ register: jest.fn(async () => Promise.resolve()) }));
|
||||
|
||||
describe('<Register />', () => {
|
||||
test('renders without exploding', async () => {
|
||||
const { queryByText, getByText } = render(<Register />);
|
||||
const { queryByText } = render(<Register />);
|
||||
expect(queryByText('register.create_account')).not.toBeNull();
|
||||
expect(queryByText('register.have_account')).not.toBeNull();
|
||||
|
||||
const submit = getByText('register.action');
|
||||
fireEvent.click(submit);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(register).toBeCalled();
|
||||
expect(queryByText('register.loading')).not.toBeNull();
|
||||
});
|
||||
expect(queryByText('register.action')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,76 +1,27 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { register } from '@/apis/register';
|
||||
import Button from '@/components/Button';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
import Input from '@/components/Input';
|
||||
import PasswordInput from '@/components/Input/PasswordInput';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import NavArrowIcon from '@/components/Icons/NavArrowIcon';
|
||||
import CreateAccount from '@/containers/CreateAccount';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const Register: FC = () => {
|
||||
const Register = () => {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const { loading, error, result, run: asyncRegister } = useApi(register);
|
||||
|
||||
const signUp: FormEventHandler = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
await asyncRegister(username, password);
|
||||
},
|
||||
[username, password, asyncRegister]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (result?.redirectTo) {
|
||||
window.location.assign(result.redirectTo);
|
||||
}
|
||||
}, [result]);
|
||||
const history = useHistory();
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.wrapper)}>
|
||||
<form className={classNames(styles.form)}>
|
||||
<div className={styles.title}>{t('register.create_account')}</div>
|
||||
<Input
|
||||
name="username"
|
||||
disabled={loading}
|
||||
placeholder={t('sign_in.username')}
|
||||
value={username}
|
||||
onChange={({ target }) => {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const { value } = target;
|
||||
setUsername(value);
|
||||
}
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.navBar}>
|
||||
<NavArrowIcon
|
||||
onClick={() => {
|
||||
history.goBack();
|
||||
}}
|
||||
/>
|
||||
<PasswordInput
|
||||
name="password"
|
||||
disabled={loading}
|
||||
placeholder={t('sign_in.password')}
|
||||
value={password}
|
||||
onChange={({ target }) => {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const { value } = target;
|
||||
setPassword(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && <ErrorMessage className={styles.box} error={error.code} />}
|
||||
<Button isDisabled={loading} onClick={signUp}>
|
||||
{loading ? t('register.loading') : t('register.action')}
|
||||
</Button>
|
||||
|
||||
<div className={styles.haveAccount}>
|
||||
<span className={styles.prefix}>{t('register.have_account')}</span>
|
||||
<TextLink href="/sign-in">{t('sign_in.action')}</TextLink>
|
||||
</div>
|
||||
</form>
|
||||
<div className={styles.title}>{t('register.create_account')}</div>
|
||||
<CreateAccount />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
.wrapper {
|
||||
position: relative;
|
||||
padding: _.unit(8);
|
||||
height: 100%;
|
||||
@include _.flex-column;
|
||||
|
||||
.header {
|
||||
|
@ -12,7 +11,7 @@
|
|||
|
||||
|
||||
.createAccount {
|
||||
position: absolute;
|
||||
bottom: _.unit(10);
|
||||
position: fixed;
|
||||
bottom: _.unit(12);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue