diff --git a/.changeset/cyan-rockets-switch.md b/.changeset/cyan-rockets-switch.md new file mode 100644 index 00000000000..51ea4d6f60c --- /dev/null +++ b/.changeset/cyan-rockets-switch.md @@ -0,0 +1,9 @@ +--- +"@wso2is/admin.users.v1": minor +"@wso2is/admin.core.v1": patch +"@wso2is/admin.server-configurations.v1": patch +"@wso2is/i18n": patch +"@wso2is/core": patch +--- + +Add UI support for multiple email and mobile numbers per user diff --git a/apps/console/java/org.wso2.identity.apps.console.server.feature/resources/deployment.config.json.j2 b/apps/console/java/org.wso2.identity.apps.console.server.feature/resources/deployment.config.json.j2 index f9a2bc76f46..13b27acb6f5 100644 --- a/apps/console/java/org.wso2.identity.apps.console.server.feature/resources/deployment.config.json.j2 +++ b/apps/console/java/org.wso2.identity.apps.console.server.feature/resources/deployment.config.json.j2 @@ -207,6 +207,9 @@ {% if console.ui.show_app_switch_button is defined %} "showAppSwitchButton": {{ console.ui.show_app_switch_button }}, {% endif %} + {% if identity_mgt.user_claim_update.enable_multiple_emails_and_mobile_numbers is defined %} + "isMultipleEmailsAndMobileNumbersEnabled": {{ identity_mgt.user_claim_update.enable_multiple_emails_and_mobile_numbers }}, + {% endif %} "features": { {% if console.extensions.features is defined %} {% for name, feature in console.extensions.features.items() %} diff --git a/apps/console/src/public/deployment.config.json b/apps/console/src/public/deployment.config.json index 6dd4386e2d7..f61e0ca7a3a 100644 --- a/apps/console/src/public/deployment.config.json +++ b/apps/console/src/public/deployment.config.json @@ -1247,6 +1247,7 @@ "isHeaderAvatarLabelAllowed": false, "isLeftNavigationCategorized": true, "isMarketingConsentBannerEnabled": false, + "isMultipleEmailsAndMobileNumbersEnabled": false, "isPasswordInputValidationEnabled": true, "isRequestPathAuthenticationEnabled": false, "isSAASDeployment": false, diff --git a/features/admin.core.v1/configs/app.ts b/features/admin.core.v1/configs/app.ts index 592452d6d53..467e9086806 100644 --- a/features/admin.core.v1/configs/app.ts +++ b/features/admin.core.v1/configs/app.ts @@ -331,7 +331,9 @@ export class Config { isGroupAndRoleSeparationEnabled: window[ "AppUtils" ]?.getConfig()?.ui?.isGroupAndRoleSeparationEnabled, isHeaderAvatarLabelAllowed: window[ "AppUtils" ]?.getConfig()?.ui?.isHeaderAvatarLabelAllowed, isLeftNavigationCategorized: window[ "AppUtils" ]?.getConfig()?.ui?.isLeftNavigationCategorized, - isMarketingConsentBannerEnabled: window[ "AppUtils" ]?.getConfig()?.ui?.isMarketingConsentBannerEnabled, + isMarketingConsentBannerEnabled: window["AppUtils"]?.getConfig()?.ui?.isMarketingConsentBannerEnabled, + isMultipleEmailsAndMobileNumbersEnabled: + window["AppUtils"]?.getConfig()?.ui?.isMultipleEmailsAndMobileNumbersEnabled, isPasswordInputValidationEnabled: window["AppUtils"]?.getConfig()?.ui?.isPasswordInputValidationEnabled, isRequestPathAuthenticationEnabled: window[ "AppUtils" ]?.getConfig()?.ui?.isRequestPathAuthenticationEnabled, diff --git a/features/admin.core.v1/models/config.ts b/features/admin.core.v1/models/config.ts index 2dc521e3f45..56d3f66a4ca 100644 --- a/features/admin.core.v1/models/config.ts +++ b/features/admin.core.v1/models/config.ts @@ -479,6 +479,10 @@ export interface UIConfigInterface extends CommonUIConfigInterface = ( (state: AppState) => state.global.supportedI18nLanguages ); const featureConfig: FeatureConfigInterface = useSelector((state: AppState) => state.config.ui.features); + const { UIConfig } = useUIConfig(); const hasUsersUpdatePermissions: boolean = useRequiredScopes( featureConfig?.users?.scopes?.update @@ -186,7 +214,10 @@ export const UserProfile: FunctionComponent = ( const [ configSettings, setConfigSettings ] = useState({ accountDisable: "false", accountLock: "false", - forcePasswordReset: "false" + forcePasswordReset: "false", + isEmailVerificationEnabled: "false", + isMobileVerificationByPrivilegeUserEnabled: "false", + isMobileVerificationEnabled: "false" }); const [ alert, setAlert, alertComponent ] = useConfirmationModalAlert(); const [ countryList, setCountryList ] = useState([]); @@ -203,41 +234,120 @@ export const UserProfile: FunctionComponent = ( const oneTimePassword: string = user[userConfig.userProfileSchema]?.oneTimePassword; const isCurrentUserAdmin: boolean = user?.roles?.some((role: RolesMemberInterface) => role.display === administratorConfig.adminRoleName) ?? false; + const [ expandMultiAttributeAccordion, setExpandMultiAttributeAccordion ] = useState>({ + [EMAIL_ADDRESSES_ATTRIBUTE]: false, + [MOBILE_NUMBERS_ATTRIBUTE]: false + }); + const [ isFormStale, setIsFormStale ] = useState(false); + const [ isMultipleEmailAndMobileNumberEnabled, setIsMultipleEmailAndMobileNumberEnabled ] = + useState(false); + const [ tempMultiValuedItemValue, setTempMultiValuedItemValue ] = useState>({}); + const [ isMultiValuedItemInvalid, setIsMultiValuedItemInvalid ] = useState>({}); + + // Multi-valued attribute delete confirmation modal related states. + const [ selectedAttributeInfo, setSelectedAttributeInfo ] = + useState<{ value: string; schema?: ProfileSchemaInterface }>({ value: "" }); + const [ showMultiValuedItemDeleteConfirmationModal, setShowMultiValuedItemDeleteConfirmationModal ] = + useState(false); + const handleMultiValuedItemDeleteModalClose: () => void = useCallback(() => { + setShowMultiValuedItemDeleteConfirmationModal(false); + setSelectedAttributeInfo({ value: "" }); + }, []); + const handleMultiValuedItemDeleteConfirmClick: ()=> void = useCallback(() => { + handleMultiValuedItemDelete(selectedAttributeInfo.schema, selectedAttributeInfo.value); + handleMultiValuedItemDeleteModalClose(); + }, [ selectedAttributeInfo, handleMultiValuedItemDeleteModalClose ]); useEffect(() => { - if (connectorProperties && Array.isArray(connectorProperties) && connectorProperties?.length > 0) { - let configurationStatuses: AccountConfigSettingsInterface = { ...configSettings } ; + const configurationStatuses: AccountConfigSettingsInterface = { ...configSettings } ; for (const property of connectorProperties) { - if (property.name === ServerConfigurationsConstants.ACCOUNT_DISABLING_ENABLE) { - configurationStatuses = { - ...configurationStatuses, - accountDisable: property.value - }; - } else if (property.name === ServerConfigurationsConstants.RECOVERY_LINK_PASSWORD_RESET - || property.name === ServerConfigurationsConstants.OTP_PASSWORD_RESET - || property.name === ServerConfigurationsConstants.OFFLINE_PASSWORD_RESET) { - - if(property.value === "true") { - configurationStatuses = { - ...configurationStatuses, - forcePasswordReset: property.value - }; - } - } else if (property.name === ServerConfigurationsConstants.ACCOUNT_LOCK_ON_CREATION) { - configurationStatuses = { - ...configurationStatuses, - accountLock: property.value - }; + switch (property.name) { + case ServerConfigurationsConstants.ACCOUNT_DISABLING_ENABLE: + configurationStatuses.accountDisable = property.value; + + break; + case ServerConfigurationsConstants.RECOVERY_LINK_PASSWORD_RESET: + case ServerConfigurationsConstants.OTP_PASSWORD_RESET: + case ServerConfigurationsConstants.OFFLINE_PASSWORD_RESET: + if (property.value === "true") { + configurationStatuses.forcePasswordReset = property.value; + } + + break; + case ServerConfigurationsConstants.ACCOUNT_LOCK_ON_CREATION: + configurationStatuses.accountLock = property.value; + + break; + case ServerConfigurationsConstants.ENABLE_EMAIL_VERIFICATION: + configurationStatuses.isEmailVerificationEnabled = property.value; + + break; + case ServerConfigurationsConstants.ENABLE_MOBILE_VERIFICATION: + configurationStatuses.isMobileVerificationEnabled = property.value; + + break; + case ServerConfigurationsConstants.ENABLE_MOBILE_VERIFICATION_BY_PRIVILEGED_USER: + configurationStatuses.isMobileVerificationByPrivilegeUserEnabled = property.value; + + break; } } - setConfigSettings(configurationStatuses); } }, [ connectorProperties ]); + /** + * Check if multiple emails and mobile numbers feature is enabled. + */ + const isMultipleEmailsAndMobileNumbersEnabled = (): void => { + if (isEmpty(profileInfo) || isEmpty(profileSchema)) return; + + if (!UIConfig?.isMultipleEmailsAndMobileNumbersEnabled) { + setIsMultipleEmailAndMobileNumberEnabled(false); + + return; + } + + const multipleEmailsAndMobileFeatureRelatedAttributes: string[] = [ + MOBILE_ATTRIBUTE, + EMAIL_ATTRIBUTE, + EMAIL_ADDRESSES_ATTRIBUTE, + MOBILE_NUMBERS_ATTRIBUTE, + VERIFIED_EMAIL_ADDRESSES_ATTRIBUTE, + VERIFIED_MOBILE_NUMBERS_ATTRIBUTE + ]; + + const domainName: string[] = profileInfo?.get("userName")?.toString().split("/"); + const userStoreDomain: string = (domainName.length > 1 + ? domainName[0] + : PRIMARY_USERSTORE)?.toUpperCase(); + + // Check each required attribute exists and domain is not excluded in the excluded user store list. + const attributeCheck: boolean = multipleEmailsAndMobileFeatureRelatedAttributes.every( + (attribute: string) => { + const schema: ProfileSchemaInterface = profileSchema?.find( + (schema: ProfileSchemaInterface) => schema?.name === attribute); + + if (!schema) { + return false; + } + + const excludedUserStores: string[] = + schema?.excludedUserStores?.split(",")?.map((store: string) => store.trim().toUpperCase()) || []; + + return !excludedUserStores.includes(userStoreDomain); + }); + + setIsMultipleEmailAndMobileNumberEnabled(attributeCheck); + }; + + useEffect(() => { + isMultipleEmailsAndMobileNumbersEnabled(); + }, [ profileSchema, profileInfo ]); + /** * . */ @@ -257,16 +367,29 @@ export const UserProfile: FunctionComponent = ( * Sort the elements of the profileSchema state accordingly by the displayOrder attribute in the ascending order. */ useEffect(() => { + + const getDisplayOrder = (schema: ProfileSchemaInterface): number => { + if (schema.name === EMAIL_ADDRESSES_ATTRIBUTE + && !schema.displayOrder) return 6; + if (schema.name === MOBILE_NUMBERS_ATTRIBUTE + && !schema.displayOrder) return 7; + + return schema.displayOrder ? parseInt(schema.displayOrder, 10) : -1; + }; + const sortedSchemas: ProfileSchemaInterface[] = ProfileUtils.flattenSchemas([ ...profileSchemas ]) .filter((item: ProfileSchemaInterface) => item.name !== ProfileConstants?.SCIM2_SCHEMA_DICTIONARY.get("META_VERSION")) .sort((a: ProfileSchemaInterface, b: ProfileSchemaInterface) => { - if (!a.displayOrder) { + const orderA: number = getDisplayOrder(a); + const orderB: number = getDisplayOrder(b); + + if (orderA === -1) { return -1; - } else if (!b.displayOrder) { + } else if (orderB === -1) { return 1; } else { - return parseInt(a.displayOrder, 10) - parseInt(b.displayOrder, 10); + return orderA - orderB; } }); @@ -337,6 +460,26 @@ export const UserProfile: FunctionComponent = ( schema.extended && userInfo[ProfileConstants.SCIM2_WSO2_CUSTOM_SCHEMA] && userInfo[ProfileConstants.SCIM2_WSO2_CUSTOM_SCHEMA][schemaNames[0]] ) { + const multiValuedAttributes: string[] = [ + EMAIL_ADDRESSES_ATTRIBUTE, + MOBILE_NUMBERS_ATTRIBUTE, + VERIFIED_EMAIL_ADDRESSES_ATTRIBUTE, + VERIFIED_MOBILE_NUMBERS_ATTRIBUTE + ]; + + if (multiValuedAttributes.includes(schemaNames[0])) { + + const attributeValue: string | string[] = + userInfo[ProfileConstants.SCIM2_WSO2_CUSTOM_SCHEMA]?.[schemaNames[0]]; + + const formattedValue: string = Array.isArray(attributeValue) + ? attributeValue.join(",") + : ""; + + tempProfileInfo.set(schema.name, formattedValue); + + return; + } tempProfileInfo.set( schema.name, userInfo[ProfileConstants.SCIM2_WSO2_CUSTOM_SCHEMA][schemaNames[0]] ); @@ -450,6 +593,18 @@ export const UserProfile: FunctionComponent = ( schema.extended && userInfo[ProfileConstants.SCIM2_WSO2_CUSTOM_SCHEMA] && userInfo[ProfileConstants.SCIM2_WSO2_CUSTOM_SCHEMA][schemaNames[0]] ) { + if (schemaNames[0] === EMAIL_ADDRESSES_ATTRIBUTE + || schemaNames[0] === MOBILE_NUMBERS_ATTRIBUTE + || schemaNames[0] === VERIFIED_EMAIL_ADDRESSES_ATTRIBUTE + || schemaNames[0] === VERIFIED_MOBILE_NUMBERS_ATTRIBUTE) { + + tempProfileInfo.set( + schema.name, + userInfo[ProfileConstants.SCIM2_WSO2_CUSTOM_SCHEMA][schemaNames[0]]?.join(",") + ); + + return; + } tempProfileInfo.set( schema.name, userInfo[ProfileConstants.SCIM2_WSO2_CUSTOM_SCHEMA][schemaNames[0]] ); @@ -1030,7 +1185,6 @@ export const UserProfile: FunctionComponent = ( handleUserUpdate(user.id); }) .catch((error: AxiosError) => { - if (error?.response?.data?.detail || error?.response?.data?.description) { dispatch(addAlert({ description: error?.response?.data?.detail || error?.response?.data?.description, @@ -1327,6 +1481,743 @@ export const UserProfile: FunctionComponent = ( ); }; + /** + * Delete a multi-valued item. + * + * @param schema - schema of the attribute + * @param attributeValue - value of the attribute + */ + const handleMultiValuedItemDelete = (schema: ProfileSchemaInterface, attributeValue: string) => { + const data: PatchOperationRequest = { + Operations: [ + { + op: "replace", + value: {} + } + ], + schemas: [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ] + }; + + if (schema.name === EMAIL_ADDRESSES_ATTRIBUTE) { + const emailList: string[] = profileInfo?.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY. + get("EMAIL_ADDRESSES"))?.split(",") || []; + const updatedEmailList: string[] = emailList.filter((email: string) => email !== attributeValue); + const primaryEmail: string = profileInfo?.get(EMAIL_ATTRIBUTE); + + data.Operations[0].value = { + [schema.schemaId] : { + [EMAIL_ADDRESSES_ATTRIBUTE]: updatedEmailList + } + }; + + if (attributeValue === primaryEmail) { + data.Operations.push({ + op: "replace", + value: { + [EMAIL_ATTRIBUTE]: [] + } + }); + } + } else if (schema.name === MOBILE_NUMBERS_ATTRIBUTE) { + const mobileList: string[] = profileInfo?.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY. + get("MOBILE_NUMBERS"))?.split(",") || []; + const updatedMobileList: string[] = mobileList.filter((mobile: string) => mobile !== attributeValue); + const primaryMobile: string = profileInfo.get(MOBILE_ATTRIBUTE); + + if (attributeValue === primaryMobile) { + data.Operations.push({ + op: "replace", + value: { + [ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("PHONE_NUMBERS")]: [ { + type: "mobile", + value: "" + } ] + } + }); + } + + data.Operations[0].value = { + [schema.schemaId]: { + [MOBILE_NUMBERS_ATTRIBUTE]: updatedMobileList + } + }; + } + + setIsSubmitting(true); + updateUserInfo(user.id, data) + .then(() => { + onAlertFired({ + description: t( + "user:profile.notifications.updateProfileInfo.success.description" + ), + level: AlertLevels.SUCCESS, + message: t( + "user:profile.notifications.updateProfileInfo.success.message" + ) + }); + + handleUserUpdate(user.id); + }) + .catch((error: AxiosError) => { + if (error?.response?.data?.detail || error?.response?.data?.description) { + dispatch(addAlert({ + description: error?.response?.data?.detail || error?.response?.data?.description, + level: AlertLevels.ERROR, + message: t("user:profile.notifications.updateProfileInfo." + + "error.message") + })); + + return; + } + + dispatch(addAlert({ + description: t("user:profile.notifications.updateProfileInfo." + + "genericError.description"), + level: AlertLevels.ERROR, + message: t("user:profile.notifications.updateProfileInfo." + + "genericError.message") + })); + }) + .finally(() => { + setIsSubmitting(false); + }); + }; + + /** + * Verify an email address or mobile number. + * + * @param schema - Schema of the attribute + * @param attributeValue - Value of the attribute + */ + const handleVerify = (schema: ProfileSchemaInterface, attributeValue: string) => { + setIsSubmitting(true); + const data: PatchOperationRequest = { + Operations: [ + { + op: "replace", + value: {} + } + ], + schemas: [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ] + }; + let translationKey: string = ""; + + if (schema.name === EMAIL_ADDRESSES_ATTRIBUTE) { + translationKey = "user:profile.notifications.verifyEmail."; + const verifiedEmailList: string[] = profileInfo?.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY. + get("VERIFIED_EMAIL_ADDRESSES"))?.split(",") || []; + + verifiedEmailList.push(attributeValue); + data.Operations[0].value = { + [schema.schemaId]: { + [VERIFIED_EMAIL_ADDRESSES_ATTRIBUTE]: + verifiedEmailList + } + }; + } else if (schema.name === MOBILE_NUMBERS_ATTRIBUTE) { + translationKey = "user:profile.notifications.verifyMobile."; + setSelectedAttributeInfo({ schema, value: attributeValue }); + const verifiedMobileList: string[] = profileInfo?.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY. + get("VERIFIED_MOBILE_NUMBERS"))?.split(",") || []; + + verifiedMobileList.push(attributeValue); + data.Operations[0].value = { + [schema.schemaId]: { + [VERIFIED_MOBILE_NUMBERS_ATTRIBUTE]: + verifiedMobileList + } + }; + } + + setIsSubmitting(true); + updateUserInfo(user.id, data) + .then(() => { + onAlertFired({ + description: t( + `${translationKey}success.description` + ), + level: AlertLevels.SUCCESS, + message: t( + `${translationKey}success.message` + ) + }); + + handleUserUpdate(user.id); + }) + .catch((error: AxiosError) => { + if (error?.response?.data?.detail || error?.response?.data?.description) { + dispatch(addAlert({ + description: error?.response?.data?.detail || error?.response?.data?.description, + level: AlertLevels.ERROR, + message: `${translationKey}error.message` + })); + + return; + } + dispatch(addAlert({ + description: t(`${translationKey}genericError.description`), + level: AlertLevels.ERROR, + message: t(`${translationKey}genericError.message`) + })); + }) + .finally(() => { + setIsSubmitting(false); + }); + }; + + /** + * Assign primary email address or mobile number the multi-valued attribute. + * + * @param schema - Schema of the attribute + * @param attributeValue - Value of the attribute + */ + const handleMakePrimary = (schema: ProfileSchemaInterface, attributeValue: string) => { + const data: PatchOperationRequest = { + Operations: [ + { + op: "replace", + value: {} + } + ], + schemas: [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ] + }; + + if (schema.name === EMAIL_ADDRESSES_ATTRIBUTE) { + + data.Operations[0].value = { + [EMAIL_ATTRIBUTE]: [ attributeValue ] + }; + + const existingPrimaryEmail: string = + profileInfo?.get(EMAIL_ATTRIBUTE); + const existingEmailList: string[] = profileInfo?.get( + EMAIL_ADDRESSES_ATTRIBUTE)?.split(",") || []; + + if (existingPrimaryEmail && !existingEmailList.includes(existingPrimaryEmail)) { + existingEmailList.push(existingPrimaryEmail); + data.Operations.push({ + op: "replace", + value: { + [schema.schemaId] : { + [EMAIL_ADDRESSES_ATTRIBUTE]: existingEmailList + } + } + }); + } + } else if (schema.name === MOBILE_NUMBERS_ATTRIBUTE) { + + data.Operations[0].value = { + [ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("PHONE_NUMBERS")]: [ + { + type: "mobile", + value: attributeValue + } + ] + }; + + const existingPrimaryMobile: string = + profileInfo.get(MOBILE_ATTRIBUTE); + const existingMobileList: string[] = + profileInfo?.get(MOBILE_NUMBERS_ATTRIBUTE)?.split(",") || []; + + if (existingPrimaryMobile && !existingMobileList.includes(existingPrimaryMobile)) { + existingMobileList.push(existingPrimaryMobile); + data.Operations.push({ + op: "replace", + value: { + [schema.schemaId] : { + [MOBILE_NUMBERS_ATTRIBUTE]: existingMobileList + } + } + }); + } + } + setIsSubmitting(true); + updateUserInfo(user.id, data) + .then(() => { + onAlertFired({ + description: t( + "user:profile.notifications.updateProfileInfo.success.description" + ), + level: AlertLevels.SUCCESS, + message: t( + "user:profile.notifications.updateProfileInfo.success.message" + ) + }); + + handleUserUpdate(user.id); + }) + .catch((error: AxiosError) => { + if (error?.response?.data?.detail || error?.response?.data?.description) { + dispatch(addAlert({ + description: error?.response?.data?.detail || error?.response?.data?.description, + level: AlertLevels.ERROR, + message: t("user:profile.notifications.updateProfileInfo." + + "error.message") + })); + + return; + } + + dispatch(addAlert({ + description: t("user:profile.notifications.updateProfileInfo." + + "genericError.description"), + level: AlertLevels.ERROR, + message: t("user:profile.notifications.updateProfileInfo." + + "genericError.message") + })); + }) + .finally(() => { + setIsSubmitting(false); + }); + }; + + /** + * Handle the add multi-valued attribute item. + * + * @param schema - Schema of the attribute + * @param attributeValue - Value of the attribute + */ + const handleAddMultiValuedItem = (schema: ProfileSchemaInterface, attributeValue: string) => { + const data: PatchOperationRequest = { + Operations: [ + { + op: "replace", + value: {} + } + ], + schemas: [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ] + }; + + const attributeValues: string[] = profileInfo.get(schema.name)?.split(",") || []; + + attributeValues.push(attributeValue); + if (schema.name === EMAIL_ADDRESSES_ATTRIBUTE) { + const existingPrimaryEmail: string = profileInfo?.get(EMAIL_ATTRIBUTE); + + if (existingPrimaryEmail && !attributeValues.includes(existingPrimaryEmail)) { + attributeValues.push(existingPrimaryEmail); + } + + data.Operations[0].value = { + [schema.schemaId]: { + [EMAIL_ADDRESSES_ATTRIBUTE]: attributeValues + } + }; + + if (isEmpty(existingPrimaryEmail) && !isEmpty(attributeValues)) { + data.Operations.push({ + op: "replace", + value: { + [EMAIL_ATTRIBUTE]: [ attributeValues[0] ] + } + }); + } + } else if (schema.name === MOBILE_NUMBERS_ATTRIBUTE) { + const existingPrimaryMobile: string = profileInfo?.get(MOBILE_ATTRIBUTE); + + if (existingPrimaryMobile && !attributeValues.includes(existingPrimaryMobile)) { + attributeValues.push(existingPrimaryMobile); + } + + data.Operations[0].value = { + [schema.schemaId]: { + [MOBILE_NUMBERS_ATTRIBUTE]: attributeValues + } + }; + + if (isEmpty(existingPrimaryMobile) && !isEmpty(attributeValues)) { + data.Operations.push({ + op: "replace", + value: { + [ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("PHONE_NUMBERS")]: [ + { + type: "mobile", + value: attributeValues[0] + } + ] + } + }); + } + } + setIsSubmitting(true); + updateUserInfo(user.id, data) + .then(() => { + onAlertFired({ + description: t( + "user:profile.notifications.updateProfileInfo.success.description" + ), + level: AlertLevels.SUCCESS, + message: t( + "user:profile.notifications.updateProfileInfo.success.message" + ) + }); + + handleUserUpdate(user.id); + }) + .catch((error: AxiosError) => { + if (error?.response?.data?.detail || error?.response?.data?.description) { + dispatch(addAlert({ + description: error?.response?.data?.detail || error?.response?.data?.description, + level: AlertLevels.ERROR, + message: t("user:profile.notifications.updateProfileInfo." + + "error.message") + })); + + return; + } + + dispatch(addAlert({ + description: t("user:profile.notifications.updateProfileInfo." + + "genericError.description"), + level: AlertLevels.ERROR, + message: t("user:profile.notifications.updateProfileInfo." + + "genericError.message") + })); + }) + .finally(() => { + setIsSubmitting(false); + }); + }; + + const resolveMultiValuedAttributesFormField = ( + schema: ProfileSchemaInterface, + fieldName: string, + key: number + ): ReactElement => { + let attributeValueList: string[] = []; + let verifiedAttributeValueList: string[] = []; + let primaryAttributeValue: string = ""; + let verificationEnabled: boolean = false; + let primaryAttributeSchema: ProfileSchemaInterface; + let maxAllowedLimit: number = 0; + + if (schema.name === EMAIL_ADDRESSES_ATTRIBUTE) { + attributeValueList = profileInfo?.get(EMAIL_ADDRESSES_ATTRIBUTE)?.split(",") ?? []; + verifiedAttributeValueList = profileInfo?.get(VERIFIED_EMAIL_ADDRESSES_ATTRIBUTE)?.split(",") ?? []; + primaryAttributeValue = profileInfo?.get(EMAIL_ATTRIBUTE); + verificationEnabled = configSettings?.isEmailVerificationEnabled === "true"; + primaryAttributeSchema = profileSchema.find((schema: ProfileSchemaInterface) => + schema.name === EMAIL_ATTRIBUTE); + maxAllowedLimit = ProfileConstants.MAX_EMAIL_ADDRESSES_ALLOWED; + + } else if (schema.name === MOBILE_NUMBERS_ATTRIBUTE) { + attributeValueList = profileInfo?.get(MOBILE_NUMBERS_ATTRIBUTE)?.split(",") ?? []; + verifiedAttributeValueList = profileInfo?.get(VERIFIED_MOBILE_NUMBERS_ATTRIBUTE)?.split(",") ?? []; + primaryAttributeValue = profileInfo?.get(MOBILE_ATTRIBUTE); + verificationEnabled = configSettings?.isMobileVerificationEnabled === "true" + || configSettings?.isMobileVerificationByPrivilegeUserEnabled === "true"; + primaryAttributeSchema = profileSchema.find((schema: ProfileSchemaInterface) => + schema.name === MOBILE_ATTRIBUTE); + maxAllowedLimit = ProfileConstants.MAX_MOBILE_NUMBERS_ALLOWED; + } + + // Move the primary attribute value to the top of the list. + if (!isEmpty(primaryAttributeValue)) { + attributeValueList = attributeValueList.filter((value: string) => + !isEmpty(value) + && value !== primaryAttributeValue); + attributeValueList.unshift(primaryAttributeValue); + } + const showAccordion: boolean = attributeValueList.length >= 1; + const accordionLabelValue: string = showAccordion ? attributeValueList[0] : ""; + + const showVerifiedPopup = (value: string): boolean => { + return verificationEnabled && + (verifiedAttributeValueList.includes(value) || value === primaryAttributeValue); + }; + + const showPrimaryPopup = (value: string): boolean => { + return value === primaryAttributeValue; + }; + + const showMakePrimaryButton = (value: string): boolean => { + if (verificationEnabled) { + return verifiedAttributeValueList.includes(value) && value !== primaryAttributeValue; + } else { + return value !== primaryAttributeValue; + } + }; + + const showDeleteButton = (value: string): boolean => { + return !(primaryAttributeSchema?.required && value === primaryAttributeValue); + }; + + const showVerifyButton = (value: string): boolean => + schema.name === EMAIL_ADDRESSES_ATTRIBUTE + && verificationEnabled + && !(verifiedAttributeValueList.includes(value) || value === primaryAttributeValue); + + return ( +
+ { + event.preventDefault(); + const value: string = tempMultiValuedItemValue[schema.name]; + + if (isMultiValuedItemInvalid[schema.name] || isEmpty(value)) return; + handleAddMultiValuedItem(schema, value); + } + } } + disabled = { isSubmitting || isReadOnly || attributeValueList?.length >= maxAllowedLimit } + data-testid={ `${ testId }-profile-form-${ schema.name }-input` } + name={ schema.name } + label={ schema.name === "profileUrl" ? "Profile Image URL" : + ( (!commonConfig.userEditSection.showEmail && schema.name === "userName") + ? fieldName +" (Email)" + : fieldName + ) + } + required={ schema.required } + requiredErrorMessage={ fieldName + " " + "is required" } + placeholder={ "Enter your" + " " + fieldName } + type="text" + readOnly={ isReadOnly || schema.mutability === ProfileConstants.READONLY_SCHEMA } + validation={ (value: string, validation: Validation) => { + if (!RegExp(primaryAttributeSchema.regEx).test(value)) { + setIsMultiValuedItemInvalid({ + ...isMultiValuedItemInvalid, + [schema.name]: true + }); + validation.isValid = false; + validation.errorMessages + .push(t("users:forms.validation.formatError", { + field: fieldName + })); + } else { + setIsMultiValuedItemInvalid({ + ...isMultiValuedItemInvalid, + [schema.name]: false + }); + } + } } + displayErrorOn="blur" + listen={ (values: ProfileInfoInterface) => { + setTempMultiValuedItemValue({ + ...tempMultiValuedItemValue, + [schema.name]: values.get(schema.name) + }); + } } + maxLength={ + fieldName.toLowerCase().includes("uri") || fieldName.toLowerCase().includes("url") + ? ProfileConstants.URI_CLAIM_VALUE_MAX_LENGTH + : ( + schema.maxLength + ? schema.maxLength + : ProfileConstants.CLAIM_VALUE_MAX_LENGTH + ) + } + /> + +
+ ); + }; + const resolveFormField = (schema: ProfileSchemaInterface, fieldName: string, key: number): ReactElement => { if (schema.type.toUpperCase() === "BOOLEAN") { return ( @@ -1434,6 +2325,11 @@ export const UserProfile: FunctionComponent = ( fluid /> ); + } else if ( + schema?.name === EMAIL_ADDRESSES_ATTRIBUTE + || schema?.name === MOBILE_NUMBERS_ATTRIBUTE + ) { + return resolveMultiValuedAttributesFormField(schema, fieldName, key); } else if (schema?.name === "dateOfBirth") { return ( = ( } } maxLength={ fieldName.toLowerCase().includes("uri") || fieldName.toLowerCase().includes("url") - ? 1024 + ? ProfileConstants.URI_CLAIM_VALUE_MAX_LENGTH : ( schema.maxLength ? schema.maxLength @@ -1525,19 +2421,35 @@ export const UserProfile: FunctionComponent = ( * @returns the form field for the profile schema. */ const generateProfileEditForm = (schema: ProfileSchemaInterface, key: number): JSX.Element => { + // Hide the email and mobile number fields when the multi-valued email and mobile config is enabled. + const fieldsToHide: string[] = [ + isMultipleEmailAndMobileNumberEnabled + ? EMAIL_ATTRIBUTE + : EMAIL_ADDRESSES_ATTRIBUTE, + isMultipleEmailAndMobileNumberEnabled + ? MOBILE_ATTRIBUTE + : MOBILE_NUMBERS_ATTRIBUTE, + VERIFIED_MOBILE_NUMBERS_ATTRIBUTE, + VERIFIED_EMAIL_ADDRESSES_ATTRIBUTE + ]; + + if (fieldsToHide.some((name: string) => schema.name === name)) { + return; + } + + if (!commonConfig.userEditSection.showEmail && schema.name === EMAIL_ADDRESSES_ATTRIBUTE) { + return; + } + const fieldName: string = t("user:profile.fields." + schema.name.replace(".", "_"), { defaultValue: schema.displayName } ); const domainName: string[] = profileInfo?.get(schema.name)?.toString().split("/"); - if (multipleEmailMobileFeatureSpecificSchemaNames?.includes(schema?.name)) { - return; - } - return ( - + { schema.name === "userName" && domainName.length > 1 ? ( <> @@ -1607,6 +2519,56 @@ export const UserProfile: FunctionComponent = ( }; /** + * This methods generates and returns the delete confirmation modal. + * + * @returns ReactElement Generates the delete confirmation modal. + */ + const generateDeleteConfirmationModalForMultiValuedField = (): JSX.Element => { + if (isEmpty(selectedAttributeInfo?.value)) { + return null; + } + + const translationKey: string = "user:profile.confirmationModals.deleteAttributeConfirmation."; + let attributeDisplayName: string = ""; + let primaryAttributeSchema: ProfileSchemaInterface; + + if (selectedAttributeInfo?.schema?.name === EMAIL_ADDRESSES_ATTRIBUTE) { + primaryAttributeSchema = profileSchema.find((schema: ProfileSchemaInterface) => + schema.name === EMAIL_ATTRIBUTE); + } else if (selectedAttributeInfo?.schema?.name === MOBILE_NUMBERS_ATTRIBUTE) { + primaryAttributeSchema = profileSchema.find((schema: ProfileSchemaInterface) => + schema.name === MOBILE_ATTRIBUTE); + } + attributeDisplayName = primaryAttributeSchema?.displayName; + + return ( + + + { t(`${translationKey}heading`) } + + + { t(`${translationKey}description`, { attributeDisplayName }) } + + + { t(`${translationKey}content`, { attributeDisplayName }) } + + + ); + }; + + /* * Resolves the user account locked reason text. * @returns The resolved account locked reason in readable text. */ @@ -1637,12 +2599,13 @@ export const UserProfile: FunctionComponent = ( ) => handleSubmit(values) } + onStaleChange={ (stale: boolean) => setIsFormStale(stale) } > { user.id && ( - +