[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 {
appErr := c.App.UnAssignPoliciesFromChannels(c.AppContext, policyID, assignments.ChannelIds)
appErr := c.App.UnassignPoliciesFromChannels(c.AppContext, policyID, assignments.ChannelIds)
if appErr != nil {
c.Err = appErr
return

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

@@ -30,6 +30,7 @@ func (a *App) GetChannelsForPolicy(rctx request.CTX, policyID string, cursor mod
}
channelIDs := make([]string, 0, len(policies))
// channel IDs are the same as policy IDs
for _, p := range policies {
channelIDs = append(channelIDs, p.ID)
}
@@ -173,10 +174,10 @@ func (a *App) AssignAccessControlPolicyToChannels(rctx request.CTX, parentID str
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
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{
@@ -184,7 +185,7 @@ func (a *App) UnAssignPoliciesFromChannels(rctx request.CTX, policyID string, ch
ParentID: policyID,
})
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)

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

@@ -399,7 +399,7 @@ func TestUnAssignPoliciesFromChannels(t *testing.T) {
t.Run("Feature not enabled", func(t *testing.T) {
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)
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, 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)
assert.Equal(t, expectedErr.Id, appErr.Id)
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, 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)
})
@@ -449,7 +449,7 @@ func TestUnAssignPoliciesFromChannels(t *testing.T) {
mockAccessControl.On("DeletePolicy", rctx, ch1.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)
})
}

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

@@ -1614,7 +1614,7 @@ func (a *App) addUserToChannel(c request.CTX, user *model.User, channel *model.C
}
}
} 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
import (
"net/http"
"os"
"os/signal"
"runtime"
@@ -138,7 +139,7 @@ func NewChannels(s *Server) (*Channels, error) {
ch.AccessControl = accessControlServiceInterface(app)
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))
}

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

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

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

@@ -319,14 +319,14 @@ func testAccessControlPolicyStoreGetAll(t *testing.T, rctx request.CTX, ss store
require.NoError(t, err)
require.NotNil(t, resourcePolicy)
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.NotNil(t, policies)
require.Len(t, policies, 3)
})
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.NotNil(t, policies)
require.Len(t, policies, 2)

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

@@ -7920,6 +7920,10 @@
"id": "common.parse_error_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",
"translation": "Failed to run access control sync job."

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

@@ -627,11 +627,11 @@ func (c *Client4) accessControlPoliciesRoute() string {
}
func (c *Client4) celRoute() string {
return "/access_control_policies/cel"
return fmt.Sprintf(c.accessControlPoliciesRoute() + "/cel")
}
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) {

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

@@ -102,7 +102,9 @@ function CELEditor({
const schemas = {
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);

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

@@ -53,3 +53,22 @@
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 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 {
onClick: () => void;
disabled: boolean;

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

@@ -2,60 +2,113 @@
// See LICENSE.txt for license information.
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 {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 {UserPropertyField} from '@mattermost/types/properties';
import * as Menu from 'components/menu';
import WithTooltip from 'components/with_tooltip';
import './selector_menus.scss';
interface AttributeOption {
attribute: string;
values: string[];
}
// Define AttributeIcon outside the main component
const AttributeIcon = (props: IconProps & { attribute?: UserPropertyField }) => {
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 {
currentAttribute: string;
availableAttributes: AttributeOption[];
availableAttributes: UserPropertyField[];
disabled: boolean;
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 [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 is stable
const options = useMemo(() => {
return availableAttributes.filter((attr) => {
return attr.attribute.toLowerCase().includes(filter.toLowerCase());
return attr.name.toLowerCase().includes(filter.toLowerCase());
});
}, [availableAttributes, filter]);
const handleAttributeChange = (attribute: string) => {
const handleAttributeChange = React.useCallback((attribute: string) => {
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 AttributeIcon = (props: IconProps) => <MenuVariantIcon {...props}/>;
const selectedAttributeObject = useMemo(() => {
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 (
<Menu.Container
menuButton={{
id: 'attribute-selector-button',
id: buttonId,
class: classNames('btn btn-transparent field-selector-menu-button', {
disabled,
}),
children: (
<>
<AttributeIcon/>
<AttributeIcon attribute={selectedAttributeObject}/>
{currentAttribute || formatMessage({id: 'admin.access_control.table_editor.selector.select_attribute', defaultMessage: 'Select attribute'})}
</>
),
@@ -63,39 +116,91 @@ const AttributeSelectorMenu = ({currentAttribute, availableAttributes, disabled,
disabled,
}}
menu={{
id: 'attribute-selector-menu',
id: menuId,
'aria-label': 'Select attribute',
className: 'select-attribute-mui-menu',
}}
>
{[
<Menu.InputItem
key='filter_attributes'
id='filter_attributes'
type='text'
placeholder={formatMessage({id: 'admin.access_control.table_editor.selector.filter_attributes', defaultMessage: 'Search attributes...'})}
className='attribute-selector-search'
value={filter}
onChange={onFilterChange}
/>,
]}
<Menu.InputItem
key='filter_attributes'
id='filter_attributes'
type='text'
placeholder={formatMessage({id: 'admin.access_control.table_editor.selector.filter_attributes', defaultMessage: 'Search attributes...'})}
className='attribute-selector-search'
value={filter}
onChange={onFilterChange}
/>
{options.map((option) => {
const {attribute} = option;
return (
const {name} = option;
const hasSpaces = name.includes(' ');
const isSynced = option.attrs?.ldap || option.attrs?.saml;
const menuItem = (
<Menu.Item
id={`attribute-${attribute}`}
key={attribute}
id={`attribute-${name}`}
key={name}
role='menuitemradio'
forceCloseOnSelect={true}
aria-checked={attribute === currentAttribute}
onClick={() => handleAttributeChange(attribute)}
labels={<span>{attribute}</span>}
leadingElement={<AttributeIcon size={18}/>}
trailingElements={attribute === currentAttribute && (
<CheckIcon/>
aria-checked={name === currentAttribute}
onClick={hasSpaces ? undefined : () => handleAttributeChange(name)}
labels={<span>{name}</span>}
disabled={hasSpaces}
leadingElement={
<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>
);

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

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

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

@@ -2,19 +2,11 @@
width: 100%;
height: 40px;
justify-content: start;
border-color: transparent;
border-radius: 0;
box-shadow: none;
border: none;
font-weight: normal;
&:hover,
&:focus {
border-color: transparent;
box-shadow: none;
}
&:hover {
background: rgba(var(--center-channel-color-rgb), 0.04)
background: rgba(var(--center-channel-color-rgb), 0.04);
}
&:focus,
@@ -27,20 +19,79 @@
opacity: 0.6;
}
svg {
margin-right: 8px;
> span > span:first-child:not([style*="flex-wrap: wrap"]) {
overflow: hidden;
padding-inline-start: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.select-attribute-mui-menu,
.select-operator-mui-menu {
margin-top: 0;
.value-selector-menu-button {
&__multi-values-container {
display: flex;
overflow: hidden;
flex-grow: 1;
flex-wrap: wrap;
gap: 2px;
}
.MenuItem {
height: 40px;
&__inner-wrapper {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
}
}
.values-editor {
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;
svg {
margin-right: 8px;
&: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 {
position: relative;
margin-bottom: 24px;
&__table {
overflow: hidden;
width: 100%;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
border-radius: 4px;
border-collapse: collapse;
}
&__header {
display: flex;
padding: 12px 16px;
th {
padding: 12px;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
background: rgba(var(--center-channel-color-rgb), 0.04);
}
&__column-header {
flex: 1;
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
padding-inline: 10px;
&:nth-child(1) {
flex: 1;
}
&:nth-child(2) {
flex: 0.8;
}
&:nth-child(3) {
flex: 2.3;
}
text-align: left;
}
&__column-header-actions {
color: var(--center-channel-color);
font-size: 14px;
font-weight: 600;
}
&__row {
display: flex;
align-items: center;
padding: 0;
td {
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
vertical-align: middle;
}
&__cell {
flex: 1;
&:nth-child(1) {
flex: 1;
}
&:nth-child(2) {
flex: 0.8;
}
&:nth-child(3) {
flex: 2;
}
.table-editor__column-header-value {
padding-inline: 10px;
}
&__cell-actions {
width: 40px;
text-align: right;
}
// Set column widths
th:nth-child(2), td:nth-child(2) { width: 20%; }
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,
&__operator-select {
width: 100%;
}
&__select {
width: 100%;
padding: 10px 16px;
border: none;
border-radius: 4px;
appearance: none;
background-color: transparent;
color: var(--center-channel-color);
font-size: 14px;
&__blank-state {
padding: 16px;
text-align: center;
&: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;
span {
color: rgba(var(--center-channel-color-rgb), 0.64);
}
}
&__row-remove {
display: flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: none;
color: rgba(var(--center-channel-color-rgb), 0.56);
@@ -113,62 +48,15 @@
&:hover {
color: var(--error-text);
}
&:disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
&__actions-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
.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);
}
margin-top: 12px;
}
&__add-button-container {
display: flex;
align-items: center;
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.
// 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 type {AccessControlVisualAST} from '@mattermost/types/access_control';
import type {UserPropertyField} from '@mattermost/types/properties';
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 OperatorSelectorMenu from './operator_selector_menu';
import type {TableRow} from './table_row';
import ValuesEditor from './values_editor';
import type {TableRow} from './value_selector_menu';
import ValueSelectorMenu from './value_selector_menu';
import CELHelpModal from '../../modals/cel_help/cel_help_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';
@@ -23,53 +26,36 @@ interface TableEditorProps {
onChange: (value: string) => void;
onValidate?: (isValid: boolean) => void;
disabled?: boolean;
userAttributes: Array<{
attribute: string;
values: string[];
}>;
userAttributes: UserPropertyField[];
onParseError: (error: string) => void;
actions: {
getVisualAST: (expr: string) => Promise<ActionResult>;
};
}
// Parse CEL expression into table rows
const parseExpression = async (expr: string): Promise<TableRow[]> => {
// Parses a CEL (Common Expression Language) string into a structured array of TableRow objects.
// This allows the expression to be displayed and edited in a user-friendly table format.
export const parseExpression = (visualAST: AccessControlVisualAST): TableRow[] => {
const tableRows: TableRow[] = [];
if (!expr) {
if (!visualAST) {
return tableRows;
}
const rawVisualAST = await Client4.expressionToVisualFormat(expr);
for (const node of rawVisualAST.conditions) {
for (const node of visualAST.conditions) {
let attr;
// Extracts the attribute name, removing the 'user.attributes.' prefix.
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 {
throw new Error(`Unknown attribute: ${node.attribute}`);
}
let op;
switch (node.operator) {
case '==':
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 op = OPERATOR_LABELS[node.operator];
if (!op) {
// Fallback for unknown operators, defaulting to 'is' logic
op = OperatorLabel.IS;
}
let values;
@@ -89,181 +75,246 @@ const parseExpression = async (expr: string): Promise<TableRow[]> => {
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({
value,
onChange,
onValidate,
disabled = false,
userAttributes,
onParseError,
actions,
}: TableEditorProps): JSX.Element {
const {formatMessage} = useIntl();
const [rows, setRows] = useState<TableRow[]>([]);
const [showTestResults, setShowTestResults] = 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(() => {
parseExpression(value).then((rows) => {
setRows(rows);
actions.getVisualAST(value).then((result) => {
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
const updateExpression = (newRows: TableRow[]) => {
const validRows = newRows.filter((row) => row.attribute && row.values.length > 0);
const expr = validRows.map((row) => {
if (row.operator === 'is') {
return `user.attributes.${row.attribute} == "${row.values[0]}"`;
// Converts the internal rows state back into a CEL expression string
// and calls the onChange and onValidate props.
const updateExpression = useCallback((newRows: TableRow[]) => {
const rowsThatCanFormExpressions = newRows.filter((row) => row.attribute); // Only include rows that have an attribute selected
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') {
return `user.attributes.${row.attribute} != "${row.values[0]}"`;
if (config.type === 'list') { // Handles 'in'
const valuesStr = row.values.map((val: string) => `"${val}"`).join(', ');
return `${attributeExpr} ${config.celOp} [${valuesStr}]`;
}
if (row.operator === 'starts with') {
return `user.attributes.${row.attribute}.startsWith("${row.values[0]}")`;
// For 'comparison' and 'method' types, they operate on a single value.
const value = row.values.length > 0 ? row.values[0] : '';
if (config.type === 'comparison') {
return `${attributeExpr} ${config.celOp} "${value}"`;
}
if (row.operator === 'ends with') {
return `user.attributes.${row.attribute}.endsWith("${row.values[0]}")`;
}
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}]`;
// config.type must be 'method'
return `${attributeExpr}.${config.celOp}("${value}")`;
}).join(' && ');
onChange(expr);
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 = () => {
// Find first available attribute
const availableAttrs = getAvailableAttributes();
if (availableAttrs.length === 0) {
return;
// Row Manipulation Handlers
const addRow = useCallback(() => {
if (userAttributes.length === 0) {
return; // Do not add a row if no attributes are available
}
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, {
attribute: availableAttrs[0].attribute,
operator: 'is',
values: [],
}];
const removeRow = useCallback((index: number) => {
setRows((currentRows) => {
const newRows = currentRows.toSpliced(index, 1);
updateExpression(newRows);
return newRows;
});
}, [updateExpression]);
setRows(newRows);
updateExpression(newRows);
};
const updateRowAttribute = useCallback((index: number, attribute: string) => {
setRows((currentRows) => {
const newRows = [...currentRows];
const oldAttribute = newRows[index].attribute;
newRows[index] = {...newRows[index], attribute};
const removeRow = (index: number) => {
const newRows = rows.filter((_, i) => i !== index);
setRows(newRows);
updateExpression(newRows);
};
// If attribute changes, we are resetting values.
if (oldAttribute !== attribute) {
newRows[index].values = [];
newRows[index].operator = OperatorLabel.IS;
}
updateExpression(newRows);
return newRows;
});
}, [updateExpression]);
const updateRowAttribute = (index: number, attribute: string) => {
const newRows = [...rows];
newRows[index].attribute = attribute;
setRows(newRows);
updateExpression(newRows);
};
const updateRowOperator = useCallback((index: number, newOperator: string) => {
setRows((currentRows) => {
const oldOperator = currentRows[index].operator;
let newValues = [...currentRows[index].values]; // Start with a copy of current values
const updateRowOperator = (index: number, operator: string) => {
const newRows = [...rows];
newRows[index].operator = operator;
if (newOperator === OperatorLabel.IN && oldOperator !== OperatorLabel.IN) {
// Transitioning TO 'in' FROM a non-'in' (likely single-value) 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) {
newRows[index].values = newRows[index].values.length > 0 ? [newRows[index].values[0]] : [];
}
const newRows = [...currentRows];
newRows[index] = {
...currentRows[index],
operator: newOperator,
values: newValues,
};
setRows(newRows);
updateExpression(newRows);
};
updateExpression(newRows);
return newRows;
});
}, [updateExpression]);
const updateRowValues = (index: number, values: string[]) => {
const newRows = [...rows];
newRows[index].values = values;
setRows(newRows);
updateExpression(newRows);
};
// Get available attributes (excluding ones already used)
const getAvailableAttributes = () => {
const usedAttributes = new Set(rows.map((row) => row.attribute));
return userAttributes.filter((attr) => !usedAttributes.has(attr.attribute));
};
const updateRowValues = useCallback((index: number, values: string[]) => {
setRows((currentRows) => {
const newRows = [...currentRows];
newRows[index] = {...newRows[index], values};
updateExpression(newRows);
return newRows;
});
}, [updateExpression]);
return (
<div className='table-editor'>
<div className='table-editor__table'>
<div className='table-editor__header'>
<div className='table-editor__column-header'>
<FormattedMessage
id='admin.access_control.table_editor.attribute'
defaultMessage='Attribute'
/>
</div>
<div className='table-editor__column-header'>
<FormattedMessage
id='admin.access_control.table_editor.operator'
defaultMessage='Operator'
/>
</div>
<div className='table-editor__column-header'>
<FormattedMessage
id='admin.access_control.table_editor.values'
defaultMessage='Values'
/>
</div>
<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',
})}
<table className='table-editor__table'>
<thead>
<tr className='table-editor__header-row'>
<th className='table-editor__column-header'>
<FormattedMessage
id='admin.access_control.table_editor.attribute'
defaultMessage='Attribute'
/>
</th>
<th className='table-editor__column-header'>
<FormattedMessage
id='admin.access_control.table_editor.operator'
defaultMessage='Operator'
/>
</th>
<th className='table-editor__column-header'>
<span className='table-editor__column-header-value'>
<FormattedMessage
id='admin.access_control.table_editor.values'
defaultMessage='Values'
/>
</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) => (
<div
<tr
key={index}
className='table-editor__row'
>
<div className='table-editor__cell'>
<td className='table-editor__cell'>
<AttributeSelectorMenu
currentAttribute={row.attribute}
availableAttributes={getAvailableAttributes().concat(
row.attribute ? [{attribute: row.attribute, values: []}] : [],
)}
availableAttributes={userAttributes}
disabled={disabled}
onChange={(attribute) => updateRowAttribute(index, attribute)}
menuId={`attribute-selector-menu-${index}`}
buttonId={`attribute-selector-button-${index}`}
autoOpen={index === autoOpenAttributeMenuForRow}
onMenuOpened={() => setAutoOpenAttributeMenuForRow(null)}
/>
</div>
<div className='table-editor__cell'>
</td>
<td className='table-editor__cell'>
<OperatorSelectorMenu
currentOperator={row.operator}
disabled={disabled}
onChange={(operator) => updateRowOperator(index, operator)}
/>
</div>
<div className='table-editor__cell'>
<ValuesEditor
</td>
<td className='table-editor__cell'>
<ValueSelectorMenu
row={row}
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>
<div className='table-editor__cell-actions'>
</td>
<td className='table-editor__cell-actions'>
<button
type='button'
className='table-editor__row-remove'
onClick={() => removeRow(index)}
disabled={disabled}
@@ -271,22 +322,32 @@ function TableEditor({
>
<i className='icon icon-trash-can-outline'/>
</button>
</div>
</div>
</td>
</tr>
))
)}
</div>
<div className='table-editor__add-button-container'>
<AddAttributeButton
onClick={addRow}
disabled={disabled || getAvailableAttributes().length === 0}
/>
</div>
</div>
</tbody>
<tfoot>
<tr>
<td
colSpan={4}
className='table-editor__add-button-container'
>
<AddAttributeButton
onClick={addRow}
disabled={disabled || userAttributes.length === 0}
/>
</td>
</tr>
</tfoot>
</table>
<div className='table-editor__actions-row'>
<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
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"
>
<TextSetting
autoFocus={false}
id="admin.access_control.policy.edit_policy.policyName"
inputClassName="col-sm-8"
label={
@@ -94,7 +95,13 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
</CardHeader>
<CardBody>
<TableEditor
actions={
Object {
"getVisualAST": [MockFunction],
}
}
onChange={[Function]}
onParseError={[Function]}
onValidate={[Function]}
userAttributes={Array []}
value=""
@@ -225,6 +232,7 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
className="admin-console__setting-group"
>
<TextSetting
autoFocus={false}
id="admin.access_control.policy.edit_policy.policyName"
inputClassName="col-sm-8"
label={
@@ -291,7 +299,13 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh
</CardHeader>
<CardBody>
<TableEditor
actions={
Object {
"getVisualAST": [MockFunction],
}
}
onChange={[Function]}
onParseError={[Function]}
onValidate={[Function]}
userAttributes={Array []}
value=""

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

@@ -5,7 +5,7 @@ import {connect} from 'react-redux';
import {bindActionCreators} 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 {getAccessControlPolicy as getPolicy} from 'mattermost-redux/selectors/entities/access_control';
@@ -45,6 +45,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
getAccessControlFields,
createJob,
updateAccessControlPolicyActive,
getVisualAST,
}, dispatch),
};
}

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

@@ -25,4 +25,12 @@
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 mockCreateJob = jest.fn();
const mockUpdateAccessControlPolicyActive = jest.fn();
const mockGetVisualAST = jest.fn();
const defaultProps = {
policyId: 'policy1',
channels: [
@@ -64,6 +64,7 @@ describe('components/admin_console/access_control/policy_details/PolicyDetails',
getAccessControlFields: mockGetAccessControlFields,
createJob: mockCreateJob,
updateAccessControlPolicyActive: mockUpdateAccessControlPolicyActive,
getVisualAST: mockGetVisualAST,
},
};
@@ -85,6 +86,7 @@ describe('components/admin_console/access_control/policy_details/PolicyDetails',
mockGetAccessControlFields.mockReset();
mockCreateJob.mockReset();
mockUpdateAccessControlPolicyActive.mockReset();
mockGetVisualAST.mockReset();
});
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 {ChannelSearchOpts, ChannelWithTeamData} from '@mattermost/types/channels';
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';
@@ -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 ChannelSelectorModal from 'components/channel_selector_modal';
import SaveButton from 'components/save_button';
import SectionNotice from 'components/section_notice';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import TextSetting from 'components/widgets/settings/text_setting';
@@ -46,6 +47,7 @@ interface PolicyActions {
getAccessControlFields: (after: string, limit: number) => Promise<ActionResult>;
createJob: (job: JobTypeBase & { data: any }) => Promise<ActionResult>;
updateAccessControlPolicyActive: (policyId: string, active: boolean) => Promise<ActionResult>;
getVisualAST: (expression: string) => Promise<ActionResult>;
}
export interface PolicyDetailsProps {
@@ -78,10 +80,12 @@ function PolicyDetails({
});
const [saveNeeded, setSaveNeeded] = useState(false);
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 [showDeleteConfirmationModal, setShowDeleteConfirmationModal] = useState(false);
const {formatMessage} = useIntl();
useEffect(() => {
loadPage();
}, [policyId]);
@@ -96,20 +100,21 @@ function PolicyDetails({
// or user.attributes.X.startsWith/endsWith/contains("Y")
return expr.split('&&').every((condition) => {
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+\.startsWith\(['"][^'"]+['"].*?\)$/) ||
trimmed.match(/^user\.attributes\.\w+\.endsWith\(['"][^'"]+['"].*?\)$/) ||
trimmed.match(/^user\.attributes\.\w+\.contains\(['"][^'"]+['"].*?\)$/);
trimmed.match(/^user\.attributes\.\w+\.startsWith\(['"][^'"]*['"].*?\)$/) ||
trimmed.match(/^user\.attributes\.\w+\.endsWith\(['"][^'"]*['"].*?\)$/) ||
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.
const fieldsPromise = actions.getAccessControlFields('', 100).then((result) => {
if (result.data) {
setAutocompleteResult(result.data);
}
setAttributesLoaded(true);
});
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) => {
let success = true;
let currentPolicyId = policyId;
@@ -164,7 +188,10 @@ function PolicyDetails({
try {
await actions.updateAccessControlPolicyActive(currentPolicyId, autoSyncMembership);
} 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;
return;
}
@@ -181,7 +208,10 @@ function PolicyDetails({
setChannelChanges({removed: {}, added: {}, removedCount: 0});
} 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;
return;
}
@@ -196,7 +226,10 @@ function PolicyDetails({
};
await actions.createJob(job);
} 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;
return;
}
@@ -221,7 +254,10 @@ function PolicyDetails({
try {
await actions.unassignChannelsFromAccessControlPolicy(policyId, Object.keys(channelChanges.removed));
} 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;
}
}
@@ -231,7 +267,10 @@ function PolicyDetails({
try {
await actions.deletePolicy(policyId);
} 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'
inputClassName='col-sm-8'
autoFocus={policyId === undefined}
/>
<BooleanSetting
id='admin.access_control.policy.edit_policy.autoSyncMembership'
@@ -348,7 +388,30 @@ function PolicyDetails({
}
/>
</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
expanded={true}
className={'console'}
@@ -384,7 +447,10 @@ function PolicyDetails({
isDisabled={editorMode === 'cel' && !isSimpleExpression(expression)}
tooltipText={
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
}
/>
@@ -411,10 +477,13 @@ function PolicyDetails({
setSaveNeeded(true);
}}
onValidate={() => {}}
userAttributes={autocompleteResult.map((attr) => ({
attribute: attr.name,
values: [],
}))}
userAttributes={autocompleteResult}
onParseError={() => {
setEditorMode('cel');
}}
actions={{
getVisualAST: actions.getVisualAST,
}}
/>
)}
</Card.Body>
@@ -555,6 +624,9 @@ function PolicyDetails({
<SaveButton
disabled={!saveNeeded}
onClick={() => {
if (!preSaveCheck()) {
return;
}
if (hasChannels()) {
setShowConfirmationModal(true);
} else {

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

@@ -720,7 +720,17 @@ const AdminDefinition: AdminDefinitionType = {
type: 'bool',
key: 'AccessControlSettings.EnableAttributeBasedAccessControl',
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.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.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.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",
@@ -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.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.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.placeholder": "Add a unique policy name",
"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.table_editor.add_attribute": "Add 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.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.operator": "Operator",
"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.selector.filter_attributes": "Search attributes...",
"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.test_access_rule": "Test access rule",
"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.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.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.title": "Attribute-Based Access",
"admin.advance.cluster": "High Availability",

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

@@ -150,3 +150,10 @@ export function searchUsersForExpression(expression: string, term: string, after
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 {PreferenceType} from '@mattermost/types/preferences';
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 {RemoteCluster, RemoteClusterAcceptInvite, RemoteClusterPatch, RemoteClusterWithPassword} from '@mattermost/types/remote_clusters';
import type {UserReport, UserReportFilter, UserReportOptions} from '@mattermost/types/reports';
@@ -4486,7 +4486,7 @@ export default class Client4 {
};
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}`,
{method: 'get'},
);