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:
parent
4f53e41f0a
commit
787183a6ff
30 changed files with 376 additions and 211 deletions
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -83,15 +83,15 @@ const useCheckSingleSignOn = () => {
|
|||
return true;
|
||||
},
|
||||
[
|
||||
availableSsoConnectorsMap,
|
||||
clearContext,
|
||||
handleError,
|
||||
navigate,
|
||||
request,
|
||||
setSsoEmail,
|
||||
setSsoConnectors,
|
||||
singleSignOn,
|
||||
setSsoEmail,
|
||||
navigate,
|
||||
handleError,
|
||||
t,
|
||||
availableSsoConnectorsMap,
|
||||
singleSignOn,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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> });
|
||||
|
||||
|
|
|
@ -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 });
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 },
|
||||
|
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
|
|
|
@ -94,7 +94,6 @@ describe('PasswordSignInForm', () => {
|
|||
search: '',
|
||||
},
|
||||
{
|
||||
state: { identifier, value },
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
}}
|
||||
|
|
|
@ -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'] }
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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(),
|
||||
});
|
||||
|
|
Loading…
Add table
Reference in a new issue