diff --git a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignInForm/SignInMethodEditBox/index.tsx b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignInForm/SignInMethodEditBox/index.tsx index aa56c2918d0..30c85ea221a 100644 --- a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignInForm/SignInMethodEditBox/index.tsx +++ b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignInForm/SignInMethodEditBox/index.tsx @@ -3,13 +3,18 @@ import { conditional } from '@silverhand/essentials'; import { Controller, useFieldArray, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { isDevFeaturesEnabled } from '@/consts/env'; import { DragDropProvider, DraggableItem } from '@/ds-components/DragDrop'; import useEnabledConnectorTypes from '@/hooks/use-enabled-connector-types'; import type { SignInExperienceForm } from '../../../../types'; import { signInIdentifiers, signUpIdentifiersMapping } from '../../../constants'; import { identifierRequiredConnectorMapping } from '../../constants'; -import { getSignUpRequiredConnectorTypes, createSignInMethod } from '../../utils'; +import { + getSignUpRequiredConnectorTypes, + createSignInMethod, + getSignUpIdentifiersRequiredConnectors, +} from '../../utils'; import AddButton from './AddButton'; import SignInMethodItem from './SignInMethodItem'; @@ -43,12 +48,17 @@ function SignInMethodEditBox() { const { identifier: signUpIdentifier, + identifiers: signUpIdentifiers, password: isSignUpPasswordRequired, verify: isSignUpVerificationRequired, } = signUp; const requiredSignInIdentifiers = signUpIdentifiersMapping[signUpIdentifier]; - const ignoredWarningConnectors = getSignUpRequiredConnectorTypes(signUpIdentifier); + + // TODO: Remove this dev feature guard when multi sign-up identifiers are launched + const ignoredWarningConnectors = isDevFeaturesEnabled + ? getSignUpIdentifiersRequiredConnectors(signUpIdentifiers.map(({ identifier }) => identifier)) + : getSignUpRequiredConnectorTypes(signUpIdentifier); const signInIdentifierOptions = signInIdentifiers.filter((candidateIdentifier) => fields.every(({ identifier }) => identifier !== candidateIdentifier) @@ -103,12 +113,15 @@ function SignInMethodEditBox() { (); + + // Note: `useWatch` is a hook that returns the updated value on every render. + // Unlike `watch`, it doesn't require a re-render to get the updated value (alway return the current ref). + const signUp = useWatch({ + control, + name: 'signUp', + }); + + const signUpIdentifiers = useWatch({ + control, + name: 'signUp.identifiers', + }); + + const { shouldShowAuthenticationFields, shouldShowVerificationField } = useMemo(() => { + return { + shouldShowAuthenticationFields: signUpIdentifiers.length > 0, + shouldShowVerificationField: signUpIdentifiers[0]?.identifier !== SignInIdentifier.Username, + }; + }, [signUpIdentifiers]); + + // Should sync the sign-up identifier auth settings when the sign-up identifiers changed + // TODO: need to check with designer + useEffect(() => { + // Only trigger the effect when the identifiers field is dirty + const isIdentifiersDirty = dirtyFields.signUp?.identifiers; + if (!isIdentifiersDirty) { + return; + } + + const identifiers = signUpIdentifiers.map(({ identifier }) => identifier); + if (identifiers.length === 0) { + setValue('signUp.password', false); + setValue('signUp.verify', false); + return; + } + + if (identifiers.includes(SignInIdentifier.Username)) { + setValue('signUp.password', true); + } + + // Disable verification when the primary identifier is username, + // otherwise enable it for the rest of the identifiers (email, phone, emailOrPhone) + setValue('signUp.verify', identifiers[0] !== SignInIdentifier.Username); + }, [dirtyFields.signUp?.identifiers, setValue, signUpIdentifiers]); + + // Sync sign-in methods when sign-up methods change + useEffect(() => { + // Only trigger the effect when the sign-up field is dirty + const isIdentifiersDirty = dirtyFields.signUp; + if (!isIdentifiersDirty) { + return; + } + + const signInMethods = getValues('signIn.methods'); + const { password, identifiers } = signUp; + + const enabledSignUpIdentifiers = identifiers.reduce( + (identifiers, { identifier: signUpIdentifier }) => { + if (signUpIdentifier === AlternativeSignUpIdentifier.EmailOrPhone) { + return [...identifiers, SignInIdentifier.Email, SignInIdentifier.Phone]; + } + + return [...identifiers, signUpIdentifier]; + }, + [] + ); + + // Note: Auto append newly assigned sign-up identifiers to the sign-in methods list if they don't already exist + // User may remove them manually if they don't want to use it for sign-in. + const mergedSignInMethods = enabledSignUpIdentifiers.reduce((methods, signUpIdentifier) => { + if (signInMethods.some(({ identifier }) => identifier === signUpIdentifier)) { + return methods; + } + + return [...methods, createSignInMethod(signUpIdentifier)]; + }, signInMethods); + + setValue( + 'signIn.methods', + mergedSignInMethods.map((method) => { + const { identifier } = method; + + if (identifier === SignInIdentifier.Username) { + return method; + } + + return { + ...method, + // Auto enabled password for email and phone sign-in methods if password is required for sign-up. + // User may disable it manually if they don't want to use password for email or phone sign-in. + password: method.password || password, + // Note: if password is not set for sign-up, + // then auto enable verification code for email and phone sign-in methods. + verificationCode: password ? method.verificationCode : true, + }; + }) + ); + + // Note: we need to revalidate the sign-in methods after the signIn form data has been updated + if (submitCount) { + // Wait for the form re-render before validating the new data. + setTimeout(() => { + void trigger('signIn.methods'); + }, 0); + } + }, [dirtyFields.signUp, getValues, setValue, signUp, submitCount, trigger]); + + return ( + + + + + {t('sign_in_exp.sign_up_and_sign_in.sign_up.identifier_description')} + + + + {shouldShowAuthenticationFields && ( + + + {t('sign_in_exp.sign_up_and_sign_in.sign_up.authentication_description')} + +
+ ( + + )} + /> + {shouldShowVerificationField && ( + ( + + )} + /> + )} +
+
+ )} +
+ ); +} + +export default SignUpForm; diff --git a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/SignUpIdentifiersEditBox/SignUpIdentifierItem.module.scss b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/SignUpIdentifiersEditBox/SignUpIdentifierItem.module.scss new file mode 100644 index 00000000000..93960371652 --- /dev/null +++ b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/SignUpIdentifiersEditBox/SignUpIdentifierItem.module.scss @@ -0,0 +1,35 @@ +@use '@/scss/underscore' as _; + +.signUpMethodItem { + display: flex; + align-items: center; + margin: _.unit(2) 0; + gap: _.unit(2); +} + +.signUpMethod { + display: flex; + align-items: center; + height: 44px; + width: 100%; + padding: _.unit(3) _.unit(2); + background-color: var(--color-layer-2); + border-radius: 8px; + cursor: move; + gap: _.unit(1); + color: var(--color-text); + font: var(--font-label-2); + + &.error { + outline: 1px solid var(--color-error); + } + + .draggableIcon { + color: var(--color-text-secondary); + } +} + +.errorMessage { + font: var(--font-body-2); + color: var(--color-error); +} diff --git a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/SignUpIdentifiersEditBox/SignUpIdentifierItem.tsx b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/SignUpIdentifiersEditBox/SignUpIdentifierItem.tsx new file mode 100644 index 00000000000..cb4cf5b7549 --- /dev/null +++ b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/SignUpIdentifiersEditBox/SignUpIdentifierItem.tsx @@ -0,0 +1,55 @@ +import { + AlternativeSignUpIdentifier, + type ConnectorType, + type SignUpIdentifier, +} from '@logto/schemas'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; + +import Draggable from '@/assets/icons/draggable.svg?react'; +import Minus from '@/assets/icons/minus.svg?react'; +import IconButton from '@/ds-components/IconButton'; + +import ConnectorSetupWarning from '../../components/ConnectorSetupWarning'; + +import styles from './SignUpIdentifierItem.module.scss'; + +type Props = { + readonly identifier: SignUpIdentifier; + readonly requiredConnectors: ConnectorType[]; + readonly hasError?: boolean; + readonly errorMessage?: string; + readonly onDelete: () => void; +}; + +function SignUpIdentifierItem({ + identifier, + requiredConnectors, + hasError, + errorMessage, + onDelete, +}: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + return ( +
+
+
+ + {t( + `sign_in_exp.sign_up_and_sign_in.identifiers_${ + identifier === AlternativeSignUpIdentifier.EmailOrPhone ? 'email_or_sms' : identifier + }` + )} +
+ + + +
+ {errorMessage &&
{errorMessage}
} + +
+ ); +} + +export default SignUpIdentifierItem; diff --git a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/SignUpIdentifiersEditBox/index.module.scss b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/SignUpIdentifiersEditBox/index.module.scss new file mode 100644 index 00000000000..bad8cddc8c8 --- /dev/null +++ b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/SignUpIdentifiersEditBox/index.module.scss @@ -0,0 +1,3 @@ +.draggleItemContainer { + transform: translate(0, 0); +} diff --git a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/SignUpIdentifiersEditBox/index.tsx b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/SignUpIdentifiersEditBox/index.tsx new file mode 100644 index 00000000000..fe51a7c2514 --- /dev/null +++ b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/SignUpIdentifiersEditBox/index.tsx @@ -0,0 +1,144 @@ +import { + AlternativeSignUpIdentifier, + SignInIdentifier, + type SignUpIdentifier, +} from '@logto/schemas'; +import { t } from 'i18next'; +import { useMemo } from 'react'; +import { Controller, useFieldArray, useFormContext, useWatch } from 'react-hook-form'; + +import { DragDropProvider, DraggableItem } from '@/ds-components/DragDrop'; +import useEnabledConnectorTypes from '@/hooks/use-enabled-connector-types'; +import { type SignInExperienceForm } from '@/pages/SignInExperience/types'; + +import IdentifiersAddButton from '../../components/IdentifiersAddButton'; +import { getSignUpIdentifiersRequiredConnectors } from '../../utils'; + +import SignUpIdentifierItem from './SignUpIdentifierItem'; +import styles from './index.module.scss'; + +const signInIdentifierOptions = Object.values(SignInIdentifier).map((identifier) => ({ + value: identifier, + label: t(`admin_console.sign_in_exp.sign_up_and_sign_in.identifiers_${identifier}`), +})); + +const emailOrPhoneOption = { + value: AlternativeSignUpIdentifier.EmailOrPhone, + label: t('admin_console.sign_in_exp.sign_up_and_sign_in.identifiers_email_or_sms'), +}; + +const signUpIdentifierOptions = [...signInIdentifierOptions, emailOrPhoneOption]; + +function SignUpIdentifiersEditBox() { + const { control } = useFormContext(); + + const signUpIdentifiers = useWatch({ control, name: 'signUp.identifiers' }); + + const { isConnectorTypeEnabled } = useEnabledConnectorTypes(); + + const { fields, swap, remove, append } = useFieldArray({ + control, + name: 'signUp.identifiers', + }); + + const options = useMemo< + Array<{ + value: SignUpIdentifier; + label: string; + }> + >(() => { + const identifiersSet = new Set(signUpIdentifiers.map(({ identifier }) => identifier)); + + return signUpIdentifierOptions.filter(({ value }) => { + // Basic condition: filter out if identifiers include the value + if (identifiersSet.has(value)) { + return false; + } + + // Condition 2: If identifiers include EmailOrPhone, filter out Email and Phone + if ( + identifiersSet.has(AlternativeSignUpIdentifier.EmailOrPhone) && + (value === SignInIdentifier.Email || value === SignInIdentifier.Phone) + ) { + return false; + } + + // Condition 3: If identifiers include Email or Phone, filter out EmailOrPhone + if ( + (identifiersSet.has(SignInIdentifier.Email) || + identifiersSet.has(SignInIdentifier.Phone)) && + value === AlternativeSignUpIdentifier.EmailOrPhone + ) { + return false; + } + + // If none of the conditions matched, keep the value + return true; + }); + }, [signUpIdentifiers]); + + return ( +
+ + {fields.map((data, index) => { + const { id, identifier } = data; + const requiredConnectors = getSignUpIdentifiersRequiredConnectors([identifier]); + + return ( + + { + if ( + requiredConnectors.some( + (connectorType) => !isConnectorTypeEnabled(connectorType) + ) + ) { + return false; + } + + return true; + }, + }} + render={({ + field: { + value: { identifier }, + }, + fieldState: { error }, + }) => ( + { + remove(index); + }} + /> + )} + /> + + ); + })} + + 0} + onSelected={(identifier) => { + append({ identifier }); + }} + /> +
+ ); +} + +export default SignUpIdentifiersEditBox; diff --git a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/index.tsx b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/index.tsx index 4cd264bc79a..825c1a705d7 100644 --- a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/index.tsx +++ b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/SignUpForm/index.tsx @@ -1,7 +1,10 @@ +import { SignInIdentifier } from '@logto/schemas'; import { conditional } from '@silverhand/essentials'; +import { useCallback } from 'react'; import { Controller, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; +import { isDevFeaturesEnabled } from '@/consts/env'; import Card from '@/ds-components/Card'; import Checkbox from '@/ds-components/Checkbox'; import FormField from '@/ds-components/FormField'; @@ -19,11 +22,9 @@ import { } from '../../constants'; import ConnectorSetupWarning from '../components/ConnectorSetupWarning'; import { + createSignInMethod, getSignUpRequiredConnectorTypes, isVerificationRequiredSignUpIdentifiers, - createSignInMethod, - getSignInMethodPasswordCheckState, - getSignInMethodVerificationCodeCheckState, } from '../utils'; import styles from './index.module.scss'; @@ -38,41 +39,42 @@ function SignUpForm() { trigger, formState: { errors, submitCount }, } = useFormContext(); + const { isConnectorTypeEnabled } = useEnabledConnectorTypes(); const signUp = watch('signUp'); - const { identifier: signUpIdentifier } = signUp; - const isUsernamePasswordSignUp = signUpIdentifier === SignUpIdentifier.Username; - const postSignUpIdentifierChange = (signUpIdentifier: SignUpIdentifier) => { - if (signUpIdentifier === SignUpIdentifier.Username) { - setValue('signUp.password', true); - setValue('signUp.verify', false); - - return; - } - - if (signUpIdentifier === SignUpIdentifier.None) { - setValue('signUp.password', false); - setValue('signUp.verify', false); + const postSignUpIdentifierChange = useCallback( + (signUpIdentifier: SignUpIdentifier) => { + if (signUpIdentifier === SignUpIdentifier.Username) { + setValue('signUp.password', true); + setValue('signUp.verify', false); + return; + } - return; - } + if (signUpIdentifier === SignUpIdentifier.None) { + setValue('signUp.password', false); + setValue('signUp.verify', false); + return; + } - if (isVerificationRequiredSignUpIdentifiers(signUpIdentifier)) { - setValue('signUp.verify', true); - } - }; + if (isVerificationRequiredSignUpIdentifiers(signUpIdentifier)) { + setValue('signUp.verify', true); + } + }, + [setValue] + ); const refreshSignInMethods = () => { const signInMethods = getValues('signIn.methods'); - const { identifier: signUpIdentifier } = signUp; + const { verify, password, identifier } = signUp; + const enabledSignUpIdentifiers = signUpIdentifiersMapping[identifier]; - // Note: append required sign-in methods according to the sign-up identifier config - const requiredSignInIdentifiers = signUpIdentifiersMapping[signUpIdentifier]; - const allSignInMethods = requiredSignInIdentifiers.reduce((methods, requiredIdentifier) => { + // Auto append newly assigned sign-up identifiers to the sign-in methods list if they don't already exist + // User may remove them manually if they don't want to use it for sign-in. + const mergedSignInMethods = enabledSignUpIdentifiers.reduce((methods, requiredIdentifier) => { if (signInMethods.some(({ identifier }) => identifier === requiredIdentifier)) { return methods; } @@ -80,20 +82,26 @@ function SignUpForm() { return [...methods, createSignInMethod(requiredIdentifier)]; }, signInMethods); + // Note: if verification is required, but password is not set for sign-up, then + // make sure all the email and phone sign-in methods have verification code enabled. + const isVerificationCodeRequired = verify && !password; + setValue( 'signIn.methods', // Note: refresh sign-in authentications according to the sign-up authentications config - allSignInMethods.map((method) => { - const { identifier, password, verificationCode } = method; + mergedSignInMethods.map((method) => { + const { identifier } = method; + + if (identifier === SignInIdentifier.Username) { + return method; + } return { ...method, - password: getSignInMethodPasswordCheckState(identifier, signUp, password), - verificationCode: getSignInMethodVerificationCodeCheckState( - identifier, - signUp, - verificationCode - ), + // Auto enabled password for email and phone sign-in methods if password is required for sign-up. + // User may disable it manually if they don't want to use password for email or phone sign-in. + password: method.password || password, + verificationCode: isVerificationCodeRequired ? true : method.verificationCode, }; }) ); @@ -170,10 +178,11 @@ function SignUpForm() { render={({ field: { value, onChange } }) => ( { diff --git a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/components/IdentifiersAddButton/index.module.scss b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/components/IdentifiersAddButton/index.module.scss new file mode 100644 index 00000000000..150e426cf44 --- /dev/null +++ b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/components/IdentifiersAddButton/index.module.scss @@ -0,0 +1,7 @@ +.addAnotherIdentifierDropdown { + min-width: 208px; +} + +.addIdentifierDropDown { + min-width: unset; +} diff --git a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/components/IdentifiersAddButton/index.tsx b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/components/IdentifiersAddButton/index.tsx new file mode 100644 index 00000000000..6e4ec740f09 --- /dev/null +++ b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/components/IdentifiersAddButton/index.tsx @@ -0,0 +1,77 @@ +import type { SignInIdentifier, SignUpIdentifier } from '@logto/schemas'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; + +import CirclePlus from '@/assets/icons/circle-plus.svg?react'; +import Plus from '@/assets/icons/plus.svg?react'; +import ActionMenu from '@/ds-components/ActionMenu'; +import type { Props as ButtonProps } from '@/ds-components/Button'; +import { DropdownItem } from '@/ds-components/Dropdown'; + +import styles from './index.module.scss'; + +type MethodsType = 'sign-in' | 'sign-up'; + +type Options = Array<{ + value: T; + label: string; +}>; + +type Props = { + readonly type: MethodsType; + readonly options: Options; + readonly onSelected: (identifier: T) => void; + readonly hasSelectedIdentifiers: boolean; +}; + +function IdentifiersAddButton({ + type, + options, + onSelected, + hasSelectedIdentifiers, +}: Props) { + const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' }); + + if (options.length === 0) { + return null; + } + const addSignInMethodButtonProps: ButtonProps = { + type: 'default', + size: 'medium', + title: `sign_in_exp.sign_up_and_sign_in.sign_in.${ + type === 'sign-in' ? 'add_sign_in_method' : 'add_sign_up_method' + }`, + icon: , + }; + + const addAnotherButtonProps: ButtonProps = { + type: 'text', + size: 'small', + title: 'general.add_another', + icon: , + }; + + return ( + + {options.map(({ value, label }) => ( + { + onSelected(value); + }} + > + {label} + + ))} + + ); +} + +export default IdentifiersAddButton; diff --git a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/index.tsx b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/index.tsx index 9914eff1bcb..4532a137a5c 100644 --- a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/index.tsx +++ b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/index.tsx @@ -1,10 +1,12 @@ import PageMeta from '@/components/PageMeta'; +import { isDevFeaturesEnabled } from '@/consts/env'; import SignInExperienceTabWrapper from '../components/SignInExperienceTabWrapper'; import AdvancedOptions from './AdvancedOptions'; import SignInForm from './SignInForm'; import SignUpForm from './SignUpForm'; +import NewSignUpFrom from './SignUpForm/SignUpForm'; import SocialSignInForm from './SocialSignInForm'; type Props = { @@ -17,7 +19,7 @@ function SignUpAndSignIn({ isActive }: Props) { {isActive && ( )} - + {isDevFeaturesEnabled ? : } diff --git a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/utils.ts b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/utils.ts index 61e6a430e7c..3588948611c 100644 --- a/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/utils.ts +++ b/packages/console/src/pages/SignInExperience/PageContent/SignUpAndSignIn/utils.ts @@ -1,43 +1,15 @@ -import { type ConnectorType, SignInIdentifier } from '@logto/schemas'; +import { + AlternativeSignUpIdentifier, + ConnectorType, + SignInIdentifier, + type SignUpIdentifier as SignUpIdentifierMethod, +} from '@logto/schemas'; -import type { SignUpForm } from '../../types'; -import { SignUpIdentifier } from '../../types'; +import { type SignUpIdentifier } from '../../types'; import { signUpIdentifiersMapping } from '../constants'; import { identifierRequiredConnectorMapping } from './constants'; -export const getSignInMethodPasswordCheckState = ( - signInIdentifier: SignInIdentifier, - signUpConfig: SignUpForm, - currentCheckState: boolean -) => { - if (signInIdentifier === SignInIdentifier.Username) { - return currentCheckState; - } - - const { password: isSignUpPasswordRequired } = signUpConfig; - - return isSignUpPasswordRequired || currentCheckState; -}; - -export const getSignInMethodVerificationCodeCheckState = ( - signInIdentifier: SignInIdentifier, - signUpConfig: SignUpForm, - currentCheckState: boolean -) => { - if (signInIdentifier === SignInIdentifier.Username) { - return currentCheckState; - } - - const { identifier: signUpIdentifier, password: isSignUpPasswordRequired } = signUpConfig; - - if (SignUpIdentifier.None !== signUpIdentifier && !isSignUpPasswordRequired) { - return true; - } - - return currentCheckState; -}; - export const createSignInMethod = (identifier: SignInIdentifier) => ({ identifier, password: true, @@ -45,6 +17,13 @@ export const createSignInMethod = (identifier: SignInIdentifier) => ({ isPasswordPrimary: true, }); +/** + * Check if the verification is required for the given sign-up identifier. + * + * - Email + * - Phone + * - EmailOrSms + */ export const isVerificationRequiredSignUpIdentifiers = (signUpIdentifier: SignUpIdentifier) => { const identifiers = signUpIdentifiersMapping[signUpIdentifier]; @@ -53,6 +32,10 @@ export const isVerificationRequiredSignUpIdentifiers = (signUpIdentifier: SignUp ); }; +/** + * @deprecated + * TODO: replace with the new implementation, once the multi sign-up identifier feature is fully implemented. + */ export const getSignUpRequiredConnectorTypes = ( signUpIdentifier: SignUpIdentifier ): ConnectorType[] => @@ -60,3 +43,32 @@ export const getSignUpRequiredConnectorTypes = ( .map((identifier) => identifierRequiredConnectorMapping[identifier]) // eslint-disable-next-line unicorn/prefer-native-coercion-functions .filter((connectorType): connectorType is ConnectorType => Boolean(connectorType)); + +export const getSignUpIdentifiersRequiredConnectors = ( + signUpIdentifiers: SignUpIdentifierMethod[] +): ConnectorType[] => { + const requiredConnectors = new Set(); + + for (const signUpIdentifier of signUpIdentifiers) { + switch (signUpIdentifier) { + case SignInIdentifier.Email: { + requiredConnectors.add(ConnectorType.Email); + continue; + } + case SignInIdentifier.Phone: { + requiredConnectors.add(ConnectorType.Sms); + continue; + } + case AlternativeSignUpIdentifier.EmailOrPhone: { + requiredConnectors.add(ConnectorType.Email); + requiredConnectors.add(ConnectorType.Sms); + continue; + } + default: { + continue; + } + } + } + + return Array.from(requiredConnectors); +}; diff --git a/packages/console/src/pages/SignInExperience/PageContent/constants.ts b/packages/console/src/pages/SignInExperience/PageContent/constants.ts index eb5d8fb4f0a..7e464af6b1a 100644 --- a/packages/console/src/pages/SignInExperience/PageContent/constants.ts +++ b/packages/console/src/pages/SignInExperience/PageContent/constants.ts @@ -7,6 +7,10 @@ export const signUpIdentifiers = Object.values(SignUpIdentifier); export const signInIdentifiers = Object.values(SignInIdentifier); +/** + * @deprecated + * TODO: remove this once the multi sign-up identifier feature is fully implemented. + */ export const signUpIdentifiersMapping: { [key in SignUpIdentifier]: SignInIdentifier[] } = { [SignUpIdentifier.Username]: [SignInIdentifier.Username], [SignUpIdentifier.Email]: [SignInIdentifier.Email], diff --git a/packages/console/src/pages/SignInExperience/PageContent/utils/parser.ts b/packages/console/src/pages/SignInExperience/PageContent/utils/parser.ts index b7b1a255acb..86cb316ca08 100644 --- a/packages/console/src/pages/SignInExperience/PageContent/utils/parser.ts +++ b/packages/console/src/pages/SignInExperience/PageContent/utils/parser.ts @@ -1,23 +1,29 @@ import { passwordPolicyGuard } from '@logto/core-kit'; import { + AlternativeSignUpIdentifier, + SignInIdentifier, SignInMode, type SignInExperience, type SignUp, - type SignInIdentifier, } from '@logto/schemas'; -import { isSameArray } from '@silverhand/essentials'; +import { conditional, isSameArray } from '@silverhand/essentials'; +import { isDevFeaturesEnabled } from '@/consts/env'; import { emptyBranding } from '@/types/sign-in-experience'; import { removeFalsyValues } from '@/utils/object'; import { + type SignUpIdentifier, type UpdateSignInExperienceData, type SignInExperienceForm, type SignUpForm, - type SignUpIdentifier, } from '../../types'; import { signUpIdentifiersMapping } from '../constants'; +/** + * @deprecated + * TODO: remove this once the multi sign-up identifier feature is fully implemented. + */ const mapIdentifiersToSignUpIdentifier = (identifiers: SignInIdentifier[]): SignUpIdentifier => { for (const [signUpIdentifier, mappedIdentifiers] of Object.entries(signUpIdentifiersMapping)) { if (isSameArray(identifiers, mappedIdentifiers)) { @@ -28,17 +34,101 @@ const mapIdentifiersToSignUpIdentifier = (identifiers: SignInIdentifier[]): Sign throw new Error('Invalid identifiers in the sign up settings.'); }; +/** + * For backward compatibility, + * we need to safely parse the @see {SignUp['identifiers']} to the @see {SignUpForm['identifiers']} format. + */ +const parsePrimaryIdentifier = (identifiers: SignInIdentifier[]): SignUpForm['identifiers'] => { + if (identifiers.length === 0) { + return []; + } + + if (identifiers.length === 1 && identifiers[0]) { + return [ + { + identifier: identifiers[0], + }, + ]; + } + + if ( + identifiers.length === 2 && + identifiers.includes(SignInIdentifier.Email) && + identifiers.includes(SignInIdentifier.Phone) + ) { + return [ + { + identifier: AlternativeSignUpIdentifier.EmailOrPhone, + }, + ]; + } + + throw new Error('Invalid identifiers in the sign up settings.'); +}; + +const signUpIdentifiersParser = { + /** + * Merge the @see {SignUp['identifiers']} with the @see {SignUp['secondaryIdentifiers']} + * into one @see {SignUpForm['identifiers']} form field. + */ + toSignUpForm: ( + identifiers: SignInIdentifier[], + secondaryIdentifiers: SignUp['secondaryIdentifiers'] = [] + ): SignUpForm['identifiers'] => { + const primarySignUpIdentifier = parsePrimaryIdentifier(identifiers); + return [ + ...primarySignUpIdentifier, + ...secondaryIdentifiers.map(({ identifier }) => ({ identifier })), + ]; + }, + /** + * For backward compatibility, + * we need to split the @see {SignUpForm['identifiers']} into @see {SignUp['identifiers']} + * and @see {SignUp['secondaryIdentifiers']} two fields. + */ + toSieData: ( + signUpIdentifiers: SignUpForm['identifiers'] + ): Pick => { + const primaryIdentifier = signUpIdentifiers[0]; + + const identifiers = primaryIdentifier + ? primaryIdentifier.identifier === AlternativeSignUpIdentifier.EmailOrPhone + ? [SignInIdentifier.Email, SignInIdentifier.Phone] + : [primaryIdentifier.identifier] + : []; + + const secondaryIdentifiers = signUpIdentifiers.slice(1).map(({ identifier }) => ({ + identifier, + // For email or phone, we always set the `verify` flag to true. + ...conditional(identifier !== SignInIdentifier.Username && { verify: true }), + })); + + return { + identifiers, + secondaryIdentifiers, + }; + }, +}; + export const signUpFormDataParser = { fromSignUp: (data: SignUp): SignUpForm => { - const { identifiers, ...signUpData } = data; + const { identifiers, secondaryIdentifiers, ...signUpData } = data; return { identifier: mapIdentifiersToSignUpIdentifier(identifiers), + identifiers: signUpIdentifiersParser.toSignUpForm(identifiers, secondaryIdentifiers), ...signUpData, }; }, toSignUp: (formData: SignUpForm): SignUp => { - const { identifier, ...signUpFormData } = formData; + const { identifier, identifiers, ...signUpFormData } = formData; + + if (isDevFeaturesEnabled) { + return { + ...signUpIdentifiersParser.toSieData(identifiers), + ...signUpFormData, + }; + } return { identifiers: signUpIdentifiersMapping[identifier], diff --git a/packages/console/src/pages/SignInExperience/types.ts b/packages/console/src/pages/SignInExperience/types.ts index 0a3a1a943fb..baf92fb127a 100644 --- a/packages/console/src/pages/SignInExperience/types.ts +++ b/packages/console/src/pages/SignInExperience/types.ts @@ -1,5 +1,10 @@ import { type PasswordPolicy } from '@logto/core-kit'; -import { type SignUp, type SignInExperience, type SignInIdentifier } from '@logto/schemas'; +import { + type SignUp, + type SignInExperience, + type SignInIdentifier, + type SignUpIdentifier as SignUpIdentifierMethod, +} from '@logto/schemas'; export enum SignInExperienceTab { Branding = 'branding', @@ -8,6 +13,9 @@ export enum SignInExperienceTab { PasswordPolicy = 'password-policy', } +/** + * @deprecated + */ export enum SignUpIdentifier { Email = 'email', Phone = 'phone', @@ -16,8 +24,24 @@ export enum SignUpIdentifier { None = 'none', } -export type SignUpForm = Omit & { +export type SignUpForm = Omit & { + /** + * TODO: remove this field after the multi sign-up identifier feature is fully implemented. + * @deprecated + */ identifier: SignUpIdentifier; + /** + * New identifiers field that merges the `signUpIdentifier` and `secondaryIdentifiers` fields + **/ + identifiers: Array<{ + /** + * Wrapped the identifier value into an object to make it manageable using the `useFieldArray` hook. + * `useFieldArray` requires the array item to be an object. + * Also for the future benefit, we may add `verify` field to the identifier object, once we support + * unverified email/phone as the sign-up identifier. + */ + identifier: SignUpIdentifierMethod; + }>; }; export type SignInExperienceForm = Omit< diff --git a/packages/core/src/libraries/sign-in-experience/sign-in.test.ts b/packages/core/src/libraries/sign-in-experience/sign-in.test.ts index b7a26d6312c..197a06ebd29 100644 --- a/packages/core/src/libraries/sign-in-experience/sign-in.test.ts +++ b/packages/core/src/libraries/sign-in-experience/sign-in.test.ts @@ -113,33 +113,6 @@ describe('validate sign-in', () => { }); }); - it('throws when sign up requires set a password and sign in password is not enabled', () => { - expect(() => { - validateSignIn( - { - methods: [ - { - ...mockSignInMethod, - identifier: SignInIdentifier.Email, - password: false, - verificationCode: true, - }, - ], - }, - { - ...mockSignUp, - identifiers: [SignInIdentifier.Email], - password: true, - }, - enabledConnectors - ); - }).toMatchError( - new RequestError({ - code: 'sign_in_experiences.password_sign_in_must_be_enabled', - }) - ); - }); - it('throws when sign up only requires verify and sign in verification code is not enabled', () => { expect(() => { validateSignIn( diff --git a/packages/core/src/libraries/sign-in-experience/sign-in.ts b/packages/core/src/libraries/sign-in-experience/sign-in.ts index ce25da07201..8e79265bb18 100644 --- a/packages/core/src/libraries/sign-in-experience/sign-in.ts +++ b/packages/core/src/libraries/sign-in-experience/sign-in.ts @@ -47,15 +47,6 @@ export const validateSignIn = ( }) ); - if (signUp.password) { - assertThat( - signIn.methods.every(({ password }) => password), - new RequestError({ - code: 'sign_in_experiences.password_sign_in_must_be_enabled', - }) - ); - } - if (signUp.verify && !signUp.password) { assertThat( signIn.methods.every( diff --git a/packages/integration-tests/src/tests/console/sign-in-experience/sign-up-and-sign-in/happy-path.test.ts b/packages/integration-tests/src/tests/console/sign-in-experience/sign-up-and-sign-in/happy-path.test.ts index 1633fee8f89..f64b7724848 100644 --- a/packages/integration-tests/src/tests/console/sign-in-experience/sign-up-and-sign-in/happy-path.test.ts +++ b/packages/integration-tests/src/tests/console/sign-in-experience/sign-up-and-sign-in/happy-path.test.ts @@ -7,7 +7,7 @@ import { goToAdminConsole, waitForToast, } from '#src/ui-helpers/index.js'; -import { expectNavigation, appendPathname } from '#src/utils.js'; +import { expectNavigation, appendPathname, devFeatureDisabledTest } from '#src/utils.js'; import { expectToSaveSignInExperience, waitForFormCard } from '../helpers.js'; @@ -70,7 +70,7 @@ describe('sign-in experience(happy path): sign-up and sign-in', () => { await waitForFormCard(page, 'SOCIAL SIGN-IN'); }); - describe('email as sign-up identifier (verify only)', () => { + devFeatureDisabledTest.describe('email as sign-up identifier (verify only)', () => { afterAll(async () => { await expectToResetSignUpAndSignInConfig(page); }); @@ -165,7 +165,7 @@ describe('sign-in experience(happy path): sign-up and sign-in', () => { }); }); - describe('email as sign-up identifier (password & verify)', () => { + devFeatureDisabledTest.describe('email as sign-up identifier (password & verify)', () => { afterAll(async () => { await expectToResetSignUpAndSignInConfig(page); }); @@ -245,7 +245,7 @@ describe('sign-in experience(happy path): sign-up and sign-in', () => { }); }); - describe('phone as sign-up identifier (verify only)', () => { + devFeatureDisabledTest.describe('phone as sign-up identifier (verify only)', () => { afterAll(async () => { await expectToResetSignUpAndSignInConfig(page); }); @@ -312,7 +312,7 @@ describe('sign-in experience(happy path): sign-up and sign-in', () => { }); }); - describe('phone as sign-up identifier (password & verify)', () => { + devFeatureDisabledTest.describe('phone as sign-up identifier (password & verify)', () => { afterAll(async () => { await expectToResetSignUpAndSignInConfig(page); }); @@ -357,7 +357,7 @@ describe('sign-in experience(happy path): sign-up and sign-in', () => { }); }); - describe('email or phone as sign-up identifier (verify only)', () => { + devFeatureDisabledTest.describe('email or phone as sign-up identifier (verify only)', () => { afterAll(async () => { await expectToResetSignUpAndSignInConfig(page); }); @@ -419,68 +419,71 @@ describe('sign-in experience(happy path): sign-up and sign-in', () => { }); }); - describe('email or phone as sign-up identifier (password & verify)', () => { - afterAll(async () => { - await expectToResetSignUpAndSignInConfig(page); - }); - - it('select email or phone as sign-up identifier and enable password settings for sign-up', async () => { - await expectToSelectSignUpIdentifier(page, 'Email address or phone number'); - // Username will be added in later tests - await expectToRemoveSignInMethod(page, 'Username'); - - /** - * Sign-in method - * - Email address: password + verification code - * - Phone number: password + verification code - */ - await expectToSaveSignInExperience(page, { needToConfirmChanges: true }); - }); - - it('update sign-in method configs', async () => { - /** - * Sign-in method - * - Email address: verification code + password - * - Phone number: verification code + password - */ - await expectToSwapSignInMethodAuthnOption(page, 'Email address'); - await expectToSwapSignInMethodAuthnOption(page, 'Phone number'); - await expectToSaveSignInExperience(page); - - /** - * Sign-in method - * - Email address: password - * - Phone number: verification code + password - */ - await expectToClickSignInMethodAuthnOption(page, { - method: 'Email address', - option: 'Verification code', + devFeatureDisabledTest.describe( + 'email or phone as sign-up identifier (password & verify)', + () => { + afterAll(async () => { + await expectToResetSignUpAndSignInConfig(page); }); - await expectToSaveSignInExperience(page, { needToConfirmChanges: true }); - /** - * Sign-in method - * - Email address: password - * - Phone number: password - */ - await expectToClickSignInMethodAuthnOption(page, { - method: 'Phone number', - option: 'Verification code', + it('select email or phone as sign-up identifier and enable password settings for sign-up', async () => { + await expectToSelectSignUpIdentifier(page, 'Email address or phone number'); + // Username will be added in later tests + await expectToRemoveSignInMethod(page, 'Username'); + + /** + * Sign-in method + * - Email address: password + verification code + * - Phone number: password + verification code + */ + await expectToSaveSignInExperience(page, { needToConfirmChanges: true }); }); - await expectToSaveSignInExperience(page, { needToConfirmChanges: true }); - /** - * Sign-in method - * - Email address: password - * - Phone number: password - * - Username: password - */ - await expectToAddSignInMethod(page, 'Username'); - await expectToSaveSignInExperience(page, { needToConfirmChanges: true }); - }); - }); + it('update sign-in method configs', async () => { + /** + * Sign-in method + * - Email address: verification code + password + * - Phone number: verification code + password + */ + await expectToSwapSignInMethodAuthnOption(page, 'Email address'); + await expectToSwapSignInMethodAuthnOption(page, 'Phone number'); + await expectToSaveSignInExperience(page); + + /** + * Sign-in method + * - Email address: password + * - Phone number: verification code + password + */ + await expectToClickSignInMethodAuthnOption(page, { + method: 'Email address', + option: 'Verification code', + }); + await expectToSaveSignInExperience(page, { needToConfirmChanges: true }); + + /** + * Sign-in method + * - Email address: password + * - Phone number: password + */ + await expectToClickSignInMethodAuthnOption(page, { + method: 'Phone number', + option: 'Verification code', + }); + await expectToSaveSignInExperience(page, { needToConfirmChanges: true }); + + /** + * Sign-in method + * - Email address: password + * - Phone number: password + * - Username: password + */ + await expectToAddSignInMethod(page, 'Username'); + await expectToSaveSignInExperience(page, { needToConfirmChanges: true }); + }); + } + ); - describe('not applicable as sign-up identifier', () => { + devFeatureDisabledTest.describe('not applicable as sign-up identifier', () => { afterAll(async () => { await expectToResetSignUpAndSignInConfig(page); }); diff --git a/packages/integration-tests/src/tests/console/sign-in-experience/sign-up-and-sign-in/sad-path.test.ts b/packages/integration-tests/src/tests/console/sign-in-experience/sign-up-and-sign-in/sad-path.test.ts index d2e389cf433..4c9de4a7c3e 100644 --- a/packages/integration-tests/src/tests/console/sign-in-experience/sign-up-and-sign-in/sad-path.test.ts +++ b/packages/integration-tests/src/tests/console/sign-in-experience/sign-up-and-sign-in/sad-path.test.ts @@ -4,7 +4,7 @@ import { goToAdminConsole, expectToSaveChanges, } from '#src/ui-helpers/index.js'; -import { expectNavigation, appendPathname } from '#src/utils.js'; +import { expectNavigation, appendPathname, devFeatureDisabledTest } from '#src/utils.js'; import { expectToSaveSignInExperience, waitForFormCard } from '../helpers.js'; @@ -51,7 +51,7 @@ describe('sign-in experience(sad path): sign-up and sign-in', () => { await waitForFormCard(page, 'SOCIAL SIGN-IN'); }); - describe('cases that no connector is setup', () => { + devFeatureDisabledTest.describe('cases that no connector is setup', () => { describe('email address as sign-up identifier', () => { afterAll(async () => { await expectToResetSignUpAndSignInConfig(page, false); @@ -174,7 +174,7 @@ describe('sign-in experience(sad path): sign-up and sign-in', () => { }); }); - describe('cases that only Email connector is setup', () => { + devFeatureDisabledTest.describe('cases that only Email connector is setup', () => { beforeAll(async () => { // Email connector await expectToSetupPasswordlessConnector(page, testSendgridConnector); @@ -240,7 +240,7 @@ describe('sign-in experience(sad path): sign-up and sign-in', () => { }); }); - describe('cases that only SMS connector is setup', () => { + devFeatureDisabledTest.describe('cases that only SMS connector is setup', () => { beforeAll(async () => { // SMS connector await expectToSetupPasswordlessConnector(page, testTwilioConnector); diff --git a/packages/integration-tests/src/utils.ts b/packages/integration-tests/src/utils.ts index 664bc88774c..697aae8accd 100644 --- a/packages/integration-tests/src/utils.ts +++ b/packages/integration-tests/src/utils.ts @@ -132,3 +132,8 @@ export const devFeatureTest = Object.freeze({ it: isDevFeaturesEnabled ? it : it.skip, describe: isDevFeaturesEnabled ? describe : describe.skip, }); + +export const devFeatureDisabledTest = Object.freeze({ + it: isDevFeaturesEnabled ? it.skip : it, + describe: isDevFeaturesEnabled ? describe.skip : describe, +}); diff --git a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/sign-up-and-sign-in.ts b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/sign-up-and-sign-in.ts index 9d71252f2ba..6f29856cd63 100644 --- a/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/sign-up-and-sign-in.ts +++ b/packages/phrases/src/locales/en/translation/admin-console/sign-in-exp/sign-up-and-sign-in.ts @@ -8,9 +8,8 @@ const sign_up_and_sign_in = { or: 'or', sign_up: { title: 'SIGN UP', - sign_up_identifier: 'Sign-up identifier', - identifier_description: - 'The sign-up identifier is required for account creation and must be included in your sign-in screen.', + sign_up_identifier: 'Sign-up identifiers', + identifier_description: 'The sign-up identifier is required for account creation.', sign_up_authentication: 'Authentication setting for sign-up', authentication_description: 'All selected actions will be obligatory for users to complete the flow.', @@ -24,6 +23,7 @@ const sign_up_and_sign_in = { description: 'Users can sign in using any of the options available. Adjust the layout by drag and dropping below options.', add_sign_in_method: 'Add sign-in method', + add_sign_up_method: 'Add sign-up method', password_auth: 'Password', verification_code_auth: 'Verification code', auth_swap_tip: 'Swap the options below to determine which appears first in the flow.', diff --git a/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts b/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts index 9eb8f6190da..a438f9040d2 100644 --- a/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts +++ b/packages/schemas/src/foundations/jsonb-types/sign-in-experience.ts @@ -57,8 +57,10 @@ export enum AlternativeSignUpIdentifier { EmailOrPhone = 'emailOrPhone', } +export type SignUpIdentifier = SignInIdentifier | AlternativeSignUpIdentifier; + type RequiredSignUpIdentifierSettings = { - identifier: SignInIdentifier | AlternativeSignUpIdentifier; + identifier: SignUpIdentifier; /** * For `email` and `phone` identifiers only. If `true`, the user must verify the email or phone number. */