diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 68b696f100..54053a0c70 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -27,6 +27,7 @@ import { import {trackEvent} from 'actions/telemetry_actions.jsx'; import CustomPluginSettings from 'components/admin_console/custom_plugin_settings'; +import CustomProfileAttributes from 'components/admin_console/custom_profile_attributes/custom_profile_attributes'; import PluginManagement from 'components/admin_console/plugin_management'; import SystemAnalytics from 'components/analytics/system_analytics'; import {searchableStrings as systemAnalyticsSearchableStrings} from 'components/analytics/system_analytics/system_analytics'; @@ -3917,6 +3918,15 @@ const AdminDefinition: AdminDefinitionType = { ), ), }, + { + type: 'custom', + key: 'LdapSettings.CustomProfileAttributes', + component: CustomProfileAttributes, + isHidden: it.not(it.all( + it.licensedForSku(LicenseSkus.Enterprise), + it.configIsTrue('FeatureFlags', 'CustomProfileAttributes'), + )), + }, ], }, { @@ -4620,6 +4630,15 @@ const AdminDefinition: AdminDefinitionType = { it.stateIsFalse('SamlSettings.Enable'), ), }, + { + type: 'custom', + key: 'SamlSettings.CustomProfileAttributes', + component: CustomProfileAttributes, + isHidden: it.not(it.all( + it.licensedForSku(LicenseSkus.Enterprise), + it.configIsTrue('FeatureFlags', 'CustomProfileAttributes'), + )), + }, { type: 'text', key: 'SamlSettings.LocaleAttribute', diff --git a/webapp/channels/src/components/admin_console/custom_profile_attributes/custom_profile_attributes.test.tsx b/webapp/channels/src/components/admin_console/custom_profile_attributes/custom_profile_attributes.test.tsx new file mode 100644 index 0000000000..57d2afff3d --- /dev/null +++ b/webapp/channels/src/components/admin_console/custom_profile_attributes/custom_profile_attributes.test.tsx @@ -0,0 +1,271 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {screen, fireEvent} from '@testing-library/react'; +import React from 'react'; +import {act} from 'react-dom/test-utils'; + +import type {UserPropertyField, UserPropertyFieldGroupID, UserPropertyFieldType} from '@mattermost/types/properties'; + +import {Client4} from 'mattermost-redux/client'; + +import {renderWithContext} from 'tests/react_testing_utils'; + +import CustomProfileAttributes from './custom_profile_attributes'; + +jest.mock('mattermost-redux/client'); + +describe('components/admin_console/custom_profile_attributes/CustomProfileAttributes', () => { + const baseProps = { + isDisabled: false, + setSaveNeeded: jest.fn(), + registerSaveAction: jest.fn(), + unRegisterSaveAction: jest.fn(), + }; + + const baseField: Omit = { + type: 'text', + group_id: 'custom_profile_attributes' as UserPropertyFieldGroupID, + create_at: 1736541716295, + delete_at: 0, + update_at: 0, + }; + + const createAttribute = (id: string, name: string, attrs: Record): UserPropertyField => ({ + ...baseField, + id, + name, + attrs: { + ...attrs, + sort_order: 0, + visibility: 'when_set', + value_type: '', + }, + }); + + const attr1 = createAttribute('attr1', 'Department', {ldap: 'department'}); + const attr2 = createAttribute('attr2', 'Location', {ldap: 'location'}); + const samlAttr = createAttribute('attr3', 'Title', {saml: 'title'}); + + const createInitialState = (attributes: Record) => ({ + entities: { + general: { + customProfileAttributes: attributes, + }, + }, + }); + + const initialState = createInitialState({attr1, attr2}); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('should not render anything when no attributes exist', () => { + const {container} = renderWithContext( + , + ); + + expect(container.firstChild).toBeNull(); + }); + + describe('LDAP attributes', () => { + test('should render LDAP attributes with correct help text', async () => { + renderWithContext( + , + initialState, + ); + + await screen.findByText('Department'); + await screen.findByText('Location'); + + expect(screen.getByDisplayValue('department')).toBeInTheDocument(); + expect(screen.getByDisplayValue('location')).toBeInTheDocument(); + + const helpText = screen.getAllByText((content) => content.includes('When set, users cannot edit their')); + expect(helpText).toHaveLength(2); + }); + + test('should save LDAP attribute changes', async () => { + jest.spyOn(Client4, 'patchCustomProfileAttributeField').mockResolvedValue({ + ...attr1, + attrs: { + ...attr1.attrs, + ldap: 'new-department', + }, + }); + + renderWithContext( + , + initialState, + ); + + const input = await screen.findByDisplayValue('department'); + fireEvent.change(input, {target: {value: 'new-department'}}); + + const saveAction = baseProps.registerSaveAction.mock.calls[1][0]; + await act(async () => { + await saveAction(); + }); + + expect(Client4.patchCustomProfileAttributeField).toHaveBeenCalledWith('attr1', { + type: 'text', + attrs: { + ldap: 'new-department', + sort_order: 0, + value_type: '', + visibility: 'when_set', + }, + }); + }); + }); + + describe('SAML attributes', () => { + const samlInitialState = createInitialState({samlAttr}); + test('should render SAML attributes with correct help text', async () => { + (Client4.getCustomProfileAttributeFields as jest.Mock).mockImplementation(async () => { + return [samlAttr]; + }); + renderWithContext( + , + samlInitialState, + ); + + await screen.findByText('Title'); + expect(screen.getByDisplayValue('title')).toBeInTheDocument(); + + const helpText = screen.getByText((content) => content.includes('The attribute in the SAML Assertion')); + expect(helpText).toBeInTheDocument(); + }); + + test('should save SAML attribute changes', async () => { + jest.spyOn(Client4, 'patchCustomProfileAttributeField').mockResolvedValue({ + ...samlAttr, + attrs: { + ...samlAttr.attrs, + saml: 'new-title', + }, + }); + + renderWithContext( + , + samlInitialState, + ); + + const input = await screen.findByDisplayValue('title'); + fireEvent.change(input, {target: {value: 'new-title'}}); + + const saveAction = baseProps.registerSaveAction.mock.calls[1][0]; + await act(async () => { + await saveAction(); + }); + + expect(Client4.patchCustomProfileAttributeField).toHaveBeenCalledWith('attr3', { + type: 'text', + attrs: { + saml: 'new-title', + sort_order: 0, + value_type: '', + visibility: 'when_set', + }, + }); + }); + }); + + test('should show warning for non-text attributes', async () => { + const selectAttr = {...attr1, type: 'select' as UserPropertyFieldType}; + const selectInitialState = createInitialState({selectAttr}); + + renderWithContext( + , + selectInitialState, + ); + + const warning = await screen.findByText((content) => content.includes('This attribute will be converted to a TEXT attribute')); + expect(warning).toBeInTheDocument(); + }); + + test('should handle save errors gracefully', async () => { + jest.spyOn(Client4, 'patchCustomProfileAttributeField').mockRejectedValue(new Error('Network error')); + + renderWithContext( + , + initialState, + ); + + const input = await screen.findByDisplayValue('department'); + fireEvent.change(input, {target: {value: 'new-department'}}); + + const saveAction = baseProps.registerSaveAction.mock.calls[1][0]; + + // Verify the save action catches and returns the error + await expect(saveAction()).resolves.toEqual( + expect.objectContaining({ + error: expect.any(Error), + }), + ); + }); + + test('should respect disabled state', async () => { + renderWithContext( + , + initialState, + ); + + const input = await screen.findByDisplayValue('department'); + expect(input).toBeDisabled(); + }); + + test('should handle empty attribute values', async () => { + const emptyAttr = createAttribute('attr1', 'Department', {ldap: ''}); + const emptyInitialState = createInitialState({emptyAttr}); + + renderWithContext( + , + emptyInitialState, + ); + + const input = await screen.findByDisplayValue(''); + expect(input).toBeInTheDocument(); + }); + + test('should cleanup on unmount', async () => { + const {unmount} = renderWithContext( + , + initialState, + ); + + await screen.findByDisplayValue('department'); + + // Verify save action was registered + expect(baseProps.registerSaveAction).toHaveBeenCalledTimes(1); + const saveAction = baseProps.registerSaveAction.mock.calls[0][0]; + + unmount(); + + // Verify same save action was unregistered + expect(baseProps.unRegisterSaveAction).toHaveBeenCalledWith(saveAction); + }); + + test('should handle invalid attribute types', async () => { + const invalidAttr = {...attr1, type: 'invalid_type' as any}; + const invalidInitialState = createInitialState({invalidAttr}); + + renderWithContext( + , + invalidInitialState, + ); + + const warning = await screen.findByText((content) => content.includes('This attribute will be converted to a TEXT attribute')); + expect(warning).toBeInTheDocument(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/custom_profile_attributes/custom_profile_attributes.tsx b/webapp/channels/src/components/admin_console/custom_profile_attributes/custom_profile_attributes.tsx new file mode 100644 index 0000000000..5e748b09db --- /dev/null +++ b/webapp/channels/src/components/admin_console/custom_profile_attributes/custom_profile_attributes.tsx @@ -0,0 +1,182 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useEffect, useState, memo} from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; +import {Link} from 'react-router-dom'; + +import type {UserPropertyField, UserPropertyFieldType} from '@mattermost/types/properties'; + +import {Client4} from 'mattermost-redux/client'; +import {getCustomProfileAttributes} from 'mattermost-redux/selectors/entities/general'; + +import SettingsGroup from 'components/admin_console/settings_group'; +import TextSetting from 'components/admin_console/text_setting'; + +import type {GlobalState} from 'types/store'; + +type AttributeHelpTextProps = { + attributeKey: string; + attributeName: string; + attributeType: string; +}; + +const AttributeHelpText = memo(({attributeKey, attributeName, attributeType}: AttributeHelpTextProps) => ( +
+ {attributeKey === 'ldap' && ( + {msg}, + }} + /> + )} + {attributeKey === 'saml' && ( + + )} + {attributeType !== 'text' && ( +
+ {msg}, + }} + /> +
+ )} +
+)); + +AttributeHelpText.displayName = 'AttributeHelpText'; + +type Props = { + isDisabled?: boolean; + setSaveNeeded: () => void; + registerSaveAction: (saveAction: () => Promise) => void; + unRegisterSaveAction: (saveAction: () => Promise) => void; + id?: string; +} + +type SaveActionResult = { + error?: Error; +}; + +const getAttributeKey = (id?: string) => { + return id === 'SamlSettings.CustomProfileAttributes' ? 'saml' : 'ldap'; +}; + +const CustomProfileAttributes: React.FC = (props: Props): JSX.Element | null => { + const customProfileAttributeFields = useSelector((state: GlobalState) => getCustomProfileAttributes(state)); + const [attributes, setAttributes] = useState( + Object.values(customProfileAttributeFields), + ); + const [originalAttributes] = useState(attributes); + const attributeKey = getAttributeKey(props.id); + + useEffect(() => { + const handleSave = async () => { + try { + await Promise.all( + attributes.map((attr) => { + const original = originalAttributes.find((o) => o.id === attr.id); + if (original?.attrs?.[attributeKey] !== attr.attrs?.[attributeKey]) { + const updatedAttr = { + type: 'text' as UserPropertyFieldType, + attrs: { + ...attr.attrs, + }, + }; + return Client4.patchCustomProfileAttributeField(attr.id, updatedAttr); + } + return Promise.resolve(null); + }), + ); + + return {error: undefined} as SaveActionResult; + } catch (error) { + return {error} as SaveActionResult; + } + }; + + props.registerSaveAction(handleSave); + return () => props.unRegisterSaveAction(handleSave); + }, [props.registerSaveAction, props.unRegisterSaveAction, attributes, originalAttributes, attributeKey, props]); + + if (attributes.length === 0) { + return null; + } + + return ( + + } + subtitle={ + ( + + {msg} + + ), + }} + /> + } + > + {attributes.map((attr) => ( + { + setAttributes((prevAttrs) => prevAttrs.map((a) => { + if (a.id === attr.id) { + return { + ...a, + attrs: { + ...a.attrs, + [attributeKey]: newValue, + }, + }; + } + return a; + })); + props.setSaveNeeded(); + }} + setByEnv={false} + disabled={props.isDisabled} + placeholder={{id: 'admin.customProfileAttr.placeholder', defaultMessage: 'E.g.: "fieldName"'}} + helpText={ + + } + /> + ))} + + ); +}; + +export default CustomProfileAttributes; diff --git a/webapp/channels/src/components/admin_console/schema_admin_settings.scss b/webapp/channels/src/components/admin_console/schema_admin_settings.scss index 32bae08f20..7c407362de 100644 --- a/webapp/channels/src/components/admin_console/schema_admin_settings.scss +++ b/webapp/channels/src/components/admin_console/schema_admin_settings.scss @@ -23,6 +23,12 @@ .section-body { padding: 28px 32px; + + .section-header { + padding: 24px 0px; + border-top: 1px solid rgba(61, 60, 64, 0.08); + border-bottom: none; + } } } diff --git a/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx b/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx index 74589bd9d2..19d5b13033 100644 --- a/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx +++ b/webapp/channels/src/components/user_settings/general/user_settings_general.test.tsx @@ -505,4 +505,72 @@ describe('components/user_settings/general/UserSettingsGeneral', () => { expect(props.actions.saveCustomProfileAttribute).toHaveBeenCalledWith('user_id', 'field1', ''); }); + + test('should not show custom attribute input field when LDAP attribute is set', async () => { + const props = { + ...requiredProps, + enableCustomProfileAttributes: true, + customProfileAttributeFields: [ + { + ...customProfileAttribute, + attrs: { + ...customProfileAttribute.attrs, + ldap: 'ldap_field', + }, + }, + ], + user: {...user, auth_service: 'ldap'}, + activeSection: 'customAttribute_field1', + }; + + renderWithContext(); + expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument(); + expect(screen.queryByRole('textbox', {name: customProfileAttribute.name})).not.toBeInTheDocument(); + expect(await screen.findByText('This field is handled through your login provider. If you want to change it, you need to do so through your login provider.')).toBeInTheDocument(); + }); + + test('should not show custom attribute input field when SAML attribute is set', async () => { + const props = { + ...requiredProps, + enableCustomProfileAttributes: true, + customProfileAttributeFields: [ + { + ...customProfileAttribute, + attrs: { + ...customProfileAttribute.attrs, + saml: 'saml_field', + }, + }, + ], + user: {...user, auth_service: 'saml'}, + activeSection: 'customAttribute_field1', + }; + + renderWithContext(); + expect(await screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument(); + expect(screen.queryByRole('textbox', {name: customProfileAttribute.name})).not.toBeInTheDocument(); + expect(await screen.findByText('This field is handled through your login provider. If you want to change it, you need to do so through your login provider.')).toBeInTheDocument(); + }); + + test('should show custom attribute input field when LDAP auth but no LDAP attribute set', async () => { + const props = { + ...requiredProps, + enableCustomProfileAttributes: true, + customProfileAttributeFields: [ + { + ...customProfileAttribute, + attrs: { + ...customProfileAttribute.attrs, + ldap: '', + }, + }, + ], + user: {...user, auth_service: 'ldap'}, + activeSection: 'customAttribute_field1', + }; + + renderWithContext(); + expect(await screen.getByRole('button', {name: 'Save'})).toBeInTheDocument(); + expect(screen.queryByRole('textbox', {name: customProfileAttribute.name})).toBeInTheDocument(); + }); }); diff --git a/webapp/channels/src/components/user_settings/general/user_settings_general.tsx b/webapp/channels/src/components/user_settings/general/user_settings_general.tsx index bbcd1f2098..857fc30841 100644 --- a/webapp/channels/src/components/user_settings/general/user_settings_general.tsx +++ b/webapp/channels/src/components/user_settings/general/user_settings_general.tsx @@ -1433,81 +1433,97 @@ export class UserSettingsGeneralTab extends PureComponent { if (active) { const inputs = []; + let extraInfo: JSX.Element|string; + let submit = null; - let attributeLabel: JSX.Element | string = ( - attribute.name - ); - if (this.props.isMobileView) { - attributeLabel = ''; - } - - if (attribute.type === 'select' || attribute.type === 'multiselect') { - const attribOptions: PropertyFieldOption[] = attribute.attrs!.options as PropertyFieldOption[]; - const opts = attribOptions.map((o) => { - return {label: o.name, value: o.id} as SelectOption; - }); - inputs.push( - this.updateSelectAttribute(v, a, attribute.id)} - />, + if ((this.props.user.auth_service === Constants.LDAP_SERVICE && attribute.attrs?.ldap) || + (this.props.user.auth_service === Constants.SAML_SERVICE && attribute.attrs?.saml)) { + extraInfo = ( + + + ); } else { - const inputType = attribute.type as string; - inputs.push( -
- -
- -
-
, + let attributeLabel: JSX.Element | string = ( + attribute.name ); + if (this.props.isMobileView) { + attributeLabel = ''; + } + + if (attribute.type === 'select' || attribute.type === 'multiselect') { + const attribOptions: PropertyFieldOption[] = attribute.attrs!.options as PropertyFieldOption[]; + const opts = attribOptions.map((o) => { + return {label: o.name, value: o.id} as SelectOption; + }); + inputs.push( + this.updateSelectAttribute(v, a, attribute.id)} + />, + ); + } else { + const inputType = attribute.type as string; + inputs.push( +
+ +
+ +
+
, + ); + } + extraInfo = ( + + + + ); + + submit = this.submitAttribute.bind(this, [attribute.id]); } - const extraInfo = ( - - - - ); max = ( system properties page.", + "admin.customProfileAttributes.title": "Custom profile attributes sync", + "admin.customProfileAttribWarning": "(Warning) This attribute will be converted to a TEXT attribute, if the field is set to synchronize.", "admin.data_grid.empty": "No items found", "admin.data_grid.loading": "Loading", "admin.data_grid.paginatorCount": "{startCount, number} - {endCount, number} of {total, number}", diff --git a/webapp/channels/src/sass/routes/_admin-console.scss b/webapp/channels/src/sass/routes/_admin-console.scss index 0e7e5ede19..a1965a08fd 100644 --- a/webapp/channels/src/sass/routes/_admin-console.scss +++ b/webapp/channels/src/sass/routes/_admin-console.scss @@ -62,6 +62,24 @@ .admin-console__content { max-width: 920px; + + .admin-console__content { + border-bottom: 1px solid rgba(61, 60, 64, 0.08); + } + + .section-header { + padding: 24px 0px; + border-top: 1px solid rgba(61, 60, 64, 0.08); + + .section-title { + font-size: 16px; + font-weight: bold; + } + + .section-subtitle { + font-size: 14px; + } + } } .admin-console__checkbox-list { @@ -228,6 +246,10 @@ .help-text { white-space: pre-line; + + .help-text-warning { + color: var(--error-text) + } } .form-group {