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>
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
b0c403f5d1
Коммит
27575d50c2
@@ -27,6 +27,7 @@ import {
|
|||||||
import {trackEvent} from 'actions/telemetry_actions.jsx';
|
import {trackEvent} from 'actions/telemetry_actions.jsx';
|
||||||
|
|
||||||
import CustomPluginSettings from 'components/admin_console/custom_plugin_settings';
|
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 PluginManagement from 'components/admin_console/plugin_management';
|
||||||
import SystemAnalytics from 'components/analytics/system_analytics';
|
import SystemAnalytics from 'components/analytics/system_analytics';
|
||||||
import {searchableStrings as systemAnalyticsSearchableStrings} from 'components/analytics/system_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'),
|
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',
|
type: 'text',
|
||||||
key: 'SamlSettings.LocaleAttribute',
|
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 {
|
.section-body {
|
||||||
padding: 28px 32px;
|
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', '');
|
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) {
|
if (active) {
|
||||||
const inputs = [];
|
const inputs = [];
|
||||||
|
let extraInfo: JSX.Element|string;
|
||||||
|
let submit = null;
|
||||||
|
|
||||||
let attributeLabel: JSX.Element | string = (
|
if ((this.props.user.auth_service === Constants.LDAP_SERVICE && attribute.attrs?.ldap) ||
|
||||||
attribute.name
|
(this.props.user.auth_service === Constants.SAML_SERVICE && attribute.attrs?.saml)) {
|
||||||
);
|
extraInfo = (
|
||||||
if (this.props.isMobileView) {
|
<span>
|
||||||
attributeLabel = '';
|
<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.'
|
||||||
if (attribute.type === 'select' || attribute.type === 'multiselect') {
|
/>
|
||||||
const attribOptions: PropertyFieldOption[] = attribute.attrs!.options as PropertyFieldOption[];
|
</span>
|
||||||
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 {
|
} else {
|
||||||
const inputType = attribute.type as string;
|
let attributeLabel: JSX.Element | string = (
|
||||||
inputs.push(
|
attribute.name
|
||||||
<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>,
|
|
||||||
);
|
);
|
||||||
|
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 = (
|
max = (
|
||||||
<SettingItemMax
|
<SettingItemMax
|
||||||
key={'settingItemMax_' + attribute.id}
|
key={'settingItemMax_' + attribute.id}
|
||||||
title={attribute.name}
|
title={attribute.name}
|
||||||
inputs={inputs}
|
inputs={inputs}
|
||||||
submit={this.submitAttribute.bind(this, [attribute.id])}
|
submit={submit}
|
||||||
saving={this.state.sectionIsSaving}
|
saving={this.state.sectionIsSaving}
|
||||||
serverError={this.state.serverError}
|
serverError={this.state.serverError}
|
||||||
clientError={this.state.clientError}
|
clientError={this.state.clientError}
|
||||||
|
|||||||
@@ -694,6 +694,10 @@
|
|||||||
"admin.customization.uniqueEmojiReactionLimitPerPost.minValue": "Cannot decrease the limit below 0.",
|
"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.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.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.empty": "No items found",
|
||||||
"admin.data_grid.loading": "Loading",
|
"admin.data_grid.loading": "Loading",
|
||||||
"admin.data_grid.paginatorCount": "{startCount, number} - {endCount, number} of {total, number}",
|
"admin.data_grid.paginatorCount": "{startCount, number} - {endCount, number} of {total, number}",
|
||||||
|
|||||||
@@ -62,6 +62,24 @@
|
|||||||
|
|
||||||
.admin-console__content {
|
.admin-console__content {
|
||||||
max-width: 920px;
|
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 {
|
.admin-console__checkbox-list {
|
||||||
@@ -228,6 +246,10 @@
|
|||||||
|
|
||||||
.help-text {
|
.help-text {
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
|
|
||||||
|
.help-text-warning {
|
||||||
|
color: var(--error-text)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user