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

refactor(console): refactor SIE settings form (#7154)

* refactor(core): remove mandatory sign-in password verification rule

remove mandatory sign-in password verification rule

* refactor(console): refactor SIE settings form

refactor SIE settings form

* feat(console): add dev feature guard

* feat(console): add multi sign-up identifiers (#7168)

update SIE page to support multi sign-up identifiers

* chore(console): disable test cases for devFeature

disable test cases for devFeature

* refactor(test): disable some console tests for devFeature

disable some console tests for devFeature

* fix(console): fix emailOrPhone sign-in method sync

fix emailOrPhone sign-in method sync
This commit is contained in:
simeng-li 2025-03-25 09:53:34 +08:00 committed by GitHub
parent e6f315d1b4
commit cdc1acb238
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 821 additions and 190 deletions

View file

@ -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() {
<SignInMethodItem
signInMethod={value}
isPasswordCheckable={
identifier !== SignInIdentifier.Username && !isSignUpPasswordRequired
identifier !== SignInIdentifier.Username &&
(isDevFeaturesEnabled || !isSignUpPasswordRequired)
}
isVerificationCodeCheckable={
!(isSignUpVerificationRequired && !isSignUpPasswordRequired)
}
isDeletable={!requiredSignInIdentifiers.includes(identifier)}
isDeletable={
isDevFeaturesEnabled || !requiredSignInIdentifiers.includes(identifier)
}
requiredConnectors={requiredConnectors}
hasError={Boolean(error)}
errorMessage={error?.message}

View file

@ -0,0 +1,182 @@
import { AlternativeSignUpIdentifier, SignInIdentifier } from '@logto/schemas';
import { useEffect, useMemo } from 'react';
import { Controller, useFormContext, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import Card from '@/ds-components/Card';
import Checkbox from '@/ds-components/Checkbox';
import FormField from '@/ds-components/FormField';
import type { SignInExperienceForm } from '../../../types';
import FormFieldDescription from '../../components/FormFieldDescription';
import FormSectionTitle from '../../components/FormSectionTitle';
import { createSignInMethod } from '../utils';
import SignUpIdentifiersEditBox from './SignUpIdentifiersEditBox';
import styles from './index.module.scss';
function SignUpForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
control,
setValue,
getValues,
trigger,
formState: { submitCount, dirtyFields },
} = useFormContext<SignInExperienceForm>();
// 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<SignInIdentifier[]>(
(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 (
<Card>
<FormSectionTitle title="sign_up_and_sign_in.sign_up.title" />
<FormField title="sign_in_exp.sign_up_and_sign_in.sign_up.sign_up_identifier">
<FormFieldDescription>
{t('sign_in_exp.sign_up_and_sign_in.sign_up.identifier_description')}
</FormFieldDescription>
<SignUpIdentifiersEditBox />
</FormField>
{shouldShowAuthenticationFields && (
<FormField title="sign_in_exp.sign_up_and_sign_in.sign_up.sign_up_authentication">
<FormFieldDescription>
{t('sign_in_exp.sign_up_and_sign_in.sign_up.authentication_description')}
</FormFieldDescription>
<div className={styles.selections}>
<Controller
name="signUp.password"
control={control}
render={({ field: { value, onChange } }) => (
<Checkbox
label={t('sign_in_exp.sign_up_and_sign_in.sign_up.set_a_password_option')}
checked={value}
onChange={onChange}
/>
)}
/>
{shouldShowVerificationField && (
<Controller
name="signUp.verify"
control={control}
render={({ field: { value, onChange } }) => (
<Checkbox
disabled
label={t('sign_in_exp.sign_up_and_sign_in.sign_up.verify_at_sign_up_option')}
checked={value}
tooltip={t('sign_in_exp.sign_up_and_sign_in.tip.verify_at_sign_up')}
onChange={onChange}
/>
)}
/>
)}
</div>
</FormField>
)}
</Card>
);
}
export default SignUpForm;

View file

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

View file

@ -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 (
<div>
<div key={identifier} className={styles.signUpMethodItem}>
<div className={classNames(styles.signUpMethod, hasError && styles.error)}>
<Draggable className={styles.draggableIcon} />
{t(
`sign_in_exp.sign_up_and_sign_in.identifiers_${
identifier === AlternativeSignUpIdentifier.EmailOrPhone ? 'email_or_sms' : identifier
}`
)}
</div>
<IconButton onClick={onDelete}>
<Minus />
</IconButton>
</div>
{errorMessage && <div className={styles.errorMessage}>{errorMessage}</div>}
<ConnectorSetupWarning requiredConnectors={requiredConnectors} />
</div>
);
}
export default SignUpIdentifierItem;

View file

@ -0,0 +1,3 @@
.draggleItemContainer {
transform: translate(0, 0);
}

View file

@ -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<SignInExperienceForm>();
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 (
<div>
<DragDropProvider>
{fields.map((data, index) => {
const { id, identifier } = data;
const requiredConnectors = getSignUpIdentifiersRequiredConnectors([identifier]);
return (
<DraggableItem
key={id}
id={id}
sortIndex={index}
moveItem={swap}
className={styles.draggleItemContainer}
>
<Controller
control={control}
name={`signUp.identifiers.${index}`}
rules={{
validate: () => {
if (
requiredConnectors.some(
(connectorType) => !isConnectorTypeEnabled(connectorType)
)
) {
return false;
}
return true;
},
}}
render={({
field: {
value: { identifier },
},
fieldState: { error },
}) => (
<SignUpIdentifierItem
identifier={identifier}
requiredConnectors={requiredConnectors}
hasError={Boolean(error)}
errorMessage={error?.message}
onDelete={() => {
remove(index);
}}
/>
)}
/>
</DraggableItem>
);
})}
</DragDropProvider>
<IdentifiersAddButton
type="sign-up"
options={options}
hasSelectedIdentifiers={signUpIdentifiers.length > 0}
onSelected={(identifier) => {
append({ identifier });
}}
/>
</div>
);
}
export default SignUpIdentifiersEditBox;

View file

@ -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<SignInExperienceForm>();
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);
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 (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 } }) => (
<Checkbox
label={t('sign_in_exp.sign_up_and_sign_in.sign_up.set_a_password_option')}
disabled={isUsernamePasswordSignUp}
disabled={!isDevFeaturesEnabled && isUsernamePasswordSignUp}
checked={value}
tooltip={conditional(
isUsernamePasswordSignUp &&
!isDevFeaturesEnabled &&
isUsernamePasswordSignUp &&
t('sign_in_exp.sign_up_and_sign_in.tip.set_a_password')
)}
onChange={(value) => {

View file

@ -0,0 +1,7 @@
.addAnotherIdentifierDropdown {
min-width: 208px;
}
.addIdentifierDropDown {
min-width: unset;
}

View file

@ -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<T> = Array<{
value: T;
label: string;
}>;
type Props<T> = {
readonly type: MethodsType;
readonly options: Options<T>;
readonly onSelected: (identifier: T) => void;
readonly hasSelectedIdentifiers: boolean;
};
function IdentifiersAddButton<T extends SignInIdentifier | SignUpIdentifier>({
type,
options,
onSelected,
hasSelectedIdentifiers,
}: Props<T>) {
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: <Plus className={styles.plusIcon} />,
};
const addAnotherButtonProps: ButtonProps = {
type: 'text',
size: 'small',
title: 'general.add_another',
icon: <CirclePlus />,
};
return (
<ActionMenu
buttonProps={hasSelectedIdentifiers ? addAnotherButtonProps : addSignInMethodButtonProps}
dropdownHorizontalAlign="start"
dropdownClassName={classNames(
hasSelectedIdentifiers ? styles.addAnotherIdentifierDropdown : styles.addIdentifierDropDown
)}
isDropdownFullWidth={!hasSelectedIdentifiers}
>
{options.map(({ value, label }) => (
<DropdownItem
key={value}
onClick={() => {
onSelected(value);
}}
>
{label}
</DropdownItem>
))}
</ActionMenu>
);
}
export default IdentifiersAddButton;

View file

@ -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 && (
<PageMeta titleKey={['sign_in_exp.tabs.sign_up_and_sign_in', 'sign_in_exp.page_title']} />
)}
<SignUpForm />
{isDevFeaturesEnabled ? <NewSignUpFrom /> : <SignUpForm />}
<SignInForm />
<SocialSignInForm />
<AdvancedOptions />

View file

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

View file

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

View file

@ -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<SignUp, 'identifiers' | 'secondaryIdentifiers'> => {
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],

View file

@ -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<SignUp, 'identifiers'> & {
export type SignUpForm = Omit<SignUp, 'identifiers' | 'secondaryIdentifiers'> & {
/**
* 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<

View file

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

View file

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

View file

@ -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);
describe('not applicable as sign-up identifier', () => {
/**
* 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 });
});
}
);
devFeatureDisabledTest.describe('not applicable as sign-up identifier', () => {
afterAll(async () => {
await expectToResetSignUpAndSignInConfig(page);
});

View file

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

View file

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

View file

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

View file

@ -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.
*/