From b902bfd25bf98bbbde3822d08b40b44cdb36a411 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Mon, 26 Aug 2024 15:45:40 +0800 Subject: [PATCH] refactor(experience): cache identifier for reset password first screen --- .../UserInteractionContext.tsx | 27 +------- .../UserInteractionContextProvider/index.tsx | 19 +----- .../src/hooks/use-prefilled-identifier.ts | 68 +++++++++++++++++-- .../src/pages/ForgotPassword/index.tsx | 36 ++-------- .../src/pages/ResetPassword/index.tsx | 29 +++++++- .../src/pages/ResetPasswordLanding/index.tsx | 9 ++- 6 files changed, 101 insertions(+), 87 deletions(-) diff --git a/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx b/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx index 11811cada60..845a9260e8a 100644 --- a/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx +++ b/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx @@ -2,10 +2,7 @@ import { type SsoConnectorMetadata } from '@logto/schemas'; import { noop } from '@silverhand/essentials'; import { createContext } from 'react'; -import { - type IdentifierInputType, - type IdentifierInputValue, -} from '@/components/InputFields/SmartInputField'; +import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField'; export type UserInteractionContextType = { // All the enabled sso connectors @@ -19,26 +16,6 @@ export type UserInteractionContextType = { * The cached identifier input value that the user has inputted. */ identifierInputValue?: IdentifierInputValue; - /** - * Retrieves the cached identifier input value that the user has inputted based on enabled types. - * The value will be used to pre-fill the identifier input field in experience pages. - * - * @param {IdentifierInputType[]} enabledTypes - Array of enabled identifier types - * @returns {IdentifierInputValue | undefined} The identifier input value object or undefined - * - * The function checks if the type of identifierInputValue is in the `enabledTypes` array, - * if the type matches, it returns `identifierInputValue`; otherwise, it returns `undefined` - * - * Example: - * ```ts - * const value = getIdentifierInputValueByTypes(['email', 'phone']); - * // Returns `identifierInputValue` if its type is 'email' or 'phone' - * // Returns `undefined` otherwise - * ``` - */ - getIdentifierInputValueByTypes: ( - enabledTypes: IdentifierInputType[] - ) => IdentifierInputValue | undefined; /** * This method is used to cache the identifier input value. */ @@ -74,8 +51,6 @@ export default createContext({ setSsoEmail: noop, setSsoConnectors: noop, identifierInputValue: undefined, - // eslint-disable-next-line unicorn/no-useless-undefined - getIdentifierInputValueByTypes: () => undefined, setIdentifierInputValue: noop, forgotPasswordIdentifierInputValue: undefined, setForgotPasswordIdentifierInputValue: noop, diff --git a/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx b/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx index 663b171ea9f..1a4133e28db 100644 --- a/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx +++ b/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx @@ -1,10 +1,7 @@ import { type SsoConnectorMetadata } from '@logto/schemas'; import { type ReactNode, useEffect, useMemo, useState, useCallback } from 'react'; -import { - type IdentifierInputType, - type IdentifierInputValue, -} from '@/components/InputFields/SmartInputField'; +import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField'; import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages'; import { useSieMethods } from '@/hooks/use-sie'; @@ -79,18 +76,6 @@ const UserInteractionContextProvider = ({ children }: Props) => { [ssoConnectors] ); - const getIdentifierInputValueByTypes = useCallback( - (enabledTypes: IdentifierInputType[]) => { - const { type } = identifierInputValue ?? {}; - /** - * Check if the type is included in the enabledTypes array - * If it is, return identifierInputValue; otherwise, return undefined - */ - return type && enabledTypes.includes(type) ? identifierInputValue : undefined; - }, - [identifierInputValue] - ); - const clearInteractionContextSessionStorage = useCallback(() => { remove(StorageKeys.IdentifierInputValue); remove(StorageKeys.ForgotPasswordIdentifierInputValue); @@ -104,7 +89,6 @@ const UserInteractionContextProvider = ({ children }: Props) => { ssoConnectors: domainFilteredConnectors, setSsoConnectors: setDomainFilteredConnectors, identifierInputValue, - getIdentifierInputValueByTypes, setIdentifierInputValue, forgotPasswordIdentifierInputValue, setForgotPasswordIdentifierInputValue, @@ -115,7 +99,6 @@ const UserInteractionContextProvider = ({ children }: Props) => { ssoConnectorsMap, domainFilteredConnectors, identifierInputValue, - getIdentifierInputValueByTypes, forgotPasswordIdentifierInputValue, clearInteractionContextSessionStorage, ] diff --git a/packages/experience/src/hooks/use-prefilled-identifier.ts b/packages/experience/src/hooks/use-prefilled-identifier.ts index d36df5435df..0907d695fa3 100644 --- a/packages/experience/src/hooks/use-prefilled-identifier.ts +++ b/packages/experience/src/hooks/use-prefilled-identifier.ts @@ -1,26 +1,80 @@ import { type SignInIdentifier } from '@logto/schemas'; +import { type Optional } from '@silverhand/essentials'; import { useContext, useMemo } from 'react'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; -import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField'; +import { + type IdentifierInputType, + type IdentifierInputValue, +} from '@/components/InputFields/SmartInputField'; import useLoginHint from './use-login-hint'; +/** + * Retrieves the cached identifier input value that the user has inputted based on enabled types. + * The value will be used to pre-fill the identifier input field in experience pages. + * + * @param {IdentifierInputValue} identifierInputValue - The identifier input value to be checked + * @param {IdentifierInputType[]} enabledTypes - Array of enabled identifier types + * @returns {IdentifierInputValue | undefined} The identifier input value object or undefined + * + * The function checks if the type of identifierInputValue is in the `enabledTypes` array, + * if the type matches, it returns `identifierInputValue`; otherwise, it returns `undefined` + * + * Example: + * ```ts + * const value = getIdentifierInputValueByTypes(['email', 'phone']); + * // Returns `identifierInputValue` if its type is 'email' or 'phone' + * // Returns `undefined` otherwise + * ``` + */ +const getIdentifierInputValueByTypes = ( + identifierInputValue: IdentifierInputValue, + enabledTypes: IdentifierInputType[] +): Optional => { + const { type } = identifierInputValue; + /** + * Check if the type is included in the enabledTypes array + * If it is, return identifierInputValue; otherwise, return undefined + */ + return type && enabledTypes.includes(type) ? identifierInputValue : undefined; +}; + type Options = { enabledIdentifiers?: SignInIdentifier[]; + /** + * Whether the current page is the forgot password page + * + * Note: since a user may not use the same identifier to sign in and reset password, + * we need to distinguish between the two scenarios. + * E.g. the user may only use username to sign in, but only email or phone number can be used to reset password. + */ + isForgotPassword?: boolean; }; -const usePrefilledIdentifier = ({ enabledIdentifiers }: Options = {}) => { - const { identifierInputValue, getIdentifierInputValueByTypes } = +const usePrefilledIdentifier = ({ enabledIdentifiers, isForgotPassword = false }: Options = {}) => { + const { identifierInputValue, forgotPasswordIdentifierInputValue } = useContext(UserInteractionContext); const loginHint = useLoginHint(); const cachedInputIdentifier = useMemo(() => { - return enabledIdentifiers - ? getIdentifierInputValueByTypes(enabledIdentifiers) - : identifierInputValue; - }, [enabledIdentifiers, getIdentifierInputValueByTypes, identifierInputValue]); + const identifier = isForgotPassword ? forgotPasswordIdentifierInputValue : identifierInputValue; + /** + * If there's no identifier input value or no limitations for enabled identifiers, + * return the identifier input value as is (which might be undefined) + */ + if (!identifier || !enabledIdentifiers) { + return identifier; + } + + return getIdentifierInputValueByTypes(identifier, enabledIdentifiers); + }, [ + enabledIdentifiers, + forgotPasswordIdentifierInputValue, + identifierInputValue, + isForgotPassword, + ]); return useMemo(() => { /** diff --git a/packages/experience/src/pages/ForgotPassword/index.tsx b/packages/experience/src/pages/ForgotPassword/index.tsx index 5ce84622301..8224a1a069f 100644 --- a/packages/experience/src/pages/ForgotPassword/index.tsx +++ b/packages/experience/src/pages/ForgotPassword/index.tsx @@ -1,9 +1,7 @@ -import { SignInIdentifier } from '@logto/schemas'; -import { useCallback, useContext } from 'react'; import { useTranslation } from 'react-i18next'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; -import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; +import usePrefilledIdentifier from '@/hooks/use-prefilled-identifier'; import { useForgotPasswordSettings } from '@/hooks/use-sie'; import { identifierInputDescriptionMap } from '@/utils/form'; @@ -15,37 +13,15 @@ const ForgotPassword = () => { const { isForgotPasswordEnabled, enabledMethodSet } = useForgotPasswordSettings(); const { t } = useTranslation(); const enabledMethods = [...enabledMethodSet]; - const { forgotPasswordIdentifierInputValue } = useContext(UserInteractionContext); - - const getDefaultIdentifierType = useCallback( - (identifier?: SignInIdentifier) => { - if ( - identifier === SignInIdentifier.Username || - identifier === SignInIdentifier.Email || - !identifier - ) { - return enabledMethodSet.has(SignInIdentifier.Email) - ? SignInIdentifier.Email - : SignInIdentifier.Phone; - } - - return enabledMethodSet.has(SignInIdentifier.Phone) - ? SignInIdentifier.Phone - : SignInIdentifier.Email; - }, - [enabledMethodSet] - ); + const { value: prefilledValue } = usePrefilledIdentifier({ + enabledIdentifiers: enabledMethods, + isForgotPassword: true, + }); if (!isForgotPasswordEnabled) { return ; } - const defaultType = getDefaultIdentifierType(forgotPasswordIdentifierInputValue?.type); - const defaultValue = - (forgotPasswordIdentifierInputValue?.type === defaultType && - forgotPasswordIdentifierInputValue.value) || - ''; - return ( { types: enabledMethods.map((method) => t(identifierInputDescriptionMap[method])), }} > - + ); }; diff --git a/packages/experience/src/pages/ResetPassword/index.tsx b/packages/experience/src/pages/ResetPassword/index.tsx index 39756686150..da03f5dc4f3 100644 --- a/packages/experience/src/pages/ResetPassword/index.tsx +++ b/packages/experience/src/pages/ResetPassword/index.tsx @@ -21,7 +21,13 @@ const ResetPassword = () => { const { setToast } = useToast(); const navigate = useNavigate(); const { show } = usePromiseConfirmModal(); - const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext); + const { + identifierInputValue, + setIdentifierInputValue, + forgotPasswordIdentifierInputValue, + setForgotPasswordIdentifierInputValue, + } = useContext(UserInteractionContext); + const errorHandlers: ErrorHandlers = useMemo( () => ({ 'session.verification_session_not_found': async (error) => { @@ -37,14 +43,31 @@ const ResetPassword = () => { const successHandler: SuccessHandler = useCallback( (result) => { if (result) { - // Clear the forgot password identifier input value + /** + * Improve user experience by caching the identifier input value for sign-in page + * when the user is first redirected to the reset password page. + * This allows user to continue the sign flow without having to re-enter the identifier. + */ + if (!identifierInputValue) { + setIdentifierInputValue(forgotPasswordIdentifierInputValue); + } + + // Clear the forgot password identifier input value after the password is set setForgotPasswordIdentifierInputValue(undefined); setToast(t('description.password_changed')); navigate('/sign-in', { replace: true }); } }, - [navigate, setForgotPasswordIdentifierInputValue, setToast, t] + [ + forgotPasswordIdentifierInputValue, + identifierInputValue, + navigate, + setForgotPasswordIdentifierInputValue, + setIdentifierInputValue, + setToast, + t, + ] ); const [action] = usePasswordAction({ diff --git a/packages/experience/src/pages/ResetPasswordLanding/index.tsx b/packages/experience/src/pages/ResetPasswordLanding/index.tsx index 6bc258bd3d3..85885945708 100644 --- a/packages/experience/src/pages/ResetPasswordLanding/index.tsx +++ b/packages/experience/src/pages/ResetPasswordLanding/index.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { Navigate } from 'react-router-dom'; import FocusedAuthPageLayout from '@/Layout/FocusedAuthPageLayout'; -import useLoginHint from '@/hooks/use-login-hint'; +import usePrefilledIdentifier from '@/hooks/use-prefilled-identifier'; import { identifierInputDescriptionMap } from '@/utils/form'; import ForgotPasswordForm from '../ForgotPassword/ForgotPasswordForm'; @@ -33,7 +33,10 @@ import { useResetPasswordMethods } from './use-reset-password-methods'; const ResetPasswordLanding = () => { const { t } = useTranslation(); const enabledMethods = useResetPasswordMethods(); - const loginHint = useLoginHint(); + const { value: prefilledValue } = usePrefilledIdentifier({ + enabledIdentifiers: enabledMethods, + isForgotPassword: true, + }); // Fallback to sign-in page if (enabledMethods.length === 0) { @@ -54,7 +57,7 @@ const ResetPasswordLanding = () => { text: 'description.back_to_sign_in', }} > - + ); };