From ab90f43db23fb221e21c1b2491b1a82e42402026 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Mon, 12 Aug 2024 10:28:32 +0800 Subject: [PATCH] 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 --- .changeset/rare-moons-unite.md | 7 +++ .../UserInteractionContext.tsx | 32 ++++++++-- .../UserInteractionContextProvider/index.tsx | 19 +++++- .../ForgotPasswordForm/index.tsx | 3 +- .../Register/IdentifierRegisterForm/index.tsx | 4 +- .../SignIn/IdentifierSignInForm/index.tsx | 5 +- .../experience/identifier-input-cache.test.ts | 61 +++++++++++++++++++ 7 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 .changeset/rare-moons-unite.md create mode 100644 packages/integration-tests/src/tests/experience/identifier-input-cache.test.ts diff --git a/.changeset/rare-moons-unite.md b/.changeset/rare-moons-unite.md new file mode 100644 index 000000000..75a5fcccf --- /dev/null +++ b/.changeset/rare-moons-unite.md @@ -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. diff --git a/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx b/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx index 5c62f3c82..11811cada 100644 --- a/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx +++ b/packages/experience/src/Providers/UserInteractionContextProvider/UserInteractionContext.tsx @@ -2,7 +2,10 @@ import { type SsoConnectorMetadata } from '@logto/schemas'; import { noop } from '@silverhand/essentials'; import { createContext } from 'react'; -import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField'; +import { + type IdentifierInputType, + type IdentifierInputValue, +} from '@/components/InputFields/SmartInputField'; export type UserInteractionContextType = { // All the enabled sso connectors @@ -13,12 +16,31 @@ export type UserInteractionContextType = { ssoConnectors: SsoConnectorMetadata[]; setSsoConnectors: React.Dispatch>; /** - * 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. + * The cached identifier input value that the user has inputted. */ 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>; /** @@ -52,6 +74,8 @@ export default createContext({ setSsoEmail: noop, setSsoConnectors: noop, identifierInputValue: undefined, + // eslint-disable-next-line unicorn/no-useless-undefined + getIdentifierInputValueByTypes: () => undefined, setIdentifierInputValue: noop, forgotPasswordIdentifierInputValue: undefined, setForgotPasswordIdentifierInputValue: noop, diff --git a/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx b/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx index 1a4133e28..663b171ea 100644 --- a/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx +++ b/packages/experience/src/Providers/UserInteractionContextProvider/index.tsx @@ -1,7 +1,10 @@ import { type SsoConnectorMetadata } from '@logto/schemas'; 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 { useSieMethods } from '@/hooks/use-sie'; @@ -76,6 +79,18 @@ const UserInteractionContextProvider = ({ children }: Props) => { [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(() => { remove(StorageKeys.IdentifierInputValue); remove(StorageKeys.ForgotPasswordIdentifierInputValue); @@ -89,6 +104,7 @@ const UserInteractionContextProvider = ({ children }: Props) => { ssoConnectors: domainFilteredConnectors, setSsoConnectors: setDomainFilteredConnectors, identifierInputValue, + getIdentifierInputValueByTypes, setIdentifierInputValue, forgotPasswordIdentifierInputValue, setForgotPasswordIdentifierInputValue, @@ -99,6 +115,7 @@ const UserInteractionContextProvider = ({ children }: Props) => { ssoConnectorsMap, domainFilteredConnectors, identifierInputValue, + getIdentifierInputValueByTypes, forgotPasswordIdentifierInputValue, clearInteractionContextSessionStorage, ] diff --git a/packages/experience/src/pages/ForgotPassword/ForgotPasswordForm/index.tsx b/packages/experience/src/pages/ForgotPassword/ForgotPasswordForm/index.tsx index ec8d810a2..e98316878 100644 --- a/packages/experience/src/pages/ForgotPassword/ForgotPasswordForm/index.tsx +++ b/packages/experience/src/pages/ForgotPassword/ForgotPasswordForm/index.tsx @@ -42,8 +42,7 @@ const ForgotPasswordForm = ({ UserFlow.ForgotPassword ); - const { setForgotPasswordIdentifierInputValue, setIdentifierInputValue } = - useContext(UserInteractionContext); + const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext); const { handleSubmit, diff --git a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx index ff11145db..e0df93ff1 100644 --- a/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx +++ b/packages/experience/src/pages/Register/IdentifierRegisterForm/index.tsx @@ -36,7 +36,9 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props) const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit(); - const { identifierInputValue, setIdentifierInputValue } = useContext(UserInteractionContext); + const { getIdentifierInputValueByTypes, setIdentifierInputValue } = + useContext(UserInteractionContext); + const identifierInputValue = getIdentifierInputValueByTypes(signUpMethods); const [searchParams] = useSearchParams(); diff --git a/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.tsx b/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.tsx index 994a9d60f..a06bb0b50 100644 --- a/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.tsx +++ b/packages/experience/src/pages/SignIn/IdentifierSignInForm/index.tsx @@ -34,7 +34,8 @@ 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 { getIdentifierInputValueByTypes, setIdentifierInputValue } = + useContext(UserInteractionContext); const [searchParams] = useSearchParams(); const enabledSignInMethods = useMemo( @@ -42,6 +43,8 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) => [signInMethods] ); + const identifierInputValue = getIdentifierInputValueByTypes(enabledSignInMethods); + const { watch, handleSubmit, diff --git a/packages/integration-tests/src/tests/experience/identifier-input-cache.test.ts b/packages/integration-tests/src/tests/experience/identifier-input-cache.test.ts new file mode 100644 index 000000000..f00dc0325 --- /dev/null +++ b/packages/integration-tests/src/tests/experience/identifier-input-cache.test.ts @@ -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=""]'); + }); +});