0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(ui): update pages (#479)

* feat(ui): update pages

update page routing and render logics

* chore(ui): component renaming

component renaming
This commit is contained in:
simeng-li 2022-04-02 11:35:33 +08:00 committed by GitHub
parent 812841494a
commit 04d00fc11f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 315 additions and 56 deletions

View file

@ -20,6 +20,10 @@ const translation = {
password: 'Password',
email: 'Email',
phone_number: 'Phone Number',
enter_passcode: 'Enter Passcode',
passcode_sent: 'The Passcode has been sent to {{target}}',
resend_passcode: 'Resend Passcode',
resend_after_seconds: 'Resend after {{ seconds }} seconds',
terms_of_use: 'Terms of Use',
terms_agreement_prefix: 'I agree with ',
continue_with: 'Continue With',

View file

@ -22,6 +22,10 @@ const translation = {
password: '密码',
email: '邮箱',
phone_number: '手机',
enter_passcode: '请输入验证码',
passcode_sent: '验证码已发送至 {{target}}',
resend_passcode: '重新发送验证码',
resend_after_seconds: '在 {{ seconds }} 秒后重发',
terms_of_use: '用户协议',
terms_agreement_prefix: '登录即表明您已经同意',
continue_with: '更多',

View file

@ -5,6 +5,7 @@ import AppContent from './components/AppContent';
import useTheme from './hooks/use-theme';
import initI18n from './i18n/init';
import Consent from './pages/Consent';
import Passcode from './pages/Passcode';
import Register from './pages/Register';
import SecondarySignIn from './pages/SecondarySignIn';
import SignIn from './pages/SignIn';
@ -21,8 +22,10 @@ const App = () => {
<Switch>
<Route exact path="/sign-in" component={SignIn} />
<Route exact path="/sign-in/consent" component={Consent} />
<Route exact path="/sign-in/:channel" component={SecondarySignIn} />
<Route exact path="/register" component={Register} />
<Route exact path="/sign-in-secondary" component={SecondarySignIn} />
<Route exact path="/register/:channel" component={Register} />
<Route exact path="/:type/:channel/passcode-validation" component={Passcode} />
</Switch>
</BrowserRouter>
</AppContent>

View file

@ -0,0 +1,47 @@
import {
verifyEmailPasscode as verifyRegisterEmailPasscode,
verifyPhonePasscode as verifyRegisterPhonePasscode,
sendEmailPasscode as sendRegisterEmailPasscode,
sendPhonePasscode as sendRegisterPhonePasscode,
} from './register';
import {
verifyEmailPasscode as verifySignInEmailPasscode,
verifyPhonePasscode as verifySignInPhonePasscode,
sendEmailPasscode as sendSignInEmailPasscode,
sendPhonePasscode as sendSignInPhonePasscode,
} from './sign-in';
export type PasscodeType = 'sign-in' | 'register';
export type PasscodeChannel = 'phone' | 'email';
export const getSendPasscodeApi = (type: PasscodeType, channel: PasscodeChannel) => {
if (type === 'sign-in' && channel === 'email') {
return sendSignInEmailPasscode;
}
if (type === 'sign-in' && channel === 'phone') {
return sendSignInPhonePasscode;
}
if (type === 'register' && channel === 'email') {
return sendRegisterEmailPasscode;
}
return sendRegisterPhonePasscode;
};
export const getVerifyPasscodeApi = (type: PasscodeType, channel: PasscodeChannel) => {
if (type === 'sign-in' && channel === 'email') {
return verifySignInEmailPasscode;
}
if (type === 'sign-in' && channel === 'phone') {
return verifySignInPhonePasscode;
}
if (type === 'register' && channel === 'email') {
return verifyRegisterEmailPasscode;
}
return verifyRegisterPhonePasscode;
};

View file

@ -36,3 +36,7 @@
}
}
}
.errorMessage {
margin-top: _.unit(2);
}

View file

@ -1,4 +1,3 @@
import classNames from 'classnames';
import React, {
useMemo,
useRef,
@ -9,17 +8,17 @@ import React, {
ClipboardEventHandler,
} from 'react';
import ErrorMessage, { ErrorType } from '../ErrorMessage';
import * as styles from './index.module.scss';
export const defaultLength = 6;
export type Props = {
name: string;
isDisabled?: boolean;
className?: string;
length?: number;
value: string[];
hasError?: boolean;
error?: ErrorType;
onChange: (value: string[]) => void;
};
@ -46,15 +45,7 @@ const trim = (oldValue: string | undefined, newValue: string) => {
return newValue;
};
const Passcode = ({
name,
isDisabled,
className,
value,
length = defaultLength,
hasError,
onChange,
}: Props) => {
const Passcode = ({ name, className, value, length = defaultLength, error, onChange }: Props) => {
/* eslint-disable @typescript-eslint/ban-types */
const inputReferences = useRef<Array<HTMLInputElement | null>>(
Array.from<null>({ length }).fill(null)
@ -187,29 +178,32 @@ const Passcode = ({
);
return (
<div className={classNames(styles.passcode, className)}>
{Array.from({ length }).map((_, index) => (
<input
ref={(element) => {
// eslint-disable-next-line @silverhand/fp/no-mutation
inputReferences.current[index] = element;
}}
// eslint-disable-next-line react/no-array-index-key
key={`${name}_${index}`}
name={`${name}_${index}`}
data-id={index}
disabled={isDisabled}
value={codes[index]}
className={hasError ? styles.error : undefined}
type="text"
inputMode="numeric"
maxLength={2} // Allow overwrite input
onPaste={onPasteHandler}
onInput={onInputHandler}
onKeyDown={onKeyDownHandler}
onFocus={onFocusHandler}
/>
))}
<div className={className}>
<div className={styles.passcode}>
{Array.from({ length }).map((_, index) => (
<input
ref={(element) => {
// eslint-disable-next-line @silverhand/fp/no-mutation
inputReferences.current[index] = element;
}}
// eslint-disable-next-line react/no-array-index-key
key={`${name}_${index}`}
autoFocus={index === 0}
name={`${name}_${index}`}
data-id={index}
value={codes[index]}
className={error ? styles.error : undefined}
type="text"
inputMode="numeric"
maxLength={2} // Allow overwrite input
onPaste={onPasteHandler}
onInput={onInputHandler}
onKeyDown={onKeyDownHandler}
onFocus={onFocusHandler}
/>
))}
</div>
{error && <ErrorMessage error={error} className={styles.errorMessage} />}
</div>
);
};

View file

@ -2,7 +2,7 @@
.form {
width: 100%;
max-width: 320px;
max-width: 360px;
@include _.flex-column;
> * {

View file

@ -0,0 +1,11 @@
@use '@/scss/underscore' as _;
.form {
width: 100%;
max-width: 360px;
@include _.flex-column;
> * {
width: 100%;
}
}

View file

@ -0,0 +1,16 @@
import React from 'react';
import * as styles from './index.module.scss';
type Props = {
type: 'sign-in' | 'register';
channel: 'email' | 'phone';
};
const PasscodeValidation = ({ type, channel }: Props) => {
console.log(type, channel);
return <form className={styles.form}>{/* TODO */}</form>;
};
export default PasscodeValidation;

View file

@ -9,8 +9,7 @@ import classNames from 'classnames';
import React, { useState, useCallback, useMemo, useEffect, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { sendEmailPasscode as sendRegisterEmailPasscode } from '@/apis/register';
import { sendEmailPasscode as sendSignInEmailPasscode } from '@/apis/sign-in';
import { getSendPasscodeApi } from '@/apis/utils';
import Button from '@/components/Button';
import { ErrorType } from '@/components/ErrorMessage';
import Input from '@/components/Input';
@ -47,7 +46,7 @@ const EmailPasswordless = ({ type }: Props) => {
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
const { setToast } = useContext(PageContext);
const sendPasscode = type === 'sign-in' ? sendSignInEmailPasscode : sendRegisterEmailPasscode;
const sendPasscode = getSendPasscodeApi(type, 'email');
const { loading, error, result, run: asyncSendPasscode } = useApi(sendPasscode);

View file

@ -9,8 +9,7 @@ import classNames from 'classnames';
import React, { useState, useCallback, useMemo, useEffect, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { sendPhonePasscode as sendRegisterPhonePasscode } from '@/apis/register';
import { sendPhonePasscode as sendSignInPhonePasscode } from '@/apis/sign-in';
import { getSendPasscodeApi } from '@/apis/utils';
import Button from '@/components/Button';
import { ErrorType } from '@/components/ErrorMessage';
import PhoneInput from '@/components/Input/PhoneInput';
@ -48,7 +47,7 @@ const PhonePasswordless = ({ type }: Props) => {
const { phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
const sendPasscode = type === 'sign-in' ? sendSignInPhonePasscode : sendRegisterPhonePasscode;
const sendPasscode = getSendPasscodeApi(type, 'phone');
const { loading, error, result, run: asyncSendPasscode } = useApi(sendPasscode);
const validations = useMemo<FieldValidations>(

View file

@ -2,7 +2,7 @@
.form {
width: 100%;
max-width: 320px;
max-width: 360px;
@include _.flex-column;
> * {

View file

@ -2,7 +2,7 @@
.form {
width: 100%;
max-width: 320px;
max-width: 360px;
@include _.flex-column;
> * {

View file

@ -0,0 +1,22 @@
@use '@/scss/underscore' as _;
.wrapper {
position: relative;
padding: _.unit(8) _.unit(5);
@include _.flex-column;
}
.navBar {
width: 100%;
margin-bottom: _.unit(6);
svg {
margin-left: _.unit(-2);
}
}
.title {
width: 100%;
@include _.title;
margin-bottom: _.unit(9);
}

View file

@ -0,0 +1,48 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory, useParams } from 'react-router-dom';
import NavArrowIcon from '@/components/Icons/NavArrowIcon';
import PasscodeValidation from '@/containers/PasscodeValidation';
import * as styles from './index.module.scss';
type Props = {
type: string;
channel: string;
};
const Passcode = () => {
const { t } = useTranslation();
const history = useHistory();
const { type, channel } = useParams<Props>();
// TODO: 404 page
if (type !== 'sign-in' && type !== 'register') {
window.location.assign('/404');
return null;
}
if (channel !== 'email' && channel !== 'phone') {
window.location.assign('/404');
return null;
}
return (
<div className={styles.wrapper}>
<div className={styles.navBar}>
<NavArrowIcon
onClick={() => {
history.goBack();
}}
/>
</div>
<div className={styles.title}>{t('sign_in.enter_passcode')}</div>
<PasscodeValidation type={type} channel={channel} />
</div>
);
};
export default Passcode;

View file

@ -2,7 +2,7 @@
.wrapper {
position: relative;
padding: _.unit(8);
padding: _.unit(8) _.unit(5);
@include _.flex-column;
}

View file

@ -1,5 +1,6 @@
import { render } from '@testing-library/react';
import React from 'react';
import { Route, MemoryRouter } from 'react-router-dom';
import Register from '@/pages/Register';
@ -7,8 +8,36 @@ jest.mock('@/apis/register', () => ({ register: jest.fn(async () => Promise.reso
describe('<Register />', () => {
test('renders without exploding', async () => {
const { queryByText } = render(<Register />);
const { queryByText } = render(
<MemoryRouter initialEntries={['/register']}>
<Register />
</MemoryRouter>
);
expect(queryByText('register.create_account')).not.toBeNull();
expect(queryByText('register.action')).not.toBeNull();
});
test('renders phone', async () => {
const { queryByText, container } = render(
<MemoryRouter initialEntries={['/register/phone']}>
<Route path="/register/:channel">
<Register />
</Route>
</MemoryRouter>
);
expect(queryByText('register.create_account')).not.toBeNull();
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
});
test('renders email', async () => {
const { queryByText, container } = render(
<MemoryRouter initialEntries={['/register/email']}>
<Route path="/register/:channel">
<Register />
</Route>
</MemoryRouter>
);
expect(queryByText('register.create_account')).not.toBeNull();
expect(container.querySelector('input[name="email"]')).not.toBeNull();
});
});

View file

@ -1,15 +1,33 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { useHistory, useParams } from 'react-router-dom';
import NavArrowIcon from '@/components/Icons/NavArrowIcon';
import CreateAccount from '@/containers/CreateAccount';
import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless';
import * as styles from './index.module.scss';
type Props = {
channel?: 'phone' | 'email' | 'username';
};
const Register = () => {
const { t } = useTranslation();
const history = useHistory();
const { channel } = useParams<Props>();
const registerForm = useMemo(() => {
if (channel === 'phone') {
return <PhonePasswordless type="register" />;
}
if (channel === 'email') {
return <EmailPasswordless type="register" />;
}
return <CreateAccount />;
}, [channel]);
return (
<div className={styles.wrapper}>
@ -21,7 +39,7 @@ const Register = () => {
/>
</div>
<div className={styles.title}>{t('register.create_account')}</div>
<CreateAccount />
{registerForm}
</div>
);
};

View file

@ -2,7 +2,7 @@
.wrapper {
position: relative;
padding: _.unit(8);
padding: _.unit(8) _.unit(5);
@include _.flex-column;
}

View file

@ -0,0 +1,43 @@
import { render } from '@testing-library/react';
import React from 'react';
import { Route, MemoryRouter } from 'react-router-dom';
import SecondarySignIn from '@/pages/SecondarySignIn';
jest.mock('@/apis/register', () => ({ register: jest.fn(async () => Promise.resolve()) }));
describe('<SecondarySignIn />', () => {
test('renders without exploding', async () => {
const { queryByText } = render(
<MemoryRouter initialEntries={['/sign-in/username']}>
<SecondarySignIn />
</MemoryRouter>
);
expect(queryByText('sign_in.sign_in')).not.toBeNull();
expect(queryByText('sign_in.action')).not.toBeNull();
});
test('renders phone', async () => {
const { queryByText, container } = render(
<MemoryRouter initialEntries={['/sign-in/phone']}>
<Route path="/sign-in/:channel">
<SecondarySignIn />
</Route>
</MemoryRouter>
);
expect(queryByText('sign_in.sign_in')).not.toBeNull();
expect(container.querySelector('input[name="phone"]')).not.toBeNull();
});
test('renders email', async () => {
const { queryByText, container } = render(
<MemoryRouter initialEntries={['/sign-in/email']}>
<Route path="/sign-in/:channel">
<SecondarySignIn />
</Route>
</MemoryRouter>
);
expect(queryByText('sign_in.sign_in')).not.toBeNull();
expect(container.querySelector('input[name="email"]')).not.toBeNull();
});
});

View file

@ -1,15 +1,33 @@
import React from 'react';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useHistory } from 'react-router-dom';
import { useHistory, useParams } from 'react-router-dom';
import NavArrowIcon from '@/components/Icons/NavArrowIcon';
import { PhonePasswordless } from '@/containers/Passwordless';
import { PhonePasswordless, EmailPasswordless } from '@/containers/Passwordless';
import UsernameSignin from '@/containers/UsernameSignin';
import * as styles from './index.module.scss';
type Props = {
channel?: 'phone' | 'email' | 'username';
};
const SecondarySignIn = () => {
const { t } = useTranslation();
const history = useHistory();
const { channel } = useParams<Props>();
const signInForm = useMemo(() => {
if (channel === 'phone') {
return <PhonePasswordless type="sign-in" />;
}
if (channel === 'email') {
return <EmailPasswordless type="sign-in" />;
}
return <UsernameSignin />;
}, [channel]);
return (
<div className={styles.wrapper}>
@ -21,7 +39,7 @@ const SecondarySignIn = () => {
/>
</div>
<div className={styles.title}>{t('sign_in.sign_in')}</div>
<PhonePasswordless type="sign-in" />
{signInForm}
</div>
);
};

View file

@ -2,7 +2,7 @@
.wrapper {
position: relative;
padding: _.unit(8);
padding: _.unit(8) _.unit(5);
@include _.flex-column;
.header {