MM-62703 Implement cpa for ldap/saml in System Console (#30350)

* implement cpa for ldap/saml for System Console

* i18n-extract

* update tests for changes

* revert package-lock.json

* fixes from review commnts

* import link

* fix bad merge

* more fixes

* update tests

* put behind a featureflag

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Scott Bishel
2025-04-09 10:36:51 -06:00
коммит произвёл GitHub
родитель b0c403f5d1
Коммит 27575d50c2
8 изменённых файлов: 652 добавлений и 64 удалений

Просмотреть файл

@@ -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',

Просмотреть файл

@@ -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<UserPropertyField, 'id' | 'name' | 'attrs'> = {
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<string, string>): 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<string, UserPropertyField>) => ({
entities: {
general: {
customProfileAttributes: attributes,
},
},
});
const initialState = createInitialState({attr1, attr2});
beforeEach(() => {
jest.clearAllMocks();
});
test('should not render anything when no attributes exist', () => {
const {container} = renderWithContext(
<CustomProfileAttributes {...baseProps}/>,
);
expect(container.firstChild).toBeNull();
});
describe('LDAP attributes', () => {
test('should render LDAP attributes with correct help text', async () => {
renderWithContext(
<CustomProfileAttributes {...baseProps}/>,
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(
<CustomProfileAttributes {...baseProps}/>,
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(
<CustomProfileAttributes
{...baseProps}
id='SamlSettings.CustomProfileAttributes'
/>,
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(
<CustomProfileAttributes
{...baseProps}
id='SamlSettings.CustomProfileAttributes'
/>,
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(
<CustomProfileAttributes {...baseProps}/>,
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(
<CustomProfileAttributes {...baseProps}/>,
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(
<CustomProfileAttributes
{...baseProps}
isDisabled={true}
/>,
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(
<CustomProfileAttributes {...baseProps}/>,
emptyInitialState,
);
const input = await screen.findByDisplayValue('');
expect(input).toBeInTheDocument();
});
test('should cleanup on unmount', async () => {
const {unmount} = renderWithContext(
<CustomProfileAttributes {...baseProps}/>,
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(
<CustomProfileAttributes {...baseProps}/>,
invalidInitialState,
);
const warning = await screen.findByText((content) => content.includes('This attribute will be converted to a TEXT attribute'));
expect(warning).toBeInTheDocument();
});
});

Просмотреть файл

@@ -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) => (
<div className='help-text-container'>
{attributeKey === 'ldap' && (
<FormattedMessage
id='admin.customProfileAttribDesc'
defaultMessage='(Optional) The attribute in the AD/LDAP server used to populate the {name} of users in Mattermost. When set, users cannot edit their {name}, since it is synchronized with the LDAP server. When left blank, users can set their {name} in <strong>Account Menu > Account Settings > Profile</strong>.'
values={{
name: attributeName,
strong: (msg: string) => <strong>{msg}</strong>,
}}
/>
)}
{attributeKey === 'saml' && (
<FormattedMessage
id='admin.customProfileAttribDesc'
defaultMessage='(Optional) The attribute in the SAML Assertion that will be used to populate the {name} of users in Mattermost.'
values={{
name: attributeName,
}}
/>
)}
{attributeType !== 'text' && (
<div className='help-text-warning'>
<FormattedMessage
id='admin.customProfileAttribWarning'
defaultMessage='(Warning) This attribute will be converted to a TEXT attribute, if the field is set to synchronize.'
values={{
name: attributeName,
strong: (msg: string) => <strong>{msg}</strong>,
}}
/>
</div>
)}
</div>
));
AttributeHelpText.displayName = 'AttributeHelpText';
type Props = {
isDisabled?: boolean;
setSaveNeeded: () => void;
registerSaveAction: (saveAction: () => Promise<unknown>) => void;
unRegisterSaveAction: (saveAction: () => Promise<unknown>) => void;
id?: string;
}
type SaveActionResult = {
error?: Error;
};
const getAttributeKey = (id?: string) => {
return id === 'SamlSettings.CustomProfileAttributes' ? 'saml' : 'ldap';
};
const CustomProfileAttributes: React.FC<Props> = (props: Props): JSX.Element | null => {
const customProfileAttributeFields = useSelector((state: GlobalState) => getCustomProfileAttributes(state));
const [attributes, setAttributes] = useState<UserPropertyField[]>(
Object.values(customProfileAttributeFields),
);
const [originalAttributes] = useState<UserPropertyField[]>(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 (
<SettingsGroup
id={props.id}
title={
<FormattedMessage
id='admin.customProfileAttributes.title'
defaultMessage='Custom profile attributes sync'
/>
}
subtitle={
<FormattedMessage
id='admin.customProfileAttributes.subtitle'
defaultMessage='You can add or remove custom profile attributes by going to the <link>system properties page</link>.'
values={{
link: (msg: string) => (
<Link
to='/admin_console/site_config/system_properties'
>
{msg}
</Link>
),
}}
/>
}
>
{attributes.map((attr) => (
<TextSetting
key={attr.id}
id={`custom_profile_attribute-${attr.name}`}
label={attr.name}
value={attr.attrs?.[attributeKey] as string || ''}
onChange={(id, newValue) => {
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={
<AttributeHelpText
attributeKey={attributeKey}
attributeName={attr.name}
attributeType={attr.type}
/>
}
/>
))}
</SettingsGroup>
);
};
export default CustomProfileAttributes;

Просмотреть файл

@@ -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;
}
}
}

Просмотреть файл

@@ -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(<UserSettingsGeneral {...props}/>);
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(<UserSettingsGeneral {...props}/>);
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(<UserSettingsGeneral {...props}/>);
expect(await screen.getByRole('button', {name: 'Save'})).toBeInTheDocument();
expect(screen.queryByRole('textbox', {name: customProfileAttribute.name})).toBeInTheDocument();
});
});

Просмотреть файл

@@ -1433,81 +1433,97 @@ export class UserSettingsGeneralTab extends PureComponent<Props, State> {
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(
<ReactSelect
isMulti={attribute.type === 'multiselect' ? true : undefined}
key={sectionName}
id={'customProfileAttribute_' + attribute.id}
inputId={'customProfileAttribute_' + attribute.id + '_input'}
className='react-select inlineSelect'
classNamePrefix='react-select'
options={opts}
isClearable={true}
isSearchable={false}
isDisabled={false}
placeholder={formatMessage({
id: 'user.settings.general.select',
defaultMessage: 'Select',
})}
components={{IndicatorSeparator: null}}
styles={selectStyles}
value={getDisplayValue(this.state.customAttributeValues[attribute.id]) as SelectOption}
onChange={(v, a) => 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 = (
<span>
<FormattedMessage
id='user.settings.general.field_handled_externally'
defaultMessage='This field is handled through your login provider. If you want to change it, you need to do so through your login provider.'
/>
</span>
);
} else {
const inputType = attribute.type as string;
inputs.push(
<div
key={sectionName}
className='form-group'
>
<label className='col-sm-5 control-label'>{attributeLabel}</label>
<div className='col-sm-7'>
<input
id={sectionName}
autoFocus={true}
className='form-control'
type={inputType}
onChange={this.updateAttribute}
value={getDisplayValue(this.state.customAttributeValues[attribute.id]) as string}
maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_LENGTH}
autoCapitalize='off'
onFocus={Utils.moveCursorToEnd}
aria-label={attribute.name}
/>
</div>
</div>,
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(
<ReactSelect
isMulti={attribute.type === 'multiselect' ? true : undefined}
key={sectionName}
id={'customProfileAttribute_' + attribute.id}
inputId={'customProfileAttribute_' + attribute.id + '_input'}
className='react-select inlineSelect'
classNamePrefix='react-select'
options={opts}
isClearable={true}
isSearchable={false}
isDisabled={false}
placeholder={formatMessage({
id: 'user.settings.general.select',
defaultMessage: 'Select',
})}
components={{IndicatorSeparator: null}}
styles={selectStyles}
value={getDisplayValue(this.state.customAttributeValues[attribute.id]) as SelectOption}
onChange={(v, a) => this.updateSelectAttribute(v, a, attribute.id)}
/>,
);
} else {
const inputType = attribute.type as string;
inputs.push(
<div
key={sectionName}
className='form-group'
>
<label className='col-sm-5 control-label'>{attributeLabel}</label>
<div className='col-sm-7'>
<input
id={sectionName}
autoFocus={true}
className='form-control'
type={inputType}
onChange={this.updateAttribute}
value={getDisplayValue(this.state.customAttributeValues[attribute.id]) as string}
maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_LENGTH}
autoCapitalize='off'
onFocus={Utils.moveCursorToEnd}
aria-label={attribute.name}
/>
</div>
</div>,
);
}
extraInfo = (
<span>
<FormattedMessage
id='user.settings.general.attributeExtra'
defaultMessage='This will be shown in your profile popover.'
/>
</span>
);
submit = this.submitAttribute.bind(this, [attribute.id]);
}
const extraInfo = (
<span>
<FormattedMessage
id='user.settings.general.attributeExtra'
defaultMessage='This will be shown in your profile popover.'
/>
</span>
);
max = (
<SettingItemMax
key={'settingItemMax_' + attribute.id}
title={attribute.name}
inputs={inputs}
submit={this.submitAttribute.bind(this, [attribute.id])}
submit={submit}
saving={this.state.sectionIsSaving}
serverError={this.state.serverError}
clientError={this.state.clientError}

Просмотреть файл

@@ -694,6 +694,10 @@
"admin.customization.uniqueEmojiReactionLimitPerPost.minValue": "Cannot decrease the limit below 0.",
"admin.customization.uniqueEmojiReactionLimitPerPostDesc": "The number of unique emoji reactions that can be added to a post. Increasing this limit could lead to poor client performance. Maximum is 500.",
"admin.customization.uniqueEmojiReactionLimitPerPostPlaceholder": "E.g.: 25",
"admin.customProfileAttribDesc": "(Optional) The attribute in the SAML Assertion that will be used to populate the {name} of users in Mattermost.",
"admin.customProfileAttributes.subtitle": "You can add or remove custom profile attributes by going to the <link>system properties page</link>.",
"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}",

Просмотреть файл

@@ -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 {