[MM-70891] Show and protect integration-owned attributes in Attribute Management (#38850)

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
David Krauser
2026-10-02 13:25:49 -04:00
committed by GitHub
co-authored by Cursor Mattermost Build
parent 922a900b8a
commit 5f01389e99
26 changed files with 1760 additions and 160 deletions
@@ -820,7 +820,7 @@ func isMachineCaller(pluginChecker PluginChecker, callerID string) bool {
// service is a singleton (one LDAP, one SAML), so its owner type is "service"
// and it carries no scope; for a plugin the manifest ID is the owner ID and the
// scope is whatever the plugin declared on the request context.
func (h *AccessControlHook) callerOwnerIdentity(callerID, scope string) (ownerID, ownerType, effectiveScope string) {
func callerOwnerIdentity(callerID, scope string) (ownerID, ownerType, effectiveScope string) {
switch callerID {
case model.CallerIDLDAPSync:
return model.PropertyFieldAttrLDAP, model.PropertyOwnerTypeService, ""
@@ -873,7 +873,7 @@ func (h *AccessControlHook) checkOwnerValueWriteAccess(field *model.PropertyFiel
return fmt.Errorf("field %s is owner-managed and cannot be modified by human caller %q: %w", field.ID, callerID, ErrAccessDenied)
}
ownerID, ownerType, effectiveScope := h.callerOwnerIdentity(callerID, scope)
ownerID, ownerType, effectiveScope := callerOwnerIdentity(callerID, scope)
for _, owner := range h.effectiveOwners(field) {
if owner.Type == ownerType && owner.ID == ownerID &&
(len(owner.Scopes) == 0 || slices.Contains(owner.Scopes, effectiveScope)) {
@@ -887,8 +887,8 @@ func (h *AccessControlHook) checkOwnerValueWriteAccess(field *model.PropertyFiel
// isListedOwner reports whether the machine caller matches an explicit owner
// entry on the field (by id and type). Scope is not consulted: being a listed
// owner is what authorizes managing the field; scope only gates value writes.
func (h *AccessControlHook) isListedOwner(field *model.PropertyField, callerID string) bool {
ownerID, ownerType, _ := h.callerOwnerIdentity(callerID, "")
func isListedOwner(field *model.PropertyField, callerID string) bool {
ownerID, ownerType, _ := callerOwnerIdentity(callerID, "")
for _, owner := range model.GetPropertyFieldOwners(field) {
if owner.Type == ownerType && owner.ID == ownerID {
return true
@@ -908,7 +908,7 @@ func (h *AccessControlHook) isListedOwner(field *model.PropertyField, callerID s
// protected / source_plugin_id rules continue to apply.
func (h *AccessControlHook) enforceFieldUpdateAccess(existing, updated *model.PropertyField, callerID string) error {
if model.HasPropertyFieldOwners(existing) {
if isMachineCaller(h.pluginChecker, callerID) && !h.isListedOwner(existing, callerID) {
if isMachineCaller(h.pluginChecker, callerID) && !isListedOwner(existing, callerID) {
return fmt.Errorf("field %s is owner-managed and can only be modified by an administrator or a listed owner: %w", existing.ID, ErrAccessDenied)
}
return nil
@@ -1014,7 +1014,7 @@ func (h *AccessControlHook) checkLegacyFieldWriteAccess(field *model.PropertyFie
// IMPORTANT: Always pass the existing field fetched from the database, not a field provided by the caller.
func (h *AccessControlHook) checkFieldDeleteAccess(field *model.PropertyField, callerID string) error {
if model.HasPropertyFieldOwners(field) {
if isMachineCaller(h.pluginChecker, callerID) && !h.isListedOwner(field, callerID) {
if isMachineCaller(h.pluginChecker, callerID) && !isListedOwner(field, callerID) {
return fmt.Errorf("field %s is owner-managed and can only be deleted by an administrator or a listed owner: %w", field.ID, ErrAccessDenied)
}
return nil
@@ -360,10 +360,9 @@ func (h *AccessControlAttributeValidationHook) sanitizeAndValidateOptions(field
// sanitizeAndValidateOwners normalizes and validates the owners attr on a
// field. Each entry is trimmed and must be well-formed ({id, type, scopes}
// with a recognized type); scopes are trimmed and deduped; duplicate owner
// entries (same type+id) are merged. Owners may not be combined with
// managed="admin". An empty or absent list is removed so HasPropertyFieldOwners
// stays false. The normalized list is written back in canonical form
// ([]any of maps) so downstream readers see a single shape.
// entries (same type+id) are merged. An empty or absent list is removed so
// HasPropertyFieldOwners stays false. The normalized list is written back in
// canonical form ([]any of maps) so downstream readers see a single shape.
//
// Defensive bounds (see the Property Owner* constants) cap the id and scope
// lengths and the number of owners/scopes so a buggy or hostile owner cannot
@@ -393,10 +392,6 @@ func (h *AccessControlAttributeValidationHook) sanitizeAndValidateOwners(field *
return nil
}
if managed, _ := field.Attrs[model.PropertyFieldAttrManaged].(string); managed == "admin" {
return fmt.Errorf("owners cannot be combined with managed=admin: %w", ErrInvalidFieldAttrs)
}
normalized := make([]model.PropertyOwner, 0, len(owners))
indexByKey := make(map[string]int, len(owners))
for _, owner := range owners {
@@ -524,7 +519,9 @@ func rankSortKey(rank *int) int {
// - When managed="admin", PermissionValues is set to sysadmin. This is
// gated on PermissionManageSystem; callers without an identifiable
// caller ID (e.g. internal callers with no session on rctx) are
// treated as non-admin and rejected.
// treated as non-admin and rejected. A plugin listed as an owner of a
// field that is already admin-managed is exempt, so it can edit the
// field or remove itself as owner without being a system admin.
// - When the field is owner-managed, PermissionValues is pinned to sysadmin.
// Human value writes are already blocked authoritatively by
// checkOwnerValueWriteAccess in the property-service hook, but pinning
@@ -534,19 +531,31 @@ func rankSortKey(rank *int) int {
// - Otherwise, PermissionValues is left as-is when set, and default-filled
// by ObjectType when nil (member for user fields, sysadmin for system
// and template). Caller pins are never downgraded.
func (h *AccessControlAttributeValidationHook) enforceGroupPermissions(rctx request.CTX, field *model.PropertyField) (*model.PropertyField, error) {
func (h *AccessControlAttributeValidationHook) enforceGroupPermissions(rctx request.CTX, field, existing *model.PropertyField) (*model.PropertyField, error) {
sysadmin := model.PermissionLevelSysadmin
if managed, _ := field.Attrs[model.PropertyFieldAttrManaged].(string); managed == "admin" {
// Verify the caller has admin privileges. Default-deny if the
// permission checker isn't wired up or if the caller is
// unidentifiable — we never silently promote to sysadmin.
if h.permissionChecker == nil {
return nil, fmt.Errorf("missing permission to set managed=admin: no permission checker configured: %w", ErrAdminRequired)
}
callerID := h.propertyService.extractCallerID(rctx)
if callerID == "" || !h.permissionChecker(rctx, callerID, model.PermissionManageSystem) {
return nil, fmt.Errorf("missing permission to set managed=admin: only system admins can set managed=admin: %w", ErrAdminRequired)
exempt := false
if existing != nil {
if exManaged, _ := existing.Attrs[model.PropertyFieldAttrManaged].(string); exManaged == "admin" {
if isMachineCaller(h.pluginChecker, callerID) && isListedOwner(existing, callerID) {
exempt = true
}
}
}
if !exempt {
// Verify the caller has admin privileges. Default-deny if the
// permission checker isn't wired up or if the caller is
// unidentifiable — we never silently promote to sysadmin.
if h.permissionChecker == nil {
return nil, fmt.Errorf("missing permission to set managed=admin: no permission checker configured: %w", ErrAdminRequired)
}
if callerID == "" || !h.permissionChecker(rctx, callerID, model.PermissionManageSystem) {
return nil, fmt.Errorf("missing permission to set managed=admin: only system admins can set managed=admin: %w", ErrAdminRequired)
}
}
field.PermissionValues = &sysadmin
} else if model.HasPropertyFieldOwners(field) {
@@ -605,7 +614,7 @@ func (h *AccessControlAttributeValidationHook) PreCreatePropertyField(rctx reque
return nil, err
}
return h.enforceGroupPermissions(rctx, field)
return h.enforceGroupPermissions(rctx, field, nil)
}
func (h *AccessControlAttributeValidationHook) PreUpdatePropertyField(rctx request.CTX, groupID string, field *model.PropertyField) (*model.PropertyField, error) {
@@ -630,7 +639,7 @@ func (h *AccessControlAttributeValidationHook) PreUpdatePropertyField(rctx reque
return nil, err
}
return h.enforceGroupPermissions(rctx, field)
return h.enforceGroupPermissions(rctx, field, existing)
}
func (h *AccessControlAttributeValidationHook) PreUpdatePropertyFields(rctx request.CTX, groupID string, fields []*model.PropertyField) ([]*model.PropertyField, error) {
@@ -677,7 +686,7 @@ func (h *AccessControlAttributeValidationHook) PreUpdatePropertyFields(rctx requ
return nil, fmt.Errorf("field %s: %w", field.ID, err)
}
updated, err := h.enforceGroupPermissions(rctx, field)
updated, err := h.enforceGroupPermissions(rctx, field, existing)
if err != nil {
return nil, fmt.Errorf("field %s: %w", field.ID, err)
}
@@ -2269,7 +2269,7 @@ func TestAccessControlAttributeValidationHook_Owners(t *testing.T) {
assert.Contains(t, createErr.Error(), "owner id")
})
t.Run("rejects owners combined with managed=admin", func(t *testing.T) {
t.Run("allows owners combined with managed=admin for an admin caller", func(t *testing.T) {
field := &model.PropertyField{
GroupID: group.ID,
Name: "field_" + model.NewId(),
@@ -2283,9 +2283,15 @@ func TestAccessControlAttributeValidationHook_Owners(t *testing.T) {
},
},
}
_, createErr := th.service.CreatePropertyField(th.Context, field)
require.Error(t, createErr)
assert.Contains(t, createErr.Error(), "managed=admin")
created, createErr := th.service.CreatePropertyField(RequestContextWithCallerID(th.Context, "admin-user"), field)
require.NoError(t, createErr)
assert.Equal(t, "admin", created.Attrs[model.PropertyFieldAttrManaged])
owners := model.GetPropertyFieldOwners(created)
require.Len(t, owners, 1)
assert.Equal(t, "com.mattermost.scim", owners[0].ID)
assert.Equal(t, []string{"entra"}, owners[0].Scopes)
require.NotNil(t, created.PermissionValues)
assert.Equal(t, model.PermissionLevelSysadmin, *created.PermissionValues)
})
t.Run("allows owners combined with saml sync attr", func(t *testing.T) {
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/public/shared/request"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -532,3 +533,165 @@ func TestOwnerSyncBidirectionalTransitions(t *testing.T) {
assertCombinedWrites(t, created.ID)
})
}
func TestOwnersWithAdminManaged(t *testing.T) {
th := Setup(t)
group, err := th.service.RegisterPropertyGroup(&model.PropertyGroup{
Name: model.AccessControlPropertyGroupName,
Version: model.PropertyGroupVersionV2,
})
require.NoError(t, err)
th.CPAGroupID = group.ID
adminID := model.NewId()
pluginChecker := func(pluginID string) bool {
return pluginID == "plugin-owner" || pluginID == "plugin-other"
}
// Register hooks in production order.
acHook := NewAccessControlHook(th.service, nil, th.CPAGroupID)
acHook.setPluginCheckerForTests(pluginChecker)
th.service.AddHook(acHook)
th.service.AddHook(NewAccessControlAttributeValidationHook(th.service, AccessControlAttributeValidationHookConfig{
PermissionChecker: func(_ request.CTX, userID string, permission *model.Permission) bool {
return userID == adminID && permission.Id == model.PermissionManageSystem.Id
},
PluginChecker: pluginChecker,
}, th.CPAGroupID))
rctxAdmin := RequestContextWithCallerID(th.Context, adminID)
createOwnedAdminManaged := func(t *testing.T, name string) *model.PropertyField {
t.Helper()
field := ownerField(th.CPAGroupID, name, "plugin-owner", []string{"entra"})
field.Attrs[model.PropertyFieldAttrManaged] = "admin"
created, err := th.service.CreatePropertyField(rctxAdmin, field)
require.NoError(t, err)
return created
}
newValue := func(fieldID string) *model.PropertyValue {
return &model.PropertyValue{
GroupID: th.CPAGroupID,
FieldID: fieldID,
TargetType: "user",
TargetID: model.NewId(),
Value: json.RawMessage(`"v"`),
}
}
t.Run("admin adds owners to an admin-managed field", func(t *testing.T) {
created, err := th.service.CreatePropertyField(rctxAdmin, &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "admin_managed_add_owners",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyFieldAttrManaged: "admin",
},
})
require.NoError(t, err)
created.Attrs[model.PropertyAttrsOwners] = []model.PropertyOwner{
{ID: "plugin-owner", Type: model.PropertyOwnerTypePlugin, Scopes: []string{"entra"}},
}
updated, _, upErr := th.service.UpdatePropertyField(rctxAdmin, th.CPAGroupID, created)
require.NoError(t, upErr)
require.True(t, model.HasPropertyFieldOwners(updated))
assert.Equal(t, "admin", updated.Attrs[model.PropertyFieldAttrManaged])
require.NotNil(t, updated.PermissionValues)
assert.Equal(t, model.PermissionLevelSysadmin, *updated.PermissionValues)
})
t.Run("denies a system admin writing a value", func(t *testing.T) {
created := createOwnedAdminManaged(t, "owned_admin_value_admin")
_, upErr := th.service.UpsertPropertyValue(rctxAdmin, newValue(created.ID))
require.Error(t, upErr)
assert.ErrorIs(t, upErr, ErrAccessDenied)
})
t.Run("allows the owner plugin writing a value with a matching scope", func(t *testing.T) {
created := createOwnedAdminManaged(t, "owned_admin_value_owner")
rctx := RequestContextWithCallerIDAndOptions(th.Context, "plugin-owner", model.PropertyRequestOptions{ActingAsScope: "entra"})
_, upErr := th.service.UpsertPropertyValue(rctx, newValue(created.ID))
require.NoError(t, upErr)
})
t.Run("admin removes owners from an admin-managed field", func(t *testing.T) {
created := createOwnedAdminManaged(t, "owned_admin_remove_owners")
delete(created.Attrs, model.PropertyAttrsOwners)
updated, _, upErr := th.service.UpdatePropertyField(rctxAdmin, th.CPAGroupID, created)
require.NoError(t, upErr)
assert.False(t, model.HasPropertyFieldOwners(updated))
assert.Equal(t, "admin", updated.Attrs[model.PropertyFieldAttrManaged])
require.NotNil(t, updated.PermissionValues)
assert.Equal(t, model.PermissionLevelSysadmin, *updated.PermissionValues)
})
t.Run("listed owner plugin edits an admin-managed field", func(t *testing.T) {
created := createOwnedAdminManaged(t, "owned_admin_owner_edits")
created.Attrs[model.CustomProfileAttributesPropertyAttrsVisibility] = model.PropertyFieldVisibilityAlways
rctx := RequestContextWithCallerID(th.Context, "plugin-owner")
updated, _, upErr := th.service.UpdatePropertyField(rctx, th.CPAGroupID, created)
require.NoError(t, upErr)
assert.Equal(t, model.PropertyFieldVisibilityAlways, updated.Attrs[model.CustomProfileAttributesPropertyAttrsVisibility])
assert.Equal(t, "admin", updated.Attrs[model.PropertyFieldAttrManaged])
})
t.Run("listed owner plugin removes itself as the last owner", func(t *testing.T) {
created := createOwnedAdminManaged(t, "owned_admin_owner_leaves")
delete(created.Attrs, model.PropertyAttrsOwners)
rctx := RequestContextWithCallerID(th.Context, "plugin-owner")
_, _, upErr := th.service.UpdatePropertyField(rctx, th.CPAGroupID, created)
require.NoError(t, upErr)
stored, getErr := th.service.GetPropertyField(rctxAdmin, th.CPAGroupID, created.ID)
require.NoError(t, getErr)
assert.False(t, model.HasPropertyFieldOwners(stored))
assert.Equal(t, "admin", stored.Attrs[model.PropertyFieldAttrManaged])
require.NotNil(t, stored.PermissionValues)
assert.Equal(t, model.PermissionLevelSysadmin, *stored.PermissionValues)
})
t.Run("non-owner plugin may not update an admin-managed field", func(t *testing.T) {
created := createOwnedAdminManaged(t, "owned_admin_non_owner")
created.Attrs[model.CustomProfileAttributesPropertyAttrsVisibility] = model.PropertyFieldVisibilityHidden
_, _, upErr := th.service.UpdatePropertyField(RequestContextWithCallerID(th.Context, "plugin-other"), th.CPAGroupID, created)
require.Error(t, upErr)
})
t.Run("listed owner plugin may not turn on admin-managed", func(t *testing.T) {
created, err := th.service.CreatePropertyField(rctxAdmin, ownerField(th.CPAGroupID, "owned_not_admin_managed", "plugin-owner", []string{"entra"}))
require.NoError(t, err)
created.Attrs[model.PropertyFieldAttrManaged] = "admin"
_, _, upErr := th.service.UpdatePropertyField(RequestContextWithCallerID(th.Context, "plugin-owner"), th.CPAGroupID, created)
require.Error(t, upErr)
assert.ErrorIs(t, upErr, ErrAdminRequired)
})
t.Run("plugin may not create an admin-managed field", func(t *testing.T) {
field := &model.PropertyField{
GroupID: th.CPAGroupID,
Name: "plugin_created_admin_managed",
Type: model.PropertyFieldTypeText,
ObjectType: model.PropertyFieldObjectTypeUser,
TargetType: string(model.PropertyFieldTargetLevelSystem),
Attrs: model.StringInterface{
model.PropertyFieldAttrManaged: "admin",
},
}
_, err := th.service.CreatePropertyField(RequestContextWithCallerID(th.Context, "plugin-owner"), field)
require.Error(t, err)
assert.ErrorIs(t, err, ErrAdminRequired)
})
t.Run("non-admin human may not update an admin-managed field", func(t *testing.T) {
created := createOwnedAdminManaged(t, "owned_admin_human_edit")
created.Attrs[model.CustomProfileAttributesPropertyAttrsVisibility] = model.PropertyFieldVisibilityHidden
_, _, upErr := th.service.UpdatePropertyField(RequestContextWithCallerID(th.Context, model.NewId()), th.CPAGroupID, created)
require.Error(t, upErr)
assert.ErrorIs(t, upErr, ErrAdminRequired)
})
}
@@ -130,6 +130,15 @@ describe('AttributeAppliesTo', () => {
});
});
describe('userWhoCanSetLockedTooltip on the Users row', () => {
it('locks Who can set the value once the Users row is expanded', async () => {
renderComponent({appliesTo: ['user'], userWhoCanSetLockedTooltip: 'Managed by SCIM'});
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-lockWrap')).toBeInTheDocument();
});
});
describe('blockedTypes', () => {
it('offers a blocked type as a disabled option carrying its reason, and adds nothing when it is clicked', async () => {
renderComponent({blockedTypes: {user: 'Name already in use'}});
@@ -183,4 +192,16 @@ describe('AttributeAppliesTo', () => {
expect(screen.queryByTestId('attributeAppliesToRow-channel-toggleLockWrap')).not.toBeInTheDocument();
});
});
describe('removeLockedTooltips', () => {
it('locks Remove only on the row whose type has a tooltip', async () => {
renderComponent({appliesTo: ['user', 'channel'], removeLockedTooltips: {user: 'Locked'}});
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
expect(screen.getByTestId('attributeAppliesToRow-user-removeLockWrap')).toBeInTheDocument();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-channel-toggle'));
expect(screen.getByTestId('attributeAppliesToRow-channel-remove')).toBeEnabled();
});
});
});
@@ -47,6 +47,10 @@ type Props = {
// the transient `saving` state -- threaded straight through to each row's
// AttributeAppliesToItemProps.lockedTooltip. Undefined renders no tooltip.
lockedTooltip?: ReactNode;
// Locks Remove on the rows of the given types and explains why; the rest of
// each row follows `disabled`.
removeLockedTooltips?: Partial<Record<ResourceObjectType, ReactNode>>;
onAdd: (type: ResourceObjectType) => void;
onRemove: (type: ResourceObjectType) => void;
@@ -57,6 +61,7 @@ type Props = {
onUserVisibilityChange?: (visibility: FieldVisibility) => void;
userManaged?: UserManagedValue;
onUserManagedChange?: (managed: UserManagedValue) => void;
userWhoCanSetLockedTooltip?: ReactNode;
// Set when the attribute's values are synced from AD/LDAP or SAML, which
// the Users row surfaces as a read-only "Managed by" indicator.
@@ -92,13 +97,14 @@ const RESOURCE_TYPE_ITEM_COMPONENTS: Record<Exclude<ResourceObjectType, 'channel
// PostItem -- rather than one generic item parameterized by resourceType).
// Holds no selection state of its own -- "available" picker options are
// derived purely from props on every render. Makes no data-mutating dispatch
// calls, no Client4/API calls (see R6 -- the page owns all of that).
// calls, no Client4/API calls (the page owns all of that).
function AttributeAppliesTo({
appliesTo,
disabled = false,
hideAddResource = false,
blockedTypes,
lockedTooltip,
removeLockedTooltips,
onAdd,
onRemove,
userVisibility,
@@ -106,6 +112,7 @@ function AttributeAppliesTo({
userManaged,
onUserManagedChange,
externalSource,
userWhoCanSetLockedTooltip,
channelResource,
onChannelResourceChange,
ordered,
@@ -215,6 +222,7 @@ function AttributeAppliesTo({
ordered={ordered}
disabled={disabled}
lockedTooltip={lockedTooltip}
removeLockedTooltip={removeLockedTooltips?.[type]}
onRemove={() => onRemove(type)}
/>
);
@@ -227,12 +235,14 @@ function AttributeAppliesTo({
managed: userManaged,
onManagedChange: onUserManagedChange,
externalSource,
whoCanSetLockedTooltip: userWhoCanSetLockedTooltip,
} : {};
return (
<Item
key={type}
disabled={disabled}
lockedTooltip={lockedTooltip}
removeLockedTooltip={removeLockedTooltips?.[type]}
onRemove={() => onRemove(type)}
{...userProps}
/>
@@ -71,6 +71,28 @@ describe('AttributeAppliesToChannelItem', () => {
expect(screen.getByTestId('attributeAppliesToRow-channel-remove')).toBeDisabled();
});
it('removeLockedTooltip locks only Remove behind a tooltip wrap', async () => {
renderComponent({removeLockedTooltip: 'Locked'});
await userEvent.click(screen.getByTestId('attributeAppliesToRow-channel-toggle'));
expect(screen.getByTestId('attributeAppliesToRow-channel-removeLockWrap')).toBeInTheDocument();
expect(screen.getByTestId('attributeAppliesToRow-channel-remove')).toBeDisabled();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-channel-remove'));
expect(onRemove).not.toHaveBeenCalled();
expect(screen.getByTestId('attributeAppliesToRow-channel-toggle')).toBeEnabled();
await userEvent.click(screen.getByTestId('channelsResourceLocation-display_label_header'));
expect(onConfigChange).toHaveBeenCalled();
});
it('renders no Remove lock wrap and enables Remove without removeLockedTooltip', async () => {
renderComponent();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-channel-toggle'));
expect(screen.queryByTestId('attributeAppliesToRow-channel-removeLockWrap')).not.toBeInTheDocument();
expect(screen.getByTestId('attributeAppliesToRow-channel-remove')).toBeEnabled();
});
it('states the configuration in the row summary', async () => {
renderComponent({config: {required: true, changePolicy: 'never', displayLocations: ['display_label_header']}});
@@ -20,13 +20,12 @@ import './attribute_applies_to_item.scss';
const BODY_ID = 'attribute-applies-to-channel-panel';
// The Channels row of the Applies-to list -- owns its own expand/collapse
// state (deliberately not the shared Accordion component, see the plan's
// Decisions table: AccordionCard renders the row itself from plain data with
// no slot for a child component to own it, and its open-row tracking is by
// array index, which misattributes state when a row is removed from the
// middle of the list). Remove is only reachable once expanded -- there is no
// collapsed-row remove affordance.
function AttributeAppliesToChannelItem({config, onConfigChange, ordered, disabled = false, lockedTooltip, onRemove}: AttributeAppliesToChannelItemProps): JSX.Element {
// state (deliberately not the shared Accordion component: AccordionCard
// renders the row itself from plain data with no slot for a child component to
// own it, and its open-row tracking is by array index, which misattributes
// state when a row is removed from the middle of the list). Remove is only
// reachable once expanded -- there is no collapsed-row remove affordance.
function AttributeAppliesToChannelItem({config, onConfigChange, ordered, disabled = false, lockedTooltip, removeLockedTooltip, onRemove}: AttributeAppliesToChannelItemProps): JSX.Element {
const intl = useIntl();
const {formatMessage} = intl;
const [isOpen, setIsOpen] = useState(false);
@@ -68,6 +67,21 @@ function AttributeAppliesToChannelItem({config, onConfigChange, ordered, disable
</button>
);
const removeButton = (
<Button
type='button'
emphasis='tertiary'
variant='destructive'
size='sm'
className='AttributeAppliesToItem__remove'
onClick={onRemove}
disabled={disabled || Boolean(removeLockedTooltip)}
data-testid='attributeAppliesToRow-channel-remove'
>
<FormattedMessage {...messages.removeLabel}/>
</Button>
);
return (
<div
className={classNames('AttributeAppliesToItem', {'AttributeAppliesToItem--open': isOpen})}
@@ -85,20 +99,17 @@ function AttributeAppliesToChannelItem({config, onConfigChange, ordered, disable
</span>
</WithTooltip>
) : toggleButton}
{isOpen && (
<Button
type='button'
emphasis='tertiary'
variant='destructive'
size='sm'
className='AttributeAppliesToItem__remove'
onClick={onRemove}
disabled={disabled}
data-testid='attributeAppliesToRow-channel-remove'
>
<FormattedMessage {...messages.removeLabel}/>
</Button>
)}
{isOpen && (removeLockedTooltip ? (
<WithTooltip title={removeLockedTooltip}>
<span
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- WithTooltip's useFocus only fires on its cloned child; without this the disabled Remove is unreachable by keyboard, so the tooltip explaining the lock is mouse-only
tabIndex={0}
data-testid='attributeAppliesToRow-channel-removeLockWrap'
>
{removeButton}
</span>
</WithTooltip>
) : removeButton)}
</div>
{isOpen && (
<div
@@ -40,6 +40,9 @@ export type AttributeAppliesToItemProps = {
// lock tooltip convention on the parent page. Undefined (the `saving`
// case) renders no tooltip, matching today's existing behavior.
lockedTooltip?: ReactNode;
// Locks only Remove and explains why; the rest of the row follows `disabled`.
removeLockedTooltip?: ReactNode;
onRemove: () => void;
// Users-only config. Optional so this shared prop type still fits
@@ -54,6 +57,10 @@ export type AttributeAppliesToItemProps = {
// The external system this attribute's values are synced from, when there
// is one. Undefined for attributes managed in Mattermost or by a plugin.
externalSource?: ExternalSource;
// Locks only "Who can set the value" at its stored value and explains why;
// Profile display keeps following `disabled`; Remove has its own lock.
whoCanSetLockedTooltip?: ReactNode;
};
// Channels is the one resource with settings of its own, so its row takes the
@@ -180,6 +180,62 @@ describe('AttributeAppliesToUserItem', () => {
expect(screen.queryByTestId('attributeAppliesToRow-user-toggleLockWrap')).not.toBeInTheDocument();
});
it('whoCanSetLockedTooltip locks only Who can set the value, at its stored value, behind a tooltip wrap', async () => {
const onManagedChange = jest.fn();
const onVisibilityChange = jest.fn();
renderComponent({whoCanSetLockedTooltip: 'Managed by SCIM', managed: 'admin', onManagedChange, onVisibilityChange});
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-lockWrap')).toBeInTheDocument();
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-member')).toBeDisabled();
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-admin')).toBeDisabled();
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-admin')).toBeChecked();
await userEvent.click(screen.getByTestId('attributeAppliesToUserWhoCanSet-member'));
expect(onManagedChange).not.toHaveBeenCalled();
expect(screen.getByTestId('attributeAppliesToUserProfileDisplay-always')).toBeEnabled();
await userEvent.click(screen.getByTestId('attributeAppliesToUserProfileDisplay-always'));
expect(onVisibilityChange).toHaveBeenCalledWith('always');
expect(screen.getByTestId('attributeAppliesToRow-user-remove')).toBeEnabled();
});
it('removeLockedTooltip locks only Remove behind a tooltip wrap', async () => {
const onVisibilityChange = jest.fn();
renderComponent({removeLockedTooltip: 'Managed by SCIM', onVisibilityChange});
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
expect(screen.getByTestId('attributeAppliesToRow-user-removeLockWrap')).toBeInTheDocument();
expect(screen.getByTestId('attributeAppliesToRow-user-remove')).toBeDisabled();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-remove'));
expect(onRemove).not.toHaveBeenCalled();
expect(screen.getByTestId('attributeAppliesToRow-user-toggle')).toBeEnabled();
expect(screen.queryByTestId('attributeAppliesToRow-user-toggleLockWrap')).not.toBeInTheDocument();
expect(screen.getByTestId('attributeAppliesToUserProfileDisplay-always')).toBeEnabled();
await userEvent.click(screen.getByTestId('attributeAppliesToUserProfileDisplay-always'));
expect(onVisibilityChange).toHaveBeenCalledWith('always');
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-member')).toBeEnabled();
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-admin')).toBeEnabled();
});
it('renders no Remove lock wrap and enables Remove without removeLockedTooltip', async () => {
renderComponent();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
expect(screen.queryByTestId('attributeAppliesToRow-user-removeLockWrap')).not.toBeInTheDocument();
expect(screen.getByTestId('attributeAppliesToRow-user-remove')).toBeEnabled();
});
it('renders no Who can set the value lock wrap and enables the radios without whoCanSetLockedTooltip', async () => {
renderComponent();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
expect(screen.queryByTestId('attributeAppliesToUserWhoCanSet-lockWrap')).not.toBeInTheDocument();
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-member')).toBeEnabled();
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-admin')).toBeEnabled();
});
it('makes no Client4 calls and no data-mutating dispatch', async () => {
const createPropertyField = jest.spyOn(Client4, 'createPropertyField');
const deletePropertyField = jest.spyOn(Client4, 'deletePropertyField');
@@ -41,12 +41,14 @@ const MANAGED_BY_OPTIONS: Record<ExternalSource, AttributeSelectOption<ExternalS
function AttributeAppliesToUserItem({
disabled = false,
lockedTooltip,
removeLockedTooltip,
onRemove,
visibility = 'when_set',
onVisibilityChange,
managed = '',
onManagedChange,
externalSource,
whoCanSetLockedTooltip,
}: AttributeAppliesToItemProps): JSX.Element {
const {formatMessage} = useIntl();
const [isOpen, setIsOpen] = useState(false);
@@ -76,6 +78,49 @@ function AttributeAppliesToUserItem({
</button>
);
const removeButton = (
<Button
type='button'
emphasis='tertiary'
variant='destructive'
size='sm'
className='AttributeAppliesToItem__remove'
onClick={onRemove}
disabled={disabled || Boolean(removeLockedTooltip)}
data-testid='attributeAppliesToRow-user-remove'
>
<FormattedMessage {...messages.removeLabel}/>
</Button>
);
const whoCanSetDisabled = disabled || Boolean(whoCanSetLockedTooltip);
const whoCanSetRadioList = (
<div className='AttributeAppliesToItem__radioList'>
<label className='AttributeAppliesToItem__radioOption'>
<input
type='radio'
name='attribute-applies-to-user-who-can-set'
checked={managed === ''}
disabled={whoCanSetDisabled}
onChange={() => onManagedChange?.('')}
data-testid='attributeAppliesToUserWhoCanSet-member'
/>
<FormattedMessage {...messages.whoCanSetMemberLabel}/>
</label>
<label className='AttributeAppliesToItem__radioOption'>
<input
type='radio'
name='attribute-applies-to-user-who-can-set'
checked={managed === 'admin'}
disabled={whoCanSetDisabled}
onChange={() => onManagedChange?.('admin')}
data-testid='attributeAppliesToUserWhoCanSet-admin'
/>
<FormattedMessage {...messages.whoCanSetAdminLabel}/>
</label>
</div>
);
return (
<div
className={classNames('AttributeAppliesToItem', {'AttributeAppliesToItem--open': isOpen})}
@@ -93,20 +138,17 @@ function AttributeAppliesToUserItem({
</span>
</WithTooltip>
) : toggleButton}
{isOpen && (
<Button
type='button'
emphasis='tertiary'
variant='destructive'
size='sm'
className='AttributeAppliesToItem__remove'
onClick={onRemove}
disabled={disabled}
data-testid='attributeAppliesToRow-user-remove'
>
<FormattedMessage {...messages.removeLabel}/>
</Button>
)}
{isOpen && (removeLockedTooltip ? (
<WithTooltip title={removeLockedTooltip}>
<span
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- WithTooltip's useFocus only fires on its cloned child; without this the disabled Remove is unreachable by keyboard, so the tooltip explaining the lock is mouse-only
tabIndex={0}
data-testid='attributeAppliesToRow-user-removeLockWrap'
>
{removeButton}
</span>
</WithTooltip>
) : removeButton)}
</div>
{isOpen && (
<div
@@ -166,30 +208,17 @@ function AttributeAppliesToUserItem({
<FormattedMessage {...messages.whoCanSetLabel}/>
</span>
<div className='AttributeAppliesToItem__whoCanSet'>
<div className='AttributeAppliesToItem__radioList'>
<label className='AttributeAppliesToItem__radioOption'>
<input
type='radio'
name='attribute-applies-to-user-who-can-set'
checked={managed === ''}
disabled={disabled}
onChange={() => onManagedChange?.('')}
data-testid='attributeAppliesToUserWhoCanSet-member'
/>
<FormattedMessage {...messages.whoCanSetMemberLabel}/>
</label>
<label className='AttributeAppliesToItem__radioOption'>
<input
type='radio'
name='attribute-applies-to-user-who-can-set'
checked={managed === 'admin'}
disabled={disabled}
onChange={() => onManagedChange?.('admin')}
data-testid='attributeAppliesToUserWhoCanSet-admin'
/>
<FormattedMessage {...messages.whoCanSetAdminLabel}/>
</label>
</div>
{whoCanSetLockedTooltip ? (
<WithTooltip title={whoCanSetLockedTooltip}>
<span
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex -- WithTooltip's useFocus only fires on its cloned child; without this the disabled radios are unreachable by keyboard, so the tooltip explaining the lock is mouse-only
tabIndex={0}
data-testid='attributeAppliesToUserWhoCanSet-lockWrap'
>
{whoCanSetRadioList}
</span>
</WithTooltip>
) : whoCanSetRadioList}
<div className='AttributeAppliesToItem__helpText'>
<FormattedMessage {...messages.whoCanSetHelp}/>
</div>
@@ -2255,10 +2255,33 @@ describe('AttributeDetails', () => {
type: 'text',
attrs: expect.objectContaining({display_name: 'Department 2'}),
}));
expect(patchPropertyField.mock.calls[0][3].attrs).not.toHaveProperty('visibility');
expect(patchPropertyField.mock.calls[0][3].attrs).not.toHaveProperty('managed');
expect(patchPropertyField.mock.calls[0][3]).not.toHaveProperty('permission_values');
expect(createPropertyField).not.toHaveBeenCalled();
expect(deletePropertyField).not.toHaveBeenCalled();
});
it('saves a standalone user field\'s Users row settings in the same PATCH as the definition', async () => {
mockLoadedNonTemplateField(makeNonTemplate('user'));
const patchPropertyField = jest.spyOn(Client4, 'patchPropertyField').mockResolvedValue(makeNonTemplate('user'));
renderEdit();
await waitForForm();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
await userEvent.click(screen.getByTestId('attributeAppliesToUserProfileDisplay-hidden'));
await userEvent.click(screen.getByTestId('attributeAppliesToUserWhoCanSet-admin'));
await userEvent.click(screen.getByTestId('saveSetting'));
await waitFor(() => expect(mockHistoryPush).toHaveBeenCalledWith('/admin_console/system_attributes/manage_attributes'));
expect(patchPropertyField).toHaveBeenCalledTimes(1);
expect(patchPropertyField).toHaveBeenCalledWith('access_control', 'user', FIELD_ID, expect.objectContaining({
attrs: expect.objectContaining({visibility: 'hidden', managed: 'admin'}),
permission_values: 'sysadmin',
}));
});
it('surfaces a rejected PATCH for a non-template field and leaves Save usable', async () => {
mockLoadedNonTemplateField(makeNonTemplate('user'));
const patchPropertyField = jest.spyOn(Client4, 'patchPropertyField').mockRejectedValue(makeClientError('app.property_field.update.name_conflict.app_error'));
@@ -2311,16 +2334,112 @@ describe('AttributeDetails', () => {
expect(screen.getByTestId('attributeAppliesToRow-channel-summary')).toHaveTextContent('Required');
});
it('disables the row\'s toggle behind an explanatory lock tooltip', async () => {
it('leaves a standalone user field\'s row toggle, Profile display and Who can set the value enabled, with Remove locked', async () => {
mockLoadedNonTemplateField(makeNonTemplate('user'));
renderEdit();
await waitForForm();
expect(screen.getByTestId('attributeAppliesToRow-user-toggle')).toBeEnabled();
expect(screen.queryByTestId('attributeAppliesToRow-user-toggleLockWrap')).not.toBeInTheDocument();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
expect(screen.getByTestId('attributeAppliesToRow-user-remove')).toBeDisabled();
expect(screen.getByTestId('attributeAppliesToRow-user-removeLockWrap')).toBeInTheDocument();
expect(screen.getByTestId('attributeAppliesToUserProfileDisplay-hidden')).toBeEnabled();
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-admin')).toBeEnabled();
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-member')).toBeEnabled();
});
it('keeps a standalone post field\'s row toggle disabled behind an explanatory lock tooltip', async () => {
mockLoadedNonTemplateField(makeNonTemplate('post'));
renderEdit();
await waitForForm();
expect(screen.getByTestId('attributeAppliesToRow-post-toggle')).toBeDisabled();
expect(screen.getByTestId('attributeAppliesToRow-post-toggleLockWrap')).toBeInTheDocument();
});
it('leaves a standalone channel field\'s row toggle and settings enabled, with Remove locked', async () => {
mockLoadedNonTemplateField(makeNonTemplate('channel'));
renderEdit();
await waitForForm();
expect(screen.getByTestId('attributeAppliesToRow-channel-toggle')).toBeEnabled();
expect(screen.queryByTestId('attributeAppliesToRow-channel-toggleLockWrap')).not.toBeInTheDocument();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-channel-toggle'));
expect(screen.getByTestId('attributeAppliesToRow-channel-remove')).toBeDisabled();
expect(screen.getByTestId('attributeAppliesToRow-channel-removeLockWrap')).toBeInTheDocument();
expect(screen.getByTestId('channelsResourceLocation-display_label_header')).toBeEnabled();
});
it('saves a standalone channel field\'s row settings in the same PATCH as the definition', async () => {
mockLoadedNonTemplateField(makeNonTemplate('channel'));
const patchPropertyField = jest.spyOn(Client4, 'patchPropertyField').mockResolvedValue(makeNonTemplate('channel'));
renderEdit();
await waitForForm();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-channel-toggle'));
await userEvent.click(screen.getByTestId('channelsResourceRequired-button'));
await userEvent.click(screen.getByTestId('saveSetting'));
await waitFor(() => expect(mockHistoryPush).toHaveBeenCalledWith('/admin_console/system_attributes/manage_attributes'));
expect(patchPropertyField).toHaveBeenCalledTimes(1);
expect(patchPropertyField).toHaveBeenCalledWith('access_control', 'channel', FIELD_ID, expect.objectContaining({
attrs: expect.objectContaining({
display_name: 'Department',
required: true,
change_policy: expect.any(String),
editable: null,
actions: expect.any(Array),
}),
}));
});
it('keeps a standalone channel field\'s loaded config when only the display name changes', async () => {
mockLoadedNonTemplateField(makeNonTemplate('channel', {attrs: {display_name: 'Region', required: true}}));
const patchPropertyField = jest.spyOn(Client4, 'patchPropertyField').mockResolvedValue(makeNonTemplate('channel'));
renderEdit();
await waitForForm();
await userEvent.type(screen.getByTestId('attributeDisplayNameInput'), ' 2');
await userEvent.click(screen.getByTestId('saveSetting'));
await waitFor(() => expect(mockHistoryPush).toHaveBeenCalled());
expect(patchPropertyField).toHaveBeenCalledWith('access_control', 'channel', FIELD_ID, expect.objectContaining({
attrs: expect.objectContaining({display_name: 'Region 2', required: true}),
}));
});
it('keeps a plugin-created standalone user field\'s row toggle disabled', async () => {
mockLoadedNonTemplateField(makeNonTemplate('user', {
attrs: {display_name: 'Plugin field', source_plugin_id: 'com.example.plugin', protected: true},
}));
renderEdit();
await waitForForm();
expect(screen.getByTestId('attributeAppliesToRow-user-toggle')).toBeDisabled();
expect(screen.getByTestId('attributeAppliesToRow-user-toggleLockWrap')).toBeInTheDocument();
});
it('keeps a plugin-created standalone channel field\'s row toggle disabled', async () => {
mockLoadedNonTemplateField(makeNonTemplate('channel', {
attrs: {display_name: 'Plugin field', source_plugin_id: 'com.example.plugin', protected: true},
}));
renderEdit();
await waitForForm();
expect(screen.getByTestId('attributeAppliesToRow-channel-toggle')).toBeDisabled();
expect(screen.getByTestId('attributeAppliesToRow-channel-toggleLockWrap')).toBeInTheDocument();
});
it('leaves Type editable -- the single-resource lock must not regress the earlier applies-to-stays-editable guard', async () => {
mockLoadedNonTemplateField(makeNonTemplate('user'));
@@ -3091,6 +3210,306 @@ describe('AttributeDetails', () => {
});
});
describe('owned field', () => {
const SCIM_ID = 'com.mattermost.scim';
const scimOwner = {id: SCIM_ID, type: 'plugin', scopes: []};
function mockScimStatus(installed: boolean) {
return jest.spyOn(Client4, 'getPluginStatuses').mockResolvedValue(installed ? [{
plugin_id: SCIM_ID,
name: 'SCIM',
description: '',
version: '1.0.0',
cluster_id: '',
plugin_path: '',
state: 1,
}] : []);
}
it('locks Type and Unique Name on an owned template, naming the owner instead of "applies to a resource"', async () => {
const getPluginStatuses = mockScimStatus(true);
mockLoadedField(makeTemplate(), [makeLinked('user', 'user-field', {attrs: {display_name: 'Department', owners: [scimOwner]}})]);
renderEdit();
await waitForForm();
const typeButton = screen.getByTestId('attributeTypeMenuButton');
const nameLink = screen.getByTestId('attributeNameEditLink');
await waitFor(() => expect(typeButton).toHaveAccessibleName(/managed by SCIM/));
expect(typeButton).toBeDisabled();
expect(nameLink).toBeDisabled();
expect(nameLink).toHaveAccessibleName(/managed by SCIM/);
expect(typeButton).not.toHaveAccessibleName(/applies to a resource/);
expect(nameLink).not.toHaveAccessibleName(/applies to a resource/);
expect(getPluginStatuses).toHaveBeenCalled();
});
it('locks Type and Unique Name on an owned standalone user field', async () => {
mockScimStatus(true);
mockLoadedNonTemplateField(makeNonTemplate('user', {attrs: {display_name: 'Department', owners: [scimOwner]}}));
renderEdit();
await waitForForm();
await waitFor(() => expect(screen.getByTestId('attributeTypeMenuButton')).toHaveAccessibleName(/managed by SCIM/));
expect(screen.getByTestId('attributeTypeMenuButton')).toBeDisabled();
expect(screen.getByTestId('attributeNameEditLink')).toBeDisabled();
expect(screen.getByTestId('attributeNameEditLink')).toHaveAccessibleName(/managed by SCIM/);
});
it('stays locked and names the plugin ID when the owner plugin is not installed', async () => {
mockScimStatus(false);
mockLoadedField(makeTemplate(), [makeLinked('user', 'user-field', {attrs: {display_name: 'Department', owners: [scimOwner]}})]);
renderEdit();
await waitForForm();
expect(screen.getByTestId('attributeTypeMenuButton')).toBeDisabled();
expect(screen.getByTestId('attributeTypeMenuButton')).toHaveAccessibleName(new RegExp(`managed by ${SCIM_ID}`));
expect(screen.getByTestId('attributeNameEditLink')).toHaveAccessibleName(new RegExp(`managed by ${SCIM_ID}`));
});
it('names a service owner by its ID', async () => {
mockLoadedField(makeTemplate(), [makeLinked('user', 'user-field', {attrs: {display_name: 'Department', owners: [{id: 'svc-sync', type: 'service', scopes: []}]}})]);
renderEdit();
await waitForForm();
expect(screen.getByTestId('attributeTypeMenuButton')).toHaveAccessibleName(/managed by svc-sync/);
expect(screen.getByTestId('attributeNameEditLink')).toHaveAccessibleName(/managed by svc-sync/);
});
it('keeps display name and options editable, and lets a display name change enable Save', async () => {
mockScimStatus(true);
mockLoadedField(makeTemplate({
type: 'select',
attrs: {display_name: 'Department', options: [{id: 'opt-1', name: 'Engineering'}]},
}), [makeLinked('user', 'user-field', {type: 'select', attrs: {display_name: 'Department', options: [{id: 'opt-1', name: 'Engineering'}], owners: [scimOwner]}})]);
renderEdit();
await waitForForm();
expect(screen.getByTestId('attributeDisplayNameInput')).toBeEnabled();
expect(screen.getByTestId('attributeOptionsValues__addInput')).toBeEnabled();
await userEvent.type(screen.getByTestId('attributeDisplayNameInput'), ' Renamed');
expect(screen.getByTestId('saveSetting')).toBeEnabled();
});
it('keeps the plugin-created reasons when the linked Users field also has owners', async () => {
mockScimStatus(true);
mockLoadedField(makePluginOwnedTemplate(), [makeLinked('user', 'user-field', {attrs: {display_name: 'Plugin field', owners: [scimOwner]}})]);
renderEdit();
await waitForForm();
expect(screen.getByTestId('attributeTypeMenuButton')).toHaveAccessibleName(/managed by a plugin/);
expect(screen.getByTestId('attributeNameEditLink')).toHaveAccessibleName(/managed by a plugin/);
});
it('keeps the applies-to reasons when the linked Users field has no owners', async () => {
mockLoadedField(makeTemplate(), [makeLinked('user', 'user-field')]);
renderEdit();
await waitForForm();
expect(screen.getByTestId('attributeTypeMenuButton')).toHaveAccessibleName(/applies to a resource/);
expect(screen.getByTestId('attributeNameEditLink')).toHaveAccessibleName(/applies to a resource/);
});
it('shows the owners note beside the external-source editor on an owned template', async () => {
mockScimStatus(true);
mockLoadedField(makeTemplate(), [makeLinked('user', 'user-field', {attrs: {display_name: 'Department', owners: [scimOwner]}})]);
renderEdit();
await waitForForm();
await waitFor(() => expect(screen.getByTestId('attributeOwnersSourceManagedBy')).toHaveTextContent('Managed by SCIM'));
expect(screen.getByTestId('attributeExternalSource')).toBeInTheDocument();
expect(screen.queryByTestId('attributePluginSource')).not.toBeInTheDocument();
});
it('locks Who can set the value and names the owners on Remove for an owned standalone user field', async () => {
mockScimStatus(true);
mockLoadedNonTemplateField(makeNonTemplate('user', {attrs: {display_name: 'Department', owners: [scimOwner], managed: 'admin'}}));
renderEdit();
await waitForForm();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-admin')).toBeDisabled();
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-member')).toBeDisabled();
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-lockWrap')).toBeInTheDocument();
expect(screen.getByTestId('attributeAppliesToRow-user-remove')).toBeDisabled();
await userEvent.hover(screen.getByTestId('attributeAppliesToRow-user-removeLockWrap'));
expect(await screen.findByText(/managed by SCIM/)).toBeInTheDocument();
});
it('shows the owners note beside the external-source editor on an owned standalone user field', async () => {
mockScimStatus(true);
mockLoadedNonTemplateField(makeNonTemplate('user', {attrs: {display_name: 'Department', owners: [scimOwner]}}));
renderEdit();
await waitForForm();
expect(screen.getByTestId('attributeOwnersSource')).toBeInTheDocument();
expect(screen.getByTestId('attributeExternalSource')).toBeInTheDocument();
});
it('shows no owners note when the linked Users field has no owners', async () => {
mockLoadedField(makeTemplate(), [makeLinked('user', 'user-field')]);
renderEdit();
await waitForForm();
expect(screen.queryByTestId('attributeOwnersSource')).not.toBeInTheDocument();
});
it('shows only the plugin note, without the external-source editor, for a plugin-created template', async () => {
mockScimStatus(true);
mockLoadedField(makePluginOwnedTemplate(), [makeLinked('user', 'user-field', {attrs: {display_name: 'Plugin field', owners: [scimOwner]}})]);
renderEdit();
await waitForForm();
expect(screen.getByTestId('attributePluginSource')).toBeInTheDocument();
expect(screen.queryByTestId('attributeOwnersSource')).not.toBeInTheDocument();
expect(screen.queryByTestId('attributeExternalSource')).not.toBeInTheDocument();
});
it('locks Who can set the value on an owned template while Profile display stays editable', async () => {
mockScimStatus(true);
mockLoadedField(makeTemplate(), [makeLinked('user', 'user-field', {attrs: {display_name: 'Department', visibility: 'always', managed: 'admin', owners: [scimOwner]}})]);
renderEdit();
await waitForForm();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-admin')).toBeChecked();
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-admin')).toBeDisabled();
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-member')).toBeDisabled();
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-lockWrap')).toBeInTheDocument();
const hidden = screen.getByTestId('attributeAppliesToUserProfileDisplay-hidden');
expect(hidden).toBeEnabled();
expect(screen.getByTestId('saveSetting')).toBeDisabled();
await userEvent.click(hidden);
expect(hidden).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByTestId('saveSetting')).toBeEnabled();
});
it('leaves Who can set the value editable when the linked Users field has no owners', async () => {
mockLoadedField(makeTemplate(), [makeLinked('user', 'user-field', {attrs: {display_name: 'Department', visibility: 'always', managed: 'admin'}})]);
renderEdit();
await waitForForm();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-admin')).toBeEnabled();
expect(screen.getByTestId('attributeAppliesToUserWhoCanSet-member')).toBeEnabled();
expect(screen.queryByTestId('attributeAppliesToUserWhoCanSet-lockWrap')).not.toBeInTheDocument();
});
describe('Remove lock and save', () => {
const ownedAttrs = {display_name: 'Department', owners: [scimOwner], managed: 'admin', visibility: 'always'};
function mockPatch() {
return jest.spyOn(Client4, 'patchPropertyField').mockImplementation((_group, objectType) => (
Promise.resolve(objectType === 'user' ? makeLinked('user', 'user-field', {attrs: ownedAttrs}) : makeTemplate())
));
}
it('locks Remove on the Users row only, keeping Remove on other rows', async () => {
mockScimStatus(true);
mockLoadedField(makeTemplate(), [
makeLinked('user', 'user-field', {attrs: ownedAttrs}),
makeLinked('channel', 'channel-field'),
]);
renderEdit();
await waitForForm();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
expect(screen.getByTestId('attributeAppliesToRow-user-remove')).toBeDisabled();
expect(screen.getByTestId('attributeAppliesToRow-user-removeLockWrap')).toBeInTheDocument();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-channel-toggle'));
expect(screen.getByTestId('attributeAppliesToRow-channel-remove')).toBeEnabled();
});
it('unlocks Remove and deletes the Users field on save once the owner plugin is uninstalled', async () => {
mockScimStatus(false);
mockLoadedField(makeTemplate(), [makeLinked('user', 'user-field', {attrs: ownedAttrs})]);
jest.spyOn(Client4, 'patchPropertyField').mockResolvedValue(makeTemplate());
const deletePropertyField = jest.spyOn(Client4, 'deletePropertyField').mockResolvedValue({status: 'OK'});
renderEdit();
await waitForForm();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
await waitFor(() => expect(screen.getByTestId('attributeAppliesToRow-user-remove')).toBeEnabled());
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-remove'));
await userEvent.click(screen.getByTestId('saveSetting'));
await userEvent.click(await screen.findByRole('button', {name: /remove and save/i}));
await waitFor(() => expect(mockHistoryPush).toHaveBeenCalled());
expect(deletePropertyField).toHaveBeenCalledWith('access_control', 'user', 'user-field');
});
it('leaves Remove enabled on an unowned Users row', async () => {
mockLoadedField(makeTemplate(), [makeLinked('user', 'user-field')]);
renderEdit();
await waitForForm();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
expect(screen.getByTestId('attributeAppliesToRow-user-remove')).toBeEnabled();
expect(screen.queryByTestId('attributeAppliesToRow-user-removeLockWrap')).not.toBeInTheDocument();
});
it('saves a Profile display change without sending owners or deleting the Users field', async () => {
mockScimStatus(true);
mockLoadedField(makeTemplate(), [makeLinked('user', 'user-field', {attrs: ownedAttrs})]);
const patchPropertyField = mockPatch();
const deletePropertyField = jest.spyOn(Client4, 'deletePropertyField');
renderEdit();
await waitForForm();
await userEvent.click(screen.getByTestId('attributeAppliesToRow-user-toggle'));
await userEvent.click(screen.getByTestId('attributeAppliesToUserProfileDisplay-hidden'));
await userEvent.click(screen.getByTestId('saveSetting'));
await waitFor(() => expect(mockHistoryPush).toHaveBeenCalledWith('/admin_console/system_attributes/manage_attributes'));
expect(patchPropertyField).toHaveBeenCalledWith('access_control', 'user', 'user-field', {
attrs: {visibility: 'hidden', managed: 'admin'},
permission_values: 'sysadmin',
});
expect(deletePropertyField).not.toHaveBeenCalled();
});
it('saves a display name change without sending owners or managed', async () => {
mockScimStatus(true);
mockLoadedField(makeTemplate(), [makeLinked('user', 'user-field', {attrs: ownedAttrs})]);
const patchPropertyField = mockPatch();
const deletePropertyField = jest.spyOn(Client4, 'deletePropertyField');
renderEdit();
await waitForForm();
await userEvent.type(screen.getByTestId('attributeDisplayNameInput'), ' 2');
await userEvent.click(screen.getByTestId('saveSetting'));
await waitFor(() => expect(mockHistoryPush).toHaveBeenCalled());
expect(patchPropertyField).toHaveBeenCalledWith('access_control', 'user', 'user-field', {
attrs: {display_name: 'Department 2'},
});
expect(deletePropertyField).not.toHaveBeenCalled();
});
});
});
it('warns once an existing template is renamed onto a user field another attribute owns', async () => {
// The collision is reachable by renaming, not only by creating: an
// attribute that has always been `department` can be pointed at the
@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {useCallback, useEffect, useMemo, useRef, useState, type JSX} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState, type JSX, type ReactNode} from 'react';
import type {IntlShape, MessageDescriptor} from 'react-intl';
import {defineMessages, FormattedMessage, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
@@ -14,13 +14,14 @@ import {buttonClassNames} from '@mattermost/shared/components/button';
import {WithTooltip} from '@mattermost/shared/components/tooltip';
import type {FieldVisibility, PropertyField, PropertyFieldOption, PropertyPermissionLevel} from '@mattermost/types/properties';
import {supportsHierarchy, supportsOptions} from '@mattermost/types/properties';
import type {PropertyFieldOwner} from '@mattermost/types/properties_user';
import {setNavigationBlocked} from 'actions/admin_actions';
import BlockableLink from 'components/admin_console/blockable_link';
import {findRankCollision, isValidRank} from 'components/admin_console/system_properties/rank_utils';
import Card from 'components/card/card';
import {useIsFieldOrphaned, usePluginInventoryLoaded} from 'components/common/hooks/use_field_orphaned';
import {useInstalledPluginIds, useIsFieldOrphaned, usePluginInventoryLoaded} from 'components/common/hooks/use_field_orphaned';
import useGetFeatureFlagValue from 'components/common/hooks/useGetFeatureFlagValue';
import LoadingScreen from 'components/loading_screen';
import {pageAllAccessControlFieldOptions} from 'components/property_fields/graph/page_all_access_control_field_options';
@@ -31,7 +32,7 @@ import Input from 'components/widgets/inputs/input/input';
import {getHistory} from 'utils/browser_history';
import Constants from 'utils/constants';
import {CPA_FIELD_NAME_MAX_RUNES, filterCELIdentifier, slugifyForCEL, validateCPAFieldName} from 'utils/properties';
import {allOwnersAreUninstalledPlugins, CPA_FIELD_NAME_MAX_RUNES, filterCELIdentifier, slugifyForCEL, validateCPAFieldName} from 'utils/properties';
import type {CPAFieldNameValidationError} from 'utils/properties';
import AttributeAppliesTo from './attribute_applies_to';
@@ -40,6 +41,7 @@ import type {ResourceObjectType, UserManagedValue} from './attribute_applies_to_
import AttributeExternalSource from './attribute_external_source';
import AttributeOptionsRankValues from './attribute_options_rank_values';
import AttributeOptionsValues from './attribute_options_values';
import AttributeOwnersSource from './attribute_owners_source';
import AttributePluginSource from './attribute_plugin_source';
import {useConfirmRemoveAppliesTo} from './attribute_remove_applies_to_warning_modal';
import AttributeSelect from './attribute_select';
@@ -54,6 +56,7 @@ import {GLOBAL_ATTRIBUTES_LIST_ROUTE, GLOBAL_ATTRIBUTES_OBJECT_TYPE} from '../co
import {getSourceKind, getTypeIcon, getTypeLabel, isClassificationMarkingsField} from '../global_attributes_table';
import {findNameConflict, messages as nameConflictMessages, type NameConflict} from '../name_conflict';
import useAllowedResourceTypes from '../use_allowed_resource_types';
import {getFieldOwners, NO_OWNERS, useOwnersLabel} from '../use_owners_label';
import type {AttributeFieldType, AttributeTypeId, UpdateAttributeFieldPatch} from '../utils';
import {
createAttributeField,
@@ -344,6 +347,11 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
const [sourcePluginId, setSourcePluginId] = useState<string | undefined>(undefined);
const isPluginOwned = Boolean(sourcePluginId);
// Plugin-created attributes are excluded: they have their own, stricter lock and wording.
const [owners, setOwners] = useState<PropertyFieldOwner[]>(NO_OWNERS);
const isOwned = owners.length > 0 && !isPluginOwned;
const ownersLabel = useOwnersLabel(owners);
// Defaults to the template type because this page cannot create a
// user/channel/post field; load() copies the fetched field's object_type
// so Save PATCHes that type.
@@ -354,6 +362,9 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
// isEditMode check.
const isNonTemplate = objectType !== GLOBAL_ATTRIBUTES_OBJECT_TYPE;
// Only a post field's row stays fully locked; user and channel rows are editable.
const standaloneRowLocked = isNonTemplate && objectType === 'post';
// Substituted for the bare `disabled` prop everywhere else on this page --
// one boolean, not a second parallel disabled path. Keeps the pre-existing
// non-sysadmin `disabled` prop (schema-wired via isDisabled: it.not
@@ -482,7 +493,7 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
// settle orphan detection below: an inventory that hasn't loaded yet reads
// identically to "nothing installed" (see useIsFieldOrphaned's own doc
// comment), so isOrphaned is only trusted once this is true.
const pluginInventoryLoaded = usePluginInventoryLoaded(isPluginOwned);
const pluginInventoryLoaded = usePluginInventoryLoaded(isPluginOwned || owners.some((owner) => owner.type === 'plugin'));
// protected: true is safe to hardcode here (rather than threaded from the
// loaded field) -- sourcePluginId is only ever set when getSourceKind(field)
@@ -559,7 +570,7 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
setSamlAttr(loadedSaml);
// A non-template field has no linked children: Applies-to is its
// own object type, and AttributeAppliesTo locks it via isNonTemplate.
// own object type, so there is no other resource to add or remove.
setAppliesTo(
field.object_type === GLOBAL_ATTRIBUTES_OBJECT_TYPE ?
ALL_RESOURCE_TYPES.filter((type) => Boolean(linkedByType[type])) :
@@ -570,6 +581,7 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
// a template with no Users child still gets the same defaults as
// create. A non-template user field is itself that Users field.
const userField = field.object_type === 'user' ? field : linkedByType.user;
setOwners(userField ? getFieldOwners(userField) : NO_OWNERS);
const rawVisibility = userField?.attrs?.visibility;
const loadedVisibility: FieldVisibility = rawVisibility === 'always' || rawVisibility === 'when_set' || rawVisibility === 'hidden' ? rawVisibility : 'when_set';
const loadedManaged: UserManagedValue = userField?.attrs?.managed === 'admin' ? 'admin' : '';
@@ -580,8 +592,8 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
// A non-template channel field is itself the channel field, so its
// row settings parse from it directly; a template seeds them from
// its linked channel child instead. Without this the locked Channels
// row would show the default config, misrepresenting the field.
// its linked channel child instead. Save writes every channel setting,
// so without this seed a rename would overwrite them with defaults.
const channelConfigSource = field.object_type === 'channel' ? field : linkedByType.channel;
if (channelConfigSource) {
setChannelResource(parseChannelFieldConfig(channelConfigSource));
@@ -689,15 +701,31 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
nameDescribedBy = 'attribute-save-error';
}
// Plugin ownership wins over non-template: it disables the whole page,
// Plugin ownership wins over the post-field lock: it disables the whole page,
// so it is the more specific reason to show.
let appliesToLockedTooltip: string | undefined;
if (isPluginOwned) {
appliesToLockedTooltip = formatMessage(isOrphaned ? messages.appliesToLockedPluginOrphanedTooltip : messages.appliesToLockedPluginTooltip);
} else if (isNonTemplate) {
} else if (standaloneRowLocked) {
appliesToLockedTooltip = formatMessage(messages.appliesToLockedSingleResourceTooltip);
}
const ownedLockedTooltip = isOwned ? formatMessage(messages.ownedLockedTooltip, {owners: ownersLabel}) : undefined;
// An uninstalled plugin can't manage the Users field, so once every owner is one,
// Remove unlocks, matching the Delete action in the attributes table.
const installedPluginIds = useInstalledPluginIds();
const ownersGone = pluginInventoryLoaded && allOwnersAreUninstalledPlugins(owners, installedPluginIds);
const userRemoveLocked = isOwned && !ownersGone;
const ownedRemoveLockedTooltip = userRemoveLocked ? ownedLockedTooltip : undefined;
let removeLockedTooltips: Partial<Record<ResourceObjectType, ReactNode>> | undefined;
if (isNonTemplate) {
removeLockedTooltips = {[objectType]: ownedRemoveLockedTooltip ?? formatMessage(messages.appliesToLockedSingleResourceTooltip)};
} else if (ownedRemoveLockedTooltip) {
removeLockedTooltips = {user: ownedRemoveLockedTooltip};
}
const markDirty = useCallback(() => {
setIsDirty(true);
dispatch(setNavigationBlocked(true));
@@ -929,7 +957,7 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
// Type is locked outright; every other field keeps its menu but no longer
// offers Hierarchical (see selectableTypes below).
const typeLockedByGraph = isEditMode && originalFieldTypeRef.current === 'graph';
const typeLocked = hasExternalSource || typeLockedByAppliesTo || isPluginOwned || typeLockedByGraph;
const typeLocked = hasExternalSource || typeLockedByAppliesTo || isPluginOwned || isOwned || typeLockedByGraph;
const serverFieldType = toServerFieldType(fieldType);
const typeChanged = isEditMode && serverFieldType !== originalFieldTypeRef.current;
const typeSupportsOptions = supportsOptions({type: serverFieldType});
@@ -941,7 +969,7 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
// persisted snapshot, not appliesTo, so a pending local remove doesn't
// unlock a rename the dependents wouldn't receive.
const nameLockedByAppliesTo = isEditMode && Object.keys(persistedLinkedFieldsRef.current).length > 0;
const nameLocked = nameLockedByAppliesTo || isPluginOwned;
const nameLocked = nameLockedByAppliesTo || isPluginOwned || isOwned;
// Defensive re-check, not the primary guard: both options editors already
// block duplicate names and invalid/duplicate ranks interactively at
@@ -1067,6 +1095,13 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
// to that object type, skipping the template create-plus-link path
// below entirely.
if (objectType !== GLOBAL_ATTRIBUTES_OBJECT_TYPE) {
const userConfigChanged = userVisibility !== originalUserVisibilityRef.current || userManaged !== originalUserManagedRef.current;
if (objectType === 'user' && userConfigChanged) {
patch.resourceAttrs = userConfigAttrs;
patch.permissionValues = userConfigPermissionValues;
} else if (objectType === 'channel') {
patch.resourceAttrs = buildChannelFieldPatch(channelResource).attrs;
}
try {
await updateAttributeField(objectType, fieldId, patch);
} catch (error) {
@@ -1083,7 +1118,7 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
}
const persisted = persistedLinkedFieldsRef.current;
const toDelete = (Object.keys(persisted) as ResourceObjectType[]).filter((type) => !appliesTo.includes(type));
const toDelete = (Object.keys(persisted) as ResourceObjectType[]).filter((type) => !appliesTo.includes(type) && !(userRemoveLocked && type === 'user'));
const toCreate = appliesTo.filter((type) => !persisted[type]);
// Removing a resource deletes every value stored under its linked
@@ -1354,7 +1389,7 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
}
finalizeSave(outcome);
}, [canSave, isEditMode, fieldId, objectType, nameUnchanged, displayName, currentName, fieldType, typeChanged, options, ldapAttr, samlAttr, appliesTo, channelResource, finalizeSave, confirmRemoveAppliesTo, userVisibility, userManaged]);
}, [canSave, isEditMode, fieldId, objectType, nameUnchanged, displayName, currentName, fieldType, typeChanged, options, ldapAttr, samlAttr, appliesTo, channelResource, finalizeSave, confirmRemoveAppliesTo, userVisibility, userManaged, userRemoveLocked]);
const handleChannelResourceChange = useCallback((next: ChannelResourceConfig) => {
setChannelResource(next);
@@ -1378,16 +1413,20 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
// "(no longer installed)" copy the Managed-by panel shows one card up.
// graph outranks externalSource/appliesTo because it is the only one of the
// three the admin cannot undo: telling them to remove resources or unlink a
// source would promise an unlock that never arrives.
const typeLockReason = firstMatchingReason<'pluginOrphaned' | 'plugin' | 'graph' | 'externalSource' | 'appliesTo'>(
// source would promise an unlock that never arrives. owned outranks graph
// and the rest: an owned template always has a linked Users field, so a
// lower-ranked reason would blame "applies to a resource" instead of the
// integration that actually holds the attribute.
const typeLockReason = firstMatchingReason<'pluginOrphaned' | 'plugin' | 'owned' | 'graph' | 'externalSource' | 'appliesTo'>(
['pluginOrphaned', isPluginOwned && isOrphaned],
['plugin', isPluginOwned],
['owned', isOwned],
['graph', typeLockedByGraph],
['externalSource', hasExternalSource],
['appliesTo', typeLockedByAppliesTo],
);
const typeLockTooltip = formatMessage(TYPE_LOCK_MESSAGES[typeLockReason ?? 'appliesTo'].tooltip);
const typeButtonAriaLabel = typeLockReason ? formatMessage(TYPE_LOCK_MESSAGES[typeLockReason].ariaLabel, {value: formatMessage(getTypeLabel(fieldType))}) : formatMessage(messages.typeFieldAriaLabel, {value: formatMessage(getTypeLabel(fieldType))});
const typeLockTooltip = formatMessage(TYPE_LOCK_MESSAGES[typeLockReason ?? 'appliesTo'].tooltip, {owners: ownersLabel});
const typeButtonAriaLabel = typeLockReason ? formatMessage(TYPE_LOCK_MESSAGES[typeLockReason].ariaLabel, {value: formatMessage(getTypeLabel(fieldType)), owners: ownersLabel}) : formatMessage(messages.typeFieldAriaLabel, {value: formatMessage(getTypeLabel(fieldType))});
const typeMenu = (
<AttributeSelect
idPrefix='attribute-type'
@@ -1581,7 +1620,7 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
onKeyDown={handleNameKeyDown}
onBlur={handleDoneClick}
autoFocus={true}
disabled={saving || effectiveDisabled || nameLockedByAppliesTo}
disabled={saving || effectiveDisabled || nameLocked}
maxLength={CPA_FIELD_NAME_MAX_RUNES}
aria-labelledby='attribute-unique-name-prefix'
aria-describedby={nameDescribedBy}
@@ -1602,12 +1641,13 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
// and the tooltip below -- same priority reasoning as
// typeLockReason above, including preferring pluginOrphaned
// over plain "plugin" once the field is confirmed orphaned.
const nameLockReason = firstMatchingReason<'pluginOrphaned' | 'plugin' | 'appliesTo'>(
const nameLockReason = firstMatchingReason<'pluginOrphaned' | 'plugin' | 'owned' | 'appliesTo'>(
['pluginOrphaned', isPluginOwned && isOrphaned],
['plugin', isPluginOwned],
['owned', isOwned],
['appliesTo', nameLockedByAppliesTo],
);
const nameEditLinkAriaLabel = nameLockReason ? formatMessage(NAME_LOCK_MESSAGES[nameLockReason].ariaLabel) : formatMessage(isEditingName ? messages.doneLinkAriaLabel : messages.editLinkAriaLabel);
const nameEditLinkAriaLabel = nameLockReason ? formatMessage(NAME_LOCK_MESSAGES[nameLockReason].ariaLabel, {owners: ownersLabel}) : formatMessage(isEditingName ? messages.doneLinkAriaLabel : messages.editLinkAriaLabel);
const editLinkButton = (
<button
@@ -1621,7 +1661,7 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
e.preventDefault();
}
}}
disabled={saving || effectiveDisabled || nameLockedByAppliesTo}
disabled={saving || effectiveDisabled || nameLocked}
aria-disabled={isDoneBlocked || undefined}
aria-describedby={isDoneBlocked ? 'attribute-unique-name-error' : undefined}
aria-label={nameEditLinkAriaLabel}
@@ -1631,7 +1671,7 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
</button>
);
const nameLockTooltip = formatMessage(NAME_LOCK_MESSAGES[nameLockReason ?? 'appliesTo'].tooltip);
const nameLockTooltip = formatMessage(NAME_LOCK_MESSAGES[nameLockReason ?? 'appliesTo'].tooltip, {owners: ownersLabel});
return nameLocked ? (
<WithTooltip title={nameLockTooltip}>
<span
@@ -1724,16 +1764,24 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
pluginInventoryLoaded={pluginInventoryLoaded}
/>
) : (
showsExternalSource && fieldType !== 'graph' && (
<AttributeExternalSource
ldapAttr={ldapAttr}
samlAttr={samlAttr}
fieldType={fieldType}
onLink={handleLink}
disabled={saving || effectiveDisabled}
disableAdding={typeLockedByAppliesTo}
/>
)
<>
{isOwned && (
<AttributeOwnersSource
owners={owners}
pluginInventoryLoaded={pluginInventoryLoaded}
/>
)}
{showsExternalSource && fieldType !== 'graph' && (
<AttributeExternalSource
ldapAttr={ldapAttr}
samlAttr={samlAttr}
fieldType={fieldType}
onLink={handleLink}
disabled={saving || effectiveDisabled}
disableAdding={typeLockedByAppliesTo}
/>
)}
</>
)}
</div>
</div>
@@ -1742,7 +1790,7 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
<AttributeAppliesTo
appliesTo={appliesTo}
allowedTypes={allowedResourceTypes}
disabled={saving || effectiveDisabled || isNonTemplate}
disabled={saving || effectiveDisabled || standaloneRowLocked}
hideAddResource={isPluginOwned || isNonTemplate}
blockedTypes={usersBlockedByNameConflict ? {user: formatMessage(nameConflictMessages.usersBlockedMenuLabel)} : undefined}
lockedTooltip={appliesToLockedTooltip}
@@ -1753,6 +1801,8 @@ function AttributeDetails({disabled = false}: Props): JSX.Element {
userManaged={userManaged}
onUserManagedChange={handleUserManagedChange}
externalSource={managedByExternalSource}
userWhoCanSetLockedTooltip={ownedLockedTooltip}
removeLockedTooltips={removeLockedTooltips}
channelResource={channelResource}
onChannelResourceChange={handleChannelResourceChange}
ordered={fieldType === 'rank'}
@@ -1821,6 +1871,14 @@ const messages = defineMessages({
id: 'admin.global_attributes.attribute_details.unique_name.locked_plugin_tooltip',
defaultMessage: 'Name cannot be changed — this attribute is managed by a plugin.',
},
nameLockedOwnedAriaLabel: {
id: 'admin.global_attributes.attribute_details.unique_name.locked_owned_aria_label',
defaultMessage: 'Edit unique name. Locked because this attribute is managed by {owners}.',
},
nameLockedOwnedTooltip: {
id: 'admin.global_attributes.attribute_details.unique_name.locked_owned_tooltip',
defaultMessage: 'Name cannot be changed — this attribute is managed by {owners}.',
},
nameLockedPluginOrphanedAriaLabel: {
id: 'admin.global_attributes.attribute_details.unique_name.locked_plugin_orphaned_aria_label',
defaultMessage: "Edit unique name. Locked because this attribute was managed by a plugin that's no longer installed.",
@@ -1849,6 +1907,10 @@ const messages = defineMessages({
id: 'admin.global_attributes.attribute_details.type.field_locked_plugin_aria_label',
defaultMessage: 'Type: {value}. Locked because this attribute is managed by a plugin.',
},
typeFieldLockedOwnedAriaLabel: {
id: 'admin.global_attributes.attribute_details.type.field_locked_owned_aria_label',
defaultMessage: 'Type: {value}. Locked because this attribute is managed by {owners}.',
},
typeFieldLockedPluginOrphanedAriaLabel: {
id: 'admin.global_attributes.attribute_details.type.field_locked_plugin_orphaned_aria_label',
defaultMessage: "Type: {value}. Locked because this attribute was managed by a plugin that's no longer installed.",
@@ -1869,6 +1931,10 @@ const messages = defineMessages({
id: 'admin.global_attributes.attribute_details.type.locked_plugin_tooltip',
defaultMessage: 'Type cannot be changed — this attribute is managed by a plugin.',
},
typeLockedOwnedTooltip: {
id: 'admin.global_attributes.attribute_details.type.locked_owned_tooltip',
defaultMessage: 'Type cannot be changed — this attribute is managed by {owners}.',
},
typeLockedPluginOrphanedTooltip: {
id: 'admin.global_attributes.attribute_details.type.locked_plugin_orphaned_tooltip',
defaultMessage: "Type cannot be changed — this attribute was managed by a plugin that's no longer installed.",
@@ -1889,6 +1955,10 @@ const messages = defineMessages({
id: 'admin.global_attributes.attribute_details.applies_to.locked_single_resource_tooltip',
defaultMessage: 'This resource cannot be changed — this attribute applies to only one resource.',
},
ownedLockedTooltip: {
id: 'admin.global_attributes.attribute_details.applies_to.owned_locked_tooltip',
defaultMessage: 'This attribute is managed by {owners}.',
},
optionsLabel: {id: 'admin.global_attributes.attribute_details.options.label', defaultMessage: 'Options'},
optionsHelp: {
id: 'admin.global_attributes.attribute_details.options.help',
@@ -1909,17 +1979,19 @@ const messages = defineMessages({
// One reason derived once (see typeLockReason above), looked up here for both
// the tooltip and the aria-label -- a new lock reason becomes one entry in
// this map instead of a fourth branch across two separate if/else chains.
const TYPE_LOCK_MESSAGES: Record<'pluginOrphaned' | 'plugin' | 'graph' | 'externalSource' | 'appliesTo', {tooltip: MessageDescriptor; ariaLabel: MessageDescriptor}> = {
const TYPE_LOCK_MESSAGES: Record<'pluginOrphaned' | 'plugin' | 'owned' | 'graph' | 'externalSource' | 'appliesTo', {tooltip: MessageDescriptor; ariaLabel: MessageDescriptor}> = {
pluginOrphaned: {tooltip: messages.typeLockedPluginOrphanedTooltip, ariaLabel: messages.typeFieldLockedPluginOrphanedAriaLabel},
plugin: {tooltip: messages.typeLockedPluginTooltip, ariaLabel: messages.typeFieldLockedPluginAriaLabel},
owned: {tooltip: messages.typeLockedOwnedTooltip, ariaLabel: messages.typeFieldLockedOwnedAriaLabel},
graph: {tooltip: messages.typeLockedGraphTooltip, ariaLabel: messages.typeFieldLockedGraphAriaLabel},
externalSource: {tooltip: messages.typeLockedExternalSourceTooltip, ariaLabel: messages.typeFieldLockedAriaLabel},
appliesTo: {tooltip: messages.typeLockedAppliesToTooltip, ariaLabel: messages.typeFieldLockedAppliesToAriaLabel},
};
const NAME_LOCK_MESSAGES: Record<'pluginOrphaned' | 'plugin' | 'appliesTo', {tooltip: MessageDescriptor; ariaLabel: MessageDescriptor}> = {
const NAME_LOCK_MESSAGES: Record<'pluginOrphaned' | 'plugin' | 'owned' | 'appliesTo', {tooltip: MessageDescriptor; ariaLabel: MessageDescriptor}> = {
pluginOrphaned: {tooltip: messages.nameLockedPluginOrphanedTooltip, ariaLabel: messages.nameLockedPluginOrphanedAriaLabel},
plugin: {tooltip: messages.nameLockedPluginTooltip, ariaLabel: messages.nameLockedPluginAriaLabel},
owned: {tooltip: messages.nameLockedOwnedTooltip, ariaLabel: messages.nameLockedOwnedAriaLabel},
appliesTo: {tooltip: messages.nameLockedAppliesToTooltip, ariaLabel: messages.nameLockedAppliesToAriaLabel},
};
@@ -0,0 +1,99 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {PropertyFieldOwner} from '@mattermost/types/properties_user';
import {Client4} from 'mattermost-redux/client';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import AttributeOwnersSource from './attribute_owners_source';
describe('AttributeOwnersSource', () => {
const plugin: PropertyFieldOwner = {id: 'com.mattermost.scim', type: 'plugin', scopes: []};
const service: PropertyFieldOwner = {id: 'svc-sync', type: 'service', scopes: []};
const renderComponent = (
owners: PropertyFieldOwner[],
{installed = true, pluginInventoryLoaded = true} = {},
) => {
return renderWithContext(
<AttributeOwnersSource
owners={owners}
pluginInventoryLoaded={pluginInventoryLoaded}
/>,
installed ? {
entities: {
admin: {
pluginStatuses: {
[plugin.id]: {name: 'SCIM'} as never,
},
},
},
} : undefined,
);
};
it('names an installed plugin owner and links to its settings', () => {
renderComponent([plugin]);
expect(screen.getByTestId('attributeOwnersSourceManagedBy')).toHaveTextContent('Managed by SCIM');
const link = screen.getByTestId(`attributeOwnersSourceLink-${plugin.id}`);
expect(link).toHaveAttribute('href', `/admin_console/plugins/plugin_${plugin.id}`);
expect(link).toHaveTextContent('SCIM settings');
});
it('falls back to the plugin ID with no link when the plugin is not installed', () => {
renderComponent([plugin], {installed: false});
expect(screen.getByTestId('attributeOwnersSourceManagedBy')).toHaveTextContent(`Managed by ${plugin.id}`);
expect(screen.queryByTestId(`attributeOwnersSourceLink-${plugin.id}`)).not.toBeInTheDocument();
});
it('shows no link until the plugin inventory has loaded', () => {
renderComponent([plugin], {pluginInventoryLoaded: false});
expect(screen.queryByTestId(`attributeOwnersSourceLink-${plugin.id}`)).not.toBeInTheDocument();
});
it('names a service owner by its ID with no link', () => {
renderComponent([service]);
expect(screen.getByTestId('attributeOwnersSourceManagedBy')).toHaveTextContent('Managed by svc-sync');
expect(screen.queryByRole('link')).not.toBeInTheDocument();
});
it('names every owner in order and links only the installed plugin', () => {
renderComponent([plugin, service]);
expect(screen.getByTestId('attributeOwnersSourceManagedBy')).toHaveTextContent('Managed by SCIM and svc-sync');
expect(screen.getAllByRole('link')).toHaveLength(1);
});
it('shows owner scopes on the Managed by line but not in the helper text', () => {
renderComponent([{...plugin, scopes: ['local']}]);
expect(screen.getByTestId('attributeOwnersSourceManagedBy')).toHaveTextContent('Managed by SCIM: local');
expect(screen.getByTestId('attributeOwnersSourceHelperText')).toHaveTextContent('Values for users are set by SCIM.');
});
it('names the owners in helper text that differs from the plugin-created text', () => {
renderComponent([plugin]);
const helperText = screen.getByTestId('attributeOwnersSourceHelperText');
expect(helperText).toHaveTextContent('Values for users are set by SCIM.');
expect(helperText).not.toHaveTextContent('read-only');
});
it('makes no Client4 calls', () => {
const getPluginStatuses = jest.spyOn(Client4, 'getPluginStatuses');
const patchPropertyField = jest.spyOn(Client4, 'patchPropertyField');
renderComponent([plugin]);
expect(getPluginStatuses).not.toHaveBeenCalled();
expect(patchPropertyField).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type JSX} from 'react';
import {defineMessages, FormattedMessage, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import {Link} from 'react-router-dom';
import {OpenInNewIcon, PowerPlugOutlineIcon} from '@mattermost/compass-icons/components';
import type {PropertyFieldOwner} from '@mattermost/types/properties_user';
import {getPluginDisplayName} from 'selectors/plugins';
import {useInstalledPluginIds} from 'components/common/hooks/use_field_orphaned';
import type {GlobalState} from 'types/store';
import './attribute_plugin_source.scss';
import {useOwnersLabel} from '../use_owners_label';
type Props = {
owners: PropertyFieldOwner[];
// Installed-plugin detection reads as "nothing installed" until the fetch settles.
pluginInventoryLoaded: boolean;
};
function OwnerSettingsLink({pluginId}: {pluginId: string}): JSX.Element {
const pluginName = useSelector((state: GlobalState) => getPluginDisplayName(state, pluginId));
return (
<Link
to={`/admin_console/plugins/plugin_${pluginId}`}
className='AttributePluginSource__link'
data-testid={`attributeOwnersSourceLink-${pluginId}`}
>
<FormattedMessage
{...messages.settingsLink}
values={{pluginName}}
/>
<OpenInNewIcon
size={14}
aria-hidden={true}
/>
</Link>
);
}
function AttributeOwnersSource({owners, pluginInventoryLoaded}: Props): JSX.Element {
const {formatMessage} = useIntl();
const ownersLabel = useOwnersLabel(owners);
const scopedOwnersLabel = useOwnersLabel(owners, {withScopes: true});
const installedPluginIds = useInstalledPluginIds();
return (
<div
className='AttributePluginSource'
data-testid='attributeOwnersSource'
>
<div className='AttributePluginSource__managedBy'>
<PowerPlugOutlineIcon
size={16}
aria-hidden={true}
/>
<span data-testid='attributeOwnersSourceManagedBy'>
{formatMessage(messages.managedBy, {owners: scopedOwnersLabel})}
</span>
{pluginInventoryLoaded && owners.map((owner) => (
owner.type === 'plugin' && installedPluginIds.has(owner.id) && (
<OwnerSettingsLink
key={owner.id}
pluginId={owner.id}
/>
)
))}
</div>
<p
className='AttributePluginSource__helperText'
data-testid='attributeOwnersSourceHelperText'
>
{formatMessage(messages.helperText, {owners: ownersLabel})}
</p>
</div>
);
}
export default AttributeOwnersSource;
const messages = defineMessages({
managedBy: {
id: 'admin.global_attributes.attribute_details.owners_source.managed_by',
defaultMessage: 'Managed by {owners}',
},
settingsLink: {
id: 'admin.global_attributes.attribute_details.owners_source.settings_link',
defaultMessage: '{pluginName} settings',
},
helperText: {
id: 'admin.global_attributes.attribute_details.owners_source.helper_text',
defaultMessage: 'Values for users are set by {owners}. Unique name and type are locked; display name, options, and Profile display can still be changed here.',
},
});
@@ -59,6 +59,20 @@ describe('GlobalAttributeDeleteModal', () => {
expect(screen.getByText(/was created by the plugin "unknown"/i)).toBeInTheDocument();
});
it('names a single uninstalled owner plugin', () => {
renderModal({isOrphaned: true, ownerPluginIds: ['com.acme.scim']});
expect(screen.getByText(/was managed by com\.acme\.scim, which is no longer installed/i)).toBeInTheDocument();
expect(screen.queryByText(/was created by the plugin/i)).not.toBeInTheDocument();
expect(screen.getByText(/permanently remove its definition/i)).toBeInTheDocument();
});
it('names every uninstalled owner plugin', () => {
renderModal({isOrphaned: true, ownerPluginIds: ['com.acme.scim', 'com.acme.sync']});
expect(screen.getByText(/was managed by com\.acme\.scim and com\.acme\.sync, which are no longer installed/i)).toBeInTheDocument();
});
it('says nothing about plugins for an ordinary attribute', () => {
renderModal();
@@ -17,6 +17,7 @@ type Props = {
onExited: () => void;
isOrphaned?: boolean;
sourcePluginId?: string;
ownerPluginIds?: string[];
};
// GenericModal only renders a Cancel button when handleCancel is supplied, and
@@ -34,7 +35,8 @@ const noop = () => {};
* Pass `orphan` when the field's source plugin is no longer installed, so the
* confirmation can explain where the leftover attribute came from. It is an
* object rather than a bare flag so there is no way to declare a field orphaned
* without supplying the plugin it came from.
* without supplying the plugin it came from. For an attribute whose owners are
* all uninstalled, pass `{ownerPluginIds}` instead to name those owners.
*
* `onExited` runs once the modal has finished closing, whether it was confirmed
* or cancelled. ModalController composes it with its own close handling, so the
@@ -45,7 +47,7 @@ const noop = () => {};
export const useGlobalAttributeFieldDelete = () => {
const dispatch = useDispatch();
return (name: string, onConfirm: () => void, orphan?: {sourcePluginId?: string}, onExited?: () => void) => {
return (name: string, onConfirm: () => void, orphan?: {sourcePluginId?: string} | {ownerPluginIds: string[]}, onExited?: () => void) => {
dispatch(openModal({
modalId: ModalIdentifiers.GLOBAL_ATTRIBUTE_FIELD_DELETE,
dialogType: GlobalAttributeDeleteModal,
@@ -53,15 +55,16 @@ export const useGlobalAttributeFieldDelete = () => {
name,
onConfirm,
isOrphaned: Boolean(orphan),
sourcePluginId: orphan?.sourcePluginId,
sourcePluginId: orphan && 'sourcePluginId' in orphan ? orphan.sourcePluginId : undefined,
ownerPluginIds: orphan && 'ownerPluginIds' in orphan ? orphan.ownerPluginIds : undefined,
onExited,
},
}));
};
};
function GlobalAttributeDeleteModal({name, onConfirm, onExited, isOrphaned = false, sourcePluginId}: Props) {
const {formatMessage} = useIntl();
function GlobalAttributeDeleteModal({name, onConfirm, onExited, isOrphaned = false, sourcePluginId, ownerPluginIds}: Props) {
const {formatMessage, formatList} = useIntl();
const title = formatMessage({
id: 'admin.global_attributes.confirm.delete.title',
@@ -84,9 +87,20 @@ function GlobalAttributeDeleteModal({name, onConfirm, onExited, isOrphaned = fal
compassDesign={true}
>
{/* An uninstalled plugin leaves no manifest behind to resolve a display
name from, so the raw source_plugin_id is the only identifier we can
honestly show here. */}
{isOrphaned && (
name from, so the raw plugin ID (source_plugin_id or an owner's ID) is the
only identifier we can honestly show here. */}
{isOrphaned && ownerPluginIds?.length ? (
<p>
<FormattedMessage
id='admin.global_attributes.confirm.delete.orphaned_owners_body'
defaultMessage='This attribute was managed by {pluginIds}, which {count, plural, one {is} other {are}} no longer installed.'
values={{
pluginIds: formatList(ownerPluginIds, {type: 'conjunction'}),
count: ownerPluginIds.length,
}}
/>
</p>
) : isOrphaned && (
<p>
<FormattedMessage
id='admin.global_attributes.confirm.delete.orphaned_body'
@@ -7,6 +7,7 @@ import React from 'react';
import {ClientError} from '@mattermost/client';
import {ChevronDownCircleOutlineIcon, FormatListBulletedIcon, LinkVariantIcon, MenuVariantIcon, PoundIcon, PowerPlugOutlineIcon, SitemapIcon, SortAscendingIcon, SyncIcon} from '@mattermost/compass-icons/components';
import type {PropertyField} from '@mattermost/types/properties';
import type {PropertyFieldOwner} from '@mattermost/types/properties_user';
import type {DeepPartial} from '@mattermost/types/utilities';
import {Client4} from 'mattermost-redux/client';
@@ -1050,6 +1051,117 @@ describe('GlobalAttributesTable', () => {
getPluginStatuses.mockRestore();
});
describe('owned attributes', () => {
const mockTemplateWithUserField = (userFieldAttrs?: PropertyField['attrs']) => {
const template = makeField({id: 'template-1', name: 'department'});
getPropertyFields.mockImplementation((_group, objectType, _targetType, _targetId, opts) => {
if (opts?.cursorId) {
return Promise.resolve([]);
}
if (objectType === 'template') {
return Promise.resolve([template]);
}
if (objectType === 'user') {
return Promise.resolve([makeField({
id: 'user-1',
object_type: 'user',
linked_field_id: template.id,
attrs: userFieldAttrs,
})]);
}
return Promise.resolve([]);
});
};
it('names a plugin owner from the admin plugin statuses on a template row', async () => {
const getPluginStatuses = jest.spyOn(Client4, 'getPluginStatuses').mockResolvedValue([{
plugin_id: 'com.mattermost.scim',
name: 'SCIM',
description: '',
version: '1.0.0',
cluster_id: '',
plugin_path: '',
state: 1,
}]);
mockTemplateWithUserField({owners: [{id: 'com.mattermost.scim', type: 'plugin', scopes: []}]});
renderWithContext(<GlobalAttributesTable/>, getAllScopesState());
await waitFor(() => {
expect(screen.getByTestId('global-attribute-source')).toHaveTextContent('Managed by SCIM');
});
expect(screen.getByTestId('global-attribute-source').querySelector('svg')).toBeInTheDocument();
getPluginStatuses.mockRestore();
});
it('names the owners of a standalone user attribute', async () => {
getPropertyFields.mockImplementation((_group, objectType, _targetType, _targetId, opts) => {
if (opts?.cursorId || objectType !== 'user') {
return Promise.resolve([]);
}
return Promise.resolve([makeField({
id: 'user-1',
name: 'standalone',
object_type: 'user',
attrs: {owners: [{id: 'svc-sync', type: 'service', scopes: []}]},
})]);
});
renderWithContext(<GlobalAttributesTable/>, getAllScopesState());
await waitFor(() => {
expect(screen.getByTestId('global-attribute-source')).toHaveTextContent('Managed by svc-sync');
});
});
it('shows the owner alongside an LDAP/SAML link on a standalone user attribute, without scopes', async () => {
getPropertyFields.mockImplementation((_group, objectType, _targetType, _targetId, opts) => {
if (opts?.cursorId || objectType !== 'user') {
return Promise.resolve([]);
}
return Promise.resolve([makeField({
id: 'user-1',
name: 'standalone',
object_type: 'user',
attrs: {saml: 'dept', owners: [{id: 'svc-sync', type: 'service', scopes: ['local']}]},
})]);
});
renderWithContext(<GlobalAttributesTable/>, getAllScopesState());
await waitFor(() => {
expect(screen.getByTestId('global-attribute-source')).toHaveTextContent('Managed by svc-sync');
});
expect(screen.getByTestId('global-attribute-source')).toHaveTextContent('SAML');
expect(screen.getByTestId('global-attribute-source')).not.toHaveTextContent('local');
});
it('lists every owner in stored order', async () => {
mockTemplateWithUserField({owners: [
{id: 'svc-b', type: 'service', scopes: []},
{id: 'svc-a', type: 'service', scopes: []},
]});
renderWithContext(<GlobalAttributesTable/>, getAllScopesState());
await waitFor(() => {
expect(screen.getByTestId('global-attribute-source')).toHaveTextContent('Managed by svc-b and svc-a');
});
});
it('still reads "Managed here" when the linked user field has no owners', async () => {
mockTemplateWithUserField({owners: []});
renderWithContext(<GlobalAttributesTable/>, getAllScopesState());
await waitFor(() => {
expect(screen.getByTestId('global-attribute-applies-to')).toHaveTextContent('Users');
});
expect(screen.getByTestId('global-attribute-source')).toHaveTextContent('Managed here');
});
});
it('shows AD/LDAP when attrs.ldap is set', async () => {
getPropertyFields.mockResolvedValueOnce([makeField({attrs: {ldap: 'someAttribute'}})]).mockResolvedValue([]);
@@ -1561,6 +1673,134 @@ describe('GlobalAttributesTable', () => {
getPluginStatuses.mockRestore();
});
describe('owned attributes', () => {
const owner = (id: string, type: PropertyFieldOwner['type'] = 'plugin'): PropertyFieldOwner => ({id, type, scopes: []});
function mockOwnedTemplate(owners: PropertyFieldOwner[]) {
const template = makeField({id: 'template-1', name: 'department'});
getPropertyFields.mockImplementation((_group, objectType, _targetType, _targetId, opts) => {
if (opts?.cursorId) {
return Promise.resolve([]);
}
if (objectType === 'template') {
return Promise.resolve([template]);
}
if (objectType === 'user') {
return Promise.resolve([makeField({id: 'user-1', object_type: 'user', linked_field_id: template.id, attrs: {owners}})]);
}
return Promise.resolve([]);
});
}
function renderOwned(installedPluginId?: string) {
const state = getAllScopesState();
if (installedPluginId) {
state.entities!.admin = {pluginStatuses: {[installedPluginId]: {id: installedPluginId}}} as EntitiesPartial['admin'];
}
renderWithContext(
<>
<GlobalAttributesTable/>
<ModalController/>
</>,
state,
);
}
async function clickActionsAndFindDelete(fieldId: string) {
await userEvent.click(await screen.findByTestId(`global-attribute-actions-${fieldId}`));
return (await screen.findAllByRole('menuitem')).find((el) => el.textContent?.includes('Delete attribute'))!;
}
// Plugin owners trigger the inventory fetch, which remounts an open menu.
async function findDeleteOnSettledTemplate() {
await waitFor(() => expect(screen.getByTestId('global-attribute-source')).toHaveTextContent('Managed by'));
await waitFor(() => expect(Client4.getPluginStatuses).toHaveBeenCalled());
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return clickActionsAndFindDelete('template-1');
}
async function expectInertDisabledDelete(del: HTMLElement, reason: string) {
expect(del).toHaveAttribute('aria-disabled', 'true');
expect(del).toHaveTextContent(reason);
await userEvent.click(del, {pointerEventsCheck: 0});
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(deletePropertyField).not.toHaveBeenCalled();
}
it('disables Delete with the owner named on a template row whose owner plugin is installed', async () => {
mockOwnedTemplate([owner('com.acme.scim')]);
renderOwned('com.acme.scim');
const del = await findDeleteOnSettledTemplate();
await expectInertDisabledDelete(del, 'Managed by com.acme.scim');
});
it('disables Delete on a standalone user attribute owned by a service', async () => {
getPropertyFields.mockImplementation((_group, objectType, _targetType, _targetId, opts) => {
if (opts?.cursorId || objectType !== 'user') {
return Promise.resolve([]);
}
return Promise.resolve([makeField({id: 'user-1', name: 'standalone', object_type: 'user', attrs: {owners: [owner('svc-sync', 'service')]}})]);
});
renderOwned();
await expectInertDisabledDelete(await clickActionsAndFindDelete('user-1'), 'Managed by svc-sync');
});
it('enables Delete once every owner plugin is uninstalled and confirms naming them', async () => {
deletePropertyField.mockResolvedValue({status: 'OK'});
mockOwnedTemplate([owner('com.acme.one'), owner('com.acme.two')]);
renderOwned();
const del = await findDeleteOnSettledTemplate();
expect(del).not.toHaveAttribute('aria-disabled', 'true');
await userEvent.click(del);
expect(await screen.findByText(/no longer installed/i)).toHaveTextContent('com.acme.one and com.acme.two');
await userEvent.click(await screen.findByRole('button', {name: /^delete$/i}));
await waitFor(() => {
expect(deletePropertyField).toHaveBeenCalledWith('access_control', 'template', 'template-1');
});
});
it('keeps Delete disabled when an uninstalled plugin shares ownership with a service', async () => {
mockOwnedTemplate([owner('com.acme.gone'), owner('svc-sync', 'service')]);
renderOwned();
const del = await findDeleteOnSettledTemplate();
await expectInertDisabledDelete(del, 'Managed by');
});
it('keeps Delete disabled on an owned row while the plugin inventory is still in flight', async () => {
const getPluginStatuses = jest.spyOn(Client4, 'getPluginStatuses').mockImplementation(() => new Promise(() => {}));
mockOwnedTemplate([owner('com.acme.gone')]);
renderOwned();
await waitFor(() => expect(screen.getByTestId('global-attribute-source')).toHaveTextContent('Managed by com.acme.gone'));
expect(await clickActionsAndFindDelete('template-1')).toHaveAttribute('aria-disabled', 'true');
getPluginStatuses.mockRestore();
});
it('keeps Delete disabled on a template row while the user fields have not loaded', async () => {
getPropertyFields.mockImplementation((_group, objectType, _targetType, _targetId, opts) => {
if (opts?.cursorId) {
return Promise.resolve([]);
}
if (objectType === 'template') {
return Promise.resolve([makeField({id: 'template-1', name: 'department'})]);
}
return objectType === 'user' ? new Promise(() => {}) : Promise.resolve([]);
});
renderOwned();
expect(await clickActionsAndFindDelete('template-1')).toHaveAttribute('aria-disabled', 'true');
});
});
it('omits the plugin explanation for an ordinary attribute', async () => {
renderTable([makeField({attrs: {display_name: 'Department'}})]);
@@ -15,6 +15,7 @@ import type IconProps from '@mattermost/compass-icons/components/props';
import {WithTooltip} from '@mattermost/shared/components/tooltip';
import type {FieldType, PropertyField, PropertyFieldOption} from '@mattermost/types/properties';
import {valueRefersToOptions} from '@mattermost/types/properties';
import type {PropertyFieldOwner} from '@mattermost/types/properties_user';
import PropertyTypes from 'mattermost-redux/action_types/properties';
import {fetchPropertyFields} from 'mattermost-redux/actions/properties';
@@ -33,13 +34,14 @@ import {
CLASSIFICATIONS_TEMPLATE_OBJECT_TYPE,
} from 'components/admin_console/classification_markings/utils';
import AlertBanner from 'components/alert_banner';
import {useIsFieldOrphaned, usePluginInventoryLoaded} from 'components/common/hooks/use_field_orphaned';
import {useInstalledPluginIds, useIsFieldOrphaned, usePluginInventoryLoaded} from 'components/common/hooks/use_field_orphaned';
import LoadingScreen from 'components/loading_screen';
import * as Menu from 'components/menu';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import {getHistory} from 'utils/browser_history';
import {LicenseSkus} from 'utils/constants';
import {allOwnersAreUninstalledPlugins} from 'utils/properties';
import type {GlobalState} from 'types/store';
@@ -53,6 +55,7 @@ import {useGlobalAttributeFieldDelete} from './global_attribute_delete_modal';
import type {NameConflict} from './name_conflict';
import {findNameConflict, nameConflictText} from './name_conflict';
import useAllowedResourceTypes from './use_allowed_resource_types';
import {getFieldOwners, NO_OWNERS, useOwnersLabel} from './use_owners_label';
import {appliedResourceTypesByTemplateId, deleteAttributeField} from './utils';
import {it} from '../admin_definition_helpers';
@@ -164,37 +167,56 @@ type ClassificationAwareCellProps = {
isClassificationRow: boolean;
};
function SourceCell({field, isClassificationRow}: ClassificationAwareCellProps) {
function SourceCell({field, isClassificationRow, owners}: ClassificationAwareCellProps & {owners: PropertyFieldOwner[]}) {
const ownersLabel = useOwnersLabel(owners);
const pluginId = field.attrs?.source_plugin_id as string | undefined;
const pluginDisplayName = useSelector((state: GlobalState) => getPluginDisplayName(state, pluginId));
const kind = getSourceKind(field);
const syncLabel = SYNC_LABELS[kind];
const SyncKindIcon = getSourceIcon(kind);
let content: React.ReactNode;
if (isClassificationRow) {
// Not a plugin, LDAP, or SAML source, so it gets no source icon.
content = <FormattedMessage {...sourceLabels.classificationMarkings}/>;
} else if (kind === 'plugin') {
content = pluginDisplayName;
} else if (kind === 'ldap_and_saml') {
content = <FormattedMessage {...sourceLabels.ldapAndSaml}/>;
} else if (kind === 'ldap') {
content = <FormattedMessage {...sourceLabels.ldap}/>;
} else if (kind === 'saml') {
content = <FormattedMessage {...sourceLabels.saml}/>;
content = (
<>
<PowerPlugOutlineIcon size={16}/>
{pluginDisplayName}
</>
);
} else if (owners.length > 0 || syncLabel) {
// An owned field can also be synced from LDAP/SAML, so both are shown.
content = (
<>
{owners.length > 0 && (
<span className='GlobalAttributesTable__source'>
<PowerPlugOutlineIcon size={16}/>
<FormattedMessage
{...sourceLabels.ownedBy}
values={{owners: ownersLabel}}
/>
</span>
)}
{syncLabel && (
<span className='GlobalAttributesTable__source'>
{SyncKindIcon && <SyncKindIcon size={16}/>}
<FormattedMessage {...syncLabel}/>
</span>
)}
</>
);
} else {
content = <FormattedMessage {...sourceLabels.managed}/>;
}
// The classification row identifies its source via text alone ("Classification
// Markings"), not a plugin/ldap/saml/managed kind, so it doesn't get one of those icons.
const Icon = isClassificationRow ? undefined : getSourceIcon(kind);
return (
<span
className='GlobalAttributesTable__source'
data-testid='global-attribute-source'
>
{Icon && <Icon size={16}/>}
{content}
</span>
);
@@ -306,13 +328,15 @@ function AttributeCell({field, isClassificationRow, nameConflict}: AttributeCell
type ActionsCellProps = ClassificationAwareCellProps & {
isMobileView: boolean;
owners: PropertyFieldOwner[];
resourcesLoaded: boolean;
pluginInventoryLoaded: boolean;
disabled: boolean;
onDeleteError: (message: string | null) => void;
onDeleteModalExited: () => void;
};
function ActionsCell({field, isClassificationRow, isMobileView, pluginInventoryLoaded, disabled, onDeleteError, onDeleteModalExited}: ActionsCellProps) {
function ActionsCell({field, isClassificationRow, isMobileView, owners, resourcesLoaded, pluginInventoryLoaded, disabled, onDeleteError, onDeleteModalExited}: ActionsCellProps) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const promptDelete = useGlobalAttributeFieldDelete();
@@ -329,11 +353,28 @@ function ActionsCell({field, isClassificationRow, isMobileView, pluginInventoryL
const isOrphaned = pluginInventoryLoaded && fieldLooksOrphaned;
const isPluginManaged = isPluginOwned && !isOrphaned;
// Deleting an attribute deletes its owned Users field, so an owned attribute
// is deletable only once every owner is an uninstalled plugin. Until the
// Users fields and plugin inventory settle, ownership is not known yet.
const installedPluginIds = useInstalledPluginIds();
const ownersLabel = useOwnersLabel(owners);
const isOwned = owners.length > 0;
const ownersGone = pluginInventoryLoaded && allOwnersAreUninstalledPlugins(owners, installedPluginIds);
const isResolving = !resourcesLoaded && field.object_type === GLOBAL_ATTRIBUTES_OBJECT_TYPE;
const isOwnerManaged = isOwned && !ownersGone;
// Page-level read-only (non-sysadmin via schema isDisabled) and plugin-owned
// fields both open the details page as view-only; Delete stays off for those
// plus still-installed plugin-managed rows.
// plus still-installed plugin-managed or owner-managed rows.
const isViewOnly = disabled || isPluginOwned;
const isDeleteDisabled = disabled || isPluginManaged;
const isDeleteDisabled = disabled || isPluginManaged || isResolving || isOwnerManaged;
let orphanInfo: {ownerPluginIds: string[]} | {sourcePluginId?: string} | undefined;
if (isOwned) {
orphanInfo = {ownerPluginIds: owners.map((owner) => owner.id)};
} else if (isOrphaned) {
orphanInfo = {sourcePluginId: field.attrs?.source_plugin_id as string | undefined};
}
const handleConfirmed = useCallback(async () => {
onDeleteError(null);
@@ -405,13 +446,21 @@ function ActionsCell({field, isClassificationRow, isMobileView, pluginInventoryL
onClick={isDeleteDisabled ? undefined : () => promptDelete(
getDisplayName(field),
handleConfirmed,
isOrphaned ? {sourcePluginId: field.attrs?.source_plugin_id as string | undefined} : undefined,
orphanInfo,
onDeleteModalExited,
)}
labels={(
<>
<span><FormattedMessage {...actionsLabels.delete}/></span>
{isPluginManaged && <span><FormattedMessage {...actionsLabels.pluginManaged}/></span>}
{!isPluginManaged && isOwnerManaged && (
<span>
<FormattedMessage
{...actionsLabels.ownerManaged}
values={{owners: ownersLabel}}
/>
</span>
)}
</>
)}
/>
@@ -476,6 +525,18 @@ export default function GlobalAttributesTable({searchQuery = '', disabled = fals
]),
[channelLinkedFields, suppressedScopes, postLinkedFields, userLinkedFields],
);
const ownersByTemplateId = useMemo(() => {
const byTemplate: Record<string, PropertyFieldOwner[]> = {};
if (suppressedScopes.has('user')) {
return byTemplate;
}
for (const field of userLinkedFields) {
if (field.linked_field_id && field.delete_at === 0) {
byTemplate[field.linked_field_id] = getFieldOwners(field);
}
}
return byTemplate;
}, [suppressedScopes, userLinkedFields]);
const unlinkedFields = useSelector((state: GlobalState) =>
getUnlinkedSystemFieldsForGroup(state, groupId),
);
@@ -566,6 +627,13 @@ export default function GlobalAttributesTable({searchQuery = '', disabled = fals
[classificationMarkingsReachable, fields, groupId, unlinkedFields, suppressedScopes, resourcesLoaded],
);
const ownersFor = useCallback((field: PropertyField): PropertyFieldOwner[] => {
if (field.object_type === 'user') {
return getFieldOwners(field);
}
return ownersByTemplateId[field.id] ?? NO_OWNERS;
}, [ownersByTemplateId]);
// Attribute Management hides a template's linked children, so the only trace
// of one is the template's own row -- which says nothing about the name that
// child carries. Two attributes an admin reads as one (a `clearance` user
@@ -595,11 +663,13 @@ export default function GlobalAttributesTable({searchQuery = '', disabled = fals
return byFieldId;
}, [allRows, fields, resourcesLoaded, suppressedScopes, userLinkedFields]);
// The Source column resolves plugin-owned rows to a plugin display name, but
// server-only plugins are absent from the webapp manifest registry — their names
// live in the admin plugin statuses, which nothing else on this page loads.
// Fetched once, and only when a plugin-owned row is actually present.
const hasPluginOwnedFields = useMemo(() => allRows.some((field) => Boolean(field.attrs?.source_plugin_id)), [allRows]);
// The Source column resolves plugin-created and plugin-owned rows to a plugin
// display name, but server-only plugins are absent from the webapp manifest
// registry — their names live in the admin plugin statuses, which nothing else
// on this page loads. Fetched once, and only when such a row is actually present.
const hasPluginOwnedFields = useMemo(() => allRows.some((field) => (
Boolean(field.attrs?.source_plugin_id) || ownersFor(field).some((owner) => owner.type === 'plugin')
)), [allRows, ownersFor]);
// Whether the plugin inventory is known yet. This gates the orphan check
// rather than the Source column, which degrades harmlessly to the plugin ID:
@@ -729,6 +799,7 @@ export default function GlobalAttributesTable({searchQuery = '', disabled = fals
<SourceCell
field={row.original}
isClassificationRow={isClassificationRow(row.original)}
owners={ownersFor(row.original)}
/>
),
enableHiding: false,
@@ -754,6 +825,8 @@ export default function GlobalAttributesTable({searchQuery = '', disabled = fals
field={row.original}
isClassificationRow={isClassificationRow(row.original)}
isMobileView={isMobileView}
owners={ownersFor(row.original)}
resourcesLoaded={resourcesLoaded}
pluginInventoryLoaded={pluginInventoryLoadedRef.current}
disabled={disabled}
onDeleteError={setDeleteError}
@@ -764,7 +837,7 @@ export default function GlobalAttributesTable({searchQuery = '', disabled = fals
enableHiding: false,
}),
];
}, [appliesToByTemplateId, groupId, classificationMarkingsReachable, isMobileView, handleDeleteModalExited, nameConflictsByFieldId, resourcesLoaded, disabled]);
}, [appliesToByTemplateId, groupId, classificationMarkingsReachable, isMobileView, handleDeleteModalExited, nameConflictsByFieldId, resourcesLoaded, ownersFor, disabled]);
const table = useReactTable<PropertyField>({
data: rows,
@@ -884,12 +957,19 @@ const sourceLabels = defineMessages({
ldap: {id: 'admin.global_attributes.table.source.ldap', defaultMessage: 'AD/LDAP'},
saml: {id: 'admin.global_attributes.table.source.saml', defaultMessage: 'SAML'},
managed: {id: 'admin.global_attributes.table.source.managed', defaultMessage: 'Managed here'},
ownedBy: {id: 'admin.global_attributes.table.source.owned_by', defaultMessage: 'Managed by {owners}'},
classificationMarkings: {
id: 'admin.global_attributes.table.source.classification_markings',
defaultMessage: 'Classification Markings',
},
});
const SYNC_LABELS: Partial<Record<SourceKind, MessageDescriptor>> = {
ldap_and_saml: sourceLabels.ldapAndSaml,
ldap: sourceLabels.ldap,
saml: sourceLabels.saml,
};
const optionsLabels = defineMessages({
freeText: {id: 'admin.global_attributes.table.options.free_text', defaultMessage: 'Free Text'},
count: {id: 'admin.global_attributes.table.options.count', defaultMessage: '{count, plural, one {# option} other {# options}}'},
@@ -902,6 +982,7 @@ export const actionsLabels = defineMessages({
view: {id: 'admin.global_attributes.table.actions.view', defaultMessage: 'View attribute'},
delete: {id: 'admin.global_attributes.table.actions.delete', defaultMessage: 'Delete attribute'},
pluginManaged: {id: 'admin.global_attributes.table.actions.plugin_managed', defaultMessage: 'Plugin-managed'},
ownerManaged: {id: 'admin.global_attributes.table.actions.owner_managed', defaultMessage: 'Managed by {owners}'},
deleteErrorHasDependents: {
id: 'admin.global_attributes.confirm.delete.error.has_dependents',
defaultMessage: "This attribute can't be deleted because other attributes are still linked to it. Remove those links first, then try again.",
@@ -0,0 +1,67 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PropertyFieldOwner} from '@mattermost/types/properties_user';
import {renderHookWithContext} from 'tests/react_testing_utils';
import {getFieldOwners, useOwnersLabel} from './use_owners_label';
const owner = (id: string, type: PropertyFieldOwner['type'] = 'plugin'): PropertyFieldOwner => ({id, type, scopes: []});
describe('useOwnersLabel', () => {
const label = (owners: PropertyFieldOwner[], state = {}) => {
return renderHookWithContext(() => useOwnersLabel(owners), state).result.current;
};
it('uses the webapp manifest name for a plugin owner', () => {
const state = {plugins: {plugins: {scim: {name: 'SCIM'}}}};
expect(label([owner('scim')], state)).toBe('SCIM');
});
it('uses the admin plugin status name for a server-only plugin owner', () => {
const state = {entities: {admin: {pluginStatuses: {scim: {name: 'SCIM Sync'}}}}};
expect(label([owner('scim')], state)).toBe('SCIM Sync');
});
it('falls back to the plugin ID when no name is known', () => {
expect(label([owner('scim')])).toBe('scim');
});
it.each(['service', 'role', 'user'] as const)('uses the ID of a %s owner', (type) => {
expect(label([owner('svc-sync', type)])).toBe('svc-sync');
});
it('lists every owner in stored order', () => {
const state = {plugins: {plugins: {scim: {name: 'SCIM'}}}};
expect(label([owner('scim'), owner('svc-sync', 'service')], state)).toBe('SCIM and svc-sync');
});
it('appends scopes only when asked', () => {
const state = {plugins: {plugins: {scim: {name: 'SCIM'}}}};
const owners = [{...owner('scim'), scopes: ['local', 'okta']}, owner('svc-sync', 'service')];
expect(label(owners, state)).toBe('SCIM and svc-sync');
expect(renderHookWithContext(() => useOwnersLabel(owners, {withScopes: true}), state).result.current).toBe('SCIM: local, okta and svc-sync');
});
it('is empty without owners', () => {
expect(label([])).toBe('');
});
});
describe('getFieldOwners', () => {
it('returns an empty list when attrs or owners are missing or empty', () => {
expect(getFieldOwners({attrs: undefined})).toEqual([]);
expect(getFieldOwners({attrs: {}})).toEqual([]);
expect(getFieldOwners({attrs: {owners: []}})).toEqual([]);
});
it('returns the same empty array each time', () => {
expect(getFieldOwners({attrs: {}})).toBe(getFieldOwners({attrs: {owners: []}}));
});
it('returns the owners when present', () => {
const owners = [owner('scim')];
expect(getFieldOwners({attrs: {owners}})).toBe(owners);
});
});
@@ -0,0 +1,39 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {defineMessages, useIntl} from 'react-intl';
import {shallowEqual, useSelector} from 'react-redux';
import type {PropertyField} from '@mattermost/types/properties';
import type {PropertyFieldOwner} from '@mattermost/types/properties_user';
import {getPluginDisplayName} from 'selectors/plugins';
import type {GlobalState} from 'types/store';
export const NO_OWNERS: PropertyFieldOwner[] = [];
export function getFieldOwners(field: Pick<PropertyField, 'attrs'>): PropertyFieldOwner[] {
const owners = field.attrs?.owners;
return Array.isArray(owners) && owners.length > 0 ? owners as PropertyFieldOwner[] : NO_OWNERS;
}
const messages = defineMessages({
scopedOwner: {id: 'admin.global_attributes.owners.scoped', defaultMessage: '{owner}: {scopes}'},
});
// The owners' display names, e.g. "SCIM and svc-sync", or with scopes "SCIM: local and svc-sync".
// Empty when there are no owners.
export function useOwnersLabel(owners: PropertyFieldOwner[], {withScopes = false} = {}): string {
const {formatList, formatMessage} = useIntl();
const names = useSelector((state: GlobalState) => owners.map((owner) => {
return owner.type === 'plugin' ? getPluginDisplayName(state, owner.id) : owner.id;
}), shallowEqual);
const labels = withScopes ? names.map((name, i) => {
const scopes = owners[i].scopes;
return scopes?.length ? formatMessage(messages.scopedOwner, {owner: name, scopes: scopes.join(', ')}) : name;
}) : names;
return formatList(labels, {type: 'conjunction'});
}
@@ -449,6 +449,40 @@ describe('global_attributes/utils', () => {
expect(patchPropertyField).toHaveBeenCalledWith('access_control', 'user', 'field-id', expect.anything());
});
it('sends resourceAttrs next to the definition attrs and permissionValues as permission_values', async () => {
const patchPropertyField = jest.spyOn(Client4, 'patchPropertyField').mockResolvedValue({} as PropertyField);
await updateAttributeField('user', 'field-id', {
type: 'text',
displayName: 'Cost center',
options: [],
ldapAttr: '',
samlAttr: '',
resourceAttrs: {visibility: 'hidden', managed: 'admin'},
permissionValues: 'sysadmin',
});
expect(patchPropertyField).toHaveBeenCalledWith('access_control', 'user', 'field-id', {
type: 'text',
attrs: expect.objectContaining({display_name: 'Cost center', visibility: 'hidden', managed: 'admin'}),
permission_values: 'sysadmin',
});
});
it('sends no permission_values without permissionValues', async () => {
const patchPropertyField = jest.spyOn(Client4, 'patchPropertyField').mockResolvedValue({} as PropertyField);
await updateAttributeField('user', 'field-id', {
type: 'text',
displayName: 'Cost center',
options: [],
ldapAttr: '',
samlAttr: '',
});
expect(patchPropertyField.mock.calls[0][3]).not.toHaveProperty('permission_values');
});
it('preserves option metadata such as color when PATCHing a standalone channel select', async () => {
const patchPropertyField = jest.spyOn(Client4, 'patchPropertyField').mockResolvedValue({} as PropertyField);
@@ -235,6 +235,8 @@ export type UpdateAttributeFieldPatch = {
options: PropertyFieldOption[];
ldapAttr: string;
samlAttr: string;
resourceAttrs?: Record<string, unknown>;
permissionValues?: PropertyPermissionLevel;
};
// Attrs are merge-patched (mergeAttrs=true on the server): ldap/saml send
@@ -258,7 +260,9 @@ export function updateAttributeField(
ldap: patch.ldapAttr || null,
saml: patch.samlAttr || null,
value_type: valueType || null,
...patch.resourceAttrs,
},
...(patch.permissionValues ? {permission_values: patch.permissionValues} : {}),
});
}
+48
View File
@@ -6103,6 +6103,10 @@
"defaultMessage": "This resource cannot be changed — this attribute applies to only one resource.",
"description": "Tooltip on the disabled row in an attribute's 'Applies to' list (System Console > Attribute Management > attribute details), explaining that the only resource type (e.g. Users) cannot be removed or changed because it is the attribute's sole one."
},
"admin.global_attributes.attribute_details.applies_to.owned_locked_tooltip": {
"defaultMessage": "This attribute is managed by {owners}.",
"description": "Tooltip on the locked Users row in an attribute's \"Applies to\" list (System Console > Attribute Management > attribute details), shown while integrations own the attribute so the Users resource cannot be removed. {owners} is a localized list of the integrations that own the attribute (for example \"SCIM and svc-sync\")."
},
"admin.global_attributes.attribute_details.applies_to.resource_type.channel": {
"defaultMessage": "Channels",
"description": "Name of the 'channel' resource type on the attribute detail page — used as a menu item in the add-resource picker and as the label of the corresponding row. Plural noun."
@@ -6491,6 +6495,18 @@
"defaultMessage": "Values must be unique.",
"description": "Inline validation error shown while editing or adding an option value on the attribute detail page, when the typed value duplicates an existing option."
},
"admin.global_attributes.attribute_details.owners_source.helper_text": {
"defaultMessage": "Values for users are set by {owners}. Unique name and type are locked; display name, options, and Profile display can still be changed here.",
"description": "Helper text under the \"Managed by\" note on the attribute details page in System Console > Attribute Management, for an attribute owned by integrations. Explains which fields are locked and which can still be edited. {owners} is a localized list of the integrations that own the attribute (for example \"SCIM and svc-sync\"). \"Profile display\" is the name of a setting on the same page."
},
"admin.global_attributes.attribute_details.owners_source.managed_by": {
"defaultMessage": "Managed by {owners}",
"description": "Note at the top of the attribute details page in System Console > Attribute Management, naming the integrations that own the attribute and set its user values. {owners} is a localized list of the integrations that own the attribute (for example \"SCIM and svc-sync\")."
},
"admin.global_attributes.attribute_details.owners_source.settings_link": {
"defaultMessage": "{pluginName} settings",
"description": "Link next to the \"Managed by\" note on the attribute details page in System Console > Attribute Management that opens the owning plugin's settings page. {pluginName} is the plugin's display name."
},
"admin.global_attributes.attribute_details.plugin_source.helper_text": {
"defaultMessage": "Name, type, and values are owned by the plugin and are read-only here.",
"description": "Helper text in the attribute details page in System Console > System Attributes > Manage Attributes for a plugin-owned attribute, explaining which fields are read-only."
@@ -6607,6 +6623,10 @@
"defaultMessage": "Type: {value}. Locked because a hierarchical attribute cannot be converted to another type.",
"description": "Screen-reader label for the disabled Type field on the attribute edit page in System Console > Attribute Management. {value} is the localized type name (for example 'Hierarchical'). Explains why a hierarchical attribute's type cannot be changed."
},
"admin.global_attributes.attribute_details.type.field_locked_owned_aria_label": {
"defaultMessage": "Type: {value}. Locked because this attribute is managed by {owners}.",
"description": "Screen-reader label for the locked Type field on the attribute details page in System Console > Attribute Management, for an attribute owned by integrations. {value} is the current type. {owners} is a localized list of the integrations that own the attribute (for example \"SCIM and svc-sync\")."
},
"admin.global_attributes.attribute_details.type.field_locked_plugin_aria_label": {
"defaultMessage": "Type: {value}. Locked because this attribute is managed by a plugin.",
"description": "Screen-reader label for the locked Type field in the attribute details page in System Console > System Attributes > Manage Attributes for an attribute a plugin manages. {value} is the current type."
@@ -6631,6 +6651,10 @@
"defaultMessage": "Type cannot be changed — a hierarchical attribute cannot be converted to another type. Create a new attribute of the type you need instead.",
"description": "Tooltip on the disabled Type field on the attribute edit page in System Console > Attribute Management, shown for hierarchical attributes. Explains why the type is locked and what the admin should do instead."
},
"admin.global_attributes.attribute_details.type.locked_owned_tooltip": {
"defaultMessage": "Type cannot be changed — this attribute is managed by {owners}.",
"description": "Tooltip on the locked Type field on the attribute details page in System Console > Attribute Management, for an attribute owned by integrations. {owners} is a localized list of the integrations that own the attribute (for example \"SCIM and svc-sync\")."
},
"admin.global_attributes.attribute_details.type.locked_plugin_orphaned_tooltip": {
"defaultMessage": "Type cannot be changed — this attribute was managed by a plugin that's no longer installed.",
"description": "Tooltip on the locked Type field in the attribute details page in System Console > System Attributes > Manage Attributes whose managing plugin is no longer installed."
@@ -6675,6 +6699,14 @@
"defaultMessage": "Name cannot be changed while this attribute applies to a resource. Remove and save first.",
"description": "Tooltip on the locked name field in the attribute details page in System Console > System Attributes > Manage Attributes while the attribute applies to a resource."
},
"admin.global_attributes.attribute_details.unique_name.locked_owned_aria_label": {
"defaultMessage": "Edit unique name. Locked because this attribute is managed by {owners}.",
"description": "Screen-reader label for the locked unique name field on the attribute details page in System Console > Attribute Management, for an attribute owned by integrations. {owners} is a localized list of the integrations that own the attribute (for example \"SCIM and svc-sync\")."
},
"admin.global_attributes.attribute_details.unique_name.locked_owned_tooltip": {
"defaultMessage": "Name cannot be changed — this attribute is managed by {owners}.",
"description": "Tooltip on the locked unique name field on the attribute details page in System Console > Attribute Management, for an attribute owned by integrations. {owners} is a localized list of the integrations that own the attribute (for example \"SCIM and svc-sync\")."
},
"admin.global_attributes.attribute_details.unique_name.locked_plugin_aria_label": {
"defaultMessage": "Edit unique name. Locked because this attribute is managed by a plugin.",
"description": "Screen-reader label for the locked name field in the attribute details page in System Console > System Attributes > Manage Attributes for an attribute a plugin manages."
@@ -6771,6 +6803,10 @@
"defaultMessage": "This attribute was created by the plugin \"{pluginId}\", which is no longer installed.",
"description": "Extra paragraph in the delete-attribute confirmation dialog, shown only when the attribute came from a plugin that is no longer installed. {pluginId} is the raw plugin identifier string (not translated); it falls back to the literal \"unknown\"."
},
"admin.global_attributes.confirm.delete.orphaned_owners_body": {
"defaultMessage": "This attribute was managed by {pluginIds}, which {count, plural, one {is} other {are}} no longer installed.",
"description": "Extra paragraph in the delete-attribute confirmation dialog, shown when every integration that owned the attribute has been uninstalled. {pluginIds} is a localized list of raw plugin identifiers (not translated); {count} is how many there are and selects singular or plural."
},
"admin.global_attributes.confirm.delete.title": {
"defaultMessage": "Delete {name} attribute",
"description": "Title of the delete confirmation dialog in System Console > System Attributes > Manage Attributes. {name} is the attribute's display name. \"Delete\" is a verb (imperative)."
@@ -6807,6 +6843,10 @@
"defaultMessage": "New attribute",
"description": "Label of the primary button at the top of System Console > System Attributes > Manage Attributes that opens the attribute creation page. \"New\" is an adjective; keep it identical to the creation page heading."
},
"admin.global_attributes.owners.scoped": {
"defaultMessage": "{owner}: {scopes}",
"description": "One entry in the list of integrations that own an attribute, used inside the \"Managed by\" text in System Console > Attribute Management. {owner} is the integration's display name; {scopes} is a comma-separated list of the scopes it owns (not translated)."
},
"admin.global_attributes.search.placeholder": {
"defaultMessage": "Search attributes",
"description": "Placeholder in the search box above the attributes table in System Console > System Attributes > Attribute Management. 'Search' is a verb."
@@ -6831,6 +6871,10 @@
"defaultMessage": "Select an action",
"description": "Accessible name of the per-row actions dropdown menu in the attributes table."
},
"admin.global_attributes.table.actions.owner_managed": {
"defaultMessage": "Managed by {owners}",
"description": "Secondary label next to the disabled Delete item in the attributes table row menu, explaining that integrations own the attribute so it cannot be deleted here. {owners} is a localized list of the integrations that own the attribute (for example \"SCIM and svc-sync\")."
},
"admin.global_attributes.table.actions.plugin_managed": {
"defaultMessage": "Plugin-managed",
"description": "Secondary label next to the disabled Delete item in the attributes table row menu, explaining that the attribute is owned by an installed plugin and cannot be removed here."
@@ -6899,6 +6943,10 @@
"defaultMessage": "Managed here",
"description": "Value in the Source column of the attributes table meaning the attribute's values are maintained inside Mattermost rather than synced from an external directory. \"here\" refers to this admin console screen."
},
"admin.global_attributes.table.source.owned_by": {
"defaultMessage": "Managed by {owners}",
"description": "Value in the Source column of the attributes table in System Console > Attribute Management when integrations own the attribute and set its values. {owners} is a localized list of the integrations that own the attribute (for example \"SCIM and svc-sync\")."
},
"admin.global_attributes.table.source.saml": {
"defaultMessage": "SAML",
"description": "Value in the Source column of the attributes table when the attribute is synced from SAML. Keep the abbreviation as-is."
@@ -2,6 +2,7 @@
// See LICENSE.txt for license information.
import {
allOwnersAreUninstalledPlugins,
CPA_FIELD_NAME_PATTERN,
CPA_FIELD_NAME_RESERVED_WORDS,
filterCELIdentifier,
@@ -277,6 +278,29 @@ describe('filterCELIdentifier', () => {
});
});
describe('allOwnersAreUninstalledPlugins', () => {
const plugin = (id: string) => ({id, type: 'plugin' as const, scopes: []});
const installed = new Set(['com.acme.here']);
it('is false with no owners', () => {
expect(allOwnersAreUninstalledPlugins([], installed)).toBe(false);
});
it('is true for uninstalled plugins only', () => {
expect(allOwnersAreUninstalledPlugins([plugin('com.acme.gone')], installed)).toBe(true);
expect(allOwnersAreUninstalledPlugins([plugin('com.acme.gone'), plugin('com.acme.also')], installed)).toBe(true);
});
it('is false when any plugin owner is installed', () => {
expect(allOwnersAreUninstalledPlugins([plugin('com.acme.here')], installed)).toBe(false);
expect(allOwnersAreUninstalledPlugins([plugin('com.acme.gone'), plugin('com.acme.here')], installed)).toBe(false);
});
it('is false when any owner is not a plugin', () => {
expect(allOwnersAreUninstalledPlugins([plugin('com.acme.gone'), {id: 'svc', type: 'service', scopes: []}], installed)).toBe(false);
});
});
describe('isFieldOrphaned', () => {
const installed = new Set(['com.acme.plugin']);
+9 -1
View File
@@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import type {PropertyField} from '@mattermost/types/properties';
import type {UserPropertyField} from '@mattermost/types/properties_user';
import type {PropertyFieldOwner, UserPropertyField} from '@mattermost/types/properties_user';
/**
* Returns the user-facing label for a CPA field.
@@ -153,6 +153,14 @@ export function isFieldOrphaned(
return !installedPluginIds.has(sourcePluginId);
}
// True only when every owner is a plugin that is no longer installed, i.e. nothing is left to manage the field.
export function allOwnersAreUninstalledPlugins(
owners: PropertyFieldOwner[],
installedPluginIds: ReadonlySet<string>,
): boolean {
return owners.length > 0 && owners.every((owner) => owner.type === 'plugin' && !installedPluginIds.has(owner.id));
}
/**
* Mirrors server PropertyValueWithheldJSON (server/public/model/property_value.go).
* A websocket broadcast for a non-public field's value carries this marker in