mirror of
https://github.com/logto-io/logto.git
synced 2025-01-27 21:39:16 -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 { noop } from '@silverhand/essentials';
|
||||||
import { createContext } from 'react';
|
import { createContext } from 'react';
|
||||||
|
|
||||||
|
import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField';
|
||||||
|
|
||||||
export type UserInteractionContextType = {
|
export type UserInteractionContextType = {
|
||||||
// All the enabled sso connectors
|
// All the enabled sso connectors
|
||||||
availableSsoConnectorsMap: Map<string, SsoConnectorMetadata>;
|
availableSsoConnectorsMap: Map<string, SsoConnectorMetadata>;
|
||||||
|
@ -10,6 +12,37 @@ export type UserInteractionContextType = {
|
||||||
// The sso connectors that are enabled for the current domain
|
// The sso connectors that are enabled for the current domain
|
||||||
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 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>({
|
export default createContext<UserInteractionContextType>({
|
||||||
|
@ -18,4 +51,9 @@ export default createContext<UserInteractionContextType>({
|
||||||
ssoConnectors: [],
|
ssoConnectors: [],
|
||||||
setSsoEmail: noop,
|
setSsoEmail: noop,
|
||||||
setSsoConnectors: 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 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 useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
|
||||||
import { useSieMethods } from '@/hooks/use-sie';
|
import { useSieMethods } from '@/hooks/use-sie';
|
||||||
|
|
||||||
|
@ -26,6 +27,13 @@ const UserInteractionContextProvider = ({ children }: Props) => {
|
||||||
const [domainFilteredConnectors, setDomainFilteredConnectors] = useState<SsoConnectorMetadata[]>(
|
const [domainFilteredConnectors, setDomainFilteredConnectors] = useState<SsoConnectorMetadata[]>(
|
||||||
get(StorageKeys.SsoConnectors) ?? []
|
get(StorageKeys.SsoConnectors) ?? []
|
||||||
);
|
);
|
||||||
|
const [identifierInputValue, setIdentifierInputValue] = useState<
|
||||||
|
IdentifierInputValue | undefined
|
||||||
|
>(get(StorageKeys.IdentifierInputValue));
|
||||||
|
|
||||||
|
const [forgotPasswordIdentifierInputValue, setForgotPasswordIdentifierInputValue] = useState<
|
||||||
|
IdentifierInputValue | undefined
|
||||||
|
>(get(StorageKeys.ForgotPasswordIdentifierInputValue));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ssoEmail) {
|
if (!ssoEmail) {
|
||||||
|
@ -45,11 +53,34 @@ const UserInteractionContextProvider = ({ children }: Props) => {
|
||||||
set(StorageKeys.SsoConnectors, domainFilteredConnectors);
|
set(StorageKeys.SsoConnectors, domainFilteredConnectors);
|
||||||
}, [domainFilteredConnectors, remove, set]);
|
}, [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(
|
const ssoConnectorsMap = useMemo(
|
||||||
() => new Map(ssoConnectors.map((connector) => [connector.id, connector])),
|
() => new Map(ssoConnectors.map((connector) => [connector.id, connector])),
|
||||||
[ssoConnectors]
|
[ssoConnectors]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const clearInteractionContextSessionStorage = useCallback(() => {
|
||||||
|
remove(StorageKeys.IdentifierInputValue);
|
||||||
|
remove(StorageKeys.ForgotPasswordIdentifierInputValue);
|
||||||
|
}, [remove]);
|
||||||
|
|
||||||
const userInteractionContext = useMemo<UserInteractionContextType>(
|
const userInteractionContext = useMemo<UserInteractionContextType>(
|
||||||
() => ({
|
() => ({
|
||||||
ssoEmail,
|
ssoEmail,
|
||||||
|
@ -57,8 +88,20 @@ const UserInteractionContextProvider = ({ children }: Props) => {
|
||||||
availableSsoConnectorsMap: ssoConnectorsMap,
|
availableSsoConnectorsMap: ssoConnectorsMap,
|
||||||
ssoConnectors: domainFilteredConnectors,
|
ssoConnectors: domainFilteredConnectors,
|
||||||
setSsoConnectors: setDomainFilteredConnectors,
|
setSsoConnectors: setDomainFilteredConnectors,
|
||||||
|
identifierInputValue,
|
||||||
|
setIdentifierInputValue,
|
||||||
|
forgotPasswordIdentifierInputValue,
|
||||||
|
setForgotPasswordIdentifierInputValue,
|
||||||
|
clearInteractionContextSessionStorage,
|
||||||
}),
|
}),
|
||||||
[ssoEmail, ssoConnectorsMap, domainFilteredConnectors]
|
[
|
||||||
|
ssoEmail,
|
||||||
|
ssoConnectorsMap,
|
||||||
|
domainFilteredConnectors,
|
||||||
|
identifierInputValue,
|
||||||
|
forgotPasswordIdentifierInputValue,
|
||||||
|
clearInteractionContextSessionStorage,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -13,7 +13,14 @@ export type IdentifierInputType =
|
||||||
| SignInIdentifier.Username;
|
| SignInIdentifier.Username;
|
||||||
|
|
||||||
export type IdentifierInputValue = {
|
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;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
import type { SignInIdentifier } from '@logto/schemas';
|
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 TextLink from '@/components/TextLink';
|
||||||
import { UserFlow } from '@/types';
|
import { UserFlow } from '@/types';
|
||||||
|
|
||||||
|
@ -9,15 +12,24 @@ type Props = {
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ForgotPasswordLink = ({ className, ...identifierData }: Props) => (
|
const ForgotPasswordLink = ({ className, ...identifierData }: Props) => {
|
||||||
<TextLink
|
const navigate = useNavigate();
|
||||||
className={className}
|
const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
|
||||||
to={{
|
|
||||||
pathname: `/${UserFlow.ForgotPassword}`,
|
return (
|
||||||
}}
|
<TextLink
|
||||||
state={identifierData}
|
className={className}
|
||||||
text="action.forgot_password"
|
text="action.forgot_password"
|
||||||
/>
|
onClick={() => {
|
||||||
);
|
setForgotPasswordIdentifierInputValue({
|
||||||
|
type: identifierData.identifier,
|
||||||
|
value: identifierData.value ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
navigate(`/${UserFlow.ForgotPassword}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ForgotPasswordLink;
|
export default ForgotPasswordLink;
|
||||||
|
|
|
@ -1,16 +1,12 @@
|
||||||
import type { SignInIdentifier } from '@logto/schemas';
|
|
||||||
|
|
||||||
import SwitchIcon from '@/assets/icons/switch-icon.svg';
|
import SwitchIcon from '@/assets/icons/switch-icon.svg';
|
||||||
import TextLink from '@/components/TextLink';
|
import TextLink from '@/components/TextLink';
|
||||||
import { UserFlow } from '@/types';
|
import { UserFlow } from '@/types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
readonly className?: string;
|
readonly className?: string;
|
||||||
readonly method: SignInIdentifier.Email | SignInIdentifier.Phone;
|
|
||||||
readonly target: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const PasswordSignInLink = ({ className, method, target }: Props) => {
|
const PasswordSignInLink = ({ className }: Props) => {
|
||||||
return (
|
return (
|
||||||
<TextLink
|
<TextLink
|
||||||
replace
|
replace
|
||||||
|
@ -18,7 +14,6 @@ const PasswordSignInLink = ({ className, method, target }: Props) => {
|
||||||
icon={<SwitchIcon />}
|
icon={<SwitchIcon />}
|
||||||
text="action.sign_in_via_password"
|
text="action.sign_in_via_password"
|
||||||
to={`/${UserFlow.SignIn}/password`}
|
to={`/${UserFlow.SignIn}/password`}
|
||||||
state={{ identifier: method, value: target }}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -86,7 +86,7 @@ const VerificationCode = ({ flow, identifier, className, hasPasswordButton, targ
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{flow === UserFlow.SignIn && hasPasswordButton && (
|
{flow === UserFlow.SignIn && hasPasswordButton && (
|
||||||
<PasswordSignInLink method={identifier} target={target} className={styles.switch} />
|
<PasswordSignInLink className={styles.switch} />
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|
|
@ -83,15 +83,15 @@ const useCheckSingleSignOn = () => {
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
availableSsoConnectorsMap,
|
|
||||||
clearContext,
|
clearContext,
|
||||||
handleError,
|
|
||||||
navigate,
|
|
||||||
request,
|
request,
|
||||||
setSsoEmail,
|
|
||||||
setSsoConnectors,
|
setSsoConnectors,
|
||||||
singleSignOn,
|
setSsoEmail,
|
||||||
|
navigate,
|
||||||
|
handleError,
|
||||||
t,
|
t,
|
||||||
|
availableSsoConnectorsMap,
|
||||||
|
singleSignOn,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { useCallback, useContext } from 'react';
|
import { useCallback, useContext } from 'react';
|
||||||
|
|
||||||
import PageContext from '@/Providers/PageContextProvider/PageContext';
|
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.
|
* 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.
|
* 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.
|
* This is to prevent the user from interacting with the app while the redirection is in progress.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function useGlobalRedirectTo() {
|
function useGlobalRedirectTo() {
|
||||||
const { setLoading } = useContext(PageContext);
|
const { setLoading } = useContext(PageContext);
|
||||||
|
const { clearInteractionContextSessionStorage } = useContext(UserInteractionContext);
|
||||||
|
|
||||||
const redirectTo = useCallback(
|
const redirectTo = useCallback(
|
||||||
(url: string | URL) => {
|
(url: string | URL) => {
|
||||||
setLoading(true);
|
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);
|
window.location.replace(url);
|
||||||
},
|
},
|
||||||
[setLoading]
|
[clearInteractionContextSessionStorage, setLoading]
|
||||||
);
|
);
|
||||||
|
|
||||||
return redirectTo;
|
return redirectTo;
|
||||||
|
|
|
@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom';
|
||||||
import { sendVerificationCodeApi } from '@/apis/utils';
|
import { sendVerificationCodeApi } from '@/apis/utils';
|
||||||
import useApi from '@/hooks/use-api';
|
import useApi from '@/hooks/use-api';
|
||||||
import useErrorHandler from '@/hooks/use-error-handler';
|
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 useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) => {
|
||||||
const [errorMessage, setErrorMessage] = useState<string>();
|
const [errorMessage, setErrorMessage] = useState<string>();
|
||||||
|
@ -50,7 +50,6 @@ const useSendVerificationCode = (flow: UserFlow, replaceCurrentPage?: boolean) =
|
||||||
search: location.search,
|
search: location.search,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
state: { identifier, value },
|
|
||||||
replace: replaceCurrentPage,
|
replace: replaceCurrentPage,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,18 +4,22 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import * as s from 'superstruct';
|
import * as s from 'superstruct';
|
||||||
|
|
||||||
import { ssoConnectorMetadataGuard } from '@/types/guard';
|
import { identifierInputValueGuard, ssoConnectorMetadataGuard } from '@/types/guard';
|
||||||
|
|
||||||
const logtoStorageKeyPrefix = `logto:${window.location.origin}`;
|
const logtoStorageKeyPrefix = `logto:${window.location.origin}`;
|
||||||
|
|
||||||
export enum StorageKeys {
|
export enum StorageKeys {
|
||||||
SsoEmail = 'sso-email',
|
SsoEmail = 'sso-email',
|
||||||
SsoConnectors = 'sso-connectors',
|
SsoConnectors = 'sso-connectors',
|
||||||
|
IdentifierInputValue = 'identifier-input-value',
|
||||||
|
ForgotPasswordIdentifierInputValue = 'forgot-password-identifier-input-value',
|
||||||
}
|
}
|
||||||
|
|
||||||
const valueGuard = Object.freeze({
|
const valueGuard = Object.freeze({
|
||||||
[StorageKeys.SsoEmail]: s.string(),
|
[StorageKeys.SsoEmail]: s.string(),
|
||||||
[StorageKeys.SsoConnectors]: s.array(ssoConnectorMetadataGuard),
|
[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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- we don't care about the superstruct details
|
||||||
} satisfies { [key in StorageKeys]: s.Struct<any> });
|
} satisfies { [key in StorageKeys]: s.Struct<any> });
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import type { MissingProfile } from '@logto/schemas';
|
import type { MissingProfile } from '@logto/schemas';
|
||||||
import { SignInIdentifier } from '@logto/schemas';
|
import { SignInIdentifier } from '@logto/schemas';
|
||||||
import type { TFuncKey } from 'i18next';
|
import type { TFuncKey } from 'i18next';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||||
|
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||||
import useSendVerificationCode from '@/hooks/use-send-verification-code';
|
import useSendVerificationCode from '@/hooks/use-send-verification-code';
|
||||||
import type { VerificationCodeIdentifier } from '@/types';
|
import type { VerificationCodeIdentifier } from '@/types';
|
||||||
import { UserFlow } from '@/types';
|
import { UserFlow } from '@/types';
|
||||||
|
@ -59,6 +61,7 @@ const formSettings: Record<
|
||||||
|
|
||||||
const SetEmailOrPhone = ({ missingProfile, notification }: Props) => {
|
const SetEmailOrPhone = ({ missingProfile, notification }: Props) => {
|
||||||
const { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode(UserFlow.Continue);
|
const { onSubmit, errorMessage, clearErrorMessage } = useSendVerificationCode(UserFlow.Continue);
|
||||||
|
const { setIdentifierInputValue } = useContext(UserInteractionContext);
|
||||||
|
|
||||||
const handleSubmit = async (identifier: SignInIdentifier, value: string) => {
|
const handleSubmit = async (identifier: SignInIdentifier, value: string) => {
|
||||||
// Only handles email and phone
|
// Only handles email and phone
|
||||||
|
@ -66,6 +69,8 @@ const SetEmailOrPhone = ({ missingProfile, notification }: Props) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIdentifierInputValue({ type: identifier, value });
|
||||||
|
|
||||||
return onSubmit({ identifier, value });
|
return onSubmit({ identifier, value });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
import { SignInIdentifier } from '@logto/schemas';
|
import { SignInIdentifier } from '@logto/schemas';
|
||||||
import type { TFuncKey } from 'i18next';
|
import type { TFuncKey } from 'i18next';
|
||||||
|
import { useContext } from 'react';
|
||||||
|
|
||||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||||
|
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||||
|
|
||||||
import IdentifierProfileForm from '../IdentifierProfileForm';
|
import IdentifierProfileForm from '../IdentifierProfileForm';
|
||||||
|
|
||||||
|
@ -14,11 +16,15 @@ type Props = {
|
||||||
const SetUsername = (props: Props) => {
|
const SetUsername = (props: Props) => {
|
||||||
const { onSubmit, errorMessage, clearErrorMessage } = useSetUsername();
|
const { onSubmit, errorMessage, clearErrorMessage } = useSetUsername();
|
||||||
|
|
||||||
|
const { setIdentifierInputValue } = useContext(UserInteractionContext);
|
||||||
|
|
||||||
const handleSubmit = async (identifier: SignInIdentifier, value: string) => {
|
const handleSubmit = async (identifier: SignInIdentifier, value: string) => {
|
||||||
if (identifier !== SignInIdentifier.Username) {
|
if (identifier !== SignInIdentifier.Username) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIdentifierInputValue({ type: identifier, value });
|
||||||
|
|
||||||
return onSubmit(value);
|
return onSubmit(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { InteractionEvent, SignInIdentifier } from '@logto/schemas';
|
||||||
import { assert } from '@silverhand/essentials';
|
import { assert } from '@silverhand/essentials';
|
||||||
import { act, fireEvent, waitFor } from '@testing-library/react';
|
import { act, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
|
||||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||||
import { putInteraction, sendVerificationCode } from '@/apis/interaction';
|
import { putInteraction, sendVerificationCode } from '@/apis/interaction';
|
||||||
import { UserFlow, type VerificationCodeIdentifier } from '@/types';
|
import { UserFlow, type VerificationCodeIdentifier } from '@/types';
|
||||||
|
@ -32,11 +33,13 @@ describe('ForgotPasswordForm', () => {
|
||||||
|
|
||||||
const renderForm = (defaultType: VerificationCodeIdentifier, defaultValue?: string) =>
|
const renderForm = (defaultType: VerificationCodeIdentifier, defaultValue?: string) =>
|
||||||
renderWithPageContext(
|
renderWithPageContext(
|
||||||
<ForgotPasswordForm
|
<UserInteractionContextProvider>
|
||||||
enabledTypes={[SignInIdentifier.Email, SignInIdentifier.Phone]}
|
<ForgotPasswordForm
|
||||||
defaultType={defaultType}
|
enabledTypes={[SignInIdentifier.Email, SignInIdentifier.Phone]}
|
||||||
defaultValue={defaultValue}
|
defaultType={defaultType}
|
||||||
/>
|
defaultValue={defaultValue}
|
||||||
|
/>
|
||||||
|
</UserInteractionContextProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
describe.each([
|
describe.each([
|
||||||
|
@ -81,7 +84,7 @@ describe('ForgotPasswordForm', () => {
|
||||||
pathname: `/${UserFlow.ForgotPassword}/verification-code`,
|
pathname: `/${UserFlow.ForgotPassword}/verification-code`,
|
||||||
search: '',
|
search: '',
|
||||||
},
|
},
|
||||||
{ state: { identifier, value }, replace: undefined }
|
{ replace: undefined }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useContext, useEffect } from 'react';
|
||||||
import { useForm, Controller } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import ErrorMessage from '@/components/ErrorMessage';
|
import ErrorMessage from '@/components/ErrorMessage';
|
||||||
import { SmartInputField } from '@/components/InputFields';
|
import { SmartInputField } from '@/components/InputFields';
|
||||||
|
@ -41,6 +42,8 @@ const ForgotPasswordForm = ({
|
||||||
UserFlow.ForgotPassword
|
UserFlow.ForgotPassword
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
control,
|
control,
|
||||||
|
@ -70,10 +73,13 @@ const ForgotPasswordForm = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache or update the forgot password identifier input value
|
||||||
|
setForgotPasswordIdentifierInputValue({ type, value });
|
||||||
|
|
||||||
await onSubmit({ identifier: type, value });
|
await onSubmit({ identifier: type, value });
|
||||||
})(event);
|
})(event);
|
||||||
},
|
},
|
||||||
[clearErrorMessage, handleSubmit, onSubmit]
|
[clearErrorMessage, handleSubmit, onSubmit, setForgotPasswordIdentifierInputValue]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import { SignInIdentifier } from '@logto/schemas';
|
import { SignInIdentifier } from '@logto/schemas';
|
||||||
import { Globals } from '@react-spring/web';
|
import { Globals } from '@react-spring/web';
|
||||||
import { assert } from '@silverhand/essentials';
|
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 renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||||
import { mockSignInExperienceSettings, getBoundingClientRectMock } from '@/__mocks__/logto';
|
import { mockSignInExperienceSettings, getBoundingClientRectMock } from '@/__mocks__/logto';
|
||||||
|
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
|
||||||
import type { SignInExperienceResponse } from '@/types';
|
import type { SignInExperienceResponse } from '@/types';
|
||||||
|
|
||||||
import ForgotPassword from '.';
|
import ForgotPassword from '.';
|
||||||
|
@ -33,7 +35,9 @@ describe('ForgotPassword', () => {
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ForgotPassword />
|
<UserInteractionContextProvider>
|
||||||
|
<ForgotPassword />
|
||||||
|
</UserInteractionContextProvider>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -67,56 +71,63 @@ describe('ForgotPassword', () => {
|
||||||
const countryCode = '86';
|
const countryCode = '86';
|
||||||
const phone = '13911111111';
|
const phone = '13911111111';
|
||||||
|
|
||||||
const mockUseLocation = useLocation as jest.Mock;
|
const identifierCases = [
|
||||||
|
{ type: SignInIdentifier.Username, value: '' },
|
||||||
const stateCases = [
|
{ type: SignInIdentifier.Email, value: email },
|
||||||
{},
|
{ type: SignInIdentifier.Phone, value: `${countryCode}${phone}` },
|
||||||
{ identifier: SignInIdentifier.Username, value: '' },
|
|
||||||
{ identifier: SignInIdentifier.Email, value: email },
|
|
||||||
{ identifier: SignInIdentifier.Phone, value: `${countryCode}${phone}` },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
test.each(stateCases)('render the forgot password page with state %o', async (state) => {
|
test.each(identifierCases)(
|
||||||
mockUseLocation.mockImplementation(() => ({ state }));
|
'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 { queryByText, container, queryByTestId } = renderPage(settings);
|
||||||
const inputField = container.querySelector('input[name="identifier"]');
|
const inputField = container.querySelector('input[name="identifier"]');
|
||||||
const countryCodeSelectorPrefix = queryByTestId('prefix');
|
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')).not.toBeNull();
|
||||||
expect(queryByText('description.reset_password_description')).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) {
|
if (identifier.type === SignInIdentifier.Phone && settings.phone) {
|
||||||
expect(inputField.getAttribute('value')).toBe(phone);
|
expect(inputField.getAttribute('value')).toBe(phone);
|
||||||
expect(countryCodeSelectorPrefix?.style.width).toBe('100px');
|
expect(countryCodeSelectorPrefix?.style.width).toBe('100px');
|
||||||
expect(queryByText(`+${countryCode}`)).not.toBeNull();
|
expect(queryByText(`+${countryCode}`)).not.toBeNull();
|
||||||
} else if (state.identifier === SignInIdentifier.Phone) {
|
} else if (identifier.type === SignInIdentifier.Phone) {
|
||||||
// Phone Number not enabled
|
// Phone Number not enabled
|
||||||
expect(inputField.getAttribute('value')).toBe('');
|
expect(inputField.getAttribute('value')).toBe('');
|
||||||
expect(countryCodeSelectorPrefix?.style.width).toBe('0px');
|
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 { SignInIdentifier } from '@logto/schemas';
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useContext } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { validate } from 'superstruct';
|
|
||||||
|
|
||||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||||
|
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||||
import { useForgotPasswordSettings } from '@/hooks/use-sie';
|
import { useForgotPasswordSettings } from '@/hooks/use-sie';
|
||||||
import { passwordIdentifierStateGuard } from '@/types/guard';
|
|
||||||
import { identifierInputDescriptionMap } from '@/utils/form';
|
import { identifierInputDescriptionMap } from '@/utils/form';
|
||||||
|
|
||||||
import ErrorPage from '../ErrorPage';
|
import ErrorPage from '../ErrorPage';
|
||||||
|
@ -15,9 +13,9 @@ import ForgotPasswordForm from './ForgotPasswordForm';
|
||||||
|
|
||||||
const ForgotPassword = () => {
|
const ForgotPassword = () => {
|
||||||
const { isForgotPasswordEnabled, enabledMethodSet } = useForgotPasswordSettings();
|
const { isForgotPasswordEnabled, enabledMethodSet } = useForgotPasswordSettings();
|
||||||
const { state } = useLocation();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const enabledMethods = [...enabledMethodSet];
|
const enabledMethods = [...enabledMethodSet];
|
||||||
|
const { forgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
|
||||||
|
|
||||||
const getDefaultIdentifierType = useCallback(
|
const getDefaultIdentifierType = useCallback(
|
||||||
(identifier?: SignInIdentifier) => {
|
(identifier?: SignInIdentifier) => {
|
||||||
|
@ -42,10 +40,11 @@ const ForgotPassword = () => {
|
||||||
return <ErrorPage />;
|
return <ErrorPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [_, identifierState] = validate(state, passwordIdentifierStateGuard);
|
const defaultType = getDefaultIdentifierType(forgotPasswordIdentifierInputValue?.type);
|
||||||
|
const defaultValue =
|
||||||
const defaultType = getDefaultIdentifierType(identifierState?.identifier);
|
(forgotPasswordIdentifierInputValue?.type === defaultType &&
|
||||||
const defaultValue = (identifierState?.identifier === defaultType && identifierState.value) || '';
|
forgotPasswordIdentifierInputValue.value) ||
|
||||||
|
'';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout
|
<SecondaryPageLayout
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { SignInIdentifier, experience, type SsoConnectorMetadata } from '@logto/schemas';
|
import { SignInIdentifier, experience, type SsoConnectorMetadata } from '@logto/schemas';
|
||||||
import { assert } from '@silverhand/essentials';
|
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 ConfirmModalProvider from '@/Providers/ConfirmModalProvider';
|
||||||
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
|
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
|
||||||
|
@ -10,6 +10,7 @@ import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider
|
||||||
import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto';
|
import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto';
|
||||||
import { registerWithUsernamePassword } from '@/apis/interaction';
|
import { registerWithUsernamePassword } from '@/apis/interaction';
|
||||||
import { sendVerificationCodeApi } from '@/apis/utils';
|
import { sendVerificationCodeApi } from '@/apis/utils';
|
||||||
|
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
|
||||||
import { UserFlow } from '@/types';
|
import { UserFlow } from '@/types';
|
||||||
import { getDefaultCountryCallingCode } from '@/utils/country-code';
|
import { getDefaultCountryCallingCode } from '@/utils/country-code';
|
||||||
|
|
||||||
|
@ -65,6 +66,14 @@ const renderForm = (
|
||||||
|
|
||||||
describe('<IdentifierRegisterForm />', () => {
|
describe('<IdentifierRegisterForm />', () => {
|
||||||
afterEach(() => {
|
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();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -306,6 +315,14 @@ describe('<IdentifierRegisterForm />', () => {
|
||||||
describe('single sign on register form', () => {
|
describe('single sign on register form', () => {
|
||||||
const email = 'foo@email.com';
|
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 () => {
|
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 { getByText, container, queryByText } = renderForm([SignInIdentifier.Email]);
|
||||||
const submitButton = getByText('action.create_account');
|
const submitButton = getByText('action.create_account');
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { AgreeToTermsPolicy, type SignInIdentifier } from '@logto/schemas';
|
import { AgreeToTermsPolicy, type SignInIdentifier } from '@logto/schemas';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useContext, useEffect } from 'react';
|
||||||
import { Controller, useForm } from 'react-hook-form';
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||||
import LockIcon from '@/assets/icons/lock.svg';
|
import LockIcon from '@/assets/icons/lock.svg';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import ErrorMessage from '@/components/ErrorMessage';
|
import ErrorMessage from '@/components/ErrorMessage';
|
||||||
|
@ -34,6 +35,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
|
||||||
|
|
||||||
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit();
|
const { errorMessage, clearErrorMessage, onSubmit } = useOnSubmit();
|
||||||
|
|
||||||
|
const { identifierInputValue, setIdentifierInputValue } = useContext(UserInteractionContext);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
watch,
|
watch,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
@ -61,6 +64,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIdentifierInputValue({ type, value });
|
||||||
|
|
||||||
if (showSingleSignOnForm) {
|
if (showSingleSignOnForm) {
|
||||||
await navigateToSingleSignOn();
|
await navigateToSingleSignOn();
|
||||||
return;
|
return;
|
||||||
|
@ -78,6 +83,7 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
navigateToSingleSignOn,
|
navigateToSingleSignOn,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
setIdentifierInputValue,
|
||||||
showSingleSignOnForm,
|
showSingleSignOnForm,
|
||||||
termsValidation,
|
termsValidation,
|
||||||
]
|
]
|
||||||
|
@ -111,6 +117,8 @@ const IdentifierRegisterForm = ({ className, autoFocus, signUpMethods }: Props)
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
className={styles.inputField}
|
className={styles.inputField}
|
||||||
{...field}
|
{...field}
|
||||||
|
defaultValue={identifierInputValue?.value}
|
||||||
|
defaultType={identifierInputValue?.type}
|
||||||
isDanger={!!errors.id || !!errorMessage}
|
isDanger={!!errors.id || !!errorMessage}
|
||||||
errorMessage={errors.id?.message}
|
errorMessage={errors.id?.message}
|
||||||
enabledTypes={signUpMethods}
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||||
|
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||||
import { setUserPassword } from '@/apis/interaction';
|
import { setUserPassword } from '@/apis/interaction';
|
||||||
import SetPassword from '@/containers/SetPassword';
|
import SetPassword from '@/containers/SetPassword';
|
||||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||||
|
@ -20,6 +21,7 @@ const ResetPassword = () => {
|
||||||
const { setToast } = useToast();
|
const { setToast } = useToast();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { show } = useConfirmModal();
|
const { show } = useConfirmModal();
|
||||||
|
const { setForgotPasswordIdentifierInputValue } = useContext(UserInteractionContext);
|
||||||
const errorHandlers: ErrorHandlers = useMemo(
|
const errorHandlers: ErrorHandlers = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
'session.verification_session_not_found': async (error) => {
|
'session.verification_session_not_found': async (error) => {
|
||||||
|
@ -35,11 +37,14 @@ const ResetPassword = () => {
|
||||||
const successHandler: SuccessHandler<typeof setUserPassword> = useCallback(
|
const successHandler: SuccessHandler<typeof setUserPassword> = useCallback(
|
||||||
(result) => {
|
(result) => {
|
||||||
if (result) {
|
if (result) {
|
||||||
|
// Clear the forgot password identifier input value
|
||||||
|
setForgotPasswordIdentifierInputValue(undefined);
|
||||||
|
|
||||||
setToast(t('description.password_changed'));
|
setToast(t('description.password_changed'));
|
||||||
navigate('/sign-in', { replace: true });
|
navigate('/sign-in', { replace: true });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigate, setToast, t]
|
[navigate, setForgotPasswordIdentifierInputValue, setToast, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const [action] = usePasswordAction({
|
const [action] = usePasswordAction({
|
||||||
|
@ -48,6 +53,7 @@ const ResetPassword = () => {
|
||||||
errorHandlers,
|
errorHandlers,
|
||||||
successHandler,
|
successHandler,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
policy: {
|
policy: {
|
||||||
length: { min, max },
|
length: { min, max },
|
||||||
|
|
|
@ -66,7 +66,7 @@ describe('IdentifierSignInForm', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show required error message when input is empty', async () => {
|
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');
|
const submitButton = getByText('action.sign_in');
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
|
@ -128,10 +128,7 @@ describe('IdentifierSignInForm', () => {
|
||||||
if (identifier === SignInIdentifier.Username) {
|
if (identifier === SignInIdentifier.Username) {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sendVerificationCodeApi).not.toBeCalled();
|
expect(sendVerificationCodeApi).not.toBeCalled();
|
||||||
expect(mockedNavigate).toBeCalledWith(
|
expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
|
||||||
{ pathname: '/sign-in/password' },
|
|
||||||
{ state: { identifier: SignInIdentifier.Username, value } }
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
@ -146,18 +143,7 @@ describe('IdentifierSignInForm', () => {
|
||||||
if (password && (isPasswordPrimary || !verificationCode)) {
|
if (password && (isPasswordPrimary || !verificationCode)) {
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(sendVerificationCodeApi).not.toBeCalled();
|
expect(sendVerificationCodeApi).not.toBeCalled();
|
||||||
expect(mockedNavigate).toBeCalledWith(
|
expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
|
||||||
{ pathname: '/sign-in/password' },
|
|
||||||
{
|
|
||||||
state: {
|
|
||||||
identifier,
|
|
||||||
value:
|
|
||||||
identifier === SignInIdentifier.Phone
|
|
||||||
? `${getDefaultCountryCallingCode()}${value}`
|
|
||||||
: value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
|
@ -203,10 +189,7 @@ describe('IdentifierSignInForm', () => {
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(getSingleSignOnConnectorsMock).not.toBeCalled();
|
expect(getSingleSignOnConnectorsMock).not.toBeCalled();
|
||||||
expect(mockedNavigate).toBeCalledWith(
|
expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
|
||||||
{ pathname: '/sign-in/password' },
|
|
||||||
{ state: { identifier: SignInIdentifier.Username, value: username } }
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -233,10 +216,7 @@ describe('IdentifierSignInForm', () => {
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(getSingleSignOnConnectorsMock).not.toBeCalled();
|
expect(getSingleSignOnConnectorsMock).not.toBeCalled();
|
||||||
expect(mockedNavigate).toBeCalledWith(
|
expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
|
||||||
{ pathname: '/sign-in/password' },
|
|
||||||
{ state: { identifier: SignInIdentifier.Email, value: email } }
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -270,10 +250,7 @@ describe('IdentifierSignInForm', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockedNavigate).toBeCalledWith(
|
expect(mockedNavigate).toBeCalledWith({ pathname: '/sign-in/password' });
|
||||||
{ pathname: '/sign-in/password' },
|
|
||||||
{ state: { identifier: SignInIdentifier.Email, value: email } }
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { AgreeToTermsPolicy, type SignIn } from '@logto/schemas';
|
import { AgreeToTermsPolicy, type SignIn } from '@logto/schemas';
|
||||||
import classNames from 'classnames';
|
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 { useForm, Controller } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||||
import LockIcon from '@/assets/icons/lock.svg';
|
import LockIcon from '@/assets/icons/lock.svg';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import ErrorMessage from '@/components/ErrorMessage';
|
import ErrorMessage from '@/components/ErrorMessage';
|
||||||
|
@ -32,6 +33,7 @@ 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 enabledSignInMethods = useMemo(
|
const enabledSignInMethods = useMemo(
|
||||||
() => signInMethods.map(({ identifier }) => identifier),
|
() => signInMethods.map(({ identifier }) => identifier),
|
||||||
|
@ -67,6 +69,8 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIdentifierInputValue({ type, value });
|
||||||
|
|
||||||
if (showSingleSignOnForm) {
|
if (showSingleSignOnForm) {
|
||||||
await navigateToSingleSignOn();
|
await navigateToSingleSignOn();
|
||||||
return;
|
return;
|
||||||
|
@ -86,6 +90,7 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
navigateToSingleSignOn,
|
navigateToSingleSignOn,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
setIdentifierInputValue,
|
||||||
showSingleSignOnForm,
|
showSingleSignOnForm,
|
||||||
termsValidation,
|
termsValidation,
|
||||||
]
|
]
|
||||||
|
@ -117,6 +122,8 @@ const IdentifierSignInForm = ({ className, autoFocus, signInMethods }: Props) =>
|
||||||
isDanger={!!errors.identifier || !!errorMessage}
|
isDanger={!!errors.identifier || !!errorMessage}
|
||||||
errorMessage={errors.identifier?.message}
|
errorMessage={errors.identifier?.message}
|
||||||
enabledTypes={enabledSignInMethods}
|
enabledTypes={enabledSignInMethods}
|
||||||
|
defaultType={identifierInputValue?.type}
|
||||||
|
defaultValue={identifierInputValue?.value}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import type { SignIn } from '@logto/schemas';
|
import type { SignIn } from '@logto/schemas';
|
||||||
import { SignInIdentifier } from '@logto/schemas';
|
import { SignInIdentifier } from '@logto/schemas';
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useContext } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||||
import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on';
|
import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on';
|
||||||
import useSendVerificationCode from '@/hooks/use-send-verification-code';
|
import useSendVerificationCode from '@/hooks/use-send-verification-code';
|
||||||
import { useSieMethods } from '@/hooks/use-sie';
|
import { useSieMethods } from '@/hooks/use-sie';
|
||||||
|
@ -12,18 +13,13 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { ssoConnectors } = useSieMethods();
|
const { ssoConnectors } = useSieMethods();
|
||||||
const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn();
|
const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn();
|
||||||
|
const { setIdentifierInputValue } = useContext(UserInteractionContext);
|
||||||
|
|
||||||
const signInWithPassword = useCallback(
|
const navigateToPasswordPage = useCallback(() => {
|
||||||
(identifier: SignInIdentifier, value: string) => {
|
navigate({
|
||||||
navigate(
|
pathname: `/${UserFlow.SignIn}/password`,
|
||||||
{
|
});
|
||||||
pathname: `/${UserFlow.SignIn}/password`,
|
}, [navigate]);
|
||||||
},
|
|
||||||
{ state: { identifier, value } }
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[navigate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
@ -39,10 +35,12 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
|
||||||
throw new Error(`Cannot find method with identifier type ${identifier}`);
|
throw new Error(`Cannot find method with identifier type ${identifier}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIdentifierInputValue({ type: identifier, value });
|
||||||
|
|
||||||
const { password, isPasswordPrimary, verificationCode } = method;
|
const { password, isPasswordPrimary, verificationCode } = method;
|
||||||
|
|
||||||
if (identifier === SignInIdentifier.Username) {
|
if (identifier === SignInIdentifier.Username) {
|
||||||
signInWithPassword(identifier, value);
|
navigateToPasswordPage();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -57,7 +55,7 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (password && (isPasswordPrimary || !verificationCode)) {
|
if (password && (isPasswordPrimary || !verificationCode)) {
|
||||||
signInWithPassword(identifier, value);
|
navigateToPasswordPage();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -67,11 +65,12 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
signInMethods,
|
||||||
|
setIdentifierInputValue,
|
||||||
|
ssoConnectors.length,
|
||||||
|
navigateToPasswordPage,
|
||||||
checkSingleSignOn,
|
checkSingleSignOn,
|
||||||
sendVerificationCode,
|
sendVerificationCode,
|
||||||
signInMethods,
|
|
||||||
signInWithPassword,
|
|
||||||
ssoConnectors.length,
|
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { AgreeToTermsPolicy, type SignInIdentifier } from '@logto/schemas';
|
import { AgreeToTermsPolicy, type SignInIdentifier } from '@logto/schemas';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useContext, useEffect } from 'react';
|
||||||
import { useForm, Controller } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||||
import LockIcon from '@/assets/icons/lock.svg';
|
import LockIcon from '@/assets/icons/lock.svg';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import ErrorMessage from '@/components/ErrorMessage';
|
import ErrorMessage from '@/components/ErrorMessage';
|
||||||
|
@ -37,6 +38,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
|
||||||
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
|
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
|
||||||
const { isForgotPasswordEnabled } = useForgotPasswordSettings();
|
const { isForgotPasswordEnabled } = useForgotPasswordSettings();
|
||||||
const { termsValidation, agreeToTermsPolicy } = useTerms();
|
const { termsValidation, agreeToTermsPolicy } = useTerms();
|
||||||
|
const { setIdentifierInputValue } = useContext(UserInteractionContext);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
watch,
|
watch,
|
||||||
|
@ -65,6 +67,8 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIdentifierInputValue({ type, value });
|
||||||
|
|
||||||
if (showSingleSignOnForm) {
|
if (showSingleSignOnForm) {
|
||||||
await navigateToSingleSignOn();
|
await navigateToSingleSignOn();
|
||||||
return;
|
return;
|
||||||
|
@ -87,6 +91,7 @@ const PasswordSignInForm = ({ className, autoFocus, signInMethods }: Props) => {
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
navigateToSingleSignOn,
|
navigateToSingleSignOn,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
setIdentifierInputValue,
|
||||||
showSingleSignOnForm,
|
showSingleSignOnForm,
|
||||||
termsValidation,
|
termsValidation,
|
||||||
]
|
]
|
||||||
|
|
|
@ -94,7 +94,6 @@ describe('PasswordSignInForm', () => {
|
||||||
search: '',
|
search: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
state: { identifier, value },
|
|
||||||
replace: true,
|
replace: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,9 +1,10 @@
|
||||||
import { SignInIdentifier } from '@logto/schemas';
|
import { SignInIdentifier } from '@logto/schemas';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useContext, useEffect } from 'react';
|
||||||
import { useForm, Controller } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import ErrorMessage from '@/components/ErrorMessage';
|
import ErrorMessage from '@/components/ErrorMessage';
|
||||||
import { PasswordInputField } from '@/components/InputFields';
|
import { PasswordInputField } from '@/components/InputFields';
|
||||||
|
@ -39,6 +40,7 @@ const PasswordForm = ({
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
|
const { errorMessage, clearErrorMessage, onSubmit } = usePasswordSignIn();
|
||||||
|
const { setIdentifierInputValue } = useContext(UserInteractionContext);
|
||||||
const { isForgotPasswordEnabled } = useForgotPasswordSettings();
|
const { isForgotPasswordEnabled } = useForgotPasswordSettings();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
|
@ -72,13 +74,15 @@ const PasswordForm = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setIdentifierInputValue({ type, value });
|
||||||
|
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
[type]: value,
|
[type]: value,
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
})(event);
|
})(event);
|
||||||
},
|
},
|
||||||
[clearErrorMessage, handleSubmit, onSubmit]
|
[clearErrorMessage, handleSubmit, onSubmit, setIdentifierInputValue]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
import { SignInIdentifier } from '@logto/schemas';
|
import { SignInIdentifier } from '@logto/schemas';
|
||||||
|
import { renderHook } from '@testing-library/react';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
|
||||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||||
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
|
import { mockSignInExperienceSettings } from '@/__mocks__/logto';
|
||||||
|
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
|
||||||
|
|
||||||
import SignInPassword from '.';
|
import SignInPassword from '.';
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
|
||||||
...jest.requireActual('react-router-dom'),
|
|
||||||
useLocation: jest.fn(() => ({})),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('SignInPassword', () => {
|
describe('SignInPassword', () => {
|
||||||
|
const { result } = renderHook(() => useSessionStorage());
|
||||||
|
const { set, remove } = result.current;
|
||||||
const mockUseLocation = useLocation as jest.Mock;
|
const mockUseLocation = useLocation as jest.Mock;
|
||||||
const email = 'email@logto.io';
|
const email = 'email@logto.io';
|
||||||
const phone = '18571111111';
|
const phone = '18571111111';
|
||||||
|
@ -26,7 +26,9 @@ describe('SignInPassword', () => {
|
||||||
...settings,
|
...settings,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SignInPassword />
|
<UserInteractionContextProvider>
|
||||||
|
<SignInPassword />
|
||||||
|
</UserInteractionContextProvider>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -41,9 +43,7 @@ describe('SignInPassword', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Show 404 error page with invalid method', () => {
|
test('Show 404 error page with invalid method', () => {
|
||||||
mockUseLocation.mockImplementation(() => ({
|
set(StorageKeys.IdentifierInputValue, { type: SignInIdentifier.Username, value: username });
|
||||||
state: { identifier: SignInIdentifier.Username, value: username },
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { queryByText } = renderPasswordSignInPage({
|
const { queryByText } = renderPasswordSignInPage({
|
||||||
signIn: {
|
signIn: {
|
||||||
|
@ -60,6 +60,8 @@ describe('SignInPassword', () => {
|
||||||
|
|
||||||
expect(queryByText('description.enter_password')).toBeNull();
|
expect(queryByText('description.enter_password')).toBeNull();
|
||||||
expect(queryByText('description.not_found')).not.toBeNull();
|
expect(queryByText('description.not_found')).not.toBeNull();
|
||||||
|
|
||||||
|
remove(StorageKeys.IdentifierInputValue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
|
@ -68,9 +70,7 @@ describe('SignInPassword', () => {
|
||||||
])(
|
])(
|
||||||
'render password page with %variable.identifier',
|
'render password page with %variable.identifier',
|
||||||
({ identifier, value, verificationCode }) => {
|
({ identifier, value, verificationCode }) => {
|
||||||
mockUseLocation.mockImplementation(() => ({
|
set(StorageKeys.IdentifierInputValue, { type: identifier, value });
|
||||||
state: { identifier, value },
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { queryByText, container } = renderPasswordSignInPage({
|
const { queryByText, container } = renderPasswordSignInPage({
|
||||||
signIn: {
|
signIn: {
|
||||||
|
@ -93,6 +93,8 @@ describe('SignInPassword', () => {
|
||||||
} else {
|
} else {
|
||||||
expect(queryByText('action.sign_in_via_passcode')).toBeNull();
|
expect(queryByText('action.sign_in_via_passcode')).toBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
remove(StorageKeys.IdentifierInputValue);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
import { SignInIdentifier } from '@logto/schemas';
|
import { SignInIdentifier } from '@logto/schemas';
|
||||||
|
import { useContext } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { validate } from 'superstruct';
|
|
||||||
|
|
||||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||||
|
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||||
import { useSieMethods } from '@/hooks/use-sie';
|
import { useSieMethods } from '@/hooks/use-sie';
|
||||||
import ErrorPage from '@/pages/ErrorPage';
|
import ErrorPage from '@/pages/ErrorPage';
|
||||||
import { passwordIdentifierStateGuard } from '@/types/guard';
|
|
||||||
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
||||||
import { identifierInputDescriptionMap } from '@/utils/form';
|
import { identifierInputDescriptionMap } from '@/utils/form';
|
||||||
|
|
||||||
|
@ -14,18 +13,17 @@ import PasswordForm from './PasswordForm';
|
||||||
|
|
||||||
const SignInPassword = () => {
|
const SignInPassword = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state } = useLocation();
|
|
||||||
const { signInMethods } = useSieMethods();
|
const { signInMethods } = useSieMethods();
|
||||||
|
|
||||||
const [_, identifierState] = validate(state, passwordIdentifierStateGuard);
|
const { identifierInputValue } = useContext(UserInteractionContext);
|
||||||
|
|
||||||
if (!identifierState) {
|
if (!identifierInputValue) {
|
||||||
return <ErrorPage title="error.invalid_session" />;
|
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
|
// Sign-in method not enabled
|
||||||
if (!methodSetting?.password) {
|
if (!methodSetting?.password) {
|
||||||
|
@ -37,9 +35,9 @@ const SignInPassword = () => {
|
||||||
title="description.enter_password"
|
title="description.enter_password"
|
||||||
description="description.enter_password_for"
|
description="description.enter_password_for"
|
||||||
descriptionProps={{
|
descriptionProps={{
|
||||||
method: t(identifierInputDescriptionMap[identifier]),
|
method: t(identifierInputDescriptionMap[methodSetting.identifier]),
|
||||||
value:
|
value:
|
||||||
identifier === SignInIdentifier.Phone
|
methodSetting.identifier === SignInIdentifier.Phone
|
||||||
? formatPhoneNumberWithCountryCallingCode(value)
|
? formatPhoneNumberWithCountryCallingCode(value)
|
||||||
: 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 { Routes, Route } from 'react-router-dom';
|
||||||
|
import { remove } from 'tiny-cookie';
|
||||||
|
|
||||||
|
import UserInteractionContextProvider from '@/Providers/UserInteractionContextProvider';
|
||||||
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
|
||||||
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
|
||||||
|
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
|
||||||
|
|
||||||
import VerificationCode from '.';
|
import VerificationCode from '.';
|
||||||
|
|
||||||
jest.mock('react-router-dom', () => ({
|
|
||||||
...jest.requireActual('react-router-dom'),
|
|
||||||
useLocation: () => ({
|
|
||||||
state: { identifier: 'email', value: 'foo@logto.io' },
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('VerificationCode Page', () => {
|
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', () => {
|
it('render properly', () => {
|
||||||
const { queryByText } = renderWithPageContext(
|
const { queryByText } = renderWithPageContext(
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<Routes>
|
<UserInteractionContextProvider>
|
||||||
<Route path="/:flow/verification-code" element={<VerificationCode />} />
|
<Routes>
|
||||||
</Routes>
|
<Route path="/:flow/verification-code" element={<VerificationCode />} />
|
||||||
|
</Routes>
|
||||||
|
</UserInteractionContextProvider>
|
||||||
</SettingsProvider>,
|
</SettingsProvider>,
|
||||||
{ initialEntries: ['/sign-in/verification-code'] }
|
{ initialEntries: ['/sign-in/verification-code'] }
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
import { SignInIdentifier } from '@logto/schemas';
|
import { SignInIdentifier } from '@logto/schemas';
|
||||||
import { t } from 'i18next';
|
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 { validate } from 'superstruct';
|
||||||
|
|
||||||
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
import SecondaryPageLayout from '@/Layout/SecondaryPageLayout';
|
||||||
|
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||||
import VerificationCodeContainer from '@/containers/VerificationCode';
|
import VerificationCodeContainer from '@/containers/VerificationCode';
|
||||||
import { useSieMethods } from '@/hooks/use-sie';
|
import { useSieMethods } from '@/hooks/use-sie';
|
||||||
import ErrorPage from '@/pages/ErrorPage';
|
import ErrorPage from '@/pages/ErrorPage';
|
||||||
import { UserFlow } from '@/types';
|
import { UserFlow } from '@/types';
|
||||||
import { verificationCodeStateGuard, userFlowGuard } from '@/types/guard';
|
import { userFlowGuard } from '@/types/guard';
|
||||||
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
||||||
|
|
||||||
type Parameters = {
|
type Parameters = {
|
||||||
|
@ -18,22 +20,22 @@ type Parameters = {
|
||||||
const VerificationCode = () => {
|
const VerificationCode = () => {
|
||||||
const { flow } = useParams<Parameters>();
|
const { flow } = useParams<Parameters>();
|
||||||
const { signInMethods } = useSieMethods();
|
const { signInMethods } = useSieMethods();
|
||||||
const { state } = useLocation();
|
|
||||||
|
|
||||||
const [, identifierState] = validate(state, verificationCodeStateGuard);
|
const { identifierInputValue } = useContext(UserInteractionContext);
|
||||||
|
|
||||||
const [, useFlow] = validate(flow, userFlowGuard);
|
const [, useFlow] = validate(flow, userFlowGuard);
|
||||||
|
|
||||||
if (!useFlow) {
|
if (!useFlow) {
|
||||||
return <ErrorPage />;
|
return <ErrorPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!identifierState) {
|
const { type, value } = identifierInputValue ?? {};
|
||||||
|
|
||||||
|
if (!type || type === SignInIdentifier.Username || !value) {
|
||||||
return <ErrorPage title="error.invalid_session" />;
|
return <ErrorPage title="error.invalid_session" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { identifier, value } = identifierState;
|
const methodSettings = signInMethods.find((method) => method.identifier === type);
|
||||||
|
|
||||||
const methodSettings = signInMethods.find((method) => method.identifier === identifier);
|
|
||||||
|
|
||||||
// SignIn Method not enabled
|
// SignIn Method not enabled
|
||||||
if (!methodSettings && flow !== UserFlow.ForgotPassword) {
|
if (!methodSettings && flow !== UserFlow.ForgotPassword) {
|
||||||
|
@ -42,21 +44,17 @@ const VerificationCode = () => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout
|
<SecondaryPageLayout
|
||||||
title={`description.verify_${identifier}`}
|
title={`description.verify_${type}`}
|
||||||
description="description.enter_passcode"
|
description="description.enter_passcode"
|
||||||
descriptionProps={{
|
descriptionProps={{
|
||||||
address: t(
|
address: t(`description.${type === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
|
||||||
`description.${identifier === SignInIdentifier.Email ? 'email' : 'phone_number'}`
|
|
||||||
),
|
|
||||||
target:
|
target:
|
||||||
identifier === SignInIdentifier.Phone
|
type === SignInIdentifier.Phone ? formatPhoneNumberWithCountryCallingCode(value) : value,
|
||||||
? formatPhoneNumberWithCountryCallingCode(value)
|
|
||||||
: value,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<VerificationCodeContainer
|
<VerificationCodeContainer
|
||||||
flow={useFlow}
|
flow={useFlow}
|
||||||
identifier={identifier}
|
identifier={type}
|
||||||
target={value}
|
target={value}
|
||||||
hasPasswordButton={useFlow === UserFlow.SignIn && methodSettings?.password}
|
hasPasswordButton={useFlow === UserFlow.SignIn && methodSettings?.password}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -6,6 +6,8 @@ import {
|
||||||
} from '@logto/schemas';
|
} from '@logto/schemas';
|
||||||
import * as s from 'superstruct';
|
import * as s from 'superstruct';
|
||||||
|
|
||||||
|
import { type IdentifierInputValue } from '@/components/InputFields/SmartInputField';
|
||||||
|
|
||||||
import { UserFlow } from '.';
|
import { UserFlow } from '.';
|
||||||
|
|
||||||
export const userFlowGuard = s.enums([
|
export const userFlowGuard = s.enums([
|
||||||
|
@ -15,22 +17,6 @@ export const userFlowGuard = s.enums([
|
||||||
UserFlow.Continue,
|
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 */
|
/* Social Flow Guard */
|
||||||
const registeredSocialIdentity = s.optional(
|
const registeredSocialIdentity = s.optional(
|
||||||
s.object({
|
s.object({
|
||||||
|
@ -119,3 +105,17 @@ export const ssoConnectorMetadataGuard: s.Describe<SsoConnectorMetadata> = s.obj
|
||||||
darkLogo: s.optional(s.string()),
|
darkLogo: s.optional(s.string()),
|
||||||
connectorName: 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