0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(experience): handle secondary sign-up identifiers (#7142)

* refactor(experience): refactor verification code sign-in/sign-up

refactor the verification code sign-in and sign-up error handling logic.

* refactor(experience): remove verification code method assertion

remove verification code method assertion

* fix(experience): fix ut

fix ut

* chore: add some comments

add some comments

* refactor(experience): update username registration handler

update username registraction handler, directly submit the interaction is password is not enabled for sign-up

* refactor(experience): simplify react hook dependency

simplify react hook dependency
This commit is contained in:
simeng-li 2025-03-24 14:01:10 +08:00 committed by GitHub
parent d352b6716c
commit 5180368b1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 182 additions and 51 deletions

View file

@ -1,20 +1,27 @@
import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { registerWithUsername } from '@/apis/experience';
import { identifyAndSubmitInteraction, registerWithUsername } from '@/apis/experience';
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 usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
import { useSieMethods } from '@/hooks/use-sie';
const useRegisterWithUsername = () => {
const navigate = useNavigate();
const redirectTo = useGlobalRedirectTo();
const [errorMessage, setErrorMessage] = useState<string>();
const { passwordRequiredForSignUp } = useSieMethods();
const clearErrorMessage = useCallback(() => {
setErrorMessage('');
}, []);
const errorHandlers: ErrorHandlers = useMemo(
const usernameErrorHandlers: ErrorHandlers = useMemo(
() => ({
'user.username_already_in_use': (error) => {
setErrorMessage(error.message);
@ -23,21 +30,52 @@ const useRegisterWithUsername = () => {
[]
);
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
const handleError = useErrorHandler();
const asyncRegister = useApi(registerWithUsername);
const asyncSubmitInteraction = useApi(identifyAndSubmitInteraction);
const onSubmitInteraction = useCallback(async () => {
const [error, result] = await asyncSubmitInteraction();
if (error) {
await handleError(error, preSignInErrorHandler);
return;
}
if (result) {
await redirectTo(result.redirectTo);
}
}, [asyncSubmitInteraction, handleError, preSignInErrorHandler, redirectTo]);
const onSubmit = useCallback(
async (username: string) => {
const [error] = await asyncRegister(username);
if (error) {
await handleError(error, errorHandlers);
await handleError(error, usernameErrorHandlers);
return;
}
navigate('password');
// If password is required for sign up, navigate to the password page
if (passwordRequiredForSignUp) {
navigate('password');
return;
}
// Otherwise, identify and submit interaction
await onSubmitInteraction();
},
[asyncRegister, errorHandlers, handleError, navigate]
[
asyncRegister,
passwordRequiredForSignUp,
onSubmitInteraction,
handleError,
usernameErrorHandlers,
navigate,
]
);
return { errorMessage, clearErrorMessage, onSubmit };

View file

@ -1,9 +1,4 @@
import { yes } from '@silverhand/essentials';
const normalizeEnv = (value: unknown) =>
value === null || value === undefined ? undefined : String(value);
const isProduction = import.meta.env.PROD;
export const isDevFeaturesEnabled =
!isProduction || yes(normalizeEnv(import.meta.env.DEV_FEATURES_ENABLED));
process.env.NODE_ENV !== 'production' || yes(process.env.DEV_FEATURES_ENABLED);

View file

@ -1,7 +1,6 @@
import {
InteractionEvent,
SignInIdentifier,
SignInMode,
type VerificationCodeIdentifier,
} from '@logto/schemas';
import { useCallback, useMemo } from 'react';
@ -31,7 +30,7 @@ const useRegisterFlowCodeVerification = (
const navigate = useNavigate();
const redirectTo = useGlobalRedirectTo();
const { signInMode, signInMethods } = useSieMethods();
const { isVerificationCodeEnabledForSignIn } = useSieMethods();
const handleError = useErrorHandler();
@ -55,14 +54,8 @@ const useRegisterFlowCodeVerification = (
const identifierExistsErrorHandler = useCallback(async () => {
const { type, value } = identifier;
if (
// Should not redirect user to sign-in if is register-only mode
signInMode === SignInMode.Register ||
// Should not redirect user to sign-in if the verification code authentication method is not enabled for sign-in
!signInMethods.find(({ identifier }) => identifier === type)?.verificationCode
) {
if (!isVerificationCodeEnabledForSignIn(type)) {
void showIdentifierErrorAlert(IdentifierErrorType.IdentifierAlreadyExists, type, value);
return;
}
@ -92,8 +85,7 @@ const useRegisterFlowCodeVerification = (
});
}, [
identifier,
signInMode,
signInMethods,
isVerificationCodeEnabledForSignIn,
show,
t,
showIdentifierErrorAlert,

View file

@ -1,7 +1,6 @@
import {
InteractionEvent,
SignInIdentifier,
SignInMode,
type VerificationCodeIdentifier,
} from '@logto/schemas';
import { useCallback, useMemo } from 'react';
@ -30,7 +29,7 @@ const useSignInFlowCodeVerification = (
const { show } = useConfirmModal();
const navigate = useNavigate();
const redirectTo = useGlobalRedirectTo();
const { signInMode, signUpMethods } = useSieMethods();
const { isVerificationCodeEnabledForSignUp } = useSieMethods();
const handleError = useErrorHandler();
const registerWithIdentifierAsync = useApi(registerWithVerifiedIdentifier);
const asyncSignInWithVerificationCodeIdentifier = useApi(identifyWithVerificationCode);
@ -49,8 +48,8 @@ const useSignInFlowCodeVerification = (
const identifierNotExistErrorHandler = useCallback(async () => {
const { type, value } = identifier;
// Should not redirect user to register if is sign-in only mode or bind social flow
if (signInMode === SignInMode.SignIn || !signUpMethods.includes(type)) {
// Should not redirect user to register if is sign-in only mode
if (!isVerificationCodeEnabledForSignUp(type)) {
void showIdentifierErrorAlert(IdentifierErrorType.IdentifierNotExist, type, value);
return;
@ -82,8 +81,7 @@ const useSignInFlowCodeVerification = (
});
}, [
identifier,
signInMode,
signUpMethods,
isVerificationCodeEnabledForSignUp,
show,
t,
showIdentifierErrorAlert,

View file

@ -1,25 +1,117 @@
import { PasswordPolicyChecker, passwordPolicyGuard } from '@logto/core-kit';
import { SignInIdentifier } from '@logto/schemas';
import {
AlternativeSignUpIdentifier,
SignInIdentifier,
SignInMode,
type VerificationCodeSignInIdentifier,
} from '@logto/schemas';
import { condArray } from '@silverhand/essentials';
import { useContext, useMemo } from 'react';
import { useContext, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import PageContext from '@/Providers/PageContextProvider/PageContext';
import { type VerificationCodeIdentifier } from '@/types';
import { isDevFeaturesEnabled } from '@/constants/env';
// eslint-disable-next-line unused-imports/no-unused-imports -- type only import
import type useRequiredProfileErrorHandler from '@/hooks/use-required-profile-error-handler';
import { type SignInExperienceResponse, type VerificationCodeIdentifier } from '@/types';
export const useSieMethods = () => {
type UseSieMethodsReturnType = {
/**
* Primary sign-up identifiers, used to render the first screen form of the registration flow.
*
* @remarks
* Currently secondary identifiers are not used when rendering the first screen form.
* Additional identifiers must be applied in the following user profile fulfillment step.
* @see {useRequiredProfileErrorHandler} for more information.
*/
signUpMethods: SignInIdentifier[];
passwordRequiredForSignUp: boolean;
signInMethods: SignInExperienceResponse['signIn']['methods'];
socialSignInSettings: SignInExperienceResponse['socialSignIn'];
socialConnectors: SignInExperienceResponse['socialConnectors'];
ssoConnectors: SignInExperienceResponse['ssoConnectors'];
signInMode: SignInExperienceResponse['signInMode'] | undefined;
forgotPassword: SignInExperienceResponse['forgotPassword'] | undefined;
customContent: SignInExperienceResponse['customContent'] | undefined;
singleSignOnEnabled: boolean | undefined;
/**
* Check if the given verification code identifier is enabled for sign-up.
* Used in the verification code sign-in flow, if the verified email/phone number has not been registered,
* and the identifier type is enabled for sign-up, allow the user to sign-up with the identifier directly.
*/
isVerificationCodeEnabledForSignUp: (type: VerificationCodeSignInIdentifier) => boolean;
/**
* Check if the given verification code identifier is enabled for sign-in.
* Used in the verification code sign-up flow, if the verified email/phone number has been registered,
* and the identifier type is enabled for sign-in, allow the user to sign-in with the identifier directly.
*/
isVerificationCodeEnabledForSignIn: (type: VerificationCodeSignInIdentifier) => boolean;
};
export const useSieMethods = (): UseSieMethodsReturnType => {
const { experienceSettings } = useContext(PageContext);
const { password, verify } = experienceSettings?.signUp ?? {};
const signUpMethods = useMemo(
() => experienceSettings?.signUp.identifiers ?? [],
[experienceSettings]
);
const secondaryIdentifiers = useMemo(() => {
return (
experienceSettings?.signUp.secondaryIdentifiers?.map(({ identifier }) => identifier) ?? []
);
}, [experienceSettings]);
const isVerificationCodeEnabledForSignUp = useCallback(
(type: VerificationCodeSignInIdentifier) => {
if (experienceSettings?.signInMode === SignInMode.SignIn) {
return false;
}
// TODO: Remove this check when the feature is enabled for all environments
if (isDevFeaturesEnabled) {
// Return true if the identifier is enabled for sign-up either as a primary or secondary identifier
return (
signUpMethods.includes(type) ||
secondaryIdentifiers.includes(type) ||
secondaryIdentifiers.includes(AlternativeSignUpIdentifier.EmailOrPhone)
);
}
return signUpMethods.includes(type);
},
[secondaryIdentifiers, signUpMethods, experienceSettings]
);
const signInMethods = useMemo(
() =>
experienceSettings?.signIn.methods.filter(
// Filter out empty settings
({ password, verificationCode }) => password || verificationCode
) ?? [],
[experienceSettings]
);
const isVerificationCodeEnabledForSignIn = useCallback(
(type: VerificationCodeSignInIdentifier) => {
if (experienceSettings?.signInMode === SignInMode.Register) {
return false;
}
return Boolean(signInMethods.find(({ identifier }) => identifier === type)?.verificationCode);
},
[experienceSettings?.signInMode, signInMethods]
);
const passwordRequiredForSignUp = useMemo(() => {
const { signUp } = experienceSettings ?? {};
return signUp?.password ?? false;
}, [experienceSettings]);
return useMemo(
() => ({
signUpMethods: experienceSettings?.signUp.identifiers ?? [],
signUpSettings: { password, verify },
signInMethods:
experienceSettings?.signIn.methods.filter(
// Filter out empty settings
({ password, verificationCode }) => password || verificationCode
) ?? [],
signUpMethods,
signInMethods,
socialSignInSettings: experienceSettings?.socialSignIn ?? {},
socialConnectors: experienceSettings?.socialConnectors ?? [],
ssoConnectors: experienceSettings?.ssoConnectors ?? [],
@ -27,8 +119,18 @@ export const useSieMethods = () => {
forgotPassword: experienceSettings?.forgotPassword,
customContent: experienceSettings?.customContent,
singleSignOnEnabled: experienceSettings?.singleSignOnEnabled,
passwordRequiredForSignUp,
isVerificationCodeEnabledForSignUp,
isVerificationCodeEnabledForSignIn,
}),
[experienceSettings, password, verify]
[
signUpMethods,
signInMethods,
experienceSettings,
passwordRequiredForSignUp,
isVerificationCodeEnabledForSignUp,
isVerificationCodeEnabledForSignIn,
]
);
};

View file

@ -9,9 +9,9 @@ import useApi from '@/hooks/use-api';
import { usePromiseConfirmModal } from '@/hooks/use-confirm-modal';
import useErrorHandler, { type ErrorHandlers } from '@/hooks/use-error-handler';
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
import useMfaErrorHandler from '@/hooks/use-mfa-error-handler';
import usePasswordPolicyChecker from '@/hooks/use-password-policy-checker';
import usePasswordRejectionErrorHandler from '@/hooks/use-password-rejection-handler';
import usePreSignInErrorHandler from '@/hooks/use-pre-sign-in-error-handler';
import { usePasswordPolicy, useSieMethods } from '@/hooks/use-sie';
import ErrorPage from '../ErrorPage';
@ -31,7 +31,7 @@ const RegisterPassword = () => {
const asyncRegisterPassword = useApi(continueRegisterWithPassword);
const handleError = useErrorHandler();
const mfaErrorHandler = useMfaErrorHandler({ replace: true });
const preSignInErrorHandler = usePreSignInErrorHandler({ replace: true });
const passwordRejectionErrorHandler = usePasswordRejectionErrorHandler({ setErrorMessage });
const errorHandlers: ErrorHandlers = useMemo(
@ -41,10 +41,10 @@ const RegisterPassword = () => {
await show({ type: 'alert', ModalContent: error.message, cancelText: 'action.got_it' });
navigate(-1);
},
...mfaErrorHandler,
...preSignInErrorHandler,
...passwordRejectionErrorHandler,
}),
[mfaErrorHandler, passwordRejectionErrorHandler, show, navigate]
[preSignInErrorHandler, passwordRejectionErrorHandler, show, navigate]
);
const onSubmitHandler = useCallback(

View file

@ -50,11 +50,8 @@ const VerificationCode = () => {
const { type, value } = cachedIdentifierInputValue;
// SignIn Method not enabled
const methodSettings = signInMethods.find((method) => method.identifier === type);
if (!methodSettings && flow !== UserFlow.ForgotPassword) {
return <ErrorPage />;
}
const hasPasswordButton = userFlow === UserFlow.SignIn && methodSettings?.password;
// VerificationId not found
const verificationId = verificationIdsMap[codeVerificationTypeMap[type]];
@ -76,7 +73,7 @@ const VerificationCode = () => {
flow={userFlow}
identifier={cachedIdentifierInputValue}
verificationId={verificationId}
hasPasswordButton={userFlow === UserFlow.SignIn && methodSettings?.password}
hasPasswordButton={hasPasswordButton}
/>
</SecondaryPageLayout>
);

View file

@ -27,7 +27,16 @@ const buildConfig = (mode: string): UserConfig => ({
viteCompression({ disable: mode === 'development', algorithm: 'brotliCompress' }),
],
define: {
'import.meta.env.DEV_FEATURES_ENABLED': JSON.stringify(process.env.DEV_FEATURES_ENABLED),
// TODO: Remove this line after the experience package supports ESM unit test.
// Replace all the process.env references with import.meta.env in the experience package then.
// 'import.meta.env.DEV_FEATURES_ENABLED': JSON.stringify(process.env.DEV_FEATURES_ENABLED),
// Experience package does not support ESM unit test yet, can not use import.meta.env
// so we need to define the environment variables here.
'process.env': {
NODE_ENV: process.env.NODE_ENV,
DEV_FEATURES_ENABLED: process.env.DEV_FEATURES_ENABLED,
},
},
build: {
// Use the same browserslist configuration as in README.md.