0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-24 22:41:28 -05:00

refactor(ui): implement errorHander in useApi hook (#684)

* refactor(ui): implement api error handler

implement api error handler

* refactor(ui): extend register account exist flow

 extend register account exist flow

* feat(ui): username exsit error

username exsit error

* refactor(ui): redirect pack if no value found in passcode validation page

redirect pack if no value found in passcode validation page
This commit is contained in:
simeng-li 2022-04-28 12:13:23 +08:00 committed by GitHub
parent d108f4b883
commit b601fb45ce
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 286 additions and 150 deletions

View file

@ -45,6 +45,7 @@ const translation = {
forgot_password: 'Forgot Password?',
or: 'Or',
enter_passcode: 'The passcode has been sent to {{address}}',
passcode_sent: 'The passcode has been sent',
resend_after_seconds: 'Resend after {{seconds}} seconds',
resend_passcode: 'Resend Passcode',
continue_with: 'Continue with',

View file

@ -47,6 +47,7 @@ const translation = {
forgot_password: '忘记密码?',
or: '或',
enter_passcode: '验证码已经发送至 {{ address }}',
passcode_sent: '验证码已经发送',
resend_after_seconds: '在 {{ seconds }} 秒后重发',
resend_passcode: '重发验证码',
continue_with: '通过以下方式继续',

View file

@ -9,7 +9,7 @@ import React, {
ClipboardEventHandler,
} from 'react';
import ErrorMessage, { ErrorType } from '../ErrorMessage';
import ErrorMessage from '../ErrorMessage';
import * as styles from './index.module.scss';
export const defaultLength = 6;
@ -19,7 +19,7 @@ export type Props = {
className?: string;
length?: number;
value: string[];
error?: ErrorType;
error?: string;
onChange: (value: string[]) => void;
};
@ -180,12 +180,10 @@ const Passcode = ({ name, className, value, length = defaultLength, error, onCha
);
useEffect(() => {
if (error) {
// Clear field and focus
onChange([]);
if (value.length === 0) {
inputReferences.current[0]?.focus();
}
}, [error, onChange]);
}, [value, onChange]);
return (
<div className={className}>
@ -198,7 +196,6 @@ const Passcode = ({ name, className, value, length = defaultLength, error, onCha
}}
// eslint-disable-next-line react/no-array-index-key
key={`${name}_${index}`}
autoFocus={index === 0}
name={`${name}_${index}`}
data-id={index}
value={codes[index]}
@ -213,7 +210,7 @@ const Passcode = ({ name, className, value, length = defaultLength, error, onCha
/>
))}
</div>
{error && <ErrorMessage error={error} className={styles.errorMessage} />}
{error && <ErrorMessage className={styles.errorMessage}>{error}</ErrorMessage>}
</div>
);
};

View file

@ -1,10 +1,5 @@
/**
* TODO:
* 1. API redesign handle api error and loading status globally in PageContext
*/
import classNames from 'classnames';
import React, { useEffect, useCallback, useContext } from 'react';
import React, { useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { register } from '@/apis/register';
@ -12,9 +7,8 @@ import Button from '@/components/Button';
import Input from '@/components/Input';
import PasswordInput from '@/components/Input/PasswordInput';
import TermsOfUse from '@/containers/TermsOfUse';
import useApi from '@/hooks/use-api';
import useApi, { ErrorHandlers } from '@/hooks/use-api';
import useForm from '@/hooks/use-form';
import { PageContext } from '@/hooks/use-page-context';
import useTerms from '@/hooks/use-terms';
import {
usernameValidation,
@ -41,17 +35,30 @@ const defaultState: FieldState = {
};
const CreateAccount = ({ className }: Props) => {
const { t, i18n } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const { termsValidation } = useTerms();
const { setToast } = useContext(PageContext);
const { error, result, run: asyncRegister } = useApi(register);
const {
fieldValue,
setFieldValue,
setFieldErrors,
register: fieldRegister,
validateForm,
} = useForm(defaultState);
const registerErrorHandlers: ErrorHandlers = useMemo(
() => ({
'user.username_exists_register': () => {
setFieldErrors((state) => ({
...state,
username: 'username_exists',
}));
},
}),
[setFieldErrors]
);
const { result, run: asyncRegister } = useApi(register, registerErrorHandlers);
const onSubmitHandler = useCallback(() => {
if (!validateForm()) {
return;
@ -70,13 +77,6 @@ const CreateAccount = ({ className }: Props) => {
}
}, [result]);
useEffect(() => {
// TODO: username exist error message
if (error) {
setToast(error.message);
}
}, [error, i18n, setToast, t]);
return (
<form className={classNames(styles.form, className)}>
<Input

View file

@ -5,10 +5,9 @@ import reactStringReplace from 'react-string-replace';
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 useApi from '@/hooks/use-api';
import useApi, { ErrorHandlers } from '@/hooks/use-api';
import { PageContext } from '@/hooks/use-page-context';
import { UserFlow, SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
@ -33,7 +32,7 @@ const getTimeout = () => {
const PasscodeValidation = ({ type, method, className, target }: Props) => {
const [code, setCode] = useState<string[]>([]);
const [error, setError] = useState<ErrorType>();
const [error, setError] = useState<string>();
const { setToast } = useContext(PageContext);
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
@ -42,17 +41,29 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
expiryTimestamp: getTimeout(),
});
const {
error: verifyPasscodeError,
result: verifyPasscodeResult,
run: verifyPassCode,
} = useApi(getVerifyPasscodeApi(type, method));
const verifyPasscodeErrorHandlers: ErrorHandlers = useMemo(
() => ({
'passcode.expired': (error) => {
setError(error.message);
},
'passcode.code_mismatch': (error) => {
setError(error.message);
},
callback: () => {
setCode([]);
},
}),
[]
);
const {
error: sendPasscodeError,
result: sendPasscodeResult,
run: sendPassCode,
} = useApi(getSendPasscodeApi(type, method));
const { result: verifyPasscodeResult, run: verifyPassCode } = useApi(
getVerifyPasscodeApi(type, method),
verifyPasscodeErrorHandlers
);
const { result: sendPasscodeResult, run: sendPassCode } = useApi(
getSendPasscodeApi(type, method)
);
useEffect(() => {
if (code.length === defaultLength && code.every(Boolean)) {
@ -64,9 +75,10 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
useEffect(() => {
// Restart count down
if (sendPasscodeResult) {
setToast(t('description.passcode_sent'));
restart(getTimeout(), true);
}
}, [sendPasscodeResult, restart]);
}, [sendPasscodeResult, restart, setToast, t]);
useEffect(() => {
if (verifyPasscodeResult?.redirectTo) {
@ -74,20 +86,6 @@ const PasscodeValidation = ({ type, method, className, target }: Props) => {
}
}, [verifyPasscodeResult]);
useEffect(() => {
// TODO: move to global handling
if (sendPasscodeError) {
setToast(t('error.request', { ...sendPasscodeError }));
}
}, [sendPasscodeError, setToast, t]);
useEffect(() => {
// TODO: load error from api response
if (verifyPasscodeError) {
setError('invalid_passcode');
}
}, [verifyPasscodeError]);
const renderCountDownMessage = useMemo(() => {
const contents = t('description.resend_after_seconds', { seconds });

View file

@ -1,19 +1,15 @@
/**
* TODO:
* 1. API redesign handle api error and loading status globally in PageContext
*/
import classNames from 'classnames';
import React, { useCallback, useEffect, useContext } from 'react';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { getSendPasscodeApi } from '@/apis/utils';
import Button from '@/components/Button';
import Input from '@/components/Input';
import PasswordlessConfirmModal from '@/containers/PasswordlessConfirmModal';
import TermsOfUse from '@/containers/TermsOfUse';
import useApi from '@/hooks/use-api';
import useApi, { ErrorHandlers } from '@/hooks/use-api';
import useForm from '@/hooks/use-form';
import { PageContext } from '@/hooks/use-page-context';
import useTerms from '@/hooks/use-terms';
import { UserFlow } from '@/types';
import { emailValidation } from '@/utils/field-validations';
@ -32,14 +28,30 @@ type FieldState = {
const defaultState: FieldState = { email: '' };
const EmailPasswordless = ({ type, className }: Props) => {
const [showPasswordlessConfirmModal, setShowPasswordlessConfirmModal] = useState(false);
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const { setToast } = useContext(PageContext);
const navigate = useNavigate();
const { termsValidation } = useTerms();
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
const { fieldValue, setFieldValue, setFieldErrors, register, validateForm } =
useForm(defaultState);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'user.email_not_exists': () => {
setShowPasswordlessConfirmModal(true);
},
'user.email_exists_register': () => {
setShowPasswordlessConfirmModal(true);
},
'guard.invalid_input': () => {
setFieldErrors({ email: 'invalid_email' });
},
}),
[setFieldErrors]
);
const sendPasscode = getSendPasscodeApi(type, 'email');
const { error, result, run: asyncSendPasscode } = useApi(sendPasscode);
const { result, run: asyncSendPasscode } = useApi(sendPasscode, errorHandlers);
const onSubmitHandler = useCallback(() => {
if (!validateForm()) {
@ -53,6 +65,10 @@ const EmailPasswordless = ({ type, className }: Props) => {
void asyncSendPasscode(fieldValue.email);
}, [validateForm, termsValidation, asyncSendPasscode, fieldValue.email]);
const onModalCloseHandler = useCallback(() => {
setShowPasswordlessConfirmModal(false);
}, []);
useEffect(() => {
if (result) {
navigate(
@ -65,30 +81,32 @@ const EmailPasswordless = ({ type, className }: Props) => {
}
}, [fieldValue.email, navigate, result, type]);
useEffect(() => {
// TODO: request error
if (error) {
setToast(t('error.request', { ...error }));
}
}, [error, t, setToast]);
return (
<form className={classNames(styles.form, className)}>
<Input
className={styles.inputField}
name="email"
autoComplete="email"
placeholder={t('input.email')}
{...register('email', emailValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, email: '' }));
}}
<>
<form className={classNames(styles.form, className)}>
<Input
className={styles.inputField}
name="email"
autoComplete="email"
placeholder={t('input.email')}
{...register('email', emailValidation)}
onClear={() => {
setFieldValue((state) => ({ ...state, email: '' }));
}}
/>
<TermsOfUse className={styles.terms} />
<Button onClick={onSubmitHandler}>{t('action.continue')}</Button>
</form>
<PasswordlessConfirmModal
isOpen={showPasswordlessConfirmModal}
type={type === 'sign-in' ? 'register' : 'sign-in'}
method="email"
value={fieldValue.email}
onClose={onModalCloseHandler}
/>
<TermsOfUse className={styles.terms} />
<Button onClick={onSubmitHandler}>{t('action.continue')}</Button>
</form>
</>
);
};

View file

@ -1,19 +1,15 @@
/**
* TODO:
* 1. API redesign handle api error and loading status globally in PageContext
*/
import classNames from 'classnames';
import React, { useCallback, useEffect, useContext } from 'react';
import React, { useCallback, useEffect, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { getSendPasscodeApi } from '@/apis/utils';
import Button from '@/components/Button';
import PhoneInput from '@/components/Input/PhoneInput';
import PasswordlessConfirmModal from '@/containers/PasswordlessConfirmModal';
import TermsOfUse from '@/containers/TermsOfUse';
import useApi from '@/hooks/use-api';
import useApi, { ErrorHandlers } from '@/hooks/use-api';
import useForm from '@/hooks/use-form';
import { PageContext } from '@/hooks/use-page-context';
import usePhoneNumber, { countryList } from '@/hooks/use-phone-number';
import useTerms from '@/hooks/use-terms';
import { UserFlow } from '@/types';
@ -32,15 +28,31 @@ type FieldState = {
const defaultState: FieldState = { phone: '' };
const PhonePasswordless = ({ type, className }: Props) => {
const [showPasswordlessConfirmModal, setShowPasswordlessConfirmModal] = useState(false);
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const { phoneNumber, setPhoneNumber, isValidPhoneNumber } = usePhoneNumber();
const { fieldValue, setFieldValue, validateForm, register } = useForm(defaultState);
const { setToast } = useContext(PageContext);
const navigate = useNavigate();
const { termsValidation } = useTerms();
const { fieldValue, setFieldValue, setFieldErrors, validateForm, register } =
useForm(defaultState);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'user.phone_not_exists': () => {
setShowPasswordlessConfirmModal(true);
},
'user.phone_exists_register': () => {
setShowPasswordlessConfirmModal(true);
},
'guard.invalid_input': () => {
setFieldErrors({ phone: 'invalid_phone' });
},
}),
[setFieldErrors]
);
const sendPasscode = getSendPasscodeApi(type, 'sms');
const { error, result, run: asyncSendPasscode } = useApi(sendPasscode);
const { result, run: asyncSendPasscode } = useApi(sendPasscode, errorHandlers);
const phoneNumberValidation = useCallback(
(phoneNumber: string) => {
@ -63,6 +75,10 @@ const PhonePasswordless = ({ type, className }: Props) => {
void asyncSendPasscode(fieldValue.phone);
}, [validateForm, termsValidation, asyncSendPasscode, fieldValue.phone]);
const onModalCloseHandler = useCallback(() => {
setShowPasswordlessConfirmModal(false);
}, []);
useEffect(() => {
// Sync phoneNumber
setFieldValue((previous) => ({
@ -75,36 +91,39 @@ const PhonePasswordless = ({ type, className }: Props) => {
if (result) {
navigate(
{ pathname: `/${type}/sms/passcode-validation`, search: location.search },
{ state: { phone: fieldValue.phone } }
{ state: { sms: fieldValue.phone } }
);
}
}, [fieldValue.phone, navigate, result, type]);
useEffect(() => {
if (error) {
setToast(t('error.request', { ...error }));
}
}, [error, t, setToast]);
return (
<form className={classNames(styles.form, className)}>
<PhoneInput
name="phone"
className={styles.inputField}
autoComplete="mobile"
placeholder={t('input.phone_number')}
countryCallingCode={phoneNumber.countryCallingCode}
nationalNumber={phoneNumber.nationalNumber}
countryList={countryList}
{...register('phone', phoneNumberValidation)}
onChange={(data) => {
setPhoneNumber((previous) => ({ ...previous, ...data }));
}}
/>
<TermsOfUse className={styles.terms} />
<>
<form className={classNames(styles.form, className)}>
<PhoneInput
name="phone"
className={styles.inputField}
autoComplete="mobile"
placeholder={t('input.phone_number')}
countryCallingCode={phoneNumber.countryCallingCode}
nationalNumber={phoneNumber.nationalNumber}
countryList={countryList}
{...register('phone', phoneNumberValidation)}
onChange={(data) => {
setPhoneNumber((previous) => ({ ...previous, ...data }));
}}
/>
<TermsOfUse className={styles.terms} />
<Button onClick={onSubmitHandler}>{t('action.continue')}</Button>
</form>
<Button onClick={onSubmitHandler}>{t('action.continue')}</Button>
</form>
<PasswordlessConfirmModal
isOpen={showPasswordlessConfirmModal}
type={type === 'sign-in' ? 'register' : 'sign-in'}
method="sms"
value={fieldValue.phone}
onClose={onModalCloseHandler}
/>
</>
);
};

View file

@ -0,0 +1,60 @@
import React, { useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { getSendPasscodeApi, PasscodeChannel } from '@/apis/utils';
import ConfirmModal from '@/components/ConfirmModal';
import useApi from '@/hooks/use-api';
import { UserFlow } from '@/types';
type Props = {
className?: string;
isOpen?: boolean;
type: UserFlow;
method: PasscodeChannel;
value: string;
onClose: () => void;
};
const PasswordlessConfirmModal = ({ className, isOpen, type, method, value, onClose }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const sendPasscode = getSendPasscodeApi(type, method);
const navigate = useNavigate();
const methodLocalName = t(`input.${method === 'email' ? 'email' : 'phone_number'}`);
const { result, run: asyncSendPasscode } = useApi(sendPasscode);
const onConfirmHandler = useCallback(() => {
onClose();
void asyncSendPasscode(value);
}, [asyncSendPasscode, onClose, value]);
useEffect(() => {
if (result) {
navigate(
{
pathname: `/${type}/${method}/passcode-validation`,
},
{ state: { [method]: value } }
);
}
}, [method, result, type, value, navigate, onClose]);
return (
<ConfirmModal
className={className}
isOpen={isOpen}
onClose={onClose}
onConfirm={onConfirmHandler}
>
{t(
type === 'sign-in'
? 'description.create_account_id_exists'
: 'description.sign_in_id_does_not_exists',
{ type: methodLocalName, value }
)}
</ConfirmModal>
);
};
export default PasswordlessConfirmModal;

View file

@ -1,20 +1,15 @@
/**
* TODO:
* 1. API redesign handle api error and loading status globally in PageContext
*/
import classNames from 'classnames';
import React, { useCallback, useEffect, useContext } from 'react';
import React, { useCallback, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { signInBasic } from '@/apis/sign-in';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import Input from '@/components/Input';
import PasswordInput from '@/components/Input/PasswordInput';
import TermsOfUse from '@/containers/TermsOfUse';
import useApi from '@/hooks/use-api';
import useApi, { ErrorHandlers } from '@/hooks/use-api';
import useForm from '@/hooks/use-form';
import { PageContext } from '@/hooks/use-page-context';
import useTerms from '@/hooks/use-terms';
import { SearchParameters } from '@/types';
import { getSearchParameters } from '@/utils';
@ -38,10 +33,26 @@ const defaultState: FieldState = {
const UsernameSignin = ({ className }: Props) => {
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const { setToast } = useContext(PageContext);
const { error, result, run: asyncSignInBasic } = useApi(signInBasic);
const { termsValidation } = useTerms();
const { fieldValue, setFieldValue, register, validateForm } = useForm(defaultState);
const {
fieldValue,
responseErrorMessage,
setFieldValue,
register,
validateForm,
setResponseErrorMessage,
} = useForm(defaultState);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'session.invalid_credentials': (error) => {
setResponseErrorMessage(error.message);
},
}),
[setResponseErrorMessage]
);
const { result, run: asyncSignInBasic } = useApi(signInBasic, errorHandlers);
const onSubmitHandler = useCallback(async () => {
if (!validateForm()) {
@ -63,13 +74,6 @@ const UsernameSignin = ({ className }: Props) => {
}
}, [result]);
useEffect(() => {
// TODO: API error message
if (error) {
setToast(t('error.request', { ...error }));
}
}, [error, t, setToast]);
return (
<form className={classNames(styles.form, className)}>
<Input
@ -89,7 +93,7 @@ const UsernameSignin = ({ className }: Props) => {
placeholder={t('input.password')}
{...register('password', passwordValidation)}
/>
{responseErrorMessage && <ErrorMessage>{responseErrorMessage}</ErrorMessage>}
<TermsOfUse className={styles.terms} />
<Button onClick={onSubmitHandler}>{t('action.sign_in')}</Button>

View file

@ -1,6 +1,8 @@
import { LogtoErrorCode } from '@logto/phrases';
import { RequestErrorBody } from '@logto/schemas';
import { HTTPError } from 'ky';
import { useState, useCallback, useContext } from 'react';
import { useState, useCallback, useContext, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { PageContext } from '@/hooks/use-page-context';
@ -10,13 +12,22 @@ type UseApi<T extends any[], U> = {
run: (...args: T) => Promise<void>;
};
export type ErrorHandlers = {
[key in LogtoErrorCode]?: (error: RequestErrorBody) => void;
} & {
global?: (error: RequestErrorBody) => void;
callback?: (error: RequestErrorBody) => void;
};
function useApi<Args extends any[], Response>(
api: (...args: Args) => Promise<Response>
api: (...args: Args) => Promise<Response>,
errorHandlers?: ErrorHandlers
): UseApi<Args, Response> {
const { t } = useTranslation(undefined, { keyPrefix: 'main_flow' });
const [error, setError] = useState<RequestErrorBody>();
const [result, setResult] = useState<Response>();
const { setLoading } = useContext(PageContext);
const { setLoading, setToast } = useContext(PageContext);
const run = useCallback(
async (...args: Args) => {
@ -44,6 +55,25 @@ function useApi<Args extends any[], Response>(
[api, setLoading]
);
useEffect(() => {
if (!error) {
return;
}
const { code } = error;
const handler = errorHandlers?.[code] ?? errorHandlers?.global;
errorHandlers?.callback?.(error);
if (handler) {
handler(error);
return;
}
setToast(t('error.request', { ...error }));
}, [error, errorHandlers, setToast, t]);
return {
error,
result,

View file

@ -14,6 +14,7 @@ const useForm = <T>(initialState: T) => {
const [fieldValue, setFieldValue] = useState<T>(initialState);
const [fieldErrors, setFieldErrors] = useState<ErrorState>({});
const [responseErrorMessage, setResponseErrorMessage] = useState<string>();
const fieldValidationsRef = useRef<FieldValidations>({});
@ -61,9 +62,11 @@ const useForm = <T>(initialState: T) => {
return {
fieldValue,
fieldErrors,
responseErrorMessage,
validateForm,
setFieldValue,
setFieldErrors,
setResponseErrorMessage,
register,
};
};

View file

@ -25,22 +25,28 @@ const Passcode = () => {
const { method, type } = useParams<Parameters>();
const state = useLocation().state as StateType;
const invalidSignInMethod = type !== 'sign-in' && type !== 'register';
const invalidChannel = method !== 'email' && method !== 'sms';
const invalidMethod = method !== 'email' && method !== 'sms';
useEffect(() => {
if (invalidSignInMethod || invalidChannel) {
if (invalidSignInMethod || invalidMethod) {
navigate('/404', { replace: true });
}
}, [invalidChannel, invalidSignInMethod, navigate]);
if (invalidSignInMethod || invalidChannel) {
return;
}
// Navigate to the back if no method value found
if (!state?.[method]) {
navigate(-1);
}
}, [invalidMethod, invalidSignInMethod, method, navigate, state]);
if (invalidSignInMethod || invalidMethod) {
return null;
}
const target = state ? state[method] : undefined;
const target = state?.[method];
if (!target) {
// TODO: no email or phone found
return null;
}

View file

@ -15,7 +15,6 @@ const SignIn = () => {
return (
<div className={classNames(styles.wrapper)}>
{/* TODO: load content from sign-in experience */}
<BrandingHeader
className={styles.header}
headline={style === BrandingStyle.Logo_Slogan ? slogan : undefined}