0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-13 21:30:30 -05:00

refactor(experience): cache input identifier for reset password first screen (#6516)

This commit is contained in:
Xiao Yijun 2024-08-26 22:10:41 +08:00 committed by GitHub
parent ef78823948
commit 41ee881bb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 103 additions and 89 deletions

View file

@ -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<UserInteractionContextType>({
setSsoEmail: noop,
setSsoConnectors: noop,
identifierInputValue: undefined,
// eslint-disable-next-line unicorn/no-useless-undefined
getIdentifierInputValueByTypes: () => undefined,
setIdentifierInputValue: noop,
forgotPasswordIdentifierInputValue: undefined,
setForgotPasswordIdentifierInputValue: noop,

View file

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

View file

@ -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';
type Options = {
enabledIdentifiers?: SignInIdentifier[];
/**
* 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<IdentifierInputValue> => {
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;
};
const usePrefilledIdentifier = ({ enabledIdentifiers }: Options = {}) => {
const { identifierInputValue, getIdentifierInputValueByTypes } =
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, 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<IdentifierInputValue>(() => {
/**

View file

@ -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 <ErrorPage />;
}
const defaultType = getDefaultIdentifierType(forgotPasswordIdentifierInputValue?.type);
const defaultValue =
(forgotPasswordIdentifierInputValue?.type === defaultType &&
forgotPasswordIdentifierInputValue.value) ||
'';
return (
<SecondaryPageLayout
title="description.reset_password"
@ -54,7 +30,7 @@ const ForgotPassword = () => {
types: enabledMethods.map((method) => t(identifierInputDescriptionMap[method])),
}}
>
<ForgotPasswordForm autoFocus defaultValue={defaultValue} enabledTypes={enabledMethods} />
<ForgotPasswordForm autoFocus defaultValue={prefilledValue} enabledTypes={enabledMethods} />
</SecondaryPageLayout>
);
};

View file

@ -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<typeof setUserPassword> = 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({

View file

@ -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',
}}
>
<ForgotPasswordForm autoFocus defaultValue={loginHint} enabledTypes={enabledMethods} />
<ForgotPasswordForm autoFocus defaultValue={prefilledValue} enabledTypes={enabledMethods} />
</FocusedAuthPageLayout>
);
};