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

refactor(experience): cache user input identifier for a better sign-in experience (#6164)

This commit is contained in:
Xiao Yijun 2024-07-08 08:57:20 +08:00 committed by GitHub
parent 4f53e41f0a
commit 787183a6ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 376 additions and 211 deletions

View file

@ -2,6 +2,8 @@ import { type SsoConnectorMetadata } from '@logto/schemas';
import { noop } from '@silverhand/essentials'; import { noop } from '@silverhand/essentials';
import { createContext } from 'react'; import { createContext } from 'react';
import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField';
export type UserInteractionContextType = { export type UserInteractionContextType = {
// All the enabled sso connectors // All the enabled sso connectors
availableSsoConnectorsMap: Map<string, SsoConnectorMetadata>; availableSsoConnectorsMap: Map<string, SsoConnectorMetadata>;
@ -10,6 +12,37 @@ export type UserInteractionContextType = {
// The sso connectors that are enabled for the current domain // The sso connectors that are enabled for the current domain
ssoConnectors: SsoConnectorMetadata[]; ssoConnectors: SsoConnectorMetadata[];
setSsoConnectors: React.Dispatch<React.SetStateAction<SsoConnectorMetadata[]>>; setSsoConnectors: React.Dispatch<React.SetStateAction<SsoConnectorMetadata[]>>;
/**
* The cached identifier input value that the user has inputted when signing in.
* The value will be used to pre-fill the identifier input field in sign-in pages.
*/
identifierInputValue?: IdentifierInputValue;
/**
* This method is used to cache the identifier input value when signing in.
*/
setIdentifierInputValue: React.Dispatch<React.SetStateAction<IdentifierInputValue | undefined>>;
/**
* The cached identifier input value that used in the 'ForgotPassword' flow.
* The value will be used to pre-fill the identifier input field in the `ForgotPassword` page.
*/
forgotPasswordIdentifierInputValue?: IdentifierInputValue;
/**
* This method is used to cache the identifier input values for the 'ForgotPassword' flow.
*/
setForgotPasswordIdentifierInputValue: React.Dispatch<
React.SetStateAction<IdentifierInputValue | undefined>
>;
/**
* This method only clear the identifier input values from the session storage.
*
* The state of the identifier input values in the `UserInteractionContext` will
* not be updated.
*
* Call this method after the user successfully signs in and before redirecting to
* the application page to avoid triggering any side effects that depends on the
* identifier input values.
*/
clearInteractionContextSessionStorage: () => void;
}; };
export default createContext<UserInteractionContextType>({ export default createContext<UserInteractionContextType>({
@ -18,4 +51,9 @@ export default createContext<UserInteractionContextType>({
ssoConnectors: [], ssoConnectors: [],
setSsoEmail: noop, setSsoEmail: noop,
setSsoConnectors: noop, setSsoConnectors: noop,
identifierInputValue: undefined,
setIdentifierInputValue: noop,
forgotPasswordIdentifierInputValue: undefined,
setForgotPasswordIdentifierInputValue: noop,
clearInteractionContextSessionStorage: noop,
}); });

View file

@ -1,6 +1,7 @@
import { type SsoConnectorMetadata } from '@logto/schemas'; import { type SsoConnectorMetadata } from '@logto/schemas';
import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { type ReactNode, useEffect, useMemo, useState, useCallback } from 'react';
import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField';
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages'; import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
import { useSieMethods } from '@/hooks/use-sie'; import { useSieMethods } from '@/hooks/use-sie';
@ -26,6 +27,13 @@ const UserInteractionContextProvider = ({ children }: Props) => {
const [domainFilteredConnectors, setDomainFilteredConnectors] = useState<SsoConnectorMetadata[]>( const [domainFilteredConnectors, setDomainFilteredConnectors] = useState<SsoConnectorMetadata[]>(
get(StorageKeys.SsoConnectors) ?? [] get(StorageKeys.SsoConnectors) ?? []
); );
const [identifierInputValue, setIdentifierInputValue] = useState<
IdentifierInputValue | undefined
>(get(StorageKeys.IdentifierInputValue));
const [forgotPasswordIdentifierInputValue, setForgotPasswordIdentifierInputValue] = useState<
IdentifierInputValue | undefined
>(get(StorageKeys.ForgotPasswordIdentifierInputValue));
useEffect(() => { useEffect(() => {
if (!ssoEmail) { if (!ssoEmail) {
@ -45,11 +53,34 @@ const UserInteractionContextProvider = ({ children }: Props) => {
set(StorageKeys.SsoConnectors, domainFilteredConnectors); set(StorageKeys.SsoConnectors, domainFilteredConnectors);
}, [domainFilteredConnectors, remove, set]); }, [domainFilteredConnectors, remove, set]);
useEffect(() => {
if (!identifierInputValue) {
remove(StorageKeys.IdentifierInputValue);
return;
}
set(StorageKeys.IdentifierInputValue, identifierInputValue);
}, [identifierInputValue, remove, set]);
useEffect(() => {
if (!forgotPasswordIdentifierInputValue) {
remove(StorageKeys.ForgotPasswordIdentifierInputValue);
return;
}
set(StorageKeys.ForgotPasswordIdentifierInputValue, forgotPasswordIdentifierInputValue);
}, [forgotPasswordIdentifierInputValue, remove, set]);
const ssoConnectorsMap = useMemo( const ssoConnectorsMap = useMemo(
() => new Map(ssoConnectors.map((connector) => [connector.id, connector])), () => new Map(ssoConnectors.map((connector) => [connector.id, connector])),
[ssoConnectors] [ssoConnectors]
); );
const clearInteractionContextSessionStorage = useCallback(() => {
remove(StorageKeys.IdentifierInputValue);
remove(StorageKeys.ForgotPasswordIdentifierInputValue);
}, [remove]);
const userInteractionContext = useMemo<UserInteractionContextType>( const userInteractionContext = useMemo<UserInteractionContextType>(
() => ({ () => ({
ssoEmail, ssoEmail,
@ -57,8 +88,20 @@ const UserInteractionContextProvider = ({ children }: Props) => {
availableSsoConnectorsMap: ssoConnectorsMap, availableSsoConnectorsMap: ssoConnectorsMap,
ssoConnectors: domainFilteredConnectors, ssoConnectors: domainFilteredConnectors,
setSsoConnectors: setDomainFilteredConnectors, setSsoConnectors: setDomainFilteredConnectors,
identifierInputValue,
setIdentifierInputValue,
forgotPasswordIdentifierInputValue,
setForgotPasswordIdentifierInputValue,
clearInteractionContextSessionStorage,
}), }),
[ssoEmail, ssoConnectorsMap, domainFilteredConnectors] [
ssoEmail,
ssoConnectorsMap,
domainFilteredConnectors,
identifierInputValue,
forgotPasswordIdentifierInputValue,
clearInteractionContextSessionStorage,
]
); );
return ( return (

View file

@ -13,7 +13,14 @@ export type IdentifierInputType =
| SignInIdentifier.Username; | SignInIdentifier.Username;
export type IdentifierInputValue = { export type IdentifierInputValue = {
type: IdentifierInputType | undefined; /**
* The type of the identifier input.
* `undefined` value is for the case when the user has inputted an identifier but the type is not yet determined in the `SmartInputField`.
*/
type?: IdentifierInputType;
/**
* The value of the identifier input.
*/
value: string; value: string;
}; };

View file

@ -1,5 +1,8 @@
import type { SignInIdentifier } from '@logto/schemas'; import type { SignInIdentifier } from '@logto/schemas';
import { useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import TextLink from '@/components/TextLink'; import TextLink from '@/components/TextLink';
import { UserFlow } from '@/types'; import { UserFlow } from '@/types';
@ -9,15 +12,24 @@ type Props = {
readonly className?: string; readonly className?: string;
}; };
const ForgotPasswordLink = ({ className, ...identifierData }: Props) => ( const ForgotPasswordLink = ({ className, ...identifierData }: Props) => {
<TextLink const navigate = useNavigate();
className={className} const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
to={{
pathname: `/${UserFlow.ForgotPassword}`, return (
}} <TextLink
state={identifierData} className={className}
text="action.forgot_password" text="action.forgot_password"
/> onClick={() => {
); setForgotPasswordIdentifierInputValue({
type: identifierData.identifier,
value: identifierData.value ?? '',
});
navigate(`/${UserFlow.ForgotPassword}`);
}}
/>
);
};
export default ForgotPasswordLink; export default ForgotPasswordLink;

View file

@ -1,16 +1,12 @@
import type { SignInIdentifier } from '@logto/schemas';
import SwitchIcon from '@/assets/icons/switch-icon.svg'; import SwitchIcon from '@/assets/icons/switch-icon.svg';
import TextLink from '@/components/TextLink'; import TextLink from '@/components/TextLink';
import { UserFlow } from '@/types'; import { UserFlow } from '@/types';
type Props = { type Props = {
readonly className?: string; readonly className?: string;
readonly method: SignInIdentifier.Email | SignInIdentifier.Phone;
readonly target: string;
}; };
const PasswordSignInLink = ({ className, method, target }: Props) => { const PasswordSignInLink = ({ className }: Props) => {
return ( return (
<TextLink <TextLink
replace replace
@ -18,7 +14,6 @@ const PasswordSignInLink = ({ className, method, target }: Props) => {
icon={<SwitchIcon />} icon={<SwitchIcon />}
text="action.sign_in_via_password" text="action.sign_in_via_password"
to={`/${UserFlow.SignIn}/password`} to={`/${UserFlow.SignIn}/password`}
state={{ identifier: method, value: target }}
/> />
); );
}; };

View file

@ -86,7 +86,7 @@ const VerificationCode = ({ flow, identifier, className, hasPasswordButton, targ
)} )}
</div> </div>
{flow === UserFlow.SignIn && hasPasswordButton && ( {flow === UserFlow.SignIn && hasPasswordButton && (
<PasswordSignInLink method={identifier} target={target} className={styles.switch} /> <PasswordSignInLink className={styles.switch} />
)} )}
</form> </form>
); );

View file

@ -83,15 +83,15 @@ const useCheckSingleSignOn = () => {
return true; return true;
}, },
[ [
availableSsoConnectorsMap,
clearContext, clearContext,
handleError,
navigate,
request, request,
setSsoEmail,
setSsoConnectors, setSsoConnectors,
singleSignOn, setSsoEmail,
navigate,
handleError,
t, t,
availableSsoConnectorsMap,
singleSignOn,
] ]
); );

View file

@ -1,6 +1,7 @@
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.
@ -8,16 +9,21 @@ import PageContext from '@/Providers/PageContextProvider/PageContext';
* Set the global loading state to true before redirecting. * Set the global loading state to true 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 { clearInteractionContextSessionStorage } = useContext(UserInteractionContext);
const redirectTo = useCallback( const redirectTo = useCallback(
(url: string | URL) => { (url: string | URL) => {
setLoading(true); setLoading(true);
/**
* Clear all identifier input values from the storage once the interaction is submitted.
* The Identifier cache should be session-isolated, so it should be cleared after the interaction is completed.
*/
clearInteractionContextSessionStorage();
window.location.replace(url); window.location.replace(url);
}, },
[setLoading] [clearInteractionContextSessionStorage, setLoading]
); );
return redirectTo; return redirectTo;

View file

@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom';
import { sendVerificationCodeApi } from '@/apis/utils'; import { sendVerificationCodeApi } from '@/apis/utils';
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';
import type { VerificationCodeIdentifier, UserFlow } from '@/types'; import { type VerificationCodeIdentifier, type UserFlow } from '@/types';
const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) => { const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) => {
const [errorMessage, setErrorMessage] = useState<string>(); const [errorMessage, setErrorMessage] = useState<string>();
@ -50,7 +50,6 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
search: location.search, search: location.search,
}, },
{ {
state: { identifier, value },
replace: replaceCurrentPage, replace: replaceCurrentPage,
} }
); );

View file

@ -4,18 +4,22 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import * as s from 'superstruct'; import * as s from 'superstruct';
import { ssoConnectorMetadataGuard } from '@/types/guard'; import { identifierInputValueGuard, 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',
IdentifierInputValue = 'identifier-input-value',
ForgotPasswordIdentifierInputValue = 'forgot-password-identifier-input-value',
} }
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.IdentifierInputValue]: identifierInputValueGuard,
[StorageKeys.ForgotPasswordIdentifierInputValue]: identifierInputValueGuard,
// 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

@ -1,8 +1,10 @@
import type { MissingProfile } from '@logto/schemas'; import type { MissingProfile } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import type { TFuncKey } from 'i18next'; import type { TFuncKey } from 'i18next';
import { useContext } from 'react';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import useSendVerificationCode from '@/hooks/use-send-verification-code'; import useSendVerificationCode from '@/hooks/use-send-verification-code';
import type { VerificationCodeIdentifier } from '@/types'; import type { VerificationCodeIdentifier } from '@/types';
import { UserFlow } from '@/types'; import { UserFlow } from '@/types';
@ -59,6 +61,7 @@ const formSettings: Record<
const SetEmailOrPhone = ({ missingProfile, notification }: Props) => { const SetEmailOrPhone = ({ missingProfile, notification }: Props) => {
const { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode(UserFlow.Continue); const { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode(UserFlow.Continue);
const { setIdentifierInputValue } = useContext(UserInteractionContext);
const handleSubmit = async (identifier: SignInIdentifier, value: string) => { const handleSubmit = async (identifier: SignInIdentifier, value: string) => {
// Only handles email and phone // Only handles email and phone
@ -66,6 +69,8 @@ const SetEmailOrPhone = ({ missingProfile, notification }: Props) => {
return; return;
} }
setIdentifierInputValue({ type: identifier, value });
return onSubmit({ identifier, value }); return onSubmit({ identifier, value });
}; };

View file

@ -1,7 +1,9 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import type { TFuncKey } from 'i18next'; import type { TFuncKey } from 'i18next';
import { useContext } from 'react';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import IdentifierProfileForm from '../IdentifierProfileForm'; import IdentifierProfileForm from '../IdentifierProfileForm';
@ -14,11 +16,15 @@ type Props = {
const SetUsername = (props: Props) => { const SetUsername = (props: Props) => {
const { onSubmit, errorMessage, clearErrorMessage } = useSetUsername(); const { onSubmit, errorMessage, clearErrorMessage } = useSetUsername();
const { setIdentifierInputValue } = useContext(UserInteractionContext);
const handleSubmit = async (identifier: SignInIdentifier, value: string) => { const handleSubmit = async (identifier: SignInIdentifier, value: string) => {
if (identifier !== SignInIdentifier.Username) { if (identifier !== SignInIdentifier.Username) {
return; return;
} }
setIdentifierInputValue({ type: identifier, value });
return onSubmit(value); return onSubmit(value);
}; };

View file

@ -2,6 +2,7 @@ import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
import { assert } from '@silverhand/essentials'; import { assert } from '@silverhand/essentials';
import { act, fireEvent, waitFor } from '@testing-library/react'; import { act, fireEvent, waitFor } from '@testing-library/react';
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext'; import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { putInteraction, sendVerificationCode } from '@/apis/interaction'; import { putInteraction, sendVerificationCode } from '@/apis/interaction';
import { UserFlow, type VerificationCodeIdentifier } from '@/types'; import { UserFlow, type VerificationCodeIdentifier } from '@/types';
@ -32,11 +33,13 @@ describe('ForgotPasswordForm', () => {
const renderForm = (defaultType: VerificationCodeIdentifier, defaultValue?: string) => const renderForm = (defaultType: VerificationCodeIdentifier, defaultValue?: string) =>
renderWithPageContext( renderWithPageContext(
<ForgotPasswordForm <UserInteractionContextProvider>
enabledTypes={[SignInIdentifier.Email, SignInIdentifier.Phone]} <ForgotPasswordForm
defaultType={defaultType} enabledTypes={[SignInIdentifier.Email, SignInIdentifier.Phone]}
defaultValue={defaultValue} defaultType={defaultType}
/> defaultValue={defaultValue}
/>
</UserInteractionContextProvider>
); );
describe.each([ describe.each([
@ -81,7 +84,7 @@ describe('ForgotPasswordForm', () => {
pathname: `/${UserFlow.ForgotPassword}/verification-code`, pathname: `/${UserFlow.ForgotPassword}/verification-code`,
search: '', search: '',
}, },
{ state: { identifier, value }, replace: undefined } { replace: undefined }
); );
}); });
}); });

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 { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
const { const {
handleSubmit, handleSubmit,
control, control,
@ -70,10 +73,13 @@ const ForgotPasswordForm = ({
return; return;
} }
// Cache or update the forgot password identifier input value
setForgotPasswordIdentifierInputValue({ type, value });
await onSubmit({ identifier: type, value }); await onSubmit({ identifier: type, value });
})(event); })(event);
}, },
[clearErrorMessage, handleSubmit, onSubmit] [clearErrorMessage, handleSubmit, onSubmit, setForgotPasswordIdentifierInputValue]
); );
return ( return (

View file

@ -1,11 +1,13 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import { Globals } from '@react-spring/web'; import { Globals } from '@react-spring/web';
import { assert } from '@silverhand/essentials'; import { assert } from '@silverhand/essentials';
import { useLocation } from 'react-router-dom'; import { renderHook } from '@testing-library/react';
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, getBoundingClientRectMock } from '@/__mocks__/logto'; import { mockSignInExperienceSettings, getBoundingClientRectMock } from '@/__mocks__/logto';
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
import type { SignInExperienceResponse } from '@/types'; import type { SignInExperienceResponse } from '@/types';
import ForgotPassword from '.'; import ForgotPassword from '.';
@ -33,7 +35,9 @@ describe('ForgotPassword', () => {
}, },
}} }}
> >
<ForgotPassword /> <UserInteractionContextProvider>
<ForgotPassword />
</UserInteractionContextProvider>
</SettingsProvider> </SettingsProvider>
); );
@ -67,56 +71,63 @@ describe('ForgotPassword', () => {
const countryCode = '86'; const countryCode = '86';
const phone = '13911111111'; const phone = '13911111111';
const mockUseLocation = useLocation as jest.Mock; const identifierCases = [
{ type: SignInIdentifier.Username, value: '' },
const stateCases = [ { type: SignInIdentifier.Email, value: email },
{}, { type: SignInIdentifier.Phone, value: `${countryCode}${phone}` },
{ identifier: SignInIdentifier.Username, value: '' },
{ identifier: SignInIdentifier.Email, value: email },
{ identifier: SignInIdentifier.Phone, value: `${countryCode}${phone}` },
]; ];
test.each(stateCases)('render the forgot password page with state %o', async (state) => { test.each(identifierCases)(
mockUseLocation.mockImplementation(() => ({ state })); 'render the forgot password page with identifier session %o',
async (identifier) => {
const { result } = renderHook(() => useSessionStorage());
const { set, remove } = result.current;
set(StorageKeys.ForgotPasswordIdentifierInputValue, {
type: identifier.type,
value: identifier.value,
});
const { queryByText, container, queryByTestId } = renderPage(settings); const { queryByText, container, queryByTestId } = renderPage(settings);
const inputField = container.querySelector('input[name="identifier"]'); const inputField = container.querySelector('input[name="identifier"]');
const countryCodeSelectorPrefix = queryByTestId('prefix'); const countryCodeSelectorPrefix = queryByTestId('prefix');
assert(inputField, new Error('input field not found')); assert(inputField, new Error('input field not found'));
expect(queryByText('description.reset_password')).not.toBeNull(); expect(queryByText('description.reset_password')).not.toBeNull();
expect(queryByText('description.reset_password_description')).not.toBeNull(); expect(queryByText('description.reset_password_description')).not.toBeNull();
expect(queryByText('action.switch_to')).toBeNull(); expect(queryByText('action.switch_to')).toBeNull();
if (state.identifier === SignInIdentifier.Phone && settings.phone) { if (identifier.type === SignInIdentifier.Phone && settings.phone) {
expect(inputField.getAttribute('value')).toBe(phone); expect(inputField.getAttribute('value')).toBe(phone);
expect(countryCodeSelectorPrefix?.style.width).toBe('100px'); expect(countryCodeSelectorPrefix?.style.width).toBe('100px');
expect(queryByText(`+${countryCode}`)).not.toBeNull(); expect(queryByText(`+${countryCode}`)).not.toBeNull();
} else if (state.identifier === SignInIdentifier.Phone) { } else if (identifier.type === SignInIdentifier.Phone) {
// Phone Number not enabled // Phone Number not enabled
expect(inputField.getAttribute('value')).toBe(''); expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelectorPrefix?.style.width).toBe('0px'); expect(countryCodeSelectorPrefix?.style.width).toBe('0px');
}
if (identifier.type === SignInIdentifier.Email && settings.email) {
expect(inputField.getAttribute('value')).toBe(email);
expect(countryCodeSelectorPrefix?.style.width).toBe('0px');
} else if (identifier.type === SignInIdentifier.Email) {
// Only PhoneNumber is enabled
expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelectorPrefix?.style.width).toBe('100px');
}
if (identifier.type === SignInIdentifier.Username && settings.email) {
expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelectorPrefix?.style.width).toBe('0px');
} else if (identifier.type === SignInIdentifier.Username) {
// Only PhoneNumber is enabled
expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelectorPrefix?.style.width).toBe('100px');
}
remove(StorageKeys.ForgotPasswordIdentifierInputValue);
} }
);
if (state.identifier === SignInIdentifier.Email && settings.email) {
expect(inputField.getAttribute('value')).toBe(email);
expect(countryCodeSelectorPrefix?.style.width).toBe('0px');
} else if (state.identifier === SignInIdentifier.Email) {
// Only PhoneNumber is enabled
expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelectorPrefix?.style.width).toBe('100px');
}
if (state.identifier === SignInIdentifier.Username && settings.email) {
expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelectorPrefix?.style.width).toBe('0px');
} else if (state.identifier === SignInIdentifier.Username) {
// Only PhoneNumber is enabled
expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelectorPrefix?.style.width).toBe('100px');
}
});
}); });
}); });

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 { forgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
const getDefaultIdentifierType = useCallback( const getDefaultIdentifierType = useCallback(
(identifier?: SignInIdentifier) => { (identifier?: SignInIdentifier) => {
@ -42,10 +40,11 @@ const ForgotPassword = () => {
return <ErrorPage />; return <ErrorPage />;
} }
const [_, identifierState] = validate(state, passwordIdentifierStateGuard); const defaultType = getDefaultIdentifierType(forgotPasswordIdentifierInputValue?.type);
const defaultValue =
const defaultType = getDefaultIdentifierType(identifierState?.identifier); (forgotPasswordIdentifierInputValue?.type === defaultType &&
const defaultValue = (identifierState?.identifier === defaultType && identifierState.value) || ''; forgotPasswordIdentifierInputValue.value) ||
'';
return ( return (
<SecondaryPageLayout <SecondaryPageLayout

View file

@ -1,6 +1,6 @@
import { SignInIdentifier, experience, type SsoConnectorMetadata } from '@logto/schemas'; import { SignInIdentifier, experience, type SsoConnectorMetadata } 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, renderHook } from '@testing-library/react';
import ConfirmModalProvider from '@/Providers/ConfirmModalProvider'; import ConfirmModalProvider from '@/Providers/ConfirmModalProvider';
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider'; import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
@ -10,6 +10,7 @@ import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider
import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto'; import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto';
import { registerWithUsernamePassword } from '@/apis/interaction'; import { registerWithUsernamePassword } from '@/apis/interaction';
import { sendVerificationCodeApi } from '@/apis/utils'; import { sendVerificationCodeApi } from '@/apis/utils';
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
import { UserFlow } from '@/types'; import { UserFlow } from '@/types';
import { getDefaultCountryCallingCode } from '@/utils/country-code'; import { getDefaultCountryCallingCode } from '@/utils/country-code';
@ -65,6 +66,14 @@ const renderForm = (
describe('<IdentifierRegisterForm />', () => { describe('<IdentifierRegisterForm />', () => {
afterEach(() => { afterEach(() => {
/**
* Clear the session storage for each test to avoid test pollution
* since the registration follow will store the current identifier
*/
const { result } = renderHook(() => useSessionStorage());
const { remove } = result.current;
remove(StorageKeys.IdentifierInputValue);
jest.clearAllMocks(); jest.clearAllMocks();
}); });
@ -306,6 +315,14 @@ describe('<IdentifierRegisterForm />', () => {
describe('single sign on register form', () => { describe('single sign on register form', () => {
const email = 'foo@email.com'; const email = 'foo@email.com';
const { result } = renderHook(() => useSessionStorage());
const { remove } = result.current;
afterEach(() => {
remove(StorageKeys.IdentifierInputValue);
});
it('should not call check single sign-on connector when no single sign-on connector is enabled', async () => { it('should not call check single sign-on connector when no single sign-on connector is enabled', async () => {
const { getByText, container, queryByText } = renderForm([SignInIdentifier.Email]); const { getByText, container, queryByText } = renderForm([SignInIdentifier.Email]);
const submitButton = getByText('action.create_account'); const submitButton = getByText('action.create_account');

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 { identifierInputValue, setIdentifierInputValue } = useContext(UserInteractionContext);
const { const {
watch, watch,
handleSubmit, handleSubmit,
@ -61,6 +64,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
return; return;
} }
setIdentifierInputValue({ type, value });
if (showSingleSignOnForm) { if (showSingleSignOnForm) {
await navigateToSingleSignOn(); await navigateToSingleSignOn();
return; return;
@ -78,6 +83,7 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
handleSubmit, handleSubmit,
navigateToSingleSignOn, navigateToSingleSignOn,
onSubmit, onSubmit,
setIdentifierInputValue,
showSingleSignOnForm, showSingleSignOnForm,
termsValidation, termsValidation,
] ]
@ -111,6 +117,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
autoFocus={autoFocus} autoFocus={autoFocus}
className={styles.inputField} className={styles.inputField}
{...field} {...field}
defaultValue={identifierInputValue?.value}
defaultType={identifierInputValue?.type}
isDanger={!!errors.id || !!errorMessage} isDanger={!!errors.id || !!errorMessage}
errorMessage={errors.id?.message} errorMessage={errors.id?.message}
enabledTypes={signUpMethods} enabledTypes={signUpMethods}

View file

@ -1,8 +1,9 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useContext, useMemo, useState } 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 SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { setUserPassword } from '@/apis/interaction'; import { setUserPassword } from '@/apis/interaction';
import SetPassword from '@/containers/SetPassword'; import SetPassword from '@/containers/SetPassword';
import { useConfirmModal } from '@/hooks/use-confirm-modal'; import { useConfirmModal } from '@/hooks/use-confirm-modal';
@ -20,6 +21,7 @@ const ResetPassword = () => {
const { setToast } = useToast(); const { setToast } = useToast();
const navigate = useNavigate(); const navigate = useNavigate();
const { show } = useConfirmModal(); const { show } = useConfirmModal();
const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
const errorHandlers: ErrorHandlers = useMemo( const errorHandlers: ErrorHandlers = useMemo(
() => ({ () => ({
'session.verification_session_not_found': async (error) => { 'session.verification_session_not_found': async (error) => {
@ -35,11 +37,14 @@ const ResetPassword = () => {
const successHandler: SuccessHandler<typeof setUserPassword> = useCallback( const successHandler: SuccessHandler<typeof setUserPassword> = useCallback(
(result) => { (result) => {
if (result) { if (result) {
// Clear the forgot password identifier input value
setForgotPasswordIdentifierInputValue(undefined);
setToast(t('description.password_changed')); setToast(t('description.password_changed'));
navigate('/sign-in', { replace: true }); navigate('/sign-in', { replace: true });
} }
}, },
[navigate, setToast, t] [navigate, setForgotPasswordIdentifierInputValue, setToast, t]
); );
const [action] = usePasswordAction({ const [action] = usePasswordAction({
@ -48,6 +53,7 @@ const ResetPassword = () => {
errorHandlers, errorHandlers,
successHandler, successHandler,
}); });
const { const {
policy: { policy: {
length: { min, max }, length: { min, max },

View file

@ -66,7 +66,7 @@ describe('IdentifierSignInForm', () => {
}); });
test('should show required error message when input is empty', async () => { test('should show required error message when input is empty', async () => {
const { getByText, container } = renderForm(mockSignInMethodSettingsTestCases[0]!); const { getByText } = renderForm(mockSignInMethodSettingsTestCases[0]!);
const submitButton = getByText('action.sign_in'); const submitButton = getByText('action.sign_in');
act(() => { act(() => {
@ -128,10 +128,7 @@ describe('IdentifierSignInForm', () => {
if (identifier === SignInIdentifier.Username) { if (identifier === SignInIdentifier.Username) {
await waitFor(() => { await waitFor(() => {
expect(sendVerificationCodeApi).not.toBeCalled(); expect(sendVerificationCodeApi).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith( expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
{ pathname: '/sign-in/password' },
{ state: { identifier: SignInIdentifier.Username, value } }
);
}); });
return; return;
@ -146,18 +143,7 @@ describe('IdentifierSignInForm', () => {
if (password && (isPasswordPrimary || !verificationCode)) { if (password && (isPasswordPrimary || !verificationCode)) {
await waitFor(() => { await waitFor(() => {
expect(sendVerificationCodeApi).not.toBeCalled(); expect(sendVerificationCodeApi).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith( expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
{ pathname: '/sign-in/password' },
{
state: {
identifier,
value:
identifier === SignInIdentifier.Phone
? `${getDefaultCountryCallingCode()}${value}`
: value,
},
}
);
}); });
return; return;
@ -203,10 +189,7 @@ describe('IdentifierSignInForm', () => {
await waitFor(() => { await waitFor(() => {
expect(getSingleSignOnConnectorsMock).not.toBeCalled(); expect(getSingleSignOnConnectorsMock).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith( expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
{ pathname: '/sign-in/password' },
{ state: { identifier: SignInIdentifier.Username, value: username } }
);
}); });
}); });
@ -233,10 +216,7 @@ describe('IdentifierSignInForm', () => {
await waitFor(() => { await waitFor(() => {
expect(getSingleSignOnConnectorsMock).not.toBeCalled(); expect(getSingleSignOnConnectorsMock).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith( expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
{ pathname: '/sign-in/password' },
{ state: { identifier: SignInIdentifier.Email, value: email } }
);
}); });
}); });
@ -270,10 +250,7 @@ describe('IdentifierSignInForm', () => {
}); });
await waitFor(() => { await waitFor(() => {
expect(mockedNavigate).toBeCalledWith( expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
{ pathname: '/sign-in/password' },
{ state: { identifier: SignInIdentifier.Email, value: email } }
);
}); });
}); });

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 { identifierInputValue, setIdentifierInputValue } = useContext(UserInteractionContext);
const enabledSignInMethods = useMemo( const enabledSignInMethods = useMemo(
() => signInMethods.map(({ identifier }) => identifier), () => signInMethods.map(({ identifier }) => identifier),
@ -67,6 +69,8 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
return; return;
} }
setIdentifierInputValue({ type, value });
if (showSingleSignOnForm) { if (showSingleSignOnForm) {
await navigateToSingleSignOn(); await navigateToSingleSignOn();
return; return;
@ -86,6 +90,7 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
handleSubmit, handleSubmit,
navigateToSingleSignOn, navigateToSingleSignOn,
onSubmit, onSubmit,
setIdentifierInputValue,
showSingleSignOnForm, showSingleSignOnForm,
termsValidation, termsValidation,
] ]
@ -117,6 +122,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={identifierInputValue?.type}
defaultValue={identifierInputValue?.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,18 +13,13 @@ 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 { setIdentifierInputValue } = useContext(UserInteractionContext);
const signInWithPassword = useCallback( const navigateToPasswordPage = useCallback(() => {
(identifier: SignInIdentifier, value: string) => { navigate({
navigate( pathname: `/${UserFlow.SignIn}/password`,
{ });
pathname: `/${UserFlow.SignIn}/password`, }, [navigate]);
},
{ state: { identifier, value } }
);
},
[navigate]
);
const { const {
errorMessage, errorMessage,
@ -39,10 +35,12 @@ 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}`);
} }
setIdentifierInputValue({ type: identifier, value });
const { password, isPasswordPrimary, verificationCode } = method; const { password, isPasswordPrimary, verificationCode } = method;
if (identifier === SignInIdentifier.Username) { if (identifier === SignInIdentifier.Username) {
signInWithPassword(identifier, value); navigateToPasswordPage();
return; return;
} }
@ -57,7 +55,7 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
} }
if (password && (isPasswordPrimary || !verificationCode)) { if (password && (isPasswordPrimary || !verificationCode)) {
signInWithPassword(identifier, value); navigateToPasswordPage();
return; return;
} }
@ -67,11 +65,12 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
} }
}, },
[ [
signInMethods,
setIdentifierInputValue,
ssoConnectors.length,
navigateToPasswordPage,
checkSingleSignOn, checkSingleSignOn,
sendVerificationCode, sendVerificationCode,
signInMethods,
signInWithPassword,
ssoConnectors.length,
] ]
); );

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 { 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';
@ -37,6 +38,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn(); const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
const { isForgotPasswordEnabled } = useForgotPasswordSettings(); const { isForgotPasswordEnabled } = useForgotPasswordSettings();
const { termsValidation, agreeToTermsPolicy } = useTerms(); const { termsValidation, agreeToTermsPolicy } = useTerms();
const { setIdentifierInputValue } = useContext(UserInteractionContext);
const { const {
watch, watch,
@ -65,6 +67,8 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
return; return;
} }
setIdentifierInputValue({ type, value });
if (showSingleSignOnForm) { if (showSingleSignOnForm) {
await navigateToSingleSignOn(); await navigateToSingleSignOn();
return; return;
@ -87,6 +91,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
handleSubmit, handleSubmit,
navigateToSingleSignOn, navigateToSingleSignOn,
onSubmit, onSubmit,
setIdentifierInputValue,
showSingleSignOnForm, showSingleSignOnForm,
termsValidation, termsValidation,
] ]

View file

@ -94,7 +94,6 @@ describe('PasswordSignInForm', () => {
search: '', search: '',
}, },
{ {
state: { identifier, value },
replace: true, replace: true,
} }
); );

View file

@ -1,9 +1,10 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
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 { PasswordInputField } from '@/components/InputFields'; import { PasswordInputField } from '@/components/InputFields';
@ -39,6 +40,7 @@ const PasswordForm = ({
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn(); const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
const { setIdentifierInputValue } = useContext(UserInteractionContext);
const { isForgotPasswordEnabled } = useForgotPasswordSettings(); const { isForgotPasswordEnabled } = useForgotPasswordSettings();
const { const {
@ -72,13 +74,15 @@ const PasswordForm = ({
return; return;
} }
setIdentifierInputValue({ type, value });
await onSubmit({ await onSubmit({
[type]: value, [type]: value,
password, password,
}); });
})(event); })(event);
}, },
[clearErrorMessage, handleSubmit, onSubmit] [clearErrorMessage, handleSubmit, onSubmit, setIdentifierInputValue]
); );
return ( return (

View file

@ -1,18 +1,18 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import { renderHook } from '@testing-library/react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
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 } from '@/__mocks__/logto'; import { mockSignInExperienceSettings } from '@/__mocks__/logto';
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
import SignInPassword from '.'; import SignInPassword from '.';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: jest.fn(() => ({})),
}));
describe('SignInPassword', () => { describe('SignInPassword', () => {
const { result } = renderHook(() => useSessionStorage());
const { set, remove } = result.current;
const mockUseLocation = useLocation as jest.Mock; const mockUseLocation = useLocation as jest.Mock;
const email = 'email@logto.io'; const email = 'email@logto.io';
const phone = '18571111111'; const phone = '18571111111';
@ -26,7 +26,9 @@ describe('SignInPassword', () => {
...settings, ...settings,
}} }}
> >
<SignInPassword /> <UserInteractionContextProvider>
<SignInPassword />
</UserInteractionContextProvider>
</SettingsProvider> </SettingsProvider>
); );
@ -41,9 +43,7 @@ describe('SignInPassword', () => {
}); });
test('Show 404 error page with invalid method', () => { test('Show 404 error page with invalid method', () => {
mockUseLocation.mockImplementation(() => ({ set(StorageKeys.IdentifierInputValue, { type: SignInIdentifier.Username, value: username });
state: { identifier: SignInIdentifier.Username, value: username },
}));
const { queryByText } = renderPasswordSignInPage({ const { queryByText } = renderPasswordSignInPage({
signIn: { signIn: {
@ -60,6 +60,8 @@ describe('SignInPassword', () => {
expect(queryByText('description.enter_password')).toBeNull(); expect(queryByText('description.enter_password')).toBeNull();
expect(queryByText('description.not_found')).not.toBeNull(); expect(queryByText('description.not_found')).not.toBeNull();
remove(StorageKeys.IdentifierInputValue);
}); });
test.each([ test.each([
@ -68,9 +70,7 @@ describe('SignInPassword', () => {
])( ])(
'render password page with %variable.identifier', 'render password page with %variable.identifier',
({ identifier, value, verificationCode }) => { ({ identifier, value, verificationCode }) => {
mockUseLocation.mockImplementation(() => ({ set(StorageKeys.IdentifierInputValue, { type: identifier, value });
state: { identifier, value },
}));
const { queryByText, container } = renderPasswordSignInPage({ const { queryByText, container } = renderPasswordSignInPage({
signIn: { signIn: {
@ -93,6 +93,8 @@ describe('SignInPassword', () => {
} else { } else {
expect(queryByText('action.sign_in_via_passcode')).toBeNull(); expect(queryByText('action.sign_in_via_passcode')).toBeNull();
} }
remove(StorageKeys.IdentifierInputValue);
} }
); );
}); });

View file

@ -1,12 +1,11 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import { 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 { useSieMethods } from '@/hooks/use-sie'; import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage'; import ErrorPage from '@/pages/ErrorPage';
import { passwordIdentifierStateGuard } from '@/types/guard';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code'; import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
import { identifierInputDescriptionMap } from '@/utils/form'; import { identifierInputDescriptionMap } from '@/utils/form';
@ -14,18 +13,17 @@ import PasswordForm from './PasswordForm';
const SignInPassword = () => { const SignInPassword = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { state } = useLocation();
const { signInMethods } = useSieMethods(); const { signInMethods } = useSieMethods();
const [_, identifierState] = validate(state, passwordIdentifierStateGuard); const { identifierInputValue } = useContext(UserInteractionContext);
if (!identifierState) { if (!identifierInputValue) {
return <ErrorPage title="error.invalid_session" />; return <ErrorPage title="error.invalid_session" />;
} }
const { identifier, value } = identifierState; const { type, value } = identifierInputValue;
const methodSetting = signInMethods.find((method) => method.identifier === identifier); const methodSetting = signInMethods.find((method) => method.identifier === type);
// Sign-in method not enabled // Sign-in method not enabled
if (!methodSetting?.password) { if (!methodSetting?.password) {
@ -37,9 +35,9 @@ const SignInPassword = () => {
title="description.enter_password" title="description.enter_password"
description="description.enter_password_for" description="description.enter_password_for"
descriptionProps={{ descriptionProps={{
method: t(identifierInputDescriptionMap[identifier]), method: t(identifierInputDescriptionMap[methodSetting.identifier]),
value: value:
identifier === SignInIdentifier.Phone methodSetting.identifier === SignInIdentifier.Phone
? formatPhoneNumberWithCountryCallingCode(value) ? formatPhoneNumberWithCountryCallingCode(value)
: value, : value,
}} }}

View file

@ -1,24 +1,35 @@
import { SignInIdentifier } from '@logto/schemas';
import { renderHook } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import { remove } from 'tiny-cookie';
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 useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
import VerificationCode from '.'; import VerificationCode from '.';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
state: { identifier: 'email', value: 'foo@logto.io' },
}),
}));
describe('VerificationCode Page', () => { describe('VerificationCode Page', () => {
const { result } = renderHook(() => useSessionStorage());
const { set } = result.current;
beforeEach(() => {
set(StorageKeys.IdentifierInputValue, { type: SignInIdentifier.Email, value: 'foo@logto.io' });
});
afterEach(() => {
remove(StorageKeys.IdentifierInputValue);
});
it('render properly', () => { it('render properly', () => {
const { queryByText } = renderWithPageContext( const { queryByText } = renderWithPageContext(
<SettingsProvider> <SettingsProvider>
<Routes> <UserInteractionContextProvider>
<Route path="/:flow/verification-code" element={<VerificationCode />} /> <Routes>
</Routes> <Route path="/:flow/verification-code" element={<VerificationCode />} />
</Routes>
</UserInteractionContextProvider>
</SettingsProvider>, </SettingsProvider>,
{ initialEntries: ['/sign-in/verification-code'] } { initialEntries: ['/sign-in/verification-code'] }
); );

View file

@ -1,14 +1,16 @@
import { SignInIdentifier } from '@logto/schemas'; import { SignInIdentifier } from '@logto/schemas';
import { t } from 'i18next'; import { t } from 'i18next';
import { useParams, useLocation } from 'react-router-dom'; import { useContext } from 'react';
import { useParams } from 'react-router-dom';
import { validate } from 'superstruct'; import { validate } from 'superstruct';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout'; import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import VerificationCodeContainer from '@/containers/VerificationCode'; import VerificationCodeContainer from '@/containers/VerificationCode';
import { useSieMethods } from '@/hooks/use-sie'; import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage'; import ErrorPage from '@/pages/ErrorPage';
import { UserFlow } from '@/types'; import { UserFlow } from '@/types';
import { verificationCodeStateGuard, userFlowGuard } from '@/types/guard'; import { userFlowGuard } from '@/types/guard';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code'; import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
type Parameters = { type Parameters = {
@ -18,22 +20,22 @@ type Parameters = {
const VerificationCode = () => { const VerificationCode = () => {
const { flow } = useParams<Parameters>(); const { flow } = useParams<Parameters>();
const { signInMethods } = useSieMethods(); const { signInMethods } = useSieMethods();
const { state } = useLocation();
const [, identifierState] = validate(state, verificationCodeStateGuard); const { identifierInputValue } = useContext(UserInteractionContext);
const [, useFlow] = validate(flow, userFlowGuard); const [, useFlow] = validate(flow, userFlowGuard);
if (!useFlow) { if (!useFlow) {
return <ErrorPage />; return <ErrorPage />;
} }
if (!identifierState) { const { type, value } = identifierInputValue ?? {};
if (!type || type === SignInIdentifier.Username || !value) {
return <ErrorPage title="error.invalid_session" />; return <ErrorPage title="error.invalid_session" />;
} }
const { identifier, value } = identifierState; const methodSettings = signInMethods.find((method) => method.identifier === type);
const methodSettings = signInMethods.find((method) => method.identifier === identifier);
// SignIn Method not enabled // SignIn Method not enabled
if (!methodSettings && flow !== UserFlow.ForgotPassword) { if (!methodSettings && flow !== UserFlow.ForgotPassword) {
@ -42,21 +44,17 @@ const VerificationCode = () => {
return ( return (
<SecondaryPageLayout <SecondaryPageLayout
title={`description.verify_${identifier}`} title={`description.verify_${type}`}
description="description.enter_passcode" description="description.enter_passcode"
descriptionProps={{ descriptionProps={{
address: t( address: t(`description.${type === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
`description.${identifier === SignInIdentifier.Email ? 'email' : 'phone_number'}`
),
target: target:
identifier === SignInIdentifier.Phone type === SignInIdentifier.Phone ? formatPhoneNumberWithCountryCallingCode(value) : value,
? formatPhoneNumberWithCountryCallingCode(value)
: value,
}} }}
> >
<VerificationCodeContainer <VerificationCodeContainer
flow={useFlow} flow={useFlow}
identifier={identifier} identifier={type}
target={value} target={value}
hasPasswordButton={useFlow === UserFlow.SignIn && methodSettings?.password} hasPasswordButton={useFlow === UserFlow.SignIn && methodSettings?.password}
/> />

View file

@ -6,6 +6,8 @@ import {
} from '@logto/schemas'; } from '@logto/schemas';
import * as s from 'superstruct'; import * as s from 'superstruct';
import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField';
import { UserFlow } from '.'; import { UserFlow } from '.';
export const userFlowGuard = s.enums([ export const userFlowGuard = s.enums([
@ -15,22 +17,6 @@ export const userFlowGuard = s.enums([
UserFlow.Continue, UserFlow.Continue,
]); ]);
/* Password SignIn Flow */
export const passwordIdentifierStateGuard = s.object({
identifier: s.enums([SignInIdentifier.Email, SignInIdentifier.Phone, SignInIdentifier.Username]),
value: s.string(),
});
/* Verification Code Flow Guard */
const verificationCodeMethodGuard = s.union([
s.literal(SignInIdentifier.Email),
s.literal(SignInIdentifier.Phone),
]);
export const verificationCodeStateGuard = s.object({
identifier: verificationCodeMethodGuard,
value: s.string(),
});
/* Social Flow Guard */ /* Social Flow Guard */
const registeredSocialIdentity = s.optional( const registeredSocialIdentity = s.optional(
s.object({ s.object({
@ -119,3 +105,17 @@ 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 type guard for user identifier input value caching.
*
* Purpose: 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 identifierInputValueGuard: s.Describe<IdentifierInputValue> = s.object({
type: s.optional(
s.enums([SignInIdentifier.Email, SignInIdentifier.Phone, SignInIdentifier.Username])
),
value: s.string(),
});