From 46fb70c4bf5c1315cda366b0e714831e13ded9b6 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Mon, 1 Jul 2024 15:52:52 +0800 Subject: [PATCH] refactor(experience): cache user input identifier value --- packages/experience/src/App.tsx | 6 +- .../SingleSignOnContext.tsx | 21 ----- .../SingleSignOnContextProvider/index.tsx | 62 ------------- .../UserInteractionContext.tsx | 29 ++++++ .../UserInteractionContextProvider/index.tsx | 90 +++++++++++++++++++ .../HiddenIdentifierInput/index.tsx | 23 +++++ .../src/containers/SetPassword/Lite.tsx | 2 + .../containers/SetPassword/SetPassword.tsx | 2 + .../src/hooks/use-check-single-sign-on.ts | 13 +-- .../src/hooks/use-global-redirect-to.ts | 7 +- .../src/hooks/use-session-storages.ts | 4 +- .../src/hooks/use-single-sign-on-watch.ts | 14 +-- .../ForgotPasswordForm/index.tsx | 10 ++- .../src/pages/ForgotPassword/index.tsx | 14 ++- .../IdentifierRegisterForm/index.test.tsx | 6 +- .../Register/IdentifierRegisterForm/index.tsx | 7 +- .../IdentifierRegisterForm/use-on-submit.ts | 14 ++- .../IdentifierSignInForm/index.test.tsx | 6 +- .../SignIn/IdentifierSignInForm/index.tsx | 6 +- .../IdentifierSignInForm/use-on-submit.ts | 7 +- .../SignIn/PasswordSignInForm/index.test.tsx | 6 +- .../pages/SingleSignOnConnectors/index.tsx | 10 +-- .../src/pages/SingleSignOnEmail/index.tsx | 6 +- packages/experience/src/types/guard.ts | 17 ++++ 24 files changed, 247 insertions(+), 135 deletions(-) delete mode 100644 packages/experience/src/Providers/SingleSignOnContextProvider/SingleSignOnContext.tsx delete mode 100644 packages/experience/src/Providers/SingleSignOnContextProvider/index.tsx create mode 100644 packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx create mode 100644 packages/experience/src/Providers/UserInteractionContextProvider/index.tsx create mode 100644 packages/experience/src/components/HiddenIdentifierInput/index.tsx diff --git a/packages/experience/src/App.tsx b/packages/experience/src/App.tsx index e58276176..3880823a5 100644 --- a/packages/experience/src/App.tsx +++ b/packages/experience/src/App.tsx @@ -6,7 +6,7 @@ import AppBoundary from './Providers/AppBoundary'; import LoadingLayerProvider from './Providers/LoadingLayerProvider'; import PageContextProvider from './Providers/PageContextProvider'; import SettingsProvider from './Providers/SettingsProvider'; -import SingleSignOnContextProvider from './Providers/SingleSignOnContextProvider'; +import UserInteractionContextProvider from './Providers/UserInteractionContextProvider'; import Callback from './pages/Callback'; import Consent from './pages/Consent'; import Continue from './pages/Continue'; @@ -45,7 +45,7 @@ const App = () => { - + }> @@ -125,7 +125,7 @@ const App = () => { - + diff --git a/packages/experience/src/Providers/SingleSignOnContextProvider/SingleSignOnContext.tsx b/packages/experience/src/Providers/SingleSignOnContextProvider/SingleSignOnContext.tsx deleted file mode 100644 index 3295c46e9..000000000 --- a/packages/experience/src/Providers/SingleSignOnContextProvider/SingleSignOnContext.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { type SsoConnectorMetadata } from '@logto/schemas'; -import { noop } from '@silverhand/essentials'; -import { createContext } from 'react'; - -export type SingleSignOnContextType = { - // All the enabled sso connectors - availableSsoConnectorsMap: Map; - email?: string; - setEmail: React.Dispatch>; - // The sso connectors that are enabled for the current domain - ssoConnectors: SsoConnectorMetadata[]; - setSsoConnectors: React.Dispatch>; -}; - -export default createContext({ - email: undefined, - availableSsoConnectorsMap: new Map(), - ssoConnectors: [], - setEmail: noop, - setSsoConnectors: noop, -}); diff --git a/packages/experience/src/Providers/SingleSignOnContextProvider/index.tsx b/packages/experience/src/Providers/SingleSignOnContextProvider/index.tsx deleted file mode 100644 index ed4cbf00e..000000000 --- a/packages/experience/src/Providers/SingleSignOnContextProvider/index.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { type SsoConnectorMetadata } from '@logto/schemas'; -import { type ReactNode, useEffect, useMemo, useState } from 'react'; - -import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages'; -import { useSieMethods } from '@/hooks/use-sie'; - -import SingleSignOnContext, { type SingleSignOnContextType } from './SingleSignOnContext'; - -type Props = { - readonly children: ReactNode; -}; - -const SingleSignOnContextProvider = ({ children }: Props) => { - const { ssoConnectors } = useSieMethods(); - const { get, set, remove } = useSessionStorage(); - const [email, setEmail] = useState(get(StorageKeys.SsoEmail)); - const [domainFilteredConnectors, setDomainFilteredConnectors] = useState( - get(StorageKeys.SsoConnectors) ?? [] - ); - - useEffect(() => { - if (!email) { - remove(StorageKeys.SsoEmail); - return; - } - - set(StorageKeys.SsoEmail, email); - }, [email, remove, set]); - - useEffect(() => { - if (domainFilteredConnectors.length === 0) { - remove(StorageKeys.SsoConnectors); - return; - } - - set(StorageKeys.SsoConnectors, domainFilteredConnectors); - }, [domainFilteredConnectors, remove, set]); - - const ssoConnectorsMap = useMemo( - () => new Map(ssoConnectors.map((connector) => [connector.id, connector])), - [ssoConnectors] - ); - - const singleSignOnContext = useMemo( - () => ({ - email, - setEmail, - availableSsoConnectorsMap: ssoConnectorsMap, - ssoConnectors: domainFilteredConnectors, - setSsoConnectors: setDomainFilteredConnectors, - }), - [domainFilteredConnectors, email, ssoConnectorsMap] - ); - - return ( - - {children} - - ); -}; - -export default SingleSignOnContextProvider; diff --git a/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx b/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx new file mode 100644 index 000000000..66ad65857 --- /dev/null +++ b/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx @@ -0,0 +1,29 @@ +import { type SsoConnectorMetadata } from '@logto/schemas'; +import { noop } from '@silverhand/essentials'; +import { createContext } from 'react'; + +import { type CurrentIdentifierSession } from '@/types/guard'; + +export type UserInteractionContextType = { + // All the enabled sso connectors + availableSsoConnectorsMap: Map; + ssoEmail?: string; + setSsoEmail: React.Dispatch>; + // The sso connectors that are enabled for the current domain + ssoConnectors: SsoConnectorMetadata[]; + setSsoConnectors: React.Dispatch>; + currentIdentifier?: CurrentIdentifierSession; + setCurrentIdentifier: React.Dispatch>; + clearUserInteractionSession: () => void; +}; + +export default createContext({ + ssoEmail: undefined, + availableSsoConnectorsMap: new Map(), + ssoConnectors: [], + setSsoEmail: noop, + setSsoConnectors: noop, + currentIdentifier: undefined, + setCurrentIdentifier: noop, + clearUserInteractionSession: noop, +}); diff --git a/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx b/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx new file mode 100644 index 000000000..60cb356c7 --- /dev/null +++ b/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx @@ -0,0 +1,90 @@ +import { type SsoConnectorMetadata } from '@logto/schemas'; +import { type ReactNode, useEffect, useMemo, useState, useCallback } from 'react'; + +import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages'; +import { useSieMethods } from '@/hooks/use-sie'; +import { type CurrentIdentifierSession } from '@/types/guard'; + +import UserInteractionContext, { type UserInteractionContextType } from './UserInteractionContext'; + +type Props = { + readonly children: ReactNode; +}; + +const UserInteractionContextProvider = ({ children }: Props) => { + const { ssoConnectors } = useSieMethods(); + const { get, set, remove } = useSessionStorage(); + const [ssoEmail, setSsoEmail] = useState(get(StorageKeys.SsoEmail)); + const [domainFilteredConnectors, setDomainFilteredConnectors] = useState( + get(StorageKeys.SsoConnectors) ?? [] + ); + const [currentIdentifier, setCurrentIdentifier] = useState( + get(StorageKeys.CurrentIdentifier) + ); + + useEffect(() => { + if (!ssoEmail) { + remove(StorageKeys.SsoEmail); + return; + } + + set(StorageKeys.SsoEmail, ssoEmail); + }, [ssoEmail, remove, set]); + + useEffect(() => { + if (domainFilteredConnectors.length === 0) { + remove(StorageKeys.SsoConnectors); + return; + } + + set(StorageKeys.SsoConnectors, domainFilteredConnectors); + }, [domainFilteredConnectors, remove, set]); + + useEffect(() => { + if (!currentIdentifier) { + remove(StorageKeys.CurrentIdentifier); + return; + } + + set(StorageKeys.CurrentIdentifier, currentIdentifier); + }, [currentIdentifier, remove, set]); + + const ssoConnectorsMap = useMemo( + () => new Map(ssoConnectors.map((connector) => [connector.id, connector])), + [ssoConnectors] + ); + + const clearUserInteractionSession = useCallback(() => { + setSsoEmail(undefined); + setDomainFilteredConnectors([]); + setCurrentIdentifier(undefined); + }, []); + + const singleSignOnContext = useMemo( + () => ({ + ssoEmail, + setSsoEmail, + availableSsoConnectorsMap: ssoConnectorsMap, + ssoConnectors: domainFilteredConnectors, + setSsoConnectors: setDomainFilteredConnectors, + currentIdentifier, + setCurrentIdentifier, + clearUserInteractionSession, + }), + [ + ssoEmail, + ssoConnectorsMap, + domainFilteredConnectors, + currentIdentifier, + clearUserInteractionSession, + ] + ); + + return ( + + {children} + + ); +}; + +export default UserInteractionContextProvider; diff --git a/packages/experience/src/components/HiddenIdentifierInput/index.tsx b/packages/experience/src/components/HiddenIdentifierInput/index.tsx new file mode 100644 index 000000000..02f01d93b --- /dev/null +++ b/packages/experience/src/components/HiddenIdentifierInput/index.tsx @@ -0,0 +1,23 @@ +import { useContext } from 'react'; + +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; + +/** + * This component renders a hidden input field that stores the user's identifier. + * Its primary purpose is to assist password managers in associating the correct + * identifier with the password being set or changed. + * + * By including this hidden field, we enable password managers to correctly save + * or update the user's credentials, enhancing the user experience and security. + */ +const HiddenIdentifierInput = () => { + const { currentIdentifier } = useContext(UserInteractionContext); + + if (!currentIdentifier) { + return null; + } + + return ; +}; + +export default HiddenIdentifierInput; diff --git a/packages/experience/src/containers/SetPassword/Lite.tsx b/packages/experience/src/containers/SetPassword/Lite.tsx index d13bc778c..b087371bf 100644 --- a/packages/experience/src/containers/SetPassword/Lite.tsx +++ b/packages/experience/src/containers/SetPassword/Lite.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import Button from '@/components/Button'; import ErrorMessage from '@/components/ErrorMessage'; +import HiddenIdentifierInput from '@/components/HiddenIdentifierInput'; import { PasswordInputField } from '@/components/InputFields'; import * as styles from './index.module.scss'; @@ -53,6 +54,7 @@ const Lite = ({ className, autoFocus, onSubmit, errorMessage, clearErrorMessage return (
+ + { const navigate = useNavigate(); const request = useApi(getSingleSignOnConnectors); const [errorMessage, setErrorMessage] = useState(); - const { setEmail, setSsoConnectors, availableSsoConnectorsMap } = useContext(SingleSignOnContext); + const { setSsoEmail, setSsoConnectors, availableSsoConnectorsMap } = + useContext(UserInteractionContext); const singleSignOn = useSingleSignOn(); const handleError = useErrorHandler(); @@ -26,9 +27,9 @@ const useCheckSingleSignOn = () => { // Should clear the context and storage if the user trying to resubmit the form const clearContext = useCallback(() => { - setEmail(undefined); + setSsoEmail(undefined); setSsoConnectors([]); - }, [setEmail, setSsoConnectors]); + }, [setSsoEmail, setSsoConnectors]); /** * Check if the email is registered with any SSO connectors @@ -66,7 +67,7 @@ const useCheckSingleSignOn = () => { } setSsoConnectors(connectors); - setEmail(email); + setSsoEmail(email); if (!continueSignIn) { return true; @@ -87,7 +88,7 @@ const useCheckSingleSignOn = () => { handleError, navigate, request, - setEmail, + setSsoEmail, setSsoConnectors, singleSignOn, t, diff --git a/packages/experience/src/hooks/use-global-redirect-to.ts b/packages/experience/src/hooks/use-global-redirect-to.ts index e9a0b41dd..10765d65d 100644 --- a/packages/experience/src/hooks/use-global-redirect-to.ts +++ b/packages/experience/src/hooks/use-global-redirect-to.ts @@ -1,23 +1,26 @@ import { useCallback, useContext } from 'react'; import PageContext from '@/Providers/PageContextProvider/PageContext'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; /** * This hook provides a function that process the app redirection after user successfully signs in. * Use window.location.replace to handle the redirection. * Set the global loading state to true before redirecting. + * Clear the user interaction session before redirecting. * This is to prevent the user from interacting with the app while the redirection is in progress. */ - function useGlobalRedirectTo() { const { setLoading } = useContext(PageContext); + const { clearUserInteractionSession } = useContext(UserInteractionContext); const redirectTo = useCallback( (url: string | URL) => { setLoading(true); + clearUserInteractionSession(); window.location.replace(url); }, - [setLoading] + [clearUserInteractionSession, setLoading] ); return redirectTo; diff --git a/packages/experience/src/hooks/use-session-storages.ts b/packages/experience/src/hooks/use-session-storages.ts index b492f16e6..d9cb13738 100644 --- a/packages/experience/src/hooks/use-session-storages.ts +++ b/packages/experience/src/hooks/use-session-storages.ts @@ -4,18 +4,20 @@ import { useCallback } from 'react'; import * as s from 'superstruct'; -import { ssoConnectorMetadataGuard } from '@/types/guard'; +import { currentIdentifierSessionGuard, ssoConnectorMetadataGuard } from '@/types/guard'; const logtoStorageKeyPrefix = `logto:${window.location.origin}`; export enum StorageKeys { SsoEmail = 'sso-email', SsoConnectors = 'sso-connectors', + CurrentIdentifier = 'current-identifier', } const valueGuard = Object.freeze({ [StorageKeys.SsoEmail]: s.string(), [StorageKeys.SsoConnectors]: s.array(ssoConnectorMetadataGuard), + [StorageKeys.CurrentIdentifier]: currentIdentifierSessionGuard, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- we don't care about the superstruct details } satisfies { [key in StorageKeys]: s.Struct }); diff --git a/packages/experience/src/hooks/use-single-sign-on-watch.ts b/packages/experience/src/hooks/use-single-sign-on-watch.ts index 767fbf4b1..e7794bc81 100644 --- a/packages/experience/src/hooks/use-single-sign-on-watch.ts +++ b/packages/experience/src/hooks/use-single-sign-on-watch.ts @@ -7,8 +7,8 @@ import { import { useEffect, useCallback, useContext } from 'react'; import { useNavigate } from 'react-router-dom'; -import SingleSignOnContext from '@/Providers/SingleSignOnContextProvider/SingleSignOnContext'; import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import { getSingleSignOnConnectors } from '@/apis/single-sign-on'; import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField'; import useApi from '@/hooks/use-api'; @@ -23,8 +23,8 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => { const { singleSignOnEnabled } = useSieMethods(); - const { setEmail, setSsoConnectors, ssoConnectors, availableSsoConnectorsMap } = - useContext(SingleSignOnContext); + const { setSsoEmail, setSsoConnectors, ssoConnectors, availableSsoConnectorsMap } = + useContext(UserInteractionContext); const { showSingleSignOnForm, setShowSingleSignOnForm } = useContext(SingleSignOnFormModeContext); @@ -53,10 +53,10 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => { } setSsoConnectors(connectors); - setEmail(email); + setSsoEmail(email); return true; }, - [availableSsoConnectorsMap, request, setEmail, setSsoConnectors] + [availableSsoConnectorsMap, request, setSsoEmail, setSsoConnectors] ); // Reset the ssoContext @@ -64,9 +64,9 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => { if (!showSingleSignOnForm) { setSsoConnectors([]); - setEmail(undefined); + setSsoEmail(undefined); } - }, [setEmail, setSsoConnectors, showSingleSignOnForm]); + }, [setSsoEmail, setSsoConnectors, showSingleSignOnForm]); const navigateToSingleSignOn = useCallback(async () => { if (!showSingleSignOnForm) { diff --git a/packages/experience/src/pages/ForgotPassword/ForgotPasswordForm/index.tsx b/packages/experience/src/pages/ForgotPassword/ForgotPasswordForm/index.tsx index 59fbf5c55..fa1c7b73a 100644 --- a/packages/experience/src/pages/ForgotPassword/ForgotPasswordForm/index.tsx +++ b/packages/experience/src/pages/ForgotPassword/ForgotPasswordForm/index.tsx @@ -1,8 +1,9 @@ import classNames from 'classnames'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useContext, useEffect } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import Button from '@/components/Button'; import ErrorMessage from '@/components/ErrorMessage'; import { SmartInputField } from '@/components/InputFields'; @@ -41,6 +42,8 @@ const ForgotPasswordForm = ({ UserFlow.ForgotPassword ); + const { setCurrentIdentifier } = useContext(UserInteractionContext); + const { handleSubmit, control, @@ -70,10 +73,13 @@ const ForgotPasswordForm = ({ return; } + // Update user interaction identifier session + setCurrentIdentifier({ type, value }); + await onSubmit({ identifier: type, value }); })(event); }, - [clearErrorMessage, handleSubmit, onSubmit] + [clearErrorMessage, handleSubmit, onSubmit, setCurrentIdentifier] ); return ( diff --git a/packages/experience/src/pages/ForgotPassword/index.tsx b/packages/experience/src/pages/ForgotPassword/index.tsx index 1bc8adebb..3a44c9108 100644 --- a/packages/experience/src/pages/ForgotPassword/index.tsx +++ b/packages/experience/src/pages/ForgotPassword/index.tsx @@ -1,12 +1,10 @@ import { SignInIdentifier } from '@logto/schemas'; -import { useCallback } from 'react'; +import { useCallback, useContext } from 'react'; import { useTranslation } from 'react-i18next'; -import { useLocation } from 'react-router-dom'; -import { validate } from 'superstruct'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import { useForgotPasswordSettings } from '@/hooks/use-sie'; -import { passwordIdentifierStateGuard } from '@/types/guard'; import { identifierInputDescriptionMap } from '@/utils/form'; import ErrorPage from '../ErrorPage'; @@ -15,9 +13,9 @@ import ForgotPasswordForm from './ForgotPasswordForm'; const ForgotPassword = () => { const { isForgotPasswordEnabled, enabledMethodSet } = useForgotPasswordSettings(); - const { state } = useLocation(); const { t } = useTranslation(); const enabledMethods = [...enabledMethodSet]; + const { currentIdentifier } = useContext(UserInteractionContext); const getDefaultIdentifierType = useCallback( (identifier?: SignInIdentifier) => { @@ -42,10 +40,8 @@ const ForgotPassword = () => { return ; } - const [_, identifierState] = validate(state, passwordIdentifierStateGuard); - - const defaultType = getDefaultIdentifierType(identifierState?.identifier); - const defaultValue = (identifierState?.identifier === defaultType && identifierState.value) || ''; + const defaultType = getDefaultIdentifierType(currentIdentifier?.type); + const defaultValue = (currentIdentifier?.type === defaultType && currentIdentifier.value) || ''; return ( - + - + ); diff --git a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx index 26ad7768b..e69e61da2 100644 --- a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx +++ b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx @@ -1,9 +1,10 @@ import { AgreeToTermsPolicy, type SignInIdentifier } from '@logto/schemas'; import classNames from 'classnames'; -import { useCallback, useEffect } from 'react'; +import { useCallback, useContext, useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import LockIcon from '@/assets/icons/lock.svg'; import Button from '@/components/Button'; import ErrorMessage from '@/components/ErrorMessage'; @@ -34,6 +35,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props) const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(); + const { currentIdentifier } = useContext(UserInteractionContext); + const { watch, handleSubmit, @@ -114,6 +117,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props) isDanger={!!errors.id || !!errorMessage} errorMessage={errors.id?.message} enabledTypes={signUpMethods} + defaultType={currentIdentifier?.type} + defaultValue={currentIdentifier?.value} /> )} /> diff --git a/packages/experience/src/pages/Register/IdentifierRegisterForm/use-on-submit.ts b/packages/experience/src/pages/Register/IdentifierRegisterForm/use-on-submit.ts index c1fda2787..fab839567 100644 --- a/packages/experience/src/pages/Register/IdentifierRegisterForm/use-on-submit.ts +++ b/packages/experience/src/pages/Register/IdentifierRegisterForm/use-on-submit.ts @@ -1,6 +1,7 @@ import { SignInIdentifier } from '@logto/schemas'; -import { useCallback } from 'react'; +import { useCallback, useContext } from 'react'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on'; import useSendVerificationCode from '@/hooks/use-send-verification-code'; import { useSieMethods } from '@/hooks/use-sie'; @@ -11,6 +12,7 @@ import useRegisterWithUsername from './use-register-with-username'; const useOnSubmit = () => { const { ssoConnectors } = useSieMethods(); const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn(); + const { setCurrentIdentifier } = useContext(UserInteractionContext); const { errorMessage: usernameRegisterErrorMessage, @@ -31,6 +33,8 @@ const useOnSubmit = () => { const onSubmit = useCallback( async (identifier: SignInIdentifier, value: string) => { + setCurrentIdentifier({ type: identifier, value }); + if (identifier === SignInIdentifier.Username) { await registerWithUsername(value); @@ -48,7 +52,13 @@ const useOnSubmit = () => { await sendVerificationCode({ identifier, value }); }, - [checkSingleSignOn, registerWithUsername, sendVerificationCode, ssoConnectors.length] + [ + checkSingleSignOn, + registerWithUsername, + sendVerificationCode, + setCurrentIdentifier, + ssoConnectors.length, + ] ); return { diff --git a/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx b/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx index bfde9a783..e836bcef3 100644 --- a/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx +++ b/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.test.tsx @@ -3,8 +3,8 @@ import { SignInIdentifier, experience } from '@logto/schemas'; import { assert } from '@silverhand/essentials'; import { fireEvent, act, waitFor } from '@testing-library/react'; -import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider'; import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider'; +import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import { @@ -52,11 +52,11 @@ const renderForm = (signInMethods: SignIn['methods'], ssoConnectors: SsoConnecto ssoConnectors, }} > - + - + ); diff --git a/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.tsx b/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.tsx index af0869471..660d91343 100644 --- a/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.tsx +++ b/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.tsx @@ -1,9 +1,10 @@ import { AgreeToTermsPolicy, type SignIn } from '@logto/schemas'; import classNames from 'classnames'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useContext, useEffect, useMemo } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import LockIcon from '@/assets/icons/lock.svg'; import Button from '@/components/Button'; import ErrorMessage from '@/components/ErrorMessage'; @@ -32,6 +33,7 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) => const { t } = useTranslation(); const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(signInMethods); const { termsValidation, agreeToTermsPolicy } = useTerms(); + const { currentIdentifier } = useContext(UserInteractionContext); const enabledSignInMethods = useMemo( () => signInMethods.map(({ identifier }) => identifier), @@ -117,6 +119,8 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) => isDanger={!!errors.identifier || !!errorMessage} errorMessage={errors.identifier?.message} enabledTypes={enabledSignInMethods} + defaultType={currentIdentifier?.type} + defaultValue={currentIdentifier?.value} /> )} /> diff --git a/packages/experience/src/pages/SignIn/IdentifierSignInForm/use-on-submit.ts b/packages/experience/src/pages/SignIn/IdentifierSignInForm/use-on-submit.ts index 5b1af59b4..92487137b 100644 --- a/packages/experience/src/pages/SignIn/IdentifierSignInForm/use-on-submit.ts +++ b/packages/experience/src/pages/SignIn/IdentifierSignInForm/use-on-submit.ts @@ -1,8 +1,9 @@ import type { SignIn } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas'; -import { useCallback } from 'react'; +import { useCallback, useContext } from 'react'; import { useNavigate } from 'react-router-dom'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on'; import useSendVerificationCode from '@/hooks/use-send-verification-code'; import { useSieMethods } from '@/hooks/use-sie'; @@ -12,6 +13,7 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => { const navigate = useNavigate(); const { ssoConnectors } = useSieMethods(); const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn(); + const { setCurrentIdentifier } = useContext(UserInteractionContext); const signInWithPassword = useCallback( (identifier: SignInIdentifier, value: string) => { @@ -39,6 +41,8 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => { throw new Error(`Cannot find method with identifier type ${identifier}`); } + setCurrentIdentifier({ type: identifier, value }); + const { password, isPasswordPrimary, verificationCode } = method; if (identifier === SignInIdentifier.Username) { @@ -69,6 +73,7 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => { [ checkSingleSignOn, sendVerificationCode, + setCurrentIdentifier, signInMethods, signInWithPassword, ssoConnectors.length, diff --git a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.test.tsx b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.test.tsx index 98a842071..5d55680c7 100644 --- a/packages/experience/src/pages/SignIn/PasswordSignInForm/index.test.tsx +++ b/packages/experience/src/pages/SignIn/PasswordSignInForm/index.test.tsx @@ -3,8 +3,8 @@ import { assert } from '@silverhand/essentials'; import { fireEvent, waitFor } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; -import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider'; import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider'; +import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; @@ -50,11 +50,11 @@ describe('UsernamePasswordSignInForm', () => { ) => renderWithPageContext( - + - + ); diff --git a/packages/experience/src/pages/SingleSignOnConnectors/index.tsx b/packages/experience/src/pages/SingleSignOnConnectors/index.tsx index 284e24bdf..c1b0904a6 100644 --- a/packages/experience/src/pages/SingleSignOnConnectors/index.tsx +++ b/packages/experience/src/pages/SingleSignOnConnectors/index.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import PageContext from '@/Providers/PageContextProvider/PageContext'; -import SingleSignOnContext from '@/Providers/SingleSignOnContextProvider/SingleSignOnContext'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import SocialLinkButton from '@/components/Button/SocialLinkButton'; import useNativeMessageListener from '@/hooks/use-native-message-listener'; import useSingleSignOn from '@/hooks/use-single-sign-on'; @@ -13,7 +13,7 @@ import * as styles from './index.module.scss'; const SingleSignOnConnectors = () => { const { theme } = useContext(PageContext); - const { email, ssoConnectors } = useContext(SingleSignOnContext); + const { ssoEmail, ssoConnectors } = useContext(UserInteractionContext); const navigate = useNavigate(); const onSubmit = useSingleSignOn(); @@ -22,18 +22,18 @@ const SingleSignOnConnectors = () => { useEffect(() => { // Return to the previous page if no email and no connectors are available in the context - if (!email || ssoConnectors.length === 0) { + if (!ssoEmail || ssoConnectors.length === 0) { navigate('../email', { replace: true, }); } - }, [email, navigate, ssoConnectors.length]); + }, [ssoEmail, navigate, ssoConnectors.length]); return (
{ssoConnectors.map((connector) => { diff --git a/packages/experience/src/pages/SingleSignOnEmail/index.tsx b/packages/experience/src/pages/SingleSignOnEmail/index.tsx index 786d1a2ca..86437fe4d 100644 --- a/packages/experience/src/pages/SingleSignOnEmail/index.tsx +++ b/packages/experience/src/pages/SingleSignOnEmail/index.tsx @@ -3,7 +3,7 @@ import { useCallback, useContext, useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; -import SingleSignOnContext from '@/Providers/SingleSignOnContextProvider/SingleSignOnContext'; +import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext'; import LockIcon from '@/assets/icons/lock.svg'; import Button from '@/components/Button'; import ErrorMessage from '@/components/ErrorMessage'; @@ -21,7 +21,7 @@ type FormState = { const SingleSignOnEmail = () => { const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(); - const { email } = useContext(SingleSignOnContext); + const { ssoEmail } = useContext(UserInteractionContext); const { handleSubmit, @@ -73,7 +73,7 @@ const SingleSignOnEmail = () => { className={styles.inputField} {...field} isDanger={!!errors.identifier} - defaultValue={email} + defaultValue={ssoEmail} errorMessage={errors.identifier?.message} enabledTypes={[SignInIdentifier.Email]} /> diff --git a/packages/experience/src/types/guard.ts b/packages/experience/src/types/guard.ts index d52ab1723..5d40ea3dd 100644 --- a/packages/experience/src/types/guard.ts +++ b/packages/experience/src/types/guard.ts @@ -119,3 +119,20 @@ export const ssoConnectorMetadataGuard: s.Describe = s.obj darkLogo: s.optional(s.string()), connectorName: s.string(), }); + +/** + * Defines the structure for caching the current user's identifier. + * + * Purpose: + * - Used in conjunction with the HiddenIdentifierInput component to assist + * password managers in associating the correct identifier with passwords. + * + * - Cache the identifier so that when the user returns from the verification + * page or the password page, the identifier they entered will not be cleared. + */ +export const currentIdentifierSessionGuard = s.object({ + type: s.enums([SignInIdentifier.Email, SignInIdentifier.Phone, SignInIdentifier.Username]), + value: s.string(), +}); + +export type CurrentIdentifierSession = s.Infer;