0
Fork 0
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:
simeng-li 2022-03-28 09:40:47 +08:00 committed by GitHub
parent b99c11e211
commit 45a82f97c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 510 additions and 255 deletions

View file

@ -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({

View file

@ -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({

View file

@ -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};

View file

@ -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>

View file

@ -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;

View file

@ -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} />);

View file

@ -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>
);
};

View file

@ -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);
}

View file

@ -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();
}
});
});

View file

@ -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>
);
};

View file

@ -20,3 +20,7 @@
.content {
@include _.text-hint;
}
.errorMessage {
margin-top: _.unit(2);
}

View file

@ -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>
);
};

View 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;
}
}
}

View 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');
});
});

View 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;

View file

@ -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]

View file

@ -15,4 +15,13 @@ Object.defineProperty(window, 'matchMedia', {
})),
});
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: {
t: (key: string) => key,
},
}),
}));
export {};

View file

@ -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>

View file

@ -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;

View file

@ -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();
});
});

View file

@ -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>
);
};

View file

@ -12,7 +12,6 @@
@mixin flex-row {
display: flex;
align-items: center;
justify-content: center;
}
@mixin image-align-center {