Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Draft
wants to merge 5 commits into
base: simeng-log-10890-experience-api-update-registration-event-to-handle-multiple
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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;
}

// If password is required for sign up, navigate to the password page
if (passwordRequiredForSignUp) {
navigate('password');
return;
}

navigate('password');
// Otherwise, identify and submit interaction
await onSubmitInteraction();
},
[asyncRegister, errorHandlers, handleError, navigate]
[
asyncRegister,
passwordRequiredForSignUp,
onSubmitInteraction,
handleError,
usernameErrorHandlers,
navigate,
]
);

return { errorMessage, clearErrorMessage, onSubmit };
Expand Down
7 changes: 1 addition & 6 deletions packages/experience/src/constants/env.ts
Original file line number Diff line number Diff line change
@@ -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);
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
InteractionEvent,
SignInIdentifier,
SignInMode,
type VerificationCodeIdentifier,
} from '@logto/schemas';
import { useCallback, useMemo } from 'react';
Expand Down Expand Up @@ -31,7 +30,7 @@ const useRegisterFlowCodeVerification = (
const navigate = useNavigate();
const redirectTo = useGlobalRedirectTo();

const { signInMode, signInMethods } = useSieMethods();
const { isVerificationCodeEnabledForSignIn } = useSieMethods();

const handleError = useErrorHandler();

Expand All @@ -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;
}

Expand Down Expand Up @@ -92,8 +85,7 @@ const useRegisterFlowCodeVerification = (
});
}, [
identifier,
signInMode,
signInMethods,
isVerificationCodeEnabledForSignIn,
show,
t,
showIdentifierErrorAlert,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
InteractionEvent,
SignInIdentifier,
SignInMode,
type VerificationCodeIdentifier,
} from '@logto/schemas';
import { useCallback, useMemo } from 'react';
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down Expand Up @@ -82,8 +81,7 @@ const useSignInFlowCodeVerification = (
});
}, [
identifier,
signInMode,
signUpMethods,
isVerificationCodeEnabledForSignUp,
show,
t,
showIdentifierErrorAlert,
Expand Down
134 changes: 121 additions & 13 deletions packages/experience/src/hooks/use-sie.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,142 @@
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 ?? [],
signInMode: experienceSettings?.signInMode,
forgotPassword: experienceSettings?.forgotPassword,
customContent: experienceSettings?.customContent,
singleSignOnEnabled: experienceSettings?.singleSignOnEnabled,
passwordRequiredForSignUp,
isVerificationCodeEnabledForSignUp,
isVerificationCodeEnabledForSignIn,
}),
[experienceSettings, password, verify]
[
signUpMethods,
signInMethods,
experienceSettings?.socialSignIn,
experienceSettings?.socialConnectors,
experienceSettings?.ssoConnectors,
experienceSettings?.signInMode,
experienceSettings?.forgotPassword,
experienceSettings?.customContent,
experienceSettings?.singleSignOnEnabled,
passwordRequiredForSignUp,
isVerificationCodeEnabledForSignUp,
isVerificationCodeEnabledForSignIn,
]
);
};

Expand Down
8 changes: 4 additions & 4 deletions packages/experience/src/pages/RegisterPassword/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(
Expand All @@ -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(
Expand Down
7 changes: 2 additions & 5 deletions packages/experience/src/pages/VerificationCode/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]];
Expand All @@ -76,7 +73,7 @@ const VerificationCode = () => {
flow={userFlow}
identifier={cachedIdentifierInputValue}
verificationId={verificationId}
hasPasswordButton={userFlow === UserFlow.SignIn && methodSettings?.password}
hasPasswordButton={hasPasswordButton}
/>
</SecondaryPageLayout>
);
Expand Down
Loading
Loading