From b098dd5f59a55a29bff8461b69c9613b7d6d06ed Mon Sep 17 00:00:00 2001 From: simeng-li Date: Sat, 2 Apr 2022 13:07:39 +0800 Subject: [PATCH] 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 --- packages/ui/package.json | 3 +- .../components/AppContent/index.module.scss | 1 + packages/ui/src/components/Passcode/index.tsx | 16 ++- packages/ui/src/components/TextLink/index.tsx | 2 +- .../PasscodeValidation/index.module.scss | 14 +++ .../PasscodeValidation/index.test.tsx | 69 +++++++++++ .../containers/PasscodeValidation/index.tsx | 113 +++++++++++++++++- .../Passwordless/EmailPasswordless.tsx | 10 +- .../Passwordless/PhonePasswordless.tsx | 10 +- .../ui/src/pages/Passcode/index.module.scss | 9 +- packages/ui/src/pages/Passcode/index.test.tsx | 51 ++++++++ packages/ui/src/pages/Passcode/index.tsx | 24 +++- pnpm-lock.yaml | 12 ++ 13 files changed, 316 insertions(+), 18 deletions(-) create mode 100644 packages/ui/src/containers/PasscodeValidation/index.test.tsx create mode 100644 packages/ui/src/pages/Passcode/index.test.tsx diff --git a/packages/ui/package.json b/packages/ui/package.json index 182e39c09..28d7cc761 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/components/AppContent/index.module.scss b/packages/ui/src/components/AppContent/index.module.scss index 0223130d1..cc054c25c 100644 --- a/packages/ui/src/components/AppContent/index.module.scss +++ b/packages/ui/src/components/AppContent/index.module.scss @@ -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}; diff --git a/packages/ui/src/components/Passcode/index.tsx b/packages/ui/src/components/Passcode/index.tsx index 009adebb8..6efa44efe 100644 --- a/packages/ui/src/components/Passcode/index.tsx +++ b/packages/ui/src/components/Passcode/index.tsx @@ -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({ 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 (
diff --git a/packages/ui/src/components/TextLink/index.tsx b/packages/ui/src/components/TextLink/index.tsx index 33b228be8..032e46327 100644 --- a/packages/ui/src/components/TextLink/index.tsx +++ b/packages/ui/src/components/TextLink/index.tsx @@ -9,7 +9,7 @@ export type Props = { className?: string; children?: ReactNode; text?: I18nKey; - href: string; + href?: string; type?: 'primary' | 'secondary'; onClick?: React.MouseEventHandler; }; diff --git a/packages/ui/src/containers/PasscodeValidation/index.module.scss b/packages/ui/src/containers/PasscodeValidation/index.module.scss index 20507e724..ab632df13 100644 --- a/packages/ui/src/containers/PasscodeValidation/index.module.scss +++ b/packages/ui/src/containers/PasscodeValidation/index.module.scss @@ -8,4 +8,18 @@ > * { width: 100%; } + + .field { + margin-bottom: _.unit(8); + + &.withError { + margin-bottom: _.unit(2); + } + } + + .message { + > span { + color: var(--color-primary); + } + } } diff --git a/packages/ui/src/containers/PasscodeValidation/index.test.tsx b/packages/ui/src/containers/PasscodeValidation/index.test.tsx new file mode 100644 index 000000000..43a6ebaef --- /dev/null +++ b/packages/ui/src/containers/PasscodeValidation/index.test.tsx @@ -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('', () => { + const email = 'foo@logto.io'; + + afterAll(() => { + jest.clearAllMocks(); + }); + + it('render counter', () => { + const { queryByText } = render( + + ); + + expect(queryByText(timeRange)).not.toBeNull(); + + act(() => { + jest.runAllTimers(); + }); + + expect(queryByText('sign_in.resend_passcode')).not.toBeNull(); + }); + + it('fire resend event', async () => { + const { getByText } = render( + + ); + 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( + + ); + const inputs = container.querySelectorAll('input'); + + await waitFor(() => { + for (const input of inputs) { + act(() => { + fireEvent.input(input, { target: { value: '1' } }); + }); + } + + expect(verifyPasscodeApi).toBeCalledWith(email, '111111'); + }); + }); +}); diff --git a/packages/ui/src/containers/PasscodeValidation/index.tsx b/packages/ui/src/containers/PasscodeValidation/index.tsx index c3e10ce14..3c35ff599 100644 --- a/packages/ui/src/containers/PasscodeValidation/index.tsx +++ b/packages/ui/src/containers/PasscodeValidation/index.tsx @@ -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
{/* TODO */}
; +const getTimeout = () => { + const now = new Date(); + now.setSeconds(now.getSeconds() + timeRange); + + return now; +}; + +const PasscodeValidation = ({ type, channel, className, target }: Props) => { + const [code, setCode] = useState([]); + const [error, setError] = useState(); + 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 = {seconds}; + + // eslint-disable-next-line @silverhand/fp/no-mutating-methods + contents.splice(1, 0, counter); + + return
{contents}
; + }, [seconds, t]); + + return ( +
+ + {isRunning ? ( + renderCountDownMessage + ) : ( + { + void sendPassCode(target); + }} + /> + )} + + ); }; export default PasscodeValidation; diff --git a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx index 9bd3f238d..7479b1229 100644 --- a/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx +++ b/packages/ui/src/containers/Passwordless/EmailPasswordless.tsx @@ -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(defaultState); const [fieldErrors, setFieldErrors] = useState({}); 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 diff --git a/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx b/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx index 428a225d7..d2309a05d 100644 --- a/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx +++ b/packages/ui/src/containers/Passwordless/PhonePasswordless.tsx @@ -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(defaultState); const [fieldErrors, setFieldErrors] = useState({}); 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 diff --git a/packages/ui/src/pages/Passcode/index.module.scss b/packages/ui/src/pages/Passcode/index.module.scss index 66b0a03c5..d24bb1426 100644 --- a/packages/ui/src/pages/Passcode/index.module.scss +++ b/packages/ui/src/pages/Passcode/index.module.scss @@ -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); } diff --git a/packages/ui/src/pages/Passcode/index.test.tsx b/packages/ui/src/pages/Passcode/index.test.tsx new file mode 100644 index 000000000..45cdf4b6e --- /dev/null +++ b/packages/ui/src/pages/Passcode/index.test.tsx @@ -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( + + + + + + ); + + expect(queryByText('sign_in.enter_passcode')).toBeNull(); + }); + + it('render with invalid channel should lead to 404 page', () => { + const { queryByText } = render( + + + + + + ); + + expect(queryByText('sign_in.enter_passcode')).toBeNull(); + }); + + it('render properly', () => { + const { queryByText } = render( + + + + + + ); + + expect(queryByText('sign_in.enter_passcode')).not.toBeNull(); + expect(queryByText('sign_in.passcode_sent')).not.toBeNull(); + }); +}); diff --git a/packages/ui/src/pages/Passcode/index.tsx b/packages/ui/src/pages/Passcode/index.tsx index 27eb69541..f7f906978 100644 --- a/packages/ui/src/pages/Passcode/index.tsx +++ b/packages/ui/src/pages/Passcode/index.tsx @@ -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(); + const location = useLocation(); // 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 (
@@ -40,7 +55,8 @@ const Passcode = () => { />
{t('sign_in.enter_passcode')}
- +
{t('sign_in.passcode_sent', { target })}
+
); }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ff5f13ca..f78decf25 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'}