0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-20 21:32:31 -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 { createContext } from 'react';
import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField';
export type UserInteractionContextType = {
// All the enabled sso connectors
availableSsoConnectorsMap: Map<string, SsoConnectorMetadata>;
@ -10,6 +12,37 @@ export type UserInteractionContextType = {
// The sso connectors that are enabled for the current domain
ssoConnectors: 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>({
@ -18,4 +51,9 @@ export default createContext<UserInteractionContextType>({
ssoConnectors: [],
setSsoEmail: 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 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 { useSieMethods } from '@/hooks/use-sie';
@ -26,6 +27,13 @@ const UserInteractionContextProvider = ({ children }: Props) => {
const [domainFilteredConnectors, setDomainFilteredConnectors] = useState<SsoConnectorMetadata[]>(
get(StorageKeys.SsoConnectors) ?? []
);
const [identifierInputValue, setIdentifierInputValue] = useState<
IdentifierInputValue | undefined
>(get(StorageKeys.IdentifierInputValue));
const [forgotPasswordIdentifierInputValue, setForgotPasswordIdentifierInputValue] = useState<
IdentifierInputValue | undefined
>(get(StorageKeys.ForgotPasswordIdentifierInputValue));
useEffect(() => {
if (!ssoEmail) {
@ -45,11 +53,34 @@ const UserInteractionContextProvider = ({ children }: Props) => {
set(StorageKeys.SsoConnectors, domainFilteredConnectors);
}, [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(
() => new Map(ssoConnectors.map((connector) => [connector.id, connector])),
[ssoConnectors]
);
const clearInteractionContextSessionStorage = useCallback(() => {
remove(StorageKeys.IdentifierInputValue);
remove(StorageKeys.ForgotPasswordIdentifierInputValue);
}, [remove]);
const userInteractionContext = useMemo<UserInteractionContextType>(
() => ({
ssoEmail,
@ -57,8 +88,20 @@ const UserInteractionContextProvider = ({ children }: Props) => {
availableSsoConnectorsMap: ssoConnectorsMap,
ssoConnectors: domainFilteredConnectors,
setSsoConnectors: setDomainFilteredConnectors,
identifierInputValue,
setIdentifierInputValue,
forgotPasswordIdentifierInputValue,
setForgotPasswordIdentifierInputValue,
clearInteractionContextSessionStorage,
}),
[ssoEmail, ssoConnectorsMap, domainFilteredConnectors]
[
ssoEmail,
ssoConnectorsMap,
domainFilteredConnectors,
identifierInputValue,
forgotPasswordIdentifierInputValue,
clearInteractionContextSessionStorage,
]
);
return (

View file

@ -13,7 +13,14 @@ export type IdentifierInputType =
| SignInIdentifier.Username;
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;
};

View file

@ -1,5 +1,8 @@
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 { UserFlow } from '@/types';
@ -9,15 +12,24 @@ type Props = {
readonly className?: string;
};
const ForgotPasswordLink = ({ className, ...identifierData }: Props) => (
<TextLink
className={className}
to={{
pathname: `/${UserFlow.ForgotPassword}`,
}}
state={identifierData}
text="action.forgot_password"
/>
);
const ForgotPasswordLink = ({ className, ...identifierData }: Props) => {
const navigate = useNavigate();
const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
return (
<TextLink
className={className}
text="action.forgot_password"
onClick={() => {
setForgotPasswordIdentifierInputValue({
type: identifierData.identifier,
value: identifierData.value ?? '',
});
navigate(`/${UserFlow.ForgotPassword}`);
}}
/>
);
};
export default ForgotPasswordLink;

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import { useCallback, useContext } from 'react';
import PageContext from '@/Providers/PageContextProvider/PageContext';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
/**
* This hook provides a function that process the app redirection after user successfully signs in.
@ -8,16 +9,21 @@ import PageContext from '@/Providers/PageContextProvider/PageContext';
* 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.
*/
function useGlobalRedirectTo() {
const { setLoading } = useContext(PageContext);
const { clearInteractionContextSessionStorage } = useContext(UserInteractionContext);
const redirectTo = useCallback(
(url: string | URL) => {
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);
},
[setLoading]
[clearInteractionContextSessionStorage, setLoading]
);
return redirectTo;

View file

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

View file

@ -4,18 +4,22 @@
import { useCallback } from 'react';
import * as s from 'superstruct';
import { ssoConnectorMetadataGuard } from '@/types/guard';
import { identifierInputValueGuard, ssoConnectorMetadataGuard } from '@/types/guard';
const logtoStorageKeyPrefix = `logto:${window.location.origin}`;
export enum StorageKeys {
SsoEmail = 'sso-email',
SsoConnectors = 'sso-connectors',
IdentifierInputValue = 'identifier-input-value',
ForgotPasswordIdentifierInputValue = 'forgot-password-identifier-input-value',
}
const valueGuard = Object.freeze({
[StorageKeys.SsoEmail]: s.string(),
[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
} satisfies { [key in StorageKeys]: s.Struct<any> });

View file

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

View file

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

View file

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

View file

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

View file

@ -1,11 +1,13 @@
import { SignInIdentifier } from '@logto/schemas';
import { Globals } from '@react-spring/web';
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 SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings, getBoundingClientRectMock } from '@/__mocks__/logto';
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
import type { SignInExperienceResponse } from '@/types';
import ForgotPassword from '.';
@ -33,7 +35,9 @@ describe('ForgotPassword', () => {
},
}}
>
<ForgotPassword />
<UserInteractionContextProvider>
<ForgotPassword />
</UserInteractionContextProvider>
</SettingsProvider>
);
@ -67,56 +71,63 @@ describe('ForgotPassword', () => {
const countryCode = '86';
const phone = '13911111111';
const mockUseLocation = useLocation as jest.Mock;
const stateCases = [
{},
{ identifier: SignInIdentifier.Username, value: '' },
{ identifier: SignInIdentifier.Email, value: email },
{ identifier: SignInIdentifier.Phone, value: `${countryCode}${phone}` },
const identifierCases = [
{ type: SignInIdentifier.Username, value: '' },
{ type: SignInIdentifier.Email, value: email },
{ type: SignInIdentifier.Phone, value: `${countryCode}${phone}` },
];
test.each(stateCases)('render the forgot password page with state %o', async (state) => {
mockUseLocation.mockImplementation(() => ({ state }));
test.each(identifierCases)(
'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 inputField = container.querySelector('input[name="identifier"]');
const countryCodeSelectorPrefix = queryByTestId('prefix');
const { queryByText, container, queryByTestId } = renderPage(settings);
const inputField = container.querySelector('input[name="identifier"]');
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_description')).not.toBeNull();
expect(queryByText('description.reset_password')).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) {
expect(inputField.getAttribute('value')).toBe(phone);
expect(countryCodeSelectorPrefix?.style.width).toBe('100px');
expect(queryByText(`+${countryCode}`)).not.toBeNull();
} else if (state.identifier === SignInIdentifier.Phone) {
// Phone Number not enabled
expect(inputField.getAttribute('value')).toBe('');
expect(countryCodeSelectorPrefix?.style.width).toBe('0px');
if (identifier.type === SignInIdentifier.Phone && settings.phone) {
expect(inputField.getAttribute('value')).toBe(phone);
expect(countryCodeSelectorPrefix?.style.width).toBe('100px');
expect(queryByText(`+${countryCode}`)).not.toBeNull();
} else if (identifier.type === SignInIdentifier.Phone) {
// Phone Number not enabled
expect(inputField.getAttribute('value')).toBe('');
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 { useCallback } from 'react';
import { useCallback, useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { validate } from 'superstruct';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { useForgotPasswordSettings } from '@/hooks/use-sie';
import { passwordIdentifierStateGuard } from '@/types/guard';
import { identifierInputDescriptionMap } from '@/utils/form';
import ErrorPage from '../ErrorPage';
@ -15,9 +13,9 @@ import ForgotPasswordForm from './ForgotPasswordForm';
const ForgotPassword = () => {
const { isForgotPasswordEnabled, enabledMethodSet } = useForgotPasswordSettings();
const { state } = useLocation();
const { t } = useTranslation();
const enabledMethods = [...enabledMethodSet];
const { forgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
const getDefaultIdentifierType = useCallback(
(identifier?: SignInIdentifier) => {
@ -42,10 +40,11 @@ const ForgotPassword = () => {
return <ErrorPage />;
}
const [_, identifierState] = validate(state, passwordIdentifierStateGuard);
const defaultType = getDefaultIdentifierType(identifierState?.identifier);
const defaultValue = (identifierState?.identifier === defaultType && identifierState.value) || '';
const defaultType = getDefaultIdentifierType(forgotPasswordIdentifierInputValue?.type);
const defaultValue =
(forgotPasswordIdentifierInputValue?.type === defaultType &&
forgotPasswordIdentifierInputValue.value) ||
'';
return (
<SecondaryPageLayout

View file

@ -1,6 +1,6 @@
import { SignInIdentifier, experience, type SsoConnectorMetadata } from '@logto/schemas';
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 SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
@ -10,6 +10,7 @@ import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider
import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto';
import { registerWithUsernamePassword } from '@/apis/interaction';
import { sendVerificationCodeApi } from '@/apis/utils';
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
import { UserFlow } from '@/types';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
@ -65,6 +66,14 @@ const renderForm = (
describe('<IdentifierRegisterForm />', () => {
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();
});
@ -306,6 +315,14 @@ describe('<IdentifierRegisterForm />', () => {
describe('single sign on register form', () => {
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 () => {
const { getByText, container, queryByText } = renderForm([SignInIdentifier.Email]);
const submitButton = getByText('action.create_account');

View file

@ -1,9 +1,10 @@
import { AgreeToTermsPolicy, type SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useCallback, useEffect } from 'react';
import { useCallback, useContext, useEffect } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import LockIcon from '@/assets/icons/lock.svg';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
@ -34,6 +35,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit();
const { identifierInputValue, setIdentifierInputValue } = useContext(UserInteractionContext);
const {
watch,
handleSubmit,
@ -61,6 +64,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
return;
}
setIdentifierInputValue({ type, value });
if (showSingleSignOnForm) {
await navigateToSingleSignOn();
return;
@ -78,6 +83,7 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
handleSubmit,
navigateToSingleSignOn,
onSubmit,
setIdentifierInputValue,
showSingleSignOnForm,
termsValidation,
]
@ -111,6 +117,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
autoFocus={autoFocus}
className={styles.inputField}
{...field}
defaultValue={identifierInputValue?.value}
defaultType={identifierInputValue?.type}
isDanger={!!errors.id || !!errorMessage}
errorMessage={errors.id?.message}
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 { useNavigate } from 'react-router-dom';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { setUserPassword } from '@/apis/interaction';
import SetPassword from '@/containers/SetPassword';
import { useConfirmModal } from '@/hooks/use-confirm-modal';
@ -20,6 +21,7 @@ const ResetPassword = () => {
const { setToast } = useToast();
const navigate = useNavigate();
const { show } = useConfirmModal();
const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
const errorHandlers: ErrorHandlers = useMemo(
() => ({
'session.verification_session_not_found': async (error) => {
@ -35,11 +37,14 @@ const ResetPassword = () => {
const successHandler: SuccessHandler<typeof setUserPassword> = useCallback(
(result) => {
if (result) {
// Clear the forgot password identifier input value
setForgotPasswordIdentifierInputValue(undefined);
setToast(t('description.password_changed'));
navigate('/sign-in', { replace: true });
}
},
[navigate, setToast, t]
[navigate, setForgotPasswordIdentifierInputValue, setToast, t]
);
const [action] = usePasswordAction({
@ -48,6 +53,7 @@ const ResetPassword = () => {
errorHandlers,
successHandler,
});
const {
policy: {
length: { min, max },

View file

@ -66,7 +66,7 @@ describe('IdentifierSignInForm', () => {
});
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');
act(() => {
@ -128,10 +128,7 @@ describe('IdentifierSignInForm', () => {
if (identifier === SignInIdentifier.Username) {
await waitFor(() => {
expect(sendVerificationCodeApi).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/password' },
{ state: { identifier: SignInIdentifier.Username, value } }
);
expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
});
return;
@ -146,18 +143,7 @@ describe('IdentifierSignInForm', () => {
if (password && (isPasswordPrimary || !verificationCode)) {
await waitFor(() => {
expect(sendVerificationCodeApi).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/password' },
{
state: {
identifier,
value:
identifier === SignInIdentifier.Phone
? `${getDefaultCountryCallingCode()}${value}`
: value,
},
}
);
expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
});
return;
@ -203,10 +189,7 @@ describe('IdentifierSignInForm', () => {
await waitFor(() => {
expect(getSingleSignOnConnectorsMock).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/password' },
{ state: { identifier: SignInIdentifier.Username, value: username } }
);
expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
});
});
@ -233,10 +216,7 @@ describe('IdentifierSignInForm', () => {
await waitFor(() => {
expect(getSingleSignOnConnectorsMock).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/password' },
{ state: { identifier: SignInIdentifier.Email, value: email } }
);
expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
});
});
@ -270,10 +250,7 @@ describe('IdentifierSignInForm', () => {
});
await waitFor(() => {
expect(mockedNavigate).toBeCalledWith(
{ pathname: '/sign-in/password' },
{ state: { identifier: SignInIdentifier.Email, value: email } }
);
expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
});
});

View file

@ -1,9 +1,10 @@
import { AgreeToTermsPolicy, type SignIn } from '@logto/schemas';
import classNames from 'classnames';
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useContext, useEffect, useMemo } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import LockIcon from '@/assets/icons/lock.svg';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
@ -32,6 +33,7 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
const { t } = useTranslation();
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(signInMethods);
const { termsValidation, agreeToTermsPolicy } = useTerms();
const { identifierInputValue, setIdentifierInputValue } = useContext(UserInteractionContext);
const enabledSignInMethods = useMemo(
() => signInMethods.map(({ identifier }) => identifier),
@ -67,6 +69,8 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
return;
}
setIdentifierInputValue({ type, value });
if (showSingleSignOnForm) {
await navigateToSingleSignOn();
return;
@ -86,6 +90,7 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
handleSubmit,
navigateToSingleSignOn,
onSubmit,
setIdentifierInputValue,
showSingleSignOnForm,
termsValidation,
]
@ -117,6 +122,8 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
isDanger={!!errors.identifier || !!errorMessage}
errorMessage={errors.identifier?.message}
enabledTypes={enabledSignInMethods}
defaultType={identifierInputValue?.type}
defaultValue={identifierInputValue?.value}
/>
)}
/>

View file

@ -1,8 +1,9 @@
import type { SignIn } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import { useCallback } from 'react';
import { useCallback, useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import { useSieMethods } from '@/hooks/use-sie';
@ -12,18 +13,13 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
const navigate = useNavigate();
const { ssoConnectors } = useSieMethods();
const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn();
const { setIdentifierInputValue } = useContext(UserInteractionContext);
const signInWithPassword = useCallback(
(identifier: SignInIdentifier, value: string) => {
navigate(
{
pathname: `/${UserFlow.SignIn}/password`,
},
{ state: { identifier, value } }
);
},
[navigate]
);
const navigateToPasswordPage = useCallback(() => {
navigate({
pathname: `/${UserFlow.SignIn}/password`,
});
}, [navigate]);
const {
errorMessage,
@ -39,10 +35,12 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
throw new Error(`Cannot find method with identifier type ${identifier}`);
}
setIdentifierInputValue({ type: identifier, value });
const { password, isPasswordPrimary, verificationCode } = method;
if (identifier === SignInIdentifier.Username) {
signInWithPassword(identifier, value);
navigateToPasswordPage();
return;
}
@ -57,7 +55,7 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
}
if (password && (isPasswordPrimary || !verificationCode)) {
signInWithPassword(identifier, value);
navigateToPasswordPage();
return;
}
@ -67,11 +65,12 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
}
},
[
signInMethods,
setIdentifierInputValue,
ssoConnectors.length,
navigateToPasswordPage,
checkSingleSignOn,
sendVerificationCode,
signInMethods,
signInWithPassword,
ssoConnectors.length,
]
);

View file

@ -1,9 +1,10 @@
import { AgreeToTermsPolicy, type SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useCallback, useEffect } from 'react';
import { useCallback, useContext, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import LockIcon from '@/assets/icons/lock.svg';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
@ -37,6 +38,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
const { isForgotPasswordEnabled } = useForgotPasswordSettings();
const { termsValidation, agreeToTermsPolicy } = useTerms();
const { setIdentifierInputValue } = useContext(UserInteractionContext);
const {
watch,
@ -65,6 +67,8 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
return;
}
setIdentifierInputValue({ type, value });
if (showSingleSignOnForm) {
await navigateToSingleSignOn();
return;
@ -87,6 +91,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
handleSubmit,
navigateToSingleSignOn,
onSubmit,
setIdentifierInputValue,
showSingleSignOnForm,
termsValidation,
]

View file

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

View file

@ -1,9 +1,10 @@
import { SignInIdentifier } from '@logto/schemas';
import classNames from 'classnames';
import { useCallback, useEffect } from 'react';
import { useCallback, useContext, useEffect } from 'react';
import { useForm, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import Button from '@/components/Button';
import ErrorMessage from '@/components/ErrorMessage';
import { PasswordInputField } from '@/components/InputFields';
@ -39,6 +40,7 @@ const PasswordForm = ({
}: Props) => {
const { t } = useTranslation();
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
const { setIdentifierInputValue } = useContext(UserInteractionContext);
const { isForgotPasswordEnabled } = useForgotPasswordSettings();
const {
@ -72,13 +74,15 @@ const PasswordForm = ({
return;
}
setIdentifierInputValue({ type, value });
await onSubmit({
[type]: value,
password,
});
})(event);
},
[clearErrorMessage, handleSubmit, onSubmit]
[clearErrorMessage, handleSubmit, onSubmit, setIdentifierInputValue]
);
return (

View file

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

View file

@ -1,12 +1,11 @@
import { SignInIdentifier } from '@logto/schemas';
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { validate } from 'superstruct';
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '@/pages/ErrorPage';
import { passwordIdentifierStateGuard } from '@/types/guard';
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
import { identifierInputDescriptionMap } from '@/utils/form';
@ -14,18 +13,17 @@ import PasswordForm from './PasswordForm';
const SignInPassword = () => {
const { t } = useTranslation();
const { state } = useLocation();
const { signInMethods } = useSieMethods();
const [_, identifierState] = validate(state, passwordIdentifierStateGuard);
const { identifierInputValue } = useContext(UserInteractionContext);
if (!identifierState) {
if (!identifierInputValue) {
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
if (!methodSetting?.password) {
@ -37,9 +35,9 @@ const SignInPassword = () => {
title="description.enter_password"
description="description.enter_password_for"
descriptionProps={{
method: t(identifierInputDescriptionMap[identifier]),
method: t(identifierInputDescriptionMap[methodSetting.identifier]),
value:
identifier === SignInIdentifier.Phone
methodSetting.identifier === SignInIdentifier.Phone
? formatPhoneNumberWithCountryCallingCode(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 { remove } from 'tiny-cookie';
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
import VerificationCode from '.';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({
state: { identifier: 'email', value: 'foo@logto.io' },
}),
}));
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', () => {
const { queryByText } = renderWithPageContext(
<SettingsProvider>
<Routes>
<Route path="/:flow/verification-code" element={<VerificationCode />} />
</Routes>
<UserInteractionContextProvider>
<Routes>
<Route path="/:flow/verification-code" element={<VerificationCode />} />
</Routes>
</UserInteractionContextProvider>
</SettingsProvider>,
{ initialEntries: ['/sign-in/verification-code'] }
);

View file

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

View file

@ -6,6 +6,8 @@ import {
} from '@logto/schemas';
import * as s from 'superstruct';
import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField';
import { UserFlow } from '.';
export const userFlowGuard = s.enums([
@ -15,22 +17,6 @@ export const userFlowGuard = s.enums([
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 */
const registeredSocialIdentity = s.optional(
s.object({
@ -119,3 +105,17 @@ export const ssoConnectorMetadataGuard: s.Describe<SsoConnectorMetadata> = s.obj
darkLogo: s.optional(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(),
});