Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 } =
charIeszhao marked this conversation as resolved.
Show resolved Hide resolved
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
Loading