0
Fork 0
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:
simeng-li 2025-03-28 00:13:54 +08:00 committed by GitHub
parent bf74d32593
commit bae3c78a5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 235 additions and 17 deletions

View file

@ -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 });

View file

@ -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,
]);

View file

@ -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', {

View file

@ -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;

View file

@ -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);
}
);
}
);