mirror of
https://github.com/logto-io/logto.git
synced 2025-03-10 22:22:45 -05:00
feat(ui): re-implement username signin page (#447)
* feat(ui): re-implement username signin page re-implement username signin page * fix(ui): username signin CR fix username signin CR fix
This commit is contained in:
parent
b99c11e211
commit
45a82f97c1
22 changed files with 510 additions and 255 deletions
|
@ -17,6 +17,7 @@ const translation = {
|
|||
terms_of_use: 'Terms of Use',
|
||||
terms_agreement_prefix: 'I agree with ',
|
||||
continue_with: 'Continue With',
|
||||
forgot_password: 'Forgot Password?',
|
||||
},
|
||||
register: {
|
||||
create_account: 'Create an Account',
|
||||
|
@ -344,6 +345,10 @@ const errors = {
|
|||
not_exists_with_id: 'The {{name}} with ID `{{id}}` does not exist.',
|
||||
not_found: 'The resource does not exist',
|
||||
},
|
||||
form: {
|
||||
required: 'Please enter the {{ fieldName }}',
|
||||
terms_required: 'Please check & agree the Terms',
|
||||
},
|
||||
};
|
||||
|
||||
const en = Object.freeze({
|
||||
|
|
|
@ -19,6 +19,7 @@ const translation = {
|
|||
terms_of_use: '用户协议',
|
||||
terms_agreement_prefix: '登录即表明您已经同意',
|
||||
continue_with: '更多',
|
||||
forgot_password: '忘记密码?',
|
||||
},
|
||||
register: {
|
||||
create_account: '创建新账户',
|
||||
|
@ -342,6 +343,10 @@ const errors = {
|
|||
not_exists_with_id: 'ID 为 `{{id}}` 的 {{name}} 不存在。',
|
||||
not_found: '该资源不存在',
|
||||
},
|
||||
form: {
|
||||
required: '请输入 {{ fieldName }}',
|
||||
terms_required: '请先同意并勾选《用户协议》',
|
||||
},
|
||||
};
|
||||
|
||||
const zhCN: typeof en = Object.freeze({
|
||||
|
|
|
@ -45,7 +45,7 @@ $font-family: 'PingFang SC', 'SF Pro Text', sans-serif;
|
|||
color var(--transition-default-function);
|
||||
}
|
||||
|
||||
.dark {
|
||||
.darkLegacy {
|
||||
/* ===== Legacy Styling ===== */
|
||||
/* Color */
|
||||
--color-heading: #f3f6f9;
|
||||
|
@ -61,7 +61,8 @@ $font-family: 'PingFang SC', 'SF Pro Text', sans-serif;
|
|||
--shadow-control: 1px 1px 2px rgb(221, 221, 221, 25%);
|
||||
}
|
||||
|
||||
.light {
|
||||
.light,
|
||||
.dark {
|
||||
/* Color */
|
||||
--color-primary: #{$color-primary};
|
||||
--color-background: #{$color-neutral-0};
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = {
|
||||
className?: string;
|
||||
logo: string;
|
||||
headline?: string;
|
||||
};
|
||||
|
||||
const BrandingHeader = ({ logo, headline }: Props) => {
|
||||
const BrandingHeader = ({ logo, headline, className }: Props) => {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={classNames(styles.container, className)}>
|
||||
<img className={styles.logo} alt="app logo" src={logo} />
|
||||
{headline && <div className={styles.headline}>{headline}</div>}
|
||||
</div>
|
||||
|
|
|
@ -5,20 +5,30 @@ import { useTranslation } from 'react-i18next';
|
|||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type ErrorType = LogtoErrorCode | { code: LogtoErrorCode; data?: Record<string, unknown> };
|
||||
|
||||
export type Props = {
|
||||
errorCode?: LogtoErrorCode;
|
||||
error?: ErrorType;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const ErrorMessage = ({ errorCode, className, children }: Props) => {
|
||||
const ErrorMessage = ({ error, className, children }: Props) => {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.error, className)}>
|
||||
{children ?? (errorCode ? i18n.t<string, LogtoErrorI18nKey>(`errors:${errorCode}`) : ``)}
|
||||
</div>
|
||||
);
|
||||
const getMessage = () => {
|
||||
if (!error) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (typeof error === 'string') {
|
||||
return i18n.t<string, LogtoErrorI18nKey>(`errors:${error}`);
|
||||
}
|
||||
|
||||
return i18n.t<string, LogtoErrorI18nKey>(`errors:${error.code}`, { ...error.data });
|
||||
};
|
||||
|
||||
return <div className={classNames(styles.error, className)}>{getMessage()}</div>;
|
||||
};
|
||||
|
||||
export default ErrorMessage;
|
||||
|
|
|
@ -8,7 +8,17 @@ describe('Input Field UI Component', () => {
|
|||
const onChange = jest.fn();
|
||||
|
||||
test('render password input', () => {
|
||||
const { container } = render(<PasswordInput name="foo" value={text} onChange={onChange} />);
|
||||
const { container } = render(
|
||||
<PasswordInput
|
||||
name="foo"
|
||||
value={text}
|
||||
onChange={({ target }) => {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
onChange(target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const inputEle = container.querySelector('input');
|
||||
expect(inputEle?.value).toEqual(text);
|
||||
|
@ -20,6 +30,12 @@ describe('Input Field UI Component', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('render error message', () => {
|
||||
const errorCode = 'user.email_not_exists';
|
||||
const { queryByText } = render(<PasswordInput error={errorCode} />);
|
||||
expect(queryByText(`errors:${errorCode}`)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('click on toggle visibility button', () => {
|
||||
const { container } = render(<PasswordInput name="foo" value={text} onChange={onChange} />);
|
||||
|
||||
|
|
|
@ -1,71 +1,61 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef, HTMLProps } from 'react';
|
||||
|
||||
import ErrorMessage, { ErrorType } from '../ErrorMessage';
|
||||
import { PrivacyIcon } from '../Icons';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
export type Props = {
|
||||
name: string;
|
||||
autoComplete?: AutoCompleteType;
|
||||
isDisabled?: boolean;
|
||||
export type Props = Omit<HTMLProps<HTMLInputElement>, 'type'> & {
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
hasError?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
error?: ErrorType;
|
||||
};
|
||||
|
||||
const PasswordInput = ({
|
||||
name,
|
||||
autoComplete,
|
||||
isDisabled,
|
||||
className,
|
||||
placeholder,
|
||||
value,
|
||||
hasError = false,
|
||||
onChange,
|
||||
}: Props) => {
|
||||
const PasswordInput = ({ className, value, error, onFocus, onBlur, ...rest }: Props) => {
|
||||
// Toggle the password visibility
|
||||
const [type, setType] = useState('password');
|
||||
const [onFocus, setOnFocus] = useState(false);
|
||||
const [onInputFocus, setOnInputFocus] = useState(false);
|
||||
const inputElement = useRef<HTMLInputElement>(null);
|
||||
const iconType = type === 'password' ? 'hide' : 'show';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.wrapper,
|
||||
onFocus && styles.focus,
|
||||
hasError && styles.error,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
name={name}
|
||||
disabled={isDisabled}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
value={value}
|
||||
autoComplete={autoComplete}
|
||||
onFocus={() => {
|
||||
setOnFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setOnFocus(false);
|
||||
}}
|
||||
onChange={({ target: { value } }) => {
|
||||
onChange(value);
|
||||
}}
|
||||
/>
|
||||
{value && onFocus && (
|
||||
<PrivacyIcon
|
||||
className={styles.actionButton}
|
||||
type={iconType}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
setType(type === 'password' ? 'text' : 'password');
|
||||
<div className={className}>
|
||||
<div
|
||||
className={classNames(styles.wrapper, onInputFocus && styles.focus, error && styles.error)}
|
||||
>
|
||||
<input
|
||||
ref={inputElement}
|
||||
type={type}
|
||||
value={value}
|
||||
onFocus={(event) => {
|
||||
setOnInputFocus(true);
|
||||
onFocus?.(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setOnInputFocus(false);
|
||||
onBlur?.(event);
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
{value && onInputFocus && (
|
||||
<PrivacyIcon
|
||||
className={styles.actionButton}
|
||||
type={iconType}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
setType(type === 'password' ? 'text' : 'password');
|
||||
|
||||
if (inputElement.current) {
|
||||
const { length } = inputElement.current.value;
|
||||
// Force async render, move cursor to the end of the input
|
||||
setTimeout(() => {
|
||||
inputElement.current?.setSelectionRange(length, length);
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{error && <ErrorMessage className={styles.errorMessage} error={error} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
background: var(--color-control-background);
|
||||
color: var(--color-font-primary);
|
||||
|
||||
|
||||
> *:not(:first-child) {
|
||||
margin-left: _.unit(1);
|
||||
}
|
||||
|
@ -41,6 +42,10 @@
|
|||
}
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
margin-top: _.unit(2);
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
fill: var(--color-control-action);
|
||||
}
|
||||
|
|
|
@ -6,9 +6,20 @@ import Input from '.';
|
|||
describe('Input Field UI Component', () => {
|
||||
const text = 'foo';
|
||||
const onChange = jest.fn();
|
||||
const onClear = jest.fn();
|
||||
|
||||
test('render plain text input with value', () => {
|
||||
const { container } = render(<Input name="foo" value={text} onChange={onChange} />);
|
||||
const { container } = render(
|
||||
<Input
|
||||
name="foo"
|
||||
value={text}
|
||||
onChange={({ target }) => {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
onChange(target.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const inputEle = container.querySelector('input');
|
||||
expect(inputEle).not.toBeNull();
|
||||
expect(inputEle?.value).toEqual(text);
|
||||
|
@ -19,8 +30,16 @@ describe('Input Field UI Component', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('render error message', () => {
|
||||
const errorCode = 'user.email_not_exists';
|
||||
const { queryByText } = render(<Input error={errorCode} />);
|
||||
expect(queryByText(`errors:${errorCode}`)).not.toBeNull();
|
||||
});
|
||||
|
||||
test('click on clear button', () => {
|
||||
const { container } = render(<Input name="foo" value={text} onChange={onChange} />);
|
||||
const { container } = render(
|
||||
<Input name="foo" value={text} onChange={onChange} onClear={onClear} />
|
||||
);
|
||||
const inputField = container.querySelector('input');
|
||||
|
||||
expect(container.querySelector('svg')).toBeNull();
|
||||
|
@ -34,7 +53,7 @@ describe('Input Field UI Component', () => {
|
|||
|
||||
if (clearIcon) {
|
||||
fireEvent.mouseDown(clearIcon);
|
||||
expect(onChange).toBeCalledWith('');
|
||||
expect(onClear).toBeCalledWith();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,71 +1,58 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, HTMLProps } from 'react';
|
||||
|
||||
import ErrorMessage, { ErrorType } from '../ErrorMessage';
|
||||
import { ClearIcon } from '../Icons';
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
type SupportedInputType = 'text' | 'email';
|
||||
|
||||
export type Props = {
|
||||
name: string;
|
||||
autoComplete?: AutoCompleteType;
|
||||
isDisabled?: boolean;
|
||||
export type Props = HTMLProps<HTMLInputElement> & {
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
type?: SupportedInputType;
|
||||
value: string;
|
||||
hasError?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
error?: ErrorType;
|
||||
onClear?: () => void;
|
||||
validation?: (value: string | number | readonly string[] | undefined) => ErrorType | undefined;
|
||||
};
|
||||
|
||||
const Input = ({
|
||||
name,
|
||||
autoComplete,
|
||||
isDisabled,
|
||||
className,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
value,
|
||||
hasError = false,
|
||||
onChange,
|
||||
error,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onClear,
|
||||
...rest
|
||||
}: Props) => {
|
||||
const [onFocus, setOnFocus] = useState(false);
|
||||
const [onInputFocus, setOnInputFocus] = useState(false);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.wrapper,
|
||||
onFocus && styles.focus,
|
||||
hasError && styles.error,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<input
|
||||
name={name}
|
||||
disabled={isDisabled}
|
||||
placeholder={placeholder}
|
||||
type={type}
|
||||
value={value}
|
||||
autoComplete={autoComplete}
|
||||
onFocus={() => {
|
||||
setOnFocus(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
setOnFocus(false);
|
||||
}}
|
||||
onChange={({ target: { value } }) => {
|
||||
onChange(value);
|
||||
}}
|
||||
/>
|
||||
{value && onFocus && (
|
||||
<ClearIcon
|
||||
className={styles.actionButton}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onChange('');
|
||||
<div className={className}>
|
||||
<div
|
||||
className={classNames(styles.wrapper, onInputFocus && styles.focus, error && styles.error)}
|
||||
>
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onFocus={(event) => {
|
||||
setOnInputFocus(true);
|
||||
onFocus?.(event);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setOnInputFocus(false);
|
||||
onBlur?.(event);
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
{value && onInputFocus && onClear && (
|
||||
<ClearIcon
|
||||
className={styles.actionButton}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onClear();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{error && <ErrorMessage error={error} className={styles.errorMessage} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -20,3 +20,7 @@
|
|||
.content {
|
||||
@include _.text-hint;
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
margin-top: _.unit(2);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { TermsOfUse as TermsOfUseType } from '@logto/schemas';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ErrorMessage, { ErrorType } from '@/components/ErrorMessage';
|
||||
import RadioButtonIcon from '@/components/Icons/RadioButtonIcon';
|
||||
import TextLink from '@/components/TextLink';
|
||||
|
||||
|
@ -13,10 +13,11 @@ type Props = {
|
|||
className?: string;
|
||||
termsOfUse: TermsOfUseType;
|
||||
isChecked?: boolean;
|
||||
error?: ErrorType;
|
||||
onChange: (checked: boolean) => void;
|
||||
};
|
||||
|
||||
const TermsOfUse = ({ name, className, termsOfUse, isChecked, onChange }: Props) => {
|
||||
const TermsOfUse = ({ name, className, termsOfUse, isChecked, error, onChange }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!termsOfUse.enabled || !termsOfUse.contentUrl) {
|
||||
|
@ -26,25 +27,29 @@ const TermsOfUse = ({ name, className, termsOfUse, isChecked, onChange }: Props)
|
|||
const prefix = t('sign_in.terms_agreement_prefix');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.terms, className)}
|
||||
onClick={() => {
|
||||
onChange(!isChecked);
|
||||
}}
|
||||
>
|
||||
<input disabled readOnly name={name} type="checkbox" checked={isChecked} />
|
||||
<RadioButtonIcon checked={isChecked} className={styles.radioButton} />
|
||||
<div className={styles.content}>
|
||||
{prefix}
|
||||
<TextLink
|
||||
text="sign_in.terms_of_use"
|
||||
href={termsOfUse.contentUrl}
|
||||
type="secondary"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
<div className={className}>
|
||||
<div
|
||||
className={styles.terms}
|
||||
onClick={() => {
|
||||
onChange(!isChecked);
|
||||
}}
|
||||
>
|
||||
<input disabled readOnly name={name} type="checkbox" checked={isChecked} />
|
||||
<RadioButtonIcon checked={isChecked} className={styles.radioButton} />
|
||||
<div className={styles.content}>
|
||||
{prefix}
|
||||
<TextLink
|
||||
text="sign_in.terms_of_use"
|
||||
href={termsOfUse.contentUrl}
|
||||
type="secondary"
|
||||
onClick={(event) => {
|
||||
// Prevent above parent onClick event being triggered
|
||||
event.stopPropagation();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{error && <ErrorMessage error={error} className={styles.errorMessage} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
36
packages/ui/src/containers/UsernameSignin/index.module.scss
Normal file
36
packages/ui/src/containers/UsernameSignin/index.module.scss
Normal file
|
@ -0,0 +1,36 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
@include _.flex-column;
|
||||
|
||||
> * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inputField:first-child {
|
||||
margin-bottom: _.unit(4);
|
||||
|
||||
&.withError {
|
||||
margin-bottom: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
||||
.textLink {
|
||||
margin-top: _.unit(3);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.inputField.withError + .textLink {
|
||||
margin-top: _.unit(1);
|
||||
}
|
||||
|
||||
.terms {
|
||||
margin: _.unit(5) 0;
|
||||
|
||||
&.withError {
|
||||
margin: _.unit(5) 0 _.unit(2) 0;
|
||||
}
|
||||
}
|
||||
}
|
72
packages/ui/src/containers/UsernameSignin/index.test.tsx
Normal file
72
packages/ui/src/containers/UsernameSignin/index.test.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { fireEvent, render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { signInBasic } from '@/apis/sign-in';
|
||||
|
||||
import UsernameSignin from '.';
|
||||
|
||||
jest.mock('@/apis/sign-in', () => ({ signInBasic: jest.fn(async () => Promise.resolve()) }));
|
||||
|
||||
describe('<UsernameSignin>', () => {
|
||||
test('render', () => {
|
||||
const { queryByText, container } = render(<UsernameSignin />);
|
||||
expect(container.querySelector('input[name="username"]')).not.toBeNull();
|
||||
expect(container.querySelector('input[name="password"]')).not.toBeNull();
|
||||
expect(queryByText('sign_in.action')).not.toBeNull();
|
||||
expect(queryByText('sign_in.forgot_password')).not.toBeNull();
|
||||
expect(queryByText('sign_in.terms_of_use')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('required inputs with error message', () => {
|
||||
const { queryByText, queryAllByText, getByText, container } = render(<UsernameSignin />);
|
||||
const submitButton = getByText('sign_in.action');
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(queryAllByText('errors:form.required')).toHaveLength(2);
|
||||
|
||||
const usernameInput = container.querySelector('input[name="username"]');
|
||||
const passwordInput = container.querySelector('input[name="password"]');
|
||||
|
||||
expect(usernameInput).not.toBeNull();
|
||||
expect(passwordInput).not.toBeNull();
|
||||
|
||||
if (usernameInput) {
|
||||
fireEvent.change(usernameInput, { target: { value: 'username' } });
|
||||
}
|
||||
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: 'password' } });
|
||||
}
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(queryByText('errors:form.required')).toBeNull();
|
||||
expect(queryByText('errors:form.terms_required')).not.toBeNull();
|
||||
|
||||
expect(signInBasic).not.toBeCalled();
|
||||
});
|
||||
|
||||
test('submit form', () => {
|
||||
const { getByText, container } = render(<UsernameSignin />);
|
||||
const submitButton = getByText('sign_in.action');
|
||||
|
||||
const usernameInput = container.querySelector('input[name="username"]');
|
||||
const passwordInput = container.querySelector('input[name="password"]');
|
||||
|
||||
if (usernameInput) {
|
||||
fireEvent.change(usernameInput, { target: { value: 'username' } });
|
||||
}
|
||||
|
||||
if (passwordInput) {
|
||||
fireEvent.change(passwordInput, { target: { value: 'password' } });
|
||||
}
|
||||
|
||||
const termsButton = getByText('sign_in.terms_agreement_prefix');
|
||||
fireEvent.click(termsButton);
|
||||
|
||||
fireEvent.click(submitButton);
|
||||
|
||||
expect(signInBasic).toBeCalledWith('username', 'password');
|
||||
});
|
||||
});
|
164
packages/ui/src/containers/UsernameSignin/index.tsx
Normal file
164
packages/ui/src/containers/UsernameSignin/index.tsx
Normal file
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* 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, { FC, useState, useCallback, useEffect, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { signInBasic } from '@/apis/sign-in';
|
||||
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 TextLink from '@/components/TextLink';
|
||||
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;
|
||||
termsAgreement: boolean;
|
||||
};
|
||||
|
||||
type ErrorState = {
|
||||
[key in keyof FieldState]?: ErrorType;
|
||||
};
|
||||
|
||||
const defaultState: FieldState = {
|
||||
username: '',
|
||||
password: '',
|
||||
termsAgreement: false,
|
||||
};
|
||||
|
||||
const UsernameSignin: FC = () => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [fieldState, setFieldState] = useState<FieldState>(defaultState);
|
||||
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
|
||||
|
||||
const { setToast } = useContext(PageContext);
|
||||
|
||||
const { error, loading, result, run: asyncSignInBasic } = useApi(signInBasic);
|
||||
|
||||
const onSubmitHandler = useCallback(async () => {
|
||||
// Should be removed after api redesign
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fieldState.username) {
|
||||
setFieldErrors((previous) => ({
|
||||
...previous,
|
||||
username: { code: 'form.required', data: { fieldName: t('sign_in.username') } },
|
||||
}));
|
||||
}
|
||||
|
||||
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) {
|
||||
setFieldErrors((previous) => ({
|
||||
...previous,
|
||||
termsAgreement: 'form.terms_required',
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
void asyncSignInBasic(fieldState.username, fieldState.password);
|
||||
}, [asyncSignInBasic, loading, t, fieldState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (result?.redirectTo) {
|
||||
window.location.assign(result.redirectTo);
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear errors
|
||||
for (const key of Object.keys(fieldState) as [keyof FieldState]) {
|
||||
if (fieldState[key] && fieldErrors[key]) {
|
||||
setFieldErrors((previous) => ({ ...previous, [key]: undefined }));
|
||||
}
|
||||
}
|
||||
}, [fieldErrors, fieldState]);
|
||||
|
||||
useEffect(() => {
|
||||
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
|
||||
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 }));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<TextLink
|
||||
className={styles.textLink}
|
||||
type="secondary"
|
||||
text="sign_in.forgot_password"
|
||||
href="/passcode"
|
||||
/>
|
||||
|
||||
<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('sign_in.action')}</Button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default UsernameSignin;
|
|
@ -5,9 +5,7 @@ import { useState, useCallback } from 'react';
|
|||
type UseApi<T extends any[], U> = {
|
||||
result?: U;
|
||||
loading: boolean;
|
||||
// FIXME:
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
error: RequestErrorBody | null;
|
||||
error: RequestErrorBody | undefined;
|
||||
run: (...args: T) => Promise<void>;
|
||||
};
|
||||
|
||||
|
@ -15,31 +13,30 @@ function useApi<Args extends any[], Response>(
|
|||
api: (...args: Args) => Promise<Response>
|
||||
): UseApi<Args, Response> {
|
||||
const [loading, setLoading] = useState(false);
|
||||
// FIXME:
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
const [error, setError] = useState<RequestErrorBody | null>(null);
|
||||
const [error, setError] = useState<RequestErrorBody>();
|
||||
const [result, setResult] = useState<Response>();
|
||||
|
||||
const run = useCallback(
|
||||
async (...args: Args) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// eslint-disable-next-line unicorn/no-useless-undefined
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
const result = await api(...args);
|
||||
setResult(result);
|
||||
setLoading(false);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof HTTPError) {
|
||||
if (error instanceof HTTPError && error.response.body) {
|
||||
const kyError = await error.response.json<RequestErrorBody>();
|
||||
setError(kyError);
|
||||
setLoading(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
// TODO: handle unknown server error
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[api]
|
||||
|
|
|
@ -15,4 +15,13 @@ Object.defineProperty(window, 'matchMedia', {
|
|||
})),
|
||||
});
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
i18n: {
|
||||
t: (key: string) => key,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
export {};
|
||||
|
|
|
@ -39,19 +39,29 @@ const Register: FC = () => {
|
|||
<div className={styles.title}>{t('register.create_account')}</div>
|
||||
<Input
|
||||
name="username"
|
||||
isDisabled={loading}
|
||||
disabled={loading}
|
||||
placeholder={t('sign_in.username')}
|
||||
value={username}
|
||||
onChange={setUsername} // TODO: account validation
|
||||
onChange={({ target }) => {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const { value } = target;
|
||||
setUsername(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<PasswordInput
|
||||
name="password"
|
||||
isDisabled={loading}
|
||||
disabled={loading}
|
||||
placeholder={t('sign_in.password')}
|
||||
value={password}
|
||||
onChange={setPassword} // TODO: password validation
|
||||
onChange={({ target }) => {
|
||||
if (target instanceof HTMLInputElement) {
|
||||
const { value } = target;
|
||||
setPassword(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{error && <ErrorMessage className={styles.box} errorCode={error.code} />}
|
||||
{error && <ErrorMessage className={styles.box} error={error.code} />}
|
||||
<Button isDisabled={loading} onClick={signUp}>
|
||||
{loading ? t('register.loading') : t('register.action')}
|
||||
</Button>
|
||||
|
|
|
@ -5,35 +5,11 @@
|
|||
padding: _.unit(8);
|
||||
height: 100%;
|
||||
@include _.flex-column;
|
||||
}
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
@include _.flex-column;
|
||||
|
||||
> * {
|
||||
margin-bottom: _.unit(1.5);
|
||||
.header {
|
||||
margin-bottom: _.unit(10);
|
||||
}
|
||||
|
||||
.title {
|
||||
@include _.title;
|
||||
margin-bottom: _.unit(9);
|
||||
}
|
||||
|
||||
.box {
|
||||
margin-bottom: _.unit(-6);
|
||||
}
|
||||
|
||||
> .inputField {
|
||||
width: 100%;
|
||||
margin-top: _.unit(3);
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
> button {
|
||||
margin-top: _.unit(12);
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.createAccount {
|
||||
position: absolute;
|
||||
|
|
|
@ -1,22 +1,12 @@
|
|||
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import { signInBasic } from '@/apis/sign-in';
|
||||
import SignIn from '@/pages/SignIn';
|
||||
|
||||
jest.mock('@/apis/sign-in', () => ({ signInBasic: jest.fn(async () => Promise.resolve()) }));
|
||||
|
||||
describe('<SignIn />', () => {
|
||||
test('renders without exploding', async () => {
|
||||
const { queryByText, getByText } = render(<SignIn />);
|
||||
expect(queryByText('Sign in to Logto')).not.toBeNull();
|
||||
|
||||
const submit = getByText('sign_in.action');
|
||||
fireEvent.click(submit);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(signInBasic).toBeCalled();
|
||||
expect(queryByText('sign_in.loading')).not.toBeNull();
|
||||
});
|
||||
const { queryByText } = render(<SignIn />);
|
||||
expect(queryByText('Welcome to Logto')).not.toBeNull();
|
||||
expect(queryByText('sign_in.action')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,70 +1,23 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { FC, FormEventHandler, useState, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React from 'react';
|
||||
|
||||
import { signInBasic } from '@/apis/sign-in';
|
||||
import Button from '@/components/Button';
|
||||
import ErrorMessage from '@/components/ErrorMessage';
|
||||
import Input from '@/components/Input';
|
||||
import PasswordInput from '@/components/Input/PasswordInput';
|
||||
import BrandingHeader from '@/components/BrandingHeader';
|
||||
import TextLink from '@/components/TextLink';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import UsernameSignin from '@/containers/UsernameSignin';
|
||||
|
||||
import * as styles from './index.module.scss';
|
||||
|
||||
const SignIn: FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const { loading, error, result, run: asyncSignInBasic } = useApi(signInBasic);
|
||||
|
||||
const signInHandler: FormEventHandler = useCallback(
|
||||
async (event) => {
|
||||
event.preventDefault();
|
||||
await asyncSignInBasic(username, password);
|
||||
},
|
||||
[username, password, asyncSignInBasic]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (result?.redirectTo) {
|
||||
window.location.assign(result.redirectTo);
|
||||
}
|
||||
}, [result]);
|
||||
|
||||
const SignIn = () => {
|
||||
return (
|
||||
<div className={classNames(styles.wrapper)}>
|
||||
<form className={classNames(styles.form)}>
|
||||
<div className={styles.title}>Sign in to Logto</div>
|
||||
<Input
|
||||
name="username"
|
||||
autoComplete="username"
|
||||
isDisabled={loading}
|
||||
placeholder={t('sign_in.username')}
|
||||
value={username}
|
||||
className={styles.inputField}
|
||||
onChange={setUsername}
|
||||
/>
|
||||
<PasswordInput
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
isDisabled={loading}
|
||||
placeholder={t('sign_in.password')}
|
||||
value={password}
|
||||
className={styles.inputField}
|
||||
onChange={setPassword}
|
||||
/>
|
||||
{error && <ErrorMessage className={styles.box} errorCode={error.code} />}
|
||||
<Button isDisabled={loading} type="primary" onClick={signInHandler}>
|
||||
{loading ? t('sign_in.loading') : t('sign_in.action')}
|
||||
</Button>
|
||||
<TextLink
|
||||
className={styles.createAccount}
|
||||
href="/register"
|
||||
text="register.create_account"
|
||||
/>
|
||||
</form>
|
||||
{/* TODO: load content from sign-in experience */}
|
||||
<BrandingHeader
|
||||
className={styles.header}
|
||||
headline="Welcome to Logto"
|
||||
logo="https://avatars.githubusercontent.com/u/84981374?s=400&u=6c44c3642f2fe15a59a56cdcb0358c0bd8b92f57&v=4"
|
||||
/>
|
||||
<UsernameSignin />
|
||||
<TextLink className={styles.createAccount} href="/register" text="register.create_account" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -12,7 +12,6 @@
|
|||
@mixin flex-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@mixin image-align-center {
|
||||
|
|
Loading…
Add table
Reference in a new issue