mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(ui): implement passcode page (#487)
* feat(ui): update pages update page routing and render logics * chore(ui): component renaming component renaming * feat(ui): implement passcode page implement passcode page * chore(ui): component renaming component renaming
This commit is contained in:
parent
5a0c91dbba
commit
b098dd5f59
13 changed files with 316 additions and 18 deletions
|
@ -27,7 +27,8 @@
|
|||
"react-i18next": "^11.15.4",
|
||||
"react-modal": "^3.14.4",
|
||||
"react-phone-number-input": "^3.1.46",
|
||||
"react-router-dom": "^5.2.0"
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-timer-hook": "^3.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/types": "^27.5.1",
|
||||
|
|
|
@ -81,6 +81,7 @@ $font-family: 'PingFang SC', 'SF Pro Text', sans-serif;
|
|||
/* Font Color */
|
||||
--color-font-primary: #{$color-neutral-100};
|
||||
--color-font-secondary: #444;
|
||||
--color-font-tertiary: #777;
|
||||
--color-font-placeholder: #aaa;
|
||||
--color-font-divider: #bbb;
|
||||
--color-font-button-text: #{$color-neutral-0};
|
||||
|
|
|
@ -2,6 +2,7 @@ import React, {
|
|||
useMemo,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
FormEventHandler,
|
||||
KeyboardEventHandler,
|
||||
FocusEventHandler,
|
||||
|
@ -30,14 +31,15 @@ const normalize = (value: string[], length: number): string[] => {
|
|||
}
|
||||
|
||||
if (value.length < length) {
|
||||
return value.concat(Array.from({ length: length - value.length }));
|
||||
// Undefined will not overwrite the original input displays, need to pass in empty string instead
|
||||
return value.concat(Array.from<string>({ length: length - value.length }).fill(''));
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
const trim = (oldValue: string | undefined, newValue: string) => {
|
||||
// Trim oldValue from the latest input to get the updated char
|
||||
// Pop oldValue from the latest input to get the updated Digit
|
||||
if (newValue.length > 1 && oldValue) {
|
||||
return newValue.replace(oldValue, '');
|
||||
}
|
||||
|
@ -78,7 +80,7 @@ const Passcode = ({ name, className, value, length = defaultLength, error, onCha
|
|||
|
||||
const targetId = Number(dataset.id);
|
||||
|
||||
// Update the total input value
|
||||
// Update the root input value
|
||||
onChange(Object.assign([], codes, { [targetId]: trim(codes[targetId], value) }));
|
||||
|
||||
// Move to the next target
|
||||
|
@ -177,6 +179,14 @@ const Passcode = ({ name, className, value, length = defaultLength, error, onCha
|
|||
[codes, onChange]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
// Clear field and focus
|
||||
onChange([]);
|
||||
inputReferences.current[0]?.focus();
|
||||
}
|
||||
}, [error, onChange]);
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={styles.passcode}>
|
||||
|
|
|
@ -9,7 +9,7 @@ export type Props = {
|
|||
className?: string;
|
||||
children?: ReactNode;
|
||||
text?: I18nKey;
|
||||
href: string;
|
||||
href?: string;
|
||||
type?: 'primary' | 'secondary';
|
||||
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
|
||||
};
|
||||
|
|
|
@ -8,4 +8,18 @@
|
|||
> * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: _.unit(8);
|
||||
|
||||
&.withError {
|
||||
margin-bottom: _.unit(2);
|
||||
}
|
||||
}
|
||||
|
||||
.message {
|
||||
> span {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
69
packages/ui/src/containers/PasscodeValidation/index.test.tsx
Normal file
69
packages/ui/src/containers/PasscodeValidation/index.test.tsx
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { render, act, fireEvent, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
import PasscodeValidation, { timeRange } from '.';
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
const sendPasscodeApi = jest.fn();
|
||||
const verifyPasscodeApi = jest.fn();
|
||||
|
||||
jest.mock('@/apis/utils', () => ({
|
||||
getSendPasscodeApi: () => sendPasscodeApi,
|
||||
getVerifyPasscodeApi: () => verifyPasscodeApi,
|
||||
}));
|
||||
|
||||
describe('<PasscodeValidation />', () => {
|
||||
const email = 'foo@logto.io';
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('render counter', () => {
|
||||
const { queryByText } = render(
|
||||
<PasscodeValidation type="sign-in" channel="email" target={email} />
|
||||
);
|
||||
|
||||
expect(queryByText(timeRange)).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
|
||||
expect(queryByText('sign_in.resend_passcode')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('fire resend event', async () => {
|
||||
const { getByText } = render(
|
||||
<PasscodeValidation type="sign-in" channel="email" target={email} />
|
||||
);
|
||||
act(() => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
const resendButton = getByText('sign_in.resend_passcode');
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(resendButton);
|
||||
});
|
||||
|
||||
expect(sendPasscodeApi).toBeCalledWith(email);
|
||||
});
|
||||
|
||||
it('fire validate passcode event', async () => {
|
||||
const { container } = render(
|
||||
<PasscodeValidation type="sign-in" channel="email" target={email} />
|
||||
);
|
||||
const inputs = container.querySelectorAll('input');
|
||||
|
||||
await waitFor(() => {
|
||||
for (const input of inputs) {
|
||||
act(() => {
|
||||
fireEvent.input(input, { target: { value: '1' } });
|
||||
});
|
||||
}
|
||||
|
||||
expect(verifyPasscodeApi).toBeCalledWith(email, '111111');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,16 +1,121 @@
|
|||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import React, { useState, useEffect, useContext, useMemo, ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTimer } from 'react-timer-hook';
|
||||
|
||||
import { getSendPasscodeApi, getVerifyPasscodeApi } from '@/apis/utils';
|
||||
import { ErrorType } from '@/components/ErrorMessage';
|
||||
import Passcode, { defaultLength } from '@/components/Passcode';
|
||||
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 Props = {
|
||||
type: 'sign-in' | 'register';
|
||||
channel: 'email' | 'phone';
|
||||
target: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const PasscodeValidation = ({ type, channel }: Props) => {
|
||||
console.log(type, channel);
|
||||
export const timeRange = 59;
|
||||
|
||||
return <form className={styles.form}>{/* TODO */}</form>;
|
||||
const getTimeout = () => {
|
||||
const now = new Date();
|
||||
now.setSeconds(now.getSeconds() + timeRange);
|
||||
|
||||
return now;
|
||||
};
|
||||
|
||||
const PasscodeValidation = ({ type, channel, className, target }: Props) => {
|
||||
const [code, setCode] = useState<string[]>([]);
|
||||
const [error, setError] = useState<ErrorType>();
|
||||
const { setToast } = useContext(PageContext);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { seconds, isRunning, restart } = useTimer({
|
||||
autoStart: true,
|
||||
expiryTimestamp: getTimeout(),
|
||||
});
|
||||
|
||||
const {
|
||||
error: verifyPasscodeError,
|
||||
result: verifyPasscodeResult,
|
||||
run: verifyPassCode,
|
||||
} = useApi(getVerifyPasscodeApi(type, channel));
|
||||
|
||||
const {
|
||||
error: sendPasscodeError,
|
||||
result: sendPasscodeResult,
|
||||
run: sendPassCode,
|
||||
} = useApi(getSendPasscodeApi(type, channel));
|
||||
|
||||
useEffect(() => {
|
||||
if (code.length === defaultLength && code.every(Boolean)) {
|
||||
void verifyPassCode(target, code.join(''));
|
||||
}
|
||||
}, [code, target, verifyPassCode]);
|
||||
|
||||
useEffect(() => {
|
||||
// Restart count down
|
||||
if (sendPasscodeResult) {
|
||||
restart(getTimeout(), true);
|
||||
}
|
||||
}, [sendPasscodeResult, restart]);
|
||||
|
||||
useEffect(() => {
|
||||
if (verifyPasscodeResult?.redirectTo) {
|
||||
window.location.assign(verifyPasscodeResult.redirectTo);
|
||||
}
|
||||
}, [verifyPasscodeResult]);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: move to global handling
|
||||
if (sendPasscodeError) {
|
||||
setToast(sendPasscodeError.message);
|
||||
}
|
||||
}, [sendPasscodeError, setToast]);
|
||||
|
||||
useEffect(() => {
|
||||
if (verifyPasscodeError) {
|
||||
setError(verifyPasscodeError.code);
|
||||
}
|
||||
}, [verifyPasscodeError]);
|
||||
|
||||
const renderCountDownMessage = useMemo(() => {
|
||||
const contents: ReactNode[] = t('sign_in.resend_after_seconds', { seconds }).split(
|
||||
`${seconds}`
|
||||
);
|
||||
const counter = <span key="counter">{seconds}</span>;
|
||||
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||
contents.splice(1, 0, counter);
|
||||
|
||||
return <div className={styles.message}>{contents}</div>;
|
||||
}, [seconds, t]);
|
||||
|
||||
return (
|
||||
<form className={classNames(styles.form, className)}>
|
||||
<Passcode
|
||||
name="passcode"
|
||||
className={classNames(styles.field, error && styles.withError)}
|
||||
value={code}
|
||||
error={error}
|
||||
onChange={setCode}
|
||||
/>
|
||||
{isRunning ? (
|
||||
renderCountDownMessage
|
||||
) : (
|
||||
<TextLink
|
||||
text="sign_in.resend_passcode"
|
||||
onClick={() => {
|
||||
void sendPassCode(target);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasscodeValidation;
|
||||
|
|
|
@ -8,6 +8,7 @@ import { LogtoErrorI18nKey } from '@logto/phrases';
|
|||
import classNames from 'classnames';
|
||||
import React, { useState, useCallback, useMemo, useEffect, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { getSendPasscodeApi } from '@/apis/utils';
|
||||
import Button from '@/components/Button';
|
||||
|
@ -45,6 +46,7 @@ const EmailPasswordless = ({ type }: Props) => {
|
|||
const [fieldState, setFieldState] = useState<FieldState>(defaultState);
|
||||
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
|
||||
const { setToast } = useContext(PageContext);
|
||||
const history = useHistory();
|
||||
|
||||
const sendPasscode = getSendPasscodeApi(type, 'email');
|
||||
|
||||
|
@ -92,9 +94,13 @@ const EmailPasswordless = ({ type }: Props) => {
|
|||
}, [loading, validations, fieldState, asyncSendPasscode]);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: navigate to the passcode page
|
||||
console.log(result);
|
||||
}, [result]);
|
||||
|
||||
if (result) {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||
history.push(`/${type}/email/passcode-validation`, { email: fieldState.email });
|
||||
}
|
||||
}, [fieldState.email, history, result, type]);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear errors
|
||||
|
|
|
@ -8,6 +8,7 @@ import { LogtoErrorI18nKey } from '@logto/phrases';
|
|||
import classNames from 'classnames';
|
||||
import React, { useState, useCallback, useMemo, useEffect, useContext } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
|
||||
import { getSendPasscodeApi } from '@/apis/utils';
|
||||
import Button from '@/components/Button';
|
||||
|
@ -44,6 +45,7 @@ const PhonePasswordless = ({ type }: Props) => {
|
|||
const [fieldState, setFieldState] = useState<FieldState>(defaultState);
|
||||
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
|
||||
const { setToast } = useContext(PageContext);
|
||||
const history = useHistory();
|
||||
|
||||
const { phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
|
||||
|
||||
|
@ -99,9 +101,13 @@ const PhonePasswordless = ({ type }: Props) => {
|
|||
}, [phoneNumber]);
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: navigate to the passcode page
|
||||
console.log(result);
|
||||
}, [result]);
|
||||
|
||||
if (result) {
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||
history.push(`/${type}/phone/passcode-validation`, { phone: fieldState.phone });
|
||||
}
|
||||
}, [fieldState.phone, history, result, type]);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear errors
|
||||
|
|
|
@ -18,5 +18,12 @@
|
|||
.title {
|
||||
width: 100%;
|
||||
@include _.title;
|
||||
margin-bottom: _.unit(9);
|
||||
margin-bottom: _.unit(1);
|
||||
}
|
||||
|
||||
.detail {
|
||||
width: 100%;
|
||||
margin-bottom: _.unit(9);
|
||||
font: var(--font-body);
|
||||
color: var(--color-font-tertiary);
|
||||
}
|
||||
|
|
51
packages/ui/src/pages/Passcode/index.test.tsx
Normal file
51
packages/ui/src/pages/Passcode/index.test.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { render } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { Route, MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import Passcode from '.';
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: () => ({
|
||||
state: { email: 'foo@logto.io' },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Passcode Page', () => {
|
||||
it('render with invalid type should lead to 404 page', () => {
|
||||
const { queryByText } = render(
|
||||
<MemoryRouter initialEntries={['/foo/phone/passcode-validation']}>
|
||||
<Route path="/:type/:channel/passcode-validation">
|
||||
<Passcode />
|
||||
</Route>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('sign_in.enter_passcode')).toBeNull();
|
||||
});
|
||||
|
||||
it('render with invalid channel should lead to 404 page', () => {
|
||||
const { queryByText } = render(
|
||||
<MemoryRouter initialEntries={['/sign-in/username/passcode-validation']}>
|
||||
<Route path="/:type/:channel/passcode-validation">
|
||||
<Passcode />
|
||||
</Route>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('sign_in.enter_passcode')).toBeNull();
|
||||
});
|
||||
|
||||
it('render properly', () => {
|
||||
const { queryByText } = render(
|
||||
<MemoryRouter initialEntries={['/sign-in/email/passcode-validation']}>
|
||||
<Route path="/:type/:channel/passcode-validation">
|
||||
<Passcode />
|
||||
</Route>
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(queryByText('sign_in.enter_passcode')).not.toBeNull();
|
||||
expect(queryByText('sign_in.passcode_sent')).not.toBeNull();
|
||||
});
|
||||
});
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useHistory, useParams } from 'react-router-dom';
|
||||
import { useHistory, useParams, useLocation } from 'react-router-dom';
|
||||
|
||||
import NavArrowIcon from '@/components/Icons/NavArrowIcon';
|
||||
import PasscodeValidation from '@/containers/PasscodeValidation';
|
||||
|
@ -12,24 +12,39 @@ type Props = {
|
|||
channel: string;
|
||||
};
|
||||
|
||||
type StateType = {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
const Passcode = () => {
|
||||
const { t } = useTranslation();
|
||||
const history = useHistory();
|
||||
const { type, channel } = useParams<Props>();
|
||||
const location = useLocation<StateType>();
|
||||
|
||||
// TODO: 404 page
|
||||
if (type !== 'sign-in' && type !== 'register') {
|
||||
window.location.assign('/404');
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||
history.push('/404');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (channel !== 'email' && channel !== 'phone') {
|
||||
window.location.assign('/404');
|
||||
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
|
||||
history.push('/404');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const target = location.state[channel];
|
||||
|
||||
if (!target) {
|
||||
// TODO: no email or phone found
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.navBar}>
|
||||
|
@ -40,7 +55,8 @@ const Passcode = () => {
|
|||
/>
|
||||
</div>
|
||||
<div className={styles.title}>{t('sign_in.enter_passcode')}</div>
|
||||
<PasscodeValidation type={type} channel={channel} />
|
||||
<div className={styles.detail}>{t('sign_in.passcode_sent', { target })}</div>
|
||||
<PasscodeValidation type={type} channel={channel} target={target} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -309,6 +309,7 @@ importers:
|
|||
react-modal: ^3.14.4
|
||||
react-phone-number-input: ^3.1.46
|
||||
react-router-dom: ^5.2.0
|
||||
react-timer-hook: ^3.0.5
|
||||
stylelint: ^13.13.1
|
||||
ts-jest: ^27.0.5
|
||||
typescript: ^4.6.2
|
||||
|
@ -326,6 +327,7 @@ importers:
|
|||
react-modal: 3.14.4_react-dom@17.0.2+react@17.0.2
|
||||
react-phone-number-input: 3.1.46_react-dom@17.0.2+react@17.0.2
|
||||
react-router-dom: 5.3.0_react@17.0.2
|
||||
react-timer-hook: 3.0.5_react-dom@17.0.2+react@17.0.2
|
||||
devDependencies:
|
||||
'@jest/types': 27.5.1
|
||||
'@parcel/core': 2.3.2
|
||||
|
@ -12067,6 +12069,16 @@ packages:
|
|||
react: 17.0.2
|
||||
dev: false
|
||||
|
||||
/react-timer-hook/3.0.5_react-dom@17.0.2+react@17.0.2:
|
||||
resolution: {integrity: sha512-n+98SdmYvui2ne3KyWb3Ldu4k0NYQa3g/VzW6VEIfZJ8GAk/jJsIY700M8Nd2vNSTj05c7wKyQfJBqZ0x7zfiA==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
dependencies:
|
||||
react: 17.0.2
|
||||
react-dom: 17.0.2_react@17.0.2
|
||||
dev: false
|
||||
|
||||
/react/17.0.2:
|
||||
resolution: {integrity: sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
Loading…
Reference in a new issue