From 03f71c9c844d33b34596f1b639b839692ccb7476 Mon Sep 17 00:00:00 2001 From: Devin Binnie <52460000+devinbinnie@users.noreply.github.com> Date: Wed, 7 Feb 2024 08:56:59 -0500 Subject: [PATCH] [MM-54296] Admin Reporting for Users, new User Management screen (#26055) * trial commit * [MM-56172] Integrate fetching and sorting of user reporting into the table component (#25749) * inint * lint * Update system_users.test.tsx.snap * cypress test fix * added pkg * revoke sessio into new compo * Update system_users.test.tsx.snap * trans * more disable * rename * Update system_users_dropdown.test.tsx * snap * Delete system_users_list.test.tsx.snap * Create system_users_list.test.tsx.snap * Delete system_users_list.test.tsx.snap * name2 * start removing the filter from the list * more clean * snaps * Update package-lock.json * des * Update index.scss * Update system_users_list.tsx * Update system_users_list.tsx * Update system_users.tsx * Update system_users_list.tsx * Update index.scss * menu * action menu * split * Update system_users_list.tsx * Update system_users_list_new.tsx * fix * Update en.json * temp remove * Update system_users.test.tsx.snap * Delete system_users_list.test.tsx.snap * Update en.json * ch * move * a * Update en.json * fix * Update system_users.test.tsx.snap * Update menu.tsx * Update admin_header.tsx * changes in the file system * fixes * integration begin * lint * Update rhs.ts * upper pagination * Update list_table.scss * pinned * more a11y * Update list_table.scss * i18n mor * design changes * descriptions * lint * loading states * fix * actions update * index fix * single action --------- Co-authored-by: Mattermost Build * [MM-56467] Clear up unused code of old systems users (#25855) * ccc * Update en.json * [MM-56478] Improve the user loading in user details page after changes in users list page in admin console (#25861) * init * tem * more fix * handlers clear up, team's fixes --------- Co-authored-by: Mattermost Build * Merge'd * [MM-56279] Add paging to user list (#25863) * Update system_users.tsx * Update system_users.tsx * [MM-56270] Hook up user actions menu, refactor modals to work with new menuing style (#25897) * Hookup menu items and modals * Add error handling, test fixes * Ensure modals animate properly * Add a bunch of TODOs around keeping the user data up to date * Fix i18n and snaps --------- Co-authored-by: Mattermost Build * [MM-56263] Enable selective column hiding for users table (#25898) * [MM-56539] Revisiting the design of the total users count on the left and in pagination in users table (#25947) * default sort order * default sort order (#25956) Co-authored-by: Mattermost Build * [MM-56267] Add search term filtering (#25952) * Include search term with request * Update user count with filtering * Oops * Add 500ms debounce to avoid spamming server with requests * PR feedback --------- Co-authored-by: Mattermost Build * [MM-56398] Add date range filter to user reporting table (#25974) * WIP * Some small adjustments * Two fixes * PR feedback * Remove menuitem --------- Co-authored-by: Mattermost Build * Merge'd * [MM-56615] Add messaging for MySQL instances around the post stats (#26036) * [MM-56261] Add export modals and hook up button to user management screen (#26015) * [MM-56576] Update users when actions occur and show errors when actions fail (#25951) * [MM-56576] Update users when actions occur and show errors when actions fail * Forgot the else * PR feedback * [MM-56617] Move action modals to ConfirmModalRedux * Add filters popover to the users admin table (#26042) * int * 0 zeros * dd * re * file move * Update system_users_filter_popover.scss * a * Update system_users_filter_popover.scss * role and status * fix * Update index.test.ts * overlay * Update dropdown_input.tsx * i * Update en.json * done * Update en.json * dd --------- Co-authored-by: Mattermost Build * Make column toggler column menu items consistent with table column names * Couple typo fixes * Fix i18n * some e2e related changes * Fixed issue where you couldn't queue multiple batch export jobs * Fix id tag * table header mis allignment issue solved * [MM-56727] Fix a TODO, fix Demote to guest should only appear when enabled * rev comments * Stop sending down all profile data * chore(e2e): Fix tests cases for new user Management ui (#26089) * chore(e2e): Fix lint issues in cypress tests * chore(e2e): Fix e2e tests - user_management_spec - authentication_method_spec * [MM-56710] Revert to in-memory compilation of CSV report * Update system_users.tsx * Merge'd * Fix test --------- Co-authored-by: M-ZubairAhmed Co-authored-by: Mattermost Build Co-authored-by: yasserfaraazkhan --- .../channels/bot_accounts/in_lists_1_spec.js | 7 +- .../auth_sso/mfa_authentication_spec.js | 15 +- .../guest_invitation_ui_more_spec.ts | 7 +- .../system_console_manage_guest_spec.ts | 23 +- .../authentication_method_spec.js | 48 +- .../user_management/users_spec.js | 52 +- .../system_console/user_management_spec.js | 45 +- server/channels/app/report.go | 18 +- server/public/model/report.go | 1 + webapp/channels/src/actions/views/admin.ts | 71 + webapp/channels/src/actions/views/search.js | 7 - .../admin_console/admin_console.tsx | 3 + .../admin_user_card.test.tsx.snap | 16 +- .../admin_user_card/admin_user_card.scss | 7 + .../admin_user_card/admin_user_card.tsx | 53 +- .../src/components/admin_console/index.ts | 2 + .../admin_console/list_table/index.ts | 5 +- .../admin_console/list_table/list_table.scss | 50 +- .../admin_console/list_table/list_table.tsx | 131 +- .../admin_console/list_table/pagination.tsx | 19 +- .../manage_roles_modal/manage_roles_modal.tsx | 24 +- .../manage_teams_modal.test.tsx.snap | 3 +- .../manage_teams_modal.test.tsx | 4 +- .../manage_teams_modal/manage_teams_modal.tsx | 11 +- .../manage_tokens_modal.test.tsx.snap | 5 +- .../manage_tokens_modal.test.tsx | 4 +- .../manage_tokens_modal.tsx | 21 +- .../reset_email_modal.test.tsx.snap | 2 + .../reset_email_modal.test.tsx | 8 +- .../reset_email_modal/reset_email_modal.tsx | 32 +- .../reset_password_modal.test.tsx.snap | 1 + .../reset_password_modal.test.tsx | 6 +- .../reset_password_modal.tsx | 18 +- .../system_user_detail.test.tsx.snap | 1298 ++--------------- .../admin_console/system_user_detail/index.ts | 47 +- .../system_user_detail.scss | 79 +- .../system_user_detail.test.tsx | 142 +- .../system_user_detail/system_user_detail.tsx | 742 +++++----- .../team_list/team_list.tsx | 26 +- .../__snapshots__/system_users.test.tsx.snap | 67 - .../system_users/constants/index.ts | 33 + .../admin_console/system_users/index.ts | 114 +- .../revoke_sessions_button/index.tsx | 4 +- .../system_users/system_users.scss | 83 ++ .../system_users/system_users.test.tsx | 162 -- .../system_users/system_users.tsx | 828 +++++++---- .../index.tsx | 191 +++ .../system_users_column_toggler.scss | 17 + .../system_users_date_range_menu/index.tsx | 219 +++ .../system_users_date_range_menu.scss | 23 + .../system_users_dropdown.test.tsx.snap | 713 --------- .../system_users_dropdown/index.ts | 44 - .../system_users_dropdown.test.tsx | 257 ---- .../system_users_dropdown.tsx | 751 ---------- .../export_error_modal.tsx | 65 + .../export_user_data_modal.tsx | 84 ++ .../system_users_export/index.tsx | 120 ++ .../system_users_export.scss | 21 + .../upgrade_export_data_modal.scss | 5 + .../upgrade_export_data_modal.tsx | 62 + .../system_users_filter_role/index.tsx | 59 - .../system_users_filter_team/index.tsx | 66 - .../system_users_filters_popover/index.tsx | 169 +++ .../styled_users_filters_status/index.tsx | 72 + .../system_users_filter_popover.scss | 36 + .../system_users_filter_role/index.tsx | 79 + .../async_team_select.scss | 60 + .../system_users_filter_team/index.tsx | 229 +++ .../system_users_list.test.tsx.snap | 501 ------- .../system_users/system_users_list/index.ts | 37 - .../system_users_list/selectors.test.ts | 176 --- .../system_users_list/selectors.ts | 54 - .../system_users_list.test.tsx | 117 -- .../system_users_list/system_users_list.tsx | 381 ----- ...reate_group_syncables_membership_modal.tsx | 76 + .../deactivate_member_modal.tsx | 164 +++ .../demote_to_guest_modal.tsx | 71 + .../system_users_list_actions/index.tsx | 387 +++++ .../promote_to_member_modal.tsx | 71 + .../revoke_sessions_modal.tsx | 73 + .../system_users_search/index.tsx | 67 +- .../system_users_search.scss | 4 + .../system_users/utils/index.test.ts | 75 + .../system_users/utils/index.tsx | 202 +++ .../src/components/confirm_modal.scss | 3 + .../channels/src/components/confirm_modal.tsx | 10 +- .../src/components/dropdown_input.scss | 2 - .../src/components/dropdown_input.tsx | 8 + webapp/channels/src/components/menu/menu.tsx | 12 +- .../src/components/menu/menu_item.tsx | 40 +- .../styled_popover_container/index.tsx | 16 + .../widgets/inputs/input/input.scss | 4 +- webapp/channels/src/i18n/en.json | 135 +- .../src/constants/preferences.ts | 5 + webapp/channels/src/reducers/views/admin.ts | 39 +- .../src/reducers/views/search.test.js | 2 - webapp/channels/src/reducers/views/search.ts | 14 - .../channels/src/sass/components/_inputs.scss | 14 + .../src/sass/routes/_admin-console.scss | 30 +- webapp/channels/src/selectors/views/admin.ts | 4 + webapp/channels/src/types/store/views.ts | 24 +- webapp/channels/src/utils/constants.tsx | 58 +- .../channels/src/utils/filter_users.test.ts | 60 - webapp/channels/src/utils/filter_users.ts | 51 - webapp/platform/client/src/client4.ts | 6 +- webapp/platform/types/src/client4.ts | 25 - webapp/platform/types/src/reports.ts | 84 ++ webapp/platform/types/src/users.ts | 8 - 108 files changed, 4701 insertions(+), 6095 deletions(-) delete mode 100644 webapp/channels/src/components/admin_console/system_users/__snapshots__/system_users.test.tsx.snap create mode 100644 webapp/channels/src/components/admin_console/system_users/constants/index.ts create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users.scss delete mode 100644 webapp/channels/src/components/admin_console/system_users/system_users.test.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_column_toggler_menu/index.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_column_toggler_menu/system_users_column_toggler.scss create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_date_range_menu/index.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_date_range_menu/system_users_date_range_menu.scss delete mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_dropdown/__snapshots__/system_users_dropdown.test.tsx.snap delete mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_dropdown/index.ts delete mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_dropdown/system_users_dropdown.test.tsx delete mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_dropdown/system_users_dropdown.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_export/export_error_modal.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_export/export_user_data_modal.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_export/index.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_export/system_users_export.scss create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_export/upgrade_export_data_modal.scss create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_export/upgrade_export_data_modal.tsx delete mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_filter_role/index.tsx delete mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_filter_team/index.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/index.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/styled_users_filters_status/index.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_popover.scss create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_role/index.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_team/async_team_select.scss create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_team/index.tsx delete mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_list/__snapshots__/system_users_list.test.tsx.snap delete mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_list/index.ts delete mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_list/selectors.test.ts delete mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_list/selectors.ts delete mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_list/system_users_list.test.tsx delete mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_list/system_users_list.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_list_actions/create_group_syncables_membership_modal.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_list_actions/deactivate_member_modal.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_list_actions/demote_to_guest_modal.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_list_actions/index.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_list_actions/promote_to_member_modal.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_list_actions/revoke_sessions_modal.tsx create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_search/system_users_search.scss create mode 100644 webapp/channels/src/components/admin_console/system_users/utils/index.test.ts create mode 100644 webapp/channels/src/components/admin_console/system_users/utils/index.tsx create mode 100644 webapp/channels/src/components/confirm_modal.scss create mode 100644 webapp/channels/src/components/styled_popover_container/index.tsx delete mode 100644 webapp/channels/src/utils/filter_users.test.ts delete mode 100644 webapp/channels/src/utils/filter_users.ts create mode 100644 webapp/platform/types/src/reports.ts diff --git a/e2e-tests/cypress/tests/integration/channels/bot_accounts/in_lists_1_spec.js b/e2e-tests/cypress/tests/integration/channels/bot_accounts/in_lists_1_spec.js index 5de4c85bd9..532f80808b 100644 --- a/e2e-tests/cypress/tests/integration/channels/bot_accounts/in_lists_1_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/bot_accounts/in_lists_1_spec.js @@ -12,6 +12,7 @@ import {createBotPatch} from '../../../support/api/bots'; import {generateRandomUser} from '../../../support/api/user'; +import * as TIMEOUTS from '../../../fixtures/timeouts'; describe('Bots in lists', () => { let team; @@ -59,13 +60,13 @@ describe('Bots in lists', () => { bots.forEach(({username}) => { // # Search for bot - cy.get('#searchUsers').clear().type(`@${username}`); + cy.get('#input_searchTerm').clear().type(`${username}`).wait(TIMEOUTS.ONE_SEC); // * Verify bot not in list - cy.findByTestId('noUsersFound').should('have.text', 'No users found'); + cy.get('.noRows').should('have.text', 'No data'); // * Verify pseudo checksum total of non bot users - cy.get('#searchableUserListTotal').contains('0 users of').should('be.visible'); + cy.get('.adminConsoleListTabletOptionalHead > span').should('have.text', '0 users').should('be.visible'); }); }); }); diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/auth_sso/mfa_authentication_spec.js b/e2e-tests/cypress/tests/integration/channels/enterprise/auth_sso/mfa_authentication_spec.js index 98b7443832..13ec3ebc9c 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/auth_sso/mfa_authentication_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/auth_sso/mfa_authentication_spec.js @@ -58,11 +58,10 @@ describe('Authentication', () => { // # Navigate to System Console -> User Management -> Users cy.visit('/admin_console/user_management/users'); - cy.get('#searchUsers', {timeout: TIMEOUTS.ONE_MIN}).type(testUser.email); + cy.findByPlaceholderText('Search users').type(testUser.username).wait(TIMEOUTS.HALF_SEC); // * Remove MFA option not available for the user - cy.wait(TIMEOUTS.HALF_SEC); - cy.findByTestId('userListRow').find('.MenuWrapper a').should('be.visible').click(); + cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC); cy.findByText('Remove MFA').should('not.exist'); cy.apiLogout(); @@ -105,11 +104,10 @@ describe('Authentication', () => { // # Navigate to System Console -> User Management -> Users cy.visit('/admin_console/user_management/users'); - cy.get('#searchUsers', {timeout: TIMEOUTS.ONE_MIN}).type(testUser.email); + cy.findByPlaceholderText('Search users').type(testUser.username).wait(TIMEOUTS.HALF_SEC); // * Remove MFA option available for the user and click it - cy.wait(TIMEOUTS.HALF_SEC); - cy.findByTestId('userListRow').find('.MenuWrapper a').should('be.visible').click(); + cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC); cy.findByText('Remove MFA').should('be.visible').click(); // # Navigate to System Console -> Authentication -> MFA Page. @@ -121,6 +119,7 @@ describe('Authentication', () => { // # Click "Save". cy.findByText('Save').click().wait(TIMEOUTS.ONE_SEC); + cy.apiLogout(); // # Login as test user cy.apiLogin(testUser); @@ -138,11 +137,11 @@ describe('Authentication', () => { // # Navigate to System Console -> User Management -> Users cy.visit('/admin_console/user_management/users'); - cy.get('#searchUsers', {timeout: TIMEOUTS.ONE_MIN}).type(testUser.email); + cy.findByPlaceholderText('Search users').type(testUser.username).wait(TIMEOUTS.HALF_SEC); // * Remove MFA option available for the user and click it cy.wait(TIMEOUTS.HALF_SEC); - cy.findByTestId('userListRow').find('.MenuWrapper a').should('be.visible').click(); + cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC); cy.findByText('Remove MFA').should('not.exist'); // # Done with that MFA stuff so we disable it all diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_invitation_ui_more_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_invitation_ui_more_spec.ts index d09655bcb8..3782836229 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_invitation_ui_more_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/guest_invitation_ui_more_spec.ts @@ -127,12 +127,11 @@ describe('Guest Account - Guest User Invitation Flow', () => { cy.visit('/admin_console/user_management/users'); // # Search for User by username and select the option to update email - cy.get('#searchUsers').should('be.visible').type(guest.username); + cy.findByPlaceholderText('Search users').should('be.visible').type(guest.username).wait(TIMEOUTS.ONE_SEC); // # Click on the option to update email - cy.wait(TIMEOUTS.HALF_SEC); - cy.findByTestId('userListRow').find('.MenuWrapper a').should('be.visible').click(); - cy.findByText('Update Email').should('be.visible').click(); + cy.get('#systemUsersTable-cell-0_actionsColumn').click(); + cy.findByText('Update email').should('be.visible').click(); // * Update email outside whitelisted domain and verify error message cy.findByTestId('resetEmailModal').should('be.visible').within(() => { diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_spec.ts b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_spec.ts index 0e6e7c8aa5..796198ab93 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_spec.ts +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/guest_accounts/system_console_manage_guest_spec.ts @@ -18,8 +18,6 @@ import * as TIMEOUTS from '../../../../fixtures/timeouts'; import {getRandomId} from '../../../../utils'; import {getAdminAccount} from '../../../../support/env'; -import {verifyGuest} from './helpers'; - describe('Guest Account - Verify Manage Guest Users', () => { const admin = getAdminAccount(); let guestUser: Cypress.UserProfile; @@ -60,18 +58,15 @@ describe('Guest Account - Verify Manage Guest Users', () => { cy.reload(); // # Search for Guest User by username - cy.get('#searchUsers', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible').type(guestUser.username); + cy.findByPlaceholderText('Search users').should('be.visible').type(guestUser.username).wait(TIMEOUTS.TWO_SEC); }); it('MM-T1391 Verify the manage options displayed for Guest User', () => { - // * Verify Guest user - verifyGuest(); - - // # Click on the Manage User option - cy.wait(TIMEOUTS.HALF_SEC).findByTestId('userListRow').find('.MenuWrapper a').should('be.visible').click(); + // * Verify Guest user and Click on the Manage User option + cy.get('#systemUsersTable-cell-0_actionsColumn').should('have.text', 'Guest').click(); // * Verify the manage options which should be displayed for Guest User - const includeOptions = ['Deactivate', 'Manage Roles', 'Manage Teams', 'Reset Password', 'Update Email', 'Promote to Member', 'Revoke Sessions']; + const includeOptions = ['Deactivate', 'Manage roles', 'Manage teams', 'Reset password', 'Update email', 'Promote to member', 'Remove sessions']; includeOptions.forEach((includeOption) => { cy.findByText(includeOption).should('be.visible'); }); @@ -85,8 +80,8 @@ describe('Guest Account - Verify Manage Guest Users', () => { it('MM-18048 Change Email of a Guest User and Verify', () => { // # Click on the Update Email option - cy.wait(TIMEOUTS.HALF_SEC).findByTestId('userListRow').find('.MenuWrapper a').should('be.visible').click(); - cy.wait(TIMEOUTS.HALF_SEC).findByText('Update Email').click(); + cy.get('#systemUsersTable-cell-0_actionsColumn').should('have.text', 'Guest').click(); + cy.findByText('Update email').click(); // * Update email of Guest User const email = `temp-${getRandomId()}@mattermost.com`; @@ -100,14 +95,14 @@ describe('Guest Account - Verify Manage Guest Users', () => { // # Reload and verify if behavior is same cy.reload(); - cy.get('#searchUsers').should('be.visible').type(guestUser.username); + cy.findByPlaceholderText('Search users').should('be.visible').type(guestUser.username); cy.findByText(email).should('be.visible'); }); it('MM-18048 Revoke Session of a Guest User and Verify', () => { // # Click on the Revoke Session option - cy.wait(TIMEOUTS.HALF_SEC).findByTestId('userListRow').find('.MenuWrapper a').should('be.visible').click(); - cy.wait(TIMEOUTS.HALF_SEC).findByText('Revoke Sessions').click(); + cy.get('#systemUsersTable-cell-0_actionsColumn').should('have.text', 'Guest').click(); + cy.findByText('Remove sessions').click(); // * Verify the confirmation message displayed cy.get('#confirmModal').should('be.visible').within(() => { diff --git a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/authentication_method_spec.js b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/authentication_method_spec.js index c7ffc105d7..183479c32f 100644 --- a/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/authentication_method_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/enterprise/system_console/authentication_method_spec.js @@ -65,44 +65,36 @@ describe('Settings', () => { const adminUsername = getAdminAccount().username; // # Type sysadmin - cy.get('#searchUsers').clear().type(adminUsername).wait(TIMEOUTS.HALF_SEC); + cy.findByPlaceholderText('Search users').clear().type(adminUsername).wait(TIMEOUTS.TWO_SEC); - // * Verify sign-in method - cy.findByTestId('userListRow').within(() => { - cy.get('.more-modal__details'). - should('be.visible'). - and('contain.text', 'Sign-in Method: Email'); - }); + // * Verify sign-in method from user profile + cy.get('.displayName').click(); + cy.get('.AdminUserCard__body > :nth-child(4) > span:nth-child(3)').should('have.text', 'Email'); + cy.get('a.back').click(); // # Type saml user - cy.get('#searchUsers').clear().type(samlUser.username).wait(TIMEOUTS.HALF_SEC); + cy.findByPlaceholderText('Search users').clear().type(samlUser.username).wait(TIMEOUTS.TWO_SEC); - // * Verify sign-in method - cy.findByTestId('userListRow').within(() => { - cy.get('.more-modal__details'). - should('be.visible'). - and('contain.text', 'Sign-in Method: SAML'); - }); + // * Verify sign-in method from user profile + cy.get('.displayName').click(); + cy.get('.AdminUserCard__body > :nth-child(4) > span:nth-child(3)').should('have.text', 'SAML'); + cy.get('a.back').click(); // # Type ldap user - cy.get('#searchUsers').clear().type(ldapUser.username).wait(TIMEOUTS.HALF_SEC); + cy.findByPlaceholderText('Search users').clear().type(ldapUser.username).wait(TIMEOUTS.TWO_SEC); - // * Verify sign-in method - cy.findByTestId('userListRow').within(() => { - cy.get('.more-modal__details'). - should('be.visible'). - and('contain.text', 'Sign-in Method: LDAP'); - }); + // * Verify sign-in method from user profile + cy.get('.displayName').click(); + cy.get('.AdminUserCard__body > :nth-child(4) > span:nth-child(3)').should('have.text', 'LDAP'); + cy.get('a.back').click(); // # Type mfa user - cy.get('#searchUsers').clear().type(mfaUser.username).wait(TIMEOUTS.HALF_SEC); + cy.findByPlaceholderText('Search users').clear().type(mfaUser.username).wait(TIMEOUTS.TWO_SEC); - // * Verify sign-in method - cy.findByTestId('userListRow').within(() => { - cy.get('.more-modal__details'). - should('be.visible'). - and('contain.text', 'MFA: Yes'); - }); + // * Verify sign-in method from user profile + cy.get('.displayName').click(); + cy.get('.AdminUserCard__body > :nth-child(4) > span:nth-child(3)').should('contain.text', 'MFA'); + cy.get('a.back').click(); }); }); diff --git a/e2e-tests/cypress/tests/integration/channels/system_console/user_management/users_spec.js b/e2e-tests/cypress/tests/integration/channels/system_console/user_management/users_spec.js index f39a056cd6..7a0d7f34bd 100644 --- a/e2e-tests/cypress/tests/integration/channels/system_console/user_management/users_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/system_console/user_management/users_spec.js @@ -49,7 +49,7 @@ describe('System Console > User Management > Users', () => { wait(TIMEOUTS.HALF_SEC); // # Click on the searched user name - cy.findByText(`@${testUser.username}`). + cy.findByText(`${testUser.username}`). should('be.visible'). click({force: true}); @@ -67,15 +67,13 @@ describe('System Console > User Management > Users', () => { it('MM-T932 Users - Change a user\'s password', () => { // # Search for the user. - cy.get('#searchUsers').type(testUser.email).wait(TIMEOUTS.HALF_SEC); + cy.findByPlaceholderText('Search users').type(testUser.email).wait(TIMEOUTS.HALF_SEC); // # Open the actions menu. - cy.get('[data-testid="userListRow"] .more-modal__right .more-modal__actions .MenuWrapper .text-right a'). - click().wait(TIMEOUTS.HALF_SEC); + cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC); // # Click the Reset Password menu option. - cy.get('[data-testid="userListRow"] .more-modal__right .more-modal__actions .MenuWrapper .MenuWrapperAnimation-enter-done'). - find('li').eq(3).click().wait(TIMEOUTS.HALF_SEC); + cy.findByText('Reset password').click(); // # Type new password and submit. cy.get('input[type=password]').type('new' + testUser.password); @@ -99,22 +97,20 @@ describe('System Console > User Management > Users', () => { it('MM-T933 Users - System admin changes own password - Cancel out of changes', () => { // # Search for the admin. - cy.get('#searchUsers').type(otherAdmin.username).wait(TIMEOUTS.HALF_SEC); + cy.findByPlaceholderText('Search users').type(otherAdmin.username).wait(TIMEOUTS.HALF_SEC); // # Open the actions menu. - cy.get('[data-testid="userListRow"] .more-modal__right .more-modal__actions .MenuWrapper .text-right a'). - click().wait(TIMEOUTS.HALF_SEC); + cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC); // # Click the Reset Password menu option. - cy.get('[data-testid="userListRow"] .more-modal__right .more-modal__actions .MenuWrapper .MenuWrapperAnimation-enter-done'). - find('li').eq(2).click().wait(TIMEOUTS.HALF_SEC); + cy.findByText('Reset password').click(); // # Type current password and a new password. cy.get('input[type=password]').eq(0).type(otherAdmin.password); cy.get('input[type=password]').eq(1).type('new' + otherAdmin.password); // # Click the 'Cancel' button. - cy.get('button[type=button].btn.btn-link').should('contain', 'Cancel').click().wait(TIMEOUTS.HALF_SEC); + cy.get('.modal-footer > .btn-tertiary').should('contain', 'Cancel').click().wait(TIMEOUTS.HALF_SEC); // # Log out. cy.apiLogout(); @@ -125,15 +121,13 @@ describe('System Console > User Management > Users', () => { it('MM-T934 Users - System admin changes own password - Incorrect old password', () => { // # Search for the admin. - cy.get('#searchUsers').type(otherAdmin.username).wait(TIMEOUTS.HALF_SEC); + cy.findByPlaceholderText('Search users').type(otherAdmin.username).wait(TIMEOUTS.HALF_SEC); // # Open the actions menu. - cy.get('[data-testid="userListRow"] .more-modal__right .more-modal__actions .MenuWrapper .text-right a'). - click().wait(TIMEOUTS.HALF_SEC); + cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC); // # Click the Reset Password menu option. - cy.get('[data-testid="userListRow"] .more-modal__right .more-modal__actions .MenuWrapper .MenuWrapperAnimation-enter-done'). - find('li').eq(2).click().wait(TIMEOUTS.HALF_SEC); + cy.findByText('Reset password').click(); // # Type wrong current password and a new password. cy.get('input[type=password]').eq(0).type('wrong' + otherAdmin.password); @@ -149,15 +143,13 @@ describe('System Console > User Management > Users', () => { it('MM-T935 Users - System admin changes own password - Invalid new password', () => { // # Search for the admin. - cy.get('#searchUsers').type(otherAdmin.username).wait(TIMEOUTS.HALF_SEC); + cy.findByPlaceholderText('Search users').type(otherAdmin.username).wait(TIMEOUTS.HALF_SEC); // # Open the actions menu. - cy.get('[data-testid="userListRow"] .more-modal__right .more-modal__actions .MenuWrapper .text-right a'). - click().wait(TIMEOUTS.HALF_SEC); + cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC); // # Click the Reset Password menu option. - cy.get('[data-testid="userListRow"] .more-modal__right .more-modal__actions .MenuWrapper .MenuWrapperAnimation-enter-done'). - find('li').eq(2).click().wait(TIMEOUTS.HALF_SEC); + cy.findByText('Reset password').click(); // # Type current password and a new too short password. cy.get('input[type=password]').eq(0).type(otherAdmin.password); @@ -173,15 +165,13 @@ describe('System Console > User Management > Users', () => { it('MM-T936 Users - System admin changes own password - Blank fields', () => { // # Search for the admin. - cy.get('#searchUsers').type(otherAdmin.username).wait(TIMEOUTS.HALF_SEC); + cy.findByPlaceholderText('Search users').type(otherAdmin.username).wait(TIMEOUTS.HALF_SEC); // # Open the actions menu. - cy.get('[data-testid="userListRow"] .more-modal__right .more-modal__actions .MenuWrapper .text-right a'). - click().wait(TIMEOUTS.HALF_SEC); + cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC); // # Click the Reset Password menu option. - cy.get('[data-testid="userListRow"] .more-modal__right .more-modal__actions .MenuWrapper .MenuWrapperAnimation-enter-done'). - find('li').eq(2).click().wait(TIMEOUTS.HALF_SEC); + cy.findByText('Reset password').click(); // # Click the 'Reset' button. cy.get('button[type=submit] span').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); @@ -203,15 +193,13 @@ describe('System Console > User Management > Users', () => { it('MM-T937 Users - System admin changes own password - Successfully changed', () => { // # Search for the admin. - cy.get('#searchUsers').type(otherAdmin.username).wait(TIMEOUTS.HALF_SEC); + cy.findByPlaceholderText('Search users').type(otherAdmin.username).wait(TIMEOUTS.HALF_SEC); // # Open the actions menu. - cy.get('[data-testid="userListRow"] .more-modal__right .more-modal__actions .MenuWrapper .text-right a'). - click().wait(TIMEOUTS.HALF_SEC); + cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC); // # Click the Reset Password menu option. - cy.get('[data-testid="userListRow"] .more-modal__right .more-modal__actions .MenuWrapper .MenuWrapperAnimation-enter-done'). - find('li').eq(2).click().wait(TIMEOUTS.HALF_SEC); + cy.findByText('Reset password').click(); // # Type current and new passwords.. cy.get('input[type=password]').eq(0).type(otherAdmin.password); diff --git a/e2e-tests/cypress/tests/integration/channels/system_console/user_management_spec.js b/e2e-tests/cypress/tests/integration/channels/system_console/user_management_spec.js index f3a096c561..021a377a98 100644 --- a/e2e-tests/cypress/tests/integration/channels/system_console/user_management_spec.js +++ b/e2e-tests/cypress/tests/integration/channels/system_console/user_management_spec.js @@ -52,20 +52,20 @@ describe('User Management', () => { cy.visit('/admin_console/user_management/users'); - cy.get('#searchableUserListTotal').then((el) => { + cy.get('.adminConsoleListTabletOptionalHead > span').then((el) => { const count1 = el[0].innerText.replace(/\n/g, '').replace(/\s/g, ' '); // * Can page through several pages of users. - cy.get('#searchableUserListNextBtn').should('be.visible').click(); + cy.get('.adminConsoleListTabletOptionalHead > .paginationButtons > [aria-label="Go to next page"]').should('be.visible').click(); // * Count at top changes appropriately. - cy.get('#searchableUserListTotal').then((el2) => { + cy.get('.adminConsoleListTabletOptionalHead > span').then((el2) => { const count2 = el2[0].innerText.replace(/\n/g, '').replace(/\s/g, ' '); expect(count1).not.equal(count2); }); // * Can page backward as well. - cy.get('#searchableUserListPrevBtn').should('be.visible').click(); + cy.get('.adminConsoleListTabletOptionalHead > .paginationButtons > [aria-label="Go to previous page"]').should('be.visible').click(); }); }); @@ -103,10 +103,8 @@ describe('User Management', () => { resetUserEmail(testUser.email, newEmailAddr, ''); // # Updates immediately in Profile for the user. - cy.get('#searchUsers').clear().type(newEmailAddr).wait(TIMEOUTS.HALF_SEC); - cy.get('.more-modal__details').should('be.visible').within(() => { - cy.findByText(newEmailAddr).should('exist'); - }); + cy.get('#input_searchTerm').clear().type(newEmailAddr).wait(TIMEOUTS.HALF_SEC); + cy.get('#systemUsersTable-cell-0_emailColumn').should('have.text', newEmailAddr); // * User also receives email confirmation that email address has been changed. checkResetEmail(testUser, newEmailAddr); @@ -186,15 +184,21 @@ describe('User Management', () => { cy.apiUpdateUserAuth(gitlabUser.id, gitlabUser.email, '', 'gitlab'); // # Search for the user. - cy.get('#searchUsers').clear().type(gitlabUser.email).wait(TIMEOUTS.HALF_SEC); + cy.get('#input_searchTerm').clear().type(gitlabUser.email).wait(TIMEOUTS.HALF_SEC); - cy.findByTestId('userListRow').within(() => { - // # Open the actions menu. - cy.findByText('Member').click().wait(TIMEOUTS.HALF_SEC); + // # Open actions menu. + cy.get('#systemUsersTable-cell-0_actionsColumn').click(); - // # Click the Update email menu option. - cy.findByLabelText('User Actions Menu').findByText('Update Email').should('not.exist'); - }); + // * Verify Switch to Email/Password is visible. + cy.findByText('Switch to Email/Password').should('be.visible').click().wait(TIMEOUTS.HALF_SEC); + + // # Set new password. + cy.get('input[type=password]').type('new' + testUser.password); + cy.get('button[type=submit]').should('contain', 'Reset').click().wait(TIMEOUTS.HALF_SEC); + + // * Verify Update email option is visible. + cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC); + cy.findByText('Update email').should('be.visible'); }); }); @@ -222,15 +226,10 @@ describe('User Management', () => { cy.visit('/admin_console/user_management/users'); // # Search for the user. - cy.get('#searchUsers').clear().type(oldEmail).wait(TIMEOUTS.HALF_SEC); + cy.get('#input_searchTerm').clear().type(oldEmail).wait(TIMEOUTS.HALF_SEC); - cy.findByTestId('userListRow').within(() => { - // # Open the actions menu. - cy.findByText('Member').click().wait(TIMEOUTS.HALF_SEC); - - // # Click the Update email menu option. - cy.findByLabelText('User Actions Menu').findByText('Update Email').click().wait(TIMEOUTS.HALF_SEC); - }); + cy.get('#systemUsersTable-cell-0_actionsColumn').click().wait(TIMEOUTS.HALF_SEC); + cy.findByText('Update email').click().wait(TIMEOUTS.HALF_SEC); // # Verify the modal opened. cy.findByTestId('resetEmailModal').should('exist'); diff --git a/server/channels/app/report.go b/server/channels/app/report.go index 89e41f7ea7..ab8894de5b 100644 --- a/server/channels/app/report.go +++ b/server/channels/app/report.go @@ -54,8 +54,8 @@ func (a *App) CompileReportChunks(format string, prefix string, numberOfChunks i func (a *App) compileCSVChunks(prefix string, numberOfChunks int, headers []string) *model.AppError { filePath := makeCompiledFilePath(prefix, "csv") - var headerBuf bytes.Buffer - w := csv.NewWriter(&headerBuf) + var compiledBuf bytes.Buffer + w := csv.NewWriter(&compiledBuf) err := w.Write(headers) if err != nil { return model.NewAppError("compileCSVChunks", "app.compile_csv_chunks.header_error", nil, "", http.StatusInternalServerError).Wrap(err) @@ -64,10 +64,6 @@ func (a *App) compileCSVChunks(prefix string, numberOfChunks int, headers []stri if err = w.Error(); err != nil { return model.NewAppError("saveCSVChunk", "app.save_csv_chunk.write_error", nil, "", http.StatusInternalServerError).Wrap(err) } - _, appErr := a.WriteFile(&headerBuf, filePath) - if appErr != nil { - return appErr - } for i := 0; i < numberOfChunks; i++ { chunkFilePath := makeFilePath(prefix, i, "csv") @@ -75,11 +71,17 @@ func (a *App) compileCSVChunks(prefix string, numberOfChunks int, headers []stri if err != nil { return err } - if _, err = a.AppendFile(bytes.NewReader(chunk), filePath); err != nil { + _, writeErr := compiledBuf.Write(chunk) + if writeErr != nil { return err } } + _, appErr := a.WriteFile(&compiledBuf, filePath) + if appErr != nil { + return appErr + } + return nil } @@ -230,7 +232,7 @@ func (a *App) StartUsersBatchExport(rctx request.CTX, dateRange string, startAt } } - _, err = a.Srv().Jobs.CreateJobOnce(rctx, model.JobTypeExportUsersToCSV, options) + _, err = a.Srv().Jobs.CreateJob(rctx, model.JobTypeExportUsersToCSV, options) if err != nil { return err } diff --git a/server/public/model/report.go b/server/public/model/report.go index 4e3ac2e9d7..7a9ebb407a 100644 --- a/server/public/model/report.go +++ b/server/public/model/report.go @@ -145,6 +145,7 @@ func (u *UserReportOptions) IsValid() *AppError { } func (u *UserReportQuery) ToReport() *UserReport { + u.ClearNonProfileFields() return &UserReport{ User: u.User, UserPostStats: u.UserPostStats, diff --git a/webapp/channels/src/actions/views/admin.ts b/webapp/channels/src/actions/views/admin.ts index 1221a77674..0fddb9f956 100644 --- a/webapp/channels/src/actions/views/admin.ts +++ b/webapp/channels/src/actions/views/admin.ts @@ -1,11 +1,82 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {ServerError} from '@mattermost/types/errors'; +import type {UserReportOptions, UserReport, UserReportFilter, ReportDuration} from '@mattermost/types/reports'; + +import {logError} from 'mattermost-redux/actions/errors'; +import {forceLogoutIfNecessary} from 'mattermost-redux/actions/helpers'; +import {Client4} from 'mattermost-redux/client'; +import type {ActionFuncAsync} from 'mattermost-redux/types/actions'; + import {ActionTypes} from 'utils/constants'; +import type {AdminConsoleUserManagementTableProperties} from 'types/store/views'; + export function setNeedsLoggedInLimitReachedCheck(data: boolean) { return { type: ActionTypes.NEEDS_LOGGED_IN_LIMIT_REACHED_CHECK, data, }; } + +/** + * Action to set the properties of the admin console user management table. Only pass the properties you want to set/modify. If you pass no properties, the table properties will be cleared. + */ +export function setAdminConsoleUsersManagementTableProperties(data?: Partial) { + if (!data) { + return { + type: ActionTypes.CLEAR_ADMIN_CONSOLE_USER_MANAGEMENT_TABLE_PROPERTIES, + data: null, + }; + } + + return { + type: ActionTypes.SET_ADMIN_CONSOLE_USER_MANAGEMENT_TABLE_PROPERTIES, + data, + }; +} + +export function getUserReports(options = {} as UserReportOptions): ActionFuncAsync { + return async (dispatch, getState) => { + let data; + try { + data = await Client4.getUsersForReporting(options); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error: error as ServerError}; + } + + return {data}; + }; +} + +export function getUserCountForReporting(filter = {} as UserReportFilter): ActionFuncAsync { + return async (dispatch, getState) => { + let data; + try { + data = await Client4.getUserCountForReporting(filter); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error: error as ServerError}; + } + + return {data}; + }; +} + +export function startUsersBatchExport(dateRange: ReportDuration): ActionFuncAsync { + return async (dispatch, getState) => { + try { + await Client4.startUsersBatchExport(dateRange); + } catch (error) { + forceLogoutIfNecessary(error, dispatch, getState); + dispatch(logError(error)); + return {error: error as ServerError}; + } + + return {data: true}; + }; +} diff --git a/webapp/channels/src/actions/views/search.js b/webapp/channels/src/actions/views/search.js index 7aa954b21e..afda672b1b 100644 --- a/webapp/channels/src/actions/views/search.js +++ b/webapp/channels/src/actions/views/search.js @@ -45,13 +45,6 @@ export function setUserGridFilters(filters = {}) { }; } -export function setSystemUsersSearch(term, team = '', filter = '') { - return { - type: SearchTypes.SET_SYSTEM_USERS_SEARCH, - data: {term, team, filter}, - }; -} - export function setTeamListSearch(term) { return { type: SearchTypes.SET_TEAM_LIST_SEARCH, diff --git a/webapp/channels/src/components/admin_console/admin_console.tsx b/webapp/channels/src/components/admin_console/admin_console.tsx index f05e6b09d3..49abfe768d 100644 --- a/webapp/channels/src/components/admin_console/admin_console.tsx +++ b/webapp/channels/src/components/admin_console/admin_console.tsx @@ -71,6 +71,9 @@ class AdminConsole extends React.PureComponent { document.body.classList.remove('console__body'); document.getElementById('root')?.classList.remove('console__root'); applyTheme(this.props.currentTheme); + + // Reset the admin console users management table properties + this.props.actions.setAdminConsoleUsersManagementTableProperties(); } private handleSearchChange = (search: string) => { diff --git a/webapp/channels/src/components/admin_console/admin_user_card/__snapshots__/admin_user_card.test.tsx.snap b/webapp/channels/src/components/admin_console/admin_user_card/__snapshots__/admin_user_card.test.tsx.snap index e0df87ae87..8668b1edc2 100644 --- a/webapp/channels/src/components/admin_console/admin_user_card/__snapshots__/admin_user_card.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/admin_user_card/__snapshots__/admin_user_card.test.tsx.snap @@ -47,9 +47,7 @@ exports[`components/admin_console/admin_user_card/admin_user_card should match d
- User ID: - - 1234 + User ID: 1234
- User ID: - - 1234 + User ID: 1234
- User ID: - - 1234 + User ID: 1234
- User ID: - - 1234 + User ID: 1234
= (props: BulletProps) => { - if ((props.user.first_name || props.user.last_name) && props.user.nickname) { - return ({' • '}); - } - return null; -}; +const AdminUserCard = ({isLoading = false, ...props}: Props) => { + if (!props.user || isLoading) { + return ( +
+
+ +
+
+ {isLoading && } + {!isLoading && + + } +
+ +
+ ); + } -const AdminUserCard: React.FC = (props: Props) => { return (
@@ -46,7 +64,13 @@ const AdminUserCard: React.FC = (props: Props) => { {props.user.nickname}
- {Utils.localizeMessage('admin.userManagement.userDetail.userId', 'User ID:')} {props.user.id} +
@@ -58,4 +82,11 @@ const AdminUserCard: React.FC = (props: Props) => {
); }; +const Bullet = (props: BulletProps) => { + if ((props.user.first_name || props.user.last_name) && props.user.nickname) { + return ({' • '}); + } + return null; +}; + export default AdminUserCard; diff --git a/webapp/channels/src/components/admin_console/index.ts b/webapp/channels/src/components/admin_console/index.ts index 2435a57737..1924478042 100644 --- a/webapp/channels/src/components/admin_console/index.ts +++ b/webapp/channels/src/components/admin_console/index.ts @@ -18,6 +18,7 @@ import {getTeam} from 'mattermost-redux/selectors/entities/teams'; import {isCurrentUserSystemAdmin, currentUserHasAnAdminRole, getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; import {setNavigationBlocked, deferNavigation, cancelNavigation, confirmNavigation} from 'actions/admin_actions.jsx'; +import {setAdminConsoleUsersManagementTableProperties} from 'actions/views/admin'; import {selectLhsItem} from 'actions/views/lhs'; import {getAdminDefinition, getConsoleAccess} from 'selectors/admin_console'; import {showNavigationPrompt} from 'selectors/views/admin'; @@ -68,6 +69,7 @@ function mapDispatchToProps(dispatch: Dispatch) { editRole, selectLhsItem, selectTeam, + setAdminConsoleUsersManagementTableProperties, }, dispatch), }; } diff --git a/webapp/channels/src/components/admin_console/list_table/index.ts b/webapp/channels/src/components/admin_console/list_table/index.ts index f2354e4170..87264bcc06 100644 --- a/webapp/channels/src/components/admin_console/list_table/index.ts +++ b/webapp/channels/src/components/admin_console/list_table/index.ts @@ -1,10 +1,7 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -export {useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper} from '@tanstack/react-table'; -export type {CellContext, PaginationState, SortingState, OnChangeFn, ColumnDef} from '@tanstack/react-table'; - -export {ListTable as AdminConsoleListTable, PAGE_SIZES} from './list_table'; +export {ListTable as AdminConsoleListTable, PAGE_SIZES, LoadingStates} from './list_table'; export type {TableMeta, PageSizeOption} from './list_table'; export {ElapsedDurationCell} from './elapsed_duration_cell'; diff --git a/webapp/channels/src/components/admin_console/list_table/list_table.scss b/webapp/channels/src/components/admin_console/list_table/list_table.scss index 4fa0ae9fb8..be073614b2 100644 --- a/webapp/channels/src/components/admin_console/list_table/list_table.scss +++ b/webapp/channels/src/components/admin_console/list_table/list_table.scss @@ -1,3 +1,13 @@ +@mixin textElipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.adminConsoleListTableContainer { + overflow: auto; +} + table.adminConsoleListTable { // This is rgba(var(--sys-center-channel-color-rgb), 0.08) but since we cannot use opacity // due to sticky row which shows the background thus a defined color is used @@ -17,10 +27,10 @@ table.adminConsoleListTable { white-space: nowrap; th { - max-width: 50rem; background-color: var(--sys-center-channel-bg); padding-block-end: 12px; padding-block-start: 12px; + vertical-align: middle; &:first-child { padding-inline-end: 12px; @@ -42,7 +52,6 @@ table.adminConsoleListTable { z-index: 1; right: 0; left: 0; - min-width: 20rem; background-color: var(--sys-center-channel-bg); &:first-child { @@ -81,8 +90,6 @@ table.adminConsoleListTable { } &.sortable { - vertical-align: middle; - &:hover { background-color: $sysCenterChannelColorWith8Alpha; cursor: pointer; @@ -103,25 +110,6 @@ table.adminConsoleListTable { } } } - - &.sortable.pinned { - display: flex; - align-items: center; - justify-content: space-between; - - & > span { - &:first-child { - flex-grow: 1; - justify-content: flex-start; - } - } - } - - &.pinned:not(.sortable) { - display: flex; - align-items: center; - justify-content: space-between; - } } } } @@ -144,6 +132,11 @@ table.adminConsoleListTable { td.pinned { background-color: $sysCenterChannelColorWith8Alpha; } + + td.noRows { + background-color: var(--sys-center-channel-bg); + cursor: default; + } } td { @@ -203,6 +196,10 @@ table.adminConsoleListTable { } } } + + &.noRows { + text-align: center; + } } } } @@ -233,11 +230,14 @@ table.adminConsoleListTable { .adminConsoleListTablePageSize { display: flex; flex-direction: row; + flex-grow: 1; align-items: center; - justify-content: center; + justify-content: flex-end; + justify-self: flex-end; + margin-inline-end: 12px; & > .react-select { - min-width: 64px; + min-width: 72px; margin: 0 6px; .react-select__value-container { diff --git a/webapp/channels/src/components/admin_console/list_table/list_table.tsx b/webapp/channels/src/components/admin_console/list_table/list_table.tsx index 39cd1818cb..dea9cfa1bd 100644 --- a/webapp/channels/src/components/admin_console/list_table/list_table.tsx +++ b/webapp/channels/src/components/admin_console/list_table/list_table.tsx @@ -10,10 +10,7 @@ import {FormattedMessage, defineMessages, useIntl} from 'react-intl'; import ReactSelect, {components} from 'react-select'; import type {IndicatorContainerProps, ValueType} from 'react-select'; -import OverlayTrigger from 'components/overlay_trigger'; -import Tooltip from 'components/tooltip'; - -import Constants from 'utils/constants'; +import LoadingSpinner from 'components/widgets/loading/loading_spinner'; import {Pagination} from './pagination'; @@ -47,18 +44,24 @@ export type PageSizeOption = { value: number; }; +export enum LoadingStates { + Loading = 'loading', + Loaded = 'loaded', + Failed = 'failed', +} + export type TableMeta = { tableId: string; tableCaption?: string; - isLoading?: boolean; + loadingState?: LoadingStates; + emptyDataMessage?: ReactNode; onRowClick?: (row: string) => void; disablePrevPage?: boolean; disableNextPage?: boolean; onPreviousPageClick?: () => void; onNextPageClick?: () => void; paginationInfo?: ReactNode; - totalRowInfo?: ReactNode; - hasAdditionalPaginationAtTop?: boolean; + hasDualSidedPagination?: boolean; }; interface TableMandatoryTypes { @@ -111,24 +114,28 @@ export function ListTable( } } + const colCount = props.table.getAllColumns().length; + const rowCount = props.table.getRowModel().rows.length; + return ( - <> +
- {tableMeta.totalRowInfo} - {tableMeta.hasAdditionalPaginationAtTop && ( - + {tableMeta.hasDualSidedPagination && ( + <> + {tableMeta.paginationInfo} + + )}
( id={`${headerIdPrefix}${header.id}`} colSpan={header.colSpan} scope='col' - role='columnheader' aria-sort={getAriaSortForTableHeader(header.column.getCanSort(), header.column.getIsSorted())} className={classNames(`${header.id}`, { [SORTABLE_CLASS]: header.column.getCanSort(), [PINNED_CLASS]: header.column.getCanPin(), })} + disabled={header.column.getCanSort() && tableMeta.loadingState === LoadingStates.Loading} onClick={header.column.getToggleSortingHandler()} > {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} @@ -176,29 +183,6 @@ export function ListTable( className='icon icon-arrow-up hoverSortingIcon' /> )} - - {/* Pinned Icon */} - {header.column.getCanPin() && ( - - - - } - > - - )} ))} @@ -209,14 +193,12 @@ export function ListTable( {row.getVisibleCells().map((cell) => ( ))} + + {/* State where it is initially loading the data */} + {(tableMeta.loadingState === LoadingStates.Loading && rowCount === 0) && ( + + + + )} + + {/* State where there is no data */} + {(tableMeta.loadingState === LoadingStates.Loaded && rowCount === 0) && ( + + + + )} + + {/* State where there is an error loading the data */} + {tableMeta.loadingState === LoadingStates.Failed && ( + + + + )} {props.table.getFooterGroups().map((footerGroup) => ( @@ -247,16 +270,11 @@ export function ListTable(
( ))}
+ +
+ {tableMeta.emptyDataMessage || formatMessage({id: 'adminConsole.list.table.genericNoData', defaultMessage: 'No data'})} +
+ {formatMessage({id: 'adminConsole.list.table.genericError', defaultMessage: 'There was an error loading the data, please try again'})} +
+ {tableMeta.paginationInfo} {handlePageSizeChange && (
( options={pageSizeOptions} value={selectedPageSize} onChange={handlePageSizeChange} - isDisabled={tableMeta.isLoading} + isDisabled={tableMeta.loadingState === LoadingStates.Loading} components={{ IndicatorSeparator: null, IndicatorsContainer, @@ -288,13 +306,12 @@ export function ListTable(
- +
); } @@ -311,7 +328,7 @@ function getAriaSortForTableHeader( sortDirection: boolean | SortDirection, ): AriaAttributes['aria-sort'] { if (!canSort) { - return 'none'; + return undefined; } if (sortDirection === 'asc') { diff --git a/webapp/channels/src/components/admin_console/list_table/pagination.tsx b/webapp/channels/src/components/admin_console/list_table/pagination.tsx index e851bf86b4..bda6fc4382 100644 --- a/webapp/channels/src/components/admin_console/list_table/pagination.tsx +++ b/webapp/channels/src/components/admin_console/list_table/pagination.tsx @@ -6,22 +6,21 @@ import {useIntl} from 'react-intl'; import type {TableMeta} from './list_table'; -type Props = Pick< -TableMeta, -| 'onPreviousPageClick' -| 'onNextPageClick' -| 'disablePrevPage' -| 'disableNextPage' -| 'isLoading' -| 'paginationInfo' ->; +interface Props + extends Pick { + isLoading?: boolean; +} export function Pagination(props: Props) { const {formatMessage} = useIntl(); return (
- {props.paginationInfo} {props.onPreviousPageClick && ( + } - user={ - Object { - "first_name": "Jim", - "id": "1234", - "last_name": "Halpert", - "nickname": "Big Tuna", - "roles": "system_user", - "username": "jim.halpert", - } - } + isLoading={true} />
@@ -159,7 +142,12 @@ exports[`components/admin_console/system_user_detail should match default snapsh extraClasses="" onClick={[Function]} saving={false} - savingMessage="Saving Config..." + savingMessage={ + + } />
- - -
- @@ -234,7 +197,7 @@ exports[`components/admin_console/system_user_detail should match default snapsh id="deactivate_member_modal.title" values={ Object { - "username": "jim.halpert", + "username": "", } } /> @@ -243,7 +206,7 @@ exports[`components/admin_console/system_user_detail should match default snapsh `; -exports[`components/admin_console/system_user_detail should match snapshot if LDAP Authentication 1`] = ` +exports[`SystemUserDetail should match snapshot if MFA is enabled 1`] = `
@@ -270,85 +233,66 @@ exports[`components/admin_console/system_user_detail should match snapshot if LD - - - Email + + -
- + + -
- - Username - -
- + +
- - Authentication Method - -
- - - LDAP - -
+ } footer={ - - Reset Password - - + + } - user={ - Object { - "auth_service": "ldap", - "first_name": "Jim", - "id": "1234", - "last_name": "Halpert", - "nickname": "Big Tuna", - "roles": "system_user", - "username": "jim.halpert", - } - } + isLoading={true} />
@@ -403,7 +348,12 @@ exports[`components/admin_console/system_user_detail should match snapshot if LD extraClasses="" onClick={[Function]} saving={false} - savingMessage="Saving Config..." + savingMessage={ + + } />
- - - - @@ -479,7 +403,7 @@ exports[`components/admin_console/system_user_detail should match snapshot if LD id="deactivate_member_modal.title" values={ Object { - "username": "jim.halpert", + "username": "", } } /> @@ -487,997 +411,3 @@ exports[`components/admin_console/system_user_detail should match snapshot if LD /> `; - -exports[`components/admin_console/system_user_detail should match snapshot if MFA is enabled 1`] = ` -
- -
- - -
-
-
-
- - - - Email - -
- - -
- - Username - -
- - - jim.halpert - -
- - Authentication Method - -
- - - Email, MFA - -
- - } - footer={ - - - Reset Password - - - Deactivate - - - Remove MFA - - - } - user={ - Object { - "first_name": "Jim", - "id": "1234", - "last_name": "Halpert", - "mfa_active": "MFA", - "nickname": "Big Tuna", - "roles": "system_user", - "username": "jim.halpert", - } - } - /> - - -
- } - subtitle={ - Object { - "defaultMessage": "Teams to which this user belongs", - "id": "admin.userManagement.userDetail.teamsSubtitle", - } - } - title={ - Object { - "defaultMessage": "Team Membership", - "id": "admin.userManagement.userDetail.teamsTitle", - } - } - > - - -
-
-
- - } - disabled={true} - extraClasses="" - onClick={[Function]} - saving={false} - savingMessage="Saving Config..." - /> -
- -
- - - -
- - - } - message={ -
- - -
-
- -
-
- } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={false} - title={ - - } - /> - -`; - -exports[`components/admin_console/system_user_detail should match snapshot if SAML Authentication 1`] = ` -
- -
- - -
-
-
-
- - - - Email - -
- - -
- - Username - -
- - - jim.halpert - -
- - Authentication Method - -
- - - SAML - -
- - } - footer={ - - - Reset Password - - - Deactivate - - - } - user={ - Object { - "auth_service": "saml", - "first_name": "Jim", - "id": "1234", - "last_name": "Halpert", - "nickname": "Big Tuna", - "roles": "system_user", - "username": "jim.halpert", - } - } - /> - - -
- } - subtitle={ - Object { - "defaultMessage": "Teams to which this user belongs", - "id": "admin.userManagement.userDetail.teamsSubtitle", - } - } - title={ - Object { - "defaultMessage": "Team Membership", - "id": "admin.userManagement.userDetail.teamsTitle", - } - } - > - - -
-
-
- - } - disabled={true} - extraClasses="" - onClick={[Function]} - saving={false} - savingMessage="Saving Config..." - /> -
- -
- - - -
- - - } - message={ -
- - -
-
- -
-
- } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={false} - title={ - - } - /> - -`; - -exports[`components/admin_console/system_user_detail should match snapshot if no nickname is defined 1`] = ` -
- -
- - -
-
-
-
- - - - Email - -
- - -
- - Username - -
- - - jim.halpert - -
- - Authentication Method - -
- - - Email - -
- - } - footer={ - - - Reset Password - - - Deactivate - - - } - user={ - Object { - "first_name": "Jim", - "id": "1234", - "last_name": "Halpert", - "nickname": null, - "roles": "system_user", - "username": "jim.halpert", - } - } - /> - - -
- } - subtitle={ - Object { - "defaultMessage": "Teams to which this user belongs", - "id": "admin.userManagement.userDetail.teamsSubtitle", - } - } - title={ - Object { - "defaultMessage": "Team Membership", - "id": "admin.userManagement.userDetail.teamsTitle", - } - } - > - - -
-
-
- - } - disabled={true} - extraClasses="" - onClick={[Function]} - saving={false} - savingMessage="Saving Config..." - /> -
- -
- - - -
- - - } - message={ -
- - -
-
- -
-
- } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={false} - title={ - - } - /> - -`; - -exports[`components/admin_console/system_user_detail should match snapshot if user is inactive 1`] = ` -
- -
- - -
-
-
-
- - - - Email - -
- - -
- - Username - -
- - - jim.halpert - -
- - Authentication Method - -
- - - Email - -
- - } - footer={ - - - Reset Password - - - Activate - - - } - user={ - Object { - "delete_at": 1561683854166, - "first_name": "Jim", - "id": "1234", - "last_name": "Halpert", - "nickname": "Big Tuna", - "roles": "system_user", - "username": "jim.halpert", - } - } - /> - - -
- } - subtitle={ - Object { - "defaultMessage": "Teams to which this user belongs", - "id": "admin.userManagement.userDetail.teamsSubtitle", - } - } - title={ - Object { - "defaultMessage": "Team Membership", - "id": "admin.userManagement.userDetail.teamsTitle", - } - } - > - - -
-
-
- - } - disabled={true} - extraClasses="" - onClick={[Function]} - saving={false} - savingMessage="Saving Config..." - /> -
- -
- - - -
- - - } - message={ -
- - -
-
- -
-
- } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={false} - title={ - - } - /> - -`; - -exports[`components/admin_console/system_user_detail should redirect if user id is not defined 1`] = ` - -`; diff --git a/webapp/channels/src/components/admin_console/system_user_detail/index.ts b/webapp/channels/src/components/admin_console/system_user_detail/index.ts index 277bd5d163..3fed0ac32b 100644 --- a/webapp/channels/src/components/admin_console/system_user_detail/index.ts +++ b/webapp/channels/src/components/admin_console/system_user_detail/index.ts @@ -1,49 +1,38 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {ConnectedProps} from 'react-redux'; import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import type {Dispatch} from 'redux'; import type {GlobalState} from '@mattermost/types/store'; import {addUserToTeam} from 'mattermost-redux/actions/teams'; -import {updateUserActive} from 'mattermost-redux/actions/users'; +import {updateUserActive, getUser, patchUser, updateUserMfa} from 'mattermost-redux/actions/users'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; -import {getUser} from 'mattermost-redux/selectors/entities/users'; import {setNavigationBlocked} from 'actions/admin_actions.jsx'; +import {openModal} from 'actions/views/modals'; import SystemUserDetail from './system_user_detail'; -type OwnProps = { - match: any; -}; - -function mapStateToProps(state: GlobalState, ownProps: OwnProps) { +function mapStateToProps(state: GlobalState) { const config = getConfig(state); - const userId = ownProps.match.params.user_id; - const user = getUser(state, userId); + return { - user, - mfaEnabled: config.EnableMultifactorAuthentication === 'true', + mfaEnabled: config?.EnableMultifactorAuthentication === 'true' || false, }; } -function mapDispatchToProps(dispatch: Dispatch) { - const apiActions = bindActionCreators({ - updateUserActive, - addUserToTeam, - }, dispatch); - const uiActions = bindActionCreators({ - setNavigationBlocked, - }, dispatch); +const mapDispatchToProps = { + getUser, + patchUser, + updateUserActive, + updateUserMfa, + addUserToTeam, + setNavigationBlocked, + openModal, +}; +const connector = connect(mapStateToProps, mapDispatchToProps); - const props = { - actions: Object.assign(apiActions, uiActions), - }; - - return props; -} - -export default connect(mapStateToProps, mapDispatchToProps)(SystemUserDetail); +export type PropsFromRedux = ConnectedProps; +export default connector(SystemUserDetail); diff --git a/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.scss b/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.scss index 62d2e8deb1..58925effbf 100644 --- a/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.scss +++ b/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.scss @@ -1,51 +1,40 @@ -@import 'utils/variables'; +.SystemUserDetail { + .AdminUserCard__body { + label { + display: block; + font-weight: unset; -.SystemUserDetail__field-label { - display: block; - margin-top: 12px; - margin-bottom: 4px; - font-size: 12px; - font-weight: bold; -} + > span:nth-child(1) { + display: block; + margin-top: 12px; + margin-bottom: 6px; + font-size: 12px; + font-weight: bold; + } -.SystemUserDetail__input { - display: inline-block; - max-width: 320px; - height: 40px; -} + > span:nth-child(2) { + vertical-align: middle; + } -.SystemUserDetail .add-team-button { - position: relative; - margin-left: 10px; -} + input { + display: inline-block; + max-width: 320px; + } + } -.SystemUserDetail .team-btn__content { - opacity: 1; -} + svg { + display: inline-block; + width: 16px; + height: 16px; + margin: 0 8px 0 0; + fill: rgba(var(--sys-color-sidebar-text-rgb), 0.6); + } + } -.SystemUserDetail__field-text { - vertical-align: middle; -} - -.SystemUserDetail__field-icon { - display: inline-block; - width: 16px; - height: 16px; - margin: 0 8px 0 0; - fill: rgba(61, 60, 64, 0.6); - vertical-align: middle; -} - -.SystemUserDetail__error { - padding-top: 8px; - padding-left: 15px; -} - -.SystemUserDetail__actions-menu-icon svg { - width: 18px; - color: #333; -} - -.SystemUserDetail .MenuItem .SystemUserDetail__action-remove-team { - color: #8b0008; + .teamlistLoading { + display: flex; + min-height: 20vh; + align-items: center; + justify-content: center; + } } diff --git a/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.test.tsx b/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.test.tsx index fda3ba5fce..c87ea1f29e 100644 --- a/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.test.tsx +++ b/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.test.tsx @@ -2,30 +2,39 @@ // See LICENSE.txt for license information. import React from 'react'; +import type {RouteComponentProps} from 'react-router-dom'; -import SystemUserDetail from 'components/admin_console/system_user_detail/system_user_detail'; +import type {UserProfile} from '@mattermost/types/users'; -import {shallowWithIntl} from 'tests/helpers/intl-test-helper'; -import {Constants} from 'utils/constants'; +import SystemUserDetail, {getUserAuthenticationTextField} from 'components/admin_console/system_user_detail/system_user_detail'; +import type { + Props, + Params, +} from 'components/admin_console/system_user_detail/system_user_detail'; -jest.mock('actions/admin_actions.jsx'); +import {shallowWithIntl, type MockIntl} from 'tests/helpers/intl-test-helper'; -describe('components/admin_console/system_user_detail', () => { - const defaultProps = { - user: { - username: 'jim.halpert', - first_name: 'Jim', - last_name: 'Halpert', - nickname: 'Big Tuna', - id: '1234', - roles: 'system_user', - }, - actions: { - updateUserActive: jest.fn(), - setNavigationBlocked: jest.fn(), - addUserToTeam: jest.fn(), - }, - } as any; +describe('SystemUserDetail', () => { + const defaultProps: Props = { + mfaEnabled: false, + patchUser: jest.fn(), + updateUserMfa: jest.fn(), + getUser: jest.fn(), + updateUserActive: jest.fn(), + setNavigationBlocked: jest.fn(), + addUserToTeam: jest.fn(), + openModal: jest.fn(), + intl: { + formatMessage: jest.fn(), + } as MockIntl, + ...({ + match: { + params: { + user_id: 'user_id', + }, + }, + } as RouteComponentProps), + }; test('should match default snapshot', () => { const props = defaultProps; @@ -33,76 +42,41 @@ describe('components/admin_console/system_user_detail', () => { expect(wrapper).toMatchSnapshot(); }); - test('should redirect if user id is not defined', () => { - const props = { - ...defaultProps, - user: { - id: null, - }, - }; - const wrapper = shallowWithIntl(); - expect(wrapper).toMatchSnapshot(); - }); - - test('should match snapshot if user is inactive', () => { - const props = { - ...defaultProps, - user: { - ...defaultProps.user, - delete_at: 1561683854166, - }, - }; - const wrapper = shallowWithIntl(); - expect(wrapper).toMatchSnapshot(); - }); - test('should match snapshot if MFA is enabled', () => { const props = { ...defaultProps, - user: { - ...defaultProps.user, - mfa_active: 'MFA', - }, - mfaEnabled: true, - }; - const wrapper = shallowWithIntl(); - expect(wrapper).toMatchSnapshot(); - }); - - test('should match snapshot if no nickname is defined', () => { - const props = { - ...defaultProps, - user: { - ...defaultProps.user, - nickname: null, - }, - }; - const wrapper = shallowWithIntl(); - expect(wrapper).toMatchSnapshot(); - }); - - test('should match snapshot if LDAP Authentication', () => { - const props = { - ...defaultProps, - user: { - ...defaultProps.user, - auth_service: Constants.LDAP_SERVICE, - }, - }; - const wrapper = shallowWithIntl(); - expect(wrapper).toMatchSnapshot(); - }); - - test('should match snapshot if SAML Authentication', () => { - const props = { - ...defaultProps, - user: { - ...defaultProps.user, - auth_service: Constants.SAML_SERVICE, - }, mfaEnabled: true, }; const wrapper = shallowWithIntl(); expect(wrapper).toMatchSnapshot(); }); }); + +describe('getUserAuthenticationTextField', () => { + const intl = {formatMessage: ({defaultMessage}) => defaultMessage} as MockIntl; + + it('should return empty string if user is not provided', () => { + const result = getUserAuthenticationTextField(intl, false, undefined); + expect(result).toEqual(''); + }); + + it('should return email if user has no auth service and MFA is not enabled', () => { + const result = getUserAuthenticationTextField(intl, false, {auth_service: '', mfa_active: false} as UserProfile); + expect(result).toEqual('Email'); + }); + + it('should return auth service in uppercase if it is LDAP or SAML', () => { + const result = getUserAuthenticationTextField(intl, false, {auth_service: 'ldap', mfa_active: false} as UserProfile); + expect(result).toEqual('LDAP'); + }); + + it('should return auth service in title case if it is not LDAP or SAML', () => { + const result = getUserAuthenticationTextField(intl, true, {auth_service: 'oauth', mfa_active: false} as UserProfile); + expect(result).toEqual('Oauth'); + }); + + it('should include MFA if user has MFA enabled', () => { + const result = getUserAuthenticationTextField(intl, true, {auth_service: 'oauth', mfa_active: true} as UserProfile); + expect(result).toEqual('Oauth, MFA'); + }); +}); diff --git a/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.tsx b/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.tsx index bfe142ff51..0e47c8ec4c 100644 --- a/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.tsx +++ b/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.tsx @@ -1,11 +1,10 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; -import {Overlay} from 'react-bootstrap'; -import type {WrappedComponentProps} from 'react-intl'; +import React, {PureComponent} from 'react'; +import type {ChangeEvent, MouseEvent} from 'react'; +import type {IntlShape, WrappedComponentProps} from 'react-intl'; import {FormattedMessage, defineMessage, injectIntl} from 'react-intl'; -import {Redirect} from 'react-router-dom'; import type {RouteComponentProps} from 'react-router-dom'; import type {ServerError} from '@mattermost/types/errors'; @@ -15,9 +14,6 @@ import type {UserProfile} from '@mattermost/types/users'; import type {ActionResult} from 'mattermost-redux/types/actions'; import {isEmail} from 'mattermost-redux/utils/helpers'; -import {adminResetMfa, adminResetEmail} from 'actions/admin_actions.jsx'; - -import AdminButtonOutline from 'components/admin_console/admin_button_outline/admin_button_outline'; import AdminUserCard from 'components/admin_console/admin_user_card/admin_user_card'; import BlockableLink from 'components/admin_console/blockable_link'; import ResetPasswordModal from 'components/admin_console/reset_password_modal'; @@ -26,343 +22,269 @@ import ConfirmModal from 'components/confirm_modal'; import FormError from 'components/form_error'; import SaveButton from 'components/save_button'; import TeamSelectorModal from 'components/team_selector_modal'; -import Tooltip from 'components/tooltip'; import AdminHeader from 'components/widgets/admin_console/admin_header'; import AdminPanel from 'components/widgets/admin_console/admin_panel'; import AtIcon from 'components/widgets/icons/at_icon'; import EmailIcon from 'components/widgets/icons/email_icon'; import SheidOutlineIcon from 'components/widgets/icons/shield_outline_icon'; +import LoadingSpinner from 'components/widgets/loading/loading_spinner'; -import {Constants} from 'utils/constants'; -import * as Utils from 'utils/utils'; +import {Constants, ModalIdentifiers} from 'utils/constants'; +import {toTitleCase} from 'utils/utils'; + +import type {PropsFromRedux} from './index'; import './system_user_detail.scss'; -export type Props = { - user: UserProfile; - mfaEnabled: boolean; - isDisabled?: boolean; - actions: { - updateUserActive: (userId: string, active: boolean) => Promise; - setNavigationBlocked: (blocked: boolean) => void; - addUserToTeam: (teamId: string, userId: string) => Promise; - }; -} & WrappedComponentProps; +export type Params = { + user_id?: UserProfile['id']; +}; + +export type Props = PropsFromRedux & RouteComponentProps & WrappedComponentProps; export type State = { + user?: UserProfile; + emailField: string; + isLoading: boolean; + error?: string | null; + isSaveNeeded: boolean; + isSaving: boolean; teams: TeamMembership[]; teamIds: Array; - loading: boolean; - searching: boolean; - showPasswordModal: boolean; - showDeactivateMemberModal: boolean; - saveNeeded: boolean; - saving: boolean; - serverError: string | null; - errorTooltip: boolean; - customComponentWrapperClass: string; - user: UserProfile; - addTeamOpen: boolean; refreshTeams: boolean; - error: ServerError | null; -} + showResetPasswordModal: boolean; + showDeactivateMemberModal: boolean; + showTeamSelectorModal: boolean; +}; -class SystemUserDetail extends React.PureComponent { - errorMessageRef: React.RefObject; - errorMessageRefCurrent: React.ReactInstance | undefined; - - public static defaultProps = { - user: { - email: '', - } as UserProfile, - mfaEnabled: false, - }; - - constructor(props: Props & RouteComponentProps) { +export class SystemUserDetail extends PureComponent { + constructor(props: Props) { super(props); this.state = { + emailField: '', + isLoading: false, + error: null, + isSaveNeeded: false, + isSaving: false, teams: [], teamIds: [], - loading: false, - searching: false, - showPasswordModal: false, - showDeactivateMemberModal: false, - saveNeeded: false, - saving: false, - serverError: null, - errorTooltip: false, - customComponentWrapperClass: '', - user: { - email: this.props.user.email, - } as UserProfile, - addTeamOpen: false, refreshTeams: true, - error: null, + showResetPasswordModal: false, + showDeactivateMemberModal: false, + showTeamSelectorModal: false, }; - - this.errorMessageRef = React.createRef(); } - setTeamsData = (teams: TeamMembership[]): void => { + getUser = async (userId: UserProfile['id']) => { + this.setState({isLoading: true}); + + try { + const {data, error} = await this.props.getUser(userId) as ActionResult; + if (data) { + this.setState({ + user: data, + emailField: data.email, // Set emailField to the email of the user for editing purposes + isLoading: false, + }); + } else { + throw new Error(error ? error.message : 'Unknown error'); + } + } catch (error) { + console.log('SystemUserDetails-getUser: ', error); // eslint-disable-line no-console + + this.setState({ + isLoading: false, + error: this.props.intl.formatMessage({id: 'admin.user_item.userNotFound', defaultMessage: 'Cannot load User'}), + }); + } + }; + + componentDidMount() { + const userId = this.props.match.params.user_id ?? ''; + if (userId) { + // We dont have to handle the case of userId being empty here because the redirect will take care of it from the parent components + this.getUser(userId); + } + } + + handleTeamsLoaded = (teams: TeamMembership[]) => { const teamIds = teams.map((team) => team.team_id); this.setState({teams}); this.setState({teamIds}); this.setState({refreshTeams: false}); }; - openAddTeam = (): void => { - this.setState({addTeamOpen: true}); - }; + handleAddUserToTeams = (teams: Team[]) => { + if (!this.state.user) { + return; + } - addTeams = (teams: Team[]): void => { const promises = []; for (const team of teams) { - promises.push(this.props.actions.addUserToTeam(team.id, this.props.user.id)); + promises.push(this.props.addUserToTeam(team.id, this.state.user.id)); } - Promise.all(promises).finally(() => this.setState({refreshTeams: true})); + Promise.all(promises).finally(() => + this.setState({refreshTeams: true}), + ); }; - closeAddTeam = (): void => { - this.setState({addTeamOpen: false}); + handleActivateUser = async () => { + if (!this.state.user) { + return; + } + + try { + const {error} = await this.props.updateUserActive(this.state.user.id, true) as ActionResult; + if (error) { + throw new Error(error.message); + } + + await this.getUser(this.state.user.id); + } catch (err) { + console.error('SystemUserDetails-handleActivateUser', err); // eslint-disable-line no-console + + this.setState({error: this.props.intl.formatMessage({id: 'admin.user_item.userActivateFailed', defaultMessage: 'Failed to activate user'})}); + } }; - doPasswordReset = (user: UserProfile): void => { + handleDeactivateMember = async () => { + if (!this.state.user) { + return; + } + + try { + const {error} = await this.props.updateUserActive(this.state.user.id, false) as ActionResult; + if (error) { + throw new Error(error.message); + } + + await this.getUser(this.state.user.id); + } catch (err) { + console.error('SystemUserDetails-handleDeactivateMember', err); // eslint-disable-line no-console + + this.setState({error: this.props.intl.formatMessage({id: 'admin.user_item.userDeactivateFailed', defaultMessage: 'Failed to deactivate user'})}); + } + + this.toggleCloseModalDeactivateMember(); + }; + + handleRemoveMFA = async () => { + if (!this.state.user) { + return; + } + + try { + const {error} = await this.props.updateUserMfa(this.state.user.id, false) as ActionResult; + if (error) { + throw new Error(error.message); + } + + await this.getUser(this.state.user.id); + } catch (err) { + console.error('SystemUserDetails-handleRemoveMFA', err); // eslint-disable-line no-console + + this.setState({error: this.props.intl.formatMessage({id: 'admin.user_item.userMFARemoveFailed', defaultMessage: 'Failed to remove user\'s MFA'})}); + } + }; + + handleEmailChange = (event: ChangeEvent) => { + if (!this.state.user) { + return; + } + + const {target: {value}} = event; + + const didEmailChanged = value !== this.state.user.email; this.setState({ - showPasswordModal: true, - user, + emailField: value, + isSaveNeeded: didEmailChanged, }); + + this.props.setNavigationBlocked(didEmailChanged); }; - doPasswordResetDismiss = (): void => { + handleSubmit = async (event: MouseEvent) => { + event.preventDefault(); + + if (this.state.isLoading || this.state.isSaving || !this.state.user) { + return; + } + + if (this.state.user.email === this.state.emailField) { + return; + } + + if (!isEmail(this.state.user.email)) { + this.setState({error: this.props.intl.formatMessage({id: 'admin.user_item.invalidEmail', defaultMessage: 'Invalid email address'})}); + return; + } + + const updatedUser = Object.assign({}, this.state.user, {email: this.state.emailField.trim().toLowerCase()}); + this.setState({ - showPasswordModal: false, + error: null, + isSaving: true, }); + + try { + const {data, error} = await this.props.patchUser(updatedUser) as ActionResult; + if (data) { + this.setState({ + user: data, + emailField: data.email, + error: null, + isSaving: false, + isSaveNeeded: false, + }); + } else { + throw new Error(error ? error.message : 'Unknown error'); + } + } catch (err) { + console.error('SystemUserDetails-handleSubmit', err); // eslint-disable-line no-console + + this.setState({ + error: this.props.intl.formatMessage({id: 'admin.user_item.userUpdateFailed', defaultMessage: 'Failed to update user'}), + isSaving: false, + isSaveNeeded: false, + }); + } + + this.props.setNavigationBlocked(false); }; - doPasswordResetSubmit = (): void => { - this.setState({ - showPasswordModal: false, - }); - }; + /** + * Modal close/open handlers + */ - handleMakeActive = (e: React.MouseEvent): void => { - e.preventDefault(); - this.props.actions.updateUserActive(this.props.user.id, true). - then((data) => this.onUpdateActiveResult(data.error)); - }; - - handleShowDeactivateMemberModal = (e: React.MouseEvent): void => { - e.preventDefault(); + toggleOpenModalDeactivateMember = () => { this.setState({showDeactivateMemberModal: true}); }; - handleDeactivateMember = (): void => { - this.props.actions.updateUserActive(this.props.user.id, false). - then((data) => this.onUpdateActiveResult(data.error)); + toggleCloseModalDeactivateMember = () => { this.setState({showDeactivateMemberModal: false}); }; - onUpdateActiveResult = (error: ServerError): void => { - if (error) { - this.setState({error}); - } - }; - - handleDeactivateCancel = (): void => { - this.setState({showDeactivateMemberModal: false}); - }; - - // TODO: add error handler function - handleResetMfa = (e: React.MouseEvent): void => { - e.preventDefault(); - adminResetMfa(this.props.user.id, null, null); - }; - - handleEmailChange = (e: React.ChangeEvent): void => { - const emailChanged = e.target.value !== this.props.user.email; - this.setState({ - user: { - email: e.target.value, - } as UserProfile, - saveNeeded: emailChanged, + toggleOpenModalResetPassword = () => { + this.props.openModal({ + modalId: ModalIdentifiers.RESET_PASSWORD_MODAL, + dialogType: ResetPasswordModal, + dialogProps: {user: this.state.user}, }); - this.props.actions.setNavigationBlocked(emailChanged); }; - handleSubmit = (e: React.MouseEvent): void => { - e.preventDefault(); - if (this.state.user.email !== this.props.user.email) { - if (!isEmail(this.state.user.email)) { - this.setState({serverError: 'Invalid Email address'}); - return; - } - const user = Object.assign({}, this.props.user); - const email = this.state.user.email.trim().toLowerCase(); - user.email = email; - - this.setState({serverError: null}); - - adminResetEmail( - user, - () => { - this.props.history.push('/admin_console/user_management/users'); - }, - (err: Error) => { - const serverError = (err.message ? err.message : err) as string; - this.setState({serverError}); - }, - ); - - this.setState({ - saving: false, - saveNeeded: false, - serverError: null, - }); - this.props.actions.setNavigationBlocked(false); - } + toggleCloseModalResetPassword = () => { + this.setState({showResetPasswordModal: false}); }; - renderDeactivateMemberModal = (user: UserProfile): React.ReactNode => { - const title = ( - - ); - - let warning; - if (user.auth_service !== '' && user.auth_service !== Constants.EMAIL_SERVICE) { - warning = ( - -
-
- -
- ); - } - - const message = ( -
- - {warning} -
- ); - - const confirmButtonClass = 'btn btn-danger'; - const deactivateMemberButton = ( - - ); - - return ( - - ); + toggleOpenTeamSelectorModal = () => { + this.setState({showTeamSelectorModal: true}); }; - renderActivateDeactivate = (): React.ReactNode => { - if (this.props.user.delete_at > 0) { - return ( - - {this.props.intl.formatMessage({id: 'admin.user_item.makeActive', defaultMessage: 'Activate'})} - - ); - } - return ( - - {this.props.intl.formatMessage({id: 'admin.user_item.makeInactive', defaultMessage: 'Deactivate'})} - - ); + toggleCloseTeamSelectorModal = () => { + this.setState({showTeamSelectorModal: false}); }; - renderRemoveMFA = (): React.ReactNode => { - if (this.props.user.mfa_active) { - return ( - - {this.props.intl.formatMessage({id: 'admin.user_item.resetMfa', defaultMessage: 'Remove MFA'})} - - ); - } - return null; - }; - - getAuthenticationText(): string { - const {user, mfaEnabled} = this.props; - let authLine; - - if (user.auth_service) { - let service; - if (user.auth_service === Constants.LDAP_SERVICE || user.auth_service === Constants.SAML_SERVICE) { - service = user.auth_service.toUpperCase(); - } else { - service = Utils.toTitleCase(user.auth_service); - } - authLine = service; - } else { - authLine = this.props.intl.formatMessage({id: 'admin.userManagement.userDetail.email', defaultMessage: 'Email'}); - } - if (mfaEnabled) { - if (user.mfa_active) { - authLine += ', '; - authLine += this.props.intl.formatMessage({id: 'admin.userManagement.userDetail.mfa', defaultMessage: 'MFA'}); - } - } - return authLine; - } - - componentDidMount(): void { - if (this.errorMessageRef.current) { - this.errorMessageRefCurrent = this.errorMessageRef.current; - } - } - - render(): React.ReactNode { - const {user} = this.props; - let deactivateMemberModal; - - if (!user.id) { - return ( - - ); - } - - if (user.id) { - deactivateMemberModal = this.renderDeactivateMemberModal(user); - } - + render() { return (
@@ -379,58 +301,105 @@ class SystemUserDetail extends React.PureComponent
+ + {/* User details */} - {user.position} - {this.props.intl.formatMessage({id: 'admin.userManagement.userDetail.email', defaultMessage: 'Email'})} -
- - + {this.state?.user?.position ?? ''} +
- {this.props.intl.formatMessage({id: 'admin.userManagement.userDetail.username', defaultMessage: 'Username'})} -
- - {user.username} -
- {this.props.intl.formatMessage({id: 'admin.userManagement.userDetail.authenticationMethod', defaultMessage: 'Authentication Method'})} -
- - {this.getAuthenticationText()} -
- + + + + + + } footer={ - - + + {this.state.user?.mfa_active && ( + + )} + {this.state.user?.delete_at !== 0 && ( + + )} + {this.state.user?.delete_at === 0 && ( + + )} + } /> + + {/* User's team details */}
- )} + } > - + {this.state.isLoading && ( +
+ +
+ )} + {!this.state.isLoading && this.state.user?.id && ( + + )}
+ + {/* Footer */}
-
- +
+
- - - {this.state.serverError} - -
- + } + message={ +
+ + {this.state.user?.auth_service !== '' && this.state.user?.auth_service !== Constants.EMAIL_SERVICE && ( + +
+
+ +
+ )} +
+ } + confirmButtonClass='btn btn-danger' + confirmButtonText={ + + } + onConfirm={this.handleDeactivateMember} + onCancel={this.toggleCloseModalDeactivateMember} /> - {deactivateMemberModal} - {this.state.addTeamOpen && + {this.state.showTeamSelectorModal && ( - } + )}
); } } export default injectIntl(SystemUserDetail); + +export function getUserAuthenticationTextField(intl: IntlShape, mfaEnabled: Props['mfaEnabled'], user?: UserProfile): string { + if (!user) { + return ''; + } + + let authenticationTextField; + + if (user.auth_service) { + let service; + if (user.auth_service === Constants.LDAP_SERVICE || user.auth_service === Constants.SAML_SERVICE) { + service = user.auth_service.toUpperCase(); + } else { + service = toTitleCase(user.auth_service); + } + authenticationTextField = service; + } else { + authenticationTextField = intl.formatMessage({ + id: 'admin.userManagement.userDetail.email', + defaultMessage: 'Email', + }); + } + + if (mfaEnabled) { + if (user.mfa_active) { + authenticationTextField += ', '; + authenticationTextField += intl.formatMessage({id: 'admin.userManagement.userDetail.mfa', defaultMessage: 'MFA'}); + } + } + + return authenticationTextField; +} diff --git a/webapp/channels/src/components/admin_console/system_user_detail/team_list/team_list.tsx b/webapp/channels/src/components/admin_console/system_user_detail/team_list/team_list.tsx index 0140f6fb32..e56d9e3052 100644 --- a/webapp/channels/src/components/admin_console/system_user_detail/team_list/team_list.tsx +++ b/webapp/channels/src/components/admin_console/system_user_detail/team_list/team_list.tsx @@ -142,6 +142,19 @@ export default class TeamList extends React.PureComponent { } }; + private renderRow = (item: TeamWithMembership): JSX.Element => { + return ( + + ); + }; + public render(): JSX.Element { let serverError = null; if (this.state.serverError) { @@ -167,17 +180,4 @@ export default class TeamList extends React.PureComponent { ); } - - private renderRow = (item: TeamWithMembership): JSX.Element => { - return ( - - ); - }; } diff --git a/webapp/channels/src/components/admin_console/system_users/__snapshots__/system_users.test.tsx.snap b/webapp/channels/src/components/admin_console/system_users/__snapshots__/system_users.test.tsx.snap deleted file mode 100644 index 206c847c63..0000000000 --- a/webapp/channels/src/components/admin_console/system_users/__snapshots__/system_users.test.tsx.snap +++ /dev/null @@ -1,67 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/admin_console/system_users should match default snapshot 1`] = ` -
- - - - -
-
-
-
- - - -
- -
-
-
-
-`; diff --git a/webapp/channels/src/components/admin_console/system_users/constants/index.ts b/webapp/channels/src/components/admin_console/system_users/constants/index.ts new file mode 100644 index 0000000000..89db8bb414 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/constants/index.ts @@ -0,0 +1,33 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export enum ColumnNames { + username = 'usernameColumn', + displayName = 'displayNameColumn', + email = 'emailColumn', + createAt = 'createAtColumn', + lastLoginAt = 'lastLoginColumn', + lastStatusAt = 'lastStatusAtColumn', + lastPostDate = 'lastPostDateColumn', + daysActive = 'daysActiveColumn', + totalPosts = 'totalPostsColumn', + actions = 'actionsColumn', +} + +export enum StatusFilter { + Any = 'any', + Active = 'active', + Deactivated = 'deactivated', +} + +export enum RoleFilters { + Any = 'any', + Admin = 'system_admin', + Member = 'system_user', + Guest = 'system_guest', +} + +export enum TeamFilters { + AllTeams = 'teams_filter_for_all_teams', + NoTeams = 'teams_filter_for_no_teams', +} diff --git a/webapp/channels/src/components/admin_console/system_users/index.ts b/webapp/channels/src/components/admin_console/system_users/index.ts index 35b510f9f8..4fe5523aa8 100644 --- a/webapp/channels/src/components/admin_console/system_users/index.ts +++ b/webapp/channels/src/components/admin_console/system_users/index.ts @@ -1,30 +1,24 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import type {ConnectedProps} from 'react-redux'; import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import type {Dispatch} from 'redux'; -import {logError} from 'mattermost-redux/actions/errors'; -import {getTeams, getTeamStats} from 'mattermost-redux/actions/teams'; -import { - getUser, - getUserAccessToken, - getProfiles, - searchProfiles, - getFilteredUsersStats, -} from 'mattermost-redux/actions/users'; +import {ReportDuration} from '@mattermost/types/reports'; + +import {savePreferences} from 'mattermost-redux/actions/preferences'; +import Preferences from 'mattermost-redux/constants/preferences'; +import {getCurrentUser} from 'mattermost-redux/selectors/entities/common'; import {getConfig} from 'mattermost-redux/selectors/entities/general'; -import {getTeamsList} from 'mattermost-redux/selectors/entities/teams'; -import {getFilteredUsersStats as selectFilteredUserStats, getUsers} from 'mattermost-redux/selectors/entities/users'; +import {get as getPreferences} from 'mattermost-redux/selectors/entities/preferences'; -import {loadProfilesAndTeamMembers, loadProfilesWithoutTeam} from 'actions/user_actions'; -import {setSystemUsersSearch} from 'actions/views/search'; - -import {SearchUserTeamFilter} from 'utils/constants'; +import {getUserCountForReporting, getUserReports, setAdminConsoleUsersManagementTableProperties} from 'actions/views/admin'; +import {adminConsoleUserManagementTablePropertiesInitialState} from 'reducers/views/admin'; +import {getAdminConsoleUserManagementTableProperties} from 'selectors/views/admin'; import type {GlobalState} from 'types/store'; +import {RoleFilters, StatusFilter, TeamFilters} from './constants'; import SystemUsers from './system_users'; function mapStateToProps(state: GlobalState) { @@ -34,57 +28,61 @@ function mapStateToProps(state: GlobalState) { const mfaEnabled = config.EnableMultifactorAuthentication === 'true'; const enableUserAccessTokens = config.EnableUserAccessTokens === 'true'; const experimentalEnableAuthenticationTransfer = config.ExperimentalEnableAuthenticationTransfer === 'true'; + const isMySql = config.SQLDriverName === 'mysql'; + const hideMySqlNotification = getPreferences(state, Preferences.CATEGORY_REPORTING, Preferences.HIDE_MYSQL_STATS_NOTIFICATION, '') === 'true'; - const search = state.views.search.systemUsersSearch; - let totalUsers = 0; - let searchTerm = ''; - let teamId = ''; - let filter = ''; - if (search) { - searchTerm = search.term || ''; - teamId = search.team || ''; - filter = search.filter || ''; + const currentUser = getCurrentUser(state); - if (!teamId || teamId === SearchUserTeamFilter.ALL_USERS) { - totalUsers = selectFilteredUserStats(state)?.total_users_count || 0; - } else if (teamId === SearchUserTeamFilter.NO_TEAM) { - totalUsers = 0; - } else { - const stats = state.entities.teams.stats[teamId] || {total_member_count: 0}; - totalUsers = stats.total_member_count; - } - } + const tableProperties = getAdminConsoleUserManagementTableProperties(state); + const tablePropertySortColumn = tableProperties?.sortColumn ?? adminConsoleUserManagementTablePropertiesInitialState.sortColumn; + const tablePropertySortIsDescending = tableProperties?.sortIsDescending ?? adminConsoleUserManagementTablePropertiesInitialState.sortIsDescending; + const tablePropertyPageSize = tableProperties?.pageSize ?? adminConsoleUserManagementTablePropertiesInitialState.pageSize; + const tablePropertyPageIndex = tableProperties?.pageIndex ?? adminConsoleUserManagementTablePropertiesInitialState.pageIndex; + const tablePropertyCursorDirection = tableProperties?.cursorDirection ?? adminConsoleUserManagementTablePropertiesInitialState.cursorDirection; + const tablePropertyCursorUserId = tableProperties?.cursorUserId ?? adminConsoleUserManagementTablePropertiesInitialState.cursorUserId; + const tablePropertyCursorColumnValue = tableProperties?.cursorColumnValue ?? adminConsoleUserManagementTablePropertiesInitialState.cursorColumnValue; + const tablePropertyColumnVisibility = tableProperties?.columnVisibility ?? adminConsoleUserManagementTablePropertiesInitialState.columnVisibility; + const tablePropertySearchTerm = tableProperties?.searchTerm ?? adminConsoleUserManagementTablePropertiesInitialState.searchTerm; + const tablePropertyFilterTeam = tableProperties?.filterTeam ?? TeamFilters.AllTeams; + const tablePropertyFilterTeamLabel = tableProperties?.filterTeamLabel ?? ''; + const tablePropertyFilterRole = tableProperties?.filterRole ?? RoleFilters.Any; + const tablePropertyFilterStatus = tableProperties?.filterStatus ?? StatusFilter.Any; + const tablePropertyDateRange = tableProperties?.dateRange ?? ReportDuration.AllTime; return { - teams: getTeamsList(state), siteName, mfaEnabled, - totalUsers, - searchTerm, - teamId, - filter, enableUserAccessTokens, - users: getUsers(state), experimentalEnableAuthenticationTransfer, + currentUser, + isMySql, + hideMySqlNotification, + tablePropertySortColumn, + tablePropertySortIsDescending, + tablePropertyPageSize, + tablePropertyPageIndex, + tablePropertyCursorDirection, + tablePropertyCursorUserId, + tablePropertyCursorColumnValue, + tablePropertyColumnVisibility, + tablePropertySearchTerm, + tablePropertyFilterTeam, + tablePropertyFilterTeamLabel, + tablePropertyFilterRole, + tablePropertyFilterStatus, + tablePropertyDateRange, }; } -function mapDispatchToProps(dispatch: Dispatch) { - return { - actions: bindActionCreators({ - getTeams, - getTeamStats, - getUser, - getUserAccessToken, - loadProfilesAndTeamMembers, - setSystemUsersSearch, - loadProfilesWithoutTeam, - getProfiles, - searchProfiles, - logError, - getFilteredUsersStats, - }, dispatch), - }; -} +const mapDispatchToProps = { + getUserReports, + getUserCountForReporting, + savePreferences, + setAdminConsoleUsersManagementTableProperties, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +export type PropsFromRedux = ConnectedProps; export default connect(mapStateToProps, mapDispatchToProps)(SystemUsers); diff --git a/webapp/channels/src/components/admin_console/system_users/revoke_sessions_button/index.tsx b/webapp/channels/src/components/admin_console/system_users/revoke_sessions_button/index.tsx index 4418dfc1dc..381a3de315 100644 --- a/webapp/channels/src/components/admin_console/system_users/revoke_sessions_button/index.tsx +++ b/webapp/channels/src/components/admin_console/system_users/revoke_sessions_button/index.tsx @@ -13,7 +13,7 @@ import {emitUserLoggedOutEvent} from 'actions/global_actions'; import ConfirmModal from 'components/confirm_modal'; import SystemPermissionGate from 'components/permissions_gates/system_permission_gate'; -function RevokeSessionsButton() { +export function RevokeSessionsButton() { const dispatch = useDispatch(); const [showModal, setShowModal] = useState(false); @@ -70,5 +70,3 @@ function RevokeSessionsButton() { ); } - -export default RevokeSessionsButton; diff --git a/webapp/channels/src/components/admin_console/system_users/system_users.scss b/webapp/channels/src/components/admin_console/system_users/system_users.scss new file mode 100644 index 0000000000..573084baf3 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users.scss @@ -0,0 +1,83 @@ +@mixin textElipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +table.systemUsersTable { + thead { + tr { + th.actionsColumn { + padding-inline-start: 24px; + } + } + } + + tbody { + tr { + td.usernameColumn > div { + $profilePictureSize: 40px; + + display: grid; + width: max-content; + gap: 0 0; + grid-auto-flow: row; + grid-template-areas: + "profilePictureContainer displayName" + "profilePictureContainer userName" + "profilePictureContainer error"; + grid-template-columns: calc($profilePictureSize + 16px) 1fr; + grid-template-rows: 1fr 1fr; + + .profilePictureContainer { + width: $profilePictureSize; + height: $profilePictureSize; + align-self: center; + grid-area: profilePictureContainer; + + > img { + width: 100%; + height: 100%; + border-radius: 40px; + object-fit: cover; + } + } + + .displayName { + align-self: start; + color: var(--sys-denim-button-bg); + font-weight: 600; + grid-area: displayName; + + @include textElipsis; + } + + .userName { + align-self: start; + grid-area: userName; + + @include textElipsis; + } + + .error { + align-self: start; + grid-area: error; + + @include textElipsis; + } + } + + td.actionsColumn > button { + padding: 0 12px; + } + } + } +} + +.systemUsers__mySqlAlertBanner { + margin-bottom: 20px; + + .systemUsers__mySqlAlertBanner-buttons { + margin-top: 12px; + } +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users.test.tsx b/webapp/channels/src/components/admin_console/system_users/system_users.test.tsx deleted file mode 100644 index 2e043897f1..0000000000 --- a/webapp/channels/src/components/admin_console/system_users/system_users.test.tsx +++ /dev/null @@ -1,162 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {shallow} from 'enzyme'; -import React from 'react'; - -import {Constants, SearchUserTeamFilter, UserFilters} from 'utils/constants'; - -import SystemUsers from './system_users'; -import type {SystemUsers as SystemUserClass} from './system_users'; - -jest.mock('actions/admin_actions'); - -jest.useFakeTimers(); - -describe('components/admin_console/system_users', () => { - const USERS_PER_PAGE = 50; - const defaultProps = { - teams: [], - siteName: 'Site name', - mfaEnabled: false, - enableUserAccessTokens: false, - experimentalEnableAuthenticationTransfer: false, - searchTerm: '', - teamId: '', - filter: '', - totalUsers: 0, - users: {}, - actions: { - getTeams: jest.fn().mockResolvedValue({data: []}), - getTeamStats: jest.fn().mockResolvedValue({data: []}), - getUser: jest.fn().mockResolvedValue({data: {}}), - getUserAccessToken: jest.fn().mockResolvedValue({data: ''}), - loadProfilesAndTeamMembers: jest.fn().mockResolvedValue({data: true}), - setSystemUsersSearch: jest.fn().mockResolvedValue({data: true}), - loadProfilesWithoutTeam: jest.fn().mockResolvedValue({data: true}), - getProfiles: jest.fn().mockResolvedValue({data: []}), - searchProfiles: jest.fn().mockResolvedValue({data: []}), - logError: jest.fn(), - getFilteredUsersStats: jest.fn(), - }, - }; - - test('should match default snapshot', () => { - const props = defaultProps; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - - test('loadDataForTeam() should have called getProfiles', async () => { - const getProfiles = jest.fn().mockResolvedValue(undefined); - const props = {...defaultProps, actions: {...defaultProps.actions, getProfiles}}; - const wrapper = shallow(); - - wrapper.setState({loading: true}); - - const instance = wrapper.instance() as SystemUserClass; - - await instance.loadDataForTeam(SearchUserTeamFilter.ALL_USERS, ''); - - expect(getProfiles).toHaveBeenCalled(); - expect(getProfiles).toHaveBeenCalledWith(0, Constants.PROFILE_CHUNK_SIZE, {}); - expect(wrapper.state('loading')).toEqual(false); - }); - - test('loadDataForTeam() should have called loadProfilesWithoutTeam', async () => { - const loadProfilesWithoutTeam = jest.fn().mockResolvedValue(undefined); - const props = {...defaultProps, actions: {...defaultProps.actions, loadProfilesWithoutTeam}}; - const wrapper = shallow(); - - wrapper.setState({loading: true}); - - const instance = wrapper.instance() as SystemUserClass; - - await instance.loadDataForTeam(SearchUserTeamFilter.NO_TEAM, ''); - - expect(loadProfilesWithoutTeam).toHaveBeenCalled(); - expect(loadProfilesWithoutTeam).toHaveBeenCalledWith(0, Constants.PROFILE_CHUNK_SIZE, {}); - expect(wrapper.state('loading')).toEqual(false); - - await instance.loadDataForTeam(SearchUserTeamFilter.NO_TEAM, UserFilters.INACTIVE); - - expect(loadProfilesWithoutTeam).toHaveBeenCalled(); - expect(loadProfilesWithoutTeam).toHaveBeenCalledWith(0, Constants.PROFILE_CHUNK_SIZE, {inactive: true}); - }); - - test('nextPage() should have called getProfiles', async () => { - const getProfiles = jest.fn().mockResolvedValue(undefined); - const props = { - ...defaultProps, - teamId: SearchUserTeamFilter.ALL_USERS, - actions: {...defaultProps.actions, getProfiles}, - }; - const wrapper = shallow(); - - wrapper.setState({loading: true}); - - const instance = wrapper.instance() as SystemUserClass; - - await instance.nextPage(0); - - expect(getProfiles).toHaveBeenCalled(); - expect(getProfiles).toHaveBeenCalledWith(1, USERS_PER_PAGE, {}); - expect(wrapper.state('loading')).toEqual(false); - }); - - test('nextPage() should have called loadProfilesWithoutTeam', async () => { - const loadProfilesWithoutTeam = jest.fn().mockResolvedValue({data: true}); - const props = { - ...defaultProps, - teamId: SearchUserTeamFilter.NO_TEAM, - actions: {...defaultProps.actions, loadProfilesWithoutTeam}, - }; - const wrapper = shallow(); - - wrapper.setState({loading: true}); - - const instance = wrapper.instance() as SystemUserClass; - - await instance.nextPage(0); - - expect(loadProfilesWithoutTeam).toHaveBeenCalled(); - expect(loadProfilesWithoutTeam).toHaveBeenCalledWith(1, USERS_PER_PAGE, {}); - expect(wrapper.state('loading')).toEqual(false); - }); - - test('doSearch() should have called searchProfiles with allow_inactive', async () => { - const searchProfiles = jest.fn().mockResolvedValue({data: [{}]}); - const props = { - ...defaultProps, - teamId: SearchUserTeamFilter.NO_TEAM, - actions: {...defaultProps.actions, searchProfiles}, - }; - const wrapper = shallow(); - - const instance = wrapper.instance() as SystemUserClass; - - await instance.doSearch('searchterm', '', ''); - - jest.runOnlyPendingTimers(); - expect(searchProfiles).toHaveBeenCalled(); - expect(searchProfiles).toHaveBeenCalledWith('searchterm', {allow_inactive: true}); - }); - - test('doSearch() should have called searchProfiles with allow_inactive and system_admin role', async () => { - const searchProfiles = jest.fn().mockResolvedValue({data: [{}]}); - const props = { - ...defaultProps, - teamId: SearchUserTeamFilter.NO_TEAM, - actions: {...defaultProps.actions, searchProfiles}, - }; - const wrapper = shallow(); - - const instance = wrapper.instance() as SystemUserClass; - - await instance.doSearch('searchterm', '', 'system_admin'); - - jest.runOnlyPendingTimers(); - expect(searchProfiles).toHaveBeenCalled(); - expect(searchProfiles).toHaveBeenCalledWith('searchterm', {allow_inactive: true, role: 'system_admin'}); - }); -}); diff --git a/webapp/channels/src/components/admin_console/system_users/system_users.tsx b/webapp/channels/src/components/admin_console/system_users/system_users.tsx index d95514fe8a..b484d05ea1 100644 --- a/webapp/channels/src/components/admin_console/system_users/system_users.tsx +++ b/webapp/channels/src/components/admin_console/system_users/system_users.tsx @@ -1,107 +1,61 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React from 'react'; -import type {ChangeEvent} from 'react'; +import type {PaginationState, SortingState, VisibilityState, ColumnDef, CellContext, OnChangeFn} from '@tanstack/react-table'; +import {useReactTable, getCoreRowModel, getSortedRowModel} from '@tanstack/react-table'; +import React, {useEffect, useMemo, useState} from 'react'; +import {useIntl, FormattedMessage, defineMessages} from 'react-intl'; import type {MessageDescriptor} from 'react-intl'; -import {FormattedMessage, defineMessages} from 'react-intl'; +import {useHistory} from 'react-router-dom'; import type {ServerError} from '@mattermost/types/errors'; -import type {Team} from '@mattermost/types/teams'; -import type {GetFilteredUsersStatsOpts, UserAccessToken, UserProfile, UsersStats} from '@mattermost/types/users'; +import {CursorPaginationDirection} from '@mattermost/types/reports'; +import type {ReportDuration, UserReport} from '@mattermost/types/reports'; -import {debounce} from 'mattermost-redux/actions/helpers'; -import type {ActionResult} from 'mattermost-redux/types/actions'; +import Preferences from 'mattermost-redux/constants/preferences'; +import {AdminConsoleListTable, ElapsedDurationCell, PAGE_SIZES, LoadingStates} from 'components/admin_console/list_table'; +import type {TableMeta} from 'components/admin_console/list_table'; +import AlertBanner from 'components/alert_banner'; import AdminHeader from 'components/widgets/admin_console/admin_header'; -import {Constants, UserSearchOptions, SearchUserTeamFilter} from 'utils/constants'; -import {getUserOptionsFromFilter, searchUserOptionsFromFilter} from 'utils/filter_users'; +import {getDisplayName, imageURLForUser} from 'utils/utils'; -import RevokeSessionsButton from './revoke_sessions_button'; -import SystemUsersFilterRole from './system_users_filter_role'; -import SystemUsersFilterTeam from './system_users_filter_team'; -import SystemUsersList from './system_users_list'; -import SystemUsersSearch from './system_users_search'; +import type {AdminConsoleUserManagementTableProperties} from 'types/store/views'; -const USER_ID_LENGTH = 26; -const USERS_PER_PAGE = 50; +import {ColumnNames} from './constants'; +import {RevokeSessionsButton} from './revoke_sessions_button'; +import {SystemUsersColumnTogglerMenu} from './system_users_column_toggler_menu'; +import {SystemUsersDateRangeMenu} from './system_users_date_range_menu'; +import {SystemUsersExport} from './system_users_export'; +import {SystemUsersFilterPopover} from './system_users_filters_popover'; +import {SystemUsersListAction} from './system_users_list_actions'; +import {SystemUsersSearch} from './system_users_search'; +import {getSortableColumnValueBySortColumn, getPaginationInfo, convertTableOptionsToUserReportOptions} from './utils'; -type Props = { +import './system_users.scss'; - /** - * Array of team objects - */ - teams: Team[]; +import type {PropsFromRedux} from './index'; - /** - * Title of the app or site. - */ - siteName?: string; +type Props = PropsFromRedux; - /** - * Whether or not MFA is licensed and enabled. - */ - mfaEnabled: boolean; +export type TableOptions = { + pageSize?: PaginationState['pageSize']; + sortColumn?: SortingState[0]['id']; + sortIsDescending?: SortingState[0]['desc']; + fromColumnValue?: AdminConsoleUserManagementTableProperties['cursorColumnValue']; + fromId?: AdminConsoleUserManagementTableProperties['cursorUserId']; + direction?: CursorPaginationDirection; + searchTerm?: string; + filterTeam?: AdminConsoleUserManagementTableProperties['filterTeam']; + filterRole?: AdminConsoleUserManagementTableProperties['filterRole']; + filterStatus?: AdminConsoleUserManagementTableProperties['filterStatus']; + dateRange?: ReportDuration; +} - /** - * Whether or not user access tokens are enabled. - */ - enableUserAccessTokens: boolean; +type UserReportWithError = UserReport & {error?: ServerError}; - /** - * Whether or not the experimental authentication transfer is enabled. - */ - experimentalEnableAuthenticationTransfer: boolean; - totalUsers: number; - searchTerm: string; - teamId: string; - filter: string; - users: Record; - - actions: { - - /** - * Function to get teams - */ - getTeams: (startInde: number, endIndex: number) => void; - - /** - * Function to get statistics for a team - */ - getTeamStats: (teamId: string) => Promise; - - /** - * Function to get a user - */ - getUser: (id: string) => Promise; - - /** - * Function to get a user access token - */ - getUserAccessToken: (tokenId: string) => Promise>; - loadProfilesAndTeamMembers: (page: number, maxItemsPerPage: number, teamId: string, options: Record) => void; - loadProfilesWithoutTeam: (page: number, maxItemsPerPage: number, options: Record) => void; - getProfiles: (page: number, maxItemsPerPage: number, options: Record) => void; - setSystemUsersSearch: (searchTerm: string, teamId: string, filter: string) => void; - searchProfiles: (term: string, options?: any) => Promise>; - - /** - * Function to log errors - */ - logError: (error: {type: string; message: string}) => void; - getFilteredUsersStats: (filters: GetFilteredUsersStatsOpts) => Promise<{ - data?: UsersStats; - error?: ServerError; - }>; - }; -}; - -type State = { - loading: boolean; - searching: boolean; - term?: string; -}; +const tableId = 'systemUsersTable'; const messages = defineMessages({ title: {id: 'admin.system_users.title', defaultMessage: '{siteName} Users'}, @@ -109,254 +63,496 @@ const messages = defineMessages({ export const searchableStrings: Array = [[messages.title, {siteName: ''}]]; -export class SystemUsers extends React.PureComponent { - constructor(props: Props) { - super(props); +function SystemUsers(props: Props) { + const {formatMessage} = useIntl(); + const history = useHistory(); - this.state = { - loading: true, - searching: false, - }; - } + const [userReports, setUserReports] = useState([]); + const [userCount, setUserCount] = useState(); + const [loadingState, setLoadingState] = useState(LoadingStates.Loading); + const [showMySqlBanner, setShowMySqlBanner] = useState(props.isMySql && !props.hideMySqlNotification); - componentDidMount() { - this.loadDataForTeam(this.props.teamId, this.props.filter); - this.props.actions.getTeams(0, 1000); - } - - componentWillUnmount() { - this.props.actions.setSystemUsersSearch('', '', ''); - } - - loadDataForTeam = async (teamId: string, filter: string | undefined) => { - const { - getProfiles, - loadProfilesWithoutTeam, - loadProfilesAndTeamMembers, - getTeamStats, - getFilteredUsersStats, - } = this.props.actions; - - if (this.props.searchTerm) { - this.doSearch(this.props.searchTerm, teamId, filter); - return; - } - - const options = getUserOptionsFromFilter(filter); - - if (teamId === SearchUserTeamFilter.ALL_USERS) { - await Promise.all([ - getProfiles(0, Constants.PROFILE_CHUNK_SIZE, options), - getFilteredUsersStats({include_bots: false, include_deleted: true}), - ]); - } else if (teamId === SearchUserTeamFilter.NO_TEAM) { - await loadProfilesWithoutTeam(0, Constants.PROFILE_CHUNK_SIZE, options); - } else { - await Promise.all([ - loadProfilesAndTeamMembers(0, Constants.PROFILE_CHUNK_SIZE, teamId, options), - getTeamStats(teamId), - ]); - } - - this.setState({loading: false}); - }; - - handleTeamChange = (e: ChangeEvent) => { - const teamId = e.target.value; - this.loadDataForTeam(teamId, this.props.filter); - this.props.actions.setSystemUsersSearch(this.props.searchTerm, teamId, this.props.filter); - }; - - handleFilterChange = (e: ChangeEvent) => { - const filter = e.target.value; - this.loadDataForTeam(this.props.teamId, filter); - this.props.actions.setSystemUsersSearch(this.props.searchTerm, this.props.teamId, filter); - }; - - handleTermChange = (term: string) => { - this.props.actions.setSystemUsersSearch(term, this.props.teamId, this.props.filter); - }; - - handleSearchFiltersChange = ({searchTerm, teamId, filter}: {searchTerm?: string; teamId?: string; filter?: string}) => { - const changedSearchTerm = typeof searchTerm === 'undefined' ? this.props.searchTerm : searchTerm; - const changedTeamId = typeof teamId === 'undefined' ? this.props.teamId : teamId; - const changedFilter = typeof filter === 'undefined' ? this.props.filter : filter; - - this.props.actions.setSystemUsersSearch(changedSearchTerm, changedTeamId, changedFilter); - }; - - nextPage = async (page: number) => { - const {teamId, filter} = this.props; - - // Paging isn't supported while searching - const { - getProfiles, - loadProfilesWithoutTeam, - loadProfilesAndTeamMembers, - } = this.props.actions; - - const options = getUserOptionsFromFilter(filter); - - if (teamId === SearchUserTeamFilter.ALL_USERS) { - await getProfiles(page + 1, USERS_PER_PAGE, options); - } else if (teamId === SearchUserTeamFilter.NO_TEAM) { - await loadProfilesWithoutTeam(page + 1, USERS_PER_PAGE, options); - } else { - await loadProfilesAndTeamMembers(page + 1, USERS_PER_PAGE, teamId, options); - } - this.setState({loading: false}); - }; - - onSearch = async (term: string) => { - this.setState({loading: true}); - - const options = { - ...searchUserOptionsFromFilter(this.props.filter), - ...this.props.teamId && {team_id: this.props.teamId}, - ...this.props.teamId === SearchUserTeamFilter.NO_TEAM && { - [UserSearchOptions.WITHOUT_TEAM]: true, - }, - allow_inactive: true, + // Effect to get the total user count + useEffect(() => { + const getUserCount = async (tableOptions?: TableOptions) => { + const {data} = await props.getUserCountForReporting(convertTableOptionsToUserReportOptions(tableOptions)); + setUserCount(data); }; - const {data: profiles} = await this.props.actions.searchProfiles(term, options); - if (profiles!.length === 0 && term.length === USER_ID_LENGTH) { - await this.getUserByTokenOrId(term); - } + getUserCount({ + pageSize: props.tablePropertyPageSize, + sortColumn: props.tablePropertySortColumn, + sortIsDescending: props.tablePropertySortIsDescending, + fromColumnValue: props.tablePropertyCursorColumnValue, + fromId: props.tablePropertyCursorUserId, + direction: props.tablePropertyCursorDirection, + searchTerm: props.tablePropertySearchTerm, + filterTeam: props.tablePropertyFilterTeam, + filterRole: props.tablePropertyFilterRole, + filterStatus: props.tablePropertyFilterStatus, + }); + }, [ + props.tablePropertyPageSize, + props.tablePropertySortColumn, + props.tablePropertySortIsDescending, + props.tablePropertyCursorDirection, + props.tablePropertyCursorColumnValue, + props.tablePropertyCursorUserId, + props.tablePropertySearchTerm, + props.tablePropertyFilterTeam, + props.tablePropertyFilterRole, + props.tablePropertyFilterStatus, + ]); - this.setState({loading: false}); - }; + // Effect to get the user reports + useEffect(() => { + async function fetchUserReportsWithOptions(tableOptions?: TableOptions) { + setLoadingState(LoadingStates.Loading); - onFilter = async ({teamId, filter}: {teamId?: string; filter?: string}) => { - if (this.props.searchTerm) { - this.onSearch(this.props.searchTerm); - return; - } - - this.setState({loading: true}); - - const newTeamId = typeof teamId === 'undefined' ? this.props.teamId : teamId; - const newFilter = typeof filter === 'undefined' ? this.props.filter : filter; - - const options = getUserOptionsFromFilter(newFilter); - - if (newTeamId === SearchUserTeamFilter.ALL_USERS) { - await Promise.all([ - this.props.actions.getProfiles(0, Constants.PROFILE_CHUNK_SIZE, options), - this.props.actions.getFilteredUsersStats({include_bots: false, include_deleted: true}), - ]); - } else if (newTeamId === SearchUserTeamFilter.NO_TEAM) { - await this.props.actions.loadProfilesWithoutTeam(0, Constants.PROFILE_CHUNK_SIZE, options); - } else { - await Promise.all([ - this.props.actions.loadProfilesAndTeamMembers(0, Constants.PROFILE_CHUNK_SIZE, newTeamId, options), - this.props.actions.getTeamStats(newTeamId), - ]); - } - - this.setState({loading: false}); - }; - - doSearch = debounce(async (term, teamId = this.props.teamId, filter = this.props.filter) => { - if (!term) { - return; - } - - this.setState({loading: true}); - - const options = { - ...searchUserOptionsFromFilter(filter), - ...teamId && {team_id: teamId}, - ...teamId === SearchUserTeamFilter.NO_TEAM && { - [UserSearchOptions.WITHOUT_TEAM]: true, - }, - allow_inactive: true, - }; - - const {data: profiles} = await this.props.actions.searchProfiles(term, options); - if (profiles!.length === 0 && term.length === USER_ID_LENGTH) { - await this.getUserByTokenOrId(term); - } - - this.setState({loading: false}); - }, Constants.SEARCH_TIMEOUT_MILLISECONDS, false, () => {}); - - getUserById = async (id: string) => { - if (this.props.users[id]) { - this.setState({loading: false}); - return; - } - - await this.props.actions.getUser(id); - this.setState({loading: false}); - }; - - getUserByTokenOrId = async (id: string) => { - if (this.props.enableUserAccessTokens) { - const {data} = await this.props.actions.getUserAccessToken(id); + const {data} = await props.getUserReports(convertTableOptionsToUserReportOptions(tableOptions)); if (data) { - this.setState({term: data.user_id}); - this.getUserById(data.user_id); - return; + if (data.length > 0) { + setUserReports(data); + } else { + setUserReports([]); + } + setLoadingState(LoadingStates.Loaded); + } else { + setLoadingState(LoadingStates.Failed); } } - this.getUserById(id); - }; + fetchUserReportsWithOptions({ + pageSize: props.tablePropertyPageSize, + sortColumn: props.tablePropertySortColumn, + sortIsDescending: props.tablePropertySortIsDescending, + fromColumnValue: props.tablePropertyCursorColumnValue, + fromId: props.tablePropertyCursorUserId, + direction: props.tablePropertyCursorDirection, + searchTerm: props.tablePropertySearchTerm, + filterTeam: props.tablePropertyFilterTeam, + filterRole: props.tablePropertyFilterRole, + filterStatus: props.tablePropertyFilterStatus, + dateRange: props.tablePropertyDateRange, + }); + }, [ + props.tablePropertyPageSize, + props.tablePropertySortColumn, + props.tablePropertySortIsDescending, + props.tablePropertyCursorDirection, + props.tablePropertyCursorColumnValue, + props.tablePropertyCursorUserId, + props.tablePropertySearchTerm, + props.tablePropertyFilterRole, + props.tablePropertyFilterTeam, + props.tablePropertyFilterStatus, + props.tablePropertyDateRange, + ]); - render() { - return ( -
- - - - -
-
-
-
- - - SortingState) { + const currentSortingState = [{id: props.tablePropertySortColumn, desc: props.tablePropertySortIsDescending}]; + const [updatedSortingState] = updateFn(currentSortingState); + + if (props.tablePropertySortColumn !== updatedSortingState.id) { + // If we are clicking on a new column, we want to sort in descending order + updatedSortingState.desc = false; + } + + props.setAdminConsoleUsersManagementTableProperties({ + pageIndex: 0, + cursorDirection: undefined, // reset the cursor to the beginning on any filter change + cursorUserId: undefined, + cursorColumnValue: undefined, + sortColumn: updatedSortingState.id, + sortIsDescending: updatedSortingState.desc, + }); + } + + function handlePaginationChange(updateFn: (currentPaginationState: PaginationState) => PaginationState) { + const currentPaginationState = {pageIndex: 0, pageSize: props.tablePropertyPageSize}; + const updatedPaginationState = updateFn(currentPaginationState); + + props.setAdminConsoleUsersManagementTableProperties({ + pageIndex: 0, + cursorDirection: undefined, // reset the cursor to the beginning on any filter change + cursorUserId: undefined, + cursorColumnValue: undefined, + pageSize: updatedPaginationState.pageSize, + }); + } + + function handleColumnVisibilityChange(updateFn: (currentVisibilityState: VisibilityState) => VisibilityState) { + const updatedVisibilityState = updateFn(props.tablePropertyColumnVisibility); + + props.setAdminConsoleUsersManagementTableProperties({ + columnVisibility: Object.assign({}, props.tablePropertyColumnVisibility, updatedVisibilityState), + }); + } + + function updateUserReport(userId: string, updatedReport: Partial) { + setUserReports(userReports.map((user) => { + if (user.id === userId) { + return { + ...user, + error: undefined, + ...updatedReport, + }; + } + + return user; + })); + } + + const columns: Array> = useMemo( + () => [ + { + id: ColumnNames.username, + accessorKey: 'username', + header: formatMessage({ + id: 'admin.system_users.list.userDetails', + defaultMessage: 'User details', + }), + cell: (info: CellContext) => { + return ( +
+
+
- +
+ {getDisplayName(info.row.original) || ''} +
+
+ {info.row.original.username} +
+ {info.row.original.error && +
+ {info.row.original.error.message} +
}
+ ); + }, + enableHiding: false, + enablePinning: true, + enableSorting: true, + }, + { + id: ColumnNames.email, + accessorKey: 'email', + header: formatMessage({ + id: 'admin.system_users.list.email', + defaultMessage: 'Email', + }), + cell: (info: CellContext) => info.getValue() || '', + enableHiding: true, + enablePinning: false, + enableSorting: true, + }, + { + id: ColumnNames.createAt, + accessorKey: 'create_at', + header: formatMessage({ + id: 'admin.system_users.list.memberSince', + defaultMessage: 'Member since', + }), + cell: (info: CellContext) => , + enableHiding: true, + enablePinning: false, + enableSorting: true, + }, + { + id: ColumnNames.lastLoginAt, + accessorKey: 'last_login_at', + header: formatMessage({ + id: 'admin.system_users.list.lastLoginAt', + defaultMessage: 'Last login', + }), + cell: (info: CellContext) => , + enableHiding: true, + enablePinning: false, + enableSorting: false, + }, + { + id: ColumnNames.lastStatusAt, + accessorKey: 'last_status_at', + header: formatMessage({ + id: 'admin.system_users.list.lastActivity', + defaultMessage: 'Last activity', + }), + cell: (info: CellContext) => , + enableHiding: true, + enablePinning: false, + enableSorting: false, + }, + { + id: ColumnNames.lastPostDate, + accessorKey: 'last_post_date', + header: formatMessage({ + id: 'admin.system_users.list.lastPost', + defaultMessage: 'Last post', + }), + cell: (info: CellContext) => , + enableHiding: !props.isMySql, + enablePinning: false, + enableSorting: false, + }, + { + id: ColumnNames.daysActive, + accessorKey: 'days_active', + header: formatMessage({ + id: 'admin.system_users.list.daysActive', + defaultMessage: 'Days active', + }), + cell: (info: CellContext) => info.getValue() || null, + meta: { + isNumeric: true, + }, + enableHiding: !props.isMySql, + enablePinning: false, + enableSorting: false, + }, + { + id: ColumnNames.totalPosts, + accessorKey: 'total_posts', + header: formatMessage({ + id: 'admin.system_users.list.totalPosts', + defaultMessage: 'Messages posted', + }), + cell: (info: CellContext) => info.getValue() || null, + meta: { + isNumeric: true, + }, + enableHiding: !props.isMySql, + enablePinning: false, + enableSorting: false, + }, + { + id: ColumnNames.actions, + accessorKey: 'actions', + header: formatMessage({ + id: 'admin.system_users.list.actions', + defaultMessage: 'Actions', + }), + cell: (info: CellContext) => ( + updateUserReport(info.row.original.id, updatedUser)} + onError={(error) => updateUserReport(info.row.original.id, {error})} + /> + ), + enableHiding: false, + enablePinning: true, + enableSorting: false, + }, + ], + [props.currentUser, userReports], + ); + + // Table state which are correctly formatted for the table component + + const sortingTableState = [{ + id: props && props.tablePropertySortColumn && props.tablePropertySortColumn.length > 0 ? props.tablePropertySortColumn : ColumnNames.username, + desc: props?.tablePropertySortIsDescending ?? false, + }]; + + const paginationTableState = { + pageIndex: props?.tablePropertyPageIndex ?? 0, + pageSize: props?.tablePropertyPageSize || PAGE_SIZES[0], + }; + + const columnVisibility = { + ...props.tablePropertyColumnVisibility, + ...(props.isMySql ? { + [ColumnNames.lastPostDate]: false, + [ColumnNames.daysActive]: false, + [ColumnNames.totalPosts]: false, + } : {}), + }; + + const table = useReactTable({ + data: userReports, + columns, + state: { + sorting: sortingTableState, + pagination: paginationTableState, + columnVisibility, + }, + meta: { + tableId: 'systemUsersTable', + tableCaption: formatMessage({id: 'admin.system_users.list.caption', defaultMessage: 'System Users'}), + loadingState, + disablePrevPage: !props.tablePropertyCursorUserId || props.tablePropertyPageIndex <= 0 || (props.tablePropertyCursorDirection === 'prev' && userReports.length < paginationTableState.pageSize), + disableNextPage: props.tablePropertyCursorDirection === 'next' && userReports.length < paginationTableState.pageSize, + onRowClick: handleRowClick, + onPreviousPageClick: handlePreviousPageClick, + onNextPageClick: handleNextPageClick, + paginationInfo: getPaginationInfo(paginationTableState.pageIndex, paginationTableState.pageSize, userReports.length, userCount), + hasDualSidedPagination: true, + } as TableMeta, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + onPaginationChange: handlePaginationChange as OnChangeFn, + onSortingChange: handleSortingChange as OnChangeFn, + onColumnVisibilityChange: handleColumnVisibilityChange as OnChangeFn, + manualSorting: true, + enableSortingRemoval: false, + enableMultiSort: false, + manualFiltering: true, + manualPagination: true, + renderFallbackValue: '', + }); + + return ( +
+ + + {(formatMessageChunk) => ( + {formatMessageChunk} + )} + + + +
+ {showMySqlBanner && + + } + message={ + <> + +
+ + +
+ + } + onDismiss={handleDismissMySqlNotice} + /> + } +
+
+ + + + +
+ + table={table} + />
- ); - } +
+ ); } export default SystemUsers; diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_column_toggler_menu/index.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_column_toggler_menu/index.tsx new file mode 100644 index 0000000000..f7ead6294d --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_column_toggler_menu/index.tsx @@ -0,0 +1,191 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Column, CoreColumn} from '@tanstack/react-table'; +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; + +import type {UserReport} from '@mattermost/types/reports'; + +import * as Menu from 'components/menu'; +import Input from 'components/widgets/inputs/input/input'; +import Tag from 'components/widgets/tag/tag'; +import WithTooltip from 'components/with_tooltip'; + +import {ColumnNames} from '../constants'; + +import './system_users_column_toggler.scss'; + +interface Props { + isMySql: boolean; + allColumns: Array>; + visibleColumnsLength: number; +} + +export function SystemUsersColumnTogglerMenu(props: Props) { + const {formatMessage} = useIntl(); + + function getColumnName(columnId: CoreColumn['id']) { + switch (columnId) { + case ColumnNames.username: + return ( + + ); + case ColumnNames.email: + return ( + + ); + case ColumnNames.createAt: + return ( + + ); + case ColumnNames.lastLoginAt: + return ( + + ); + case ColumnNames.lastStatusAt: + return ( + + ); + case ColumnNames.lastPostDate: + return ( + + ); + case ColumnNames.daysActive: + return ( + + ); + case ColumnNames.totalPosts: + return ( + + ); + case ColumnNames.actions: + return ( + + ); + default: + return ; + } + } + + return ( +
+ + } + /> + ), + }} + menu={{ + id: 'systemUsersColumnTogglerMenu', + 'aria-label': formatMessage({ + id: 'admin.system_users.column_toggler.dropdownAriaLabel', + defaultMessage: 'Columns visibility menu', + }), + }} + > + {props.allColumns.map((column) => { + let leadingElement; + if (column.getIsVisible()) { + leadingElement = ( + + ); + } else { + leadingElement = ( + + ); + } + + const postStatsColumns: string[] = [ColumnNames.lastPostDate, ColumnNames.daysActive, ColumnNames.totalPosts]; + if (props.isMySql && postStatsColumns.includes(column.id)) { + return ( + + } + onClick={column.getToggleVisibilityHandler()} + /> + + ); + } + + return ( + + ); + })} + +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_column_toggler_menu/system_users_column_toggler.scss b/webapp/channels/src/components/admin_console/system_users/system_users_column_toggler_menu/system_users_column_toggler.scss new file mode 100644 index 0000000000..a6bc25ee46 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_column_toggler_menu/system_users_column_toggler.scss @@ -0,0 +1,17 @@ +.systemUsersColumnToggler { + margin-inline-end: 8px; +} + +#systemUsersColumnTogglerMenu { + i.icon.icon-checkbox-marked { + color: var(--button-bg); + } + + i.icon.icon-checkbox-blank-outline { + color: rgba(var(--sys-center-channel-color-rgb), 0.24); + } + + .systemUsersColumnToggler__lockedItem { + pointer-events: auto; + } +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_date_range_menu/index.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_date_range_menu/index.tsx new file mode 100644 index 0000000000..e142f57a20 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_date_range_menu/index.tsx @@ -0,0 +1,219 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch} from 'react-redux'; + +import {ReportDuration} from '@mattermost/types/reports'; + +import {setAdminConsoleUsersManagementTableProperties} from 'actions/views/admin'; + +import * as Menu from 'components/menu'; +import Input from 'components/widgets/inputs/input/input'; + +import './system_users_date_range_menu.scss'; + +function get30DaysBack(now: Date) { + const prev = new Date(now); + prev.setDate(prev.getDate() - 30); + return prev; +} + +function get6MonthsBack(now: Date) { + const prev = new Date(now); + prev.setMonth(prev.getMonth() - 6); + return prev; +} + +function getBeginningOfLastMonth(now: Date) { + const beginningOfMonth = new Date(now); + beginningOfMonth.setMonth(beginningOfMonth.getMonth() - 1); + beginningOfMonth.setDate(1); + return beginningOfMonth; +} + +function getEndOfLastMonth(now: Date) { + const endOfMonth = new Date(now); + endOfMonth.setDate(1); + endOfMonth.setDate(endOfMonth.getDate() - 1); + return endOfMonth; +} + +type Props = { + dateRange: ReportDuration; +} + +export function SystemUsersDateRangeMenu(props: Props) { + const {formatMessage, formatDate} = useIntl(); + + const dispatch = useDispatch(); + + const now = new Date(); + + function getSelectedDateRange(dateRange: ReportDuration) { + if (dateRange === ReportDuration.Last30Days) { + return formatMessage({ + id: 'admin.system_users.date_range_selector.date_range.last_30_days', + defaultMessage: 'Last 30 days', + }); + } else if (dateRange === ReportDuration.PreviousMonth) { + return formatMessage({ + id: 'admin.system_users.date_range_selector.date_range.previous_month', + defaultMessage: 'Previous month', + }); + } else if (dateRange === ReportDuration.Last6Months) { + return formatMessage({ + id: 'admin.system_users.date_range_selector.date_range.last_6_months', + defaultMessage: 'Last 6 months', + }); + } + + return formatMessage({ + id: 'admin.system_users.date_range_selector.date_range.all_time', + defaultMessage: 'All time', + }); + } + + function updateDateRange(value?: ReportDuration) { + dispatch(setAdminConsoleUsersManagementTableProperties({dateRange: value})); + } + + return ( +
+ + } + /> + ), + }} + menu={{ + id: 'systemUsersDateRangeSelectorMenu', + 'aria-label': formatMessage({ + id: 'admin.system_users.date_range_selector.dropdownAriaLabel', + defaultMessage: 'Date range menu', + }), + width: '250px', + }} + > + + } + trailingElements={props.dateRange === ReportDuration.AllTime && } + onClick={() => updateDateRange()} + /> + + + + + } + trailingElements={props.dateRange === ReportDuration.Last30Days && } + onClick={() => updateDateRange(ReportDuration.Last30Days)} + /> + + + + + } + trailingElements={props.dateRange === ReportDuration.PreviousMonth && } + onClick={() => updateDateRange(ReportDuration.PreviousMonth)} + /> + + + + + + } + trailingElements={props.dateRange === ReportDuration.Last6Months && } + onClick={() => updateDateRange(ReportDuration.Last6Months)} + /> + + + + ({chunks}), + }} + /> + + } + disabled={true} + /> + +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_date_range_menu/system_users_date_range_menu.scss b/webapp/channels/src/components/admin_console/system_users/system_users_date_range_menu/system_users_date_range_menu.scss new file mode 100644 index 0000000000..ee4ffc3daa --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_date_range_menu/system_users_date_range_menu.scss @@ -0,0 +1,23 @@ +.systemUsersDateRangeSelector { + margin-inline-end: 8px; + + .inputWithMenu input.form-control { + width: 120px; + } +} + +#dateRangeSelectorMenu { + .systemUsersDateRangeSelector__trailing-message { + color: rgba(var(--center-channel-color-rgb), 0.56); + opacity: 1; + + span { + max-width: 210px; + text-wrap: wrap; + } + } + + i.icon-check { + color: var(--button-bg); + } +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_dropdown/__snapshots__/system_users_dropdown.test.tsx.snap b/webapp/channels/src/components/admin_console/system_users/system_users_dropdown/__snapshots__/system_users_dropdown.test.tsx.snap deleted file mode 100644 index 5f73de0097..0000000000 --- a/webapp/channels/src/components/admin_console/system_users/system_users_dropdown/__snapshots__/system_users_dropdown.test.tsx.snap +++ /dev/null @@ -1,713 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/admin_console/system_users/system_users_dropdown/system_users_dropdown renderDeactivateMemberModal should not render the bot accounts warning in case the user do not have any bot accounts 1`] = ` -
- - -
-`; - -exports[`components/admin_console/system_users/system_users_dropdown/system_users_dropdown renderDeactivateMemberModal should not render the bot accounts warning. owner_id has no enabled bot accounts 1`] = ` -
- - -
-`; - -exports[`components/admin_console/system_users/system_users_dropdown/system_users_dropdown renderDeactivateMemberModal should render the bot accounts warning. owner_id has enabled bot accounts 1`] = ` -
- -
    -
  • - -
  • -
  • - -
  • -
  • - -
  • -
-

-

- - -

-`; - -exports[`components/admin_console/system_users/system_users_dropdown/system_users_dropdown should match snapshot with license 1`] = ` - - - } - message={ -
- - -
- } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={false} - title={ - - } - /> - - } - message={ - - } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={false} - title={ - - } - /> - - } - message={ - - } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={false} - title={ - - } - /> - - } - message={ - - } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={false} - title={ - - } - /> - - } - confirmButtonClass="btn btn-danger" - confirmButtonText={ - - } - message={ - - } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={false} - title={ - - } - /> - - - - - - - - - - - - - - - - - - - - - - -
-`; - -exports[`components/admin_console/system_users/system_users_dropdown/system_users_dropdown should match snapshot without license 1`] = ` - - - } - message={ -
- - -
- } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={false} - title={ - - } - /> - - } - message={ - - } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={false} - title={ - - } - /> - - } - message={ - - } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={false} - title={ - - } - /> - - } - message={ - - } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={false} - title={ - - } - /> - - } - confirmButtonClass="btn btn-danger" - confirmButtonText={ - - } - message={ - - } - modalClass="" - onCancel={[Function]} - onConfirm={[Function]} - show={false} - title={ - - } - /> - - - - - - - - - - - - - - - - - - - - - - -
-`; diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_dropdown/index.ts b/webapp/channels/src/components/admin_console/system_users/system_users_dropdown/index.ts deleted file mode 100644 index b15aa23ac4..0000000000 --- a/webapp/channels/src/components/admin_console/system_users/system_users_dropdown/index.ts +++ /dev/null @@ -1,44 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import type {Dispatch} from 'redux'; - -import {loadBots} from 'mattermost-redux/actions/bots'; -import {createGroupTeamsAndChannels} from 'mattermost-redux/actions/groups'; -import {updateUserActive, revokeAllSessionsForUser, promoteGuestToUser, demoteUserToGuest} from 'mattermost-redux/actions/users'; -import * as Selectors from 'mattermost-redux/selectors/entities/admin'; -import {getExternalBotAccounts} from 'mattermost-redux/selectors/entities/bots'; -import {getLicense} from 'mattermost-redux/selectors/entities/general'; -import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; - -import type {GlobalState} from 'types/store'; - -import SystemUsersDropdown from './system_users_dropdown'; - -function mapStateToProps(state: GlobalState) { - const bots = getExternalBotAccounts(state); - const license = getLicense(state); - return { - isLicensed: license && license.IsLicensed === 'true', - config: Selectors.getConfig(state), - currentUser: getCurrentUser(state), - bots, - }; -} - -function mapDispatchToProps(dispatch: Dispatch) { - return { - actions: bindActionCreators({ - updateUserActive, - revokeAllSessionsForUser, - promoteGuestToUser, - demoteUserToGuest, - loadBots, - createGroupTeamsAndChannels, - }, dispatch), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(SystemUsersDropdown); diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_dropdown/system_users_dropdown.test.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_dropdown/system_users_dropdown.test.tsx deleted file mode 100644 index 0557109777..0000000000 --- a/webapp/channels/src/components/admin_console/system_users/system_users_dropdown/system_users_dropdown.test.tsx +++ /dev/null @@ -1,257 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {shallow} from 'enzyme'; -import React from 'react'; - -import type {UserProfile} from '@mattermost/types/users'; - -import Menu from 'components/widgets/menu/menu'; - -import {TestHelper} from 'utils/test_helper'; - -import SystemUsersDropdown from './system_users_dropdown'; -import type {Props} from './system_users_dropdown'; - -describe('components/admin_console/system_users/system_users_dropdown/system_users_dropdown', () => { - const user: UserProfile & {mfa_active: boolean} = Object.assign(TestHelper.getUserMock(), {mfa_active: true}); - - const otherUser = TestHelper.getUserMock({ - id: 'other_user_id', - roles: '', - username: 'other-user', - }); - - const mockMouseEvent = TestHelper.getMockMouseButtonEvent(); - - const requiredProps: Props = { - user, - mfaEnabled: true, - isLicensed: true, - enableUserAccessTokens: true, - experimentalEnableAuthenticationTransfer: true, - doPasswordReset: jest.fn(), - doEmailReset: jest.fn(), - doManageTeams: jest.fn(), - doManageRoles: jest.fn(), - doManageTokens: jest.fn(), - onError: jest.fn(), - currentUser: otherUser, - index: 0, - totalUsers: 10, - actions: { - updateUserActive: jest.fn().mockResolvedValue({data: true}), - revokeAllSessionsForUser: jest.fn().mockResolvedValue({data: true}), - promoteGuestToUser: jest.fn().mockResolvedValue({data: true}), - demoteUserToGuest: jest.fn().mockResolvedValue({data: true}), - loadBots: jest.fn(() => Promise.resolve([])), - createGroupTeamsAndChannels: jest.fn().mockResolvedValue({data: true}), - }, - config: { - GuestAccountsSettings: { - Enable: true, - }, - }, - bots: {}, - }; - - test('handleMakeActive() should have called updateUserActive', async () => { - const wrapper = shallow(); - - wrapper.instance().handleMakeActive(mockMouseEvent, false); - - expect(requiredProps.actions.updateUserActive).toHaveBeenCalledTimes(1); - expect(requiredProps.actions.updateUserActive).toHaveBeenCalledWith(requiredProps.user.id, true); - }); - - test('handleMakeActive() should not have called updateUserActive if user auth service is LDAP', async () => { - const ldapUserProps = {...requiredProps, user: {...requiredProps.user, auth_service: 'ldap'}}; - - const wrapper = shallow(); - - wrapper.instance().handleMakeActive(mockMouseEvent, true); - - expect(requiredProps.actions.updateUserActive).toHaveBeenCalledTimes(0); - }); - - test('handleMakeActive() should have called onError', async () => { - const retVal = {error: {server_error_id: 'id', message: 'error'}}; - const updateUserActive = jest.fn().mockResolvedValue(retVal); - const props = {...requiredProps, actions: {...requiredProps.actions, updateUserActive}}; - const wrapper = shallow(); - - await wrapper.instance().handleMakeActive(mockMouseEvent, false); - - expect(requiredProps.onError).toHaveBeenCalledTimes(1); - expect(requiredProps.onError).toHaveBeenCalledWith({id: retVal.error.server_error_id, ...retVal.error}); - }); - - test('handleDeactivateMember() should have called updateUserActive', async () => { - const wrapper = shallow(); - - await wrapper.instance().handleDeactivateMember(); - - expect(requiredProps.actions.updateUserActive).toHaveBeenCalledTimes(1); - expect(requiredProps.actions.updateUserActive).toHaveBeenCalledWith(requiredProps.user.id, false); - }); - - test('handleShowDeactivateMemberModal() should not have show the deactivation modal if user auth service is LDAP', async () => { - const ldapUserProps = {...requiredProps, user: {...requiredProps.user, auth_service: 'ldap'}}; - - const wrapper = shallow(); - - await wrapper.instance().handleShowDeactivateMemberModal(mockMouseEvent, true); - - wrapper.update(); - - expect(wrapper.state('showDeactivateMemberModal')).toBeFalsy(); - }); - - test('handleDeactivateMember() should have called onError', async () => { - const retVal = {error: {server_error_id: 'id', message: 'error'}}; - const updateUserActive = jest.fn().mockResolvedValue(retVal); - const props = {...requiredProps, actions: {...requiredProps.actions, updateUserActive}}; - const wrapper = shallow(); - - await wrapper.instance().handleDeactivateMember(); - - expect(requiredProps.onError).toHaveBeenCalledTimes(1); - expect(requiredProps.onError).toHaveBeenCalledWith({id: retVal.error.server_error_id, ...retVal.error}); - }); - - test('handleRevokeSessions() should have called revokeAllSessions', async () => { - const wrapper = shallow(); - - await wrapper.instance().handleRevokeSessions(); - - expect(requiredProps.actions.revokeAllSessionsForUser).toHaveBeenCalled(); - expect(requiredProps.actions.revokeAllSessionsForUser).toHaveBeenCalledWith(requiredProps.user.id); - }); - - test('handleRevokeSessions() should have called onError', async () => { - const revokeAllSessionsForUser = jest.fn().mockResolvedValue({error: {}}); - const onError = jest.fn(); - const props = {...requiredProps, onError, actions: {...requiredProps.actions, revokeAllSessionsForUser}}; - const wrapper = shallow(); - - await wrapper.instance().handleRevokeSessions(); - - expect(onError).toHaveBeenCalled(); - }); - - test('handleShowDeactivateMemberModal should not call the loadBots if the setting is not true', async () => { - const wrapper = shallow(); - - await wrapper.instance().handleShowDeactivateMemberModal(mockMouseEvent, false); - - expect(requiredProps.actions.loadBots).toHaveBeenCalledTimes(0); - }); - - test('handleShowDeactivateMemberModal should call the loadBots only if the setting is true', async () => { - const overrideConfig = { - ServiceSettings: { - DisableBotsWhenOwnerIsDeactivated: true, - }, - }; - const wrapper = shallow(); - - await wrapper.instance().handleShowDeactivateMemberModal(mockMouseEvent, false); - - expect(requiredProps.actions.loadBots).toHaveBeenCalledTimes(1); - }); - - test('renderDeactivateMemberModal should not render the bot accounts warning in case the user do not have any bot accounts', async () => { - const overrideProps = { - config: { - ServiceSettings: { - DisableBotsWhenOwnerIsDeactivated: true, - }, - }, - bots: { - 1: TestHelper.getBotMock({owner_id: '1'}), - 2: TestHelper.getBotMock({owner_id: '1'}), - 3: TestHelper.getBotMock({owner_id: '2'}), - }, - }; - const wrapper = shallow(); - const ConfirmModal = () => wrapper.instance().renderDeactivateMemberModal(); - const modal = shallow(); - expect(modal.prop('message')).toMatchSnapshot(); - }); - - test('renderDeactivateMemberModal should render the bot accounts warning. owner_id has enabled bot accounts', async () => { - const overrideProps = { - config: { - ServiceSettings: { - DisableBotsWhenOwnerIsDeactivated: true, - }, - }, - bots: { - 1: TestHelper.getBotMock({owner_id: '1', delete_at: 0}), - 2: TestHelper.getBotMock({owner_id: '1', delete_at: 0}), - 3: TestHelper.getBotMock({owner_id: 'user_id', delete_at: 0}), - }, - }; - const wrapper = shallow(); - wrapper.setState({showDeactivateMemberModal: true}); - const ConfirmModal = () => wrapper.instance().renderDeactivateMemberModal(); - const modal = shallow(); - expect(modal.prop('message')).toMatchSnapshot(); - }); - - test('renderDeactivateMemberModal should not render the bot accounts warning. owner_id has no enabled bot accounts', async () => { - const overrideProps = { - config: { - ServiceSettings: { - DisableBotsWhenOwnerIsDeactivated: true, - }, - }, - bots: { - 1: TestHelper.getBotMock({owner_id: '1', delete_at: 0}), - 2: TestHelper.getBotMock({owner_id: '1', delete_at: 0}), - 3: TestHelper.getBotMock({owner_id: 'user_id', delete_at: 1234}), - }, - }; - const wrapper = shallow(); - wrapper.setState({showDeactivateMemberModal: true}); - const ConfirmModal = () => wrapper.instance().renderDeactivateMemberModal(); - const modal = shallow(); - expect(modal.prop('message')).toMatchSnapshot(); - }); - - test('Manage Roles button should be hidden for system manager', async () => { - const systemManager = TestHelper.getUserMock({ - id: 'system_manager_id', - roles: 'system_user system_manager', - username: 'system-manager', - }); - const overrideProps = { - currentUser: systemManager, - }; - const wrapper = shallow(); - expect(wrapper.find(Menu.ItemAction).find({text: 'Manage Roles'}).props().show).toBe(false); - }); - - test('Manage Roles button should be visible for system admin', async () => { - const systemAdmin = TestHelper.getUserMock({ - id: 'system_admin_id', - roles: 'system_user system_admin', - username: 'system-admin', - }); - const overrideProps = { - currentUser: systemAdmin, - }; - const wrapper = shallow(); - expect(wrapper.find(Menu.ItemAction).find({text: 'Manage Roles'}).props().show).toBe(true); - }); - - test('should match snapshot with license', async () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - - test('should match snapshot without license', async () => { - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); -}); diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_dropdown/system_users_dropdown.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_dropdown/system_users_dropdown.tsx deleted file mode 100644 index 5be4774e98..0000000000 --- a/webapp/channels/src/components/admin_console/system_users/system_users_dropdown/system_users_dropdown.tsx +++ /dev/null @@ -1,751 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage} from 'react-intl'; - -import type {Bot} from '@mattermost/types/bots'; -import type {AdminConfig} from '@mattermost/types/config'; -import type {ServerError} from '@mattermost/types/errors'; -import type {UserProfile} from '@mattermost/types/users'; -import type {DeepPartial} from '@mattermost/types/utilities'; - -import {Permissions} from 'mattermost-redux/constants'; -import type {ActionResult} from 'mattermost-redux/types/actions'; -import * as UserUtils from 'mattermost-redux/utils/user_utils'; - -import {adminResetMfa} from 'actions/admin_actions.jsx'; -import {emitUserLoggedOutEvent} from 'actions/global_actions'; - -import ConfirmModal from 'components/confirm_modal'; -import ExternalLink from 'components/external_link'; -import FormattedMarkdownMessage from 'components/formatted_markdown_message'; -import SystemPermissionGate from 'components/permissions_gates/system_permission_gate'; -import Menu from 'components/widgets/menu/menu'; -import MenuWrapper from 'components/widgets/menu/menu_wrapper'; - -import {Constants} from 'utils/constants'; -import {t} from 'utils/i18n'; -import {getSiteURL} from 'utils/url'; -import * as Utils from 'utils/utils'; - -const ROWS_FROM_BOTTOM_TO_OPEN_UP = 3; -const TOTAL_USERS_TO_OPEN_UP = 5; - -export type Props = { - user: UserProfile; - currentUser: UserProfile; - mfaEnabled: boolean; - enableUserAccessTokens: boolean; - experimentalEnableAuthenticationTransfer: boolean; - index: number; - totalUsers: number; - config: DeepPartial; - bots: Record; - isLicensed: boolean; - actions: { - updateUserActive: (id: string, active: boolean) => Promise; - revokeAllSessionsForUser: (id: string) => Promise>; - promoteGuestToUser: (id: string) => Promise; - demoteUserToGuest: (id: string) => Promise; - loadBots: (page?: number, size?: number) => Promise; - createGroupTeamsAndChannels: (userId: string) => Promise; - }; - doPasswordReset: (user: UserProfile) => void; - doEmailReset: (user: UserProfile) => void; - doManageTeams: (user: UserProfile) => void; - doManageRoles: (user: UserProfile) => void; - doManageTokens: (user: UserProfile) => void; - onError: (error: ServerError | {id: string}) => void; -} - -type State = { - showDeactivateMemberModal: boolean; - showRevokeSessionsModal: boolean; - showPromoteToUserModal: boolean; - showDemoteToGuestModal: boolean; - showCreateGroupMembershipsModal: boolean; - user: UserProfile | null; - role: string | null; -} - -export default class SystemUsersDropdown extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { - showDeactivateMemberModal: false, - showRevokeSessionsModal: false, - showPromoteToUserModal: false, - showDemoteToGuestModal: false, - showCreateGroupMembershipsModal: false, - user: null, - role: null, - }; - } - - handleMakeActive = (e: React.MouseEvent, disableActivationToggle: boolean) => { - e.preventDefault(); - e.stopPropagation(); - if (disableActivationToggle) { - return; - } - this.props.actions.updateUserActive(this.props.user.id, true). - then(this.onUpdateActiveResult); - }; - - handleManageTeams = (e: {preventDefault: () => void}) => { - e.preventDefault(); - - this.props.doManageTeams(this.props.user); - }; - - handleManageRoles = (e: {preventDefault: () => void}) => { - e.preventDefault(); - - this.props.doManageRoles(this.props.user); - }; - - handleManageTokens = (e: {preventDefault: () => void}) => { - e.preventDefault(); - - this.props.doManageTokens(this.props.user); - }; - - handleResetPassword = (e: {preventDefault: () => void}) => { - e.preventDefault(); - this.props.doPasswordReset(this.props.user); - }; - - handleResetEmail = (e: {preventDefault: () => void}) => { - e.preventDefault(); - this.props.doEmailReset(this.props.user); - }; - - handleResetMfa = (e: {preventDefault: () => void}) => { - e.preventDefault(); - adminResetMfa(this.props.user.id, null, this.props.onError); - }; - - handleShowDeactivateMemberModal = async (e: React.MouseEvent, disableActivationToggle: boolean) => { - e.preventDefault(); - if (disableActivationToggle) { - return; - } - if (this.shouldDisableBotsWhenOwnerIsDeactivated()) { - await this.props.actions.loadBots( - Constants.Integrations.START_PAGE_NUM, - Constants.Integrations.PAGE_SIZE, - ); - } - this.setState({showDeactivateMemberModal: true}); - }; - - handleDeactivateMember = () => { - this.props.actions.updateUserActive(this.props.user.id, false). - then(this.onUpdateActiveResult); - this.setState({showDeactivateMemberModal: false}); - }; - - onUpdateActiveResult = ({error}: ActionResult) => { - if (error) { - this.props.onError({id: error.server_error_id, ...error}); - } - }; - - handleDeactivateCancel = () => { - this.setState({showDeactivateMemberModal: false}); - }; - - renderDeactivateMemberModal = () => { - const user = this.props.user; - const title = ( - - ); - - const defaultMessage = ( - ); - - let warning; - if (user.auth_service !== '' && user.auth_service !== Constants.EMAIL_SERVICE) { - warning = ( - -
-
- -
- ); - } - - const confirmationMessage = ( - ); - let messageForUsersWithBotAccounts; - if (this.shouldDisableBotsWhenOwnerIsDeactivated()) { - for (const bot of Object.values(this.props.bots)) { - if ((bot.owner_id === user.id) && this.state.showDeactivateMemberModal && (bot.delete_at === 0)) { - messageForUsersWithBotAccounts = ( - <> -
    -
  • - -
  • -
  • - -
  • -
  • - ( - - {msg} - - ), - linkDocumentation: (msg: React.ReactNode) => ( - - {msg} - - ), - }} - /> -
  • -
-

-

- - ); - break; - } - } - } - const message = ( -

- {messageForUsersWithBotAccounts || defaultMessage} - {confirmationMessage} - {warning} -
- ); - - const confirmButtonClass = 'btn btn-danger'; - const deactivateMemberButton = ( - - ); - - return ( - - ); - }; - - shouldDisableBotsWhenOwnerIsDeactivated() { - return this.props.config && - this.props.config.ServiceSettings && - this.props.config.ServiceSettings.DisableBotsWhenOwnerIsDeactivated; - } - - handleShowRevokeSessionsModal = (e: {preventDefault: () => void}) => { - e.preventDefault(); - this.setState({showRevokeSessionsModal: true}); - }; - - handleShowCreateGroupSyncableMembershipsModal = (e: {preventDefault: () => void}) => { - e.preventDefault(); - this.setState({showCreateGroupMembershipsModal: true}); - }; - - handleCreateGroupSyncableMemberships = async () => { - const {error} = await this.props.actions.createGroupTeamsAndChannels(this.props.user.id); - if (error) { - this.props.onError(error); - } - - this.setState({showCreateGroupMembershipsModal: false}); - }; - - handleCreateGroupSyncableMembershipsCancel = () => { - this.setState({showCreateGroupMembershipsModal: false}); - }; - - handleRevokeSessions = async () => { - const me = this.props.currentUser; - - const {data, error} = await this.props.actions.revokeAllSessionsForUser(this.props.user.id); - if (data && this.props.user.id === me.id) { - emitUserLoggedOutEvent(); - } else if (error) { - this.props.onError(error); - } - - this.setState({showRevokeSessionsModal: false}); - }; - - handleRevokeSessionsCancel = () => { - this.setState({showRevokeSessionsModal: false}); - }; - - handlePromoteToUser = () => { - this.setState({showPromoteToUserModal: true}); - }; - - handlePromoteToUserConfirm = async () => { - const {error} = await this.props.actions.promoteGuestToUser(this.props.user.id); - if (error) { - this.props.onError(error); - } - - this.setState({showPromoteToUserModal: false}); - }; - - handlePromoteToUserCancel = () => { - this.setState({showPromoteToUserModal: false}); - }; - - handleDemoteToGuest = () => { - this.setState({showDemoteToGuestModal: true}); - }; - - handleDemoteToGuestConfirm = async () => { - const {error} = await this.props.actions.demoteUserToGuest(this.props.user.id); - if (error) { - this.props.onError(error); - } - this.setState({showDemoteToGuestModal: false}); - }; - - handleDemoteToGuestCancel = () => { - this.setState({showDemoteToGuestModal: false}); - }; - - renderPromoteToUserModal = () => { - const title = ( - - ); - - const message = ( - - ); - - const promoteUserButton = ( - - ); - - return ( - - ); - }; - - renderDemoteToGuestModal = () => { - const title = ( - - ); - - const message = ( - - ); - - const demoteGuestButton = ( - - ); - - return ( - - ); - }; - - renderRevokeSessionsModal = () => { - const title = ( - - ); - - const message = ( - - ); - - const revokeUserButtonButton = ( - - ); - - return ( - - ); - }; - - renderCreateGroupSyncablesMembershipsModal = () => { - const title = ( - - ); - - const message = ( - - ); - - const createGroupMembershipsButton = ( - - ); - - const cancelGroupMembershipsButton = ( - - ); - - return ( - - ); - }; - - renderAccessToken = () => { - const userAccessTokensEnabled = this.props.enableUserAccessTokens; - if (!userAccessTokensEnabled) { - return null; - } - - const user = this.props.user; - const hasPostAllRole = UserUtils.hasPostAllRole(user.roles); - const hasPostAllPublicRole = UserUtils.hasPostAllPublicRole(user.roles); - const hasUserAccessTokenRole = UserUtils.hasUserAccessTokenRole(user.roles); - const isSystemAdmin = UserUtils.isSystemAdmin(user.roles); - - let messageId = ''; - if (hasUserAccessTokenRole || isSystemAdmin) { - if (hasPostAllRole) { - messageId = t('admin.user_item.userAccessTokenPostAll'); - } else if (hasPostAllPublicRole) { - messageId = t('admin.user_item.userAccessTokenPostAllPublic'); - } else { - messageId = t('admin.user_item.userAccessTokenYes'); - } - } - - if (!messageId) { - return null; - } - - return ( -
- -
- ); - }; - - render() { - const {currentUser, user, isLicensed, config} = this.props; - - // Disable if SystemAdmin being edited by non SystemAdmin eg. userManager with EditOtherUsers permissions - const isDisabled = UserUtils.isSystemAdmin(user.roles) && !UserUtils.isSystemAdmin(currentUser.roles); - - const isGuest = UserUtils.isGuest(user.roles); - if (!user) { - return
; - } - - let currentRoles = ( - - ); - - if (isGuest) { - currentRoles = ( - - ); - } - - if (user.roles.length > 0 && UserUtils.isSystemAdmin(user.roles)) { - currentRoles = ( - - ); - } - - let showMakeActive = false; - let showMakeNotActive = !UserUtils.isSystemAdmin(user.roles); - let showManageTeams = true; - let showRevokeSessions = true; - const showMfaReset = this.props.mfaEnabled && Boolean(user.mfa_active); - const showManageRoles = UserUtils.isSystemAdmin(currentUser.roles); - - if (user.delete_at > 0) { - currentRoles = ( - - ); - showMakeActive = true; - showMakeNotActive = false; - showManageTeams = false; - showRevokeSessions = false; - } - - let disableActivationToggle = false; - if (user.auth_service === Constants.LDAP_SERVICE) { - disableActivationToggle = true; - } - - const deactivateMemberModal = this.renderDeactivateMemberModal(); - const revokeSessionsModal = this.renderRevokeSessionsModal(); - const promoteToUserModal = this.renderPromoteToUserModal(); - const demoteToGuestModal = this.renderDemoteToGuestModal(); - const createGroupSyncablesMembershipsModal = this.renderCreateGroupSyncablesMembershipsModal(); - - const getExtraText = (disableActivationToggle: boolean) => { - return disableActivationToggle ? { - extraText: Utils.localizeMessage('admin.user_item.managedByLdap', 'Managed by LDAP'), - } : {}; - }; - - const {index, totalUsers} = this.props; - return ( - - {deactivateMemberModal} - {revokeSessionsModal} - {promoteToUserModal} - {demoteToGuestModal} - {createGroupSyncablesMembershipsModal} - -
- - {currentRoles} - {!isDisabled && - - } - - {this.renderAccessToken()} -
- TOTAL_USERS_TO_OPEN_UP && totalUsers - index <= ROWS_FROM_BOTTOM_TO_OPEN_UP} - ariaLabel={Utils.localizeMessage('admin.user_item.menuAriaLabel', 'User Actions Menu')} - > - ) => this.handleMakeActive(e, disableActivationToggle)} - text={Utils.localizeMessage('admin.user_item.makeActive', 'Activate')} - disabled={disableActivationToggle} - {...getExtraText(disableActivationToggle)} - /> - ) => this.handleShowDeactivateMemberModal(e, disableActivationToggle)} - text={Utils.localizeMessage('admin.user_item.makeInactive', 'Deactivate')} - disabled={disableActivationToggle} - {...getExtraText(disableActivationToggle)} - /> - - - - - - - - - - - - - - - - -
-
- ); - } -} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_export/export_error_modal.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_export/export_error_modal.tsx new file mode 100644 index 0000000000..8d2a09b82b --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_export/export_error_modal.tsx @@ -0,0 +1,65 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import type {ServerError} from '@mattermost/types/errors'; + +import ConfirmModalRedux from 'components/confirm_modal_redux'; + +type Props = { + error: ServerError; + onExited: () => void; +} + +export function ExportErrorModal({error, onExited}: Props) { + const isInProgress = error.status_code === 400 && + error.server_error_id === 'app.report.start_users_batch_export.job_exists'; + + let title = ( + + ); + + let message = ( + <> + +
{error.message}
+ + ); + + if (isInProgress) { + title = ( + + ); + message = ( + + ); + } + + return ( + + } + onExited={onExited} + /> + ); +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_export/export_user_data_modal.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_export/export_user_data_modal.tsx new file mode 100644 index 0000000000..f3cede17ea --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_export/export_user_data_modal.tsx @@ -0,0 +1,84 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useSelector} from 'react-redux'; + +import {ReportDuration} from '@mattermost/types/reports'; + +import {getAdminConsoleUserManagementTableProperties} from 'selectors/views/admin'; + +import ConfirmModalRedux from 'components/confirm_modal_redux'; + +type Props = { + onConfirm: (checked: boolean) => void; + onExited: () => void; +} + +export function ExportUserDataModal({onConfirm, onExited}: Props) { + const dateRange = useSelector(getAdminConsoleUserManagementTableProperties).dateRange ?? ReportDuration.AllTime; + + const title = ( + + ); + + let message = ( + + ); + if (dateRange === ReportDuration.Last30Days) { + message = ( + + ); + } else if (dateRange === ReportDuration.PreviousMonth) { + message = ( + + ); + } else if (dateRange === ReportDuration.Last6Months) { + message = ( + + ); + } + + const exportDataButton = ( + + ); + + const checkboxText = ( + + ); + + return ( + + ); +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_export/index.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_export/index.tsx new file mode 100644 index 0000000000..811bf6a514 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_export/index.tsx @@ -0,0 +1,120 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; + +import type {ReportDuration} from '@mattermost/types/reports'; +import type {GlobalState} from '@mattermost/types/store'; +import type {UserProfile} from '@mattermost/types/users'; + +import {savePreferences} from 'mattermost-redux/actions/preferences'; +import {Preferences} from 'mattermost-redux/constants'; +import {getLicense} from 'mattermost-redux/selectors/entities/general'; +import {get} from 'mattermost-redux/selectors/entities/preferences'; + +import {startUsersBatchExport} from 'actions/views/admin'; +import {openModal} from 'actions/views/modals'; + +import WithTooltip from 'components/with_tooltip'; + +import {LicenseSkus, ModalIdentifiers} from 'utils/constants'; + +import {ExportErrorModal} from './export_error_modal'; +import {ExportUserDataModal} from './export_user_data_modal'; +import {UpgradeExportDataModal} from './upgrade_export_data_modal'; + +import './system_users_export.scss'; + +interface Props { + currentUserId: UserProfile['id']; + dateRange: ReportDuration; +} + +export function SystemUsersExport(props: Props) { + const {formatMessage} = useIntl(); + + const dispatch = useDispatch(); + + const skipDialog = useSelector((state: GlobalState) => get(state, Preferences.CATEGORY_REPORTING, Preferences.HIDE_BATCH_EXPORT_CONFIRM_MODAL, '')) === 'true'; + + const license = useSelector(getLicense); + const isLicensed = license.IsLicensed === 'true' && (license.SkuShortName === LicenseSkus.Professional || license.SkuShortName === LicenseSkus.Enterprise); + + async function doExport(checked?: boolean) { + const {error} = await dispatch(startUsersBatchExport(props.dateRange)); + if (error) { + dispatch(openModal({ + modalId: ModalIdentifiers.EXPORT_ERROR_MODAL, + dialogType: ExportErrorModal, + dialogProps: {error}, + })); + return; + } + + if (checked) { + dispatch(savePreferences(props.currentUserId, [{ + category: Preferences.CATEGORY_REPORTING, + name: Preferences.HIDE_BATCH_EXPORT_CONFIRM_MODAL, + user_id: props.currentUserId, + value: 'true', + }])); + } + } + + function handleExport() { + if (!isLicensed) { + dispatch(openModal({ + modalId: ModalIdentifiers.UPGRADE_EXPORT_DATA_MODAL, + dialogType: UpgradeExportDataModal, + dialogProps: {}, + })); + return; + } + + if (skipDialog) { + doExport(); + return; + } + + dispatch(openModal({ + modalId: ModalIdentifiers.EXPORT_USER_DATA_MODAL, + dialogType: ExportUserDataModal, + dialogProps: {onConfirm: doExport}, + })); + } + + const button = ( + + ); + + if (!isLicensed) { + return ( + <> + + {button} + +
+ +
+ + ); + } + + return button; +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_export/system_users_export.scss b/webapp/channels/src/components/admin_console/system_users/system_users_export/system_users_export.scss new file mode 100644 index 0000000000..3245ab54dd --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_export/system_users_export.scss @@ -0,0 +1,21 @@ +.system-users-export__keyIndicator { + position: relative; + top: -6px; + right: 14px; + display: flex; + width: 20px; + height: 20px; + align-self: flex-start; + justify-content: center; + background: var(--online-indicator, #3db887); + border-radius: 12px; + + i { + color: var(--center-channel-bg, #fff); + font-size: 12px; + + &::before { + margin: 0; + } + } +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_export/upgrade_export_data_modal.scss b/webapp/channels/src/components/admin_console/system_users/system_users_export/upgrade_export_data_modal.scss new file mode 100644 index 0000000000..2d537ca57a --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_export/upgrade_export_data_modal.scss @@ -0,0 +1,5 @@ +.upgrade-export-data-modal__svg-image { + display: flex; + justify-content: center; + margin-top: 24px; +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_export/upgrade_export_data_modal.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_export/upgrade_export_data_modal.tsx new file mode 100644 index 0000000000..079027be98 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_export/upgrade_export_data_modal.tsx @@ -0,0 +1,62 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal'; +import WomanUpArrowsAndCloudsSvg from 'components/common/svg_images_components/woman_up_arrows_and_clouds_svg'; +import ConfirmModalRedux from 'components/confirm_modal_redux'; + +import './upgrade_export_data_modal.scss'; + +type Props = { + onExited: () => void; +} + +export function UpgradeExportDataModal({onExited}: Props) { + const openPricingModal = useOpenPricingModal(); + + const confirm = () => { + openPricingModal(); + }; + + const title = ( + + ); + + const message = ( + <> + +
+ +
+ + ); + + const viewPlansButton = ( + + ); + + return ( + + ); +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_filter_role/index.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_filter_role/index.tsx deleted file mode 100644 index b4b5f42de6..0000000000 --- a/webapp/channels/src/components/admin_console/system_users/system_users_filter_role/index.tsx +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import type {ChangeEvent} from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; - -import {UserFilters} from 'utils/constants'; - -type Props = { - value?: string; - onChange: ({searchTerm, teamId, filter}: {searchTerm?: string; teamId?: string; filter?: string}) => void; - onFilter: ({teamId, filter}: {teamId?: string; filter?: string}) => Promise; -}; - -function SystemUsersFilterRole(props: Props) { - const {formatMessage} = useIntl(); - - function handleChange(e: ChangeEvent) { - const filter = e?.target?.value ?? ''; - props.onChange({filter}); - props.onFilter({filter}); - } - - return ( - - ); -} - -export default SystemUsersFilterRole; diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_filter_team/index.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_filter_team/index.tsx deleted file mode 100644 index 870b0695b7..0000000000 --- a/webapp/channels/src/components/admin_console/system_users/system_users_filter_team/index.tsx +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import type {ChangeEvent} from 'react'; -import React from 'react'; -import {FormattedMessage, useIntl} from 'react-intl'; - -import type {Team} from '@mattermost/types/teams'; - -import {SearchUserTeamFilter} from 'utils/constants'; - -type Props = { - options?: Team[]; - value?: string; - onChange: ({searchTerm, teamId, filter}: {searchTerm?: string; teamId?: string; filter?: string}) => void; - onFilter: ({teamId, filter}: {teamId?: string; filter?: string}) => Promise; -}; - -function SystemUsersFilterTeam(props: Props) { - const {formatMessage} = useIntl(); - - function handleChange(e: ChangeEvent) { - const teamId = e?.target?.value ?? ''; - props.onChange({teamId}); - props.onFilter({teamId}); - } - - return ( - - ); -} - -export default SystemUsersFilterTeam; diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/index.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/index.tsx new file mode 100644 index 0000000000..52699cccd3 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/index.tsx @@ -0,0 +1,169 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {autoUpdate, useClick, useDismiss, useFloating, useInteractions, useRole, FloatingFocusManager, useTransitionStyles, autoPlacement, offset} from '@floating-ui/react'; +import React, {useState} from 'react'; +import {useIntl} from 'react-intl'; +import {useDispatch} from 'react-redux'; + +import {setAdminConsoleUsersManagementTableProperties} from 'actions/views/admin'; + +import {StyledPopoverContainer} from 'components/styled_popover_container'; + +import type {AdminConsoleUserManagementTableProperties} from 'types/store/views'; + +import {SystemUsersFiltersStatus} from './styled_users_filters_status'; +import {SystemUsersFilterRole} from './system_users_filter_role'; +import {SystemUsersFilterTeam} from './system_users_filter_team'; + +import './system_users_filter_popover.scss'; +import {RoleFilters, StatusFilter, TeamFilters} from '../constants'; + +type FiltersState = Partial>; + +interface Props { + filterTeam: AdminConsoleUserManagementTableProperties['filterTeam']; + filterTeamLabel: AdminConsoleUserManagementTableProperties['filterTeamLabel']; + filterRole: AdminConsoleUserManagementTableProperties['filterRole']; + filterStatus: AdminConsoleUserManagementTableProperties['filterStatus']; +} + +export function SystemUsersFilterPopover(props: Props) { + const dispatch = useDispatch(); + const {formatMessage} = useIntl(); + + const [isPopoverOpen, setPopoverOpen] = useState(false); + + const [filterState, setFilterState] = useState({}); + + const {context: floatingContext, refs: floatingRefs, floatingStyles} = useFloating({ + open: isPopoverOpen, + onOpenChange: setPopoverOpen, + whileElementsMounted: autoUpdate, + middleware: [ + offset(10), + autoPlacement({ + allowedPlacements: ['bottom-start', 'top-start'], + }), + ], + }); + const {isMounted, styles: floatingTransistionStyles} = useTransitionStyles(floatingContext); + const floatingContextClick = useClick(floatingContext); + const floatingContextDismiss = useDismiss(floatingContext); + const floatingContextRole = useRole(floatingContext); + const {getReferenceProps, getFloatingProps} = useInteractions([ + floatingContextClick, + floatingContextDismiss, + floatingContextRole, + ]); + + function handleTeamFilterChange(teamFilter: string, teamFilterLabel?: string) { + let filterTeam; + let filterTeamLabel; + if (teamFilter === TeamFilters.AllTeams) { + filterTeam = ''; + filterTeamLabel = ''; + } else if (teamFilter === TeamFilters.NoTeams) { + filterTeam = TeamFilters.NoTeams; + filterTeamLabel = ''; + } else { + filterTeam = teamFilter; + + // We need to set the label for selected team, + // since we might not have the selected team label in the list of teams when we are navigating back from the user details page. + filterTeamLabel = teamFilterLabel; + } + + setFilterState({...filterState, filterTeam, filterTeamLabel}); + } + + function handleRoleFilterChange(roleFilter: string) { + let filterRole = ''; + if (roleFilter === RoleFilters.Admin) { + filterRole = RoleFilters.Admin; + } else if (roleFilter === RoleFilters.Member) { + filterRole = RoleFilters.Member; + } else if (roleFilter === RoleFilters.Guest) { + filterRole = RoleFilters.Guest; + } + + setFilterState({...filterState, filterRole}); + } + + function handleStatusFilterChange(statusFilter: string) { + let filterStatus = ''; + if (statusFilter === StatusFilter.Active) { + filterStatus = 'active'; + } else if (statusFilter === StatusFilter.Deactivated) { + filterStatus = 'deactivated'; + } + + setFilterState({...filterState, filterStatus}); + } + + function handleApplyFilters() { + dispatch(setAdminConsoleUsersManagementTableProperties(filterState)); + setPopoverOpen(false); + } + + const filterStatusApplied = props.filterStatus.length > 0 ? 1 : 0; + const filterRoleApplied = props.filterRole.length > 0 ? 1 : 0; + const filterTeamApplied = props.filterTeam.length > 0 ? 1 : 0; + const filtersCount = filterStatusApplied + filterRoleApplied + filterTeamApplied; + + return ( +
+ + {isMounted && ( + + + +
+ + + +
+
+ +
+
+
+ )} +
+ ); +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/styled_users_filters_status/index.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/styled_users_filters_status/index.tsx new file mode 100644 index 0000000000..00463124a5 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/styled_users_filters_status/index.tsx @@ -0,0 +1,72 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo, useState} from 'react'; +import {useIntl} from 'react-intl'; + +import DropdownInput from 'components/dropdown_input'; + +import type {AdminConsoleUserManagementTableProperties} from 'types/store/views'; + +import {StatusFilter} from '../../constants'; +import {getDefaultSelectedValueFromList} from '../../utils'; + +type OptionType = { + label: string; + value: StatusFilter; +} + +interface Props { + initialValue: AdminConsoleUserManagementTableProperties['filterStatus']; + onChange: (value: AdminConsoleUserManagementTableProperties['filterStatus']) => void; +} + +export function SystemUsersFiltersStatus(props: Props) { + const {formatMessage} = useIntl(); + + const options = useMemo(() => { + return [ + { + value: StatusFilter.Any, + label: formatMessage({ + id: 'admin.system_users.filters.status.any', + defaultMessage: 'Any', + }), + }, + { + value: StatusFilter.Active, + label: formatMessage({ + id: 'admin.system_users.filters.status.active', + defaultMessage: 'Activated users', + }), + }, + { + value: StatusFilter.Deactivated, + label: formatMessage({ + id: 'admin.system_users.filters.status.deactive', + defaultMessage: 'Deactivated users', + }), + }, + ]; + }, []); + + const [value, setValue] = useState(() => getDefaultSelectedValueFromList(props.initialValue, options)); + + function handleChange(value: OptionType) { + setValue(value); + + props.onChange(value.value); + } + + return ( + + name='filterStatus' + showLegend={true} + isSearchable={false} + legend={formatMessage({id: 'admin.system_users.filters.status.title', defaultMessage: 'Status'})} + options={options} + value={value} + onChange={handleChange} + /> + ); +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_popover.scss b/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_popover.scss new file mode 100644 index 0000000000..88aec5bbcf --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_popover.scss @@ -0,0 +1,36 @@ +.systemUsersFilterContainer { + flex-grow: 1; +} + +.systemUsersFilterPopoverContainer { + & > #header { + padding: 20px 24px; + border-bottom: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08); + margin: unset; + color: var(--sys-center-channel-color); + font-size: 14px; + font-weight: 600; + line-height: 14px; + } + + & > .body { + width: 280px; + padding: 20px 24px 0 20px; + + & > * { + margin-bottom: 24px; + } + + .DropdownInput.Input_container { + margin-top: unset; + } + } + + .footer { + display: flex; + height: unset; + justify-content: flex-end; + margin-block-end: 20px; + margin-inline-end: 24px; + } +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_role/index.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_role/index.tsx new file mode 100644 index 0000000000..001fc6d9b8 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_role/index.tsx @@ -0,0 +1,79 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {useMemo, useState} from 'react'; +import {useIntl} from 'react-intl'; + +import DropdownInput from 'components/dropdown_input'; + +import type {AdminConsoleUserManagementTableProperties} from 'types/store/views'; + +import {RoleFilters} from '../../constants'; +import {getDefaultSelectedValueFromList} from '../../utils'; + +type OptionType = { + label: string; + value: RoleFilters; +} + +type Props = { + initialValue: AdminConsoleUserManagementTableProperties['filterRole']; + onChange: (value: AdminConsoleUserManagementTableProperties['filterRole']) => void; +}; + +export function SystemUsersFilterRole(props: Props) { + const {formatMessage} = useIntl(); + + const options = useMemo(() => { + return [ + { + value: RoleFilters.Any, + label: formatMessage({ + id: 'admin.system_users.filters.role.any', + defaultMessage: 'Any', + }), + }, + { + value: RoleFilters.Admin, + label: formatMessage({ + id: 'admin.system_users.filters.role.system_admin', + defaultMessage: 'System Admin', + }), + }, + { + value: RoleFilters.Member, + label: formatMessage({ + id: 'admin.system_users.filters.role.system_user', + defaultMessage: 'Member', + }), + }, + { + value: RoleFilters.Guest, + label: formatMessage({ + id: 'admin.system_users.filters.role.system_guest', + defaultMessage: 'Guest', + }), + }, + ]; + }, []); + + const [value, setValue] = useState(() => getDefaultSelectedValueFromList(props.initialValue, options)); + + function handleChange(value: OptionType) { + setValue(value); + + props.onChange(value.value); + } + + return ( + + name='filterRole' + showLegend={true} + isSearchable={false} + legend={formatMessage({id: 'admin.system_users.filters.role.title', defaultMessage: 'Role'})} + options={options} + value={value} + onChange={handleChange} + /> + ); +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_team/async_team_select.scss b/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_team/async_team_select.scss new file mode 100644 index 0000000000..4e7569635d --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_team/async_team_select.scss @@ -0,0 +1,60 @@ +.DropdownInput { + .Input_wrapper { + padding: 0 1px; + } + + .Input { + font-size: 14px; + line-height: 20px; + + &.Input___focus .a11y--focused { + box-shadow: none; + } + } + + .Input_legend { + margin-left: 8px; + } +} + +.asyncTeamSelectInput__controlContainer { + .DropDown__control { + background-color: var(--center-channel-bg); + } +} + +.DropDown__menu { + background-color: var(--center-channel-bg) !important; +} + +.DropDown__single-value { + color: var(--center-channel-color) !important; +} + +.asyncTeamSelectInput__indicatorsContainer { + margin-right: 8px; + + i { + color: rgba(var(--center-channel-color-rgb), 0.64); + font-weight: normal; + + &::before { + margin: 0; + } + } +} + +.asyncTeamSelectInput__option > div { + padding: 10px 24px; + cursor: pointer; + line-height: 16px; +} + +.asyncTeamSelectInput__option.selected > div { + background-color: rgba(var(--button-bg-rgb), 0.08); + color: var(--center-channel-color); +} + +.asyncTeamSelectInput__option.focused > div { + background-color: rgba(var(--center-channel-color-rgb), 0.08); +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_team/index.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_team/index.tsx new file mode 100644 index 0000000000..07d22f0fe1 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_filters_popover/system_users_filter_team/index.tsx @@ -0,0 +1,229 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import type {CSSProperties, ReactElement} from 'react'; +import React, {useEffect, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {useDispatch} from 'react-redux'; +import {components} from 'react-select'; +import type {IndicatorContainerProps, ControlProps, OptionProps, OptionsType, ValueType, StylesConfig} from 'react-select'; +import AsyncSelect from 'react-select/async'; + +import type {Team, TeamSearchOpts} from '@mattermost/types/teams'; + +import {getTeams, searchTeams} from 'mattermost-redux/actions/teams'; +import type {ActionResult} from 'mattermost-redux/types/actions'; + +import InputError from 'components/input_error'; +import LoadingSpinner from 'components/widgets/loading/loading_spinner'; + +import {TeamFilters} from '../../constants'; +import {getDefaultSelectedTeam} from '../../utils'; + +import './async_team_select.scss'; + +const TEAMS_PER_PAGE = 50; + +export type OptionType = { + label: string | ReactElement; + value: string; +} + +interface Props { + className?: string; + error?: string; + initialValue: Team['id']; + initialLabel?: string; + onChange: (value: Team['id'], label?: string) => void; +} + +export function SystemUsersFilterTeam(props: Props) { + const {formatMessage} = useIntl(); + + const dispatch = useDispatch(); + + const [error, setError] = useState(''); + + const [list, setList] = useState>(); + const [pageNumber, setPageNumber] = useState(0); + const [value, setValue] = useState>(getDefaultSelectedTeam(props.initialValue, props.initialLabel)); + + async function loadListInPageNumber(page: number) { + try { + const response = await dispatch(getTeams(page, TEAMS_PER_PAGE, true)) as ActionResult<{teams: Team[]}>; + if (response && response.data && response.data.teams && response.data.teams.length > 0) { + const list = response.data.teams. + map((team: Team) => ({ + value: team.id, + label: team.display_name, + })). + sort((a: OptionType, b: OptionType) => (a.label as string).localeCompare(b.label as string)); + + if (page === 0) { + const initialOptions = [ + { + label: formatMessage({id: 'admin.system_users.filters.team.allTeams', defaultMessage: 'All teams'}), + value: TeamFilters.AllTeams, + }, + { + label: formatMessage({id: 'admin.system_users.filters.team.noTeams', defaultMessage: 'No teams'}), + value: TeamFilters.NoTeams, + }, + ]; + setList([...initialOptions, ...list]); + } else { + setList((existingList) => [...(existingList ?? []), ...list]); + } + + setPageNumber(page + 1); + } + } catch (error) { + setError(formatMessage({id: 'admin.system_users.filters.team.errorLoading', defaultMessage: 'Error while loading teams'})); + console.error(error); // eslint-disable-line no-console + } + } + + async function searchInList(term: string, callBack: (options: OptionsType<{label: string; value: string}>) => void) { + try { + const response = await dispatch(searchTeams(term, {page: 0, per_page: TEAMS_PER_PAGE} as TeamSearchOpts)); + if (response && response.data && response.data.teams && response.data.teams.length > 0) { + const teams = response.data.teams.map((team: Team) => ({ + value: team.id, + label: team.display_name, + })); + + callBack(teams); + } + + callBack([]); + } catch (error) { + setError(formatMessage({id: 'admin.system_users.filters.team.errorSearching', defaultMessage: 'Error while searching teams'})); + console.error(error); // eslint-disable-line no-console + callBack([]); + } + } + + function handleMenuScrolledToBottom() { + loadListInPageNumber(pageNumber); + } + + function handleOnChange(value: ValueType) { + setValue(value); + props.onChange((value as OptionType).value as string, (value as OptionType).label as string); + } + + useEffect(() => { + loadListInPageNumber(0); + }, []); + + return ( +
+
+ + {formatMessage({id: 'admin.system_users.filters.team.title', defaultMessage: 'Team'})} + +
+ formatMessage({id: 'admin.channels.filterBy.team.loading', defaultMessage: 'Loading teams'})} + noOptionsMessage={() => formatMessage({id: 'admin.channels.filterBy.team.noTeams', defaultMessage: 'No teams found'})} + loadOptions={searchInList} + defaultOptions={list} + value={value} + onChange={handleOnChange} + onMenuScrollToBottom={handleMenuScrolledToBottom} + components={{ + IndicatorsContainer, + LoadingIndicator, + Option, + Control, + }} + /> +
+
+ +
+ ); +} + +const styles: Partial = { + input: (provided: CSSProperties) => ({ + ...provided, + color: 'var(--center-channel-color)', + }), + control: (provided: CSSProperties) => ({ + ...provided, + border: 'none', + boxShadow: 'none', + padding: '0 2px', + cursor: 'pointer', + }), + indicatorSeparator: (provided: CSSProperties) => ({ + ...provided, + display: 'none', + }), + menu: (provided: CSSProperties) => ({ + ...provided, + zIndex: 100, + }), + menuPortal: (provided: CSSProperties) => ({ + ...provided, + zIndex: 100, + }), +}; + +const IndicatorsContainer = (props: IndicatorContainerProps) => { + return ( +
+ + + +
+ ); +}; + +const Control = (props: ControlProps) => { + return ( +
+ +
+ ); +}; + +const Option = (props: OptionProps) => { + return ( +
+ +
+ ); +}; + +const LoadingIndicator = () => { + return ( + + ); +}; + diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_list/__snapshots__/system_users_list.test.tsx.snap b/webapp/channels/src/components/admin_console/system_users/system_users_list/__snapshots__/system_users_list.test.tsx.snap deleted file mode 100644 index 1b256d51a9..0000000000 --- a/webapp/channels/src/components/admin_console/system_users/system_users_list/__snapshots__/system_users_list.test.tsx.snap +++ /dev/null @@ -1,501 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`components/admin_console/system_users/list should match default snapshot 1`] = ` -
- - - - - - -
-`; - -exports[`components/admin_console/system_users/list should match default snapshot, with users and mfa disabled 1`] = ` -
- , - ", ", - , - ], - "id2": Array [ - , - ", ", - , - ], - "id3": Array [ - , - ", ", - , - ], - "id4": Array [ - , - ", ", - , - ], - "id5": Array [ - , - ", ", - , - ], - } - } - filter="" - focusOnMount={false} - isDisabled={false} - mfaEnabled={false} - nextPage={[Function]} - noBuiltInFilters={true} - onTermChange={[MockFunction]} - page={0} - previousPage={[Function]} - renderCount={[Function]} - renderFilterRow={[MockFunction]} - rowComponentType={ - Object { - "$$typeof": Symbol(react.memo), - "WrappedComponent": [Function], - "compare": null, - "type": [Function], - } - } - search={[MockFunction]} - teamId="" - term="" - total={0} - users={ - Array [ - Object { - "id": "id1", - }, - Object { - "id": "id2", - }, - Object { - "auth_service": "ldap", - "id": "id3", - }, - Object { - "auth_service": "saml", - "id": "id4", - }, - Object { - "auth_service": "other service", - "id": "id5", - }, - ] - } - usersPerPage={0} - /> - - - - - -
-`; - -exports[`components/admin_console/system_users/list should match default snapshot, with users and mfa enabled 1`] = ` -
- , - ", ", - , - ", ", - , - ], - "id2": Array [ - , - ", ", - , - ", ", - , - ], - "id3": Array [ - , - ", ", - , - ", ", - , - ], - "id4": Array [ - , - ", ", - , - ", ", - , - ], - "id5": Array [ - , - ", ", - , - ", ", - , - ], - } - } - filter="" - focusOnMount={false} - isDisabled={false} - mfaEnabled={true} - nextPage={[Function]} - noBuiltInFilters={true} - onTermChange={[MockFunction]} - page={0} - previousPage={[Function]} - renderCount={[Function]} - renderFilterRow={[MockFunction]} - rowComponentType={ - Object { - "$$typeof": Symbol(react.memo), - "WrappedComponent": [Function], - "compare": null, - "type": [Function], - } - } - search={[MockFunction]} - teamId="" - term="" - total={0} - users={ - Array [ - Object { - "id": "id1", - }, - Object { - "id": "id2", - }, - Object { - "auth_service": "ldap", - "id": "id3", - }, - Object { - "auth_service": "saml", - "id": "id4", - }, - Object { - "auth_service": "other service", - "id": "id5", - }, - ] - } - usersPerPage={0} - /> - - - - - -
-`; diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_list/index.ts b/webapp/channels/src/components/admin_console/system_users/system_users_list/index.ts deleted file mode 100644 index a2a7de6f9c..0000000000 --- a/webapp/channels/src/components/admin_console/system_users/system_users_list/index.ts +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import type {Dispatch} from 'redux'; - -import type {GlobalState} from '@mattermost/types/store'; - -import {getUser} from 'mattermost-redux/actions/users'; - -import {getNonBotUsers} from './selectors'; -import SystemUsersList from './system_users_list'; - -type Props = { - loading: boolean; - teamId: string; - term: string; - filter: string; -} - -function mapStateToProps(state: GlobalState, ownProps: Props) { - const users = getNonBotUsers(state, ownProps.loading, ownProps.teamId, ownProps.term, ownProps.filter); - return { - users, - }; -} - -function mapDispatchToProps(dispatch: Dispatch) { - return { - actions: bindActionCreators({ - getUser, - }, dispatch), - }; -} - -export default connect(mapStateToProps, mapDispatchToProps)(SystemUsersList); diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_list/selectors.test.ts b/webapp/channels/src/components/admin_console/system_users/system_users_list/selectors.test.ts deleted file mode 100644 index 92c696f609..0000000000 --- a/webapp/channels/src/components/admin_console/system_users/system_users_list/selectors.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import type {GlobalState} from '@mattermost/types/store'; -import type {UserProfile} from '@mattermost/types/users'; - -import * as users from 'mattermost-redux/selectors/entities/users'; - -import {getUsers} from 'components/admin_console/system_users/system_users_list/selectors'; - -jest.mock('mattermost-redux/selectors/entities/users'); - -describe('components/admin_console/system_users/list/selectors', () => { - const state = {} as GlobalState; - - test('should return no users when loading', () => { - const loading = true; - const teamId = 'teamId'; - const term = 'term'; - const filter = ''; - - expect(getUsers(state, loading, teamId, term, filter)).toEqual([]); - }); - - describe('should search by term', () => { - const loading = false; - - describe('over all profiles', () => { - const teamId = ''; - const filter = ''; - - it('returning users users', () => { - const term = 'term'; - - const expectedUsers = [{id: 'id1'}, {id: 'id2'}]; - (users.makeSearchProfilesStartingWithTerm as jest.Mock).mockImplementation(() => jest.fn().mockReturnValue(expectedUsers)); - expect(getUsers(state, loading, teamId, term, filter)).toEqual(expectedUsers); - }); - - describe('falling back to fetching user by id', () => { - const term = 'x'.repeat(26); - - it('and the user is found', () => { - const expectedUsers = [{id: 'id1'}]; - (users.makeSearchProfilesStartingWithTerm as jest.Mock).mockImplementation(() => jest.fn().mockReturnValue([])); - - (users.getUser as jest.Mock).mockReturnValue(expectedUsers[0]); - - expect(getUsers(state, loading, teamId, term, filter)).toEqual(expectedUsers); - expect(users.getUser).toBeCalledWith(state, term); - }); - - it('and the user is not found', () => { - const expectedUsers = [] as UserProfile[]; - (users.makeSearchProfilesStartingWithTerm as jest.Mock).mockImplementation(() => jest.fn().mockReturnValue([])); - (users.getUser as jest.Mock).mockReturnValue(null); - - expect(getUsers(state, loading, teamId, term, filter)).toEqual(expectedUsers); - expect(users.getUser).toBeCalledWith(state, term); - }); - }); - }); - - describe('and team id', () => { - const teamId = 'teamId'; - const filter = ''; - - it('returning users users found in team', () => { - const term = 'term'; - - const expectedUsers = [{id: 'id1'}, {id: 'id2'}]; - (users.searchProfilesInTeam as jest.Mock).mockReturnValue(expectedUsers); - - expect(getUsers(state, loading, teamId, term, filter)).toEqual(expectedUsers); - expect(users.searchProfilesInTeam).toBeCalledWith(state, teamId, term, false, {}); - }); - - describe('falling back to fetching user by id', () => { - const term = 'x'.repeat(26); - - it('and the user is found', () => { - const expectedUsers = [{id: 'id1'}]; - (users.searchProfilesInTeam as jest.Mock).mockReturnValue([]); - (users.getUser as jest.Mock).mockReturnValue(expectedUsers[0]); - - expect(getUsers(state, loading, teamId, term, filter)).toEqual(expectedUsers); - expect(users.searchProfilesInTeam).toBeCalledWith(state, teamId, term, false, {}); - expect(users.getUser).toBeCalledWith(state, term); - }); - - it('and the user is not found', () => { - const expectedUsers = [] as UserProfile[]; - (users.searchProfilesInTeam as jest.Mock).mockReturnValue([]); - (users.getUser as jest.Mock).mockReturnValue(null); - - expect(getUsers(state, loading, teamId, term, filter)).toEqual(expectedUsers); - expect(users.searchProfilesInTeam).toBeCalledWith(state, teamId, term, false, {}); - expect(users.getUser).toBeCalledWith(state, term); - }); - }); - }); - }); - - describe('should return', () => { - const loading = false; - const term = ''; - const filter = ''; - - it('all profiles', () => { - const teamId = ''; - - const expectedUsers = [{id: 'id1'}, {id: 'id2'}]; - (users.getProfiles as jest.Mock).mockReturnValue(expectedUsers); - - expect(getUsers(state, loading, teamId, term, filter)).toEqual(expectedUsers); - expect(users.getProfiles).toBeCalledWith(state, {}); - }); - - it('profiles without a team', () => { - const teamId = 'no_team'; - - const expectedUsers = [{id: 'id1'}, {id: 'id2'}]; - (users.getProfilesWithoutTeam as jest.Mock).mockReturnValue(expectedUsers); - - expect(getUsers(state, loading, teamId, term, filter)).toEqual(expectedUsers); - expect(users.getProfilesWithoutTeam).toBeCalledWith(state, {}); - }); - - it('profiles for the given team', () => { - const teamId = 'team_id1'; - - const expectedUsers = [{id: 'id1'}, {id: 'id2'}]; - (users.getProfilesInTeam as jest.Mock).mockReturnValue(expectedUsers); - expect(getUsers(state, loading, teamId, term, filter)).toEqual(expectedUsers); - expect(users.getProfilesInTeam).toBeCalledWith(state, teamId, {}); - }); - }); - - describe('filters', () => { - const loading = false; - const term = ''; - const systemAdmin = 'system_admin'; - const roleFilter = {role: 'system_admin'}; - const inactiveFilter = {inactive: true}; - const inactive = 'inactive'; - - it('all profiles with system admin', () => { - const teamId = ''; - - const expectedUsers = [{id: 'id1'}]; - (users.getProfiles as jest.Mock).mockReturnValue(expectedUsers); - - expect(getUsers(state, loading, teamId, term, systemAdmin)).toEqual(expectedUsers); - expect(users.getProfiles).toBeCalledWith(state, roleFilter); - }); - - it('inactive profiles without a team', () => { - const teamId = 'no_team'; - - const expectedUsers = [{id: 'id1'}, {id: 'id2'}]; - (users.getProfilesWithoutTeam as jest.Mock).mockReturnValue(expectedUsers); - - expect(getUsers(state, loading, teamId, term, inactive)).toEqual(expectedUsers); - expect(users.getProfilesWithoutTeam).toBeCalledWith(state, inactiveFilter); - }); - - it('system admin profiles for the given team', () => { - const teamId = 'team_id1'; - - const expectedUsers = [{id: 'id2'}]; - (users.getProfilesInTeam as jest.Mock).mockReturnValue(expectedUsers); - expect(getUsers(state, loading, teamId, term, systemAdmin)).toEqual(expectedUsers); - expect(users.getProfilesInTeam).toBeCalledWith(state, teamId, roleFilter); - }); - }); -}); diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_list/selectors.ts b/webapp/channels/src/components/admin_console/system_users/system_users_list/selectors.ts deleted file mode 100644 index 4d2c1bf5ac..0000000000 --- a/webapp/channels/src/components/admin_console/system_users/system_users_list/selectors.ts +++ /dev/null @@ -1,54 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import type {GlobalState} from '@mattermost/types/store'; -import type {UserProfile} from '@mattermost/types/users'; - -import {getUser, getProfiles, getProfilesInTeam, getProfilesWithoutTeam, makeSearchProfilesStartingWithTerm, searchProfilesInTeam} from 'mattermost-redux/selectors/entities/users'; - -import {userSelectorOptionsFromFilter} from 'utils/filter_users'; - -const ALL_USERS = ''; -const NO_TEAM = 'no_team'; -const USER_ID_LENGTH = 26; - -export function getUsers(state: GlobalState, loading: boolean, teamId: string, term: string, filter: string): UserProfile[] { - if (loading) { - // Show no users while loading. - return []; - } - const searchProfilesStartingWithTerm = makeSearchProfilesStartingWithTerm(); - - const filters = userSelectorOptionsFromFilter(filter); - if (term) { - let users = []; - if (teamId) { - users = searchProfilesInTeam(state, teamId, term, false, filters); - } else { - users = searchProfilesStartingWithTerm(state, term, false, filters); - } - - if (users.length === 0 && term.length === USER_ID_LENGTH) { - const user = getUser(state, term); - if (user) { - users = [user]; - } - } - - return users; - } - - if (teamId === ALL_USERS) { - return getProfiles(state, filters); - } else if (teamId === NO_TEAM) { - return getProfilesWithoutTeam(state, filters); - } - - return getProfilesInTeam(state, teamId, filters); -} - -export function getNonBotUsers(state: GlobalState, loading: boolean, teamId: string, term: string, filter: string): UserProfile[] { - return getUsers(state, loading, teamId, term, filter).filter((user) => { - return !user.is_bot; - }); -} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_list/system_users_list.test.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_list/system_users_list.test.tsx deleted file mode 100644 index 84c1aff931..0000000000 --- a/webapp/channels/src/components/admin_console/system_users/system_users_list/system_users_list.test.tsx +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {shallow} from 'enzyme'; -import React from 'react'; - -import type {UserProfile} from '@mattermost/types/users'; - -import SystemUsersList from 'components/admin_console/system_users/system_users_list/system_users_list'; - -import {Constants} from 'utils/constants'; - -describe('components/admin_console/system_users/list', () => { - const defaultProps = { - users: [] as UserProfile[], - usersPerPage: 0, - total: 0, - nextPage: jest.fn(), - search: jest.fn(), - focusOnMount: false, - renderFilterRow: jest.fn(), - teamId: '', - filter: '', - term: '', - onTermChange: jest.fn(), - mfaEnabled: false, - enableUserAccessTokens: false, - experimentalEnableAuthenticationTransfer: false, - actions: { - getUser: jest.fn(), - updateTeamMemberSchemeRoles: jest.fn(), - getTeamMembersForUser: jest.fn(), - getTeamsForUser: jest.fn(), - removeUserFromTeam: jest.fn(), - }, - isDisabled: false, - }; - - test('should match default snapshot', () => { - const props = defaultProps; - const wrapper = shallow(); - expect(wrapper).toMatchSnapshot(); - }); - - describe('should match default snapshot, with users', () => { - const props = { - ...defaultProps, - users: [ - {id: 'id1'}, - {id: 'id2'}, - {id: 'id3', auth_service: Constants.LDAP_SERVICE}, - {id: 'id4', auth_service: Constants.SAML_SERVICE}, - {id: 'id5', auth_service: 'other service'}, - ] as UserProfile[], - }; - - it('and mfa enabled', () => { - const wrapper = shallow( - , - ); - expect(wrapper).toMatchSnapshot(); - }); - - it('and mfa disabled', () => { - const wrapper = shallow( - , - ); - expect(wrapper).toMatchSnapshot(); - }); - }); - - describe('should reset page', () => { - it('when team changes', () => { - const wrapper = shallow( - , - ); - - expect(wrapper.state('page')).toBe(0); - (wrapper.instance() as SystemUsersList).nextPage(); - expect(wrapper.state('page')).toBe(1); - wrapper.setProps({...defaultProps, teamId: 'new'}); - expect(wrapper.state('page')).toBe(0); - }); - - it('when filter changes', () => { - const wrapper = shallow( - , - ); - - expect(wrapper.state('page')).toBe(0); - (wrapper.instance() as SystemUsersList).nextPage(); - expect(wrapper.state('page')).toBe(1); - wrapper.setProps({...defaultProps, filter: 'new'}); - expect(wrapper.state('page')).toBe(0); - }); - }); - - describe('should not reset page', () => { - it('when term changes', () => { - const wrapper = shallow( - , - ); - - expect(wrapper.state('page')).toBe(0); - (wrapper.instance() as SystemUsersList).nextPage(); - expect(wrapper.state('page')).toBe(1); - wrapper.setProps({...defaultProps, term: 'new term'}); - expect(wrapper.state('page')).toBe(1); - }); - }); -}); diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_list/system_users_list.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_list/system_users_list.tsx deleted file mode 100644 index fcaea61c60..0000000000 --- a/webapp/channels/src/components/admin_console/system_users/system_users_list/system_users_list.tsx +++ /dev/null @@ -1,381 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage} from 'react-intl'; - -import type {Team} from '@mattermost/types/teams'; -import type {UserProfile} from '@mattermost/types/users'; - -import ManageRolesModal from 'components/admin_console/manage_roles_modal'; -import ManageTeamsModal from 'components/admin_console/manage_teams_modal'; -import ManageTokensModal from 'components/admin_console/manage_tokens_modal'; -import ResetEmailModal from 'components/admin_console/reset_email_modal'; -import ResetPasswordModal from 'components/admin_console/reset_password_modal'; -import FormattedMarkdownMessage from 'components/formatted_markdown_message'; -import SearchableUserList from 'components/searchable_user_list/searchable_user_list'; -import UserListRowWithError from 'components/user_list_row_with_error'; - -import {Constants} from 'utils/constants'; -import * as Utils from 'utils/utils'; - -import SystemUsersDropdown from '../system_users_dropdown'; - -type Props = { - users: UserProfile[]; - teams?: Team[]; - usersPerPage: number; - total: number; - nextPage: (page: number) => void; - search: (term: string) => void; - focusOnMount?: boolean; - - teamId: string; - filter: string; - term: string; - onTermChange: (term: string) => void; - - /** - * Whether MFA is licensed and enabled. - */ - mfaEnabled: boolean; - - /** - * Whether or not user access tokens are enabled. - */ - enableUserAccessTokens: boolean; - - /** - * Whether or not the experimental authentication transfer is enabled. - */ - experimentalEnableAuthenticationTransfer: boolean; - - actions: { - getUser: (id: string) => void; - }; -}; - -type State = { - page: number; - filter: string; - teamId: string; - showManageTeamsModal: boolean; - showManageRolesModal: boolean; - showManageTokensModal: boolean; - showPasswordModal: boolean; - showEmailModal: boolean; - user?: UserProfile; -}; - -export default class SystemUsersList extends React.PureComponent { - constructor(props: Props) { - super(props); - - this.state = { - page: 0, - - filter: props.filter, - teamId: props.teamId, - showManageTeamsModal: false, - showManageRolesModal: false, - showManageTokensModal: false, - showPasswordModal: false, - showEmailModal: false, - user: undefined, - }; - } - - static getDerivedStateFromProps(nextProps: Props, prevState: State): { page: number; teamId: string; filter: string } | null { - if (prevState.teamId !== nextProps.teamId || prevState.filter !== nextProps.filter) { - return { - page: 0, - teamId: nextProps.teamId, - filter: nextProps.filter, - }; - } - return null; - } - - nextPage = () => { - this.setState({page: this.state.page + 1}); - - this.props.nextPage(this.state.page + 1); - }; - - previousPage = () => { - this.setState({page: this.state.page - 1}); - }; - - search = (term: string) => { - this.props.search(term); - - if (term !== '') { - this.setState({page: 0}); - } - }; - - doManageTeams = (user: UserProfile) => { - this.setState({ - showManageTeamsModal: true, - user, - }); - }; - - doManageRoles = (user: UserProfile) => { - this.setState({ - showManageRolesModal: true, - user, - }); - }; - - doManageTokens = (user: UserProfile) => { - this.setState({ - showManageTokensModal: true, - user, - }); - }; - - doManageTeamsDismiss = () => { - this.setState({ - showManageTeamsModal: false, - user: undefined, - }); - }; - - doManageRolesDismiss = () => { - this.setState({ - showManageRolesModal: false, - user: undefined, - }); - }; - - doManageTokensDismiss = () => { - this.setState({ - showManageTokensModal: false, - user: undefined, - }); - }; - - doPasswordReset = (user: UserProfile) => { - this.setState({ - showPasswordModal: true, - user, - }); - }; - - doPasswordResetDismiss = () => { - this.setState({ - showPasswordModal: false, - user: undefined, - }); - }; - - doPasswordResetSubmit = (user?: UserProfile) => { - if (user) { - this.props.actions.getUser(user.id); - } - - this.setState({ - showPasswordModal: false, - user: undefined, - }); - }; - - doEmailReset = (user: UserProfile) => { - this.setState({ - showEmailModal: true, - user, - }); - }; - - doEmailResetDismiss = () => { - this.setState({ - showEmailModal: false, - user: undefined, - }); - }; - - doEmailResetSubmit = (user?: UserProfile) => { - if (user) { - this.props.actions.getUser(user.id); - } - - this.setState({ - showEmailModal: false, - user: undefined, - }); - }; - - getInfoForUser(user: UserProfile) { - const info = []; - - if (user.auth_service) { - let service; - if (user.auth_service === Constants.LDAP_SERVICE || user.auth_service === Constants.SAML_SERVICE) { - service = user.auth_service.toUpperCase(); - } else { - service = Utils.toTitleCase(user.auth_service); - } - - info.push( - , - ); - } else { - info.push( - , - ); - } - - info.push(', '); - const userID = user.id; - info.push( - , - ); - - if (this.props.mfaEnabled) { - info.push(', '); - - if (user.mfa_active) { - info.push( - , - ); - } else { - info.push( - , - ); - } - } - - return info; - } - - renderCount(count: number, total: number, startCount: number, endCount: number, isSearch: boolean) { - if (total) { - if (isSearch) { - return ( - - ); - } else if (startCount !== 0 || endCount !== total) { - return ( - - ); - } - - return ( - - ); - } - - return null; - } - - render() { - const extraInfo: {[key: string]: Array} = {}; - if (this.props.users) { - for (const user of this.props.users) { - extraInfo[user.id] = this.getInfoForUser(user); - } - } - - return ( -
- - - - - - -
- ); - } -} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/create_group_syncables_membership_modal.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/create_group_syncables_membership_modal.tsx new file mode 100644 index 0000000000..0309352d72 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/create_group_syncables_membership_modal.tsx @@ -0,0 +1,76 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useDispatch} from 'react-redux'; + +import type {ServerError} from '@mattermost/types/errors'; +import type {UserProfile} from '@mattermost/types/users'; + +import {createGroupTeamsAndChannels} from 'mattermost-redux/actions/groups'; + +import ConfirmModalRedux from 'components/confirm_modal_redux'; + +type Props = { + user: UserProfile; + onExited: () => void; + onError: (error: ServerError) => void; +} + +export default function CreateGroupSyncablesMembershipsModal({user, onExited, onError}: Props) { + const dispatch = useDispatch(); + + async function confirm() { + const {error} = await dispatch(createGroupTeamsAndChannels(user.id)); + if (error) { + onError(error); + } + } + + const title = ( + + ); + + const message = ( + + ); + + const createGroupMembershipsButton = ( + + ); + + const cancelGroupMembershipsButton = ( + + ); + + return ( + + ); +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/deactivate_member_modal.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/deactivate_member_modal.tsx new file mode 100644 index 0000000000..64d4059bc7 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/deactivate_member_modal.tsx @@ -0,0 +1,164 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; + +import type {ServerError} from '@mattermost/types/errors'; +import type {UserProfile} from '@mattermost/types/users'; + +import {updateUserActive} from 'mattermost-redux/actions/users'; +import {getConfig} from 'mattermost-redux/selectors/entities/admin'; +import {getExternalBotAccounts} from 'mattermost-redux/selectors/entities/bots'; + +import ConfirmModalRedux from 'components/confirm_modal_redux'; +import ExternalLink from 'components/external_link'; + +import Constants from 'utils/constants'; + +type Props = { + user: UserProfile; + onExited: () => void; + onSuccess: () => void; + onError: (error: ServerError) => void; +} + +export default function DeactivateMemberModal({user, onExited, onSuccess, onError}: Props) { + const dispatch = useDispatch(); + const config = useSelector(getConfig); + const bots = useSelector(getExternalBotAccounts); + const siteURL = config.ServiceSettings?.SiteURL; + + async function deactivateMember() { + const {error} = await dispatch(updateUserActive(user.id, false)); + if (error) { + onError(error); + } else { + onSuccess(); + } + } + + const title = ( + + ); + + const defaultMessage = ( + ); + + let warning; + if (user.auth_service !== '' && user.auth_service !== Constants.EMAIL_SERVICE) { + warning = ( + +
+
+ +
+ ); + } + + const confirmationMessage = ( + ); + let messageForUsersWithBotAccounts; + if (config.ServiceSettings?.DisableBotsWhenOwnerIsDeactivated) { + for (const bot of Object.values(bots)) { + if ((bot.owner_id === user.id) && (bot.delete_at === 0)) { + messageForUsersWithBotAccounts = ( + <> +
    +
  • + +
  • +
  • + +
  • +
  • + ( + + {msg} + + ), + linkDocumentation: (msg: React.ReactNode) => ( + + {msg} + + ), + }} + /> +
  • +
+

+

+ + ); + break; + } + } + } + const message = ( +

+ {messageForUsersWithBotAccounts || defaultMessage} + {confirmationMessage} + {warning} +
+ ); + + const confirmButtonClass = 'btn btn-danger'; + const deactivateMemberButton = ( + + ); + + return ( + + ); +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/demote_to_guest_modal.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/demote_to_guest_modal.tsx new file mode 100644 index 0000000000..d2a10a9f25 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/demote_to_guest_modal.tsx @@ -0,0 +1,71 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import {useDispatch} from 'react-redux'; + +import type {ServerError} from '@mattermost/types/errors'; +import type {UserProfile} from '@mattermost/types/users'; + +import {demoteUserToGuest} from 'mattermost-redux/actions/users'; + +import ConfirmModalRedux from 'components/confirm_modal_redux'; + +type Props = { + user: UserProfile; + onExited: () => void; + onSuccess: () => void; + onError: (error: ServerError) => void; +} + +export default function DemoteToGuestModal({user, onExited, onSuccess, onError}: Props) { + const dispatch = useDispatch(); + + async function confirm() { + const {error} = await dispatch(demoteUserToGuest(user.id)); + if (error) { + onError(error); + } else { + onSuccess(); + } + } + + const title = ( + + ); + + const message = ( + + ); + + const demoteGuestButton = ( + + ); + + return ( + + ); +} diff --git a/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/index.tsx b/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/index.tsx new file mode 100644 index 0000000000..84fecff353 --- /dev/null +++ b/webapp/channels/src/components/admin_console/system_users/system_users_list_actions/index.tsx @@ -0,0 +1,387 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import classNames from 'classnames'; +import React from 'react'; +import {FormattedMessage, useIntl} from 'react-intl'; +import {useDispatch, useSelector} from 'react-redux'; + +import type {ServerError} from '@mattermost/types/errors'; +import type {UserProfile} from '@mattermost/types/users'; + +import {updateUserActive} from 'mattermost-redux/actions/users'; +import {Permissions} from 'mattermost-redux/constants'; +import General from 'mattermost-redux/constants/general'; +import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general'; +import {isSystemAdmin, isGuest} from 'mattermost-redux/utils/user_utils'; + +import {adminResetMfa} from 'actions/admin_actions'; +import {openModal} from 'actions/views/modals'; + +import ManageRolesModal from 'components/admin_console/manage_roles_modal'; +import ManageTeamsModal from 'components/admin_console/manage_teams_modal'; +import ManageTokensModal from 'components/admin_console/manage_tokens_modal'; +import ResetEmailModal from 'components/admin_console/reset_email_modal'; +import ResetPasswordModal from 'components/admin_console/reset_password_modal'; +import * as Menu from 'components/menu'; +import SystemPermissionGate from 'components/permissions_gates/system_permission_gate'; + +import Constants, {ModalIdentifiers} from 'utils/constants'; + +import CreateGroupSyncablesMembershipsModal from './create_group_syncables_membership_modal'; +import DeactivateMemberModal from './deactivate_member_modal'; +import DemoteToGuestModal from './demote_to_guest_modal'; +import PromoteToMemberModal from './promote_to_member_modal'; +import RevokeSessionsModal from './revoke_sessions_modal'; + +interface Props { + user: UserProfile; + currentUser: UserProfile; + tableId?: string; + rowIndex: number; + onError: (error: ServerError) => void; + updateUser: (user: Partial) => void; +} + +export function SystemUsersListAction({user, currentUser, tableId, rowIndex, onError, updateUser}: Props) { + const {formatMessage} = useIntl(); + const dispatch = useDispatch(); + const config = useSelector(getConfig); + const isLicensed = useSelector(getLicense)?.IsLicensed === 'true'; + + function getTranslatedUserRole(userRoles: UserProfile['roles']) { + if (user.delete_at > 0) { + return ( + + ); + } else if (user.roles.length > 0 && isSystemAdmin(userRoles)) { + return ( + + ); + } else if (isGuest(userRoles)) { + return ( + + ); + } + + return ( + + ); + } + + const menuButtonId = `actionMenuButton-${tableId}-${rowIndex}`; + const menuId = `actionMenu-${tableId}-${rowIndex}`; + const menuItemIdPrefix = `actionMenuItem-${tableId}-${rowIndex}`; + + // Disable if SystemAdmin being edited by non SystemAdmin eg. userManager with EditOtherUsers permissions + const isDisabled = !isSystemAdmin(currentUser.roles) && isSystemAdmin(user.roles); + + const onDeactivateMember = () => updateUser({delete_at: new Date().getMilliseconds()}); + const onUpdateRoles = (roles: string) => updateUser({roles}); + const onSwitchToEmailPassword = () => updateUser({auth_service: undefined}); + const onUpdateEmail = (email: string) => updateUser({email}); + const onPromoteToMember = () => updateUser({roles: user.roles.replace(General.SYSTEM_GUEST_ROLE, '')}); + const onDemoteToGuest = () => updateUser({roles: `${user.roles} ${General.SYSTEM_GUEST_ROLE}`}); + + return ( + + {getTranslatedUserRole(user.roles)} + {!isDisabled && ( +