From 6f26ad5cec8b5cb39f36b0b951b3380e0acdd106 Mon Sep 17 00:00:00 2001 From: Ibrahim Serdar Acikgoz Date: Sun, 1 Jun 2025 12:05:57 +0200 Subject: [PATCH] [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 --- server/channels/api4/access_control.go | 2 +- server/channels/app/access_control.go | 7 +- server/channels/app/access_control_test.go | 8 +- server/channels/app/channel.go | 2 +- server/channels/app/channels.go | 3 +- .../sqlstore/access_control_policy_store.go | 4 +- .../storetest/access_control_policy_store.go | 4 +- server/i18n/en.json | 4 + server/public/model/client4.go | 4 +- .../editors/cel_editor/editor.tsx | 4 +- .../access_control/editors/shared.scss | 19 + .../access_control/editors/shared.tsx | 42 ++ .../table_editor/attribute_selector_menu.tsx | 185 +++++++-- .../multi_value_selector_menu.tsx | 215 ++++++++++ .../table_editor/operator_selector_menu.tsx | 53 ++- .../editors/table_editor/selector_menus.scss | 89 +++- .../single_value_selector_menu.tsx | 211 ++++++++++ .../editors/table_editor/table_editor.scss | 150 +------ .../table_editor/table_editor.test.tsx | 145 +++++++ .../editors/table_editor/table_editor.tsx | 391 ++++++++++-------- .../editors/table_editor/table_row.tsx | 8 - .../table_editor/value_selector_menu.tsx | 63 +++ .../editors/table_editor/values_editor.scss | 78 ---- .../editors/table_editor/values_editor.tsx | 122 ------ .../policy_details.test.tsx.snap | 14 + .../access_control/policy_details/index.ts | 3 +- .../policy_details/policy_details.scss | 8 + .../policy_details/policy_details.test.tsx | 4 +- .../policy_details/policy_details.tsx | 108 ++++- .../admin_console/admin_definition.tsx | 12 +- webapp/channels/src/i18n/en.json | 22 +- .../src/actions/access_control.ts | 7 + webapp/platform/client/src/client4.ts | 4 +- 33 files changed, 1360 insertions(+), 635 deletions(-) create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/multi_value_selector_menu.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/single_value_selector_menu.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.test.tsx delete mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_row.tsx create mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/value_selector_menu.tsx delete mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.scss delete mode 100644 webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.tsx diff --git a/server/channels/api4/access_control.go b/server/channels/api4/access_control.go index e3697e11d7..6a7c391a0e 100644 --- a/server/channels/api4/access_control.go +++ b/server/channels/api4/access_control.go @@ -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 diff --git a/server/channels/app/access_control.go b/server/channels/app/access_control.go index 3b6f376e54..affdef1c35 100644 --- a/server/channels/app/access_control.go +++ b/server/channels/app/access_control.go @@ -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) diff --git a/server/channels/app/access_control_test.go b/server/channels/app/access_control_test.go index f3100849a3..ac9975abec 100644 --- a/server/channels/app/access_control_test.go +++ b/server/channels/app/access_control_test.go @@ -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) }) } diff --git a/server/channels/app/channel.go b/server/channels/app/channel.go index 11cc77802d..534f8f64b2 100644 --- a/server/channels/app/channel.go +++ b/server/channels/app/channel.go @@ -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 } } diff --git a/server/channels/app/channels.go b/server/channels/app/channels.go index 3a28c8d5c8..ae239fd49e 100644 --- a/server/channels/app/channels.go +++ b/server/channels/app/channels.go @@ -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)) } diff --git a/server/channels/store/sqlstore/access_control_policy_store.go b/server/channels/store/sqlstore/access_control_policy_store.go index 8602260d7d..6e2a10b35f 100644 --- a/server/channels/store/sqlstore/access_control_policy_store.go +++ b/server/channels/store/sqlstore/access_control_policy_store.go @@ -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 } diff --git a/server/channels/store/storetest/access_control_policy_store.go b/server/channels/store/storetest/access_control_policy_store.go index 609874cb28..0abb65c432 100644 --- a/server/channels/store/storetest/access_control_policy_store.go +++ b/server/channels/store/storetest/access_control_policy_store.go @@ -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) diff --git a/server/i18n/en.json b/server/i18n/en.json index 282fcc1a18..6a7fe0b787 100644 --- a/server/i18n/en.json +++ b/server/i18n/en.json @@ -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." diff --git a/server/public/model/client4.go b/server/public/model/client4.go index 9d8fbc2643..4c655a8a0f 100644 --- a/server/public/model/client4.go +++ b/server/public/model/client4.go @@ -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) { diff --git a/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx b/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx index 0d8b49ff66..472bbcb806 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/cel_editor/editor.tsx @@ -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); diff --git a/webapp/channels/src/components/admin_console/access_control/editors/shared.scss b/webapp/channels/src/components/admin_console/access_control/editors/shared.scss index 3ac48db74c..8ddbd00bee 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/shared.scss +++ b/webapp/channels/src/components/admin_console/access_control/editors/shared.scss @@ -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; + } + } +} diff --git a/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx b/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx index 9afcc12d58..fdecd68278 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/shared.tsx @@ -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 = { + [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 = { + [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; diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/attribute_selector_menu.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/attribute_selector_menu.tsx index 8c82188fe0..0fccd5f95c 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/attribute_selector_menu.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/attribute_selector_menu.tsx @@ -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 ; + } + if (valueType === 'url') { + return ; + } + if (valueType === 'phone') { + return ; + } + + // If no specific value_type, check the field type + switch (attribute.type) { + case 'select': + return ; + case 'multiselect': + return ; + case 'text': + default: + return ; + } + } + return ; +}; 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) => { + const onFilterChange = useCallback((e: React.ChangeEvent) => { 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) => ; + 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 ( - + {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', }} > - {[ - , - ]} + {options.map((option) => { - const {attribute} = option; - return ( + const {name} = option; + const hasSpaces = name.includes(' '); + const isSynced = option.attrs?.ldap || option.attrs?.saml; + + const menuItem = ( handleAttributeChange(attribute)} - labels={{attribute}} - leadingElement={} - trailingElements={attribute === currentAttribute && ( - + aria-checked={name === currentAttribute} + onClick={hasSpaces ? undefined : () => handleAttributeChange(name)} + labels={{name}} + disabled={hasSpaces} + leadingElement={ + + } + trailingElements={( + <> + {hasSpaces && ( + + )} + {isSynced && ( + + )} + {name === currentAttribute && + + } + )} /> ); + + // 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 ( + +
+ {menuItem} +
+
+ ); + } + + return menuItem; })}
); diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/multi_value_selector_menu.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/multi_value_selector_menu.tsx new file mode 100644 index 0000000000..4df4a77b21 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/multi_value_selector_menu.tsx @@ -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) => { + 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) => { + 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 | React.KeyboardEvent, 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 ( + + {actualTextDisplayed} + + ); + } + + return ( +
+ {values.map((value) => ( +
+
{value}
+ {!disabled && ( +
handleRemoveValue(e, value)} + role='button' + tabIndex={0} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + handleRemoveValue(e, value); + } + }} + > + +
+ )} +
+ ))} +
+ ); + }, [values, disabled]); + + return ( +
+ + {cellContents} + + + ), + dataTestId: 'valueSelectorMenuButton', + disabled, + }} + menu={{ + id: 'value-selector-menu', + 'aria-label': placeholder || defaultMultiPlaceholder, + className: 'select-value-mui-menu', + }} + > + + {filteredOptions.map((option) => { + const name = option.name || ''; + const id = option.id || name; + const isSelected = values.includes(name); + + return ( + handleSelectItem(name)} + labels={{name}} + trailingElements={isSelected && ( + + )} + /> + ); + })} + {actualAllowCreateForMenu && filter.trim() && !filteredOptions.some((opt) => opt.name === filter.trim()) && ( + handleCreateValue(filter)} + labels={ + {formatMessage({ + id: 'admin.access_control.table_editor.create_value', + defaultMessage: 'Create "{value}"', + }, {value: filter.trim()})} + } + /> + )} + +
+ ); +}; + +export default MultiValueSelector; diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.tsx index 5707730202..4d237d4285 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/operator_selector_menu.tsx @@ -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) => { + const onFilterChange = React.useCallback((e: React.ChangeEvent) => { 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; label: MessageDescriptor; }; const OPERATOR_DESCRIPTORS: IDMappedObjects = { - 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', diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/selector_menus.scss b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/selector_menus.scss index 5d65f0814d..333e82b1f5 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/selector_menus.scss +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/selector_menus.scss @@ -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; + } } diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/single_value_selector_menu.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/single_value_selector_menu.tsx new file mode 100644 index 0000000000..267ad309ef --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/single_value_selector_menu.tsx @@ -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) => { + if (e.key === 'Enter') { + e.preventDefault(); + commitInputValue(); + } + }, [commitInputValue]); + + // Filter logic for options + const onFilterChange = useCallback((e: React.ChangeEvent) => { + 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) => { + 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 ( +
+ 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} + /> +
+ ); + } + + // For attributes with options, show dropdown menu + const actualTextDisplayed = value || placeholder || defaultPlaceholder; + const useStyle = actualTextDisplayed === defaultPlaceholder; + + return ( +
+ + + {actualTextDisplayed} + + + + ), + dataTestId: 'valueSelectorMenuButton', + disabled, + }} + menu={{ + id: 'value-selector-menu', + 'aria-label': placeholder || defaultPlaceholder, + className: 'select-value-mui-menu', + }} + > + + {filteredOptions.map((option) => { + const name = option.name || ''; + const id = option.id || name; + const isSelected = value === name; + + return ( + handleSelectItem(name)} + labels={{name}} + trailingElements={isSelected && ( + + )} + /> + ); + })} + {allowCreateValue && filter.trim() && !filteredOptions.some((opt) => opt.name === filter.trim()) && ( + handleCreateValue(filter)} + labels={ + {formatMessage({ + id: 'admin.access_control.table_editor.create_value', + defaultMessage: 'Create "{value}"', + }, {value: filter.trim()})} + } + /> + )} + +
+ ); +}; + +export default SingleValueSelector; diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.scss b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.scss index 8d20c85e77..5d289ed4b9 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.scss +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.scss @@ -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; - } - } -} diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.test.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.test.tsx new file mode 100644 index 0000000000..48d6544f20 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.test.tsx @@ -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'], + }, + ]); + }); +}); diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx index 642f07c0c0..28134ad447 100644 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_editor.tsx @@ -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; + }; } -// Parse CEL expression into table rows -const parseExpression = async (expr: string): Promise => { +// 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 => { 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([]); const [showTestResults, setShowTestResults] = useState(false); const [showHelpModal, setShowHelpModal] = useState(false); + const [autoOpenAttributeMenuForRow, setAutoOpenAttributeMenuForRow] = useState(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 (
-
-
-
- -
-
- -
-
- -
-
-
- -
- {rows.length === 0 ? ( -
- - {formatMessage({ - id: 'admin.access_control.table_editor.blank_state', - defaultMessage: 'Select a user attribute and values to create a rule', - })} + + + + + + + + + + {rows.length === 0 ? ( + + + ) : ( rows.map((row, index) => ( -
-
+
+ + + )) )} - -
- -
- + + + + + + +
+ + + + + + - + +
+ + {formatMessage({ + id: 'admin.access_control.table_editor.blank_state', + defaultMessage: 'Select a user attribute and values to create a rule', + })} + +
updateRowAttribute(index, attribute)} + menuId={`attribute-selector-menu-${index}`} + buttonId={`attribute-selector-button-${index}`} + autoOpen={index === autoOpenAttributeMenuForRow} + onMenuOpened={() => setAutoOpenAttributeMenuForRow(null)} /> - -
+
updateRowOperator(index, operator)} /> - -
- +
+ updateRowValues(index, values)} + updateValues={(values: string[]) => updateRowValues(index, values)} + options={row.attribute ? userAttributes.find((attr) => attr.name === row.attribute)?.attrs?.options || [] : []} /> - -
+
- - +
+ +
setShowTestResults(true)} diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_row.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_row.tsx deleted file mode 100644 index e8e68c188e..0000000000 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/table_row.tsx +++ /dev/null @@ -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[]; -} diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/value_selector_menu.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/value_selector_menu.tsx new file mode 100644 index 0000000000..24387cec26 --- /dev/null +++ b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/value_selector_menu.tsx @@ -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 ( + + ); + } + + // For single-value operators + return ( + updateValues([value])} + options={options} + allowCreateValue={allowCreateValue} + placeholder={placeholder} + /> + ); +}; + +export default ValueSelectorMenu; diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.scss b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.scss deleted file mode 100644 index 6fbd6b9755..0000000000 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.scss +++ /dev/null @@ -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; - } - } -} diff --git a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.tsx b/webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.tsx deleted file mode 100644 index f3fbdf7fb4..0000000000 --- a/webapp/channels/src/components/admin_console/access_control/editors/table_editor/values_editor.tsx +++ /dev/null @@ -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) => { - 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 ( -
- 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} - /> -
- ); - } - - // 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 ( -
- { - 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} - /> -
- ); -} - -export default ValuesEditor; diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/__snapshots__/policy_details.test.tsx.snap b/webapp/channels/src/components/admin_console/access_control/policy_details/__snapshots__/policy_details.test.tsx.snap index c8f8f66af5..81eb85e52d 100644 --- a/webapp/channels/src/components/admin_console/access_control/policy_details/__snapshots__/policy_details.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/__snapshots__/policy_details.test.tsx.snap @@ -28,6 +28,7 @@ exports[`components/admin_console/access_control/policy_details/PolicyDetails sh className="admin-console__setting-group" > { diff --git a/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.tsx b/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.tsx index a0d4f11d59..838a2f2c78 100644 --- a/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.tsx +++ b/webapp/channels/src/components/admin_console/access_control/policy_details/policy_details.tsx @@ -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; createJob: (job: JobTypeBase & { data: any }) => Promise; updateAccessControlPolicyActive: (policyId: string, active: boolean) => Promise; + getVisualAST: (expression: string) => Promise; } export interface PolicyDetailsProps { @@ -78,10 +80,12 @@ function PolicyDetails({ }); const [saveNeeded, setSaveNeeded] = useState(false); const [channelsCount, setChannelsCount] = useState(0); - const [autocompleteResult, setAutocompleteResult] = useState([]); + const [autocompleteResult, setAutocompleteResult] = useState([]); + 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 => { // 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} />
- + {attributesLoaded && autocompleteResult.length === 0 && (
+ + } + 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'); + }, + }} + /> +
)} @@ -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, + }} /> )} @@ -555,6 +624,9 @@ function PolicyDetails({ { + if (!preSaveCheck()) { + return; + } if (hasChannels()) { setShowConfirmationModal(true); } else { diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index a122fe873b..ae3eb921cd 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -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: ( + + + + ), + }, }, ], }, diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 6b4f530c72..33c7cde438 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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", diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/access_control.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/access_control.ts index a0c26d17aa..be1c2f264f 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/access_control.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/access_control.ts @@ -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], + }); +} diff --git a/webapp/platform/client/src/client4.ts b/webapp/platform/client/src/client4.ts index 8a8c850d38..d4876a5d33 100644 --- a/webapp/platform/client/src/client4.ts +++ b/webapp/platform/client/src/client4.ts @@ -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( + return this.doFetch( `${this.getBaseRoute()}/access_control_policies/cel/autocomplete/fields?after=${after}&limit=${limit}`, {method: 'get'}, );