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:
parent
812841494a
commit
04d00fc11f
22 changed files with 315 additions and 56 deletions
|
@ -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',
|
||||
|
|
|
@ -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: '更多',
|
||||
|
|
|
@ -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>
|
||||
|
|
47
packages/ui/src/apis/utils.ts
Normal file
47
packages/ui/src/apis/utils.ts
Normal 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;
|
||||
};
|
|
@ -36,3 +36,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.errorMessage {
|
||||
margin-top: _.unit(2);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
.form {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
max-width: 360px;
|
||||
@include _.flex-column;
|
||||
|
||||
> * {
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.form {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
@include _.flex-column;
|
||||
|
||||
> * {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
16
packages/ui/src/containers/PasscodeValidation/index.tsx
Normal file
16
packages/ui/src/containers/PasscodeValidation/index.tsx
Normal 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;
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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>(
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
.form {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
max-width: 360px;
|
||||
@include _.flex-column;
|
||||
|
||||
> * {
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
.form {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
max-width: 360px;
|
||||
@include _.flex-column;
|
||||
|
||||
> * {
|
||||
|
|
22
packages/ui/src/pages/Passcode/index.module.scss
Normal file
22
packages/ui/src/pages/Passcode/index.module.scss
Normal 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);
|
||||
}
|
48
packages/ui/src/pages/Passcode/index.tsx
Normal file
48
packages/ui/src/pages/Passcode/index.tsx
Normal 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;
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
padding: _.unit(8);
|
||||
padding: _.unit(8) _.unit(5);
|
||||
@include _.flex-column;
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
padding: _.unit(8);
|
||||
padding: _.unit(8) _.unit(5);
|
||||
@include _.flex-column;
|
||||
}
|
||||
|
||||
|
|
43
packages/ui/src/pages/SecondarySignIn/index.test.tsx
Normal file
43
packages/ui/src/pages/SecondarySignIn/index.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
padding: _.unit(8);
|
||||
padding: _.unit(8) _.unit(5);
|
||||
@include _.flex-column;
|
||||
|
||||
.header {
|
||||
|
|
Loading…
Add table
Reference in a new issue