From 88d7a5f272fb0bb96c0fd4c81d14df6a7af045f5 Mon Sep 17 00:00:00 2001 From: simeng-li Date: Tue, 6 Aug 2024 15:07:25 +0800 Subject: [PATCH] refactor(experience): migrate the password register and sign-in migrate the password register and sign-in flow --- packages/experience/src/apis/experience.ts | 72 +++++++++++++++++++ .../experience/src/hooks/use-error-handler.ts | 2 +- .../src/hooks/use-password-policy-checker.ts | 32 +++++++++ .../hooks/use-password-rejection-handler.ts | 31 ++++++++ .../src/hooks/use-password-sign-in.ts | 12 ++-- .../use-register-with-username.ts | 14 ++-- .../src/pages/RegisterPassword/index.tsx | 46 +++++++----- .../pages/SignIn/PasswordSignInForm/index.tsx | 2 +- .../PasswordForm/index.test.tsx | 8 ++- .../SignInPassword/PasswordForm/index.tsx | 4 +- 10 files changed, 190 insertions(+), 33 deletions(-) create mode 100644 packages/experience/src/apis/experience.ts create mode 100644 packages/experience/src/hooks/use-password-policy-checker.ts create mode 100644 packages/experience/src/hooks/use-password-rejection-handler.ts diff --git a/packages/experience/src/apis/experience.ts b/packages/experience/src/apis/experience.ts new file mode 100644 index 00000000000..ac19fb356e2 --- /dev/null +++ b/packages/experience/src/apis/experience.ts @@ -0,0 +1,72 @@ +import { + type IdentificationApiPayload, + InteractionEvent, + type PasswordVerificationPayload, + SignInIdentifier, + type UpdateProfileApiPayload, +} from '@logto/schemas'; + +import api from './api'; + +const prefix = '/api/experience'; + +const experienceRoutes = Object.freeze({ + prefix, + identification: `${prefix}/identification`, + verification: `${prefix}/verification`, + profile: `${prefix}/profile`, + mfa: `${prefix}/profile/mfa`, +}); + +type VerificationResponse = { + verificationId: string; +}; + +type SubmitInteractionResponse = { + redirectTo: string; +}; + +const initInteraction = async (interactionEvent: InteractionEvent) => + api.put(`${experienceRoutes.prefix}`, { + json: { + interactionEvent, + }, + }); + +const identifyUser = async (payload: IdentificationApiPayload = {}) => + api.post(experienceRoutes.identification, { json: payload }); + +const submitInteraction = async () => + api.post(`${experienceRoutes.prefix}/submit`).json(); + +const updateProfile = async (payload: UpdateProfileApiPayload) => { + await api.post(experienceRoutes.profile, { json: payload }); +}; + +export const signInWithPasswordIdentifier = async (payload: PasswordVerificationPayload) => { + await initInteraction(InteractionEvent.SignIn); + + const { verificationId } = await api + .post(`${experienceRoutes.verification}/password`, { + json: payload, + }) + .json(); + + await identifyUser({ verificationId }); + + return submitInteraction(); +}; + +export const registerWithUsername = async (username: string) => { + await initInteraction(InteractionEvent.Register); + + return updateProfile({ type: SignInIdentifier.Username, value: username }); +}; + +export const continueRegisterWithPassword = async (password: string) => { + await updateProfile({ type: 'password', value: password }); + + await identifyUser(); + + return submitInteraction(); +}; diff --git a/packages/experience/src/hooks/use-error-handler.ts b/packages/experience/src/hooks/use-error-handler.ts index ed21aed4830..2dea1752e22 100644 --- a/packages/experience/src/hooks/use-error-handler.ts +++ b/packages/experience/src/hooks/use-error-handler.ts @@ -34,7 +34,7 @@ const useErrorHandler = () => { } return; - } catch { + } catch (error) { setToast(t('error.unknown')); console.error(error); diff --git a/packages/experience/src/hooks/use-password-policy-checker.ts b/packages/experience/src/hooks/use-password-policy-checker.ts new file mode 100644 index 00000000000..afc08ff7240 --- /dev/null +++ b/packages/experience/src/hooks/use-password-policy-checker.ts @@ -0,0 +1,32 @@ +import { useCallback } from 'react'; + +import usePasswordErrorMessage from './use-password-error-message'; +import { usePasswordPolicy } from './use-sie'; + +type PasswordPolicyCheckProps = { + setErrorMessage: (message?: string) => void; +}; + +const usePasswordPolicyChecker = ({ setErrorMessage }: PasswordPolicyCheckProps) => { + const { getErrorMessage } = usePasswordErrorMessage(); + const { policyChecker } = usePasswordPolicy(); + + const checkPassword = useCallback( + async (password: string) => { + // Perform fast check before sending request + const fastCheckErrorMessage = getErrorMessage(policyChecker.fastCheck(password)); + + if (fastCheckErrorMessage) { + setErrorMessage(fastCheckErrorMessage); + return false; + } + + return true; + }, + [getErrorMessage, policyChecker, setErrorMessage] + ); + + return checkPassword; +}; + +export default usePasswordPolicyChecker; diff --git a/packages/experience/src/hooks/use-password-rejection-handler.ts b/packages/experience/src/hooks/use-password-rejection-handler.ts new file mode 100644 index 00000000000..99969f039d2 --- /dev/null +++ b/packages/experience/src/hooks/use-password-rejection-handler.ts @@ -0,0 +1,31 @@ +import { type RequestErrorBody } from '@logto/schemas'; +import { useCallback, useMemo } from 'react'; + +import type { ErrorHandlers } from './use-error-handler'; +import usePasswordErrorMessage from './use-password-error-message'; + +type ErrorHandlerProps = { + setErrorMessage: (message?: string) => void; +}; + +const usePasswordRejectionErrorHandler = ({ setErrorMessage }: ErrorHandlerProps) => { + const { getErrorMessageFromBody } = usePasswordErrorMessage(); + + const passwordRejectionHandler = useCallback( + (error: RequestErrorBody) => { + setErrorMessage(getErrorMessageFromBody(error)); + }, + [getErrorMessageFromBody, setErrorMessage] + ); + + const passwordRejectionErrorHandler = useMemo( + () => ({ + 'password.rejected': passwordRejectionHandler, + }), + [passwordRejectionHandler] + ); + + return passwordRejectionErrorHandler; +}; + +export default usePasswordRejectionErrorHandler; diff --git a/packages/experience/src/hooks/use-password-sign-in.ts b/packages/experience/src/hooks/use-password-sign-in.ts index 23cce8bcb07..fd8b3ac8cc5 100644 --- a/packages/experience/src/hooks/use-password-sign-in.ts +++ b/packages/experience/src/hooks/use-password-sign-in.ts @@ -1,7 +1,7 @@ +import { SignInIdentifier, type PasswordVerificationPayload } from '@logto/schemas'; import { useCallback, useMemo, useState } from 'react'; -import type { PasswordSignInPayload } from '@/apis/interaction'; -import { signInWithPasswordIdentifier } from '@/apis/interaction'; +import { signInWithPasswordIdentifier } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; @@ -34,10 +34,12 @@ const usePasswordSignIn = () => { ); const onSubmit = useCallback( - async (payload: PasswordSignInPayload) => { + async (payload: PasswordVerificationPayload) => { + const { identifier } = payload; + // Check if the email is registered with any SSO connectors. If the email is registered with any SSO connectors, we should not proceed to the next step - if (payload.email) { - const result = await checkSingleSignOn(payload.email); + if (identifier.type === SignInIdentifier.Email) { + const result = await checkSingleSignOn(identifier.value); if (result) { return; diff --git a/packages/experience/src/pages/Register/IdentifierRegisterForm/use-register-with-username.ts b/packages/experience/src/pages/Register/IdentifierRegisterForm/use-register-with-username.ts index cd9b41e75dd..6bc4b411232 100644 --- a/packages/experience/src/pages/Register/IdentifierRegisterForm/use-register-with-username.ts +++ b/packages/experience/src/pages/Register/IdentifierRegisterForm/use-register-with-username.ts @@ -1,7 +1,7 @@ import { useState, useCallback, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { registerWithUsernamePassword } from '@/apis/interaction'; +import { registerWithUsername } from '@/apis/experience'; import useApi from '@/hooks/use-api'; import type { ErrorHandlers } from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler'; @@ -19,15 +19,12 @@ const useRegisterWithUsername = () => { 'user.username_already_in_use': (error) => { setErrorMessage(error.message); }, - 'user.missing_profile': () => { - navigate('password'); - }, }), - [navigate] + [] ); const handleError = useErrorHandler(); - const asyncRegister = useApi(registerWithUsernamePassword); + const asyncRegister = useApi(registerWithUsername); const onSubmit = useCallback( async (username: string) => { @@ -35,9 +32,12 @@ const useRegisterWithUsername = () => { if (error) { await handleError(error, errorHandlers); + return; } + + navigate('password'); }, - [asyncRegister, errorHandlers, handleError] + [asyncRegister, errorHandlers, handleError, navigate] ); return { errorMessage, clearErrorMessage, onSubmit }; diff --git a/packages/experience/src/pages/RegisterPassword/index.tsx b/packages/experience/src/pages/RegisterPassword/index.tsx index 4c0c722aa70..227a499b9dc 100644 --- a/packages/experience/src/pages/RegisterPassword/index.tsx +++ b/packages/experience/src/pages/RegisterPassword/index.tsx @@ -3,13 +3,15 @@ import { useCallback, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; -import { setUserPassword } from '@/apis/interaction'; +import { continueRegisterWithPassword } from '@/apis/experience'; import SetPassword from '@/containers/SetPassword'; +import useApi from '@/hooks/use-api'; import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal'; -import { type ErrorHandlers } from '@/hooks/use-error-handler'; +import useErrorHandler, { type ErrorHandlers } from '@/hooks/use-error-handler'; import useGlobalRedirectTo from '@/hooks/use-global-redirect-to'; import useMfaErrorHandler from '@/hooks/use-mfa-error-handler'; -import usePasswordAction, { type SuccessHandler } from '@/hooks/use-password-action'; +import usePasswordPolicyChecker from '@/hooks/use-password-policy-checker'; +import usePasswordRejectionErrorHandler from '@/hooks/use-password-rejection-handler'; import { usePasswordPolicy, useSieMethods } from '@/hooks/use-sie'; import ErrorPage from '../ErrorPage'; @@ -25,7 +27,12 @@ const RegisterPassword = () => { setErrorMessage(undefined); }, []); + const checkPassword = usePasswordPolicyChecker({ setErrorMessage }); + const asyncRegisterPassword = useApi(continueRegisterWithPassword); + const handleError = useErrorHandler(); + const mfaErrorHandler = useMfaErrorHandler({ replace: true }); + const passwordRejectionErrorHandler = usePasswordRejectionErrorHandler({ setErrorMessage }); const errorHandlers: ErrorHandlers = useMemo( () => ({ @@ -35,26 +42,33 @@ const RegisterPassword = () => { navigate(-1); }, ...mfaErrorHandler, + ...passwordRejectionErrorHandler, }), - [navigate, mfaErrorHandler, show] + [mfaErrorHandler, passwordRejectionErrorHandler, show, navigate] ); - const successHandler: SuccessHandler = useCallback( - async (result) => { - if (result && 'redirectTo' in result) { + const onSubmitHandler = useCallback( + async (password: string) => { + const success = await checkPassword(password); + + if (!success) { + return; + } + + const [error, result] = await asyncRegisterPassword(password); + + if (error) { + await handleError(error, errorHandlers); + return; + } + + if (result) { await redirectTo(result.redirectTo); } }, - [redirectTo] + [asyncRegisterPassword, checkPassword, errorHandlers, handleError, redirectTo] ); - const [action] = usePasswordAction({ - api: setUserPassword, - setErrorMessage, - errorHandlers, - successHandler, - }); - const { policy: { length: { min, max }, @@ -78,7 +92,7 @@ const RegisterPassword = () => { errorMessage={errorMessage} maxLength={max} clearErrorMessage={clearErrorMessage} - onSubmit={action} + onSubmit={onSubmitHandler} /> ); diff --git a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx index 5d6589c6796..e9ad40c98db 100644 --- a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx +++ b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.tsx @@ -80,7 +80,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => { } await onSubmit({ - [type]: value, + identifier: { type, value }, password, }); })(event); diff --git a/packages/experience/src/pages/SignInPassword/PasswordForm/index.test.tsx b/packages/experience/src/pages/SignInPassword/PasswordForm/index.test.tsx index 08d948fc0da..cf255021db9 100644 --- a/packages/experience/src/pages/SignInPassword/PasswordForm/index.test.tsx +++ b/packages/experience/src/pages/SignInPassword/PasswordForm/index.test.tsx @@ -71,7 +71,13 @@ describe('PasswordSignInForm', () => { }); await waitFor(() => { - expect(signInWithPasswordIdentifier).toBeCalledWith({ [identifier]: value, password }); + expect(signInWithPasswordIdentifier).toBeCalledWith({ + identifier: { + type: identifier, + value, + }, + password, + }); }); if (isVerificationCodeEnabled) { diff --git a/packages/experience/src/pages/SignInPassword/PasswordForm/index.tsx b/packages/experience/src/pages/SignInPassword/PasswordForm/index.tsx index 7707ee22bb0..b362e3d1acf 100644 --- a/packages/experience/src/pages/SignInPassword/PasswordForm/index.tsx +++ b/packages/experience/src/pages/SignInPassword/PasswordForm/index.tsx @@ -1,7 +1,7 @@ import { SignInIdentifier } from '@logto/schemas'; import classNames from 'classnames'; import { useCallback, useContext, useEffect } from 'react'; -import { useForm, Controller } from 'react-hook-form'; +import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; @@ -77,7 +77,7 @@ const PasswordForm = ({ setIdentifierInputValue({ type, value }); await onSubmit({ - [type]: value, + identifier: { type, value }, password, }); })(event);