0
Fork 0
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:
simeng-li 2022-04-02 13:07:39 +08:00 committed by GitHub
parent 5a0c91dbba
commit b098dd5f59
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 316 additions and 18 deletions

View file

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

View file

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

View file

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

View file

@ -9,7 +9,7 @@ export type Props = {
className?: string;
children?: ReactNode;
text?: I18nKey;
href: string;
href?: string;
type?: 'primary' | 'secondary';
onClick?: React.MouseEventHandler<HTMLAnchorElement>;
};

View file

@ -8,4 +8,18 @@
> * {
width: 100%;
}
.field {
margin-bottom: _.unit(8);
&.withError {
margin-bottom: _.unit(2);
}
}
.message {
> span {
color: var(--color-primary);
}
}
}

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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