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

fix(experience): prevent errors from applying unsupported cached identifier types (#6425)

* fix(experience): prevent errors from applying unsupported cached identifier types

* test(experience): add integration tests for cached input value

* refactor(experience): rename `getIdentifierInputValue` to `getIdentifierInputValueByTypes`

* refactor(experience): add `identifierInputValue` back

* refactor(experience): update implementation
This commit is contained in:
Xiao Yijun 2024-08-12 10:28:32 +08:00 committed by GitHub
parent 758d270f7c
commit ab90f43db2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 122 additions and 9 deletions

View file

@ -0,0 +1,7 @@
---
"@logto/experience": patch
---
fix(experience): prevent errors from applying unsupported cached identifier types
Previously, cached identifier input values were applied to all pages without type checking, potentially causing errors. Now, the type is verified before application to ensure compatibility with each page's supported types.

View file

@ -2,7 +2,10 @@ 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'; import {
type IdentifierInputType,
type IdentifierInputValue,
} from '@/components/InputFields/SmartInputField';
export type UserInteractionContextType = { export type UserInteractionContextType = {
// All the enabled sso connectors // All the enabled sso connectors
@ -13,12 +16,31 @@ export type UserInteractionContextType = {
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 cached identifier input value that the user has inputted.
* The value will be used to pre-fill the identifier input field in sign-in pages.
*/ */
identifierInputValue?: IdentifierInputValue; identifierInputValue?: IdentifierInputValue;
/** /**
* This method is used to cache the identifier input value when signing in. * Retrieves the cached identifier input value that the user has inputted based on enabled types.
* The value will be used to pre-fill the identifier input field in experience pages.
*
* @param {IdentifierInputType[]} enabledTypes - Array of enabled identifier types
* @returns {IdentifierInputValue | undefined} The identifier input value object or undefined
*
* The function checks if the type of identifierInputValue is in the `enabledTypes` array,
* if the type matches, it returns `identifierInputValue`; otherwise, it returns `undefined`
*
* Example:
* ```ts
* const value = getIdentifierInputValueByTypes(['email', 'phone']);
* // Returns `identifierInputValue` if its type is 'email' or 'phone'
* // Returns `undefined` otherwise
* ```
*/
getIdentifierInputValueByTypes: (
enabledTypes: IdentifierInputType[]
) => IdentifierInputValue | undefined;
/**
* This method is used to cache the identifier input value.
*/ */
setIdentifierInputValue: React.Dispatch<React.SetStateAction<IdentifierInputValue | undefined>>; setIdentifierInputValue: React.Dispatch<React.SetStateAction<IdentifierInputValue | undefined>>;
/** /**
@ -52,6 +74,8 @@ export default createContext<UserInteractionContextType>({
setSsoEmail: noop, setSsoEmail: noop,
setSsoConnectors: noop, setSsoConnectors: noop,
identifierInputValue: undefined, identifierInputValue: undefined,
// eslint-disable-next-line unicorn/no-useless-undefined
getIdentifierInputValueByTypes: () => undefined,
setIdentifierInputValue: noop, setIdentifierInputValue: noop,
forgotPasswordIdentifierInputValue: undefined, forgotPasswordIdentifierInputValue: undefined,
setForgotPasswordIdentifierInputValue: noop, setForgotPasswordIdentifierInputValue: noop,

View file

@ -1,7 +1,10 @@
import { type SsoConnectorMetadata } from '@logto/schemas'; import { type SsoConnectorMetadata } from '@logto/schemas';
import { type ReactNode, useEffect, useMemo, useState, useCallback } from 'react'; import { type ReactNode, useEffect, useMemo, useState, useCallback } from 'react';
import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField'; import {
type IdentifierInputType,
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';
@ -76,6 +79,18 @@ const UserInteractionContextProvider = ({ children }: Props) => {
[ssoConnectors] [ssoConnectors]
); );
const getIdentifierInputValueByTypes = useCallback(
(enabledTypes: IdentifierInputType[]) => {
const { type } = identifierInputValue ?? {};
/**
* Check if the type is included in the enabledTypes array
* If it is, return identifierInputValue; otherwise, return undefined
*/
return type && enabledTypes.includes(type) ? identifierInputValue : undefined;
},
[identifierInputValue]
);
const clearInteractionContextSessionStorage = useCallback(() => { const clearInteractionContextSessionStorage = useCallback(() => {
remove(StorageKeys.IdentifierInputValue); remove(StorageKeys.IdentifierInputValue);
remove(StorageKeys.ForgotPasswordIdentifierInputValue); remove(StorageKeys.ForgotPasswordIdentifierInputValue);
@ -89,6 +104,7 @@ const UserInteractionContextProvider = ({ children }: Props) => {
ssoConnectors: domainFilteredConnectors, ssoConnectors: domainFilteredConnectors,
setSsoConnectors: setDomainFilteredConnectors, setSsoConnectors: setDomainFilteredConnectors,
identifierInputValue, identifierInputValue,
getIdentifierInputValueByTypes,
setIdentifierInputValue, setIdentifierInputValue,
forgotPasswordIdentifierInputValue, forgotPasswordIdentifierInputValue,
setForgotPasswordIdentifierInputValue, setForgotPasswordIdentifierInputValue,
@ -99,6 +115,7 @@ const UserInteractionContextProvider = ({ children }: Props) => {
ssoConnectorsMap, ssoConnectorsMap,
domainFilteredConnectors, domainFilteredConnectors,
identifierInputValue, identifierInputValue,
getIdentifierInputValueByTypes,
forgotPasswordIdentifierInputValue, forgotPasswordIdentifierInputValue,
clearInteractionContextSessionStorage, clearInteractionContextSessionStorage,
] ]

View file

@ -42,8 +42,7 @@ const ForgotPasswordForm = ({
UserFlow.ForgotPassword UserFlow.ForgotPassword
); );
const { setForgotPasswordIdentifierInputValue, setIdentifierInputValue } = const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
useContext(UserInteractionContext);
const { const {
handleSubmit, handleSubmit,

View file

@ -36,7 +36,9 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(); const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit();
const { identifierInputValue, setIdentifierInputValue } = useContext(UserInteractionContext); const { getIdentifierInputValueByTypes, setIdentifierInputValue } =
useContext(UserInteractionContext);
const identifierInputValue = getIdentifierInputValueByTypes(signUpMethods);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();

View file

@ -34,7 +34,8 @@ 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 { getIdentifierInputValueByTypes, setIdentifierInputValue } =
useContext(UserInteractionContext);
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const enabledSignInMethods = useMemo( const enabledSignInMethods = useMemo(
@ -42,6 +43,8 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
[signInMethods] [signInMethods]
); );
const identifierInputValue = getIdentifierInputValueByTypes(enabledSignInMethods);
const { const {
watch, watch,
handleSubmit, handleSubmit,

View file

@ -0,0 +1,61 @@
import { ConnectorType } from '@logto/connector-kit';
import { SignInIdentifier, SignInMode } from '@logto/schemas';
import { appendPath } from '@silverhand/essentials';
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
import { demoAppUrl, logtoUrl } from '#src/constants.js';
import { clearConnectorsByTypes, setEmailConnector } from '#src/helpers/connector.js';
import ExpectExperience from '#src/ui-helpers/expect-experience.js';
import { generateEmail } from '#src/utils.js';
describe('identifier input cache', () => {
const testEmail = generateEmail();
// eslint-disable-next-line @silverhand/fp/no-let
let experience: ExpectExperience;
beforeAll(async () => {
await clearConnectorsByTypes([ConnectorType.Social, ConnectorType.Email, ConnectorType.Sms]);
await setEmailConnector();
await updateSignInExperience({
signUp: { identifiers: [SignInIdentifier.Username], password: true, verify: false },
signIn: {
methods: [
{
identifier: SignInIdentifier.Username,
password: true,
verificationCode: false,
isPasswordPrimary: true,
},
{
identifier: SignInIdentifier.Email,
password: true,
verificationCode: true,
isPasswordPrimary: true,
},
],
},
signInMode: SignInMode.SignInAndRegister,
});
// eslint-disable-next-line @silverhand/fp/no-mutation
experience = new ExpectExperience(await browser.newPage());
});
it('should be able to cache identifier(email) input on sign-in page', async () => {
await experience.startWith(demoAppUrl, 'sign-in');
await experience.toFillInput('identifier', testEmail, { submit: true });
// The identifier is read from the cache and displayed on the page
await experience.toMatchElement('div', { text: new RegExp(testEmail) });
// Nav back, the identifier should still be there
await experience.toClick('div[role=button][class$=navButton]', /Back/);
await experience.toMatchElement(`input[name=identifier][value="${testEmail}"]`);
});
it('cached identifier(email) should not be apply to register form (only username is allowed)', async () => {
await experience.toClick('a', 'Create account');
experience.toMatchUrl(appendPath(new URL(logtoUrl), 'register').href);
// The input should be empty
await experience.toMatchElement('input[name="id"][value=""]');
});
});