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 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 {
|
||||
|
||||
Ссылка в новой задаче
Block a user