diff --git a/.changeset/real-carpets-press.md b/.changeset/real-carpets-press.md new file mode 100644 index 00000000000..b45d8c1f1b7 --- /dev/null +++ b/.changeset/real-carpets-press.md @@ -0,0 +1,7 @@ +--- +"@wso2is/myaccount": minor +"@wso2is/core": patch +"@wso2is/i18n": patch +--- + +Add multi-valued email, mobile support to my account diff --git a/apps/myaccount/java/org.wso2.identity.apps.myaccount.server.feature/resources/deployment.config.json.j2 b/apps/myaccount/java/org.wso2.identity.apps.myaccount.server.feature/resources/deployment.config.json.j2 index 82f70d2cbc8..6395d6fef40 100644 --- a/apps/myaccount/java/org.wso2.identity.apps.myaccount.server.feature/resources/deployment.config.json.j2 +++ b/apps/myaccount/java/org.wso2.identity.apps.myaccount.server.feature/resources/deployment.config.json.j2 @@ -178,6 +178,9 @@ {% endfor %} {% 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 myaccount.extensions.features is defined %} {% for name, feature in myaccount.extensions.features.items() %} diff --git a/apps/myaccount/src/api/verify-mobile-smsotp.ts b/apps/myaccount/src/api/verify-mobile-smsotp.ts index a11e9c99a3a..5cafc2ac617 100644 --- a/apps/myaccount/src/api/verify-mobile-smsotp.ts +++ b/apps/myaccount/src/api/verify-mobile-smsotp.ts @@ -17,6 +17,7 @@ */ import { AsgardeoSPAClient, HttpError, HttpInstance, HttpRequestConfig, HttpResponse } from "@asgardeo/auth-react"; +import { MobileVerificationRecoveryScenario } from "../constants"; import { HttpMethods, SMSOTPProperty } from "../models"; import { store } from "../store"; @@ -59,12 +60,14 @@ export const validateSMSOTPCode = (code: string): Promise => { /** * Resend SMS OTP verification code for the authenticated user. */ -export const resendSMSOTPCode = (): Promise => { +export const resendSMSOTPCode = ( + recoveryScenario: string = MobileVerificationRecoveryScenario.MOBILE_VERIFICATION_ON_UPDATE +): Promise => { const properties: SMSOTPProperty[] = []; const propertyData: SMSOTPProperty = { key: "RecoveryScenario", - value: "MOBILE_VERIFICATION_ON_UPDATE" + value: recoveryScenario }; properties.push(propertyData); diff --git a/apps/myaccount/src/components/profile/profile.scss b/apps/myaccount/src/components/profile/profile.scss new file mode 100644 index 00000000000..8d4e000c6c0 --- /dev/null +++ b/apps/myaccount/src/components/profile/profile.scss @@ -0,0 +1,126 @@ +/** + * Copyright (c) 2024, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +.profile-form { + .multi-input-box { + width: 100%; + } + + .accordion-details { + border-radius: 8px; + padding: 2px; + + .multi-value-table { + .multi-value-table-data-row { + &:last-child td, &:last-child th { + border: none; + } + } + + .table-c1 { + display: flex; + flex-wrap: nowrap; + align-items: center; + + .c1-value{ + margin-bottom: 0; + flex-shrink: 0; + padding-right: 12px; + min-width: 30ch; + + &.mobile-label { + min-width: 20ch; + } + } + + .verified-icon, .primary-icon { + align-items: center; + } + } + + .table-c2 { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 4px; + } + } + } + + .mv-cancel-btn { + margin-top: 1em; + } + + .multi-attribute-dropdown { + width: 100%; + } +} + +.read-only-menu-item:hover { + background-color: transparent !important; +} + +.read-only-menu-item.Mui-selected, .read-only-menu-item.Mui-selected:hover { + background-color: transparent !important; +} + +.dropdown-row { + display: flex; + flex-wrap: nowrap; + align-items: center; + + .dropdown-label { + display: flex; + align-items: center; + padding-right: 8px; + margin: 0 !important; + min-width: 30ch; + + &.mobile-label { + min-width: 20ch; + } + } + + .verified-icon, .primary-icon { + align-items: center; + } +} + +.resend { + display: flex; + gap: 8px; + margin-top: 12px; + + .resend-button { + cursor: pointer; + max-width: none !important; + position: static !important; + + &.disabled { + cursor: not-allowed; + } + } +} + +.vertical-align-center { + display: flex; + flex-wrap: nowrap; + align-items: center; + height: 100%; +} diff --git a/apps/myaccount/src/components/profile/profile.tsx b/apps/myaccount/src/components/profile/profile.tsx index d5797e2c7f5..9de184d0531 100644 --- a/apps/myaccount/src/components/profile/profile.tsx +++ b/apps/myaccount/src/components/profile/profile.tsx @@ -16,6 +16,18 @@ * under the License. */ +import Table from "@mui/material/Table"; +import TableBody from "@mui/material/TableBody"; +import TableCell from "@mui/material/TableCell"; +import TableContainer from "@mui/material/TableContainer"; +import TableRow from "@mui/material/TableRow"; +import Accordion from "@oxygen-ui/react/Accordion"; +import AccordionDetails from "@oxygen-ui/react/AccordionDetails"; +import IconButton from "@oxygen-ui/react/IconButton"; +import MenuItem from "@oxygen-ui/react/MenuItem"; +import Paper from "@oxygen-ui/react/Paper"; +import Select from "@oxygen-ui/react/Select"; +import Typography from "@oxygen-ui/react/Typography"; import { ProfileConstants } from "@wso2is/core/constants"; import { IdentityAppsApiException } from "@wso2is/core/exceptions"; /** @@ -25,10 +37,12 @@ import { IdentityAppsApiException } from "@wso2is/core/exceptions"; import { getUserNameWithoutDomain, hasRequiredScopes, isFeatureEnabled, resolveUserDisplayName, - resolveUserEmails + resolveUserEmails, + resolveUserstore } from "@wso2is/core/helpers"; /* eslint-enable */ import { + PatchOperationRequest, ProfileSchemaInterface, SBACInterface, TestableComponentInterface @@ -37,6 +51,7 @@ import { ProfileUtils, CommonUtils as ReusableCommonUtils } from "@wso2is/core/u import { Field, FormValue, Forms, Validation } from "@wso2is/forms"; import { SupportedLanguagesMeta } from "@wso2is/i18n"; import { + ConfirmationModal, EditAvatarModal, LinkButton, Message, @@ -48,7 +63,7 @@ import { import { AxiosError, AxiosResponse } from "axios"; import isEmpty from "lodash-es/isEmpty"; import moment from "moment"; -import React, { FunctionComponent, MouseEvent, ReactElement, useEffect, useState } from "react"; +import React, { FunctionComponent, MouseEvent, ReactElement, useCallback, useEffect, useState } from "react"; import { Trans, useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; import { Dispatch } from "redux"; @@ -76,6 +91,7 @@ import { PreferenceConnectorResponse, PreferenceProperty, PreferenceRequest, + ProfilePatchOperationValue, ProfileSchema, ValidationFormInterface } from "../../models"; @@ -84,10 +100,17 @@ import { getProfileInformation, setActiveForm } from "../../store/actions"; import { CommonUtils } from "../../utils"; import { EditSection, SettingsSection } from "../shared"; import { MobileUpdateWizard } from "../shared/mobile-update-wizard"; - -// TODO: Remove this once multiple email and mobile support is onboarded. -const multipleEmailMobileFeatureSpecificSchemaNames: string[] = [ "emailAddresses", "verifiedEmailAddresses", - "mobileNumbers", "verifiedMobileNumbers" ]; +import "./profile.scss"; + +const EMAIL_ATTRIBUTE: string = ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAILS"); +const MOBILE_ATTRIBUTE: string = ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("MOBILE"); +const EMAIL_ADDRESSES_ATTRIBUTE: string = ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAIL_ADDRESSES"); +const MOBILE_NUMBERS_ATTRIBUTE: string = ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("MOBILE_NUMBERS"); +const VERIFIED_MOBILE_NUMBERS_ATTRIBUTE: string = + ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("VERIFIED_MOBILE_NUMBERS"); +const VERIFIED_EMAIL_ADDRESSES_ATTRIBUTE: string = + ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("VERIFIED_EMAIL_ADDRESSES"); +const EMAIL_MAX_LENGTH: number = 50; /** * Prop types for the basic details component. @@ -141,9 +164,30 @@ export const Profile: FunctionComponent = (props: ProfileProps): R const [ showEmail, setShowEmail ] = useState(false); const allowedScopes: string = useSelector((state: AppState) => state?.authenticationInformation?.scope); + const isMultipleEmailsAndMobileConfigEnabled: boolean = config?.ui?.isMultipleEmailsAndMobileNumbersEnabled; const [ isMobileVerificationEnabled, setIsMobileVerificationEnabled ] = useState(false); const [ isEmailVerificationEnabled, setIsEmailVerificationEnabled ] = useState(false); + const [ expandMultiAttributeAccordion, setExpandMultiAttributeAccordion ] = useState>({ + [EMAIL_ADDRESSES_ATTRIBUTE]: false, + [MOBILE_NUMBERS_ATTRIBUTE]: false + }); + const [ isMultipleEmailAndMobileNumberEnabled, setIsMultipleEmailAndMobileNumberEnabled ] = + useState(false); + + // Multi-valued attribute delete confirmation modal related states. + const [ selectedAttributeInfo, setSelectedAttributeInfo ] = + useState<{ value: string; schema?: ProfileSchema }>({ value: "" }); + const [ showMultiValuedFieldDeleteConfirmationModal, setShowMultiValuedFieldDeleteConfirmationModal ] + = useState(false); + const handleMultiValuedFieldDeleteModalClose: () => void = useCallback(() => { + setShowMultiValuedFieldDeleteConfirmationModal(false); + setSelectedAttributeInfo({ value: "" }); + }, []); + const handleMultiValuedFieldDeleteConfirmClick: ()=> void = useCallback(() => { + handleMultiValuedItemDelete(selectedAttributeInfo.schema, selectedAttributeInfo.value); + handleMultiValuedFieldDeleteModalClose(); + }, [ selectedAttributeInfo, handleMultiValuedFieldDeleteModalClose ]); /** * The following method gets the preference for verification on mobile and email update. @@ -260,6 +304,54 @@ export const Profile: FunctionComponent = (props: ProfileProps): R } }, [ profileInfo, usernameConfig ]); + /** + * Check if multiple emails and mobile numbers feature is enabled. + */ + const isMultipleEmailsAndMobileNumbersEnabled = (): void => { + + if (isEmpty(profileDetails) || isEmpty(profileSchema)) return; + if (!isMultipleEmailsAndMobileConfigEnabled) { + 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 username: string = profileDetails?.profileInfo["userName"]; + + if (!username) return; + const userStoreDomain: string = resolveUserstore(username)?.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: ProfileSchema = profileSchema?.find( + (schema: ProfileSchema) => 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, profileDetails ]); + /** * Get the configurations. */ @@ -271,17 +363,28 @@ export const Profile: FunctionComponent = (props: ProfileProps): R * Sort the elements of the profileSchema state according to the displayOrder attribute in the ascending order. */ useEffect(() => { + + const getDisplayOrder = (schema: ProfileSchema): 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( [ ...profileDetails.profileSchemas ] ).filter((item: ProfileSchemaInterface) => item.name !== ProfileConstants?.SCIM2_SCHEMA_DICTIONARY.get("META_VERSION") ).sort((a: ProfileSchema, b: ProfileSchema) => { - 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; } }); @@ -343,6 +446,28 @@ export const Profile: FunctionComponent = (props: ProfileProps): R if (schema.extended && profileDetails?.profileInfo[ProfileConstants.SCIM2_WSO2_CUSTOM_SCHEMA] && profileDetails?.profileInfo[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[] = + profileDetails?.profileInfo[ + 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, profileDetails?.profileInfo[ProfileConstants.SCIM2_WSO2_CUSTOM_SCHEMA] @@ -476,6 +601,7 @@ export const Profile: FunctionComponent = (props: ProfileProps): R isExtended: boolean, schema: ProfileSchema ): void => { + setIsSubmitting(true); const data: { Operations: Array<{ op: string, value: Record }>, schemas: Array } = { Operations: [ { @@ -500,11 +626,59 @@ export const Profile: FunctionComponent = (props: ProfileProps): R isCanonical = true; } - if (ProfileUtils.isMultiValuedSchemaAttribute(profileSchema, schemaNames[0]) || - schemaNames[0] === "phoneNumbers") { + if (ProfileUtils.isMultiValuedSchemaAttribute(profileSchema, schemaNames[0]) + || schemaNames[0] === "phoneNumbers") { const attributeValues: string[] = []; - if (schemaNames.length === 1) { + if (schema.name === EMAIL_ADDRESSES_ATTRIBUTE || schema.name === MOBILE_NUMBERS_ATTRIBUTE) { + if (isEmpty(values.get(formName))) { + setIsSubmitting(false); + + return; + } + + const currentValues: string[] = resolveProfileInfoSchemaValue(schema)?.split(",") || []; + let primaryValue: string | null; + + if (schema.name === EMAIL_ADDRESSES_ATTRIBUTE) { + primaryValue = profileDetails?.profileInfo?.emails?.length > 0 + ? profileDetails?.profileInfo?.emails[0] + : null; + } else if (schema.name === MOBILE_NUMBERS_ATTRIBUTE) { + primaryValue = profileInfo.get(MOBILE_ATTRIBUTE); + } + if (primaryValue && !currentValues.includes(primaryValue)) { + currentValues.push(primaryValue); + } + + currentValues.push(values.get(formName) as string); + values.set(formName, currentValues); + + value = { + [schema.schemaId]: { + [schemaNames[0]]: currentValues + } + }; + // If no primary value is set, set the first value as the primary value. + if (isEmpty(primaryValue) && !isEmpty(currentValues)) { + if (schema.name === EMAIL_ADDRESSES_ATTRIBUTE) { + value = { + ...value, + [EMAIL_ATTRIBUTE]: [ currentValues[0] ] + }; + } else if (schema.name === MOBILE_NUMBERS_ATTRIBUTE) { + value = { + ...value, + [ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("PHONE_NUMBERS")]: [ + { + type: "mobile", + value: currentValues[0] + } + ] + }; + } + } + } else if (schemaNames.length === 1) { // List of sub attributes. const subValue: string[] = profileDetails.profileInfo[schemaNames[0]] && profileDetails.profileInfo[schemaNames[0]] @@ -685,12 +859,278 @@ export const Profile: FunctionComponent = (props: ProfileProps): R "myAccount:components.profile.notifications.updateProfileInfo.genericError.message" ) }); + }).finally(() => { + setIsSubmitting(false); }); // Hide corresponding edit view dispatch(setActiveForm(null)); }; + /** + * Verify an email address or mobile number. + * + * @param schema - Schema of the attribute + * @param attributeValue - Value of the attribute + */ + const handleVerify = (schema: ProfileSchema, 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 = "myAccount:components.profile.notifications.verifyEmail."; + const verifiedEmailList: string[] = profileInfo?.get(VERIFIED_EMAIL_ADDRESSES_ATTRIBUTE)?.split(",") || []; + + verifiedEmailList.push(attributeValue); + data.Operations[0].value = { + [schema.schemaId]: { + [VERIFIED_EMAIL_ADDRESSES_ATTRIBUTE]: verifiedEmailList + } + }; + } else if (schema.name === MOBILE_NUMBERS_ATTRIBUTE) { + translationKey = "myAccount:components.profile.notifications.verifyMobile."; + setSelectedAttributeInfo({ schema, value: attributeValue }); + const verifiedMobileList: string[] = profileInfo?.get(VERIFIED_MOBILE_NUMBERS_ATTRIBUTE)?.split(",") || []; + + verifiedMobileList.push(attributeValue); + data.Operations[0].value = { + [schema.schemaId]: { + [VERIFIED_MOBILE_NUMBERS_ATTRIBUTE]: verifiedMobileList + } + }; + } + + updateProfileInfo(data as unknown as Record).then((response: AxiosResponse) => { + if (response.status === 200) { + onAlertFired({ + description: t( + `${ translationKey }success.description` + ), + level: AlertLevels.SUCCESS, + message: t( + `${ translationKey }success.message` + ) + }); + + // Re-fetch the profile information + dispatch(getProfileInformation(true)); + schema.name === MOBILE_NUMBERS_ATTRIBUTE && setShowMobileUpdateWizard(true); + } + }).catch((error: any) => { + onAlertFired({ + description: error?.detail ?? t( + `${ translationKey }genericError.description` + ), + level: AlertLevels.ERROR, + message: 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: ProfileSchema, attributeValue: string) => { + + setIsSubmitting(true); + 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 = profileDetails?.profileInfo?.emails?.length > 0 + ? profileDetails?.profileInfo?.emails[0] + : null; + 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 + } + } + }); + } + } + updateProfileInfo(data as unknown as Record).then((response: AxiosResponse) => { + if (response.status === 200) { + onAlertFired({ + description: t( + "myAccount:components.profile.notifications.updateProfileInfo.success.description" + ), + level: AlertLevels.SUCCESS, + message: t( + "myAccount:components.profile.notifications.updateProfileInfo.success.message" + ) + }); + + // Re-fetch the profile information + dispatch(getProfileInformation(true)); + } + }).catch((error: any) => { + onAlertFired({ + description: error?.detail ?? t( + "myAccount:components.profile.notifications.updateProfileInfo.genericError.description" + ), + level: AlertLevels.ERROR, + message: error?.message ?? t( + "myAccount:components.profile.notifications.updateProfileInfo.genericError.message" + ) + }); + }).finally(() => { + setIsSubmitting(false); + }); + }; + + /** + * Delete a multi-valued attribute value. + * + * @param schema - schema of the attribute + * @param attributeValue - value of the attribute + */ + const handleMultiValuedItemDelete = (schema: ProfileSchema, attributeValue: string) => { + + setIsSubmitting(true); + 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(EMAIL_ADDRESSES_ATTRIBUTE)?.split(",") || []; + const updatedEmailList: string[] = emailList.filter((email: string) => email !== attributeValue); + const primaryEmail: string = profileDetails?.profileInfo?.emails?.length > 0 + ? profileDetails?.profileInfo?.emails[0] + : null; + + 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(MOBILE_NUMBERS_ATTRIBUTE)?.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 + } + }; + } + updateProfileInfo(data as unknown as Record).then((response: AxiosResponse) => { + if (response.status === 200) { + onAlertFired({ + description: t( + "myAccount:components.profile.notifications.updateProfileInfo.success.description" + ), + level: AlertLevels.SUCCESS, + message: t( + "myAccount:components.profile.notifications.updateProfileInfo.success.message" + ) + }); + + // Re-fetch the profile information + dispatch(getProfileInformation(true)); + } + }).catch((error: any) => { + onAlertFired({ + description: error?.detail ?? t( + "myAccount:components.profile.notifications.updateProfileInfo.genericError.description" + ), + level: AlertLevels.ERROR, + message: error?.message ?? t( + "myAccount:components.profile.notifications.updateProfileInfo.genericError.message" + ) + }); + }).finally(() => { + setIsSubmitting(false); + }); + }; + /** * This takes the schema name and a type and sees if the schema is of the specified type * @param schema - The schema name eg: 'emails.workEmail' @@ -766,130 +1206,573 @@ export const Profile: FunctionComponent = (props: ProfileProps): R return normalizedLocale; }; - const personalInfoFieldValidation = ( - value: string, - validation: Validation, - schema: ProfileSchema - ) => { - const fieldName: string = t("myAccount:components.profile.fields." + schema.displayName, - { defaultValue: schema.displayName } - ); + /** + * This function generates the Edit Section based on the input Profile Schema. + * + * @param schema - Profile schemas. + * @returns Schema form. + */ + const generateSchemaForm = (schema: ProfileSchema, index: number): JSX.Element => { - if (!RegExp(schema.regEx).test(value)) { - validation.isValid = false; - if (checkSchemaType(schema.name, "emails")) { - validation.errorMessages.push( - t("myAccount:components.profile.forms." + - "emailChangeForm.inputs.email.validations." + - "invalidFormat") - ); - } else if (checkSchemaType(schema.name, ProfileConstants. - SCIM2_SCHEMA_DICTIONARY.get("PHONE_NUMBERS"))) { - validation.errorMessages.push(t( - profileConfig?.attributes?. - getRegExpValidationError( - ProfileConstants.SCIM2_SCHEMA_DICTIONARY - .get("PHONE_NUMBERS") - ), - { - fieldName - } - ) - ); - } else if (checkSchemaType(schema.name, - ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("DOB"))) { - validation.errorMessages.push( - t("myAccount:components.profile.forms." + - "dateChangeForm.inputs.date.validations." + - "invalidFormat", { fieldName }) - ); - } else { - validation.errorMessages.push( - t( - "myAccount:components.profile.forms." + - "generic.inputs.validations.invalidFormat", - { - fieldName - } - ) - ); - } - // Validate date format and the date is before the current date - } else if(checkSchemaType(schema.name, - ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("DOB"))){ - if (!moment(value, "YYYY-MM-DD",true).isValid()) { - validation.isValid = false; - validation.errorMessages - .push(t("myAccount:components.profile.forms." - + "dateChangeForm.inputs.date.validations." - + "invalidFormat", { - field: fieldName - })); - } else { - if (moment().isBefore(value)) { - validation.isValid = false; - validation.errorMessages - .push(t("myAccount:components.profile.forms." - + "dateChangeForm.inputs.date.validations." - + "futureDateError", { - field: fieldName - })); - } + // Define schemas to hide. + const attributesToHide: string[] = isMultipleEmailAndMobileNumberEnabled === true + ? [ EMAIL_ATTRIBUTE, MOBILE_ATTRIBUTE, VERIFIED_MOBILE_NUMBERS_ATTRIBUTE, + VERIFIED_EMAIL_ADDRESSES_ATTRIBUTE ] + : [ EMAIL_ADDRESSES_ATTRIBUTE, MOBILE_NUMBERS_ATTRIBUTE, + VERIFIED_MOBILE_NUMBERS_ATTRIBUTE, VERIFIED_EMAIL_ADDRESSES_ATTRIBUTE ]; + + // Hide the field if any of the relevant attributes match the schema name. + if (attributesToHide.some((name: string) => schema.name === name)) { + return; + } + + if (!showEmail && schema?.name === EMAIL_ADDRESSES_ATTRIBUTE) { + return; + } + + /* + * Makes the "Username" field a READ_ONLY field. By default the + * server SCIM2 endpoint sends it as a "READ_WRITE" property. + * We are able to enable/disable read-only mode for specific + * claim dialects in user-store(s). However, it does not apply to + * all the tenants. + * + * Since we only interested in checking `username` we check the + * `isProfileUsernameReadonly` condition at top level. So, + * if it is `false` by default then we won't check the `name` + * unnecessarily. + * + * Match case explanation:- + * Ideally it should be the exact attribute name @see {@link http://wso2.org/claims/username} + * `username`. But we will transform the `schema.name`. + * and `schema.displayName` to a lowercase string and then check + * the value matches. + */ + const isProfileUsernameReadonly: boolean = config?.ui?.isProfileUsernameReadonly; + const { displayName, name } = schema; + + if (isProfileUsernameReadonly) { + const usernameClaim: string = "username"; + + if (name?.toLowerCase() === usernameClaim || displayName?.toLowerCase() === usernameClaim) { + schema.mutability = ProfileConstants.READONLY_SCHEMA; + } + } + + // Makes the email field read-only for users without local credentials + if (isNonLocalCredentialUser) { + if (name?.toLowerCase() === EMAIL_ATTRIBUTE) { + schema.mutability = ProfileConstants.READONLY_SCHEMA; } } + + const fieldName: string = getFieldName(schema); + + if (activeForm === CommonConstants.PERSONAL_INFO + schema.name) { + return ( + + { generateSingleMobileVerificationSection(schema, fieldName) + || generateEditableForm(schema, fieldName) } + + ); + } else { + return ( + + { generateReadOnlyForm(schema, fieldName) } + + ); + } }; /** - * This function resolves the personal info field in schemas. + * This function generates mobile verification section for mobile schema. * - * @param schema - Profile schemas. - * @returns Schema field + * @param schema - The schema to generate the form for. + * @param fieldName - The field name to get the placeholder text for. + * @returns Mobile verification section. */ - const resolvePersonalInfoFields = (schema: ProfileSchema): JSX.Element => { - if (isEmpty(schema)) { + const generateSingleMobileVerificationSection = (schema: ProfileSchema, fieldName: string) => { + + if (!isFeatureEnabled( + featureConfig?.personalInfo, + AppConstants.FEATURE_DICTIONARY.get("PROFILEINFO_MOBILE_VERIFICATION") + ) || schema.name !== MOBILE_ATTRIBUTE || !isMobileVerificationEnabled) { return null; } - const fieldName: string = t("myAccount:components.profile.fields." + schema.displayName, - { defaultValue: schema.displayName } + return ( + +

{ t("myAccount:components.profile.messages.mobileVerification.content") }

+ + + + { fieldName } + + + + + { isProfileInfoLoading || profileSchemaLoader + ? + : profileInfo.get(schema.name) + || ( + { + setShowMobileUpdateWizard(true); + } } + onKeyPress={ ( + { key }: React.KeyboardEvent + ) => + { + if (key === "Enter") { + setShowMobileUpdateWizard(true); + } + } + } + data-testid={ + `${testId}-schema-mobile-editing-section-${ + schema.name.replace(".", "-") + }-placeholder` + } + > + { t("myAccount:components.profile.forms.generic." + + "inputs.placeholder", { + fieldName: fieldName.toLowerCase() }) + } + + ) + + } + + + + + + + setShowMobileUpdateWizard(true) }> + { t("common:update").toString() } + + + + dispatch(setActiveForm(null)) }> + { t("common:cancel").toString() } + + + + +
); + }; - // Define the field placeholder for text fields. - let innerPlaceholder: string = t("myAccount:components.profile.forms.generic." + - "inputs.placeholder", - { - fieldName: fieldName.toLowerCase() - } ); + /** + * This function generates the editable form for the schema. + * + * @param schema - The schema to generate the form for. + * @returns The editable form. + */ + const generateEditableForm = (schema: ProfileSchema, fieldName: string): JSX.Element => { - // Concatenate the date format for the birth date field. - if (schema.name === ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("DOB")) { - innerPlaceholder += " in the format YYYY-MM-DD"; - } + return ( + + + + { fieldName } + + ) => + handleSubmit(values, schema.name, schema.extended, schema) }> + { generateFormFields(schema, fieldName) } + + + + + + ); + }; + + /** + * This function generates the read-only form for the schema. + * + * @param schema - The schema to generate the form for. + * @returns The read-only form. + */ + const generateReadOnlyForm = (schema: ProfileSchema, fieldName: string): JSX.Element => { + + return ( + + + + { + !showEmail && fieldName.toLowerCase() === "username" + ? fieldName + " (Email)" + : fieldName } + + + + + + { generateReadOnlyFieldContent(schema, fieldName) } + + + + + + { generateEditIcon(schema) } + + + + + ); + }; + + /** + * This function generates the form fields for the schema. + * + * @param schema - The schema to generate the form fields for. + * @param fieldName - The field name to generate the form fields for. + * @returns The form fields. + */ + const generateFormFields = (schema: ProfileSchema, fieldName: string): JSX.Element => { if (checkSchemaType(schema.name, "country")) { - return ( + return generateCountryDropdown(schema, fieldName); + } else if (checkSchemaType(schema.name, "locale")) { + return generateEditableLocaleField(schema, fieldName); + } else if (schema.name === EMAIL_ADDRESSES_ATTRIBUTE + || schema.name === MOBILE_NUMBERS_ATTRIBUTE) { + return generateMultiValuedField(schema, fieldName); + } else { + return generateTextField(schema, fieldName); + } + }; + + const generateMultiValuedField = (schema: ProfileSchema, fieldName: string): JSX.Element => { + + let primaryAttributeSchema: ProfileSchema; + let attributeValueList: string[] = []; + let verifiedAttributeValueList: string[] = []; + let primaryAttributeValue: string = ""; + let verificationEnabled: boolean = false; + let verifyPopupHeader: string = ""; + let pendingEmailAddress: string = ""; + let maxAllowedLimit: number = 0; + + if (schema.name === EMAIL_ADDRESSES_ATTRIBUTE) { + verifyPopupHeader = t("myAccount:components.profile.actions.verifyEmail"); + attributeValueList = profileInfo?.get(EMAIL_ADDRESSES_ATTRIBUTE)?.split(",") ?? []; + verifiedAttributeValueList = profileInfo?.get(VERIFIED_EMAIL_ADDRESSES_ATTRIBUTE)?.split(",") ?? []; + pendingEmailAddress = profileDetails?.profileInfo?.pendingEmails?.length > 0 + ? profileDetails?.profileInfo?.pendingEmails[0]?.value + : null; + primaryAttributeValue = profileDetails?.profileInfo?.emails?.length > 0 + ? profileDetails?.profileInfo?.emails[0] + : null; + primaryAttributeValue = profileDetails?.profileInfo?.emails[0]; + verificationEnabled = isEmailVerificationEnabled; + primaryAttributeSchema = getSchemaFromName(EMAIL_ATTRIBUTE); + maxAllowedLimit = ProfileConstants.MAX_EMAIL_ADDRESSES_ALLOWED; + + } else if (schema.name === MOBILE_NUMBERS_ATTRIBUTE) { + verifyPopupHeader = t("myAccount:components.profile.actions.verifyMobile"); + attributeValueList = profileInfo?.get(MOBILE_NUMBERS_ATTRIBUTE)?.split(",") ?? []; + verifiedAttributeValueList = profileInfo?.get(VERIFIED_MOBILE_NUMBERS_ATTRIBUTE)?.split(",") ?? []; + primaryAttributeValue = profileInfo?.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("MOBILE")); + verificationEnabled = isMobileVerificationEnabled; + primaryAttributeSchema = getSchemaFromName(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 showPendingEmailPopup = (value: string): boolean => { + return verificationEnabled + && schema.name === EMAIL_ADDRESSES_ATTRIBUTE + && pendingEmailAddress + && value === pendingEmailAddress; + }; + + 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 showVerifyButton = (value: string): boolean => + verificationEnabled && !verifiedAttributeValueList.includes(value) && value !== primaryAttributeValue; + + const showDeleteButton = (value: string): boolean => { + return !(primaryAttributeSchema?.required && value === primaryAttributeValue); + }; + + return ( + <> = maxAllowedLimit } + className="multi-input-box" autoFocus={ true } label="" name={ schema.name } - placeholder={ t("myAccount:components.profile.forms." + - "countryChangeForm.inputs.country.placeholder") } + placeholder={ getPlaceholderText(schema, fieldName) } required={ schema.required } requiredErrorMessage={ - t("myAccount:components.profile.forms.generic" + - ".inputs.validations.empty", { fieldName }) + t("myAccount:components.profile.forms.generic.inputs.validations.empty", + { fieldName }) } + type="text" + validation={ + (value: string, validation: Validation) => validateField(value, validation, schema, fieldName) } + maxLength={ + schema.name === EMAIL_ADDRESSES_ATTRIBUTE + ? EMAIL_MAX_LENGTH + : primaryAttributeSchema.maxLength ?? ProfileConstants.CLAIM_VALUE_MAX_LENGTH + } + data-componentid={ + `${testId}-editing-section-${ + schema.name.replace(".", "-") + }-field` + } + /> + + { + dispatch(setActiveForm(null)); + } } + size="small" + type="button" + value={ t("common:cancel").toString() } + data-testid={ + `${testId}-schema-mobile-editing-section-${ + schema.name.replace(".", "-") + }-cancel-button` + } + /> + + ); + }; + + const generateCountryDropdown = (schema: ProfileSchema, fieldName: string): JSX.Element => { + + return ( + <> + { - return { - "data-testid": `${testId}-${list.value as string}`, - flag: list.flag, - key: list.key as string, - text: list.text as string, - value: list.value as string - }; - }) : [] } + children={ countryList ? countryList.map((list: DropdownItemProps) => ({ + "data-testid": `${testId}-${list.value as string}`, + flag: list.flag, + key: list.key as string, + text: list.text as string, + value: list.value as string + })) : [] } value={ resolveProfileInfoSchemaValue(schema) } disabled={ false } clearable={ !schema.required } @@ -897,15 +1780,45 @@ export const Profile: FunctionComponent = (props: ProfileProps): R selection fluid /> - ); - } +