[MM-66836] Add ability to delete orphaned protected fields from uninstalled plugins (#34867)

This change allows admins to delete protected property fields when the source plugin has been uninstalled, providing a cleanup mechanism for orphaned fields. If a field has the protected attribute set to true, but the associated source plugin is not installed, an admin can remove that field from the admin console.
This commit is contained in:
David Krauser 2026-02-06 20:45:27 -05:00 committed by GitHub
parent a995682464
commit 892492a0a8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 172 additions and 14 deletions

View file

@ -0,0 +1,25 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {useSelector} from 'react-redux';
import type {UserPropertyField} from '@mattermost/types/properties';
import type {GlobalState} from 'types/store';
export function isFieldOrphaned(
field: UserPropertyField,
installedPlugins: Record<string, any>,
): boolean {
const sourcePluginId = field.attrs?.source_plugin_id;
const isProtected = Boolean(field.attrs?.protected);
// Field is orphaned if it's protected, has a source plugin ID,
// but that plugin isn't installed
return isProtected && Boolean(sourcePluginId) && !installedPlugins[sourcePluginId as string];
}
export function useIsFieldOrphaned(field: UserPropertyField): boolean {
const installedPlugins = useSelector((state: GlobalState) => state.entities.admin.plugins ?? {});
return isFieldOrphaned(field, installedPlugins);
}

View file

@ -92,6 +92,8 @@ describe('useUserPropertyFieldDelete', () => {
dialogProps: {
name: baseField.name,
onConfirm: expect.any(Function),
isOrphaned: false,
sourcePluginId: undefined,
},
});
});

View file

@ -17,13 +17,15 @@ type Props = {
onConfirm: () => void;
onCancel?: () => void;
onExited: () => void;
isOrphaned?: boolean;
sourcePluginId?: string;
}
const noop = () => {};
export const useUserPropertyFieldDelete = () => {
const dispatch = useDispatch();
const promptDelete = (field: UserPropertyField) => {
const promptDelete = (field: UserPropertyField, isOrphaned = false) => {
return new Promise<boolean>((resolve) => {
dispatch(openModal({
modalId: ModalIdentifiers.USER_PROPERTY_FIELD_DELETE,
@ -31,6 +33,8 @@ export const useUserPropertyFieldDelete = () => {
dialogProps: {
name: field.name,
onConfirm: () => resolve(true),
isOrphaned,
sourcePluginId: field.attrs?.source_plugin_id as string | undefined,
},
}));
});
@ -44,6 +48,8 @@ function RemoveUserPropertyFieldModal({
onExited,
onCancel,
onConfirm,
isOrphaned = false,
sourcePluginId,
}: Props) {
const {formatMessage} = useIntl();
@ -57,10 +63,26 @@ function RemoveUserPropertyFieldModal({
defaultMessage: 'Delete',
});
const message = (
const message = isOrphaned ? (
<>
<p>
<FormattedMessage
id='admin.system_properties.confirm.delete.orphaned_body'
defaultMessage='This attribute was created by the plugin "{pluginId}" which has been uninstalled.'
values={{pluginId: sourcePluginId || 'unknown'}}
/>
</p>
<p>
<FormattedMessage
id='admin.system_properties.confirm.delete.orphaned_warning'
defaultMessage='Deleting this attribute will remove all user-defined values associated with it. This action cannot be undone.'
/>
</p>
</>
) : (
<FormattedMessage
id={'admin.system_properties.confirm.delete.text'}
defaultMessage={'Deleting this attribute will remove all user-defined values associated with it.'}
id='admin.system_properties.confirm.delete.text'
defaultMessage='Deleting this attribute will remove all user-defined values associated with it.'
/>
);

View file

@ -0,0 +1,12 @@
.orphaned-field-delete-button {
padding: 4px 8px;
&:hover:not(:disabled) {
background: rgba(var(--error-text-color-rgb), 0.08);
}
&:disabled {
cursor: not-allowed;
opacity: 0.4;
}
}

View file

@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {TrashCanOutlineIcon} from '@mattermost/compass-icons/components';
import type {UserPropertyField} from '@mattermost/types/properties';
import {useUserPropertyFieldDelete} from './user_properties_delete_modal';
import {isCreatePending} from './user_properties_utils';
import './user_properties_orphaned_delete_button.scss';
type Props = {
field: UserPropertyField;
deleteField: (id: string) => void;
};
const OrphanedFieldDeleteButton: React.FC<Props> = ({field, deleteField}) => {
const {promptDelete} = useUserPropertyFieldDelete();
const handleDelete = () => {
if (isCreatePending(field)) {
deleteField(field.id);
} else {
promptDelete(field, true).then(() => deleteField(field.id));
}
};
return (
<button
className='btn btn-icon btn-transparent orphaned-field-delete-button'
onClick={handleDelete}
disabled={field.delete_at !== 0}
data-testid={`orphaned-field-delete-${field.id}`}
aria-label='Delete orphaned field'
>
<TrashCanOutlineIcon
size={18}
color='var(--error-text)'
/>
</button>
);
};
export default OrphanedFieldDeleteButton;

View file

@ -16,8 +16,10 @@ import LoadingScreen from 'components/loading_screen';
import Constants from 'utils/constants';
import {DangerText, BorderlessInput, LinkButton} from './controls';
import {useIsFieldOrphaned} from './orphaned_fields_utils';
import type {SectionHook} from './section_utils';
import DotMenu from './user_properties_dot_menu';
import OrphanedFieldDeleteButton from './user_properties_orphaned_delete_button';
import SelectType from './user_properties_type_menu';
import type {UserPropertyFields} from './user_properties_utils';
import {isCreatePending, useUserPropertyFields, ValidationWarningNameRequired, ValidationWarningNameTaken, ValidationWarningNameUnique} from './user_properties_utils';
@ -231,17 +233,17 @@ export function UserPropertiesTable({
</ColHeaderRight>
);
},
cell: ({row}) => (
<ActionsRoot>
<DotMenu
cell: ({row}) => {
return (
<ActionsCell
field={row.original}
canCreate={canCreate}
createField={createField}
updateField={updateField}
deleteField={deleteField}
/>
</ActionsRoot>
),
);
},
enableHiding: false,
enableSorting: false,
}),
@ -341,6 +343,37 @@ const ActionsRoot = styled.div`
text-align: right;
`;
type ActionsCellProps = {
field: UserPropertyField;
canCreate: boolean;
createField: (field: UserPropertyField) => void;
updateField: (field: UserPropertyField) => void;
deleteField: (id: string) => void;
};
const ActionsCell = ({field, canCreate, createField, updateField, deleteField}: ActionsCellProps) => {
const isOrphaned = useIsFieldOrphaned(field);
return (
<ActionsRoot>
{isOrphaned ? (
<OrphanedFieldDeleteButton
field={field}
deleteField={deleteField}
/>
) : (
<DotMenu
field={field}
canCreate={canCreate}
createField={createField}
updateField={updateField}
deleteField={deleteField}
/>
)}
</ActionsRoot>
);
};
type EditCellProps = {
value: string;
label?: string;

View file

@ -143,6 +143,11 @@ export const useUserPropertyFields = () => {
const currentByName = byNamesLower(current.data);
const warnings = Object.values(pending.data).reduce<NonNullable<UserPropertyFields['warnings']>>((acc, field) => {
// Skip validation for protected fields - they can't be edited
if (field.attrs?.protected) {
return acc;
}
if (!field.name) {
// name not provided
acc[field.id] = {name: ValidationWarningNameRequired};

View file

@ -21,6 +21,7 @@ import {isKeyPressed} from 'utils/keyboard';
import type {GlobalState} from 'types/store';
import {DangerText} from './controls';
import {useIsFieldOrphaned} from './orphaned_fields_utils';
import './user_properties_values.scss';
import {useAttributeLinkModal} from './user_properties_dot_menu';
@ -40,6 +41,7 @@ const UserPropertyValues = ({
}: Props) => {
const {formatMessage} = useIntl();
const pluginDisplayName = useSelector((state: GlobalState) => getPluginDisplayName(state, field.attrs?.source_plugin_id));
const isOrphaned = useIsFieldOrphaned(field);
const [query, setQuery] = React.useState('');
const {promptEditLdapLink, promptEditSamlLink} = useAttributeLinkModal(field, updateField);
@ -153,11 +155,19 @@ const UserPropertyValues = ({
<>
<span className='user-property-field-values'>
<PowerPlugOutlineIcon size={18}/>
<FormattedMessage
id='admin.system_properties.user_properties.table.values.managed_by_plugin'
defaultMessage='Managed by plugin: {pluginId}'
values={{pluginId: pluginDisplayName}}
/>
{isOrphaned ? (
<FormattedMessage
id='admin.system_properties.user_properties.table.values.plugin_removed'
defaultMessage='Plugin removed: {pluginId}'
values={{pluginId: pluginDisplayName}}
/>
) : (
<FormattedMessage
id='admin.system_properties.user_properties.table.values.managed_by_plugin'
defaultMessage='Managed by plugin: {pluginId}'
values={{pluginId: pluginDisplayName}}
/>
)}
</span>
</>
);

View file

@ -2948,6 +2948,8 @@
"admin.support.termsOfServiceTitle": "Custom Terms of Service",
"admin.support.termsTitle": "Terms of Use Link:",
"admin.system_properties.confirm.delete.button": "Delete",
"admin.system_properties.confirm.delete.orphaned_body": "This attribute was created by the plugin \"{pluginId}\" which has been uninstalled.",
"admin.system_properties.confirm.delete.orphaned_warning": "Deleting this attribute will remove all user-defined values associated with it. This action cannot be undone.",
"admin.system_properties.confirm.delete.text": "Deleting this attribute will remove all user-defined values associated with it.",
"admin.system_properties.confirm.delete.title": "Delete {name} attribute",
"admin.system_properties.details.saving_changes": "Saving configuration…",
@ -2987,6 +2989,7 @@
"admin.system_properties.user_properties.table.values": "Values",
"admin.system_properties.user_properties.table.values.managed_by_plugin": "Managed by plugin: {pluginId}",
"admin.system_properties.user_properties.table.values.placeholder": "Add values… (required)",
"admin.system_properties.user_properties.table.values.plugin_removed": "Plugin removed: {pluginId}",
"admin.system_properties.user_properties.table.values.synced_with": "Synced with: {syncedProperties}",
"admin.system_properties.user_properties.table.values.synced_with.ldap": "AD/LDAP: {propertyName}",
"admin.system_properties.user_properties.table.values.synced_with.saml": "SAML: {propertyName}",