Skip to content

Commit

Permalink
refactor(experience): cache identifier for reset password first screen
Browse files Browse the repository at this point in the history
  • Loading branch information
xiaoyijun committed Aug 26, 2024
1 parent ef78823 commit b902bfd
Show file tree
Hide file tree
Showing 6 changed files with 101 additions and 87 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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);
Expand All @@ -104,7 +89,6 @@ const UserInteractionContextProvider = ({ children }: Props) => {
ssoConnectors: domainFilteredConnectors,
setSsoConnectors: setDomainFilteredConnectors,
identifierInputValue,
getIdentifierInputValueByTypes,
setIdentifierInputValue,
forgotPasswordIdentifierInputValue,
setForgotPasswordIdentifierInputValue,
Expand All @@ -115,7 +99,6 @@ const UserInteractionContextProvider = ({ children }: Props) => {
ssoConnectorsMap,
domainFilteredConnectors,
identifierInputValue,
getIdentifierInputValueByTypes,
forgotPasswordIdentifierInputValue,
clearInteractionContextSessionStorage,
]
Expand Down
68 changes: 61 additions & 7 deletions packages/experience/src/hooks/use-prefilled-identifier.ts
Original file line number Diff line number Diff line change
@@ -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<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;
};

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<IdentifierInputValue>(() => {
/**
Expand Down
36 changes: 6 additions & 30 deletions packages/experience/src/pages/ForgotPassword/index.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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"
Expand All @@ -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>
);
};
Expand Down
29 changes: 26 additions & 3 deletions packages/experience/src/pages/ResetPassword/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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({
Expand Down
9 changes: 6 additions & 3 deletions packages/experience/src/pages/ResetPasswordLanding/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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>
);
};
Expand Down

0 comments on commit b902bfd

Please sign in to comment.