From 990bdb7ee2cecec32b40aeba73e19cbc5a3d991a Mon Sep 17 00:00:00 2001 From: Asaad Mahmood Date: Thu, 24 Jul 2025 14:31:56 +0500 Subject: [PATCH] Adding the CPA LDAP/SAML Modal (#31195) * Adding the CPA LDAP/SAML Modal * Fixing lint * updating lint and separating file * Updating lint * updating lint * updating lint * updating css * Updating ldap modal * Updating translation order * Updating modal and other tests * updating test * updating * use hook in values cell * sync chips a11y --------- Co-authored-by: Caleb Roseland Co-authored-by: Mattermost Build --- .../system_properties/attribute_modal.tsx | 89 +++++++++++++ .../user_properties_dot_menu.test.tsx | 14 +- .../user_properties_dot_menu.tsx | 120 ++++++++++++++---- .../user_properties_values.scss | 1 + .../user_properties_values.test.tsx | 10 +- .../user_properties_values.tsx | 54 +++++--- .../components/quick_input/quick_input.tsx | 6 + webapp/channels/src/i18n/en.json | 10 +- webapp/channels/src/utils/constants.tsx | 2 + .../src/generic_modal/generic_modal.scss | 13 ++ 10 files changed, 257 insertions(+), 62 deletions(-) create mode 100644 webapp/channels/src/components/admin_console/system_properties/attribute_modal.tsx diff --git a/webapp/channels/src/components/admin_console/system_properties/attribute_modal.tsx b/webapp/channels/src/components/admin_console/system_properties/attribute_modal.tsx new file mode 100644 index 00000000000..a114ff0d88d --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_properties/attribute_modal.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useState} from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {GenericModal} from '@mattermost/components'; + +import QuickInput, {MaxLengthInput} from 'components/quick_input'; + +const MAX_LDAP_LENGTH = 64; + +type Props = { + initialValue: string; + fieldType: string; + onExited: () => void; + onSave: (value: string) => Promise; + error: string | null; + helpText: React.ReactNode; + modalHeaderText: JSX.Element; +}; + +const AttributeModal = ({ + initialValue, + fieldType, + onExited, + onSave, + error, + helpText, + modalHeaderText, +}: Props) => { + const [value, setValue] = useState(initialValue); + const handleClear = () => setValue(''); + const handleChange = (e: React.ChangeEvent) => setValue(e.target.value); + const handleCancel = () => onExited(); + const handleConfirm = () => onSave(value); + const isConfirmDisabled = () => value.length > MAX_LDAP_LENGTH; + + const showTypeWarning = fieldType !== 'text'; + + return ( + + } + compassDesign={true} + onExited={onExited} + handleEnterKeyPress={handleConfirm} + handleConfirm={handleConfirm} + handleCancel={handleCancel} + isConfirmDisabled={isConfirmDisabled()} + > + + + {helpText} + + {showTypeWarning && ( +
+ +
+ )} + {error &&
{error}
} +
+ ); +}; + +export default AttributeModal; diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx index 4aeeb971a63..4da526bbf6e 100644 --- a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.test.tsx @@ -139,7 +139,7 @@ describe('UserPropertyDotMenu', () => { expect(screen.queryByText('Link attribute to SAML')).not.toBeInTheDocument(); }); - it('shows "Edit link with" text when LDAP attribute is linked', async () => { + it('shows "Edit LDAP link" text when LDAP attribute is linked', async () => { const linkedField = { ...baseField, attrs: { @@ -154,12 +154,11 @@ describe('UserPropertyDotMenu', () => { const menuButton = screen.getByTestId(`user-property-field_dotmenu-${linkedField.id}`); fireEvent.click(menuButton); - // Verify the LDAP link text shows the linked property - expect(screen.getByText('Edit link with:')).toBeInTheDocument(); - expect(screen.getByText('AD/LDAP: employeeID')).toBeInTheDocument(); + // Verify the LDAP link text shows the edit option + expect(screen.getByText('Edit LDAP link')).toBeInTheDocument(); }); - it('shows "Edit link with" text when SAML attribute is linked', async () => { + it('shows "Edit SAML link" text when SAML attribute is linked', async () => { const linkedField = { ...baseField, attrs: { @@ -174,9 +173,8 @@ describe('UserPropertyDotMenu', () => { const menuButton = screen.getByTestId(`user-property-field_dotmenu-${linkedField.id}`); fireEvent.click(menuButton); - // Verify the SAML link text shows the linked property - expect(screen.getByText('Edit link with:')).toBeInTheDocument(); - expect(screen.getByText('SAML: position')).toBeInTheDocument(); + // Verify the SAML link text shows the edit option + expect(screen.getByText('Edit SAML link')).toBeInTheDocument(); }); it('handles field duplication', async () => { diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx index dd9520186ab..3f8c94b1e3c 100644 --- a/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_dot_menu.tsx @@ -1,19 +1,25 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {ComponentProps} from 'react'; import React from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch} from 'react-redux'; import {CheckIcon, ChevronRightIcon, DotsHorizontalIcon, EyeOutlineIcon, SyncIcon, TrashCanOutlineIcon, ContentCopyIcon} from '@mattermost/compass-icons/components'; import type {FieldVisibility, UserPropertyField} from '@mattermost/types/properties'; +import {openModal} from 'actions/views/modals'; + import * as Menu from 'components/menu'; -import './user_properties_dot_menu.scss'; +import {ModalIdentifiers} from 'utils/constants'; + +import AttributeModal from './attribute_modal'; import {useUserPropertyFieldDelete} from './user_properties_delete_modal'; import {isCreatePending} from './user_properties_utils'; +import './user_properties_dot_menu.scss'; + type Props = { field: UserPropertyField; canCreate: boolean; @@ -22,6 +28,82 @@ type Props = { deleteField: (id: string) => void; } +export const useAttributeLinkModal = (field: UserPropertyField, updateField: Props['updateField']) => { + const dispatch = useDispatch(); + + const promptEditLdapLink = () => { + dispatch(openModal({ + modalId: ModalIdentifiers.ATTRIBUTE_MODAL_LDAP, + dialogType: AttributeModal, + dialogProps: { + initialValue: field.attrs.ldap || '', + fieldType: field.type, + onExited: () => {}, + onSave: async (newValue: string) => { + updateField({ + ...field, + type: 'text', + attrs: { + ...field.attrs, + ldap: newValue, + }, + }); + }, + error: null, + helpText: ( + + ), + modalHeaderText: ( + + ), + }, + })); + }; + + const promptEditSamlLink = () => { + dispatch(openModal({ + modalId: ModalIdentifiers.ATTRIBUTE_MODAL_SAML, + dialogType: AttributeModal, + dialogProps: { + initialValue: field.attrs.saml || '', + fieldType: field.type, + onExited: () => {}, + onSave: async (newValue: string) => { + updateField({ + ...field, + type: 'text', + attrs: { + ...field.attrs, + saml: newValue, + }, + }); + }, + error: null, + helpText: ( + + ), + modalHeaderText: ( + + ), + }, + })); + }; + + return {promptEditLdapLink, promptEditSamlLink}; +}; + const menuId = 'user-property-field_dotmenu'; const DotMenu = ({ @@ -33,6 +115,7 @@ const DotMenu = ({ }: Props) => { const {formatMessage} = useIntl(); const {promptDelete} = useUserPropertyFieldDelete(); + const {promptEditLdapLink, promptEditSamlLink} = useAttributeLinkModal(field, updateField); const handleDuplicate = () => { const name = formatMessage({ @@ -177,45 +260,37 @@ const DotMenu = ({ /> {field.create_at !== 0 && ([ - } + onClick={() => promptEditLdapLink()} labels={field.attrs.ldap ? ( AD/LDAP: {propertyName}'} - values={{ - Chip: (chunks: React.ReactNode) => {chunks}, - propertyName: field.attrs.ldap, - }} + defaultMessage='Edit LDAP link' /> ) : ( )} />, - } + onClick={() => promptEditSamlLink()} labels={field.attrs.saml ? ( SAML: {propertyName}'} - values={{ - Chip: (chunks: React.ReactNode) => {chunks}, - propertyName: field.attrs.saml, - }} + defaultMessage='Edit SAML link' /> ) : ( )} />, @@ -250,13 +325,4 @@ const DotMenu = ({ ); }; -const Chip = ({children, ...rest}: ComponentProps<'span'>) => ( - - {children} - -); - export default DotMenu; diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_values.scss b/webapp/channels/src/components/admin_console/system_properties/user_properties_values.scss index b0ebe573ef3..6cb1e00c565 100644 --- a/webapp/channels/src/components/admin_console/system_properties/user_properties_values.scss +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_values.scss @@ -1,6 +1,7 @@ .user-property-field-values { display: flex; align-items: center; + padding: 0 12px; color: rgba(var(--center-channel-color-rgb), 0.75); gap: 4px; } diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_values.test.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_values.test.tsx index 86b31e1966d..37cb31e2e48 100644 --- a/webapp/channels/src/components/admin_console/system_properties/user_properties_values.test.tsx +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_values.test.tsx @@ -160,10 +160,9 @@ describe('UserPropertyValues', () => { const ldapLink = screen.getByText('AD/LDAP: ldapAttribute'); expect(ldapLink).toBeInTheDocument(); - // Check that the link points to the correct location + // Check that the clickable element is present (no longer checking href) const linkElement = screen.getByTestId(`user-property-field-values__ldap-${ldapField.name}`); expect(linkElement).toBeInTheDocument(); - expect(linkElement).toHaveAttribute('href', `/admin_console/authentication/ldap#custom_profile_attribute-${baseField.name}`); }); it('shows SAML sync information when field has SAML attribute', () => { @@ -182,10 +181,9 @@ describe('UserPropertyValues', () => { const samlLink = screen.getByText('SAML: samlAttribute'); expect(samlLink).toBeInTheDocument(); - // Check that the link points to the correct location + // Check that the clickable element is present (no longer checking href) const linkElement = screen.getByTestId(`user-property-field-values__saml-${samlField.name}`); expect(linkElement).toBeInTheDocument(); - expect(linkElement).toHaveAttribute('href', `/admin_console/authentication/saml#custom_profile_attribute-${baseField.name}`); }); it('shows both LDAP and SAML sync information when field has both attributes', () => { @@ -209,11 +207,11 @@ describe('UserPropertyValues', () => { const samlLink = screen.getByText('SAML: samlAttribute'); expect(samlLink).toBeInTheDocument(); - // Check that both links point to the correct locations + // Check that both clickable elements are present (no longer checking href) const ldapLinkElement = screen.getByTestId(`user-property-field-values__ldap-${baseField.name}`); expect(ldapLinkElement).toBeInTheDocument(); - const samlLinkElement = screen.getByTestId(`user-property-field-values__ldap-${baseField.name}`); + const samlLinkElement = screen.getByTestId(`user-property-field-values__saml-${baseField.name}`); expect(samlLinkElement).toBeInTheDocument(); }); }); diff --git a/webapp/channels/src/components/admin_console/system_properties/user_properties_values.tsx b/webapp/channels/src/components/admin_console/system_properties/user_properties_values.tsx index 24bfdebe86d..2906243ba3a 100644 --- a/webapp/channels/src/components/admin_console/system_properties/user_properties_values.tsx +++ b/webapp/channels/src/components/admin_console/system_properties/user_properties_values.tsx @@ -13,11 +13,11 @@ import {SyncIcon} from '@mattermost/compass-icons/components'; import type {PropertyFieldOption, UserPropertyField} from '@mattermost/types/properties'; import Constants from 'utils/constants'; +import {isKeyPressed} from 'utils/keyboard'; import {DangerText} from './controls'; - import './user_properties_values.scss'; -import BlockableLink from '../blockable_link'; +import {useAttributeLinkModal} from './user_properties_dot_menu'; type Props = { field: UserPropertyField; @@ -34,6 +34,8 @@ const UserPropertyValues = ({ const {formatMessage} = useIntl(); const [query, setQuery] = React.useState(''); + const {promptEditLdapLink, promptEditSamlLink} = useAttributeLinkModal(field, updateField); + const isQueryValid = useMemo(() => !checkForDuplicates(field.attrs.options, query.trim()), [field?.attrs?.options, query]); const addOption = (name: string) => { @@ -80,53 +82,69 @@ const UserPropertyValues = ({ const syncedProperties = [ field.attrs.ldap && ( - promptEditLdapLink()} + onKeyDown={(e) => { + if (isKeyPressed(e, Constants.KeyCodes.ENTER) || isKeyPressed(e, Constants.KeyCodes.SPACE)) { + promptEditLdapLink(); + } + }} + role='button' + tabIndex={0} > - + ), field.attrs.saml && ( - promptEditSamlLink()} + onKeyDown={(e) => { + if (isKeyPressed(e, Constants.KeyCodes.ENTER) || isKeyPressed(e, Constants.KeyCodes.SPACE)) { + promptEditSamlLink(); + } + }} + role='button' + tabIndex={0} > - + ), ].filter(Boolean); return ( - - - }} - /> - + <> + + + }} + /> + + ); } if (field.type !== 'multiselect' && field.type !== 'select') { return ( - <> + {'-'} - + ); } diff --git a/webapp/channels/src/components/quick_input/quick_input.tsx b/webapp/channels/src/components/quick_input/quick_input.tsx index 3a2a63c7cb3..7a8eef609e7 100644 --- a/webapp/channels/src/components/quick_input/quick_input.tsx +++ b/webapp/channels/src/components/quick_input/quick_input.tsx @@ -80,6 +80,7 @@ export type Props = { id?: string; onInput?: (e?: React.FormEvent) => void; tabIndex?: number; + size?: 'md' | 'lg'; role?: string; } @@ -102,6 +103,8 @@ export const QuickInput = React.memo(({ clearableWithoutValue, clearableTooltipText, onClear: onClearFromProps, + className, + size = 'md', ...restProps }: Props) => { const inputRef = useRef(null); @@ -169,6 +172,9 @@ export const QuickInput = React.memo(({ ...restProps, ref: setInputRef, defaultValue: value, // Only set the defaultValue since the real one will be updated using the 'useEffect' above + className: classNames(className, { + 'form-control--lg': size === 'lg', + }), }, ); diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index b11fa22b9a8..dc754430f14 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -2833,13 +2833,16 @@ "admin.system_properties.details.saving_changes": "Saving configuration…", "admin.system_properties.details.saving_changes_error": "There was an error while saving the configuration", "admin.system_properties.user_properties.add_property": "Add attribute", - "admin.system_properties.user_properties.dotmenu.ad_ldap.edit_link.label": "Edit link with: AD/LDAP: {propertyName}", + "admin.system_properties.user_properties.dotmenu.ad_ldap.edit_link.label": "Edit LDAP link", "admin.system_properties.user_properties.dotmenu.ad_ldap.link_property.label": "Link attribute to AD/LDAP", + "admin.system_properties.user_properties.dotmenu.ad_ldap.modal.helpText": "The attribute in the AD/LDAP server used to sync as a custom attribute in user's profile in Mattermost.", "admin.system_properties.user_properties.dotmenu.delete.label": "Delete attribute", "admin.system_properties.user_properties.dotmenu.duplicate.label": "Duplicate attribute", "admin.system_properties.user_properties.dotmenu.duplicate.name_copy": "{fieldName} (copy)", - "admin.system_properties.user_properties.dotmenu.saml.edit_link.label": "Edit link with: SAML: {propertyName}", - "admin.system_properties.user_properties.dotmenu.saml.link_property.label": "Link property to SAML", + "admin.system_properties.user_properties.dotmenu.saml.edit_link.label": "Edit SAML link", + "admin.system_properties.user_properties.dotmenu.saml.link_property.label": "Link attribute to SAML", + "admin.system_properties.user_properties.dotmenu.saml.modal.helpText": "The attribute in the SAML server used to sync as a custom attribute in user's profile in Mattermost.", + "admin.system_properties.user_properties.dotmenu.saml.modal.title": "Link attribute to SAML", "admin.system_properties.user_properties.dotmenu.visibility.always.label": "Always show", "admin.system_properties.user_properties.dotmenu.visibility.hidden.label": "Always hide", "admin.system_properties.user_properties.dotmenu.visibility.label": "Visibility", @@ -5246,6 +5249,7 @@ "rhs_root.mobile.unflag": "Remove from Saved", "rhs_thread.rootPostDeletedMessage.body": "Part of this thread has been deleted due to a data retention policy. You can no longer reply to this thread.", "rhs_thread.toast.newReplies": "New Replies", + "save": "Save", "save_button.save": "Save", "save_button.saving": "Saving", "saveChangesPanel.cancel": "Undo", diff --git a/webapp/channels/src/utils/constants.tsx b/webapp/channels/src/utils/constants.tsx index 4c40bead84f..8314f8b0c85 100644 --- a/webapp/channels/src/utils/constants.tsx +++ b/webapp/channels/src/utils/constants.tsx @@ -479,6 +479,8 @@ export const ModalIdentifiers = { SHARED_CHANNEL_REMOTE_UNINVITE: 'shared_channel_remote_uninvite', CONFIRM_RESET_FAILED_ATTEMPTS_MODAL: 'confirm_reset_failed_attempts_modal', USER_PROPERTY_FIELD_DELETE: 'user_property_field_delete', + ATTRIBUTE_MODAL_LDAP: 'attribute_modal_ldap', + ATTRIBUTE_MODAL_SAML: 'attribute_modal_saml', }; export const UserStatuses = { diff --git a/webapp/platform/components/src/generic_modal/generic_modal.scss b/webapp/platform/components/src/generic_modal/generic_modal.scss index 68eb5496261..6064859567f 100644 --- a/webapp/platform/components/src/generic_modal/generic_modal.scss +++ b/webapp/platform/components/src/generic_modal/generic_modal.scss @@ -51,8 +51,21 @@ border: var(--border-default); border-radius: 4px; + &.form-control--lg { + height: 48px; + font-size: 16px; + padding-left: 16px; + padding-right: 16px; + + & + .input-clear { + top: 17px; + right: 16px; + } + } + &:focus { border-color: var(--button-bg); + box-shadow: 0 0 0 1px var(--button-bg); } &.has-error {