mirror of
https://github.com/logto-io/logto.git
synced 2025-04-07 23:01:25 -05:00
feat(experience): show sign-in modal on register profile fulfillment flow (#7198)
show sign-in modal on register profile fulfillment flow
This commit is contained in:
parent
bf74d32593
commit
bae3c78a5a
5 changed files with 235 additions and 17 deletions
|
@ -30,11 +30,33 @@ export * from './mfa';
|
|||
export * from './social';
|
||||
export * from './one-time-token';
|
||||
|
||||
/**
|
||||
* For sign-in flow user identity not found error handling use.
|
||||
*
|
||||
* If the identity is not found, but the verification record is verified,
|
||||
* allow the user registration with the verified identifier.
|
||||
*
|
||||
* Supported verification types:
|
||||
* - email verification code
|
||||
* - phone number verification code
|
||||
* - social sign-in
|
||||
* - SSO sign-in
|
||||
*/
|
||||
export const registerWithVerifiedIdentifier = async (verificationId: string) => {
|
||||
await updateInteractionEvent(InteractionEvent.Register);
|
||||
return identifyAndSubmitInteraction({ verificationId });
|
||||
};
|
||||
|
||||
/**
|
||||
* For sign-up flow user identifier already exists error handling user.
|
||||
*
|
||||
* If the identifier has been registered by an existing user,
|
||||
* allow the user to sign-in with the verified identifier directly.
|
||||
*
|
||||
* Supported verification types:
|
||||
* - email verification code
|
||||
* - phone number verification code
|
||||
*/
|
||||
export const signInWithVerifiedIdentifier = async (verificationId: string) => {
|
||||
await updateInteractionEvent(InteractionEvent.SignIn);
|
||||
return identifyAndSubmitInteraction({ verificationId });
|
||||
|
|
|
@ -6,16 +6,19 @@ import { useLocation, useSearchParams } from 'react-router-dom';
|
|||
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
|
||||
import { updateProfileWithVerificationCode } from '@/apis/experience';
|
||||
import { getInteractionEventFromState } from '@/apis/utils';
|
||||
import { isDevFeaturesEnabled } from '@/constants/env';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import type { ErrorHandlers } from '@/hooks/use-error-handler';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
|
||||
import { useSieMethods } from '@/hooks/use-sie';
|
||||
import useSubmitInteractionErrorHandler from '@/hooks/use-submit-interaction-error-handler';
|
||||
import { SearchParameters } from '@/types';
|
||||
|
||||
import useGeneralVerificationCodeErrorHandler from './use-general-verification-code-error-handler';
|
||||
import useIdentifierErrorAlert, { IdentifierErrorType } from './use-identifier-error-alert';
|
||||
import useLinkSocialConfirmModal from './use-link-social-confirm-modal';
|
||||
import useSignInWithExistIdentifierConfirmModal from './use-sign-in-with-exist-identifier-confirm-modal';
|
||||
|
||||
const useContinueFlowCodeVerification = (
|
||||
identifier: VerificationCodeIdentifier,
|
||||
|
@ -27,9 +30,15 @@ const useContinueFlowCodeVerification = (
|
|||
|
||||
const { state } = useLocation();
|
||||
const { verificationIdsMap } = useContext(UserInteractionContext);
|
||||
const interactionEvent = getInteractionEventFromState(state) ?? InteractionEvent.SignIn;
|
||||
const { isVerificationCodeEnabledForSignIn } = useSieMethods();
|
||||
|
||||
const interactionEvent = useMemo(
|
||||
() => getInteractionEventFromState(state) ?? InteractionEvent.SignIn,
|
||||
[state]
|
||||
);
|
||||
|
||||
const handleError = useErrorHandler();
|
||||
|
||||
const verifyVerificationCode = useApi(updateProfileWithVerificationCode);
|
||||
|
||||
const { generalVerificationCodeErrorHandlers, errorMessage, clearErrorMessage } =
|
||||
|
@ -41,6 +50,7 @@ const useContinueFlowCodeVerification = (
|
|||
|
||||
const showIdentifierErrorAlert = useIdentifierErrorAlert();
|
||||
const showLinkSocialConfirmModal = useLinkSocialConfirmModal();
|
||||
const showSignInWithExistIdentifierConfirmModal = useSignInWithExistIdentifierConfirmModal();
|
||||
|
||||
const identifierExistsErrorHandler = useCallback(async () => {
|
||||
const linkSocial = searchParameters.get(SearchParameters.LinkSocial);
|
||||
|
@ -49,16 +59,35 @@ const useContinueFlowCodeVerification = (
|
|||
// Show bind with social confirm modal
|
||||
if (linkSocial && socialVerificationId) {
|
||||
await showLinkSocialConfirmModal(identifier, verificationId, socialVerificationId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { type, value } = identifier;
|
||||
|
||||
// TODO: remove this dev feature check after the feature is fully implemented
|
||||
// This is to ensure a consistent user experience during the registration process.
|
||||
// If email or phone number has been enabled as additional sign-up identifiers,
|
||||
// and user trying to provide an email/phone number that already exists in the system,
|
||||
// prompt the user to sign in with the existing identifier.
|
||||
// @see {user-register-flow-code-verification.ts} for more details.
|
||||
if (
|
||||
isDevFeaturesEnabled &&
|
||||
interactionEvent === InteractionEvent.Register &&
|
||||
isVerificationCodeEnabledForSignIn(type)
|
||||
) {
|
||||
showSignInWithExistIdentifierConfirmModal({ identifier, verificationId });
|
||||
return;
|
||||
}
|
||||
|
||||
await showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, type, value);
|
||||
}, [
|
||||
identifier,
|
||||
interactionEvent,
|
||||
isVerificationCodeEnabledForSignIn,
|
||||
searchParameters,
|
||||
showIdentifierErrorAlert,
|
||||
showLinkSocialConfirmModal,
|
||||
showSignInWithExistIdentifierConfirmModal,
|
||||
verificationId,
|
||||
verificationIdsMap,
|
||||
]);
|
||||
|
|
|
@ -58,6 +58,7 @@ const useRegisterFlowCodeVerification = (
|
|||
return;
|
||||
}
|
||||
|
||||
// TODO: replace with use-sign-in-with-exist-identifier-confirm-model.ts
|
||||
show({
|
||||
confirmText: 'action.sign_in',
|
||||
ModalContent: t('description.create_account_id_exists', {
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
import {
|
||||
InteractionEvent,
|
||||
SignInIdentifier,
|
||||
type VerificationCodeIdentifier,
|
||||
} from '@logto/schemas';
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { signInWithVerifiedIdentifier } from '@/apis/experience';
|
||||
import useApi from '@/hooks/use-api';
|
||||
import { useConfirmModal } from '@/hooks/use-confirm-modal';
|
||||
import useErrorHandler from '@/hooks/use-error-handler';
|
||||
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
|
||||
import useSubmitInteractionErrorHandler from '@/hooks/use-submit-interaction-error-handler';
|
||||
import { formatPhoneNumberWithCountryCallingCode } from '@/utils/country-code';
|
||||
|
||||
type CallbackProps = {
|
||||
/**
|
||||
* Email or phone number identifier.
|
||||
* The value will be used to display the detailed identifier info in the confirm modal.
|
||||
**/
|
||||
identifier: VerificationCodeIdentifier;
|
||||
/**
|
||||
* The verification ID for the given identifier.
|
||||
*/
|
||||
verificationId: string;
|
||||
onCanceled?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* For sign-up flow user identifier already exists error handling use.
|
||||
*
|
||||
* Returns a callback function that shows a confirm modal to allow the user to sign-in with the verified identifier directly.
|
||||
*/
|
||||
const useSignInWithExistIdentifierConfirmModal = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { show } = useConfirmModal();
|
||||
const handleError = useErrorHandler();
|
||||
const redirectTo = useGlobalRedirectTo();
|
||||
|
||||
const signInWithIdentifierAsync = useApi(signInWithVerifiedIdentifier);
|
||||
|
||||
const submitInteractionErrorHandler = useSubmitInteractionErrorHandler(
|
||||
InteractionEvent.Register,
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
({ identifier: { type, value }, verificationId, onCanceled }: CallbackProps) => {
|
||||
show({
|
||||
confirmText: 'action.sign_in',
|
||||
ModalContent: t('description.create_account_id_exists', {
|
||||
type: t(`description.${type === SignInIdentifier.Email ? 'email' : 'phone_number'}`),
|
||||
value:
|
||||
type === SignInIdentifier.Phone
|
||||
? formatPhoneNumberWithCountryCallingCode(value)
|
||||
: value,
|
||||
}),
|
||||
onConfirm: async () => {
|
||||
const [error, result] = await signInWithIdentifierAsync(verificationId);
|
||||
|
||||
if (error) {
|
||||
await handleError(error, submitInteractionErrorHandler);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.redirectTo) {
|
||||
await redirectTo(result.redirectTo);
|
||||
}
|
||||
},
|
||||
onCancel: onCanceled,
|
||||
});
|
||||
},
|
||||
[handleError, redirectTo, show, signInWithIdentifierAsync, submitInteractionErrorHandler, t]
|
||||
);
|
||||
};
|
||||
|
||||
export default useSignInWithExistIdentifierConfirmModal;
|
|
@ -1,4 +1,9 @@
|
|||
import { AlternativeSignUpIdentifier, InteractionEvent, SignInIdentifier } from '@logto/schemas';
|
||||
import {
|
||||
AlternativeSignUpIdentifier,
|
||||
InteractionEvent,
|
||||
SignInIdentifier,
|
||||
type VerificationCodeIdentifier,
|
||||
} from '@logto/schemas';
|
||||
|
||||
import { deleteUser } from '#src/api/admin-user.js';
|
||||
import { updateSignInExperience } from '#src/api/sign-in-experience.js';
|
||||
|
@ -9,10 +14,23 @@ import {
|
|||
registerNewUserUsernamePassword,
|
||||
signInWithPassword,
|
||||
} from '#src/helpers/experience/index.js';
|
||||
import {
|
||||
successfullySendVerificationCode,
|
||||
successfullyVerifyVerificationCode,
|
||||
} from '#src/helpers/experience/verification-code.js';
|
||||
import { expectRejects } from '#src/helpers/index.js';
|
||||
import { generateNewUserProfile, UserApiTest } from '#src/helpers/user.js';
|
||||
import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js';
|
||||
import { generateNewUser, generateNewUserProfile, UserApiTest } from '#src/helpers/user.js';
|
||||
import { devFeatureTest, generateUsername } from '#src/utils.js';
|
||||
|
||||
const verificationIdentifierType: readonly [SignInIdentifier.Email, SignInIdentifier.Phone] =
|
||||
Object.freeze([SignInIdentifier.Email, SignInIdentifier.Phone]);
|
||||
|
||||
const identifiersTypeToUserProfile = Object.freeze({
|
||||
email: 'primaryEmail',
|
||||
phone: 'primaryPhone',
|
||||
});
|
||||
|
||||
describe('register new user with username and password', () => {
|
||||
const userApi = new UserApiTest();
|
||||
|
||||
|
@ -98,19 +116,16 @@ devFeatureTest.describe(
|
|||
it.each([SignInIdentifier.Email, AlternativeSignUpIdentifier.EmailOrPhone])(
|
||||
'set %s as secondary identifier',
|
||||
async (secondaryIdentifier) => {
|
||||
await updateSignInExperience({
|
||||
signUp: {
|
||||
identifiers: [SignInIdentifier.Username],
|
||||
password: true,
|
||||
verify: true,
|
||||
secondaryIdentifiers: [
|
||||
{
|
||||
identifier: secondaryIdentifier,
|
||||
verify: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
passwordPolicy: {},
|
||||
await enableAllVerificationCodeSignInMethods({
|
||||
identifiers: [SignInIdentifier.Username],
|
||||
password: true,
|
||||
verify: true,
|
||||
secondaryIdentifiers: [
|
||||
{
|
||||
identifier: secondaryIdentifier,
|
||||
verify: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { username, password, primaryEmail } = generateNewUserProfile({
|
||||
|
@ -157,5 +172,74 @@ devFeatureTest.describe(
|
|||
await deleteUser(userId);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(verificationIdentifierType)(
|
||||
'should fail to sign-up with existing %s as secondary identifier, and directly sign-in instead',
|
||||
async (identifierType) => {
|
||||
await enableAllVerificationCodeSignInMethods({
|
||||
identifiers: [SignInIdentifier.Username],
|
||||
password: true,
|
||||
verify: true,
|
||||
secondaryIdentifiers: [
|
||||
{
|
||||
identifier: AlternativeSignUpIdentifier.EmailOrPhone,
|
||||
verify: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { userProfile, user } = await generateNewUser({
|
||||
[identifiersTypeToUserProfile[identifierType]]: true,
|
||||
username: true,
|
||||
password: true,
|
||||
});
|
||||
|
||||
const client = await initExperienceClient({
|
||||
interactionEvent: InteractionEvent.Register,
|
||||
});
|
||||
|
||||
await client.updateProfile({ type: SignInIdentifier.Username, value: generateUsername() });
|
||||
|
||||
const identifier: VerificationCodeIdentifier = {
|
||||
type: identifierType,
|
||||
value: userProfile[identifiersTypeToUserProfile[identifierType]]!,
|
||||
};
|
||||
|
||||
const { verificationId, code } = await successfullySendVerificationCode(client, {
|
||||
identifier,
|
||||
interactionEvent: InteractionEvent.Register,
|
||||
});
|
||||
|
||||
await successfullyVerifyVerificationCode(client, {
|
||||
identifier,
|
||||
verificationId,
|
||||
code,
|
||||
});
|
||||
|
||||
await expectRejects(
|
||||
client.identifyUser({
|
||||
verificationId,
|
||||
}),
|
||||
{
|
||||
code: `user.${identifierType}_already_in_use`,
|
||||
status: 422,
|
||||
}
|
||||
);
|
||||
|
||||
await client.updateInteractionEvent({
|
||||
interactionEvent: InteractionEvent.SignIn,
|
||||
});
|
||||
|
||||
await client.identifyUser({
|
||||
verificationId,
|
||||
});
|
||||
|
||||
const { redirectTo } = await client.submitInteraction();
|
||||
await processSession(client, redirectTo);
|
||||
await logoutClient(client);
|
||||
|
||||
await deleteUser(user.id);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
Loading…
Add table
Reference in a new issue