0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

refactor(experience): cache user input identifier value

This commit is contained in:
Xiao Yijun 2024-07-01 15:52:52 +08:00
parent 978817ec0c
commit 46fb70c4bf
No known key found for this signature in database
GPG key ID: 6F648FC1262DB420
24 changed files with 247 additions and 135 deletions

View file

@ -6,7 +6,7 @@ import AppBoundary from './Providers/AppBoundary';
import LoadingLayerProvider from './Providers/LoadingLayerProvider'; import LoadingLayerProvider from './Providers/LoadingLayerProvider';
import PageContextProvider from './Providers/PageContextProvider'; import PageContextProvider from './Providers/PageContextProvider';
import SettingsProvider from './Providers/SettingsProvider'; import SettingsProvider from './Providers/SettingsProvider';
import SingleSignOnContextProvider from './Providers/SingleSignOnContextProvider'; import UserInteractionContextProvider from './Providers/UserInteractionContextProvider';
import Callback from './pages/Callback'; import Callback from './pages/Callback';
import Consent from './pages/Consent'; import Consent from './pages/Consent';
import Continue from './pages/Continue'; import Continue from './pages/Continue';
@ -45,7 +45,7 @@ const App = () => {
<BrowserRouter> <BrowserRouter>
<PageContextProvider> <PageContextProvider>
<SettingsProvider> <SettingsProvider>
<SingleSignOnContextProvider> <UserInteractionContextProvider>
<AppBoundary> <AppBoundary>
<Routes> <Routes>
<Route element={<LoadingLayerProvider />}> <Route element={<LoadingLayerProvider />}>
@ -125,7 +125,7 @@ const App = () => {
</Route> </Route>
</Routes> </Routes>
</AppBoundary> </AppBoundary>
</SingleSignOnContextProvider> </UserInteractionContextProvider>
</SettingsProvider> </SettingsProvider>
</PageContextProvider> </PageContextProvider>
</BrowserRouter> </BrowserRouter>

View file

@ -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<string, SsoConnectorMetadata>;
email?: string;
setEmail: React.Dispatch<React.SetStateAction<string | undefined>>;
// The sso connectors that are enabled for the current domain
ssoConnectors: SsoConnectorMetadata[];
setSsoConnectors: React.Dispatch<React.SetStateAction<SsoConnectorMetadata[]>>;
};
export default createContext<SingleSignOnContextType>({
email: undefined,
availableSsoConnectorsMap: new Map(),
ssoConnectors: [],
setEmail: noop,
setSsoConnectors: noop,
});

View file

@ -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<string | undefined>(get(StorageKeys.SsoEmail));
const [domainFilteredConnectors, setDomainFilteredConnectors] = useState<SsoConnectorMetadata[]>(
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<SingleSignOnContextType>(
() => ({
email,
setEmail,
availableSsoConnectorsMap: ssoConnectorsMap,
ssoConnectors: domainFilteredConnectors,
setSsoConnectors: setDomainFilteredConnectors,
}),
[domainFilteredConnectors, email, ssoConnectorsMap]
);
return (
<SingleSignOnContext.Provider value={singleSignOnContext}>
{children}
</SingleSignOnContext.Provider>
);
};
export default SingleSignOnContextProvider;

View file

@ -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<string, SsoConnectorMetadata>;
ssoEmail?: string;
setSsoEmail: React.Dispatch<React.SetStateAction<string | undefined>>;
// The sso connectors that are enabled for the current domain
ssoConnectors: SsoConnectorMetadata[];
setSsoConnectors: React.Dispatch<React.SetStateAction<SsoConnectorMetadata[]>>;
currentIdentifier?: CurrentIdentifierSession;
setCurrentIdentifier: React.Dispatch<React.SetStateAction<CurrentIdentifierSession | undefined>>;
clearUserInteractionSession: () => void;
};
export default createContext<UserInteractionContextType>({
ssoEmail: undefined,
availableSsoConnectorsMap: new Map(),
ssoConnectors: [],
setSsoEmail: noop,
setSsoConnectors: noop,
currentIdentifier: undefined,
setCurrentIdentifier: noop,
clearUserInteractionSession: noop,
});

View file

@ -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<string | undefined>(get(StorageKeys.SsoEmail));
const [domainFilteredConnectors, setDomainFilteredConnectors] = useState<SsoConnectorMetadata[]>(
get(StorageKeys.SsoConnectors) ?? []
);
const [currentIdentifier, setCurrentIdentifier] = useState<CurrentIdentifierSession | undefined>(
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<UserInteractionContextType>(
() => ({
ssoEmail,
setSsoEmail,
availableSsoConnectorsMap: ssoConnectorsMap,
ssoConnectors: domainFilteredConnectors,
setSsoConnectors: setDomainFilteredConnectors,
currentIdentifier,
setCurrentIdentifier,
clearUserInteractionSession,
}),
[
ssoEmail,
ssoConnectorsMap,
domainFilteredConnectors,
currentIdentifier,
clearUserInteractionSession,
]
);
return (
<UserInteractionContext.Provider value={singleSignOnContext}>
{children}
</UserInteractionContext.Provider>
);
};
export default UserInteractionContextProvider;

View file

@ -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 <input readOnly hidden type={currentIdentifier.type} value={currentIdentifier.value} />;
};
export default HiddenIdentifierInput;

View file

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import Button from '@/components/Button'; import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage'; import ErrorMessage from '@/components/ErrorMessage';
import HiddenIdentifierInput from '@/components/HiddenIdentifierInput';
import { PasswordInputField } from '@/components/InputFields'; import { PasswordInputField } from '@/components/InputFields';
import * as styles from './index.module.scss'; import * as styles from './index.module.scss';
@ -53,6 +54,7 @@ const Lite = ({ className, autoFocus, onSubmit, errorMessage, clearErrorMessage
return ( return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}> <form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<HiddenIdentifierInput />
<PasswordInputField <PasswordInputField
className={styles.inputField} className={styles.inputField}
autoComplete="new-password" autoComplete="new-password"

View file

@ -7,6 +7,7 @@ import ClearIcon from '@/assets/icons/clear-icon.svg';
import Button from '@/components/Button'; import Button from '@/components/Button';
import IconButton from '@/components/Button/IconButton'; import IconButton from '@/components/Button/IconButton';
import ErrorMessage from '@/components/ErrorMessage'; import ErrorMessage from '@/components/ErrorMessage';
import HiddenIdentifierInput from '@/components/HiddenIdentifierInput';
import { InputField } from '@/components/InputFields'; import { InputField } from '@/components/InputFields';
import TogglePassword from './TogglePassword'; import TogglePassword from './TogglePassword';
@ -67,6 +68,7 @@ const SetPassword = ({
return ( return (
<form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}> <form className={classNames(styles.form, className)} onSubmit={onSubmitHandler}>
<HiddenIdentifierInput />
<InputField <InputField
className={styles.inputField} className={styles.inputField}
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}

View file

@ -3,7 +3,7 @@ import { useCallback, useState, useContext } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import SingleSignOnContext from '@/Providers/SingleSignOnContextProvider/SingleSignOnContext'; import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { getSingleSignOnConnectors } from '@/apis/single-sign-on'; import { getSingleSignOnConnectors } from '@/apis/single-sign-on';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler'; import useErrorHandler from '@/hooks/use-error-handler';
@ -15,7 +15,8 @@ const useCheckSingleSignOn = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const request = useApi(getSingleSignOnConnectors); const request = useApi(getSingleSignOnConnectors);
const [errorMessage, setErrorMessage] = useState<string | undefined>(); const [errorMessage, setErrorMessage] = useState<string | undefined>();
const { setEmail, setSsoConnectors, availableSsoConnectorsMap } = useContext(SingleSignOnContext); const { setSsoEmail, setSsoConnectors, availableSsoConnectorsMap } =
useContext(UserInteractionContext);
const singleSignOn = useSingleSignOn(); const singleSignOn = useSingleSignOn();
const handleError = useErrorHandler(); const handleError = useErrorHandler();
@ -26,9 +27,9 @@ const useCheckSingleSignOn = () => {
// Should clear the context and storage if the user trying to resubmit the form // Should clear the context and storage if the user trying to resubmit the form
const clearContext = useCallback(() => { const clearContext = useCallback(() => {
setEmail(undefined); setSsoEmail(undefined);
setSsoConnectors([]); setSsoConnectors([]);
}, [setEmail, setSsoConnectors]); }, [setSsoEmail, setSsoConnectors]);
/** /**
* Check if the email is registered with any SSO connectors * Check if the email is registered with any SSO connectors
@ -66,7 +67,7 @@ const useCheckSingleSignOn = () => {
} }
setSsoConnectors(connectors); setSsoConnectors(connectors);
setEmail(email); setSsoEmail(email);
if (!continueSignIn) { if (!continueSignIn) {
return true; return true;
@ -87,7 +88,7 @@ const useCheckSingleSignOn = () => {
handleError, handleError,
navigate, navigate,
request, request,
setEmail, setSsoEmail,
setSsoConnectors, setSsoConnectors,
singleSignOn, singleSignOn,
t, t,

View file

@ -1,23 +1,26 @@
import { useCallback, useContext } from 'react'; import { useCallback, useContext } from 'react';
import PageContext from '@/Providers/PageContextProvider/PageContext'; 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. * This hook provides a function that process the app redirection after user successfully signs in.
* Use window.location.replace to handle the redirection. * Use window.location.replace to handle the redirection.
* Set the global loading state to true before redirecting. * 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. * This is to prevent the user from interacting with the app while the redirection is in progress.
*/ */
function useGlobalRedirectTo() { function useGlobalRedirectTo() {
const { setLoading } = useContext(PageContext); const { setLoading } = useContext(PageContext);
const { clearUserInteractionSession } = useContext(UserInteractionContext);
const redirectTo = useCallback( const redirectTo = useCallback(
(url: string | URL) => { (url: string | URL) => {
setLoading(true); setLoading(true);
clearUserInteractionSession();
window.location.replace(url); window.location.replace(url);
}, },
[setLoading] [clearUserInteractionSession, setLoading]
); );
return redirectTo; return redirectTo;

View file

@ -4,18 +4,20 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import * as s from 'superstruct'; import * as s from 'superstruct';
import { ssoConnectorMetadataGuard } from '@/types/guard'; import { currentIdentifierSessionGuard, ssoConnectorMetadataGuard } from '@/types/guard';
const logtoStorageKeyPrefix = `logto:${window.location.origin}`; const logtoStorageKeyPrefix = `logto:${window.location.origin}`;
export enum StorageKeys { export enum StorageKeys {
SsoEmail = 'sso-email', SsoEmail = 'sso-email',
SsoConnectors = 'sso-connectors', SsoConnectors = 'sso-connectors',
CurrentIdentifier = 'current-identifier',
} }
const valueGuard = Object.freeze({ const valueGuard = Object.freeze({
[StorageKeys.SsoEmail]: s.string(), [StorageKeys.SsoEmail]: s.string(),
[StorageKeys.SsoConnectors]: s.array(ssoConnectorMetadataGuard), [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 // eslint-disable-next-line @typescript-eslint/no-explicit-any -- we don't care about the superstruct details
} satisfies { [key in StorageKeys]: s.Struct<any> }); } satisfies { [key in StorageKeys]: s.Struct<any> });

View file

@ -7,8 +7,8 @@ import {
import { useEffect, useCallback, useContext } from 'react'; import { useEffect, useCallback, useContext } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import SingleSignOnContext from '@/Providers/SingleSignOnContextProvider/SingleSignOnContext';
import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext'; import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { getSingleSignOnConnectors } from '@/apis/single-sign-on'; import { getSingleSignOnConnectors } from '@/apis/single-sign-on';
import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField'; import type { IdentifierInputValue } from '@/components/InputFields/SmartInputField';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
@ -23,8 +23,8 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => {
const { singleSignOnEnabled } = useSieMethods(); const { singleSignOnEnabled } = useSieMethods();
const { setEmail, setSsoConnectors, ssoConnectors, availableSsoConnectorsMap } = const { setSsoEmail, setSsoConnectors, ssoConnectors, availableSsoConnectorsMap } =
useContext(SingleSignOnContext); useContext(UserInteractionContext);
const { showSingleSignOnForm, setShowSingleSignOnForm } = useContext(SingleSignOnFormModeContext); const { showSingleSignOnForm, setShowSingleSignOnForm } = useContext(SingleSignOnFormModeContext);
@ -53,10 +53,10 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => {
} }
setSsoConnectors(connectors); setSsoConnectors(connectors);
setEmail(email); setSsoEmail(email);
return true; return true;
}, },
[availableSsoConnectorsMap, request, setEmail, setSsoConnectors] [availableSsoConnectorsMap, request, setSsoEmail, setSsoConnectors]
); );
// Reset the ssoContext // Reset the ssoContext
@ -64,9 +64,9 @@ const useSingleSignOnWatch = (identifierInput?: IdentifierInputValue) => {
if (!showSingleSignOnForm) { if (!showSingleSignOnForm) {
setSsoConnectors([]); setSsoConnectors([]);
setEmail(undefined); setSsoEmail(undefined);
} }
}, [setEmail, setSsoConnectors, showSingleSignOnForm]); }, [setSsoEmail, setSsoConnectors, showSingleSignOnForm]);
const navigateToSingleSignOn = useCallback(async () => { const navigateToSingleSignOn = useCallback(async () => {
if (!showSingleSignOnForm) { if (!showSingleSignOnForm) {

View file

@ -1,8 +1,9 @@
import classNames from 'classnames'; import classNames from 'classnames';
import { useCallback, useEffect } from 'react'; import { useCallback, useContext, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import Button from '@/components/Button'; import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage'; import ErrorMessage from '@/components/ErrorMessage';
import { SmartInputField } from '@/components/InputFields'; import { SmartInputField } from '@/components/InputFields';
@ -41,6 +42,8 @@ const ForgotPasswordForm = ({
UserFlow.ForgotPassword UserFlow.ForgotPassword
); );
const { setCurrentIdentifier } = useContext(UserInteractionContext);
const { const {
handleSubmit, handleSubmit,
control, control,
@ -70,10 +73,13 @@ const ForgotPasswordForm = ({
return; return;
} }
// Update user interaction identifier session
setCurrentIdentifier({ type, value });
await onSubmit({ identifier: type, value }); await onSubmit({ identifier: type, value });
})(event); })(event);
}, },
[clearErrorMessage, handleSubmit, onSubmit] [clearErrorMessage, handleSubmit, onSubmit, setCurrentIdentifier]
); );
return ( return (

View file

@ -1,12 +1,10 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import { useCallback } from 'react'; import { useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { validate } from 'superstruct';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { useForgotPasswordSettings } from '@/hooks/use-sie'; import { useForgotPasswordSettings } from '@/hooks/use-sie';
import { passwordIdentifierStateGuard } from '@/types/guard';
import { identifierInputDescriptionMap } from '@/utils/form'; import { identifierInputDescriptionMap } from '@/utils/form';
import ErrorPage from '../ErrorPage'; import ErrorPage from '../ErrorPage';
@ -15,9 +13,9 @@ import ForgotPasswordForm from './ForgotPasswordForm';
const ForgotPassword = () => { const ForgotPassword = () => {
const { isForgotPasswordEnabled, enabledMethodSet } = useForgotPasswordSettings(); const { isForgotPasswordEnabled, enabledMethodSet } = useForgotPasswordSettings();
const { state } = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const enabledMethods = [...enabledMethodSet]; const enabledMethods = [...enabledMethodSet];
const { currentIdentifier } = useContext(UserInteractionContext);
const getDefaultIdentifierType = useCallback( const getDefaultIdentifierType = useCallback(
(identifier?: SignInIdentifier) => { (identifier?: SignInIdentifier) => {
@ -42,10 +40,8 @@ const ForgotPassword = () => {
return <ErrorPage />; return <ErrorPage />;
} }
const [_, identifierState] = validate(state, passwordIdentifierStateGuard); const defaultType = getDefaultIdentifierType(currentIdentifier?.type);
const defaultValue = (currentIdentifier?.type === defaultType && currentIdentifier.value) || '';
const defaultType = getDefaultIdentifierType(identifierState?.identifier);
const defaultValue = (identifierState?.identifier === defaultType && identifierState.value) || '';
return ( return (
<SecondaryPageLayout <SecondaryPageLayout

View file

@ -3,8 +3,8 @@ import { assert } from '@silverhand/essentials';
import { fireEvent, act, waitFor } from '@testing-library/react'; import { fireEvent, act, waitFor } from '@testing-library/react';
import ConfirmModalProvider from '@/Providers/ConfirmModalProvider'; import ConfirmModalProvider from '@/Providers/ConfirmModalProvider';
import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider';
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider'; import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto';
@ -53,11 +53,11 @@ const renderForm = (
}} }}
> >
<ConfirmModalProvider> <ConfirmModalProvider>
<SingleSignOnContextProvider> <UserInteractionContextProvider>
<SingleSignOnFormModeContextProvider> <SingleSignOnFormModeContextProvider>
<IdentifierRegisterForm signUpMethods={signUpMethods} /> <IdentifierRegisterForm signUpMethods={signUpMethods} />
</SingleSignOnFormModeContextProvider> </SingleSignOnFormModeContextProvider>
</SingleSignOnContextProvider> </UserInteractionContextProvider>
</ConfirmModalProvider> </ConfirmModalProvider>
</SettingsProvider> </SettingsProvider>
); );

View file

@ -1,9 +1,10 @@
import { AgreeToTermsPolicy, type SignInIdentifier } from '@logto/schemas'; import { AgreeToTermsPolicy, type SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames'; import classNames from 'classnames';
import { useCallback, useEffect } from 'react'; import { useCallback, useContext, useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import LockIcon from '@/assets/icons/lock.svg'; import LockIcon from '@/assets/icons/lock.svg';
import Button from '@/components/Button'; import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage'; import ErrorMessage from '@/components/ErrorMessage';
@ -34,6 +35,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(); const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit();
const { currentIdentifier } = useContext(UserInteractionContext);
const { const {
watch, watch,
handleSubmit, handleSubmit,
@ -114,6 +117,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
isDanger={!!errors.id || !!errorMessage} isDanger={!!errors.id || !!errorMessage}
errorMessage={errors.id?.message} errorMessage={errors.id?.message}
enabledTypes={signUpMethods} enabledTypes={signUpMethods}
defaultType={currentIdentifier?.type}
defaultValue={currentIdentifier?.value}
/> />
)} )}
/> />

View file

@ -1,6 +1,7 @@
import { SignInIdentifier } from '@logto/schemas'; 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 useCheckSingleSignOn from '@/hooks/use-check-single-sign-on';
import useSendVerificationCode from '@/hooks/use-send-verification-code'; import useSendVerificationCode from '@/hooks/use-send-verification-code';
import { useSieMethods } from '@/hooks/use-sie'; import { useSieMethods } from '@/hooks/use-sie';
@ -11,6 +12,7 @@ import useRegisterWithUsername from './use-register-with-username';
const useOnSubmit = () => { const useOnSubmit = () => {
const { ssoConnectors } = useSieMethods(); const { ssoConnectors } = useSieMethods();
const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn(); const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn();
const { setCurrentIdentifier } = useContext(UserInteractionContext);
const { const {
errorMessage: usernameRegisterErrorMessage, errorMessage: usernameRegisterErrorMessage,
@ -31,6 +33,8 @@ const useOnSubmit = () => {
const onSubmit = useCallback( const onSubmit = useCallback(
async (identifier: SignInIdentifier, value: string) => { async (identifier: SignInIdentifier, value: string) => {
setCurrentIdentifier({ type: identifier, value });
if (identifier === SignInIdentifier.Username) { if (identifier === SignInIdentifier.Username) {
await registerWithUsername(value); await registerWithUsername(value);
@ -48,7 +52,13 @@ const useOnSubmit = () => {
await sendVerificationCode({ identifier, value }); await sendVerificationCode({ identifier, value });
}, },
[checkSingleSignOn, registerWithUsername, sendVerificationCode, ssoConnectors.length] [
checkSingleSignOn,
registerWithUsername,
sendVerificationCode,
setCurrentIdentifier,
ssoConnectors.length,
]
); );
return { return {

View file

@ -3,8 +3,8 @@ import { SignInIdentifier, experience } from '@logto/schemas';
import { assert } from '@silverhand/essentials'; import { assert } from '@silverhand/essentials';
import { fireEvent, act, waitFor } from '@testing-library/react'; import { fireEvent, act, waitFor } from '@testing-library/react';
import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider';
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider'; import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { import {
@ -52,11 +52,11 @@ const renderForm = (signInMethods: SignIn['methods'], ssoConnectors: SsoConnecto
ssoConnectors, ssoConnectors,
}} }}
> >
<SingleSignOnContextProvider> <UserInteractionContextProvider>
<SingleSignOnFormModeContextProvider> <SingleSignOnFormModeContextProvider>
<IdentifierSignInForm signInMethods={signInMethods} /> <IdentifierSignInForm signInMethods={signInMethods} />
</SingleSignOnFormModeContextProvider> </SingleSignOnFormModeContextProvider>
</SingleSignOnContextProvider> </UserInteractionContextProvider>
</SettingsProvider> </SettingsProvider>
); );

View file

@ -1,9 +1,10 @@
import { AgreeToTermsPolicy, type SignIn } from '@logto/schemas'; import { AgreeToTermsPolicy, type SignIn } from '@logto/schemas';
import classNames from 'classnames'; 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 { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import LockIcon from '@/assets/icons/lock.svg'; import LockIcon from '@/assets/icons/lock.svg';
import Button from '@/components/Button'; import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage'; import ErrorMessage from '@/components/ErrorMessage';
@ -32,6 +33,7 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
const { t } = useTranslation(); const { t } = useTranslation();
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(signInMethods); const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(signInMethods);
const { termsValidation, agreeToTermsPolicy } = useTerms(); const { termsValidation, agreeToTermsPolicy } = useTerms();
const { currentIdentifier } = useContext(UserInteractionContext);
const enabledSignInMethods = useMemo( const enabledSignInMethods = useMemo(
() => signInMethods.map(({ identifier }) => identifier), () => signInMethods.map(({ identifier }) => identifier),
@ -117,6 +119,8 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
isDanger={!!errors.identifier || !!errorMessage} isDanger={!!errors.identifier || !!errorMessage}
errorMessage={errors.identifier?.message} errorMessage={errors.identifier?.message}
enabledTypes={enabledSignInMethods} enabledTypes={enabledSignInMethods}
defaultType={currentIdentifier?.type}
defaultValue={currentIdentifier?.value}
/> />
)} )}
/> />

View file

@ -1,8 +1,9 @@
import type { SignIn } from '@logto/schemas'; import type { SignIn } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import { useCallback } from 'react'; import { useCallback, useContext } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on'; import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on';
import useSendVerificationCode from '@/hooks/use-send-verification-code'; import useSendVerificationCode from '@/hooks/use-send-verification-code';
import { useSieMethods } from '@/hooks/use-sie'; import { useSieMethods } from '@/hooks/use-sie';
@ -12,6 +13,7 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
const navigate = useNavigate(); const navigate = useNavigate();
const { ssoConnectors } = useSieMethods(); const { ssoConnectors } = useSieMethods();
const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn(); const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn();
const { setCurrentIdentifier } = useContext(UserInteractionContext);
const signInWithPassword = useCallback( const signInWithPassword = useCallback(
(identifier: SignInIdentifier, value: string) => { (identifier: SignInIdentifier, value: string) => {
@ -39,6 +41,8 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
throw new Error(`Cannot find method with identifier type ${identifier}`); throw new Error(`Cannot find method with identifier type ${identifier}`);
} }
setCurrentIdentifier({ type: identifier, value });
const { password, isPasswordPrimary, verificationCode } = method; const { password, isPasswordPrimary, verificationCode } = method;
if (identifier === SignInIdentifier.Username) { if (identifier === SignInIdentifier.Username) {
@ -69,6 +73,7 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
[ [
checkSingleSignOn, checkSingleSignOn,
sendVerificationCode, sendVerificationCode,
setCurrentIdentifier,
signInMethods, signInMethods,
signInWithPassword, signInWithPassword,
ssoConnectors.length, ssoConnectors.length,

View file

@ -3,8 +3,8 @@ import { assert } from '@silverhand/essentials';
import { fireEvent, waitFor } from '@testing-library/react'; import { fireEvent, waitFor } from '@testing-library/react';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider';
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider'; import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider'; import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto';
@ -50,11 +50,11 @@ describe('UsernamePasswordSignInForm', () => {
) => ) =>
renderWithPageContext( renderWithPageContext(
<SettingsProvider settings={{ ...mockSignInExperienceSettings, ...settings }}> <SettingsProvider settings={{ ...mockSignInExperienceSettings, ...settings }}>
<SingleSignOnContextProvider> <UserInteractionContextProvider>
<SingleSignOnFormModeContextProvider> <SingleSignOnFormModeContextProvider>
<PasswordSignInForm signInMethods={signInMethods} /> <PasswordSignInForm signInMethods={signInMethods} />
</SingleSignOnFormModeContextProvider> </SingleSignOnFormModeContextProvider>
</SingleSignOnContextProvider> </UserInteractionContextProvider>
</SettingsProvider> </SettingsProvider>
); );

View file

@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import PageContext from '@/Providers/PageContextProvider/PageContext'; 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 SocialLinkButton from '@/components/Button/SocialLinkButton';
import useNativeMessageListener from '@/hooks/use-native-message-listener'; import useNativeMessageListener from '@/hooks/use-native-message-listener';
import useSingleSignOn from '@/hooks/use-single-sign-on'; import useSingleSignOn from '@/hooks/use-single-sign-on';
@ -13,7 +13,7 @@ import * as styles from './index.module.scss';
const SingleSignOnConnectors = () => { const SingleSignOnConnectors = () => {
const { theme } = useContext(PageContext); const { theme } = useContext(PageContext);
const { email, ssoConnectors } = useContext(SingleSignOnContext); const { ssoEmail, ssoConnectors } = useContext(UserInteractionContext);
const navigate = useNavigate(); const navigate = useNavigate();
const onSubmit = useSingleSignOn(); const onSubmit = useSingleSignOn();
@ -22,18 +22,18 @@ const SingleSignOnConnectors = () => {
useEffect(() => { useEffect(() => {
// Return to the previous page if no email and no connectors are available in the context // 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', { navigate('../email', {
replace: true, replace: true,
}); });
} }
}, [email, navigate, ssoConnectors.length]); }, [ssoEmail, navigate, ssoConnectors.length]);
return ( return (
<SecondaryPageLayout <SecondaryPageLayout
title="action.single_sign_on" title="action.single_sign_on"
description="description.single_sign_on_connectors_list" description="description.single_sign_on_connectors_list"
descriptionProps={{ email }} descriptionProps={{ email: ssoEmail }}
> >
<div className={styles.ssoLinkList}> <div className={styles.ssoLinkList}>
{ssoConnectors.map((connector) => { {ssoConnectors.map((connector) => {

View file

@ -3,7 +3,7 @@ import { useCallback, useContext, useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form'; import { Controller, useForm } from 'react-hook-form';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; 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 LockIcon from '@/assets/icons/lock.svg';
import Button from '@/components/Button'; import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage'; import ErrorMessage from '@/components/ErrorMessage';
@ -21,7 +21,7 @@ type FormState = {
const SingleSignOnEmail = () => { const SingleSignOnEmail = () => {
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(); const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit();
const { email } = useContext(SingleSignOnContext); const { ssoEmail } = useContext(UserInteractionContext);
const { const {
handleSubmit, handleSubmit,
@ -73,7 +73,7 @@ const SingleSignOnEmail = () => {
className={styles.inputField} className={styles.inputField}
{...field} {...field}
isDanger={!!errors.identifier} isDanger={!!errors.identifier}
defaultValue={email} defaultValue={ssoEmail}
errorMessage={errors.identifier?.message} errorMessage={errors.identifier?.message}
enabledTypes={[SignInIdentifier.Email]} enabledTypes={[SignInIdentifier.Email]}
/> />

View file

@ -119,3 +119,20 @@ export const ssoConnectorMetadataGuard: s.Describe<SsoConnectorMetadata> = s.obj
darkLogo: s.optional(s.string()), darkLogo: s.optional(s.string()),
connectorName: 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<typeof currentIdentifierSessionGuard>;