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:
parent
d352b6716c
commit
5180368b1e
8 changed files with 182 additions and 51 deletions
|
@ -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 };
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Add table
Reference in a new issue