0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

feat(core, console, experience): remove dev feature guards (#7211)

* feat(core, console, experience): remove dev feature guards
remove dev feature guards

* fix(console): remove console log

remove console log

* chore: update changeset

update changeset

* chore: update changeset content

update changeset content

* fix(experience): should nav back to email form

should nav back to email form is user cancel the direct sign-in modal.
This commit is contained in:
simeng-li 2025-03-31 15:45:41 +08:00 committed by GitHub
parent e502607ddf
commit 13d04d7766
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 334 additions and 1457 deletions

View file

@ -0,0 +1,100 @@
---
"@logto/schemas": minor
"@logto/core": minor
"@logto/console": minor
"@logto/experience": minor
---
feat: support multiple sign-up identifiers in sign-in experience
## New update
Introduces a new optional field, `secondaryIdentifiers`, to the sign-in experience sign-up settings. This enhancement allows developers to specify multiple required user identifiers during the user sign-up process. Available options include `email`, `phone`, `username` and `emailOrPhone`.
### Explanation of the difference between `signUp.identifiers` and new `signUp.secondaryIdentifiers`
The existing `signUp.identifiers` field represents the sign-up identifiers enabled for user sign-up and is an array type. In this legacy setup, if multiple identifiers are provided, users can complete the sign-up process using any one of them. The only multi-value case allowed is `[email, phone]`, which signifies that users can provide either an email or a phone number.
To enhance flexibility and support multiple required sign-up identifiers, the existing `signUp.identifiers` field does not suffice. To maintain backward compatibility with existing data, we have introduced this new `secondaryIdentifiers` field.
Unlike the `signUp.identifiers` field, the `signUp.secondaryIdentifiers` array follows an `AND` logic, meaning that all elements listed in this field are required during the sign-up process, in addition to the primary identifiers. This new field also accommodates the `emailOrPhone` case by defining an exclusive `emailOrPhone` value type, which indicates that either a phone number or an email address must be provided.
In summary, while `identifiers` allows for optional selection among email and phone, `secondaryIdentifiers` enforces mandatory inclusion of all specified identifiers.
### Examples
1. `username` as the primary identifier. In addition, user will be required to provide a verified `email` and `phone number` during the sign-up process.
```json
{
"identifiers": ["username"],
"secondaryIdentifiers": [
{
"type": "email",
"verify": true
},
{
"type": "phone",
"verify": true
}
],
"verify": true,
"password": true
}
```
2. `username` as the primary identifier. In addition, user will be required to provide either a verified `email` or `phone number` during the sign-up process.
```json
{
"identifiers": ["username"],
"secondaryIdentifiers": [
{
"type": "emailOrPhone",
"verify": true
}
],
"verify": true,
"password": true
}
```
3. `email` or `phone number` as the primary identifier. In addition, user will be required to provide a `username` during the sign-up process.
```json
{
"identifiers": ["email", "phone"],
"secondaryIdentifiers": [
{
"type": "username",
"verify": true
}
],
"verify": true,
"password": false
}
```
### Sign-in experience settings
- `@logto/core`: Update the `/api/sign-in-experience` endpoint to support the new `secondaryIdentifiers` field in the sign-up settings.
- `@logto/console`: Replace the sign-up identifier single selector with a multi-selector to support multiple sign-up identifiers. The order of the identifiers can be rearranged by dragging and dropping the items in the list. The first item in the list will be considered the primary identifier and stored in the `signUp.identifiers` field, while the rest will be stored in the `signUp.secondaryIdentifiers` field.
### End-user experience
The sign-up flow is now split into two stages:
- Primary identifiers (`signUp.identifiers`) are collected in the first-screen registration screen.
- Secondary identifiers (`signUp.secondaryIdentifiers`) are requested in subsequent steps after the primary registration has been submitted.
## Other refactors
We have fully decoupled the sign-up identifier settings from the sign-in methods. Developers can now require as many user identifiers as needed during the sign-up process without impacting the sign-in process.
The following restrictions on sign-in and sign-up settings have been removed:
1. Password requirement is now optional when `username` is configured as a sign-up identifier. However, users without passwords cannot sign in using username authentication.
2. Removed the constraint requiring sign-up identifiers to be enabled as sign-in methods.
3. Removed the requirement for password verification across all sign-in methods when password is enabled for sign-up.

View file

@ -135,7 +135,7 @@ function MfaForm({ data, onMfaUpdated }: Props) {
// Only show the organization MFA policy config for the admin tenant
const showOrganizationMfaPolicyConfig = useMemo(
() => isDevFeaturesEnabled || (isCloud && currentTenantId === adminTenantId),
[]
[currentTenantId]
);
const onSubmit = handleSubmit(

View file

@ -7,7 +7,6 @@ import { useTranslation } from 'react-i18next';
import Draggable from '@/assets/icons/draggable.svg?react';
import Minus from '@/assets/icons/minus.svg?react';
import SwitchArrowIcon from '@/assets/icons/switch-arrow.svg?react';
import { isDevFeaturesEnabled } from '@/consts/env';
import Checkbox from '@/ds-components/Checkbox';
import IconButton from '@/ds-components/IconButton';
import { Tooltip } from '@/ds-components/Tip';
@ -66,12 +65,6 @@ function SignInMethodItem({
label={t('sign_in_exp.sign_up_and_sign_in.sign_in.password_auth')}
checked={password}
disabled={!isPasswordCheckable}
tooltip={conditional(
// TODO:remove the password tool tip
!isDevFeaturesEnabled &&
!isPasswordCheckable &&
t('sign_in_exp.sign_up_and_sign_in.tip.password_auth')
)}
onChange={(checked) => {
onVerificationStateChange('password', checked);
}}

View file

@ -4,18 +4,13 @@ import { useCallback } from 'react';
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 { signInIdentifiers } from '../../../constants';
import { identifierRequiredConnectorMapping } from '../../constants';
import {
getSignUpRequiredConnectorTypes,
createSignInMethod,
getSignUpIdentifiersRequiredConnectors,
} from '../../utils';
import { createSignInMethod, getSignUpIdentifiersRequiredConnectors } from '../../utils';
import AddButton from './AddButton';
import SignInMethodItem from './SignInMethodItem';
@ -47,20 +42,11 @@ function SignInMethodEditBox() {
const { isConnectorTypeEnabled } = useEnabledConnectorTypes();
const {
identifier: signUpIdentifier,
identifiers,
password: isSignUpPasswordRequired,
verify: isSignUpVerificationRequired,
} = signUp;
const { identifiers, password: isSignUpPasswordRequired } = signUp;
const requiredSignInIdentifiers = signUpIdentifiersMapping[signUpIdentifier];
const signUpIdentifiers = identifiers.map(({ identifier }) => identifier);
// TODO: Remove this dev feature guard when multi sign-up identifiers are launched
const ignoredWarningConnectors = isDevFeaturesEnabled
? getSignUpIdentifiersRequiredConnectors(signUpIdentifiers)
: getSignUpRequiredConnectorTypes(signUpIdentifier);
const ignoredWarningConnectors = getSignUpIdentifiersRequiredConnectors(signUpIdentifiers);
const signInIdentifierOptions = signInIdentifiers.filter((candidateIdentifier) =>
fields.every(({ identifier }) => identifier !== candidateIdentifier)
@ -76,10 +62,6 @@ function SignInMethodEditBox() {
return true;
}
if (!isDevFeaturesEnabled) {
return !isSignUpVerificationRequired;
}
// If the email or phone sign-in method is enabled as one of the sign-up identifiers
// and password is not required for sign-up, then verification code is required and uncheckable.
// This is to ensure new users can sign in without password.
@ -91,7 +73,7 @@ function SignInMethodEditBox() {
return !signUpVerificationRequired;
},
[isSignUpPasswordRequired, isSignUpVerificationRequired, signUpIdentifiers]
[isSignUpPasswordRequired, signUpIdentifiers]
);
return (
@ -141,15 +123,10 @@ function SignInMethodEditBox() {
}}
render={({ field: { value }, fieldState: { error } }) => (
<SignInMethodItem
isDeletable
signInMethod={value}
isPasswordCheckable={
identifier !== SignInIdentifier.Username &&
(isDevFeaturesEnabled || !isSignUpPasswordRequired)
}
isPasswordCheckable={identifier !== SignInIdentifier.Username}
isVerificationCodeCheckable={isVerificationCodeCheckable(value.identifier)}
isDeletable={
isDevFeaturesEnabled || !requiredSignInIdentifiers.includes(identifier)
}
requiredConnectors={requiredConnectors}
hasError={Boolean(error)}
errorMessage={error?.message}

View file

@ -1,87 +0,0 @@
import { 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 SignUpIdentifiersEditBox from './SignUpIdentifiersEditBox';
import styles from './index.module.scss';
import useSignUpPasswordListeners from './use-sign-up-password-listeners';
function SignUpForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const { control } = useFormContext<SignInExperienceForm>();
const signUpIdentifiers = useWatch({
control,
name: 'signUp.identifiers',
});
const signUpVerify = useWatch({
control,
name: 'signUp.verify',
});
const showAuthenticationFields = useMemo(
() => signUpIdentifiers.length > 0,
[signUpIdentifiers.length]
);
useSignUpPasswordListeners();
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>
{showAuthenticationFields && (
<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}
/>
)}
/>
{signUpVerify && (
<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}
suffixTooltip={t('sign_in_exp.sign_up_and_sign_in.sign_up.verification_tip')}
onChange={onChange}
/>
)}
/>
)}
</div>
</FormField>
)}
</Card>
);
}
export default SignUpForm;

View file

@ -47,6 +47,18 @@ function SignUpIdentifiersEditBox() {
name: 'signUp.identifiers',
});
// Revalidate the sign-up and sign-in methods,
// when appending or removing sign-up identifiers.
const revalidateForm = useCallback(() => {
if (submitCount > 0) {
// Wait for the form re-render before validating the new data.
setTimeout(() => {
void trigger('signUp.identifiers');
void trigger('signIn.methods');
}, 0);
}
}, [submitCount, trigger]);
/**
* Append the sign-in methods based on the selected sign-up identifier.
*/
@ -75,15 +87,8 @@ function SignUpIdentifiersEditBox() {
shouldDirty: true,
}
);
if (submitCount) {
// Wait for the form re-render before validating the new data.
setTimeout(() => {
void trigger('signIn.methods');
}, 0);
}
},
[getValues, setValue, submitCount, trigger]
[getValues, setValue]
);
const onAppendSignUpIdentifier = useCallback(
@ -195,6 +200,7 @@ function SignUpIdentifiersEditBox() {
errorMessage={error?.message}
onDelete={() => {
remove(index);
revalidateForm();
}}
/>
)}
@ -209,6 +215,7 @@ function SignUpIdentifiersEditBox() {
hasSelectedIdentifiers={signUpIdentifiers.length > 0}
onSelected={(identifier) => {
append({ identifier });
revalidateForm();
onAppendSignUpIdentifier(identifier);
}}
/>

View file

@ -1,119 +1,40 @@
import { SignInIdentifier } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import { useCallback } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useMemo } from 'react';
import { Controller, useFormContext, useWatch } 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';
import Select from '@/ds-components/Select';
import useEnabledConnectorTypes from '@/hooks/use-enabled-connector-types';
import { SignUpIdentifier } from '../../../types';
import type { SignInExperienceForm } from '../../../types';
import FormFieldDescription from '../../components/FormFieldDescription';
import FormSectionTitle from '../../components/FormSectionTitle';
import {
signUpIdentifierPhrase,
signUpIdentifiers,
signUpIdentifiersMapping,
} from '../../constants';
import ConnectorSetupWarning from '../components/ConnectorSetupWarning';
import {
createSignInMethod,
getSignUpRequiredConnectorTypes,
isVerificationRequiredSignUpIdentifiers,
} from '../utils';
import SignUpIdentifiersEditBox from './SignUpIdentifiersEditBox';
import styles from './index.module.scss';
import useSignUpPasswordListeners from './use-sign-up-password-listeners';
function SignUpForm() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
const { control } = useFormContext<SignInExperienceForm>();
const signUpIdentifiers = useWatch({
control,
setValue,
getValues,
watch,
trigger,
formState: { errors, submitCount },
} = useFormContext<SignInExperienceForm>();
name: 'signUp.identifiers',
});
const { isConnectorTypeEnabled } = useEnabledConnectorTypes();
const signUpVerify = useWatch({
control,
name: 'signUp.verify',
});
const signUp = watch('signUp');
const { identifier: signUpIdentifier } = signUp;
const isUsernamePasswordSignUp = signUpIdentifier === SignUpIdentifier.Username;
const postSignUpIdentifierChange = useCallback(
(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);
return;
}
if (isVerificationRequiredSignUpIdentifiers(signUpIdentifier)) {
setValue('signUp.verify', true);
}
},
[setValue]
const showAuthenticationFields = useMemo(
() => signUpIdentifiers.length > 0,
[signUpIdentifiers.length]
);
const refreshSignInMethods = () => {
const signInMethods = getValues('signIn.methods');
const { verify, password, identifier } = signUp;
const enabledSignUpIdentifiers = signUpIdentifiersMapping[identifier];
// 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;
}
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
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,
verificationCode: isVerificationCodeRequired ? true : method.verificationCode,
};
})
);
// Note: we need to revalidate the sign-in methods after we have submitted
if (submitCount) {
// Note: wait for the form to be updated before validating the new data.
setTimeout(() => {
void trigger('signIn.methods');
}, 0);
}
};
useSignUpPasswordListeners();
return (
<Card>
@ -122,51 +43,9 @@ function SignUpForm() {
<FormFieldDescription>
{t('sign_in_exp.sign_up_and_sign_in.sign_up.identifier_description')}
</FormFieldDescription>
<Controller
name="signUp.identifier"
control={control}
rules={{
validate: (value) => {
return getSignUpRequiredConnectorTypes(value).every((connectorType) =>
isConnectorTypeEnabled(connectorType)
);
},
}}
render={({ field: { value, onChange } }) => (
<Select
value={value}
error={Boolean(errors.signUp?.identifier)}
options={signUpIdentifiers.map((identifier) => ({
value: identifier,
title: (
<div>
{t(signUpIdentifierPhrase[identifier])}
{identifier === SignUpIdentifier.None && (
<span className={styles.socialOnlyDescription}>
{t(
'sign_in_exp.sign_up_and_sign_in.sign_up.social_only_creation_description'
)}
</span>
)}
</div>
),
}))}
onChange={(value) => {
if (!value) {
return;
}
onChange(value);
postSignUpIdentifierChange(value);
refreshSignInMethods();
}}
/>
)}
/>
<ConnectorSetupWarning
requiredConnectors={getSignUpRequiredConnectorTypes(signUpIdentifier)}
/>
<SignUpIdentifiersEditBox />
</FormField>
{signUpIdentifier !== SignUpIdentifier.None && (
{showAuthenticationFields && (
<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')}
@ -178,21 +57,12 @@ function SignUpForm() {
render={({ field: { value, onChange } }) => (
<Checkbox
label={t('sign_in_exp.sign_up_and_sign_in.sign_up.set_a_password_option')}
disabled={!isDevFeaturesEnabled && isUsernamePasswordSignUp}
checked={value}
tooltip={conditional(
!isDevFeaturesEnabled &&
isUsernamePasswordSignUp &&
t('sign_in_exp.sign_up_and_sign_in.tip.set_a_password')
)}
onChange={(value) => {
onChange(value);
refreshSignInMethods();
}}
onChange={onChange}
/>
)}
/>
{isVerificationRequiredSignUpIdentifiers(signUpIdentifier) && (
{signUpVerify && (
<Controller
name="signUp.verify"
control={control}
@ -201,11 +71,8 @@ function SignUpForm() {
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={(value) => {
onChange(value);
refreshSignInMethods();
}}
suffixTooltip={t('sign_in_exp.sign_up_and_sign_in.sign_up.verification_tip')}
onChange={onChange}
/>
)}
/>

View file

@ -1,12 +1,10 @@
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 SignUpFrom from './SignUpForm';
import SocialSignInForm from './SocialSignInForm';
type Props = {
@ -19,7 +17,7 @@ function SignUpAndSignIn({ isActive }: Props) {
{isActive && (
<PageMeta titleKey={['sign_in_exp.tabs.sign_up_and_sign_in', 'sign_in_exp.page_title']} />
)}
{isDevFeaturesEnabled ? <NewSignUpFrom /> : <SignUpForm />}
<SignUpFrom />
<SignInForm />
<SocialSignInForm />
<AdvancedOptions />

View file

@ -5,11 +5,6 @@ import {
type SignUpIdentifier as SignUpIdentifierMethod,
} from '@logto/schemas';
import { type SignUpIdentifier } from '../../types';
import { signUpIdentifiersMapping } from '../constants';
import { identifierRequiredConnectorMapping } from './constants';
export const createSignInMethod = (identifier: SignInIdentifier) => ({
identifier,
password: true,
@ -17,33 +12,6 @@ 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];
return (
identifiers.includes(SignInIdentifier.Email) || identifiers.includes(SignInIdentifier.Phone)
);
};
/**
* @deprecated
* TODO: replace with the new implementation, once the multi sign-up identifier feature is fully implemented.
*/
export const getSignUpRequiredConnectorTypes = (
signUpIdentifier: SignUpIdentifier
): ConnectorType[] =>
signUpIdentifiersMapping[signUpIdentifier]
.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[] => {

View file

@ -2,7 +2,6 @@ import { SignInIdentifier, type SignInExperience } from '@logto/schemas';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { isDevFeaturesEnabled } from '@/consts/env';
import InlineNotification from '@/ds-components/InlineNotification';
import { signUpFormDataParser } from '../utils/parser';
@ -18,10 +17,6 @@ function PasswordDisabledNotification({ after, className }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const shouldShowNotification = useMemo(() => {
if (!isDevFeaturesEnabled) {
return false;
}
return (
identifiers.length === 1 &&
identifiers[0]?.identifier === SignInIdentifier.Username &&

View file

@ -3,10 +3,6 @@ import { getSafe } from '@silverhand/essentials';
import { diff } from 'deep-object-diff';
import { useTranslation } from 'react-i18next';
import DynamicT from '@/ds-components/DynamicT';
import type { SignUpForm } from '../../../types';
import { signUpIdentifierPhrase } from '../../constants';
import { signUpFormDataParser } from '../../utils/parser';
import DiffSegment from './DiffSegment';
@ -18,54 +14,7 @@ type Props = {
readonly isAfter?: boolean;
};
/**
* @deprecated
* TODO: replace with NewSignUpDiffSection after dev features are ready
*/
function SignUpDiffSection({ before, after, isAfter = false }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const parsedBefore = signUpFormDataParser.fromSignUp(before);
const parsedAfter = signUpFormDataParser.fromSignUp(after);
const signUpDiff = isAfter ? diff(parsedBefore, parsedAfter) : diff(parsedAfter, parsedBefore);
const signUp = isAfter ? parsedAfter : parsedBefore;
const hasChanged = (path: keyof SignUpForm) => getSafe(signUpDiff, path) !== undefined;
const { identifier, password, verify } = signUp;
const hasAuthentication = password || verify;
const needConjunction = password && verify;
return (
<div>
<div className={styles.title}>{t('sign_in_exp.save_alert.sign_up')}</div>
<ul className={styles.list}>
<li>
<DiffSegment hasChanged={hasChanged('identifier')} isAfter={isAfter}>
<DynamicT forKey={signUpIdentifierPhrase[identifier]} />
</DiffSegment>
{hasAuthentication && ' ('}
{password && (
<DiffSegment hasChanged={hasChanged('password')} isAfter={isAfter}>
{t('sign_in_exp.sign_up_and_sign_in.sign_up.set_a_password_option')}
</DiffSegment>
)}
{needConjunction && ` ${t('sign_in_exp.sign_up_and_sign_in.and')} `}
{verify && (
<DiffSegment hasChanged={hasChanged('verify')} isAfter={isAfter}>
{needConjunction
? t(
'sign_in_exp.sign_up_and_sign_in.sign_up.verify_at_sign_up_option'
).toLowerCase()
: t('sign_in_exp.sign_up_and_sign_in.sign_up.verify_at_sign_up_option')}
</DiffSegment>
)}
{hasAuthentication && ')'}
</li>
</ul>
</div>
);
}
export function NewSignUpDiffSection({ before, after, isAfter = false }: Props) {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const parsedBefore = signUpFormDataParser.fromSignUp(before);
const parsedAfter = signUpFormDataParser.fromSignUp(after);

View file

@ -1,9 +1,7 @@
import type { SignInExperience } from '@logto/schemas';
import { isDevFeaturesEnabled } from '@/consts/env';
import SignInDiffSection from './SignInDiffSection';
import SignUpDiffSection, { NewSignUpDiffSection } from './SignUpDiffSection';
import SignUpDiffSection from './SignUpDiffSection';
import SocialTargetsDiffSection from './SocialTargetsDiffSection';
type Props = {
@ -15,11 +13,7 @@ type Props = {
function SignUpAndSignInDiffSection({ before, after, isAfter = false }: Props) {
return (
<>
{isDevFeaturesEnabled ? (
<NewSignUpDiffSection before={before.signUp} after={after.signUp} isAfter={isAfter} />
) : (
<SignUpDiffSection before={before.signUp} after={after.signUp} isAfter={isAfter} />
)}
<SignUpDiffSection before={before.signUp} after={after.signUp} isAfter={isAfter} />
<SignInDiffSection
before={before.signIn.methods}
after={after.signIn.methods}

View file

@ -1,24 +1,10 @@
import type { AdminConsoleKey } from '@logto/phrases';
import { SignInIdentifier } from '@logto/schemas';
import { SignUpIdentifier } from '../types';
export const signUpIdentifiers = Object.values(SignUpIdentifier);
import { type SignUpIdentifier } from '../types';
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],
[SignUpIdentifier.Phone]: [SignInIdentifier.Phone],
[SignUpIdentifier.EmailOrSms]: [SignInIdentifier.Email, SignInIdentifier.Phone],
[SignUpIdentifier.None]: [],
};
type SignInIdentifierPhrase = {
[key in SignInIdentifier]: AdminConsoleKey;
};
@ -32,11 +18,3 @@ export const signInIdentifierPhrase = Object.freeze({
type SignUpIdentifierPhrase = {
[key in SignUpIdentifier]: AdminConsoleKey;
};
export const signUpIdentifierPhrase = Object.freeze({
[SignUpIdentifier.Email]: 'sign_in_exp.sign_up_and_sign_in.identifiers_email',
[SignUpIdentifier.Phone]: 'sign_in_exp.sign_up_and_sign_in.identifiers_phone',
[SignUpIdentifier.Username]: 'sign_in_exp.sign_up_and_sign_in.identifiers_username',
[SignUpIdentifier.EmailOrSms]: 'sign_in_exp.sign_up_and_sign_in.identifiers_email_or_sms',
[SignUpIdentifier.None]: 'sign_in_exp.sign_up_and_sign_in.identifiers_none',
}) satisfies SignUpIdentifierPhrase;

View file

@ -3,7 +3,6 @@ import { diff } from 'deep-object-diff';
import type { DeepRequired, FieldErrorsImpl } from 'react-hook-form';
import type { SignInExperienceForm, SignInMethod, SignInMethodsObject } from '../../types';
import { SignUpIdentifier } from '../../types';
export const convertToSignInMethodsObject = (signInMethods: SignInMethod[]): SignInMethodsObject =>
signInMethods.reduce<SignInMethodsObject>(
@ -54,16 +53,12 @@ export const getSignUpAndSignInErrorCount = (
errors: FieldErrorsImpl<DeepRequired<SignInExperienceForm>>,
formData: SignInExperienceForm
) => {
const signUpIdentifier = formData.signUp.identifier;
/**
* Note: we treat the `emailOrSms` sign-up identifier as 2 errors when it's invalid.
*/
const signUpIdentifierRelatedErrorCount =
signUpIdentifier === SignUpIdentifier.EmailOrSms ? 2 : 1;
const { signUp, signIn } = errors;
const signUpErrorCount = signUp?.identifier ? signUpIdentifierRelatedErrorCount : 0;
const signUpIdentifiersError = signUp?.identifiers;
const signUpErrorCount = Array.isArray(signUpIdentifiersError)
? signUpIdentifiersError.filter(Boolean).length
: 0;
const signInMethodErrors = signIn?.methods;

View file

@ -6,33 +6,16 @@ import {
type SignInExperience,
type SignUp,
} from '@logto/schemas';
import { conditional, isSameArray } from '@silverhand/essentials';
import { conditional } 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,
} 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)) {
// eslint-disable-next-line no-restricted-syntax
return signUpIdentifier as SignUpIdentifier;
}
}
throw new Error('Invalid identifiers in the sign up settings.');
};
/**
* For backward compatibility,
@ -115,23 +98,15 @@ export const signUpFormDataParser = {
const { identifiers, secondaryIdentifiers, ...signUpData } = data;
return {
identifier: mapIdentifiersToSignUpIdentifier(identifiers),
identifiers: signUpIdentifiersParser.toSignUpForm(identifiers, secondaryIdentifiers),
...signUpData,
};
},
toSignUp: (formData: SignUpForm): SignUp => {
const { identifier, identifiers, ...signUpFormData } = formData;
if (isDevFeaturesEnabled) {
return {
...signUpIdentifiersParser.toSieData(identifiers),
...signUpFormData,
};
}
const { identifiers, ...signUpFormData } = formData;
return {
identifiers: signUpIdentifiersMapping[identifier],
...signUpIdentifiersParser.toSieData(identifiers),
...signUpFormData,
};
},

View file

@ -25,11 +25,6 @@ export enum SignUpIdentifier {
}
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
**/

View file

@ -8,7 +8,6 @@ import {
VerificationType,
} from '@logto/schemas';
import { EnvSet } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import type Libraries from '#src/tenants/Libraries.js';
import type Queries from '#src/tenants/Queries.js';
@ -173,27 +172,23 @@ export class SignInExperienceValidator {
mandatoryUserProfile.add(mandatoryPrimaryIdentifier);
}
// TODO: Remove this dev feature check
// Check for mandatory secondary identifiers
if (EnvSet.values.isDevFeaturesEnabled) {
for (const { identifier } of secondaryIdentifiers) {
switch (identifier) {
case SignInIdentifier.Email: {
mandatoryUserProfile.add(MissingProfile.email);
continue;
}
case SignInIdentifier.Phone: {
mandatoryUserProfile.add(MissingProfile.phone);
continue;
}
case SignInIdentifier.Username: {
mandatoryUserProfile.add(MissingProfile.username);
continue;
}
case AlternativeSignUpIdentifier.EmailOrPhone: {
mandatoryUserProfile.add(MissingProfile.emailOrPhone);
continue;
}
for (const { identifier } of secondaryIdentifiers) {
switch (identifier) {
case SignInIdentifier.Email: {
mandatoryUserProfile.add(MissingProfile.email);
continue;
}
case SignInIdentifier.Phone: {
mandatoryUserProfile.add(MissingProfile.phone);
continue;
}
case SignInIdentifier.Username: {
mandatoryUserProfile.add(MissingProfile.username);
continue;
}
case AlternativeSignUpIdentifier.EmailOrPhone: {
mandatoryUserProfile.add(MissingProfile.emailOrPhone);
continue;
}
}
}

View file

@ -37,6 +37,9 @@
},
"verify": {
"description": "Whether the user is required to verify their email/phone when signing-up."
},
"secondaryIdentifiers": {
"description": "Additional identifiers required during sign-up. Once specified, users will be prompted to provide these identifiers when creating an account."
}
}
},

View file

@ -1,12 +1,11 @@
import type { VerificationCodeIdentifier } from '@logto/schemas';
import { InteractionEvent, VerificationType } from '@logto/schemas';
import { useCallback, useContext, useMemo } from 'react';
import { useLocation, useSearchParams } from 'react-router-dom';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import UserInteractionContext from '@/Providers/UserInteractionContextProvider/UserInteractionContext';
import { updateProfileWithVerificationCode } from '@/apis/experience';
import { getInteractionEventFromState } from '@/apis/utils';
import { isDevFeaturesEnabled } from '@/constants/env';
import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-error-handler';
import useErrorHandler from '@/hooks/use-error-handler';
@ -27,6 +26,7 @@ const useContinueFlowCodeVerification = (
) => {
const [searchParameters] = useSearchParams();
const redirectTo = useGlobalRedirectTo();
const navigate = useNavigate();
const { state } = useLocation();
const { verificationIdsMap } = useContext(UserInteractionContext);
@ -64,18 +64,22 @@ const useContinueFlowCodeVerification = (
const { type, value } = identifier;
// TODO: remove this dev feature check after the feature is fully implemented
// This is to ensure a consistent user experience during the registration process.
// If email or phone number has been enabled as additional sign-up identifiers,
// and user trying to provide an email/phone number that already exists in the system,
// prompt the user to sign in with the existing identifier.
// @see {user-register-flow-code-verification.ts} for more details.
if (
isDevFeaturesEnabled &&
interactionEvent === InteractionEvent.Register &&
isVerificationCodeEnabledForSignIn(type)
) {
showSignInWithExistIdentifierConfirmModal({ identifier, verificationId });
showSignInWithExistIdentifierConfirmModal({
identifier,
verificationId,
onCanceled: () => {
navigate(-1);
},
});
return;
}

View file

@ -11,7 +11,6 @@ import { useContext, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import PageContext from '@/Providers/PageContextProvider/PageContext';
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';
@ -70,17 +69,11 @@ export const useSieMethods = (): UseSieMethodsReturnType => {
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);
return (
signUpMethods.includes(type) ||
secondaryIdentifiers.includes(type) ||
secondaryIdentifiers.includes(AlternativeSignUpIdentifier.EmailOrPhone)
);
},
[secondaryIdentifiers, signUpMethods, experienceSettings]
);

View file

@ -21,7 +21,7 @@ import {
import { expectRejects } from '#src/helpers/index.js';
import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js';
import { generateNewUser, generateNewUserProfile, UserApiTest } from '#src/helpers/user.js';
import { devFeatureTest, generateUsername } from '#src/utils.js';
import { generateUsername } from '#src/utils.js';
const verificationIdentifierType: readonly [SignInIdentifier.Email, SignInIdentifier.Phone] =
Object.freeze([SignInIdentifier.Email, SignInIdentifier.Phone]);
@ -106,140 +106,137 @@ describe('register new user with username and password', () => {
});
});
devFeatureTest.describe(
'register new user with username and password with secondary identifiers',
() => {
beforeAll(async () => {
await Promise.all([setEmailConnector(), setSmsConnector()]);
});
describe('register new user with username and password with secondary identifiers', () => {
beforeAll(async () => {
await Promise.all([setEmailConnector(), setSmsConnector()]);
});
it.each([SignInIdentifier.Email, AlternativeSignUpIdentifier.EmailOrPhone])(
'set %s as secondary identifier',
async (secondaryIdentifier) => {
await enableAllVerificationCodeSignInMethods({
identifiers: [SignInIdentifier.Username],
password: true,
verify: true,
secondaryIdentifiers: [
{
identifier: secondaryIdentifier,
verify: true,
},
],
});
const { username, password, primaryEmail } = generateNewUserProfile({
username: true,
password: true,
primaryEmail: true,
});
const client = await initExperienceClient({
interactionEvent: InteractionEvent.Register,
});
await client.updateProfile({ type: SignInIdentifier.Username, value: username });
await client.updateProfile({ type: 'password', value: password });
await expectRejects(client.identifyUser(), {
status: 422,
code: 'user.missing_profile',
});
await fulfillUserEmail(client, primaryEmail);
await client.identifyUser();
const { redirectTo } = await client.submitInteraction();
const userId = await processSession(client, redirectTo);
await logoutClient(client);
await signInWithPassword({
identifier: {
type: SignInIdentifier.Username,
value: username,
},
password,
});
await signInWithPassword({
identifier: {
type: SignInIdentifier.Email,
value: primaryEmail,
},
password,
});
await deleteUser(userId);
}
);
it.each(verificationIdentifierType)(
'should fail to sign-up with existing %s as secondary identifier, and directly sign-in instead',
async (identifierType) => {
await enableAllVerificationCodeSignInMethods({
identifiers: [SignInIdentifier.Username],
password: true,
verify: true,
secondaryIdentifiers: [
{
identifier: AlternativeSignUpIdentifier.EmailOrPhone,
verify: true,
},
],
});
const { userProfile, user } = await generateNewUser({
[identifiersTypeToUserProfile[identifierType]]: true,
username: true,
password: true,
});
const client = await initExperienceClient({
interactionEvent: InteractionEvent.Register,
});
await client.updateProfile({ type: SignInIdentifier.Username, value: generateUsername() });
const identifier: VerificationCodeIdentifier = {
type: identifierType,
value: userProfile[identifiersTypeToUserProfile[identifierType]]!,
};
const { verificationId, code } = await successfullySendVerificationCode(client, {
identifier,
interactionEvent: InteractionEvent.Register,
});
await successfullyVerifyVerificationCode(client, {
identifier,
verificationId,
code,
});
await expectRejects(
client.identifyUser({
verificationId,
}),
it.each([SignInIdentifier.Email, AlternativeSignUpIdentifier.EmailOrPhone])(
'set %s as secondary identifier',
async (secondaryIdentifier) => {
await enableAllVerificationCodeSignInMethods({
identifiers: [SignInIdentifier.Username],
password: true,
verify: true,
secondaryIdentifiers: [
{
code: `user.${identifierType}_already_in_use`,
status: 422,
}
);
identifier: secondaryIdentifier,
verify: true,
},
],
});
await client.updateInteractionEvent({
interactionEvent: InteractionEvent.SignIn,
});
const { username, password, primaryEmail } = generateNewUserProfile({
username: true,
password: true,
primaryEmail: true,
});
await client.identifyUser({
const client = await initExperienceClient({
interactionEvent: InteractionEvent.Register,
});
await client.updateProfile({ type: SignInIdentifier.Username, value: username });
await client.updateProfile({ type: 'password', value: password });
await expectRejects(client.identifyUser(), {
status: 422,
code: 'user.missing_profile',
});
await fulfillUserEmail(client, primaryEmail);
await client.identifyUser();
const { redirectTo } = await client.submitInteraction();
const userId = await processSession(client, redirectTo);
await logoutClient(client);
await signInWithPassword({
identifier: {
type: SignInIdentifier.Username,
value: username,
},
password,
});
await signInWithPassword({
identifier: {
type: SignInIdentifier.Email,
value: primaryEmail,
},
password,
});
await deleteUser(userId);
}
);
it.each(verificationIdentifierType)(
'should fail to sign-up with existing %s as secondary identifier, and directly sign-in instead',
async (identifierType) => {
await enableAllVerificationCodeSignInMethods({
identifiers: [SignInIdentifier.Username],
password: true,
verify: true,
secondaryIdentifiers: [
{
identifier: AlternativeSignUpIdentifier.EmailOrPhone,
verify: true,
},
],
});
const { userProfile, user } = await generateNewUser({
[identifiersTypeToUserProfile[identifierType]]: true,
username: true,
password: true,
});
const client = await initExperienceClient({
interactionEvent: InteractionEvent.Register,
});
await client.updateProfile({ type: SignInIdentifier.Username, value: generateUsername() });
const identifier: VerificationCodeIdentifier = {
type: identifierType,
value: userProfile[identifiersTypeToUserProfile[identifierType]]!,
};
const { verificationId, code } = await successfullySendVerificationCode(client, {
identifier,
interactionEvent: InteractionEvent.Register,
});
await successfullyVerifyVerificationCode(client, {
identifier,
verificationId,
code,
});
await expectRejects(
client.identifyUser({
verificationId,
});
}),
{
code: `user.${identifierType}_already_in_use`,
status: 422,
}
);
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
await client.updateInteractionEvent({
interactionEvent: InteractionEvent.SignIn,
});
await deleteUser(user.id);
}
);
}
);
await client.identifyUser({
verificationId,
});
const { redirectTo } = await client.submitInteraction();
await processSession(client, redirectTo);
await logoutClient(client);
await deleteUser(user.id);
}
);
});

View file

@ -19,7 +19,7 @@ import {
import { expectRejects } from '#src/helpers/index.js';
import { enableAllVerificationCodeSignInMethods } from '#src/helpers/sign-in-experience.js';
import { generateNewUser, generateNewUserProfile } from '#src/helpers/user.js';
import { devFeatureTest, generateEmail, generatePhone } from '#src/utils.js';
import { generateEmail, generatePhone } from '#src/utils.js';
const verificationIdentifierType: readonly [SignInIdentifier.Email, SignInIdentifier.Phone] =
Object.freeze([SignInIdentifier.Email, SignInIdentifier.Phone]);
@ -198,7 +198,7 @@ describe('Register interaction with verification code happy path', () => {
});
});
devFeatureTest.describe('username as secondary identifier', () => {
describe('username as secondary identifier', () => {
beforeAll(async () => {
await updateSignInExperience({
signUp: {

View file

@ -1,20 +1,6 @@
/* eslint-disable max-lines */
import { logtoConsoleUrl as logtoConsoleUrlString } from '#src/constants.js';
import {
expectModalWithTitle,
expectToClickModalAction,
expectToClickNavTab,
expectToSaveChanges,
goToAdminConsole,
waitForToast,
} from '#src/ui-helpers/index.js';
import {
expectNavigation,
appendPathname,
devFeatureDisabledTest,
devFeatureTest,
waitFor,
} from '#src/utils.js';
import { expectToClickNavTab, goToAdminConsole } from '#src/ui-helpers/index.js';
import { expectNavigation, appendPathname, waitFor } from '#src/utils.js';
import { expectToSaveSignInExperience, waitForFormCard } from '../helpers.js';
@ -31,13 +17,9 @@ import {
cleanUpSignInAndSignUpIdentifiers,
expectToAddSignInMethod,
expectToAddSignUpMethod,
expectToAddSocialSignInConnector,
expectToClickSignInMethodAuthnOption,
expectToClickSignUpAuthnOption,
expectToRemoveSignInMethod,
expectToRemoveSocialSignInConnector,
expectToResetSignUpAndSignInConfig,
expectToSelectSignUpIdentifier,
expectToSwapSignInMethodAuthnOption,
resetSignUpAndSignInConfigToUsernamePassword,
} from './helpers.js';
@ -80,531 +62,7 @@ describe('sign-in experience(happy path): sign-up and sign-in', () => {
await waitForFormCard(page, 'SOCIAL SIGN-IN');
});
devFeatureDisabledTest.describe('email as sign-up identifier (verify only)', () => {
afterAll(async () => {
await expectToResetSignUpAndSignInConfig(page);
});
it('select email as sign-up method and disable password settings for sign-up', async () => {
await expectToSelectSignUpIdentifier(page, 'Email address');
// Disable password settings for sign-up
await expectToClickSignUpAuthnOption(page, 'Create your password');
// Username will be added in later tests
await expectToRemoveSignInMethod(page, 'Username');
/**
* Sign-in method
* - Email address: password + verification code
*/
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
it('update email sign-in method', async () => {
/**
* Sign-in method
* - Email address: verification code
*/
await expectToClickSignInMethodAuthnOption(page, {
method: 'Email address',
option: 'Password',
});
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Email address: verification code + password
*/
await expectToClickSignInMethodAuthnOption(page, {
method: 'Email address',
option: 'Password',
});
await expectToSwapSignInMethodAuthnOption(page, 'Email address');
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
it('add username sign-in method', async () => {
/**
* Sign-in method
* - Email address: verification code + password
* - Username: password
*/
await expectToAddSignInMethod(page, 'Username');
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
it('add & update phone number sign-in method', async () => {
await expectToAddSignInMethod(page, 'Phone number');
/**
* Sign-in method
* - Email address: verification code + password
* - Username: password
* - Phone number: password + verification code
*/
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Email address: verification code + password
* - Username: password
* - Phone number: verification code
*/
await expectToClickSignInMethodAuthnOption(page, {
method: 'Phone number',
option: 'Password',
});
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Email address: verification code + password
* - Phone number: verification code
*/
await expectToRemoveSignInMethod(page, 'Username');
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Email address: verification code + password
* - Phone number: password + verification code
*/
await expectToClickSignInMethodAuthnOption(page, {
method: 'Phone number',
option: 'Password',
});
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
});
devFeatureDisabledTest.describe('email as sign-up identifier (password & verify)', () => {
afterAll(async () => {
await expectToResetSignUpAndSignInConfig(page);
});
it('select email as sign-in method and enable password settings for sign-up', async () => {
await expectToSelectSignUpIdentifier(page, 'Email address');
// Username will be added in later tests
await expectToRemoveSignInMethod(page, 'Username');
/**
* Sign-in method
* - Email address: password + verification code
*/
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
it('update email sign-in method', async () => {
/**
* Sign-in method
* - Email address: verification code + password
*/
// Sign-in method: Email address + verification code + password
await expectToSwapSignInMethodAuthnOption(page, 'Email address');
await expectToSaveSignInExperience(page);
/**
* Sign-in method
* - Email address: password
*/
await expectToClickSignInMethodAuthnOption(page, {
method: 'Email address',
option: 'Verification code',
});
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
it('add phone number & username as sign-in method', async () => {
/**
* Sign-in method
* - Email address: password
* - Phone number: password + verification code
*/
await expectToAddSignInMethod(page, 'Phone number');
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 });
/**
* Sign-in method
* - Email address: password
* - Phone number: password + verification code
* - Username: password
*/
await expectToClickSignInMethodAuthnOption(page, {
method: 'Phone number',
option: 'Verification code',
});
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
});
devFeatureDisabledTest.describe('phone as sign-up identifier (verify only)', () => {
afterAll(async () => {
await expectToResetSignUpAndSignInConfig(page);
});
it('select email as sign-in method and disable password settings for sign-up', async () => {
await expectToSelectSignUpIdentifier(page, 'Phone number');
// Disable password settings for sign-up
await expectToClickSignUpAuthnOption(page, 'Create your password');
// Username will be added in later tests
await expectToRemoveSignInMethod(page, 'Username');
/**
* Sign-in method
* - Phone number: password + verification code
*/
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
it('update sign-in methods', async () => {
/**
* Sign-in method
* - Phone number: verification code + password
*/
await expectToSwapSignInMethodAuthnOption(page, 'Phone number');
await expectToSaveSignInExperience(page);
/**
* Sign-in method
* - Phone number: verification code
*/
await expectToClickSignInMethodAuthnOption(page, {
method: 'Phone number',
option: 'Password',
});
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Phone number: verification code
* - Email address: password + verification code
*/
await expectToAddSignInMethod(page, 'Email address');
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Phone number: verification code
* - Email address: verification code
*/
await expectToClickSignInMethodAuthnOption(page, {
method: 'Email address',
option: 'Password',
});
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Phone number: verification code
* - Email address: verification code
* - Username: password
*/
await expectToAddSignInMethod(page, 'Username');
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
});
devFeatureDisabledTest.describe('phone as sign-up identifier (password & verify)', () => {
afterAll(async () => {
await expectToResetSignUpAndSignInConfig(page);
});
it('select email as sign-in method and enable password settings for sign-up', async () => {
await expectToSelectSignUpIdentifier(page, 'Phone number');
// Username will be added in later tests
await expectToRemoveSignInMethod(page, 'Username');
/**
* Sign-in method
* - Phone number: password + verification code
*/
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
it('update sign-in methods', async () => {
/**
* Sign-in method
* - Phone number: verification code + password
*/
await expectToSwapSignInMethodAuthnOption(page, 'Phone number');
await expectToSaveSignInExperience(page);
/**
* Sign-in method
* - Phone number: password
*/
await expectToClickSignInMethodAuthnOption(page, {
method: 'Phone number',
option: 'Verification code',
});
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Phone number: password
* - Username: password
*/
await expectToAddSignInMethod(page, 'Username');
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
});
devFeatureDisabledTest.describe('email or phone as sign-up identifier (verify only)', () => {
afterAll(async () => {
await expectToResetSignUpAndSignInConfig(page);
});
it('select email or phone as sign-up identifier and disable password settings for sign-up', async () => {
await expectToSelectSignUpIdentifier(page, 'Email address or phone number');
await expectToClickSignUpAuthnOption(page, 'Create your password');
// 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: verification code
* - Phone number: verification code + password
*/
await expectToClickSignInMethodAuthnOption(page, {
method: 'Email address',
option: 'Password',
});
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Email address: verification code
* - Phone number: verification code
*/
await expectToClickSignInMethodAuthnOption(page, {
method: 'Phone number',
option: 'Password',
});
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Email address: verification code
* - Phone number: verification code
* - Username: password
*/
await expectToAddSignInMethod(page, 'Username');
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
});
devFeatureDisabledTest.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',
});
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);
});
it('select not applicable as sign-up identifier', async () => {
await expectToSelectSignUpIdentifier(
page,
'Not applicable(This apply to social only account creation)'
);
await expectToRemoveSignInMethod(page, 'Username');
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
it('update sign-in methods', async () => {
/**
* Sign-in method
* - Email address: password + verification code
*/
await expectToAddSignInMethod(page, 'Email address', false);
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Email address: password
*/
await expectToClickSignInMethodAuthnOption(page, {
method: 'Email address',
option: 'Verification code',
});
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Email address: verification code
*/
await expectToClickSignInMethodAuthnOption(page, {
method: 'Email address',
option: 'Verification code',
});
await expectToClickSignInMethodAuthnOption(page, {
method: 'Email address',
option: 'Password',
});
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Email address: verification code
* - Phone number: password verification code
*/
await expectToAddSignInMethod(page, 'Phone number');
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Email address: verification code
* - Phone number: password
*/
await expectToClickSignInMethodAuthnOption(page, {
method: 'Phone number',
option: 'Verification code',
});
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
/**
* Sign-in method
* - Email address: verification code
* - Phone number: password
* - Username: password
*/
await expectToAddSignInMethod(page, 'Username');
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
it('add social sign-in connector', async () => {
await expectToAddSocialSignInConnector(page, 'Apple');
// Should have diffs about social sign-in connector
await expectToSaveChanges(page);
await expectModalWithTitle(page, 'Reminder');
const beforeSection = await expect(page).toMatchElement(
'div[class$=section]:has(div[class$=title])',
{ text: 'Before' }
);
// Ensure no social-related content in the "Before" section. The modal is already visible, so
// the timeout can be short.
await expect(beforeSection).not.toMatchElement('div[class$=title]', {
text: 'Social',
timeout: 50,
});
// Have social content in the after section
const afterSection = await expect(page).toMatchElement(
'div[class$=section]:has(div[class$=title])',
{ text: 'After' }
);
await expect(afterSection).toMatchElement('div[class$=title]', {
text: 'Social',
});
await expectToClickModalAction(page, 'Confirm');
await waitForToast(page, { text: 'Saved' });
// Reset
await expectToRemoveSocialSignInConnector(page, 'Apple');
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
});
devFeatureTest.describe('email as sign-up identifier', () => {
describe('email as sign-up identifier', () => {
beforeAll(async () => {
await cleanUpSignInAndSignUpIdentifiers(page);
});
@ -696,7 +154,7 @@ describe('sign-in experience(happy path): sign-up and sign-in', () => {
});
});
devFeatureTest.describe('email as sign-up identifier (password and verify)', () => {
describe('email as sign-up identifier (password and verify)', () => {
beforeAll(async () => {
await cleanUpSignInAndSignUpIdentifiers(page);
});
@ -775,7 +233,7 @@ describe('sign-in experience(happy path): sign-up and sign-in', () => {
});
});
devFeatureTest.describe('email or phone as sign-up identifier (verify only', () => {
describe('email or phone as sign-up identifier (verify only', () => {
beforeAll(async () => {
await cleanUpSignInAndSignUpIdentifiers(page);
});
@ -837,7 +295,7 @@ describe('sign-in experience(happy path): sign-up and sign-in', () => {
});
});
devFeatureTest.describe('email or phone as sign-up identifier (password & verify)', () => {
describe('email or phone as sign-up identifier (password & verify)', () => {
beforeAll(async () => {
await cleanUpSignInAndSignUpIdentifiers(page);
});
@ -894,7 +352,7 @@ describe('sign-in experience(happy path): sign-up and sign-in', () => {
});
});
devFeatureTest.describe('username and email as sign-up identifier', () => {
describe('username and email as sign-up identifier', () => {
beforeAll(async () => {
await cleanUpSignInAndSignUpIdentifiers(page);
});
@ -931,7 +389,7 @@ describe('sign-in experience(happy path): sign-up and sign-in', () => {
});
});
devFeatureTest.describe('username and email or phone as sign-up identifier', () => {
describe('username and email or phone as sign-up identifier', () => {
beforeAll(async () => {
await cleanUpSignInAndSignUpIdentifiers(page);
});
@ -999,4 +457,3 @@ describe('sign-in experience(happy path): sign-up and sign-in', () => {
await expectToSaveSignInExperience(page);
});
});
/* eslint-enable max-lines */

View file

@ -1,30 +1,8 @@
import { logtoConsoleUrl as logtoConsoleUrlString } from '#src/constants.js';
import {
expectToClickNavTab,
goToAdminConsole,
expectToSaveChanges,
} from '#src/ui-helpers/index.js';
import { expectNavigation, appendPathname, devFeatureDisabledTest } from '#src/utils.js';
import { expectToClickNavTab, goToAdminConsole } from '#src/ui-helpers/index.js';
import { expectNavigation, appendPathname } from '#src/utils.js';
import { expectToSaveSignInExperience, waitForFormCard } from '../helpers.js';
import {
expectToDeletePasswordlessConnector,
expectToSetupPasswordlessConnector,
testSendgridConnector,
testTwilioConnector,
} from './connector-setup-helpers.js';
import {
expectToSelectSignUpIdentifier,
expectNotificationInField,
expectSignUpIdentifierSelectorError,
expectToAddSignInMethod,
expectSignInMethodError,
expectErrorsOnNavTab,
expectToClickSignUpAuthnOption,
expectToClickSignInMethodAuthnOption,
expectToResetSignUpAndSignInConfig,
} from './helpers.js';
import { waitForFormCard } from '../helpers.js';
await page.setViewport({ width: 1920, height: 1080 });
@ -50,258 +28,4 @@ describe('sign-in experience(sad path): sign-up and sign-in', () => {
await waitForFormCard(page, 'SIGN IN');
await waitForFormCard(page, 'SOCIAL SIGN-IN');
});
devFeatureDisabledTest.describe('cases that no connector is setup', () => {
describe('email address as sign-up identifier', () => {
afterAll(async () => {
await expectToResetSignUpAndSignInConfig(page, false);
});
it('should fail to setup email as sign-up identifier', async () => {
await expectToSelectSignUpIdentifier(page, 'Email address');
// Disable password settings for sign-up settings
await expectToClickSignUpAuthnOption(page, 'Create your password');
await expectNotificationInField(page, {
field: 'Sign-up identifier',
content: /No email connector set-up yet./,
});
await expectToSaveChanges(page);
await expectSignUpIdentifierSelectorError(page);
await expectErrorsOnNavTab(page, {
tab: 'Sign-up and sign-in',
error: '1 error',
});
});
it('should fail to add phone number sign-in method', async () => {
await expectToAddSignInMethod(page, 'Phone number');
await expectNotificationInField(page, {
field: 'Identifier and authentication settings for sign-in',
content: /No SMS connector set-up yet./,
});
await expectToSaveChanges(page);
await expectSignInMethodError(page, 'Phone number');
await expectErrorsOnNavTab(page, {
tab: 'Sign-up and sign-in',
error: '2 errors',
});
// Disable password option for sign-in method
await expectToClickSignInMethodAuthnOption(page, {
method: 'Phone number',
option: 'Password',
});
await expectToSaveChanges(page);
await expectSignInMethodError(page, 'Phone number');
await expectErrorsOnNavTab(page, {
tab: 'Sign-up and sign-in',
error: '2 errors',
});
});
});
describe('phone number as sign-up identifier', () => {
afterAll(async () => {
await expectToResetSignUpAndSignInConfig(page, false);
});
it('should fail to setup phone number as sign-up identifier', async () => {
await expectToSelectSignUpIdentifier(page, 'Phone number');
// Disable password settings for sign-up settings
await expectToClickSignUpAuthnOption(page, 'Create your password');
await expectNotificationInField(page, {
field: 'Sign-up identifier',
content: /No SMS connector set-up yet./,
});
await expectToSaveChanges(page);
await expectSignUpIdentifierSelectorError(page);
await expectErrorsOnNavTab(page, {
tab: 'Sign-up and sign-in',
error: '1 error',
});
});
it('should fail to add email address sign-in method', async () => {
await expectToAddSignInMethod(page, 'Email address');
await expectNotificationInField(page, {
field: 'Identifier and authentication settings for sign-in',
content: /No email connector set-up yet./,
});
await expectToSaveChanges(page);
await expectSignInMethodError(page, 'Email address');
await expectErrorsOnNavTab(page, {
tab: 'Sign-up and sign-in',
error: '2 errors',
});
// Disable password option for sign-in method
await expectToClickSignInMethodAuthnOption(page, {
method: 'Email address',
option: 'Password',
});
await expectToSaveChanges(page);
await expectSignInMethodError(page, 'Email address');
await expectErrorsOnNavTab(page, {
tab: 'Sign-up and sign-in',
error: '2 errors',
});
});
});
describe('social sign-in', () => {
it('should display no social connector notification in social sign-in field', async () => {
await expectNotificationInField(page, {
field: 'Social sign-in',
content: /You havent set up any social connector yet./,
});
});
});
});
devFeatureDisabledTest.describe('cases that only Email connector is setup', () => {
beforeAll(async () => {
// Email connector
await expectToSetupPasswordlessConnector(page, testSendgridConnector);
// Nav back to sign-in experience page
await expectNavigation(
page.goto(
appendPathname('/console/sign-in-experience/sign-up-and-sign-in', logtoConsoleUrl).href
)
);
});
afterAll(async () => {
await expectToDeletePasswordlessConnector(page, testSendgridConnector);
await expectNavigation(
page.goto(
appendPathname('/console/sign-in-experience/sign-up-and-sign-in', logtoConsoleUrl).href
)
);
});
describe('email address as sign-up identifier', () => {
afterAll(async () => {
await expectToResetSignUpAndSignInConfig(page);
});
it('should setup email as sign-up identifier', async () => {
await expectToSelectSignUpIdentifier(page, 'Email address');
// Disable password settings for sign-up settings
await expectToClickSignUpAuthnOption(page, 'Create your password');
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
it('should fail to add phone number sign-in method', async () => {
await expectToAddSignInMethod(page, 'Phone number');
await expectNotificationInField(page, {
field: 'Identifier and authentication settings for sign-in',
content: /No SMS connector set-up yet./,
});
await expectToSaveChanges(page);
await expectSignInMethodError(page, 'Phone number');
await expectErrorsOnNavTab(page, {
tab: 'Sign-up and sign-in',
error: '1 error',
});
// Disable password option for sign-in method
await expectToClickSignInMethodAuthnOption(page, {
method: 'Phone number',
option: 'Password',
});
await expectToSaveChanges(page);
await expectSignInMethodError(page, 'Phone number');
await expectErrorsOnNavTab(page, {
tab: 'Sign-up and sign-in',
error: '1 error',
});
});
});
});
devFeatureDisabledTest.describe('cases that only SMS connector is setup', () => {
beforeAll(async () => {
// SMS connector
await expectToSetupPasswordlessConnector(page, testTwilioConnector);
// Nav back to sign-in experience page
await expectNavigation(
page.goto(
appendPathname('/console/sign-in-experience/sign-up-and-sign-in', logtoConsoleUrl).href
)
);
});
afterAll(async () => {
await expectToDeletePasswordlessConnector(page, testTwilioConnector);
await expectNavigation(
page.goto(
appendPathname('/console/sign-in-experience/sign-up-and-sign-in', logtoConsoleUrl).href
)
);
});
describe('phone number as sign-up identifier', () => {
afterAll(async () => {
await expectToResetSignUpAndSignInConfig(page);
});
it('should setup phone number as sign-up identifier', async () => {
await expectToSelectSignUpIdentifier(page, 'Phone number');
// Disable password settings for sign-up settings
await expectToClickSignUpAuthnOption(page, 'Create your password');
await expectToSaveSignInExperience(page, { needToConfirmChanges: true });
});
it('should fail to add email sign-in method', async () => {
await expectToAddSignInMethod(page, 'Email address');
await expectNotificationInField(page, {
field: 'Identifier and authentication settings for sign-in',
content: /No email connector set-up yet./,
});
await expectToSaveChanges(page);
await expectSignInMethodError(page, 'Email address');
await expectErrorsOnNavTab(page, {
tab: 'Sign-up and sign-in',
error: '1 error',
});
// Disable password option for sign-in method
await expectToClickSignInMethodAuthnOption(page, {
method: 'Email address',
option: 'Password',
});
await expectToSaveChanges(page);
await expectSignInMethodError(page, 'Email address');
await expectErrorsOnNavTab(page, {
tab: 'Sign-up and sign-in',
error: '1 error',
});
});
});
});
});