diff --git a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/modals.ts b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/modals.ts index be5deb01bb24..3daeab4efc29 100644 --- a/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/modals.ts +++ b/e2e-tests/playwright/lib/src/ui/components/system_console/sections/user_management/users/modals.ts @@ -11,10 +11,24 @@ import BaseModal from '@/ui/components/system_console/base_modal'; */ export class ManageRolesModal extends BaseModal { readonly saveButton: Locator; + readonly systemAdminRadio: Locator; + readonly delegatedRolesSection: Locator; + readonly delegatedRolesTitle: Locator; constructor(container: Locator) { super(container); this.saveButton = container.getByRole('button', {name: 'Save'}); + this.systemAdminRadio = container.locator('input[name="systemadmin"]'); + this.delegatedRolesSection = container.locator('.manage-roles-modal__delegated-roles'); + this.delegatedRolesTitle = container.getByText('Delegated Administration Roles', {exact: true}); + } + + /** + * Get the checkbox for a delegated administration role by its display name + * (e.g. "User Manager", "System Manager", "Viewer"). + */ + getDelegatedRoleCheckbox(roleDisplayName: string): Locator { + return this.delegatedRolesSection.locator('label').filter({hasText: roleDisplayName}).getByRole('checkbox'); } async save() { diff --git a/e2e-tests/playwright/specs/functional/system_console/system_users/manage_roles_delegated_admin.spec.ts b/e2e-tests/playwright/specs/functional/system_console/system_users/manage_roles_delegated_admin.spec.ts new file mode 100644 index 000000000000..1a680b997e59 --- /dev/null +++ b/e2e-tests/playwright/specs/functional/system_console/system_users/manage_roles_delegated_admin.spec.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Client4} from '@mattermost/client'; +import type {UserProfile} from '@mattermost/types/users'; + +import type {SystemConsolePage} from '@mattermost/playwright-lib'; +import {expect, test} from '@mattermost/playwright-lib'; + +/** + * Delegated (granular) administration roles surfaced in the Manage Roles modal. + * Mirrors DELEGATED_ROLE_NAMES in the manage_roles_modal component. + */ +const USER_MANAGER_ROLE = 'system_user_manager'; +const USER_MANAGER_LABEL = 'User Manager'; +const SYSTEM_MANAGER_ROLE = 'system_manager'; +const SYSTEM_MANAGER_LABEL = 'System Manager'; + +/** + * Open the Manage Roles modal for the given user from System Console > User Management > Users. + */ +async function openManageRolesModal(systemConsolePage: SystemConsolePage, user: UserProfile) { + await systemConsolePage.goto(); + await systemConsolePage.sidebar.users.click(); + await systemConsolePage.users.toBeVisible(); + + await systemConsolePage.users.searchUsers(user.email); + const userRow = systemConsolePage.users.usersTable.getRowByIndex(0); + await expect(userRow.container.getByText(user.email)).toBeVisible(); + + const actionMenu = await userRow.openActionMenu(); + await actionMenu.clickManageRoles(); + + const {manageRolesModal} = systemConsolePage.users; + await manageRolesModal.toBeVisible(); + return manageRolesModal; +} + +/** + * Skip when the server license does not enable delegated granular administration. + * The feature requires an Enterprise/Enterprise Advanced license with the LDAP Groups + * feature and is not available on the Entry SKU (see utils/license_utils.ts). + */ +async function skipIfNoDelegatedAdminLicense(adminClient: Client4) { + const license = await adminClient.getClientLicenseOld(); + test.skip( + license.IsLicensed !== 'true' || license.LDAPGroups !== 'true' || license.SkuShortName === 'entry', + 'Skipping test - server not licensed for delegated granular administration', + ); +} + +/** + * @objective Verify a delegated administration role can be granted from the Manage Roles modal and persists. + */ +test( + 'grants a delegated administration role from the Manage Roles modal', + {tag: ['@system_console', '@user_management']}, + async ({pw}) => { + const {adminUser, adminClient, user} = await pw.initSetup(); + await skipIfNoDelegatedAdminLicense(adminClient); + + // # Login as admin and open the Manage Roles modal for a regular user + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + const manageRolesModal = await openManageRolesModal(systemConsolePage, user); + + // * Verify the Delegated Administration Roles section is shown for a regular member + await expect(manageRolesModal.delegatedRolesTitle).toBeVisible(); + + // # Grant the User Manager role and save + const userManagerCheckbox = manageRolesModal.getDelegatedRoleCheckbox(USER_MANAGER_LABEL); + await expect(userManagerCheckbox).not.toBeChecked(); + await userManagerCheckbox.check(); + await manageRolesModal.save(); + + // * Verify the role was persisted via the API + const updatedUser = await adminClient.getUser(user.id); + expect(updatedUser.roles).toContain(USER_MANAGER_ROLE); + }, +); + +/** + * @objective Verify the modal pre-selects delegated administration roles the user already has. + */ +test( + 'pre-selects delegated administration roles the user already has', + {tag: ['@system_console', '@user_management']}, + async ({pw}) => { + const {adminUser, adminClient, user} = await pw.initSetup(); + await skipIfNoDelegatedAdminLicense(adminClient); + + // # Grant the System Manager role to the user up front + await adminClient.updateUserRoles(user.id, `system_user ${SYSTEM_MANAGER_ROLE}`); + + // # Login as admin and open the Manage Roles modal for that user + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + const manageRolesModal = await openManageRolesModal(systemConsolePage, user); + + // * Verify the already-granted role is pre-checked and others are not + await expect(manageRolesModal.getDelegatedRoleCheckbox(SYSTEM_MANAGER_LABEL)).toBeChecked(); + await expect(manageRolesModal.getDelegatedRoleCheckbox(USER_MANAGER_LABEL)).not.toBeChecked(); + }, +); + +/** + * @objective Verify the Delegated Administration Roles section is hidden when System Admin is selected. + */ +test( + 'hides the delegated administration roles section when System Admin is selected', + {tag: ['@system_console', '@user_management']}, + async ({pw}) => { + const {adminUser, adminClient, user} = await pw.initSetup(); + await skipIfNoDelegatedAdminLicense(adminClient); + + // # Login as admin and open the Manage Roles modal for a regular user + const {systemConsolePage} = await pw.testBrowser.login(adminUser); + const manageRolesModal = await openManageRolesModal(systemConsolePage, user); + + // * Verify the section is initially visible for a member + await expect(manageRolesModal.delegatedRolesTitle).toBeVisible(); + + // # Promote the account to System Admin within the modal + await manageRolesModal.systemAdminRadio.check(); + + // * Verify the delegated roles section is hidden since System Admins already have full access + await expect(manageRolesModal.delegatedRolesTitle).not.toBeVisible(); + }, +); diff --git a/server/public/model/slack_compatibility.go b/server/public/model/slack_compatibility.go index 020dc5fc0e21..e5a464d2cfe7 100644 --- a/server/public/model/slack_compatibility.go +++ b/server/public/model/slack_compatibility.go @@ -8,22 +8,6 @@ import ( "strings" ) -// Deprecated: Use MessageAttachment instead. -type SlackAttachment = MessageAttachment - -// Deprecated: Use MessageAttachmentField instead. -type SlackAttachmentField = MessageAttachmentField - -// Deprecated: Use ParseMessageAttachment instead. -func ParseSlackAttachment(post *Post, attachments []*MessageAttachment) { - ParseMessageAttachment(post, attachments) -} - -// Deprecated: Use StringifyMessageAttachmentFieldValue instead. -func StringifySlackFieldValue(a []*MessageAttachment) []*MessageAttachment { - return StringifyMessageAttachmentFieldValue(a) -} - // SlackCompatibleBool is an alias for bool that implements json.Unmarshaler type SlackCompatibleBool bool diff --git a/webapp/channels/src/components/admin_console/custom_plugin_settings/index.test.tsx b/webapp/channels/src/components/admin_console/custom_plugin_settings/index.test.tsx index 34db6b1df305..4dc4a6eb7dfa 100644 --- a/webapp/channels/src/components/admin_console/custom_plugin_settings/index.test.tsx +++ b/webapp/channels/src/components/admin_console/custom_plugin_settings/index.test.tsx @@ -293,6 +293,162 @@ describe('custom plugin sections and settings', () => { expect(screen.getByText('In order to view this setting, enable the plugin and click Save.')).toBeInTheDocument(); }); + it('mixed custom section fallback with plugin disabled keeps fallback sections visible', () => { + const state = { + ...baseState, + entities: { + admin: { + plugins: { + testplugin: { + ...plugin, + settings_schema: { + ...plugin.settings_schema, + sections: [ + { + key: 'section1', + title: 'Fallback Section', + settings: [ + { + key: 'fallbacknumbersetting', + label: 'Fallback Number Setting', + type: 'number' as const, + help_text: 'Fallback Number Setting Help Text', + }, + ], + custom: true, + fallback: true, + }, + { + key: 'section2', + title: 'No Fallback Section', + settings: [ + { + key: 'nofallbacknumbersetting', + label: 'No Fallback Number Setting', + type: 'number' as const, + help_text: 'No Fallback Number Setting Help Text', + }, + ], + custom: true, + fallback: false, + }, + ], + }, + }, + }, + }, + }, + }; + + const props = { + ...baseProps, + config: { + ...baseProps.config, + PluginStates: { + testplugin: { + Enabled: false, + }, + }, + }, + }; + + renderWithContext( + , + {...state}); + + expectPluginPageTitle('testplugin', 'testplugin'); + expect(screen.getByTestId('PluginSettings.PluginStates.testplugin.Enable')).toBeInTheDocument(); + + // The single collapse warning must not replace the whole page when at least one section allows a fallback. + expect(screen.queryByText('In order to view and configure plugin settings, enable the plugin and click Save.')).not.toBeInTheDocument(); + + // The fallback-enabled section stays configurable. + expect(screen.getByText('Fallback Section')).toBeInTheDocument(); + expect(screen.getByText('Fallback Number Setting Help Text')).toBeInTheDocument(); + + // The non-fallback section is hidden behind its own per-section warning. + expect(screen.getByText('No Fallback Section')).toBeInTheDocument(); + expect(screen.getByText('In order to view this section, enable the plugin and click Save.')).toBeInTheDocument(); + expect(screen.queryByText('No Fallback Number Setting Help Text')).not.toBeInTheDocument(); + }); + + it('mixed custom section fallback is order-independent', () => { + const state = { + ...baseState, + entities: { + admin: { + plugins: { + testplugin: { + ...plugin, + settings_schema: { + ...plugin.settings_schema, + sections: [ + { + key: 'section1', + title: 'No Fallback Section', + settings: [ + { + key: 'nofallbacknumbersetting', + label: 'No Fallback Number Setting', + type: 'number' as const, + help_text: 'No Fallback Number Setting Help Text', + }, + ], + custom: true, + fallback: false, + }, + { + key: 'section2', + title: 'Fallback Section', + settings: [ + { + key: 'fallbacknumbersetting', + label: 'Fallback Number Setting', + type: 'number' as const, + help_text: 'Fallback Number Setting Help Text', + }, + ], + custom: true, + fallback: true, + }, + ], + }, + }, + }, + }, + }, + }; + + const props = { + ...baseProps, + config: { + ...baseProps.config, + PluginStates: { + testplugin: { + Enabled: false, + }, + }, + }, + }; + + renderWithContext( + , + {...state}); + + expect(screen.queryByText('In order to view and configure plugin settings, enable the plugin and click Save.')).not.toBeInTheDocument(); + expect(screen.getByText('Fallback Section')).toBeInTheDocument(); + expect(screen.getByText('Fallback Number Setting Help Text')).toBeInTheDocument(); + expect(screen.getByText('No Fallback Section')).toBeInTheDocument(); + expect(screen.getByText('In order to view this section, enable the plugin and click Save.')).toBeInTheDocument(); + expect(screen.queryByText('No Fallback Number Setting Help Text')).not.toBeInTheDocument(); + }); + it('custom sections with plugin enabled should render as expected', () => { const CustomSection1 = () => { return ( diff --git a/webapp/channels/src/components/admin_console/custom_plugin_settings/index.ts b/webapp/channels/src/components/admin_console/custom_plugin_settings/index.ts index 72c36d817d12..dc5bed1ea424 100644 --- a/webapp/channels/src/components/admin_console/custom_plugin_settings/index.ts +++ b/webapp/channels/src/components/admin_console/custom_plugin_settings/index.ts @@ -137,10 +137,10 @@ function makeGetPluginSchema() { const pluginEnableSetting = getEnablePluginSetting(plugin) as AdminDefinitionSetting; const hasAllCustomSectionsDisabled = plugin.settings_schema?.sections?.every((s) => s.custom && !customSections[s.key.toLowerCase()]); - const allCustomSectionsAllowFallback = plugin.settings_schema?.sections?.every((s) => s.custom && s.fallback); + const anyCustomSectionAllowsFallback = plugin.settings_schema?.sections?.some((s) => s.custom && s.fallback); - if (plugin.settings_schema && hasAllCustomSectionsDisabled && !allCustomSectionsAllowFallback) { - // If the plugin is composed of purely custom sections (e.g. Calls), it's disabled (custom components are not found), and they don't allow a fallback, we show a single warning. + if (plugin.settings_schema && hasAllCustomSectionsDisabled && !anyCustomSectionAllowsFallback) { + // If the plugin is composed of purely custom sections (e.g. Calls), it's disabled (custom components are not found), and none allow a fallback, we show a single warning. When a section allows a fallback we render the sections instead, so fallback-enabled ones stay configurable. const warningBanner = { key: 'admin.plugin.customSections.pluginDisabledWarning', type: Constants.SettingsTypes.TYPE_BANNER, diff --git a/webapp/channels/src/components/admin_console/manage_roles_modal/index.ts b/webapp/channels/src/components/admin_console/manage_roles_modal/index.ts index ed946139492b..b831275b0ffa 100644 --- a/webapp/channels/src/components/admin_console/manage_roles_modal/index.ts +++ b/webapp/channels/src/components/admin_console/manage_roles_modal/index.ts @@ -5,7 +5,12 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import type {Dispatch} from 'redux'; +import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles'; import {updateUserRoles} from 'mattermost-redux/actions/users'; +import {getLicense} from 'mattermost-redux/selectors/entities/general'; +import {getRoles} from 'mattermost-redux/selectors/entities/roles_helpers'; + +import {isLicensedForDelegatedAdministration} from 'utils/license_utils'; import type {GlobalState} from 'types/store'; @@ -14,6 +19,8 @@ import ManageRolesModal from './manage_roles_modal'; function mapStateToProps(state: GlobalState) { return { userAccessTokensEnabled: state.entities.admin.config.ServiceSettings!.EnableUserAccessTokens, + roles: getRoles(state), + isLicensedForDelegatedAdmin: isLicensedForDelegatedAdministration(getLicense(state)), }; } @@ -21,6 +28,7 @@ function mapDispatchToProps(dispatch: Dispatch) { return { actions: bindActionCreators({ updateUserRoles, + loadRolesIfNeeded, }, dispatch), }; } diff --git a/webapp/channels/src/components/admin_console/manage_roles_modal/manage_roles_modal.test.tsx b/webapp/channels/src/components/admin_console/manage_roles_modal/manage_roles_modal.test.tsx new file mode 100644 index 000000000000..fb030043b062 --- /dev/null +++ b/webapp/channels/src/components/admin_console/manage_roles_modal/manage_roles_modal.test.tsx @@ -0,0 +1,251 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import type {Role} from '@mattermost/types/roles'; +import type {UserProfile} from '@mattermost/types/users'; + +import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils'; +import {TestHelper} from 'utils/test_helper'; + +import ManageRolesModal, {DELEGATED_ROLE_NAMES} from './manage_roles_modal'; + +function buildRoles(names: string[] = DELEGATED_ROLE_NAMES): Record { + return names.reduce>((acc, name) => { + acc[name] = TestHelper.getRoleMock({id: name, name}); + return acc; + }, {}); +} + +function getBaseProps(userOverride: Partial = {}) { + const updateUserRoles = jest.fn().mockResolvedValue({data: true}); + const loadRolesIfNeeded = jest.fn().mockResolvedValue({data: {}}); + const onSuccess = jest.fn(); + const onExited = jest.fn(); + + return { + user: TestHelper.getUserMock({id: 'user_id', username: 'manager', roles: 'system_user', ...userOverride}), + userAccessTokensEnabled: false, + roles: buildRoles(), + isLicensedForDelegatedAdmin: true, + onSuccess, + onExited, + actions: { + updateUserRoles, + loadRolesIfNeeded, + }, + }; +} + +async function clickSave() { + await userEvent.click(screen.getByRole('button', {name: 'Save'})); +} + +describe('admin_console/manage_roles_modal', () => { + test('loads the delegated roles when mounted', () => { + const props = getBaseProps(); + renderWithContext(); + + expect(props.actions.loadRolesIfNeeded).toHaveBeenCalledWith(DELEGATED_ROLE_NAMES); + }); + + test('renders a checkbox for every available delegated role, pre-checked from the user roles', () => { + const props = getBaseProps({roles: 'system_user system_manager'}); + renderWithContext(); + + expect(screen.getByText('Delegated Administration Roles')).toBeInTheDocument(); + + expect(screen.getByRole('checkbox', {name: /System Manager/})).toBeChecked(); + expect(screen.getByRole('checkbox', {name: /User Manager/})).not.toBeChecked(); + expect(screen.getByRole('checkbox', {name: /Custom Group Manager/})).not.toBeChecked(); + expect(screen.getByRole('checkbox', {name: /Shared Channel Manager/})).not.toBeChecked(); + expect(screen.getByRole('checkbox', {name: /Viewer/})).not.toBeChecked(); + }); + + test('only renders checkboxes for roles that are available in the store', () => { + const props = {...getBaseProps(), roles: buildRoles(['system_manager'])}; + renderWithContext(); + + expect(screen.getByRole('checkbox', {name: /System Manager/})).toBeInTheDocument(); + expect(screen.queryByRole('checkbox', {name: /User Manager/})).not.toBeInTheDocument(); + }); + + test('does not render the delegated roles section for bot accounts', () => { + const props = getBaseProps({is_bot: true, roles: 'system_user'}); + renderWithContext(); + + expect(screen.queryByText('Delegated Administration Roles')).not.toBeInTheDocument(); + }); + + test('merges a newly checked delegated role into the saved roles', async () => { + const props = getBaseProps({roles: 'system_user'}); + renderWithContext(); + + await userEvent.click(screen.getByRole('checkbox', {name: /User Manager/})); + await clickSave(); + + await waitFor(() => { + expect(props.actions.updateUserRoles).toHaveBeenCalledWith('user_id', 'system_user system_user_manager'); + }); + expect(props.onSuccess).toHaveBeenCalledWith('system_user system_user_manager'); + }); + + test('removes an unchecked delegated role from the saved roles', async () => { + const props = getBaseProps({roles: 'system_user system_manager'}); + renderWithContext(); + + await userEvent.click(screen.getByRole('checkbox', {name: /System Manager/})); + await clickSave(); + + await waitFor(() => { + expect(props.actions.updateUserRoles).toHaveBeenCalledWith('user_id', 'system_user'); + }); + }); + + test('keeps delegated roles when toggling to System Admin and back to Member', async () => { + const props = getBaseProps({roles: 'system_user system_manager'}); + renderWithContext(); + + await userEvent.click(screen.getByRole('radio', {name: 'System Admin'})); + await userEvent.click(screen.getByRole('radio', {name: 'Member'})); + await clickSave(); + + await waitFor(() => { + expect(props.actions.updateUserRoles).toHaveBeenCalledWith('user_id', 'system_user system_manager'); + }); + }); + + test('does not save delegated roles when the account is set to System Admin', async () => { + const props = getBaseProps({roles: 'system_user system_manager'}); + renderWithContext(); + + await userEvent.click(screen.getByRole('radio', {name: 'System Admin'})); + await clickSave(); + + await waitFor(() => { + expect(props.actions.updateUserRoles).toHaveBeenCalledWith('user_id', 'system_user system_admin'); + }); + }); + + test('hides the delegated roles section when the account is a System Admin', () => { + const props = getBaseProps({roles: 'system_user system_admin system_manager'}); + renderWithContext(); + + expect(screen.queryByText('Delegated Administration Roles')).not.toBeInTheDocument(); + expect(screen.queryByRole('checkbox', {name: /System Manager/})).not.toBeInTheDocument(); + }); + + test('hides the delegated roles section when toggling to System Admin', async () => { + const props = getBaseProps({roles: 'system_user'}); + renderWithContext(); + + expect(screen.getByText('Delegated Administration Roles')).toBeInTheDocument(); + + await userEvent.click(screen.getByRole('radio', {name: 'System Admin'})); + + expect(screen.queryByText('Delegated Administration Roles')).not.toBeInTheDocument(); + }); + + test('renders the Personal Access Tokens section heading when tokens are enabled', () => { + const props = {...getBaseProps({roles: 'system_user'}), userAccessTokensEnabled: true}; + renderWithContext(); + + expect(screen.getByText('Personal Access Tokens')).toBeInTheDocument(); + }); + + test('appends multiple selected delegated roles in a stable order', async () => { + const props = getBaseProps({roles: 'system_user'}); + renderWithContext(); + + // Click in reverse order to prove the saved order follows DELEGATED_ROLE_NAMES, not click order. + await userEvent.click(screen.getByRole('checkbox', {name: /Viewer/})); + await userEvent.click(screen.getByRole('checkbox', {name: /User Manager/})); + await clickSave(); + + await waitFor(() => { + expect(props.actions.updateUserRoles).toHaveBeenCalledWith('user_id', 'system_user system_user_manager system_read_only_admin'); + }); + }); + + test('does not grant a role that is checked and then unchecked before saving', async () => { + const props = getBaseProps({roles: 'system_user'}); + renderWithContext(); + + const checkbox = screen.getByRole('checkbox', {name: /User Manager/}); + await userEvent.click(checkbox); + await userEvent.click(checkbox); + await clickSave(); + + await waitFor(() => { + expect(props.actions.updateUserRoles).toHaveBeenCalledWith('user_id', 'system_user'); + }); + }); + + test('does not render the delegated roles section without an enterprise license', () => { + const props = {...getBaseProps({roles: 'system_user system_manager'}), isLicensedForDelegatedAdmin: false}; + renderWithContext(); + + expect(screen.queryByText('Delegated Administration Roles')).not.toBeInTheDocument(); + expect(screen.queryByRole('checkbox', {name: /System Manager/})).not.toBeInTheDocument(); + }); + + test('renders the delegated roles section with an enterprise license', () => { + const props = {...getBaseProps({roles: 'system_user'}), isLicensedForDelegatedAdmin: true}; + renderWithContext(); + + expect(screen.getByText('Delegated Administration Roles')).toBeInTheDocument(); + + const link = screen.getByRole('link', {name: 'granular administration roles'}); + expect(link).toHaveAttribute('href', '/admin_console/user_management/system_roles'); + expect(screen.getByText(/Grant targeted System Console access without full System Admin privileges using/)).toBeInTheDocument(); + }); + + test('does not render the delegated roles section when no delegated roles are available', () => { + const props = {...getBaseProps({roles: 'system_user'}), roles: {}}; + renderWithContext(); + + expect(screen.queryByText('Delegated Administration Roles')).not.toBeInTheDocument(); + }); + + test('shows an error and does not report success when saving fails', async () => { + const props = getBaseProps({roles: 'system_user'}); + props.actions.updateUserRoles.mockResolvedValue({error: {message: 'boom'}}); + renderWithContext(); + + await userEvent.click(screen.getByRole('checkbox', {name: /User Manager/})); + await clickSave(); + + expect(await screen.findByText('Unable to save roles.')).toBeInTheDocument(); + expect(props.onSuccess).not.toHaveBeenCalled(); + expect(screen.getByRole('button', {name: 'Save'})).toBeInTheDocument(); + }); + + test('closes the modal after a successful save', async () => { + const props = getBaseProps({roles: 'system_user'}); + renderWithContext(); + + await clickSave(); + + await waitFor(() => { + expect(screen.queryByRole('button', {name: 'Save'})).not.toBeInTheDocument(); + }); + }); + + test('resets the delegated role selection when a different user is provided', () => { + const props = getBaseProps({id: 'user_1', roles: 'system_user system_manager'}); + const {rerender} = renderWithContext(); + + expect(screen.getByRole('checkbox', {name: /System Manager/})).toBeChecked(); + expect(screen.getByRole('checkbox', {name: /User Manager/})).not.toBeChecked(); + + const nextProps = { + ...props, + user: TestHelper.getUserMock({id: 'user_2', username: 'other', roles: 'system_user system_user_manager'}), + }; + rerender(); + + expect(screen.getByRole('checkbox', {name: /System Manager/})).not.toBeChecked(); + expect(screen.getByRole('checkbox', {name: /User Manager/})).toBeChecked(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/manage_roles_modal/manage_roles_modal.tsx b/webapp/channels/src/components/admin_console/manage_roles_modal/manage_roles_modal.tsx index 9212493d5e1c..de0671a823da 100644 --- a/webapp/channels/src/components/admin_console/manage_roles_modal/manage_roles_modal.tsx +++ b/webapp/channels/src/components/admin_console/manage_roles_modal/manage_roles_modal.tsx @@ -4,8 +4,10 @@ import React from 'react'; import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; +import {Link} from 'react-router-dom'; import {Button} from '@mattermost/shared/components/button'; +import type {Role} from '@mattermost/types/roles'; import type {UserProfile} from '@mattermost/types/users'; import {Client4} from 'mattermost-redux/client'; @@ -21,9 +23,25 @@ import {DeveloperLinks} from 'utils/constants'; import {isSuccess} from 'types/actions'; +import {rolesStrings} from '../system_roles/strings'; + +// Delegated (granular) administration roles that can be granted from this modal. +// Kept in sync with the roles surfaced in the Delegated Granular Administration screen. +export const DELEGATED_ROLE_NAMES = [ + 'system_manager', + 'system_user_manager', + 'system_custom_group_admin', + 'system_shared_channel_manager', + 'system_read_only_admin', +]; + export type Props = { user?: UserProfile; userAccessTokensEnabled: boolean; + roles: Record; + + // Delegated administration roles are an Enterprise/Enterprise Advanced feature. + isLicensedForDelegatedAdmin: boolean; // defining custom function type instead of using React.MouseEventHandler // to make the event optional @@ -31,6 +49,7 @@ export type Props = { onExited: () => void; actions: { updateUserRoles: (userId: string, roles: string) => Promise; + loadRolesIfNeeded: (roles: Iterable) => Promise; }; }; @@ -42,8 +61,20 @@ type State = { hasPostAllPublicRole: boolean; hasUserAccessTokenRole: boolean; isSystemAdmin: boolean; + delegatedRoles: Record; }; +function getDelegatedRolesFromRoles(roles: string): Record { + const roleSet = new Set(roles.split(' ')); + const delegatedRoles: Record = {}; + + for (const name of DELEGATED_ROLE_NAMES) { + delegatedRoles[name] = roleSet.has(name); + } + + return delegatedRoles; +} + function getStateFromProps(props: Props): State { const roles = props.user && props.user.roles ? props.user.roles : ''; @@ -55,6 +86,7 @@ function getStateFromProps(props: Props): State { hasPostAllPublicRole: UserUtils.hasPostAllPublicRole(roles), hasUserAccessTokenRole: UserUtils.hasUserAccessTokenRole(roles), isSystemAdmin: UserUtils.isSystemAdmin(roles), + delegatedRoles: getDelegatedRolesFromRoles(roles), }; } @@ -71,6 +103,10 @@ export default class ManageRolesModal extends React.PureComponent return null; } + componentDidMount() { + this.props.actions.loadRolesIfNeeded(DELEGATED_ROLE_NAMES); + } + handleError = (error: any) => { this.setState({ error, @@ -103,6 +139,16 @@ export default class ManageRolesModal extends React.PureComponent }); }; + handleDelegatedRoleChange = (roleName: string) => (e: React.ChangeEvent) => { + const checked = e.target.checked; + this.setState((prevState) => ({ + delegatedRoles: { + ...prevState.delegatedRoles, + [roleName]: checked, + }, + })); + }; + onHide = () => { this.setState({show: false}); }; @@ -114,12 +160,20 @@ export default class ManageRolesModal extends React.PureComponent if (this.state.isSystemAdmin) { roles += ' ' + General.SYSTEM_ADMIN_ROLE; - } else if (this.state.hasUserAccessTokenRole) { - roles += ' ' + General.SYSTEM_USER_ACCESS_TOKEN_ROLE; - if (this.state.hasPostAllRole) { - roles += ' ' + General.SYSTEM_POST_ALL_ROLE; - } else if (this.state.hasPostAllPublicRole) { - roles += ' ' + General.SYSTEM_POST_ALL_PUBLIC_ROLE; + } else { + if (this.state.hasUserAccessTokenRole) { + roles += ' ' + General.SYSTEM_USER_ACCESS_TOKEN_ROLE; + if (this.state.hasPostAllRole) { + roles += ' ' + General.SYSTEM_POST_ALL_ROLE; + } else if (this.state.hasPostAllPublicRole) { + roles += ' ' + General.SYSTEM_POST_ALL_PUBLIC_ROLE; + } + } + + for (const roleName of DELEGATED_ROLE_NAMES) { + if (this.state.delegatedRoles[roleName]) { + roles += ' ' + roleName; + } } } @@ -138,6 +192,69 @@ export default class ManageRolesModal extends React.PureComponent } }; + renderDelegatedAdminRoles = () => { + if (!this.props.isLicensedForDelegatedAdmin) { + return null; + } + + // System Admins already have access to all System Console areas, so the delegated roles are irrelevant. + if (this.state.isSystemAdmin) { + return null; + } + + const availableRoles = DELEGATED_ROLE_NAMES.filter((name) => this.props.roles[name] && rolesStrings[name]); + + if (availableRoles.length === 0) { + return null; + } + + return ( +
+

+ + + +

+

+ ( + + {msg} + + ), + }} + /> +

+ {availableRoles.map((name) => ( +
+ +
+ ))} +
+ ); + }; + renderContents = () => { const {user} = this.props; @@ -238,7 +355,15 @@ export default class ManageRolesModal extends React.PureComponent ); } else { userAccessTokenContent = ( -
+
+

+ + + +

+ {!user.is_bot && this.renderDelegatedAdminRoles()} {userAccessTokenContent}
diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index 65b9f83b6d32..82c334e72910 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -2140,7 +2140,10 @@ "admin.manage_roles.allowUserAccessTokensDesc": "Removing this permission doesn't delete existing tokens. To delete them, go to the user's Manage Tokens menu.", "admin.manage_roles.botAdditionalRoles": "Select additional permissions for the account. Read more about roles and permissions.", "admin.manage_roles.cancel": "Cancel", + "admin.manage_roles.delegatedAdminRolesDescription": "Grant targeted System Console access without full System Admin privileges using granular administration roles.", + "admin.manage_roles.delegatedAdminRolesTitle": "Delegated Administration Roles", "admin.manage_roles.manageRolesTitle": "Manage Roles", + "admin.manage_roles.personalAccessTokensTitle": "Personal Access Tokens", "admin.manage_roles.postAllPublicRole": "Access to post to all Mattermost public channels.", "admin.manage_roles.postAllPublicRoleTitle": "post:channels", "admin.manage_roles.postAllRole": "Access to post to all Mattermost channels including direct messages.", diff --git a/webapp/channels/src/sass/routes/_admin-console.scss b/webapp/channels/src/sass/routes/_admin-console.scss index d15dd4b8ff10..b93bdd1a0b20 100644 --- a/webapp/channels/src/sass/routes/_admin-console.scss +++ b/webapp/channels/src/sass/routes/_admin-console.scss @@ -985,29 +985,50 @@ } } - .member-row--padded { - padding-left: 20px; - + %manage-row-strong-spacing { strong { margin-right: 10px; } + p strong { margin-right: 5px; } } + .member-row--padded { + padding-left: 20px; + + @extend %manage-row-strong-spacing; + } + .member-row-lone-padding { padding-top: 10px; } .manage-row--inner { - padding: 15px 0 4px; + padding: 15px 0 20px; & + div { border-top: variables.$border-gray; } } + .manage-roles-modal__delegated-roles { + padding-top: 20px; + + @extend %manage-row-strong-spacing; + } + + .manage-roles-modal__access-tokens { + padding-top: 20px; + + @extend %manage-row-strong-spacing; + } + + .manage-roles-modal__delegated-roles + .manage-roles-modal__access-tokens { + border-top: variables.$border-gray; + } + .manage-teams__info { overflow: hidden; flex: 1; diff --git a/webapp/channels/src/utils/license_utils.test.ts b/webapp/channels/src/utils/license_utils.test.ts index f92d15252fc5..f1c7132ecb09 100644 --- a/webapp/channels/src/utils/license_utils.test.ts +++ b/webapp/channels/src/utils/license_utils.test.ts @@ -1,10 +1,47 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {isLicenseExpired, isLicenseExpiring, isLicensePastGracePeriod} from 'utils/license_utils'; +import {LicenseSkus} from 'utils/constants'; +import {isLicenseExpired, isLicenseExpiring, isLicensedForDelegatedAdministration, isLicensePastGracePeriod} from 'utils/license_utils'; describe('license_utils', () => { const millisPerDay = 24 * 60 * 60 * 1000; + + describe('isLicensedForDelegatedAdministration', () => { + it('should return true for an Enterprise license with LDAPGroups enabled', () => { + const license = {IsLicensed: 'true', LDAPGroups: 'true', SkuShortName: LicenseSkus.Enterprise}; + + expect(isLicensedForDelegatedAdministration(license)).toBe(true); + }); + + it('should return true for an Enterprise Advanced license with LDAPGroups enabled', () => { + const license = {IsLicensed: 'true', LDAPGroups: 'true', SkuShortName: LicenseSkus.EnterpriseAdvanced}; + + expect(isLicensedForDelegatedAdministration(license)).toBe(true); + }); + + it('should return false for an Entry license even with LDAPGroups enabled', () => { + const license = {IsLicensed: 'true', LDAPGroups: 'true', SkuShortName: LicenseSkus.Entry}; + + expect(isLicensedForDelegatedAdministration(license)).toBe(false); + }); + + it('should return false when LDAPGroups is not enabled', () => { + const license = {IsLicensed: 'true', LDAPGroups: 'false', SkuShortName: LicenseSkus.Professional}; + + expect(isLicensedForDelegatedAdministration(license)).toBe(false); + }); + + it('should return false when not licensed', () => { + const license = {IsLicensed: 'false', LDAPGroups: 'true', SkuShortName: LicenseSkus.Enterprise}; + + expect(isLicensedForDelegatedAdministration(license)).toBe(false); + }); + + it('should return false when license is undefined', () => { + expect(isLicensedForDelegatedAdministration(undefined)).toBe(false); + }); + }); describe('isLicenseExpiring', () => { it('should return false if cloud expiring in 5 days', () => { const license = {Id: '1234', IsLicensed: 'true', Cloud: 'true', ExpiresAt: `${Date.now() + (5 * millisPerDay)}`}; diff --git a/webapp/channels/src/utils/license_utils.ts b/webapp/channels/src/utils/license_utils.ts index de77ef0ec536..26aa3c15d172 100644 --- a/webapp/channels/src/utils/license_utils.ts +++ b/webapp/channels/src/utils/license_utils.ts @@ -98,6 +98,13 @@ export const isEnterpriseLicense = (license?: ClientLicense) => { export const isNonEnterpriseLicense = (license?: ClientLicense) => !isEnterpriseLicense(license); +// Delegated Granular Administration is gated behind the LDAPGroups license feature and is +// not available on the Entry SKU. Mirrors the gating used by the Delegated Granular +// Administration screen in admin_definition (system_roles). +export const isLicensedForDelegatedAdministration = (license?: ClientLicense) => { + return Boolean(license?.IsLicensed === 'true' && license.LDAPGroups === 'true' && license.SkuShortName !== LicenseSkus.Entry); +}; + export const licenseSKUWithFirstLetterCapitalized = (license: ClientLicense) => { const sku = license.SkuShortName; return sku.charAt(0).toUpperCase() + sku.slice(1);