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:
parent
758d270f7c
commit
ab90f43db2
7 changed files with 122 additions and 9 deletions
7
.changeset/rare-moons-unite.md
Normal file
7
.changeset/rare-moons-unite.md
Normal 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.
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
]
|
]
|
||||||
|
|
|
@ -42,8 +42,7 @@ const ForgotPasswordForm = ({
|
||||||
UserFlow.ForgotPassword
|
UserFlow.ForgotPassword
|
||||||
);
|
);
|
||||||
|
|
||||||
const { setForgotPasswordIdentifierInputValue, setIdentifierInputValue } =
|
const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
|
||||||
useContext(UserInteractionContext);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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=""]');
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue