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 <caleb@calebroseland.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
Asaad Mahmood
2025-07-24 14:31:56 +05:00
committed by GitHub
co-authored by Caleb Roseland Mattermost Build
parent 257eec43ed
commit 990bdb7ee2
10 changed files with 257 additions and 62 deletions
@@ -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<void>;
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<HTMLInputElement>) => setValue(e.target.value);
const handleCancel = () => onExited();
const handleConfirm = () => onSave(value);
const isConfirmDisabled = () => value.length > MAX_LDAP_LENGTH;
const showTypeWarning = fieldType !== 'text';
return (
<GenericModal
id='attributeModal'
modalHeaderText={modalHeaderText}
confirmButtonText={
<FormattedMessage
id='save'
defaultMessage='Save'
/>
}
compassDesign={true}
onExited={onExited}
handleEnterKeyPress={handleConfirm}
handleConfirm={handleConfirm}
handleCancel={handleCancel}
isConfirmDisabled={isConfirmDisabled()}
>
<QuickInput
size='lg'
inputComponent={MaxLengthInput}
autoFocus={true}
className='form-control filter-textbox'
placeholder={'department'}
type='text'
value={value}
clearable={true}
onClear={handleClear}
onChange={handleChange}
/>
<span className='help-text'>
{helpText}
</span>
{showTypeWarning && (
<div
className='alert alert-warning'
style={{marginTop: '12px'}}
>
<FormattedMessage
id='admin.customProfileAttribWarning'
defaultMessage='(Warning) This attribute will be converted to a TEXT attribute, if the field is set to synchronize.'
/>
</div>
)}
{error && <div className='error-text'>{error}</div>}
</GenericModal>
);
};
export default AttributeModal;
@@ -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 () => {
@@ -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: (
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.ad_ldap.modal.helpText'
defaultMessage="The attribute in the AD/LDAP server used to sync as a custom attribute in user's profile in Mattermost."
/>
),
modalHeaderText: (
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.ad_ldap.link_property.label'
defaultMessage='Link attribute to AD/LDAP'
/>
),
},
}));
};
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: (
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.saml.modal.helpText'
defaultMessage="The attribute in the SAML server used to sync as a custom attribute in user's profile in Mattermost."
/>
),
modalHeaderText: (
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.saml.modal.title'
defaultMessage='Link attribute to SAML'
/>
),
},
}));
};
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 = ({
/>
</Menu.SubMenu>
{field.create_at !== 0 && ([
<Menu.LinkItem
<Menu.Item
key={`${menuId}_link_ad-ldap`}
id={`${menuId}_link_ad-ldap`}
to={`/admin_console/authentication/ldap#custom_profile_attribute-${field.name}`}
leadingElement={<SyncIcon size={18}/>}
onClick={() => promptEditLdapLink()}
labels={field.attrs.ldap ? (
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.ad_ldap.edit_link.label'
defaultMessage={'Edit link with: <Chip>AD/LDAP: {propertyName}</Chip>'}
values={{
Chip: (chunks: React.ReactNode) => <Chip>{chunks}</Chip>,
propertyName: field.attrs.ldap,
}}
defaultMessage='Edit LDAP link'
/>
) : (
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.ad_ldap.link_property.label'
defaultMessage={'Link attribute to AD/LDAP'}
defaultMessage='Link attribute to AD/LDAP'
/>
)}
/>,
<Menu.LinkItem
<Menu.Item
key={`${menuId}_link_saml`}
id={`${menuId}_link_saml`}
to={`/admin_console/authentication/saml#custom_profile_attribute-${field.name}`}
leadingElement={<SyncIcon size={18}/>}
onClick={() => promptEditSamlLink()}
labels={field.attrs.saml ? (
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.saml.edit_link.label'
defaultMessage={'Edit link with: <Chip>SAML: {propertyName}</Chip>'}
values={{
Chip: (chunks: React.ReactNode) => <Chip>{chunks}</Chip>,
propertyName: field.attrs.saml,
}}
defaultMessage='Edit SAML link'
/>
) : (
<FormattedMessage
id='admin.system_properties.user_properties.dotmenu.saml.link_property.label'
defaultMessage={'Link attribute to SAML'}
defaultMessage='Link attribute to SAML'
/>
)}
/>,
@@ -250,13 +325,4 @@ const DotMenu = ({
);
};
const Chip = ({children, ...rest}: ComponentProps<'span'>) => (
<span
className='user-property-field-dotmenu__chip'
{...rest}
>
{children}
</span>
);
export default DotMenu;
@@ -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;
}
@@ -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();
});
});
@@ -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 && (
<BlockableLink
<a
className='user-property-field-values__chip-link'
to={`/admin_console/authentication/ldap#custom_profile_attribute-${field.name}`}
key={`${field.name}-ldap`}
data-testid={`user-property-field-values__ldap-${field.name}`}
onClick={() => promptEditLdapLink()}
onKeyDown={(e) => {
if (isKeyPressed(e, Constants.KeyCodes.ENTER) || isKeyPressed(e, Constants.KeyCodes.SPACE)) {
promptEditLdapLink();
}
}}
role='button'
tabIndex={0}
>
<FormattedMessage
id='admin.system_properties.user_properties.table.values.synced_with.ldap'
defaultMessage='AD/LDAP: {propertyName}'
values={{propertyName: field.attrs.ldap}}
/>
</BlockableLink>
</a>
),
field.attrs.saml && (
<BlockableLink
<a
className='user-property-field-values__chip-link'
to={`/admin_console/authentication/saml#custom_profile_attribute-${field.name}`}
key={`${field.name}-saml`}
data-testid={`user-property-field-values__saml-${field.name}`}
onClick={() => promptEditSamlLink()}
onKeyDown={(e) => {
if (isKeyPressed(e, Constants.KeyCodes.ENTER) || isKeyPressed(e, Constants.KeyCodes.SPACE)) {
promptEditSamlLink();
}
}}
role='button'
tabIndex={0}
>
<FormattedMessage
id='admin.system_properties.user_properties.table.values.synced_with.saml'
defaultMessage='SAML: {propertyName}'
values={{propertyName: field.attrs.saml}}
/>
</BlockableLink>
</a>
),
].filter(Boolean);
return (
<span className='user-property-field-values'>
<SyncIcon size={18}/>
<FormattedMessage
id='admin.system_properties.user_properties.table.values.synced_with'
defaultMessage='Synced with: {syncedProperties}'
values={{syncedProperties: <FormattedList value={syncedProperties}/>}}
/>
</span>
<>
<span className='user-property-field-values'>
<SyncIcon size={18}/>
<FormattedMessage
id='admin.system_properties.user_properties.table.values.synced_with'
defaultMessage='Synced with: {syncedProperties}'
values={{syncedProperties: <FormattedList value={syncedProperties}/>}}
/>
</span>
</>
);
}
if (field.type !== 'multiselect' && field.type !== 'select') {
return (
<>
<span className='user-property-field-values'>
{'-'}
</>
</span>
);
}
@@ -80,6 +80,7 @@ export type Props = {
id?: string;
onInput?: (e?: React.FormEvent<HTMLInputElement>) => 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<HTMLInputElement | HTMLTextAreaElement | null>(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',
}),
},
);
+7 -3
View File
@@ -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: <Chip>AD/LDAP: {propertyName}</Chip>",
"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: <Chip>SAML: {propertyName}</Chip>",
"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",
+2
View File
@@ -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 = {
@@ -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 {