From 98b5191eac55c796a0f7c6b8f624d0e54477fb62 Mon Sep 17 00:00:00 2001 From: Pasindu Yeshan Date: Fri, 12 Jul 2024 08:49:19 +0530 Subject: [PATCH 01/27] Add multi valued fields --- .../server-configurations-constants.ts | 11 + .../components/user-profile.scss | 96 +++ .../components/user-profile.tsx | 607 +++++++++++++++++- features/admin.users.v1/pages/user-edit.tsx | 21 + 4 files changed, 727 insertions(+), 8 deletions(-) create mode 100644 features/admin.users.v1/components/user-profile.scss diff --git a/features/admin.server-configurations.v1/constants/server-configurations-constants.ts b/features/admin.server-configurations.v1/constants/server-configurations-constants.ts index e30ed64a3ad..63419f85e5e 100644 --- a/features/admin.server-configurations.v1/constants/server-configurations-constants.ts +++ b/features/admin.server-configurations.v1/constants/server-configurations-constants.ts @@ -310,6 +310,17 @@ export class ServerConfigurationsConstants { public static readonly PASSWORD_POLICY_PATTERN: string = "passwordPolicy.pattern"; public static readonly PASSWORD_POLICY_ERROR_MESSAGE: string = "passwordPolicy.errorMsg"; + /** + * User claim update - API Keyword constants. + */ + public static readonly ENABLE_MOBILE_VERIFICATION: string = "UserClaimUpdate.MobileNumber.EnableVerification"; + + public static readonly ENABLE_EMAIL_VERIFICATION: string = "UserClaimUpdate.Email.EnableVerification"; + public static readonly ENABLE_MOBILE_VERIFICATION_BY_PRIVILEGED_USER: string + = "UserClaimUpdate.MobileNumber.EnableVerificationByPrivilegedUser"; + public static readonly ENABLE_MULTIPLE_EMAILS_AND_MOBILE_NUMBERS: string + = "UserClaimUpdate.EnableMultipleEmailsAndMobileNumbers"; + /** * Real Configurations constants. */ diff --git a/features/admin.users.v1/components/user-profile.scss b/features/admin.users.v1/components/user-profile.scss new file mode 100644 index 00000000000..f350aaca620 --- /dev/null +++ b/features/admin.users.v1/components/user-profile.scss @@ -0,0 +1,96 @@ +/** + * 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. + */ + +.oxygen-accordion { + .accordion-summary { + display: flex; + padding: 0px 12px 0px 8px; + flex-wrap: nowrap; + align-items: center; + gap: 4px; + border-radius: 8px; + + .accordion-label { + margin: 0 0 0 8px; + flex-shrink: 0; + padding-right: 4px; + min-width: 250px; + + &.mobile-label { + min-width: 150px; + } + } + + .verified-icon, .primary-icon { + align-items: center; + padding: 0 4px; + } + } + + .accordion-details { + border-radius: 8px; + + .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; + font-size: 1rem; + + .c1-value{ + margin-bottom: 0; + flex-shrink: 0; + padding-right: 12px; + min-width: 250px; + + &.mobile-label { + min-width: 150px; + } + } + + .verified-icon, .primary-icon { + align-items: center; + padding: 0 4px; + } + } + + .table-c2 { + display: flex; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-end; + gap: 4px; + } + } + } + + .MuiAccordionDetails-root { + padding: 0px; + } + + .MuiAccordionSummary-root { + min-height: 48px; + } +} \ No newline at end of file diff --git a/features/admin.users.v1/components/user-profile.tsx b/features/admin.users.v1/components/user-profile.tsx index 065b5f98c79..d5d8688d22e 100644 --- a/features/admin.users.v1/components/user-profile.tsx +++ b/features/admin.users.v1/components/user-profile.tsx @@ -15,6 +15,18 @@ * specific language governing permissions and limitations * 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 AccordionSummary from "@oxygen-ui/react/AccordionSummary"; +import IconButton from "@oxygen-ui/react/IconButton"; +import Paper from "@oxygen-ui/react/Paper"; +// import Tooltip from "@oxygen-ui/react/Tooltip"; +import { CheckIcon, ChevronDownIcon, StarIcon, TrashIcon } from "@oxygen-ui/react-icons"; import { Show } from "@wso2is/access-control"; import { AppConstants, AppState, FeatureConfigInterface, history } from "@wso2is/admin.core.v1"; import { SCIMConfigs, commonConfig, userConfig } from "@wso2is/admin.extensions.v1"; @@ -51,12 +63,13 @@ import { DangerZone, DangerZoneGroup, EmphasizedSegment, - useConfirmationModalAlert + useConfirmationModalAlert, + Tooltip } from "@wso2is/react-components"; import { AxiosError, AxiosResponse } from "axios"; import isEmpty from "lodash-es/isEmpty"; import moment from "moment"; -import React, { FunctionComponent, ReactElement, ReactNode, useCallback, useEffect, useState } from "react"; +import React, { FunctionComponent, ReactElement, ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; import { Dispatch } from "redux"; @@ -65,6 +78,7 @@ import { ChangePasswordComponent } from "./user-change-password"; import { updateUserInfo } from "../api"; import { AdminAccountTypes, LocaleJoiningSymbol, UserManagementConstants } from "../constants"; import { AccountConfigSettingsInterface, SchemaAttributeValueInterface, SubValueInterface } from "../models"; +import "./user-profile.scss"; /** * Prop types for the basic details component. @@ -193,10 +207,44 @@ 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>({ + [ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAIL_ADDRESSES")]: false, + [ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("MOBILE_NUMBERS")]: false + }); + // 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 ]); + + const isEmailVerificationEnabled: boolean = useMemo(() => { + return connectorProperties?.find((property: ConnectorPropertyInterface) => + property.name === ServerConfigurationsConstants.ENABLE_EMAIL_VERIFICATION + )?.value === "true"; + }, [ connectorProperties ]); - useEffect(() => { + const isMultiValuedEmailMobileEnabled: boolean = useMemo(() => { + return connectorProperties?.find((property: ConnectorPropertyInterface) => + property.name === ServerConfigurationsConstants.ENABLE_MULTIPLE_EMAILS_AND_MOBILE_NUMBERS + )?.value === "true"; + }, [ connectorProperties ]); + + const isMobileVerificationByPrivilegeUserEnabled: boolean = useMemo(() => { + return connectorProperties?.find((property: ConnectorPropertyInterface) => + property.name === ServerConfigurationsConstants.ENABLE_MOBILE_VERIFICATION_BY_PRIVILEGED_USER + )?.value === "true"; + }, [ connectorProperties ]); + useEffect(() => { if (connectorProperties && Array.isArray(connectorProperties) && connectorProperties?.length > 0) { let configurationStatuses: AccountConfigSettingsInterface = { ...configSettings } ; @@ -1282,7 +1330,479 @@ export const UserProfile: FunctionComponent = ( ); }; + /** + * Delete a multi-valued attribute value. + * + * @param schema - schema of the attribute + * @param value - value of the attribute + */ + const handleMultiValuedItemDelete = (schema: ProfileSchemaInterface, value: string) => { + + const data: { + Operations: Array<{ + op: string, value: Record + | Array + | Array>> + }>, + schemas: Array + } = { + Operations: [ + { + op: "replace", + value: {} + } + ], + schemas: [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ] + }; + + if (schema.name === ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAIL_ADDRESSES")) { + const emailList: string[] = profileInfo?.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY. + get("EMAIL_ADDRESSES"))?.split(",") || []; + const updatedEmailList: string[] = emailList.filter((email: string) => email !== value); + const primaryEmail: string = profileInfo?.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAILS")); + + data.Operations[0].value = { + [schema.schemaId] : { + [ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAIL_ADDRESSES")]: updatedEmailList.join(",") + } + }; + + if (value === primaryEmail) { + data.Operations.push({ + op: "replace", + value: { + [ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAILS")]: [] + } + }); + } + } else if (schema.name === ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("MOBILE_NUMBERS")) { + const mobileList: string[] = profileInfo?.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY. + get("MOBILE_NUMBERS"))?.split(",") || []; + const updatedMobileList: string[] = mobileList.filter((mobile: string) => mobile !== value); + const primaryMobile: string = profileInfo.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("MOBILE")); + + if (value === primaryMobile) { + data.Operations.push({ + op: "replace", + value: { + [ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("PHONE_NUMBERS")]: [ { + type: "mobile", + value: "" + } ] + } + }); + } + + data.Operations[0].value = { + [schema.schemaId]: { + [ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("MOBILE_NUMBERS")]: updatedMobileList.join(",") + } + }; + } + + 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); + }); + }; + + /** + * Assign primary email address or mobile number the multi-valued attribute. + * + * @param schema - Schema of the attribute + * @param value - Value of the attribute + */ + const handleMakePrimary = (schema: ProfileSchemaInterface, value: string) => { + + const data: { + Operations: Array<{ + op: string, + value: Record | Array | Array> + > + }>, + schemas: Array + } = { + Operations: [ + { + op: "replace", + value: {} + } + ], + schemas: [ "urn:ietf:params:scim:api:messages:2.0:PatchOp" ] + }; + + if (schema.name === ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAIL_ADDRESSES")) { + + data.Operations[0].value = { + [ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAILS")]: [ value ] + }; + } else if (schema.name === ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("MOBILE_NUMBERS")) { + + data.Operations[0].value = { + [ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("PHONE_NUMBERS")]: [ + { + type: "mobile", + value + } + ] + }; + } + 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); + }); + }; + + + /** + * This function returns the schema for a given schema name. + * + * @param schemaName - Schema name + * @returns Profile schema + */ + const getSchemaFromName = (schemaName: string): ProfileSchemaInterface => { + + return profileSchema.find((schema: ProfileSchemaInterface) => schema?.name === schemaName); + }; + + const resolveMultiValuedAttributesFormField = ( + schema: ProfileSchemaInterface, + fieldName: string, + key: number + ): ReactElement => { + + let attributeValueList: string[] = []; + let verifiedAttributeValueList: string[] = []; + let primaryAttributeValue: string = ""; + let verificationEnabled: boolean = false; + + if (schema.name === ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAIL_ADDRESSES")) { + attributeValueList = profileInfo?.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY. + get("EMAIL_ADDRESSES"))?.split(",") ?? []; + verifiedAttributeValueList = profileInfo?.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY. + get("VERIFIED_EMAIL_ADDRESSES"))?.split(",") ?? []; + primaryAttributeValue = profileInfo?.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAILS")); + verificationEnabled = isEmailVerificationEnabled; + + } else if (schema.name === ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("MOBILE_NUMBERS")) { + attributeValueList = profileInfo?.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY. + get("MOBILE_NUMBERS"))?.split(",") ?? []; + verifiedAttributeValueList = profileInfo?.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY. + get("VERIFIED_MOBILE_NUMBERS"))?.split(",") ?? []; + primaryAttributeValue = profileInfo?.get(ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("MOBILE")); + verificationEnabled = connectorProperties.find( + (connectorProperty: ConnectorPropertyInterface) => + connectorProperty.name + === ServerConfigurationsConstants.ENABLE_MOBILE_VERIFICATION_BY_PRIVILEGED_USER + )?.value === "true"; + } + + // Move the primary attribute value to the top of the list. + if (!isEmpty(primaryAttributeValue)) { + attributeValueList = attributeValueList.filter((value: string) => 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); + }; + + 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; + } + }; + + return ( + <> + { + if (!RegExp(schema.regEx).test(value)) { + validation.isValid = false; + validation.errorMessages + .push(t("users:forms.validation.formatError", { + field: fieldName + })); + } + } } + maxLength={ + fieldName.toLowerCase().includes("uri") || fieldName.toLowerCase().includes("url") + ? 1024 + : ( + schema.maxLength + ? schema.maxLength + : ProfileConstants.CLAIM_VALUE_MAX_LENGTH + ) + } + /> + + + ); + }; + const resolveFormField = (schema: ProfileSchemaInterface, fieldName: string, key: number): ReactElement => { + if (schema.type.toUpperCase() === "BOOLEAN") { return ( = ( fluid /> ); + } else if ( + schema?.name === ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAIL_ADDRESSES") + || schema?.name === ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("MOBILE_NUMBERS") + ) { + return resolveMultiValuedAttributesFormField(schema, fieldName, key); } else { return ( = ( * @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[] = [ + isMultiValuedEmailMobileEnabled + ? ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAILS") + : ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAIL_ADDRESSES"), + isMultiValuedEmailMobileEnabled + ? ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("MOBILE") + : ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("MOBILE_NUMBERS"), + ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("VERIFIED_MOBILE_NUMBERS"), + ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("VERIFIED_EMAIL_ADDRESSES") + ]; + + if (fieldsToHide.some((name: string) => schema.name === name)) { + return; + } + const fieldName: string = t("user:profile.fields." + schema.name.replace(".", "_"), { defaultValue: schema.displayName } ); @@ -1460,7 +2002,7 @@ export const UserProfile: FunctionComponent = ( return ( - + { schema.name === "userName" && domainName.length > 1 ? ( <> @@ -1529,6 +2071,52 @@ 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; + } + + let translationKey: string = ""; + + if (selectedAttributeInfo?.schema?.name === ProfileConstants.SCIM2_SCHEMA_DICTIONARY.get("EMAIL_ADDRESSES")) { + translationKey = "myAccount:components.profile.modals.emailAddressDeleteConfirmation."; + } else { + translationKey = "myAccount:components.profile.modals.mobileNumberDeleteConfirmation."; + } + + return ( + + + { t(`${translationKey}heading`) } + + + { t(`${translationKey}description`) } + + + { t(`${translationKey}content`) } + + + ); + }; + return ( !isReadOnlyUserStoresLoading ? (<> @@ -1546,7 +2134,7 @@ export const UserProfile: FunctionComponent = ( { user.id && ( - +