mirror of
https://github.com/mattermost/mattermost.git
synced 2026-02-18 18:18:23 -05:00
[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:
parent
a995682464
commit
892492a0a8
9 changed files with 172 additions and 14 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -92,6 +92,8 @@ describe('useUserPropertyFieldDelete', () => {
|
|||
dialogProps: {
|
||||
name: baseField.name,
|
||||
onConfirm: expect.any(Function),
|
||||
isOrphaned: false,
|
||||
sourcePluginId: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
Loading…
Reference in a new issue