[ABAC - Table Editor] Improvements on table editor and review feedback (#31125)

* reflect review comments

* update table editor

* adjust test limits

* reflect review comments

* MM-64376

* resolve conflicts

* address review comments

* fix merge conflict error
Этот коммит содержится в:
Ibrahim Serdar Acikgoz
2025-06-01 12:05:57 +02:00
коммит произвёл GitHub
родитель 489ea1fdd6
Коммит 6f26ad5cec
33 изменённых файлов: 1360 добавлений и 635 удалений

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

@@ -337,7 +337,7 @@ func unassignAccessPolicy(c *Context, w http.ResponseWriter, r *http.Request) {
} }
if len(assignments.ChannelIds) != 0 { if len(assignments.ChannelIds) != 0 {
appErr := c.App.UnAssignPoliciesFromChannels(c.AppContext, policyID, assignments.ChannelIds) appErr := c.App.UnassignPoliciesFromChannels(c.AppContext, policyID, assignments.ChannelIds)
if appErr != nil { if appErr != nil {
c.Err = appErr c.Err = appErr
return return

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

@@ -30,6 +30,7 @@ func (a *App) GetChannelsForPolicy(rctx request.CTX, policyID string, cursor mod
} }
channelIDs := make([]string, 0, len(policies)) channelIDs := make([]string, 0, len(policies))
// channel IDs are the same as policy IDs
for _, p := range policies { for _, p := range policies {
channelIDs = append(channelIDs, p.ID) channelIDs = append(channelIDs, p.ID)
} }
@@ -173,10 +174,10 @@ func (a *App) AssignAccessControlPolicyToChannels(rctx request.CTX, parentID str
return policies, nil return policies, nil
} }
func (a *App) UnAssignPoliciesFromChannels(rctx request.CTX, policyID string, channelIDs []string) *model.AppError { func (a *App) UnassignPoliciesFromChannels(rctx request.CTX, policyID string, channelIDs []string) *model.AppError {
acs := a.Srv().ch.AccessControl acs := a.Srv().ch.AccessControl
if acs == nil { if acs == nil {
return model.NewAppError("UnAssignPoliciesFromChannels", "app.pap.unassign_access_control_policy_from_channels.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented) return model.NewAppError("UnassignPoliciesFromChannels", "app.pap.unassign_access_control_policy_from_channels.app_error", nil, "Policy Administration Point is not initialized", http.StatusNotImplemented)
} }
cps, _, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{ cps, _, err := a.Srv().Store().AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{
@@ -184,7 +185,7 @@ func (a *App) UnAssignPoliciesFromChannels(rctx request.CTX, policyID string, ch
ParentID: policyID, ParentID: policyID,
}) })
if err != nil { if err != nil {
return model.NewAppError("UnAssignPoliciesFromChannels", "app.pap.unassign_access_control_policy_from_channels.app_error", nil, err.Error(), http.StatusInternalServerError) return model.NewAppError("UnassignPoliciesFromChannels", "app.pap.unassign_access_control_policy_from_channels.app_error", nil, err.Error(), http.StatusInternalServerError)
} }
childPolicies := make(map[string]bool) childPolicies := make(map[string]bool)

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

@@ -399,7 +399,7 @@ func TestUnAssignPoliciesFromChannels(t *testing.T) {
t.Run("Feature not enabled", func(t *testing.T) { t.Run("Feature not enabled", func(t *testing.T) {
th.App.Srv().ch.AccessControl = nil th.App.Srv().ch.AccessControl = nil
appErr := th.App.UnAssignPoliciesFromChannels(rctx, parentPolicy.ID, []string{ch1.Id, ch2.Id}) appErr := th.App.UnassignPoliciesFromChannels(rctx, parentPolicy.ID, []string{ch1.Id, ch2.Id})
require.NotNil(t, appErr) require.NotNil(t, appErr)
assert.Equal(t, "app.pap.unassign_access_control_policy_from_channels.app_error", appErr.Id) assert.Equal(t, "app.pap.unassign_access_control_policy_from_channels.app_error", appErr.Id)
}) })
@@ -412,7 +412,7 @@ func TestUnAssignPoliciesFromChannels(t *testing.T) {
mockAccessControl.On("DeletePolicy", rctx, ch1.Id).Return(expectedErr).Once() mockAccessControl.On("DeletePolicy", rctx, ch1.Id).Return(expectedErr).Once()
mockAccessControl.On("DeletePolicy", rctx, ch2.Id).Return(nil).Maybe() mockAccessControl.On("DeletePolicy", rctx, ch2.Id).Return(nil).Maybe()
appErr := th.App.UnAssignPoliciesFromChannels(rctx, parentPolicy.ID, []string{ch1.Id, ch2.Id}) appErr := th.App.UnassignPoliciesFromChannels(rctx, parentPolicy.ID, []string{ch1.Id, ch2.Id})
require.NotNil(t, appErr) require.NotNil(t, appErr)
assert.Equal(t, expectedErr.Id, appErr.Id) assert.Equal(t, expectedErr.Id, appErr.Id)
assert.Equal(t, expectedErr.Message, appErr.Message) assert.Equal(t, expectedErr.Message, appErr.Message)
@@ -438,7 +438,7 @@ func TestUnAssignPoliciesFromChannels(t *testing.T) {
mockAccessControl.On("DeletePolicy", rctx, ch1.Id).Return(nil).Once() mockAccessControl.On("DeletePolicy", rctx, ch1.Id).Return(nil).Once()
mockAccessControl.On("DeletePolicy", rctx, ch2.Id).Return(nil).Once() mockAccessControl.On("DeletePolicy", rctx, ch2.Id).Return(nil).Once()
appErr := th.App.UnAssignPoliciesFromChannels(rctx, parentPolicy.ID, []string{ch1.Id, ch2.Id, ch3.Id}) appErr := th.App.UnassignPoliciesFromChannels(rctx, parentPolicy.ID, []string{ch1.Id, ch2.Id, ch3.Id})
require.Nil(t, appErr) require.Nil(t, appErr)
}) })
@@ -449,7 +449,7 @@ func TestUnAssignPoliciesFromChannels(t *testing.T) {
mockAccessControl.On("DeletePolicy", rctx, ch1.Id).Return(nil).Once() mockAccessControl.On("DeletePolicy", rctx, ch1.Id).Return(nil).Once()
mockAccessControl.On("DeletePolicy", rctx, ch2.Id).Return(nil).Once() mockAccessControl.On("DeletePolicy", rctx, ch2.Id).Return(nil).Once()
appErr := th.App.UnAssignPoliciesFromChannels(rctx, parentPolicy.ID, []string{ch1.Id, ch2.Id}) appErr := th.App.UnassignPoliciesFromChannels(rctx, parentPolicy.ID, []string{ch1.Id, ch2.Id})
require.Nil(t, appErr) require.Nil(t, appErr)
}) })
} }

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

@@ -1614,7 +1614,7 @@ func (a *App) addUserToChannel(c request.CTX, user *model.User, channel *model.C
} }
} }
} else if appErr != nil { } else if appErr != nil {
c.Logger().Error("Error checking access control policy for channel", mlog.Err(appErr)) return nil, appErr
} }
} }

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

@@ -4,6 +4,7 @@
package app package app
import ( import (
"net/http"
"os" "os"
"os/signal" "os/signal"
"runtime" "runtime"
@@ -138,7 +139,7 @@ func NewChannels(s *Server) (*Channels, error) {
ch.AccessControl = accessControlServiceInterface(app) ch.AccessControl = accessControlServiceInterface(app)
appErr := ch.AccessControl.Init(request.EmptyContext(s.Log())) appErr := ch.AccessControl.Init(request.EmptyContext(s.Log()))
if appErr != nil { if appErr != nil && appErr.StatusCode != http.StatusNotImplemented {
s.Log().Error("An error occurred while initializing Access Control", mlog.Err(appErr)) s.Log().Error("An error occurred while initializing Access Control", mlog.Err(appErr))
} }

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

@@ -482,7 +482,7 @@ func (s *SqlAccessControlPolicyStore) GetAll(_ request.CTX, opts model.GetAccess
limit := uint64(opts.Limit) limit := uint64(opts.Limit)
if limit < 1 { if limit < 1 {
limit = 10 limit = 1
} else if limit > MaxPerPage { } else if limit > MaxPerPage {
limit = MaxPerPage limit = MaxPerPage
} }
@@ -576,7 +576,7 @@ func (s *SqlAccessControlPolicyStore) SearchPolicies(rctx request.CTX, opts mode
limit := uint64(opts.Limit) limit := uint64(opts.Limit)
if limit < 1 { if limit < 1 {
limit = 10 limit = 1
} else if limit > MaxPerPage { } else if limit > MaxPerPage {
limit = MaxPerPage limit = MaxPerPage
} }

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

@@ -319,14 +319,14 @@ func testAccessControlPolicyStoreGetAll(t *testing.T, rctx request.CTX, ss store
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, resourcePolicy) require.NotNil(t, resourcePolicy)
t.Run("GetAll", func(t *testing.T) { t.Run("GetAll", func(t *testing.T) {
policies, _, err := ss.AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{}) policies, _, err := ss.AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{Limit: 10})
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, policies) require.NotNil(t, policies)
require.Len(t, policies, 3) require.Len(t, policies, 3)
}) })
t.Run("GetAll by type", func(t *testing.T) { t.Run("GetAll by type", func(t *testing.T) {
policies, _, err := ss.AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{Type: model.AccessControlPolicyTypeParent, IncludeChildren: true}) policies, _, err := ss.AccessControlPolicy().SearchPolicies(rctx, model.AccessControlPolicySearch{Type: model.AccessControlPolicyTypeParent, IncludeChildren: true, Limit: 10})
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, policies) require.NotNil(t, policies)
require.Len(t, policies, 2) require.Len(t, policies, 2)

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

@@ -7920,6 +7920,10 @@
"id": "common.parse_error_int64", "id": "common.parse_error_int64",
"translation": "Failed to parse the value:{{.Value}} to int64" "translation": "Failed to parse the value:{{.Value}} to int64"
}, },
{
"id": "ent.access_control.job_data_conversion.app_error",
"translation": "Failed to extract data from previous job."
},
{ {
"id": "ent.access_control.sync_job.app_error", "id": "ent.access_control.sync_job.app_error",
"translation": "Failed to run access control sync job." "translation": "Failed to run access control sync job."

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

@@ -627,11 +627,11 @@ func (c *Client4) accessControlPoliciesRoute() string {
} }
func (c *Client4) celRoute() string { func (c *Client4) celRoute() string {
return "/access_control_policies/cel" return fmt.Sprintf(c.accessControlPoliciesRoute() + "/cel")
} }
func (c *Client4) accessControlPolicyRoute(policyID string) string { func (c *Client4) accessControlPolicyRoute(policyID string) string {
return fmt.Sprintf(c.accessControlPoliciesRoute()+"/%v", policyID) return fmt.Sprintf(c.accessControlPoliciesRoute()+"/%v", url.PathEscape(policyID))
} }
func (c *Client4) GetServerLimits(ctx context.Context) (*ServerLimits, *Response, error) { func (c *Client4) GetServerLimits(ctx context.Context) (*ServerLimits, *Response, error) {

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

@@ -102,7 +102,9 @@ function CELEditor({
const schemas = { const schemas = {
user: ['attributes'], user: ['attributes'],
'user.attributes': userAttributes.map((attr) => attr.attribute), 'user.attributes': userAttributes.
map((attr) => attr.attribute).
filter((attr) => !attr.includes(' ') && attr.trim() !== ''),
}; };
const editorRef = useRef(null); const editorRef = useRef(null);

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

@@ -53,3 +53,22 @@
opacity: 0.4; opacity: 0.4;
} }
} }
.editor__help-text {
color: var(--center-channel-color-72);
font-size: 12px;
p {
margin-bottom: 0;
}
a {
display: inline-block;
margin-top: 8px;
color: var(--link-color);
&:hover {
text-decoration: underline;
}
}
}

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

@@ -7,6 +7,48 @@ import {FormattedMessage} from 'react-intl';
import './shared.scss'; import './shared.scss';
import Markdown from 'components/markdown'; import Markdown from 'components/markdown';
// CEL operator constants
export enum CELOperator {
EQUALS = '==',
NOT_EQUALS = '!=',
STARTS_WITH = 'startsWith',
ENDS_WITH = 'endsWith',
CONTAINS = 'contains',
IN = 'in',
}
// Operator label constants
export enum OperatorLabel {
IS = 'is',
IS_NOT = 'is not',
STARTS_WITH = 'starts with',
ENDS_WITH = 'ends with',
CONTAINS = 'contains',
IN = 'in',
}
// Map from CEL operator to UI label
export const OPERATOR_LABELS: Record<string, string> = {
[CELOperator.EQUALS]: OperatorLabel.IS,
[CELOperator.NOT_EQUALS]: OperatorLabel.IS_NOT,
[CELOperator.STARTS_WITH]: OperatorLabel.STARTS_WITH,
[CELOperator.ENDS_WITH]: OperatorLabel.ENDS_WITH,
[CELOperator.CONTAINS]: OperatorLabel.CONTAINS,
[CELOperator.IN]: OperatorLabel.IN,
};
type OperatorType = 'comparison' | 'method' | 'list';
// Map from UI label to operator configuration
export const OPERATOR_CONFIG: Record<string, {type: OperatorType; celOp: CELOperator}> = {
[OperatorLabel.IS]: {type: 'comparison', celOp: CELOperator.EQUALS},
[OperatorLabel.IS_NOT]: {type: 'comparison', celOp: CELOperator.NOT_EQUALS},
[OperatorLabel.STARTS_WITH]: {type: 'method', celOp: CELOperator.STARTS_WITH},
[OperatorLabel.ENDS_WITH]: {type: 'method', celOp: CELOperator.ENDS_WITH},
[OperatorLabel.CONTAINS]: {type: 'method', celOp: CELOperator.CONTAINS},
[OperatorLabel.IN]: {type: 'list', celOp: CELOperator.IN},
};
interface TestButtonProps { interface TestButtonProps {
onClick: () => void; onClick: () => void;
disabled: boolean; disabled: boolean;

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

@@ -2,60 +2,113 @@
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import classNames from 'classnames'; import classNames from 'classnames';
import React, {useMemo, useState} from 'react'; import React, {useMemo, useState, useEffect, useCallback, useRef} from 'react';
import {useIntl} from 'react-intl'; import {useIntl} from 'react-intl';
import {CheckIcon, MenuVariantIcon} from '@mattermost/compass-icons/components'; import {
CheckIcon,
MenuVariantIcon,
ChevronDownCircleOutlineIcon,
EmailOutlineIcon,
FormatListBulletedIcon,
LinkVariantIcon,
PoundIcon,
InformationOutlineIcon,
SyncIcon,
} from '@mattermost/compass-icons/components';
import type IconProps from '@mattermost/compass-icons/components/props'; import type IconProps from '@mattermost/compass-icons/components/props';
import type {UserPropertyField} from '@mattermost/types/properties';
import * as Menu from 'components/menu'; import * as Menu from 'components/menu';
import WithTooltip from 'components/with_tooltip';
import './selector_menus.scss'; import './selector_menus.scss';
interface AttributeOption { // Define AttributeIcon outside the main component
attribute: string; const AttributeIcon = (props: IconProps & { attribute?: UserPropertyField }) => {
values: string[]; const {attribute, ...iconProps} = props;
} if (attribute) {
const valueType = attribute.attrs?.value_type;
if (valueType === 'email') {
return <EmailOutlineIcon {...iconProps}/>;
}
if (valueType === 'url') {
return <LinkVariantIcon {...iconProps}/>;
}
if (valueType === 'phone') {
return <PoundIcon {...iconProps}/>;
}
// If no specific value_type, check the field type
switch (attribute.type) {
case 'select':
return <ChevronDownCircleOutlineIcon {...iconProps}/>;
case 'multiselect':
return <FormatListBulletedIcon {...iconProps}/>;
case 'text':
default:
return <MenuVariantIcon {...iconProps}/>;
}
}
return <MenuVariantIcon {...iconProps}/>;
};
interface AttributeSelectorProps { interface AttributeSelectorProps {
currentAttribute: string; currentAttribute: string;
availableAttributes: AttributeOption[]; availableAttributes: UserPropertyField[];
disabled: boolean; disabled: boolean;
onChange: (attribute: string) => void; onChange: (attribute: string) => void;
menuId: string;
buttonId: string;
autoOpen?: boolean;
onMenuOpened?: () => void;
} }
const AttributeSelectorMenu = ({currentAttribute, availableAttributes, disabled, onChange}: AttributeSelectorProps) => { const AttributeSelectorMenu = ({currentAttribute, availableAttributes, disabled, onChange, menuId, buttonId, autoOpen = false, onMenuOpened}: AttributeSelectorProps) => {
const {formatMessage} = useIntl(); const {formatMessage} = useIntl();
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
const prevAutoOpen = useRef(false);
const onFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => { const onFilterChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value); setFilter(e.target.value);
}; }, []); // setFilter is stable
const options = useMemo(() => { const options = useMemo(() => {
return availableAttributes.filter((attr) => { return availableAttributes.filter((attr) => {
return attr.attribute.toLowerCase().includes(filter.toLowerCase()); return attr.name.toLowerCase().includes(filter.toLowerCase());
}); });
}, [availableAttributes, filter]); }, [availableAttributes, filter]);
const handleAttributeChange = (attribute: string) => { const handleAttributeChange = React.useCallback((attribute: string) => {
onChange(attribute); onChange(attribute);
setFilter(''); setFilter(''); // Reset filter after selection
}; }, [onChange]); // setFilter is stable, onChange is a dependency
// TODO: We can use different icons for different attributes types const selectedAttributeObject = useMemo(() => {
const AttributeIcon = (props: IconProps) => <MenuVariantIcon {...props}/>; return availableAttributes.find((attr) => attr.name === currentAttribute);
}, [currentAttribute, availableAttributes]);
useEffect(() => {
if (autoOpen && !prevAutoOpen.current) {
const buttonElement = document.getElementById(buttonId);
buttonElement?.click();
if (onMenuOpened) {
onMenuOpened();
}
}
prevAutoOpen.current = autoOpen;
}, [autoOpen, buttonId, onMenuOpened]);
return ( return (
<Menu.Container <Menu.Container
menuButton={{ menuButton={{
id: 'attribute-selector-button', id: buttonId,
class: classNames('btn btn-transparent field-selector-menu-button', { class: classNames('btn btn-transparent field-selector-menu-button', {
disabled, disabled,
}), }),
children: ( children: (
<> <>
<AttributeIcon/> <AttributeIcon attribute={selectedAttributeObject}/>
{currentAttribute || formatMessage({id: 'admin.access_control.table_editor.selector.select_attribute', defaultMessage: 'Select attribute'})} {currentAttribute || formatMessage({id: 'admin.access_control.table_editor.selector.select_attribute', defaultMessage: 'Select attribute'})}
</> </>
), ),
@@ -63,39 +116,91 @@ const AttributeSelectorMenu = ({currentAttribute, availableAttributes, disabled,
disabled, disabled,
}} }}
menu={{ menu={{
id: 'attribute-selector-menu', id: menuId,
'aria-label': 'Select attribute', 'aria-label': 'Select attribute',
className: 'select-attribute-mui-menu', className: 'select-attribute-mui-menu',
}} }}
> >
{[ <Menu.InputItem
<Menu.InputItem key='filter_attributes'
key='filter_attributes' id='filter_attributes'
id='filter_attributes' type='text'
type='text' placeholder={formatMessage({id: 'admin.access_control.table_editor.selector.filter_attributes', defaultMessage: 'Search attributes...'})}
placeholder={formatMessage({id: 'admin.access_control.table_editor.selector.filter_attributes', defaultMessage: 'Search attributes...'})} className='attribute-selector-search'
className='attribute-selector-search' value={filter}
value={filter} onChange={onFilterChange}
onChange={onFilterChange} />
/>,
]}
{options.map((option) => { {options.map((option) => {
const {attribute} = option; const {name} = option;
return ( const hasSpaces = name.includes(' ');
const isSynced = option.attrs?.ldap || option.attrs?.saml;
const menuItem = (
<Menu.Item <Menu.Item
id={`attribute-${attribute}`} id={`attribute-${name}`}
key={attribute} key={name}
role='menuitemradio' role='menuitemradio'
forceCloseOnSelect={true} forceCloseOnSelect={true}
aria-checked={attribute === currentAttribute} aria-checked={name === currentAttribute}
onClick={() => handleAttributeChange(attribute)} onClick={hasSpaces ? undefined : () => handleAttributeChange(name)}
labels={<span>{attribute}</span>} labels={<span>{name}</span>}
leadingElement={<AttributeIcon size={18}/>} disabled={hasSpaces}
trailingElements={attribute === currentAttribute && ( leadingElement={
<CheckIcon/> <AttributeIcon
attribute={option}
size={18}
/>
}
trailingElements={(
<>
{hasSpaces && (
<InformationOutlineIcon
size={18}
/>
)}
{isSynced && (
<SyncIcon
size={18}
color='rgba(var(--center-channel-color-rgb), 0.5)'
/>
)}
{name === currentAttribute &&
<CheckIcon/>
}
</>
)} )}
/> />
); );
// Determine tooltip content based on conditions
let tooltipContent = null;
if (hasSpaces) {
tooltipContent = formatMessage({
id: 'admin.access_control.table_editor.attribute_spaces_not_supported',
defaultMessage: 'CEL is not compatible with variable names containing spaces',
});
} else if (isSynced) {
tooltipContent = formatMessage({
id: 'admin.access_control.table_editor.attribute_synced',
defaultMessage: 'This attribute is synced from an external source',
});
}
// Wrap in tooltip if needed
if (tooltipContent) {
return (
<WithTooltip
key={name}
title={tooltipContent}
>
<div className='menu-item-tooltip-wrapper'>
{menuItem}
</div>
</WithTooltip>
);
}
return menuItem;
})} })}
</Menu.Container> </Menu.Container>
); );

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

@@ -0,0 +1,215 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useState, useMemo, useCallback} from 'react';
import {useIntl} from 'react-intl';
import {CheckIcon, ChevronDownIcon, CloseIcon} from '@mattermost/compass-icons/components';
import type {PropertyFieldOption} from '@mattermost/types/properties';
import * as Menu from 'components/menu';
import './selector_menus.scss';
// MultiValueSelector handles selection of multiple values (operator 'in')
const MultiValueSelector = ({
values,
disabled,
updateValues,
options = [],
allowCreateValue = false,
placeholder,
}: {
values: string[];
disabled: boolean;
updateValues: (values: string[]) => void;
options?: PropertyFieldOption[];
allowCreateValue?: boolean;
placeholder?: string;
}) => {
const {formatMessage} = useIntl();
const [filter, setFilter] = useState('');
const hasOptions = options.length > 0;
const actualAllowCreateForMenu = hasOptions ? allowCreateValue : true;
// Filter logic for options
const onFilterChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
}, []);
const filteredOptions = useMemo(() => {
return options.filter((option) => {
const name = option.name || '';
return name.toLowerCase().includes(filter.toLowerCase());
});
}, [options, filter]);
const defaultMultiPlaceholder = formatMessage({
id: 'admin.access_control.table_editor.values.select_values',
defaultMessage: 'Select values...',
});
const defaultCreatePlaceholder = formatMessage({
id: 'admin.access_control.table_editor.values.create_placeholder',
defaultMessage: 'Type to create value',
});
const handleSelectItem = useCallback((name: string) => {
const newValues = values.includes(name) ?
values.filter((v) => v !== name) :
[...values, name];
updateValues(newValues);
}, [values, updateValues]);
const handleCreateValue = useCallback((valueToCreate: string) => {
const trimmedValue = valueToCreate.trim();
if (!trimmedValue || values.includes(trimmedValue)) {
return;
}
updateValues([...values, trimmedValue]);
setFilter('');
}, [values, updateValues]);
const handleInputKeyDownForMenu = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Tab') {
e.stopPropagation();
}
if (e.key === 'Enter' && actualAllowCreateForMenu && filter.trim()) {
e.preventDefault();
handleCreateValue(filter);
}
}, [actualAllowCreateForMenu, filter, handleCreateValue]);
const handleRemoveValue = useCallback((event: React.MouseEvent<HTMLDivElement> | React.KeyboardEvent<HTMLDivElement>, valueToRemove: string) => {
event.stopPropagation();
const newValues = values.filter((v) => v !== valueToRemove);
updateValues(newValues);
}, [values, updateValues]);
// Memoize cell contents to prevent unnecessary re-renders
const cellContents = useMemo(() => {
if (values.length === 0) {
let visualPlaceholderText = defaultMultiPlaceholder;
if (actualAllowCreateForMenu && options.length === 0) {
visualPlaceholderText = defaultCreatePlaceholder;
}
const actualTextDisplayed = placeholder || visualPlaceholderText;
const useStyle = actualTextDisplayed === defaultMultiPlaceholder || actualTextDisplayed === defaultCreatePlaceholder;
return (
<span className={classNames({'value-selector-menu-button__placeholder': useStyle})}>
{actualTextDisplayed}
</span>
);
}
return (
<div className='value-selector-menu-button__multi-values-container'>
{values.map((value) => (
<div
key={value}
className='select__multi-value'
>
<div className='select__multi-value__label'>{value}</div>
{!disabled && (
<div
className='select__multi-value__remove'
onClick={(e) => handleRemoveValue(e, value)}
role='button'
tabIndex={0}
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
handleRemoveValue(e, value);
}
}}
>
<CloseIcon size={12}/>
</div>
)}
</div>
))}
</div>
);
}, [values, disabled]);
return (
<div className='values-editor'>
<Menu.Container
menuButton={{
id: 'value-selector-button',
class: classNames('btn btn-transparent field-selector-menu-button', {
disabled,
}),
children: (
<span className='value-selector-menu-button__inner-wrapper'>
{cellContents}
<ChevronDownIcon
size={18}
color='rgba(var(--center-channel-color-rgb), 0.5)'
/>
</span>
),
dataTestId: 'valueSelectorMenuButton',
disabled,
}}
menu={{
id: 'value-selector-menu',
'aria-label': placeholder || defaultMultiPlaceholder,
className: 'select-value-mui-menu',
}}
>
<Menu.InputItem
key='filter_values'
id='filter_values'
type='text'
placeholder={formatMessage({
id: 'admin.access_control.table_editor.selector.filter_or_create',
defaultMessage: 'Search or create value...',
})}
className='attribute-selector-search'
value={filter}
onChange={onFilterChange}
onKeyDown={handleInputKeyDownForMenu}
/>
{filteredOptions.map((option) => {
const name = option.name || '';
const id = option.id || name;
const isSelected = values.includes(name);
return (
<Menu.Item
id={`value-option-${id}`}
key={id}
role='menuitemcheckbox'
forceCloseOnSelect={false}
aria-checked={isSelected}
onClick={() => handleSelectItem(name)}
labels={<span>{name}</span>}
trailingElements={isSelected && (
<CheckIcon/>
)}
/>
);
})}
{actualAllowCreateForMenu && filter.trim() && !filteredOptions.some((opt) => opt.name === filter.trim()) && (
<Menu.Item
id='create-value-option'
key='create-value-option'
onClick={() => handleCreateValue(filter)}
labels={<span>
{formatMessage({
id: 'admin.access_control.table_editor.create_value',
defaultMessage: 'Create "{value}"',
}, {value: filter.trim()})}
</span>}
/>
)}
</Menu.Container>
</div>
);
};
export default MultiValueSelector;

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

@@ -13,6 +13,7 @@ import type {IDMappedObjects} from '@mattermost/types/utilities';
import * as Menu from 'components/menu'; import * as Menu from 'components/menu';
import {OperatorLabel} from '../shared';
import './selector_menus.scss'; import './selector_menus.scss';
interface OperatorSelectorProps { interface OperatorSelectorProps {
@@ -22,22 +23,23 @@ interface OperatorSelectorProps {
} }
const OperatorSelectorMenu = ({currentOperator, disabled, onChange}: OperatorSelectorProps) => { const OperatorSelectorMenu = ({currentOperator, disabled, onChange}: OperatorSelectorProps) => {
const handleOperatorChange = (descriptor: OperatorDescriptor) => { const {formatMessage} = useIntl();
onChange(descriptor.operatorValue); const [filter, setFilter] = useState('');
const handleOperatorChange = React.useCallback((descriptor: OperatorDescriptor) => {
onChange(descriptor.id);
setFilter(''); setFilter('');
}; }, [onChange]);
const currentOperatorDescriptor = useMemo(() => { const currentOperatorDescriptor = useMemo(() => {
return getOperatorDescriptor(currentOperator); return getOperatorDescriptor(currentOperator);
}, [currentOperator]); }, [currentOperator]);
const CurrentOperatorIcon = currentOperatorDescriptor.icon; const CurrentOperatorIcon = currentOperatorDescriptor.icon;
const {formatMessage} = useIntl();
const [filter, setFilter] = useState('');
const onFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => { const onFilterChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value); setFilter(e.target.value);
}; }, []);
const filteredOperators = useMemo(() => { const filteredOperators = useMemo(() => {
return Object.values(OPERATOR_DESCRIPTORS).filter((desc) => { return Object.values(OPERATOR_DESCRIPTORS).filter((desc) => {
@@ -107,7 +109,7 @@ export default OperatorSelectorMenu;
const getOperatorDescriptor = (operatorValue: string): OperatorDescriptor => { const getOperatorDescriptor = (operatorValue: string): OperatorDescriptor => {
for (const descriptor of Object.values(OPERATOR_DESCRIPTORS)) { for (const descriptor of Object.values(OPERATOR_DESCRIPTORS)) {
if (descriptor.operatorValue === operatorValue) { if (descriptor.id === operatorValue) {
return descriptor; return descriptor;
} }
} }
@@ -115,64 +117,55 @@ const getOperatorDescriptor = (operatorValue: string): OperatorDescriptor => {
return OPERATOR_DESCRIPTORS.is; return OPERATOR_DESCRIPTORS.is;
}; };
type OperatorID = 'is' | 'is_not' | 'in' | 'starts_with' | 'ends_with' | 'contains';
type OperatorDescriptor = { type OperatorDescriptor = {
id: OperatorID; id: OperatorLabel;
operatorValue: string;
icon: ComponentType<IconProps>; icon: ComponentType<IconProps>;
label: MessageDescriptor; label: MessageDescriptor;
}; };
const OPERATOR_DESCRIPTORS: IDMappedObjects<OperatorDescriptor> = { const OPERATOR_DESCRIPTORS: IDMappedObjects<OperatorDescriptor> = {
is: { [OperatorLabel.IS]: {
id: 'is', id: OperatorLabel.IS,
operatorValue: 'is',
icon: EqualIcon, icon: EqualIcon,
label: defineMessage({ label: defineMessage({
id: 'admin.access_control.table_editor.operator.is', id: 'admin.access_control.table_editor.operator.is',
defaultMessage: 'is', defaultMessage: 'is',
}), }),
}, },
is_not: { [OperatorLabel.IS_NOT]: {
id: 'is_not', id: OperatorLabel.IS_NOT,
operatorValue: 'is not',
icon: NotEqualVariantIcon, icon: NotEqualVariantIcon,
label: defineMessage({ label: defineMessage({
id: 'admin.access_control.table_editor.operator.is_not', id: 'admin.access_control.table_editor.operator.is_not',
defaultMessage: 'is not', defaultMessage: 'is not',
}), }),
}, },
in: { [OperatorLabel.IN]: {
id: 'in', id: OperatorLabel.IN,
operatorValue: 'in',
icon: ElementOfIcon, icon: ElementOfIcon,
label: defineMessage({ label: defineMessage({
id: 'admin.access_control.table_editor.operator.in', id: 'admin.access_control.table_editor.operator.in',
defaultMessage: 'in', defaultMessage: 'in',
}), }),
}, },
starts_with: { [OperatorLabel.STARTS_WITH]: {
id: 'starts_with', id: OperatorLabel.STARTS_WITH,
operatorValue: 'starts with',
icon: FunctionIcon, icon: FunctionIcon,
label: defineMessage({ label: defineMessage({
id: 'admin.access_control.table_editor.operator.starts_with', id: 'admin.access_control.table_editor.operator.starts_with',
defaultMessage: 'starts with', defaultMessage: 'starts with',
}), }),
}, },
ends_with: { [OperatorLabel.ENDS_WITH]: {
id: 'ends_with', id: OperatorLabel.ENDS_WITH,
operatorValue: 'ends with',
icon: FunctionIcon, icon: FunctionIcon,
label: defineMessage({ label: defineMessage({
id: 'admin.access_control.table_editor.operator.ends_with', id: 'admin.access_control.table_editor.operator.ends_with',
defaultMessage: 'ends with', defaultMessage: 'ends with',
}), }),
}, },
contains: { [OperatorLabel.CONTAINS]: {
id: 'contains', id: OperatorLabel.CONTAINS,
operatorValue: 'contains',
icon: FunctionIcon, icon: FunctionIcon,
label: defineMessage({ label: defineMessage({
id: 'admin.access_control.table_editor.operator.contains', id: 'admin.access_control.table_editor.operator.contains',

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

@@ -2,19 +2,11 @@
width: 100%; width: 100%;
height: 40px; height: 40px;
justify-content: start; justify-content: start;
border-color: transparent; border: none;
border-radius: 0;
box-shadow: none;
font-weight: normal; font-weight: normal;
&:hover,
&:focus {
border-color: transparent;
box-shadow: none;
}
&:hover { &:hover {
background: rgba(var(--center-channel-color-rgb), 0.04) background: rgba(var(--center-channel-color-rgb), 0.04);
} }
&:focus, &:focus,
@@ -27,20 +19,79 @@
opacity: 0.6; opacity: 0.6;
} }
svg { > span > span:first-child:not([style*="flex-wrap: wrap"]) {
margin-right: 8px; overflow: hidden;
padding-inline-start: 10px;
text-overflow: ellipsis;
white-space: nowrap;
} }
} }
.select-attribute-mui-menu, .value-selector-menu-button {
.select-operator-mui-menu { &__multi-values-container {
margin-top: 0; display: flex;
overflow: hidden;
flex-grow: 1;
flex-wrap: wrap;
gap: 2px;
}
.MenuItem { &__inner-wrapper {
height: 40px; display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
}
}
svg { .values-editor {
margin-right: 8px; width: 100%;
.select__multi-value {
display: flex;
height: 24px;
align-items: center;
border-radius: 4px;
margin: 2px;
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
.select__multi-value__label {
padding: 0 4px 0 8px;
font-size: 12px;
}
.select__multi-value__remove {
padding: 0 4px;
cursor: pointer;
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.16);
} }
} }
&__simple-input {
width: 100%;
height: 40px;
border: none;
background: transparent;
font-size: 14px;
padding-inline-start: 22px;
&:focus {
background: rgba(var(--center-channel-color-rgb), 0.06);
outline: none;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
.MenuItem__trailing-elements {
display: flex;
align-items: center;
gap: 4px;
}
} }

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

@@ -0,0 +1,211 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useState, useMemo, useCallback} from 'react';
import {useIntl} from 'react-intl';
import {CheckIcon, ChevronDownIcon} from '@mattermost/compass-icons/components';
import type {PropertyFieldOption} from '@mattermost/types/properties';
import * as Menu from 'components/menu';
import Constants from 'utils/constants';
import './selector_menus.scss';
// SingleValueSelector handles selection of a single value (operators like 'is', 'contains', etc.)
const SingleValueSelector = ({
value,
disabled,
updateValue,
options = [],
allowCreateValue = false,
placeholder,
}: {
value: string;
disabled: boolean;
updateValue: (value: string) => void;
options?: PropertyFieldOption[];
allowCreateValue?: boolean;
placeholder?: string;
}) => {
const {formatMessage} = useIntl();
const [filter, setFilter] = useState('');
const [inputValue, setInputValue] = useState('');
const [isEditing, setIsEditing] = useState(false);
const hasOptions = options.length > 0;
// Simple input logic for attributes without options
const commitInputValue = useCallback(() => {
const trimmedValue = inputValue.trim();
if (trimmedValue) {
updateValue(trimmedValue);
}
setInputValue('');
setIsEditing(false);
}, [inputValue, updateValue]);
const handleKeyDownSimpleInput = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
commitInputValue();
}
}, [commitInputValue]);
// Filter logic for options
const onFilterChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
}, []);
const filteredOptions = useMemo(() => {
return options.filter((option) => {
const name = option.name || '';
return name.toLowerCase().includes(filter.toLowerCase());
});
}, [options, filter]);
const defaultPlaceholder = formatMessage({
id: 'admin.access_control.table_editor.value.select_value',
defaultMessage: 'Select value',
});
const handleSelectItem = useCallback((name: string) => {
updateValue(name);
setFilter('');
}, [updateValue]);
const handleCreateValue = useCallback((valueToCreate: string) => {
const trimmedValue = valueToCreate.trim();
if (trimmedValue) {
updateValue(trimmedValue);
}
setFilter('');
}, [updateValue]);
const handleInputKeyDownForMenu = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key !== 'Tab') {
e.stopPropagation();
}
if (e.key === 'Enter' && allowCreateValue && filter.trim()) {
e.preventDefault();
handleCreateValue(filter);
}
}, [allowCreateValue, filter, handleCreateValue]);
if (!hasOptions) {
// For attributes without options, show simple input field
return (
<div className='values-editor'>
<input
type='text'
className='values-editor__simple-input'
value={isEditing ? inputValue : value}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDownSimpleInput}
onFocus={() => {
setIsEditing(true);
if (value) {
setInputValue(value);
}
}}
onBlur={commitInputValue}
placeholder={placeholder || formatMessage({
id: 'admin.access_control.table_editor.value.placeholder',
defaultMessage: 'Add value...',
})}
disabled={disabled}
maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_LENGTH}
/>
</div>
);
}
// For attributes with options, show dropdown menu
const actualTextDisplayed = value || placeholder || defaultPlaceholder;
const useStyle = actualTextDisplayed === defaultPlaceholder;
return (
<div className='values-editor'>
<Menu.Container
menuButton={{
id: 'value-selector-button',
class: classNames('btn btn-transparent field-selector-menu-button', {
disabled,
}),
children: (
<span className='value-selector-menu-button__inner-wrapper'>
<span
className={classNames({'value-selector-menu-button__placeholder': useStyle})}
>
{actualTextDisplayed}
</span>
<ChevronDownIcon
size={18}
color='rgba(var(--center-channel-color-rgb), 0.5)'
/>
</span>
),
dataTestId: 'valueSelectorMenuButton',
disabled,
}}
menu={{
id: 'value-selector-menu',
'aria-label': placeholder || defaultPlaceholder,
className: 'select-value-mui-menu',
}}
>
<Menu.InputItem
key='filter_values'
id='filter_values'
type='text'
placeholder={formatMessage({
id: 'admin.access_control.table_editor.selector.filter_or_create',
defaultMessage: 'Search or create value...',
})}
className='attribute-selector-search'
value={filter}
onChange={onFilterChange}
onKeyDown={handleInputKeyDownForMenu}
/>
{filteredOptions.map((option) => {
const name = option.name || '';
const id = option.id || name;
const isSelected = value === name;
return (
<Menu.Item
id={`value-option-${id}`}
key={id}
role='menuitemradio'
forceCloseOnSelect={true}
aria-checked={isSelected}
onClick={() => handleSelectItem(name)}
labels={<span>{name}</span>}
trailingElements={isSelected && (
<CheckIcon/>
)}
/>
);
})}
{allowCreateValue && filter.trim() && !filteredOptions.some((opt) => opt.name === filter.trim()) && (
<Menu.Item
id='create-value-option'
key='create-value-option'
onClick={() => handleCreateValue(filter)}
labels={<span>
{formatMessage({
id: 'admin.access_control.table_editor.create_value',
defaultMessage: 'Create "{value}"',
}, {value: filter.trim()})}
</span>}
/>
)}
</Menu.Container>
</div>
);
};
export default SingleValueSelector;

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

@@ -1,110 +1,45 @@
.table-editor { .table-editor {
position: relative;
margin-bottom: 24px; margin-bottom: 24px;
&__table { &__table {
overflow: hidden; width: 100%;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16); border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px; border-radius: 4px;
border-collapse: collapse;
} }
&__header { th {
display: flex; padding: 12px;
padding: 12px 16px;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.16); border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
background: rgba(var(--center-channel-color-rgb), 0.04); background: rgba(var(--center-channel-color-rgb), 0.04);
}
&__column-header {
flex: 1;
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600; font-weight: 600;
padding-inline: 10px; text-align: left;
&:nth-child(1) {
flex: 1;
}
&:nth-child(2) {
flex: 0.8;
}
&:nth-child(3) {
flex: 2.3;
}
} }
&__column-header-actions { td {
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
}
&__row {
display: flex;
align-items: center;
padding: 0;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08); border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
vertical-align: middle;
} }
&__cell { .table-editor__column-header-value {
flex: 1; padding-inline: 10px;
&:nth-child(1) {
flex: 1;
}
&:nth-child(2) {
flex: 0.8;
}
&:nth-child(3) {
flex: 2;
}
} }
&__cell-actions { // Set column widths
width: 40px; th:nth-child(2), td:nth-child(2) { width: 20%; }
text-align: right; th:nth-child(3), td:nth-child(3) { width: 50%; }
} th:nth-child(4), td:nth-child(4) { width: 30px; text-align: right; }
&__attribute-select, &__blank-state {
&__operator-select { padding: 16px;
width: 100%; text-align: center;
}
&__select { span {
width: 100%; color: rgba(var(--center-channel-color-rgb), 0.64);
padding: 10px 16px;
border: none;
border-radius: 4px;
appearance: none;
background-color: transparent;
color: var(--center-channel-color);
font-size: 14px;
&:hover {
background-color: rgba(var(--button-bg-rgb), 0.08);
cursor: pointer;
}
&:focus {
background-color: rgba(var(--button-bg-rgb), 0.08);
outline: none;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
} }
} }
&__row-remove { &__row-remove {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none; border: none;
background: none; background: none;
color: rgba(var(--center-channel-color-rgb), 0.56); color: rgba(var(--center-channel-color-rgb), 0.56);
@@ -113,62 +48,15 @@
&:hover { &:hover {
color: var(--error-text); color: var(--error-text);
} }
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
} }
&__actions-row { &__actions-row {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
margin-top: 8px; margin-top: 12px;
.editor__help-text {
margin-right: 32px;
p {
margin-bottom: 0;
}
}
}
&__blank-state {
display: flex;
align-items: center;
justify-content: center;
padding: 10px;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
span {
color: var(--center-channel-color-64);
}
} }
&__add-button-container { &__add-button-container {
display: flex;
align-items: center;
padding: 8px; padding: 8px;
} }
} }
.editor__help-text {
color: var(--center-channel-color-72);
font-size: 12px;
p {
margin-bottom: 0;
}
a {
display: inline-block;
margin-top: 8px;
color: var(--link-color);
&:hover {
text-decoration: underline;
}
}
}

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

@@ -0,0 +1,145 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {AccessControlVisualAST} from '@mattermost/types/access_control';
import {parseExpression} from 'components/admin_console/access_control/editors/table_editor/table_editor';
describe('parseExpression', () => {
test('handles "==" operator mapping to "is"', () => {
const ast: AccessControlVisualAST = {
conditions: [
{
attribute: 'user.attributes.department',
operator: '==',
value: 'Engineering',
value_type: 0,
},
],
};
expect(parseExpression(ast)).toEqual([
{
attribute: 'department',
operator: 'is',
values: ['Engineering'],
},
]);
});
test('handles "in" operator with multiple values', () => {
const ast: AccessControlVisualAST = {
conditions: [
{
attribute: 'user.attributes.location',
operator: 'in',
value: ['US', 'CA'],
value_type: 0,
},
],
};
expect(parseExpression(ast)).toEqual([
{
attribute: 'location',
operator: 'in',
values: ['US', 'CA'],
},
]);
});
test('handles "!=" operator mapping to "is not"', () => {
const ast: AccessControlVisualAST = {
conditions: [
{
attribute: 'user.attributes.role',
operator: '!=',
value: 'guest',
value_type: 0,
},
],
};
expect(parseExpression(ast)).toEqual([
{
attribute: 'role',
operator: 'is not',
values: ['guest'],
},
]);
});
test('handles method style operators like "startsWith"', () => {
const ast: AccessControlVisualAST = {
conditions: [
{
attribute: 'user.attributes.email',
operator: 'startsWith',
value: 'admin',
value_type: 0,
},
],
};
expect(parseExpression(ast)).toEqual([
{
attribute: 'email',
operator: 'starts with',
values: ['admin'],
},
]);
});
test('handles multiple conditions', () => {
const ast: AccessControlVisualAST = {
conditions: [
{
attribute: 'user.attributes.email',
operator: 'startsWith',
value: 'admin',
value_type: 0,
},
{
attribute: 'user.attributes.department',
operator: '==',
value: 'Engineering',
value_type: 0,
},
],
};
expect(parseExpression(ast)).toEqual([
{
attribute: 'email',
operator: 'starts with',
values: ['admin'],
},
{
attribute: 'department',
operator: 'is',
values: ['Engineering'],
},
]);
});
test('throws on unknown operator', () => {
const ast: AccessControlVisualAST = {
conditions: [
{
attribute: 'user.attributes.department',
operator: 'unknownOp',
value: 'foo',
value_type: 0,
},
],
};
expect(parseExpression(ast)).toEqual([
{
attribute: 'department',
operator: 'is',
values: ['foo'],
},
]);
});
});

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

@@ -1,20 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information. // See LICENSE.txt for license information.
import React, {useState, useEffect} from 'react'; import React, {useState, useEffect, useCallback} from 'react';
import {FormattedMessage, useIntl} from 'react-intl'; import {FormattedMessage, useIntl} from 'react-intl';
import type {AccessControlVisualAST} from '@mattermost/types/access_control';
import type {UserPropertyField} from '@mattermost/types/properties';
import {searchUsersForExpression} from 'mattermost-redux/actions/access_control'; import {searchUsersForExpression} from 'mattermost-redux/actions/access_control';
import {Client4} from 'mattermost-redux/client'; import type {ActionResult} from 'mattermost-redux/types/actions';
import AttributeSelectorMenu from './attribute_selector_menu'; import AttributeSelectorMenu from './attribute_selector_menu';
import OperatorSelectorMenu from './operator_selector_menu'; import OperatorSelectorMenu from './operator_selector_menu';
import type {TableRow} from './table_row'; import type {TableRow} from './value_selector_menu';
import ValuesEditor from './values_editor'; import ValueSelectorMenu from './value_selector_menu';
import CELHelpModal from '../../modals/cel_help/cel_help_modal'; import CELHelpModal from '../../modals/cel_help/cel_help_modal';
import TestResultsModal from '../../modals/policy_test/test_modal'; import TestResultsModal from '../../modals/policy_test/test_modal';
import {AddAttributeButton, TestButton, HelpText} from '../shared'; import {AddAttributeButton, TestButton, HelpText, OPERATOR_CONFIG, OPERATOR_LABELS, OperatorLabel} from '../shared';
import './table_editor.scss'; import './table_editor.scss';
@@ -23,53 +26,36 @@ interface TableEditorProps {
onChange: (value: string) => void; onChange: (value: string) => void;
onValidate?: (isValid: boolean) => void; onValidate?: (isValid: boolean) => void;
disabled?: boolean; disabled?: boolean;
userAttributes: Array<{ userAttributes: UserPropertyField[];
attribute: string; onParseError: (error: string) => void;
values: string[]; actions: {
}>; getVisualAST: (expr: string) => Promise<ActionResult>;
};
} }
// Parse CEL expression into table rows // Parses a CEL (Common Expression Language) string into a structured array of TableRow objects.
const parseExpression = async (expr: string): Promise<TableRow[]> => { // This allows the expression to be displayed and edited in a user-friendly table format.
export const parseExpression = (visualAST: AccessControlVisualAST): TableRow[] => {
const tableRows: TableRow[] = []; const tableRows: TableRow[] = [];
if (!expr) { if (!visualAST) {
return tableRows; return tableRows;
} }
const rawVisualAST = await Client4.expressionToVisualFormat(expr); for (const node of visualAST.conditions) {
for (const node of rawVisualAST.conditions) {
let attr; let attr;
// Extracts the attribute name, removing the 'user.attributes.' prefix.
if (node.attribute.startsWith('user.attributes.')) { if (node.attribute.startsWith('user.attributes.')) {
attr = node.attribute.slice(16); // wow, there is no trim-prefix attr = node.attribute.slice(16); // Length of 'user.attributes.'
} else { } else {
throw new Error(`Unknown attribute: ${node.attribute}`); throw new Error(`Unknown attribute: ${node.attribute}`);
} }
let op; let op = OPERATOR_LABELS[node.operator];
if (!op) {
switch (node.operator) { // Fallback for unknown operators, defaulting to 'is' logic
case '==': op = OperatorLabel.IS;
op = 'is';
break;
case 'in':
op = 'in';
break;
case '!=':
op = 'is not';
break;
case 'startsWith':
op = 'starts with';
break;
case 'endsWith':
op = 'ends with';
break;
case 'contains':
op = 'contains';
break;
default:
throw new Error(`Unknown operator: ${node.operator}`);
} }
let values; let values;
@@ -89,181 +75,246 @@ const parseExpression = async (expr: string): Promise<TableRow[]> => {
return tableRows; return tableRows;
}; };
// TableEditor provides a user-friendly table interface for constructing and editing
// CEL (Common Expression Language) expressions based on user attributes.
// It parses incoming CEL expressions into rows and reconstructs the expression upon changes.
// The biggest limitation is that all expressions are ANDed together, so it's not possible to
// have OR logic.
function TableEditor({ function TableEditor({
value, value,
onChange, onChange,
onValidate, onValidate,
disabled = false, disabled = false,
userAttributes, userAttributes,
onParseError,
actions,
}: TableEditorProps): JSX.Element { }: TableEditorProps): JSX.Element {
const {formatMessage} = useIntl(); const {formatMessage} = useIntl();
const [rows, setRows] = useState<TableRow[]>([]); const [rows, setRows] = useState<TableRow[]>([]);
const [showTestResults, setShowTestResults] = useState(false); const [showTestResults, setShowTestResults] = useState(false);
const [showHelpModal, setShowHelpModal] = useState(false); const [showHelpModal, setShowHelpModal] = useState(false);
const [autoOpenAttributeMenuForRow, setAutoOpenAttributeMenuForRow] = useState<number | null>(null);
// Update rows when value changes externally // Effect to parse the incoming CEL expression string (value prop)
// and update the internal rows state. Handles errors during parsing.
useEffect(() => { useEffect(() => {
parseExpression(value).then((rows) => { actions.getVisualAST(value).then((result) => {
setRows(rows); if (result.error) {
setRows([]);
onParseError(result.error.message);
return;
}
setRows(parseExpression(result.data));
}).catch((err) => {
setRows([]);
if (onValidate) {
onValidate(false);
}
onParseError(err.message);
}); });
}, [value]); }, [value, onValidate, onParseError]);
// Update the CEL expression when table changes // Converts the internal rows state back into a CEL expression string
const updateExpression = (newRows: TableRow[]) => { // and calls the onChange and onValidate props.
const validRows = newRows.filter((row) => row.attribute && row.values.length > 0); const updateExpression = useCallback((newRows: TableRow[]) => {
const expr = validRows.map((row) => { const rowsThatCanFormExpressions = newRows.filter((row) => row.attribute); // Only include rows that have an attribute selected
if (row.operator === 'is') {
return `user.attributes.${row.attribute} == "${row.values[0]}"`; const expr = rowsThatCanFormExpressions.map((row) => {
const attributeExpr = `user.attributes.${row.attribute}`;
const config = OPERATOR_CONFIG[row.operator];
if (!config) {
// Fallback for unknown operators, defaulting to 'in' logic
// This handles cases where row.operator might be an unexpected string.
const valuesStr = row.values.map((val: string) => `"${val}"`).join(', ');
return `${attributeExpr} in [${valuesStr}]`;
} }
if (row.operator === 'is not') { if (config.type === 'list') { // Handles 'in'
return `user.attributes.${row.attribute} != "${row.values[0]}"`; const valuesStr = row.values.map((val: string) => `"${val}"`).join(', ');
return `${attributeExpr} ${config.celOp} [${valuesStr}]`;
} }
if (row.operator === 'starts with') { // For 'comparison' and 'method' types, they operate on a single value.
return `user.attributes.${row.attribute}.startsWith("${row.values[0]}")`; const value = row.values.length > 0 ? row.values[0] : '';
if (config.type === 'comparison') {
return `${attributeExpr} ${config.celOp} "${value}"`;
} }
if (row.operator === 'ends with') { // config.type must be 'method'
return `user.attributes.${row.attribute}.endsWith("${row.values[0]}")`; return `${attributeExpr}.${config.celOp}("${value}")`;
}
if (row.operator === 'contains') {
return `user.attributes.${row.attribute}.contains("${row.values[0]}")`;
}
const valuesStr = row.values.map((val) => `"${val}"`).join(', ');
return `user.attributes.${row.attribute} in [${valuesStr}]`;
}).join(' && '); }).join(' && ');
onChange(expr); onChange(expr);
if (onValidate) { if (onValidate) {
onValidate(true); // Basic validation: if we can build an expression, or if the expression is empty
// (e.g. no rows, or rows without attributes yet), it's valid from table perspective.
onValidate(expr === '' || rowsThatCanFormExpressions.length > 0);
} }
}; }, [onChange, onValidate]);
const addRow = () => { // Row Manipulation Handlers
// Find first available attribute const addRow = useCallback(() => {
const availableAttrs = getAvailableAttributes(); if (userAttributes.length === 0) {
if (availableAttrs.length === 0) { return; // Do not add a row if no attributes are available
return;
} }
setRows((currentRows) => {
const newRow = {
attribute: userAttributes[0]?.name || '', // Default to the first available attribute
operator: OperatorLabel.IS, // Default operator
values: [],
};
const newRows = [...currentRows, newRow];
updateExpression(newRows); // Ensure expression is updated immediately
setAutoOpenAttributeMenuForRow(newRows.length - 1); // Set for the new row
return newRows;
});
}, [userAttributes, updateExpression]);
const newRows = [...rows, { const removeRow = useCallback((index: number) => {
attribute: availableAttrs[0].attribute, setRows((currentRows) => {
operator: 'is', const newRows = currentRows.toSpliced(index, 1);
values: [], updateExpression(newRows);
}]; return newRows;
});
}, [updateExpression]);
setRows(newRows); const updateRowAttribute = useCallback((index: number, attribute: string) => {
updateExpression(newRows); setRows((currentRows) => {
}; const newRows = [...currentRows];
const oldAttribute = newRows[index].attribute;
newRows[index] = {...newRows[index], attribute};
const removeRow = (index: number) => { // If attribute changes, we are resetting values.
const newRows = rows.filter((_, i) => i !== index); if (oldAttribute !== attribute) {
setRows(newRows); newRows[index].values = [];
updateExpression(newRows); newRows[index].operator = OperatorLabel.IS;
}; }
updateExpression(newRows);
return newRows;
});
}, [updateExpression]);
const updateRowAttribute = (index: number, attribute: string) => { const updateRowOperator = useCallback((index: number, newOperator: string) => {
const newRows = [...rows]; setRows((currentRows) => {
newRows[index].attribute = attribute; const oldOperator = currentRows[index].operator;
setRows(newRows); let newValues = [...currentRows[index].values]; // Start with a copy of current values
updateExpression(newRows);
};
const updateRowOperator = (index: number, operator: string) => { if (newOperator === OperatorLabel.IN && oldOperator !== OperatorLabel.IN) {
const newRows = [...rows]; // Transitioning TO 'in' FROM a non-'in' (likely single-value) operator:
newRows[index].operator = operator; // Trim each value and then filter out any that become empty strings.
newValues = newValues.map((v) => v.trim()).filter((v) => v !== '');
} else if (newOperator !== OperatorLabel.IN) {
// Transitioning TO a non-'in' (single-value) operator (or staying as one):
// If there are multiple values (e.g., coming from 'in'), take only the first one.
if (newValues.length > 1) {
newValues = [newValues[0]];
}
}
if ((operator !== 'in') && newRows[index].values.length > 1) { const newRows = [...currentRows];
newRows[index].values = newRows[index].values.length > 0 ? [newRows[index].values[0]] : []; newRows[index] = {
} ...currentRows[index],
operator: newOperator,
values: newValues,
};
setRows(newRows); updateExpression(newRows);
updateExpression(newRows); return newRows;
}; });
}, [updateExpression]);
const updateRowValues = (index: number, values: string[]) => { const updateRowValues = useCallback((index: number, values: string[]) => {
const newRows = [...rows]; setRows((currentRows) => {
newRows[index].values = values; const newRows = [...currentRows];
setRows(newRows); newRows[index] = {...newRows[index], values};
updateExpression(newRows); updateExpression(newRows);
}; return newRows;
});
// Get available attributes (excluding ones already used) }, [updateExpression]);
const getAvailableAttributes = () => {
const usedAttributes = new Set(rows.map((row) => row.attribute));
return userAttributes.filter((attr) => !usedAttributes.has(attr.attribute));
};
return ( return (
<div className='table-editor'> <div className='table-editor'>
<div className='table-editor__table'> <table className='table-editor__table'>
<div className='table-editor__header'> <thead>
<div className='table-editor__column-header'> <tr className='table-editor__header-row'>
<FormattedMessage <th className='table-editor__column-header'>
id='admin.access_control.table_editor.attribute' <FormattedMessage
defaultMessage='Attribute' id='admin.access_control.table_editor.attribute'
/> defaultMessage='Attribute'
</div> />
<div className='table-editor__column-header'> </th>
<FormattedMessage <th className='table-editor__column-header'>
id='admin.access_control.table_editor.operator' <FormattedMessage
defaultMessage='Operator' id='admin.access_control.table_editor.operator'
/> defaultMessage='Operator'
</div> />
<div className='table-editor__column-header'> </th>
<FormattedMessage <th className='table-editor__column-header'>
id='admin.access_control.table_editor.values' <span className='table-editor__column-header-value'>
defaultMessage='Values' <FormattedMessage
/> id='admin.access_control.table_editor.values'
</div> defaultMessage='Values'
<div className='table-editor__column-header-actions'/> />
</div>
<div className='table-editor__rows'>
{rows.length === 0 ? (
<div className='table-editor__blank-state'>
<span>
{formatMessage({
id: 'admin.access_control.table_editor.blank_state',
defaultMessage: 'Select a user attribute and values to create a rule',
})}
</span> </span>
</div> </th>
<th className='table-editor__column-header-actions'/>
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td
colSpan={4}
className='table-editor__blank-state'
>
<span>
{formatMessage({
id: 'admin.access_control.table_editor.blank_state',
defaultMessage: 'Select a user attribute and values to create a rule',
})}
</span>
</td>
</tr>
) : ( ) : (
rows.map((row, index) => ( rows.map((row, index) => (
<div <tr
key={index} key={index}
className='table-editor__row' className='table-editor__row'
> >
<div className='table-editor__cell'> <td className='table-editor__cell'>
<AttributeSelectorMenu <AttributeSelectorMenu
currentAttribute={row.attribute} currentAttribute={row.attribute}
availableAttributes={getAvailableAttributes().concat( availableAttributes={userAttributes}
row.attribute ? [{attribute: row.attribute, values: []}] : [],
)}
disabled={disabled} disabled={disabled}
onChange={(attribute) => updateRowAttribute(index, attribute)} onChange={(attribute) => updateRowAttribute(index, attribute)}
menuId={`attribute-selector-menu-${index}`}
buttonId={`attribute-selector-button-${index}`}
autoOpen={index === autoOpenAttributeMenuForRow}
onMenuOpened={() => setAutoOpenAttributeMenuForRow(null)}
/> />
</div> </td>
<div className='table-editor__cell'> <td className='table-editor__cell'>
<OperatorSelectorMenu <OperatorSelectorMenu
currentOperator={row.operator} currentOperator={row.operator}
disabled={disabled} disabled={disabled}
onChange={(operator) => updateRowOperator(index, operator)} onChange={(operator) => updateRowOperator(index, operator)}
/> />
</div> </td>
<div className='table-editor__cell'> <td className='table-editor__cell'>
<ValuesEditor <ValueSelectorMenu
row={row} row={row}
disabled={disabled} disabled={disabled}
updateValues={(values) => updateRowValues(index, values)} updateValues={(values: string[]) => updateRowValues(index, values)}
options={row.attribute ? userAttributes.find((attr) => attr.name === row.attribute)?.attrs?.options || [] : []}
/> />
</div> </td>
<div className='table-editor__cell-actions'> <td className='table-editor__cell-actions'>
<button <button
type='button'
className='table-editor__row-remove' className='table-editor__row-remove'
onClick={() => removeRow(index)} onClick={() => removeRow(index)}
disabled={disabled} disabled={disabled}
@@ -271,22 +322,32 @@ function TableEditor({
> >
<i className='icon icon-trash-can-outline'/> <i className='icon icon-trash-can-outline'/>
</button> </button>
</div> </td>
</div> </tr>
)) ))
)} )}
</div> </tbody>
<div className='table-editor__add-button-container'> <tfoot>
<AddAttributeButton <tr>
onClick={addRow} <td
disabled={disabled || getAvailableAttributes().length === 0} colSpan={4}
/> className='table-editor__add-button-container'
</div> >
</div> <AddAttributeButton
onClick={addRow}
disabled={disabled || userAttributes.length === 0}
/>
</td>
</tr>
</tfoot>
</table>
<div className='table-editor__actions-row'> <div className='table-editor__actions-row'>
<HelpText <HelpText
message={'Each row is a single condition that must be met for a user to comply with the policy. All rules are combined with logical AND operator (`&&`).'} message={formatMessage({
id: 'admin.access_control.table_editor.help_text',
defaultMessage: 'Each row is a single condition that must be met for a user to comply with the policy. All rules are combined with logical AND operator (`&&`).',
})}
/> />
<TestButton <TestButton
onClick={() => setShowTestResults(true)} onClick={() => setShowTestResults(true)}

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

@@ -1,8 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export interface TableRow {
attribute: string;
operator: string;
values: string[];
}

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

@@ -0,0 +1,63 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {PropertyFieldOption} from '@mattermost/types/properties';
import MultiValueSelector from './multi_value_selector_menu';
import SingleValueSelector from './single_value_selector_menu';
export interface TableRow {
attribute: string;
operator: string;
values: string[];
}
export interface ValueSelectorMenuProps {
row: TableRow;
disabled: boolean;
updateValues: (values: string[]) => void;
options?: PropertyFieldOption[];
allowCreateValue?: boolean;
placeholder?: string;
}
// Main ValueSelectorMenu component that delegates to the appropriate selector
const ValueSelectorMenu = ({
row,
disabled,
updateValues,
options = [],
allowCreateValue = false,
placeholder,
}: ValueSelectorMenuProps) => {
const isMultiOperator = row.operator === 'in';
if (isMultiOperator) {
return (
<MultiValueSelector
values={row.values}
disabled={disabled}
updateValues={updateValues}
options={options}
allowCreateValue={allowCreateValue}
placeholder={placeholder}
/>
);
}
// For single-value operators
return (
<SingleValueSelector
value={row.values[0] || ''}
disabled={disabled}
updateValue={(value) => updateValues([value])}
options={options}
allowCreateValue={allowCreateValue}
placeholder={placeholder}
/>
);
};
export default ValueSelectorMenu;

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

@@ -1,78 +0,0 @@
.values-editor {
position: relative;
width: 100%;
.select__multi-value {
display: flex;
height: 24px;
align-items: center;
padding: 0;
border: none;
border-radius: 4px;
margin: 2px;
background-color: rgba(var(--center-channel-color-rgb), 0.08);
}
.select__multi-value__label {
padding: 0 8px;
color: var(--center-channel-color);
font-size: 12px;
line-height: 16px;
}
.select__multi-value__remove {
display: flex;
height: 100%;
align-items: center;
padding: 0 4px;
border-radius: 0 4px 4px 0;
color: rgba(var(--center-channel-color-rgb), 0.56);
cursor: pointer;
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.16);
color: var(--center-channel-color);
}
}
.select__control {
min-height: 40px;
border: none;
border-radius: 0;
overflow-y: auto;
&--is-focused {
border: none;
box-shadow: none;
}
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.06);
cursor: text;
}
}
&__simple-input {
width: 100%;
height: 40px;
padding: 0 16px;
border: none;
background: transparent;
color: var(--center-channel-color);
font-size: 14px;
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.06);
}
&:focus {
background: rgba(var(--center-channel-color-rgb), 0.06);
outline: none;
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
}

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

@@ -1,122 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState, useMemo} from 'react';
import {useIntl} from 'react-intl';
import CreatableSelect from 'react-select/creatable';
import Constants from 'utils/constants';
import './values_editor.scss';
import type {TableRow} from './table_row';
export type ValuesEditorProps = {
row: TableRow;
disabled: boolean;
updateValues: (values: string[]) => void;
}
function ValuesEditor({row, disabled, updateValues}: ValuesEditorProps) {
const {formatMessage} = useIntl();
const isMulti = row.operator === 'in';
const [inputValue, setInputValue] = useState('');
const [isEditing, setIsEditing] = useState(false);
// Format options for react-select
const value = useMemo(() => {
return row.values.map((val) => ({
label: val,
value: val,
}));
}, [row.values]);
// Handle input submission for single value
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
// Only update if there's actual text - don't set empty values
if (inputValue.trim()) {
updateValues([inputValue.trim()]);
}
setInputValue('');
setIsEditing(false);
}
};
// For single value mode, use a simple input field
if (!isMulti) {
const displayValue = row.values.length > 0 ? row.values[0] : '';
return (
<div className='values-editor'>
<input
type='text'
className='values-editor__simple-input'
value={isEditing ? inputValue : displayValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
setIsEditing(true);
if (displayValue) {
setInputValue(displayValue);
}
}}
onBlur={() => {
// Only update if there's actual text - don't set empty values
if (inputValue.trim()) {
updateValues([inputValue.trim()]);
}
setInputValue('');
setIsEditing(false);
}}
placeholder={formatMessage({id: 'admin.access_control.table_editor.value.placeholder', defaultMessage: 'Add value...'})}
disabled={disabled}
maxLength={Constants.MAX_CUSTOM_ATTRIBUTE_LENGTH}
/>
</div>
);
}
// For multi-value mode, continue using CreatableSelect
const customComponents = {
DropdownIndicator: () => null,
IndicatorsContainer: () => null,
};
const handleChange = (newValue: any) => {
if (!newValue) {
updateValues([]);
} else if (Array.isArray(newValue)) {
updateValues(newValue.map((option) => option.value));
}
};
return (
<div className='values-editor'>
<CreatableSelect
isMulti={true}
isClearable={true}
isDisabled={disabled}
components={customComponents}
value={value}
onChange={handleChange}
onCreateOption={(inputValue) => {
const val = inputValue.trim();
if (!val) {
return;
}
if (!row.values.includes(val)) {
updateValues([...row.values, val]);
}
}}
placeholder={formatMessage({id: 'admin.access_control.table_editor.values.placeholder', defaultMessage: 'Add values...'})}
classNamePrefix='select'
menuPortalTarget={document.body}
/>
</div>
);
}
export default ValuesEditor;

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

@@ -28,6 +28,7 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
className="admin-console__setting-group" className="admin-console__setting-group"
> >
<TextSetting <TextSetting
autoFocus={false}
id="admin.access_control.policy.edit_policy.policyName" id="admin.access_control.policy.edit_policy.policyName"
inputClassName="col-sm-8" inputClassName="col-sm-8"
label={ label={
@@ -94,7 +95,13 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<TableEditor <TableEditor
actions={
Object {
"getVisualAST": [MockFunction],
}
}
onChange={[Function]} onChange={[Function]}
onParseError={[Function]}
onValidate={[Function]} onValidate={[Function]}
userAttributes={Array []} userAttributes={Array []}
value="" value=""
@@ -225,6 +232,7 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
className="admin-console__setting-group" className="admin-console__setting-group"
> >
<TextSetting <TextSetting
autoFocus={false}
id="admin.access_control.policy.edit_policy.policyName" id="admin.access_control.policy.edit_policy.policyName"
inputClassName="col-sm-8" inputClassName="col-sm-8"
label={ label={
@@ -291,7 +299,13 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
</CardHeader> </CardHeader>
<CardBody> <CardBody>
<TableEditor <TableEditor
actions={
Object {
"getVisualAST": [MockFunction],
}
}
onChange={[Function]} onChange={[Function]}
onParseError={[Function]}
onValidate={[Function]} onValidate={[Function]}
userAttributes={Array []} userAttributes={Array []}
value="" value=""

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

@@ -5,7 +5,7 @@ import {connect} from 'react-redux';
import {bindActionCreators} from 'redux'; import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux'; import type {Dispatch} from 'redux';
import {getAccessControlPolicy as fetchPolicy, createAccessControlPolicy as createPolicy, deleteAccessControlPolicy as deletePolicy, searchAccessControlPolicyChannels as searchChannels, assignChannelsToAccessControlPolicy, unassignChannelsFromAccessControlPolicy, getAccessControlFields, updateAccessControlPolicyActive} from 'mattermost-redux/actions/access_control'; import {getAccessControlPolicy as fetchPolicy, createAccessControlPolicy as createPolicy, deleteAccessControlPolicy as deletePolicy, searchAccessControlPolicyChannels as searchChannels, assignChannelsToAccessControlPolicy, unassignChannelsFromAccessControlPolicy, getAccessControlFields, updateAccessControlPolicyActive, getVisualAST} from 'mattermost-redux/actions/access_control';
import {createJob} from 'mattermost-redux/actions/jobs'; import {createJob} from 'mattermost-redux/actions/jobs';
import {getAccessControlPolicy as getPolicy} from 'mattermost-redux/selectors/entities/access_control'; import {getAccessControlPolicy as getPolicy} from 'mattermost-redux/selectors/entities/access_control';
@@ -45,6 +45,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
getAccessControlFields, getAccessControlFields,
createJob, createJob,
updateAccessControlPolicyActive, updateAccessControlPolicyActive,
getVisualAST,
}, dispatch), }, dispatch),
}; };
} }

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

@@ -25,4 +25,12 @@
font-size: 16px; font-size: 16px;
} }
} }
.admin-console__warning-notice {
h4 {
margin-bottom: 0;
}
margin-bottom: 16px;
}
} }

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

@@ -33,7 +33,7 @@ describe('components/admin_console/access_control/policy_details/PolicyDetails',
const mockGetAccessControlFields = jest.fn(); const mockGetAccessControlFields = jest.fn();
const mockCreateJob = jest.fn(); const mockCreateJob = jest.fn();
const mockUpdateAccessControlPolicyActive = jest.fn(); const mockUpdateAccessControlPolicyActive = jest.fn();
const mockGetVisualAST = jest.fn();
const defaultProps = { const defaultProps = {
policyId: 'policy1', policyId: 'policy1',
channels: [ channels: [
@@ -64,6 +64,7 @@ describe('components/admin_console/access_control/policy_details/PolicyDetails',
getAccessControlFields: mockGetAccessControlFields, getAccessControlFields: mockGetAccessControlFields,
createJob: mockCreateJob, createJob: mockCreateJob,
updateAccessControlPolicyActive: mockUpdateAccessControlPolicyActive, updateAccessControlPolicyActive: mockUpdateAccessControlPolicyActive,
getVisualAST: mockGetVisualAST,
}, },
}; };
@@ -85,6 +86,7 @@ describe('components/admin_console/access_control/policy_details/PolicyDetails',
mockGetAccessControlFields.mockReset(); mockGetAccessControlFields.mockReset();
mockCreateJob.mockReset(); mockCreateJob.mockReset();
mockUpdateAccessControlPolicyActive.mockReset(); mockUpdateAccessControlPolicyActive.mockReset();
mockGetVisualAST.mockReset();
}); });
test('should match snapshot with new policy', () => { test('should match snapshot with new policy', () => {

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

@@ -9,7 +9,7 @@ import {GenericModal} from '@mattermost/components';
import type {AccessControlPolicy, AccessControlPolicyRule} from '@mattermost/types/access_control'; import type {AccessControlPolicy, AccessControlPolicyRule} from '@mattermost/types/access_control';
import type {ChannelSearchOpts, ChannelWithTeamData} from '@mattermost/types/channels'; import type {ChannelSearchOpts, ChannelWithTeamData} from '@mattermost/types/channels';
import type {JobTypeBase} from '@mattermost/types/jobs'; import type {JobTypeBase} from '@mattermost/types/jobs';
import type {PropertyField} from '@mattermost/types/properties'; import type {UserPropertyField} from '@mattermost/types/properties';
import type {ActionResult} from 'mattermost-redux/types/actions'; import type {ActionResult} from 'mattermost-redux/types/actions';
@@ -19,6 +19,7 @@ import Card from 'components/card/card';
import TitleAndButtonCardHeader from 'components/card/title_and_button_card_header/title_and_button_card_header'; import TitleAndButtonCardHeader from 'components/card/title_and_button_card_header/title_and_button_card_header';
import ChannelSelectorModal from 'components/channel_selector_modal'; import ChannelSelectorModal from 'components/channel_selector_modal';
import SaveButton from 'components/save_button'; import SaveButton from 'components/save_button';
import SectionNotice from 'components/section_notice';
import AdminHeader from 'components/widgets/admin_console/admin_header'; import AdminHeader from 'components/widgets/admin_console/admin_header';
import TextSetting from 'components/widgets/settings/text_setting'; import TextSetting from 'components/widgets/settings/text_setting';
@@ -46,6 +47,7 @@ interface PolicyActions {
getAccessControlFields: (after: string, limit: number) => Promise<ActionResult>; getAccessControlFields: (after: string, limit: number) => Promise<ActionResult>;
createJob: (job: JobTypeBase & { data: any }) => Promise<ActionResult>; createJob: (job: JobTypeBase & { data: any }) => Promise<ActionResult>;
updateAccessControlPolicyActive: (policyId: string, active: boolean) => Promise<ActionResult>; updateAccessControlPolicyActive: (policyId: string, active: boolean) => Promise<ActionResult>;
getVisualAST: (expression: string) => Promise<ActionResult>;
} }
export interface PolicyDetailsProps { export interface PolicyDetailsProps {
@@ -78,10 +80,12 @@ function PolicyDetails({
}); });
const [saveNeeded, setSaveNeeded] = useState(false); const [saveNeeded, setSaveNeeded] = useState(false);
const [channelsCount, setChannelsCount] = useState(0); const [channelsCount, setChannelsCount] = useState(0);
const [autocompleteResult, setAutocompleteResult] = useState<PropertyField[]>([]); const [autocompleteResult, setAutocompleteResult] = useState<UserPropertyField[]>([]);
const [attributesLoaded, setAttributesLoaded] = useState(false);
const [showConfirmationModal, setShowConfirmationModal] = useState(false); const [showConfirmationModal, setShowConfirmationModal] = useState(false);
const [showDeleteConfirmationModal, setShowDeleteConfirmationModal] = useState(false); const [showDeleteConfirmationModal, setShowDeleteConfirmationModal] = useState(false);
const {formatMessage} = useIntl(); const {formatMessage} = useIntl();
useEffect(() => { useEffect(() => {
loadPage(); loadPage();
}, [policyId]); }, [policyId]);
@@ -96,20 +100,21 @@ function PolicyDetails({
// or user.attributes.X.startsWith/endsWith/contains("Y") // or user.attributes.X.startsWith/endsWith/contains("Y")
return expr.split('&&').every((condition) => { return expr.split('&&').every((condition) => {
const trimmed = condition.trim(); const trimmed = condition.trim();
return trimmed.match(/^user\.attributes\.\w+\s*(==|!=)\s*['"][^'"]+['"]$/) || return trimmed.match(/^user\.attributes\.\w+\s*(==|!=)\s*['"][^'"]*['"]$/) ||
trimmed.match(/^user\.attributes\.\w+\s+in\s+\[.*?\]$/) || trimmed.match(/^user\.attributes\.\w+\s+in\s+\[.*?\]$/) ||
trimmed.match(/^user\.attributes\.\w+\.startsWith\(['"][^'"]+['"].*?\)$/) || trimmed.match(/^user\.attributes\.\w+\.startsWith\(['"][^'"]*['"].*?\)$/) ||
trimmed.match(/^user\.attributes\.\w+\.endsWith\(['"][^'"]+['"].*?\)$/) || trimmed.match(/^user\.attributes\.\w+\.endsWith\(['"][^'"]*['"].*?\)$/) ||
trimmed.match(/^user\.attributes\.\w+\.contains\(['"][^'"]+['"].*?\)$/); trimmed.match(/^user\.attributes\.\w+\.contains\(['"][^'"]*['"].*?\)$/);
}); });
}; };
const loadPage = async () => { const loadPage = async (): Promise<void> => {
// Fetch autocomplete fields first, as they are general and needed for both new and existing policies. // Fetch autocomplete fields first, as they are general and needed for both new and existing policies.
const fieldsPromise = actions.getAccessControlFields('', 100).then((result) => { const fieldsPromise = actions.getAccessControlFields('', 100).then((result) => {
if (result.data) { if (result.data) {
setAutocompleteResult(result.data); setAutocompleteResult(result.data);
} }
setAttributesLoaded(true);
}); });
if (policyId) { if (policyId) {
@@ -133,6 +138,25 @@ function PolicyDetails({
} }
}; };
const preSaveCheck = () => {
if (policyName.length === 0) {
setServerError(formatMessage({
id: 'admin.access_control.policy.edit_policy.error.name_required',
defaultMessage: 'Please add a name to the policy',
}));
return false;
}
if (expression.length === 0) {
setServerError(formatMessage({
id: 'admin.access_control.policy.edit_policy.error.expression_required',
defaultMessage: 'Please add an expression to the policy',
}));
return false;
}
return true;
};
const handleSubmit = async (apply = false) => { const handleSubmit = async (apply = false) => {
let success = true; let success = true;
let currentPolicyId = policyId; let currentPolicyId = policyId;
@@ -164,7 +188,10 @@ function PolicyDetails({
try { try {
await actions.updateAccessControlPolicyActive(currentPolicyId, autoSyncMembership); await actions.updateAccessControlPolicyActive(currentPolicyId, autoSyncMembership);
} catch (error) { } catch (error) {
setServerError(`Error updating policy active status: ${error.message}`); setServerError(formatMessage({
id: 'admin.access_control.policy.edit_policy.error.update_active_status',
defaultMessage: 'Error updating policy active status: {error}',
}, {error: error.message}));
success = false; success = false;
return; return;
} }
@@ -181,7 +208,10 @@ function PolicyDetails({
setChannelChanges({removed: {}, added: {}, removedCount: 0}); setChannelChanges({removed: {}, added: {}, removedCount: 0});
} catch (error) { } catch (error) {
setServerError(`Error assigning channels: ${error.message}`); setServerError(formatMessage({
id: 'admin.access_control.policy.edit_policy.error.assign_channels',
defaultMessage: 'Error assigning channels: {error}',
}, {error: error.message}));
success = false; success = false;
return; return;
} }
@@ -196,7 +226,10 @@ function PolicyDetails({
}; };
await actions.createJob(job); await actions.createJob(job);
} catch (error) { } catch (error) {
setServerError(`Error creating job: ${error.message}`); setServerError(formatMessage({
id: 'admin.access_control.policy.edit_policy.error.create_job',
defaultMessage: 'Error creating job: {error}',
}, {error: error.message}));
success = false; success = false;
return; return;
} }
@@ -221,7 +254,10 @@ function PolicyDetails({
try { try {
await actions.unassignChannelsFromAccessControlPolicy(policyId, Object.keys(channelChanges.removed)); await actions.unassignChannelsFromAccessControlPolicy(policyId, Object.keys(channelChanges.removed));
} catch (error) { } catch (error) {
setServerError(`Error unassigning channels: ${error.message}`); setServerError(formatMessage({
id: 'admin.access_control.policy.edit_policy.error.unassign_channels',
defaultMessage: 'Error unassigning channels: {error}',
}, {error: error.message}));
success = false; success = false;
} }
} }
@@ -231,7 +267,10 @@ function PolicyDetails({
try { try {
await actions.deletePolicy(policyId); await actions.deletePolicy(policyId);
} catch (error) { } catch (error) {
setServerError(`Error deleting policy: ${error.message}`); setServerError(formatMessage({
id: 'admin.access_control.policy.edit_policy.error.delete_policy',
defaultMessage: 'Error deleting policy: {error}',
}, {error: error.message}));
} }
} }
@@ -323,6 +362,7 @@ function PolicyDetails({
}} }}
labelClassName='col-sm-4 vertically-centered-label' labelClassName='col-sm-4 vertically-centered-label'
inputClassName='col-sm-8' inputClassName='col-sm-8'
autoFocus={policyId === undefined}
/> />
<BooleanSetting <BooleanSetting
id='admin.access_control.policy.edit_policy.autoSyncMembership' id='admin.access_control.policy.edit_policy.autoSyncMembership'
@@ -348,7 +388,30 @@ function PolicyDetails({
} }
/> />
</div> </div>
{attributesLoaded && autocompleteResult.length === 0 && (<div className='admin-console__warning-notice'>
<SectionNotice
type='warning'
title={
<FormattedMessage
id='admin.access_control.policy.edit_policy.notice.title'
defaultMessage='Please add user attributes and values to use Attribute-Based Access Control'
/>
}
text={formatMessage({
id: 'admin.access_control.policy.edit_policy.notice.text',
defaultMessage: 'You havent configured any user attributes yet. Attribute-Based Access Control requires user attributes that are either synced from an external system (like LDAP or SAML) or manually configured and enabled on this server. To start using attribute based access, please configure user attributes in System Properties.',
})}
primaryButton={{
text: formatMessage({
id: 'admin.access_control.policy.edit_policy.notice.button',
defaultMessage: 'Configure user attributes',
}),
onClick: () => {
getHistory().push('/admin_console/site_config/system_properties');
},
}}
/>
</div>)}
<Card <Card
expanded={true} expanded={true}
className={'console'} className={'console'}
@@ -384,7 +447,10 @@ function PolicyDetails({
isDisabled={editorMode === 'cel' && !isSimpleExpression(expression)} isDisabled={editorMode === 'cel' && !isSimpleExpression(expression)}
tooltipText={ tooltipText={
editorMode === 'cel' && !isSimpleExpression(expression) ? editorMode === 'cel' && !isSimpleExpression(expression) ?
'Complex expression detected. Simple expressions editor is not available at the moment.' : formatMessage({
id: 'admin.access_control.policy.edit_policy.complex_expression_tooltip',
defaultMessage: 'Complex expression detected. Simple expressions editor is not available at the moment.',
}) :
undefined undefined
} }
/> />
@@ -411,10 +477,13 @@ function PolicyDetails({
setSaveNeeded(true); setSaveNeeded(true);
}} }}
onValidate={() => {}} onValidate={() => {}}
userAttributes={autocompleteResult.map((attr) => ({ userAttributes={autocompleteResult}
attribute: attr.name, onParseError={() => {
values: [], setEditorMode('cel');
}))} }}
actions={{
getVisualAST: actions.getVisualAST,
}}
/> />
)} )}
</Card.Body> </Card.Body>
@@ -555,6 +624,9 @@ function PolicyDetails({
<SaveButton <SaveButton
disabled={!saveNeeded} disabled={!saveNeeded}
onClick={() => { onClick={() => {
if (!preSaveCheck()) {
return;
}
if (hasChannels()) { if (hasChannels()) {
setShowConfirmationModal(true); setShowConfirmationModal(true);
} else { } else {

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

@@ -720,7 +720,17 @@ const AdminDefinition: AdminDefinitionType = {
type: 'bool', type: 'bool',
key: 'AccessControlSettings.EnableAttributeBasedAccessControl', key: 'AccessControlSettings.EnableAttributeBasedAccessControl',
label: defineMessage({id: 'admin.accesscontrol.enableTitle', defaultMessage: 'Allow attribute based access controls on this server'}), label: defineMessage({id: 'admin.accesscontrol.enableTitle', defaultMessage: 'Allow attribute based access controls on this server'}),
help_text: defineMessage({id: 'admin.accesscontrol.enableDesc', defaultMessage: 'Allow access restrictions based on user attributes using custom access policies'}), help_text: defineMessage({id: 'admin.accesscontrol.enableDesc', defaultMessage: 'Allow access restrictions based on user attributes using custom access policies. To effectively use this feature, you must define user attributes (properties) in the {userAttributes} section.'}),
help_text_values: {
userAttributes: (
<a href='../site_config/system_properties'>
<FormattedMessage
id='admin.system_properties.user_properties.title'
defaultMessage='User Attributes'
/>
</a>
),
},
}, },
], ],
}, },

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

@@ -285,6 +285,7 @@
"admin.access_control.policy.edit_policy.channel_selector.remove": "Remove", "admin.access_control.policy.edit_policy.channel_selector.remove": "Remove",
"admin.access_control.policy.edit_policy.channel_selector.subtitle": "Add channels that this attribute-based access policy will apply to.", "admin.access_control.policy.edit_policy.channel_selector.subtitle": "Add channels that this attribute-based access policy will apply to.",
"admin.access_control.policy.edit_policy.channel_selector.title": "Assigned channels", "admin.access_control.policy.edit_policy.channel_selector.title": "Assigned channels",
"admin.access_control.policy.edit_policy.complex_expression_tooltip": "Complex expression detected. Simple expressions editor is not available at the moment.",
"admin.access_control.policy.edit_policy.delete_confirmation.confirm_button": "Delete Policy", "admin.access_control.policy.edit_policy.delete_confirmation.confirm_button": "Delete Policy",
"admin.access_control.policy.edit_policy.delete_confirmation.message": "Are you sure you want to delete this policy? This action cannot be undone.", "admin.access_control.policy.edit_policy.delete_confirmation.message": "Are you sure you want to delete this policy? This action cannot be undone.",
"admin.access_control.policy.edit_policy.delete_confirmation.title": "Confirm Policy Deletion", "admin.access_control.policy.edit_policy.delete_confirmation.title": "Confirm Policy Deletion",
@@ -292,6 +293,16 @@
"admin.access_control.policy.edit_policy.delete_policy.subtitle": "This policy will be deleted and cannot be recovered.", "admin.access_control.policy.edit_policy.delete_policy.subtitle": "This policy will be deleted and cannot be recovered.",
"admin.access_control.policy.edit_policy.delete_policy.subtitle.has_resources": "Remove all assigned resources (eg. Channels) to be able to delete this policy", "admin.access_control.policy.edit_policy.delete_policy.subtitle.has_resources": "Remove all assigned resources (eg. Channels) to be able to delete this policy",
"admin.access_control.policy.edit_policy.delete_policy.title": "Delete policy", "admin.access_control.policy.edit_policy.delete_policy.title": "Delete policy",
"admin.access_control.policy.edit_policy.error.assign_channels": "Error assigning channels: {error}",
"admin.access_control.policy.edit_policy.error.create_job": "Error creating job: {error}",
"admin.access_control.policy.edit_policy.error.delete_policy": "Error deleting policy: {error}",
"admin.access_control.policy.edit_policy.error.expression_required": "Please add an expression to the policy",
"admin.access_control.policy.edit_policy.error.name_required": "Please add a name to the policy",
"admin.access_control.policy.edit_policy.error.unassign_channels": "Error unassigning channels: {error}",
"admin.access_control.policy.edit_policy.error.update_active_status": "Error updating policy active status: {error}",
"admin.access_control.policy.edit_policy.notice.button": "Configure user attributes",
"admin.access_control.policy.edit_policy.notice.text": "You havent configured any user attributes yet. Attribute-Based Access Control requires user attributes that are either synced from an external system (like LDAP or SAML) or manually configured and enabled on this server. To start using attribute based access, please configure user attributes in System Properties.",
"admin.access_control.policy.edit_policy.notice.title": "Please add user attributes and values to use Attribute-Based Access Control",
"admin.access_control.policy.edit_policy.policyName": "Access control policy name:", "admin.access_control.policy.edit_policy.policyName": "Access control policy name:",
"admin.access_control.policy.edit_policy.policyName.placeholder": "Add a unique policy name", "admin.access_control.policy.edit_policy.policyName.placeholder": "Add a unique policy name",
"admin.access_control.policy.edit_policy.switch_to_advanced": "Switch to Advanced Mode", "admin.access_control.policy.edit_policy.switch_to_advanced": "Switch to Advanced Mode",
@@ -305,7 +316,11 @@
"admin.access_control.policy.save_policy_confirmation_title": "Save access control policy ", "admin.access_control.policy.save_policy_confirmation_title": "Save access control policy ",
"admin.access_control.table_editor.add_attribute": "Add attribute", "admin.access_control.table_editor.add_attribute": "Add attribute",
"admin.access_control.table_editor.attribute": "Attribute", "admin.access_control.table_editor.attribute": "Attribute",
"admin.access_control.table_editor.attribute_spaces_not_supported": "CEL is not compatible with variable names containing spaces",
"admin.access_control.table_editor.attribute_synced": "This attribute is synced from an external source",
"admin.access_control.table_editor.blank_state": "Select a user attribute and values to create a rule", "admin.access_control.table_editor.blank_state": "Select a user attribute and values to create a rule",
"admin.access_control.table_editor.create_value": "Create \"{value}\"",
"admin.access_control.table_editor.help_text": "Each row is a single condition that must be met for a user to comply with the policy. All rules are combined with logical AND operator (`&&`).",
"admin.access_control.table_editor.learnMore": "Learn more about creating access expressions with examples.", "admin.access_control.table_editor.learnMore": "Learn more about creating access expressions with examples.",
"admin.access_control.table_editor.operator": "Operator", "admin.access_control.table_editor.operator": "Operator",
"admin.access_control.table_editor.operator.contains": "contains", "admin.access_control.table_editor.operator.contains": "contains",
@@ -317,13 +332,16 @@
"admin.access_control.table_editor.remove_row": "Remove row", "admin.access_control.table_editor.remove_row": "Remove row",
"admin.access_control.table_editor.selector.filter_attributes": "Search attributes...", "admin.access_control.table_editor.selector.filter_attributes": "Search attributes...",
"admin.access_control.table_editor.selector.filter_operators": "Search operators...", "admin.access_control.table_editor.selector.filter_operators": "Search operators...",
"admin.access_control.table_editor.selector.filter_or_create": "Search or create value...",
"admin.access_control.table_editor.selector.select_attribute": "Select attribute", "admin.access_control.table_editor.selector.select_attribute": "Select attribute",
"admin.access_control.table_editor.test_access_rule": "Test access rule", "admin.access_control.table_editor.test_access_rule": "Test access rule",
"admin.access_control.table_editor.value.placeholder": "Add value...", "admin.access_control.table_editor.value.placeholder": "Add value...",
"admin.access_control.table_editor.value.select_value": "Select value",
"admin.access_control.table_editor.values": "Values", "admin.access_control.table_editor.values": "Values",
"admin.access_control.table_editor.values.placeholder": "Add values...", "admin.access_control.table_editor.values.create_placeholder": "Type to create value",
"admin.access_control.table_editor.values.select_values": "Select values...",
"admin.access_control.testResults": "Access Rule Test Results", "admin.access_control.testResults": "Access Rule Test Results",
"admin.accesscontrol.enableDesc": "Allow access restrictions based on user attributes using custom access policies", "admin.accesscontrol.enableDesc": "Allow access restrictions based on user attributes using custom access policies. To effectively use this feature, you must define user attributes (properties) in the {userAttributes} section.",
"admin.accesscontrol.enableTitle": "Allow attribute based access controls on this servers", "admin.accesscontrol.enableTitle": "Allow attribute based access controls on this servers",
"admin.accesscontrol.title": "Attribute-Based Access", "admin.accesscontrol.title": "Attribute-Based Access",
"admin.advance.cluster": "High Availability", "admin.advance.cluster": "High Availability",

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

@@ -150,3 +150,10 @@ export function searchUsersForExpression(expression: string, term: string, after
return {data}; return {data};
}; };
} }
export function getVisualAST(expression: string) {
return bindClientFunc({
clientFunc: Client4.expressionToVisualFormat,
params: [expression],
});
}

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

@@ -109,7 +109,7 @@ import type {
import type {Post, PostList, PostSearchResults, PostsUsageResponse, TeamsUsageResponse, PaginatedPostList, FilesUsageResponse, PostAcknowledgement, PostAnalytics, PostInfo} from '@mattermost/types/posts'; import type {Post, PostList, PostSearchResults, PostsUsageResponse, TeamsUsageResponse, PaginatedPostList, FilesUsageResponse, PostAcknowledgement, PostAnalytics, PostInfo} from '@mattermost/types/posts';
import type {PreferenceType} from '@mattermost/types/preferences'; import type {PreferenceType} from '@mattermost/types/preferences';
import type {ProductNotices} from '@mattermost/types/product_notices'; import type {ProductNotices} from '@mattermost/types/product_notices';
import type {PropertyField, UserPropertyField, UserPropertyFieldPatch} from '@mattermost/types/properties'; import type {UserPropertyField, UserPropertyFieldPatch} from '@mattermost/types/properties';
import type {Reaction} from '@mattermost/types/reactions'; import type {Reaction} from '@mattermost/types/reactions';
import type {RemoteCluster, RemoteClusterAcceptInvite, RemoteClusterPatch, RemoteClusterWithPassword} from '@mattermost/types/remote_clusters'; import type {RemoteCluster, RemoteClusterAcceptInvite, RemoteClusterPatch, RemoteClusterWithPassword} from '@mattermost/types/remote_clusters';
import type {UserReport, UserReportFilter, UserReportOptions} from '@mattermost/types/reports'; import type {UserReport, UserReportFilter, UserReportOptions} from '@mattermost/types/reports';
@@ -4486,7 +4486,7 @@ export default class Client4 {
}; };
getAccessControlFields = (after: string, limit: number) => { getAccessControlFields = (after: string, limit: number) => {
return this.doFetch<PropertyField[]>( return this.doFetch<UserPropertyField[]>(
`${this.getBaseRoute()}/access_control_policies/cel/autocomplete/fields?after=${after}&limit=${limit}`, `${this.getBaseRoute()}/access_control_policies/cel/autocomplete/fields?after=${after}&limit=${limit}`,
{method: 'get'}, {method: 'get'},
); );