[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
Этот коммит содержится в:
коммит произвёл
GitHub
родитель
489ea1fdd6
Коммит
6f26ad5cec
@@ -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'},
|
||||
);
|
||||
|
||||
Ссылка в новой задаче
Block a user