[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 <build@mattermost.com>

* [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 <build@mattermost.com>

* 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 <build@mattermost.com>

* [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 <build@mattermost.com>

* [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 <build@mattermost.com>

* [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 <build@mattermost.com>

* 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 <build@mattermost.com>

* 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 <m-zubairahmed@protonmail.com>
Co-authored-by: Mattermost Build <build@mattermost.com>
Co-authored-by: yasserfaraazkhan <attitude3cena.yf@gmail.com>
Этот коммит содержится в:
Devin Binnie
2024-02-07 08:56:59 -05:00
коммит произвёл GitHub
родитель 4f3eba381b
Коммит 03f71c9c84
108 изменённых файлов: 4701 добавлений и 6095 удалений

Просмотреть файл

@@ -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');
});
});
});

Просмотреть файл

@@ -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

Просмотреть файл

@@ -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(() => {

Просмотреть файл

@@ -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(() => {

Просмотреть файл

@@ -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();
});
});

Просмотреть файл

@@ -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);

Просмотреть файл

@@ -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');

Просмотреть файл

@@ -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
}

Просмотреть файл

@@ -145,6 +145,7 @@ func (u *UserReportOptions) IsValid() *AppError {
}
func (u *UserReportQuery) ToReport() *UserReport {
u.ClearNonProfileFields()
return &UserReport{
User: u.User,
UserPostStats: u.UserPostStats,

Просмотреть файл

@@ -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<AdminConsoleUserManagementTableProperties>) {
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<UserReport[]> {
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<number> {
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};
};
}

Просмотреть файл

@@ -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,

Просмотреть файл

@@ -71,6 +71,9 @@ class AdminConsole extends React.PureComponent<Props, State> {
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) => {

Просмотреть файл

@@ -47,9 +47,7 @@ exports[`components/admin_console/admin_user_card/admin_user_card should match d
<div
class="AdminUserCard__user-id"
>
User ID:
1234
User ID: 1234
</div>
</div>
<div
@@ -104,9 +102,7 @@ exports[`components/admin_console/admin_user_card/admin_user_card should match s
<div
class="AdminUserCard__user-id"
>
User ID:
1234
User ID: 1234
</div>
</div>
<div
@@ -159,9 +155,7 @@ exports[`components/admin_console/admin_user_card/admin_user_card should match s
<div
class="AdminUserCard__user-id"
>
User ID:
1234
User ID: 1234
</div>
</div>
<div
@@ -216,9 +210,7 @@ exports[`components/admin_console/admin_user_card/admin_user_card should match s
<div
class="AdminUserCard__user-id"
>
User ID:
1234
User ID: 1234
</div>
</div>
<div

Просмотреть файл

@@ -3,6 +3,13 @@
margin: 2em 0 1em;
border-radius: 2px;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.5);
.noUserBody {
display: flex;
min-height: 20vh;
align-items: center;
justify-content: center;
}
}
.AdminUserCard__header {

Просмотреть файл

@@ -2,14 +2,14 @@
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {UserProfile} from '@mattermost/types/users';
import {Client4} from 'mattermost-redux/client';
import ProfilePicture from 'components/profile_picture';
import * as Utils from 'utils/utils';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import './admin_user_card.scss';
@@ -18,19 +18,37 @@ type BulletProps = {
}
export type Props = {
user: UserProfile;
user?: UserProfile;
isLoading?: boolean;
body?: React.ReactNode;
footer?: React.ReactNode;
}
const Bullet: React.FC<BulletProps> = (props: BulletProps) => {
if ((props.user.first_name || props.user.last_name) && props.user.nickname) {
return (<span>{' • '}</span>);
}
return null;
};
const AdminUserCard = ({isLoading = false, ...props}: Props) => {
if (!props.user || isLoading) {
return (
<div className='AdminUserCard'>
<div className='AdminUserCard__header'>
<ProfilePicture
src=''
size='xxl'
wrapperClass='admin-user-card'
/>
</div>
<div className='noUserBody'>
{isLoading && <LoadingSpinner/>}
{!isLoading &&
<FormattedMessage
id='admin.userManagement.userDetail.notFound'
defaultMessage='User not found'
/>
}
</div>
</div>
);
}
const AdminUserCard: React.FC<Props> = (props: Props) => {
return (
<div className='AdminUserCard'>
<div className='AdminUserCard__header'>
@@ -46,7 +64,13 @@ const AdminUserCard: React.FC<Props> = (props: Props) => {
<span className='AdminUserCard__user-nickname'>{props.user.nickname}</span>
</div>
<div className='AdminUserCard__user-id'>
{Utils.localizeMessage('admin.userManagement.userDetail.userId', 'User ID:')} {props.user.id}
<FormattedMessage
id='admin.userManagement.userDetail.userId'
defaultMessage='User ID: {userId}'
values={{
userId: props.user.id,
}}
/>
</div>
</div>
<div className='AdminUserCard__body'>
@@ -58,4 +82,11 @@ const AdminUserCard: React.FC<Props> = (props: Props) => {
</div>);
};
const Bullet = (props: BulletProps) => {
if ((props.user.first_name || props.user.last_name) && props.user.nickname) {
return (<span>{' • '}</span>);
}
return null;
};
export default AdminUserCard;

Просмотреть файл

@@ -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),
};
}

Просмотреть файл

@@ -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';

Просмотреть файл

@@ -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 {

Просмотреть файл

@@ -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<TableType extends TableMandatoryTypes>(
}
}
const colCount = props.table.getAllColumns().length;
const rowCount = props.table.getRowModel().rows.length;
return (
<>
<div className='adminConsoleListTableContainer'>
<div className='adminConsoleListTabletOptionalHead'>
{tableMeta.totalRowInfo}
{tableMeta.hasAdditionalPaginationAtTop && (
<Pagination
disablePrevPage={tableMeta.disablePrevPage}
disableNextPage={tableMeta.disableNextPage}
isLoading={tableMeta.isLoading}
onPreviousPageClick={tableMeta.onPreviousPageClick}
onNextPageClick={tableMeta.onNextPageClick}
paginationInfo={tableMeta.paginationInfo}
/>
{tableMeta.hasDualSidedPagination && (
<>
{tableMeta.paginationInfo}
<Pagination
disablePrevPage={tableMeta.disablePrevPage}
disableNextPage={tableMeta.disableNextPage}
isLoading={tableMeta.loadingState === LoadingStates.Loading}
onPreviousPageClick={tableMeta.onPreviousPageClick}
onNextPageClick={tableMeta.onNextPageClick}
/>
</>
)}
</div>
<table
id={tableMeta.tableId}
aria-colcount={props.table.getAllColumns().length}
aria-colcount={colCount}
aria-describedby={`${tableMeta.tableId}-headerId`} // Set this id to the table header so that the title describes the table
className={classNames(
'adminConsoleListTable',
@@ -145,12 +152,12 @@ export function ListTable<TableType extends TableMandatoryTypes>(
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<TableType extends TableMandatoryTypes>(
className='icon icon-arrow-up hoverSortingIcon'
/>
)}
{/* Pinned Icon */}
{header.column.getCanPin() && (
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}
placement={
header.index === 0 ? 'bottom' : 'left'
}
overlay={
<Tooltip id='system-users-column-pinned-tooltip'>
<FormattedMessage
id='adminConsole.list.table.pinnedColumn'
defaultMessage='This column is pinned'
/>
</Tooltip>
}
>
<span
aria-hidden='true'
className='icon icon-pin-outline'
/>
</OverlayTrigger>
)}
</th>
))}
</tr>
@@ -209,14 +193,12 @@ export function ListTable<TableType extends TableMandatoryTypes>(
<tr
id={`${rowIdPrefix}${row.original.id}`}
key={row.id}
role='row'
onClick={handleRowClick}
>
{row.getVisibleCells().map((cell) => (
<td
key={cell.id}
id={`${cellIdPrefix}${cell.id}`}
role='cell'
headers={`${headerIdPrefix}${cell.column.id}`}
className={classNames(`${cell.column.id}`, {
[PINNED_CLASS]: cell.column.getCanPin(),
@@ -227,6 +209,47 @@ export function ListTable<TableType extends TableMandatoryTypes>(
))}
</tr>
))}
{/* State where it is initially loading the data */}
{(tableMeta.loadingState === LoadingStates.Loading && rowCount === 0) && (
<tr>
<td
colSpan={colCount}
className='noRows'
disabled={true}
>
<LoadingSpinner
text={formatMessage({id: 'adminConsole.list.table.genericLoading', defaultMessage: 'Loading'})}
/>
</td>
</tr>
)}
{/* State where there is no data */}
{(tableMeta.loadingState === LoadingStates.Loaded && rowCount === 0) && (
<tr>
<td
colSpan={colCount}
className='noRows'
disabled={true}
>
{tableMeta.emptyDataMessage || formatMessage({id: 'adminConsole.list.table.genericNoData', defaultMessage: 'No data'})}
</td>
</tr>
)}
{/* State where there is an error loading the data */}
{tableMeta.loadingState === LoadingStates.Failed && (
<tr>
<td
colSpan={colCount}
className='noRows'
disabled={true}
>
{formatMessage({id: 'adminConsole.list.table.genericError', defaultMessage: 'There was an error loading the data, please try again'})}
</td>
</tr>
)}
</tbody>
<tfoot>
{props.table.getFooterGroups().map((footerGroup) => (
@@ -247,16 +270,11 @@ export function ListTable<TableType extends TableMandatoryTypes>(
</tfoot>
</table>
<div className='adminConsoleListTabletOptionalFoot'>
{tableMeta.paginationInfo}
{handlePageSizeChange && (
<div
className='adminConsoleListTablePageSize'
aria-label={formatMessage(
{
id: 'adminConsole.list.table.rowCount.label',
defaultMessage: 'Show {count} rows per page',
},
{count: selectedPageSize.label},
)}
aria-label={formatMessage({id: 'adminConsole.list.table.rowCount.label', defaultMessage: 'Show {count} rows per page'}, {count: selectedPageSize.label})}
>
<FormattedMessage
id='adminConsole.list.table.rowsCount.(show)rowsPerPage'
@@ -273,7 +291,7 @@ export function ListTable<TableType extends TableMandatoryTypes>(
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<TableType extends TableMandatoryTypes>(
<Pagination
disablePrevPage={tableMeta.disablePrevPage}
disableNextPage={tableMeta.disableNextPage}
isLoading={tableMeta.isLoading}
isLoading={tableMeta.loadingState === LoadingStates.Loading}
onPreviousPageClick={tableMeta.onPreviousPageClick}
onNextPageClick={tableMeta.onNextPageClick}
paginationInfo={tableMeta.paginationInfo}
/>
</div>
</>
</div>
);
}
@@ -311,7 +328,7 @@ function getAriaSortForTableHeader(
sortDirection: boolean | SortDirection,
): AriaAttributes['aria-sort'] {
if (!canSort) {
return 'none';
return undefined;
}
if (sortDirection === 'asc') {

Просмотреть файл

@@ -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<TableMeta,
| 'onPreviousPageClick'
| 'onNextPageClick'
| 'disablePrevPage'
| 'disableNextPage'
> {
isLoading?: boolean;
}
export function Pagination(props: Props) {
const {formatMessage} = useIntl();
return (
<div className='paginationButtons'>
{props.paginationInfo}
{props.onPreviousPageClick && (
<button
className='btn btn-icon btn-sm'

Просмотреть файл

@@ -23,17 +23,20 @@ import {DeveloperLinks} from 'utils/constants';
import {isSuccess} from 'types/actions';
export type Props = {
show: boolean;
user?: UserProfile;
userAccessTokensEnabled: boolean;
// defining custom function type instead of using React.MouseEventHandler
// to make the event optional
onModalDismissed: (e?: React.MouseEvent<HTMLButtonElement>) => void;
actions: { updateUserRoles: (userId: string, roles: string) => Promise<ActionResult>};
onSuccess: (roles: string) => void;
onExited: () => void;
actions: {
updateUserRoles: (userId: string, roles: string) => Promise<ActionResult>;
};
}
type State = {
show: boolean;
user?: UserProfile;
error: any | null;
hasPostAllRole: boolean;
@@ -46,6 +49,7 @@ function getStateFromProps(props: Props): State {
const roles = props.user && props.user.roles ? props.user.roles : '';
return {
show: true,
user: props.user,
error: null,
hasPostAllRole: UserUtils.hasPostAllRole(roles),
@@ -120,6 +124,10 @@ export default class ManageRolesModal extends React.PureComponent<Props, State>
}
};
onHide = () => {
this.setState({show: false});
};
handleSave = async () => {
this.setState({error: null});
@@ -140,7 +148,8 @@ export default class ManageRolesModal extends React.PureComponent<Props, State>
this.trackRoleChanges(roles, this.props.user!.roles);
if (isSuccess(result)) {
this.props.onModalDismissed();
this.props.onSuccess(roles);
this.onHide();
} else {
this.handleError(
<FormattedMessage
@@ -343,8 +352,9 @@ export default class ManageRolesModal extends React.PureComponent<Props, State>
render() {
return (
<Modal
show={this.props.show}
onHide={this.props.onModalDismissed}
show={this.state.show}
onHide={this.onHide}
onExited={this.props.onExited}
dialogClassName='a11y__modal manage-teams'
role='dialog'
aria-labelledby='manageRolesModalLabel'
@@ -368,7 +378,7 @@ export default class ManageRolesModal extends React.PureComponent<Props, State>
<button
type='button'
className='btn btn-tertiary'
onClick={this.props.onModalDismissed}
onClick={this.onHide}
>
<FormattedMessage
id='admin.manage_roles.cancel'

Просмотреть файл

@@ -23,7 +23,8 @@ exports[`ManageTeamsModal should match snapshot init 1`] = `
"remove": [Function],
}
}
onHide={[MockFunction]}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="dialog"

Просмотреть файл

@@ -18,8 +18,8 @@ import ManageTeamsDropdown from './manage_teams_dropdown';
describe('ManageTeamsModal', () => {
const baseProps = {
locale: General.DEFAULT_LOCALE,
onModalDismissed: jest.fn(),
show: true,
onHide: jest.fn(),
onExited: jest.fn(),
user: TestHelper.getUserMock({
id: 'currentUserId',
last_picture_update: 1234,

Просмотреть файл

@@ -23,9 +23,8 @@ import RemoveFromTeamButton from './remove_from_team_button';
export type Props = {
locale: string;
onModalDismissed: () => void;
show: boolean;
user?: UserProfile;
onExited: () => void;
actions: {
getTeamMembersForUser: (userId: string) => Promise<ActionResult>;
getTeamsForUser: (userId: string) => Promise<ActionResult>;
@@ -34,10 +33,11 @@ export type Props = {
};
}
const ManageTeamsModal = ({locale, onModalDismissed, show, user, actions}: Props) => {
const ManageTeamsModal = ({locale, onExited, user, actions}: Props) => {
const [error, setError] = React.useState<JSX.Element | null>(null);
const [teams, setTeams] = React.useState<Team[] | null>(null);
const [teamMembers, setTeamMembers] = React.useState<TeamMembership[] | null>(null);
const [show, setShow] = React.useState(true);
useEffect(() => {
if (user) {
@@ -52,6 +52,10 @@ const ManageTeamsModal = ({locale, onModalDismissed, show, user, actions}: Props
}
}, [user?.id]);
const onModalDismissed = () => {
setShow(false);
};
const loadTeamsAndTeamMembers = async (user: UserProfile) => {
await getTeamMembers(user.id);
const {data} = await actions.getTeamsForUser(user.id);
@@ -185,6 +189,7 @@ const ManageTeamsModal = ({locale, onModalDismissed, show, user, actions}: Props
<Modal
show={show}
onHide={onModalDismissed}
onExited={onExited}
dialogClassName='a11y__modal manage-teams modal--overflow-visible'
role='dialog'
aria-labelledby='manageTeamsModalLabel'

Просмотреть файл

@@ -23,11 +23,12 @@ exports[`components/admin_console/manage_tokens_modal/manage_tokens_modal.tsx sh
"remove": [Function],
}
}
onHide={[MockFunction]}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
role="dialog"
show={false}
show={true}
>
<ModalHeader
bsClass="modal-header"

Просмотреть файл

@@ -16,8 +16,8 @@ describe('components/admin_console/manage_tokens_modal/manage_tokens_modal.tsx',
user: TestHelper.getUserMock({
id: 'defaultuser',
}),
show: false,
onModalDismissed: jest.fn(),
onHide: jest.fn(),
onExited: jest.fn(),
};
test('should match snapshot', () => {

Просмотреть файл

@@ -19,11 +19,6 @@ import {DeveloperLinks} from 'utils/constants';
export type Props = {
/**
* Set to render the modal
*/
show: boolean;
/**
* The user the roles are being managed for
*/
@@ -34,10 +29,7 @@ export type Props = {
*/
userAccessTokens?: Record<string, UserAccessToken>;
/**
* Function called when modal is dismissed
*/
onModalDismissed: (e?: React.MouseEvent<HTMLButtonElement>) => void;
onExited: () => void;
actions: {
/**
@@ -48,6 +40,7 @@ export type Props = {
};
type State = {
show: boolean;
error: string | null;
}
@@ -55,6 +48,7 @@ export default class ManageTokensModal extends React.PureComponent<Props, State>
public constructor(props: Props) {
super(props);
this.state = {
show: true,
error: null,
};
}
@@ -73,6 +67,10 @@ export default class ManageTokensModal extends React.PureComponent<Props, State>
});
};
private onModalDismissed = () => {
this.setState({show: false});
};
private renderContents = (): JSX.Element => {
const {user, userAccessTokens} = this.props;
@@ -188,8 +186,9 @@ export default class ManageTokensModal extends React.PureComponent<Props, State>
public render = (): JSX.Element => {
return (
<Modal
show={this.props.show}
onHide={this.props.onModalDismissed}
show={this.state.show}
onHide={this.onModalDismissed}
onExited={this.props.onExited}
dialogClassName='a11y__modal manage-teams'
role='dialog'
aria-labelledby='manageTokensModalLabel'

Просмотреть файл

@@ -24,6 +24,7 @@ exports[`components/admin_console/reset_email_modal/reset_email_modal.tsx should
"remove": [Function],
}
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}
@@ -138,6 +139,7 @@ exports[`components/admin_console/reset_email_modal/reset_email_modal.tsx should
"remove": [Function],
}
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}

Просмотреть файл

@@ -13,8 +13,6 @@ import {TestHelper} from 'utils/test_helper';
import ResetEmailModal from './reset_email_modal';
describe('components/admin_console/reset_email_modal/reset_email_modal.tsx', () => {
const emptyFunction = jest.fn();
const user: UserProfile = TestHelper.getUserMock({
email: 'arvin.darmawan@gmail.com',
});
@@ -23,9 +21,9 @@ describe('components/admin_console/reset_email_modal/reset_email_modal.tsx', ()
actions: {patchUser: jest.fn(() => Promise.resolve({}))},
user,
currentUserId: 'random_user_id',
show: true,
onModalSubmit: emptyFunction,
onModalDismissed: emptyFunction,
onHide: jest.fn(),
onSuccess: jest.fn(),
onExited: jest.fn(),
};
test('should match snapshot when not the current user', () => {

Просмотреть файл

@@ -11,6 +11,7 @@ import type {ActionResult} from 'mattermost-redux/types/actions';
import {isEmail} from 'mattermost-redux/utils/helpers';
type State = {
show: boolean;
error: JSX.Element|string|null;
isEmailError: boolean;
isCurrentPasswordError: boolean;
@@ -19,9 +20,8 @@ type State = {
type Props = {
user?: UserProfile;
currentUserId: string;
show: boolean;
onModalSubmit: (user?: UserProfile) => void;
onModalDismissed: () => void;
onSuccess: (email: string) => void;
onExited: () => void;
actions: {
patchUser: (user: UserProfile) => Promise<ActionResult>;
};
@@ -30,14 +30,12 @@ type Props = {
export default class ResetEmailModal extends React.PureComponent<Props, State> {
private emailRef: React.RefObject<HTMLInputElement>;
private currentPasswordRef: React.RefObject<HTMLInputElement>;
public static defaultProps: Partial<Props> = {
show: false,
};
public constructor(props: Props) {
super(props);
this.state = {
show: true,
error: null,
isEmailError: false,
isCurrentPasswordError: false,
@@ -47,20 +45,6 @@ export default class ResetEmailModal extends React.PureComponent<Props, State> {
this.currentPasswordRef = React.createRef();
}
public componentDidUpdate(prevProps: Props): void {
if (!prevProps.show && this.props.show) {
this.resetState();
}
}
private resetState = (): void => {
this.setState({
error: null,
isEmailError: false,
isCurrentPasswordError: false,
});
};
private isEmailValid = (): boolean => {
if (!this.emailRef.current || !this.emailRef.current.value || !isEmail(this.emailRef.current.value)) {
const errMsg = (
@@ -125,14 +109,15 @@ export default class ResetEmailModal extends React.PureComponent<Props, State> {
return;
}
this.props.onModalSubmit(this.props.user);
this.props.onSuccess(user.email);
this.setState({show: false});
};
private doCancel = (): void => {
this.setState({
show: false,
error: null,
});
this.props.onModalDismissed();
};
public render(): JSX.Element {
@@ -153,8 +138,9 @@ export default class ResetEmailModal extends React.PureComponent<Props, State> {
return (
<Modal
dialogClassName='a11y__modal'
show={this.props.show}
show={this.state.show}
onHide={this.doCancel}
onExited={this.props.onExited}
role='dialog'
aria-labelledby='resetEmailModalLabel'
data-testid='resetEmailModal'

Просмотреть файл

@@ -23,6 +23,7 @@ exports[`components/admin_console/reset_password_modal/reset_password_modal.tsx
"remove": [Function],
}
}
onExited={[MockFunction]}
onHide={[Function]}
renderBackdrop={[Function]}
restoreFocus={true}

Просмотреть файл

@@ -13,7 +13,6 @@ import {TestHelper} from 'utils/test_helper';
import ResetPasswordModal from './reset_password_modal';
describe('components/admin_console/reset_password_modal/reset_password_modal.tsx', () => {
const emptyFunction = jest.fn();
const notifyProps: UserNotifyProps = {
channel: 'true',
comments: 'never',
@@ -37,9 +36,8 @@ describe('components/admin_console/reset_password_modal/reset_password_modal.tsx
actions: {updateUserPassword: jest.fn(() => Promise.resolve({data: ''}))},
currentUserId: user.id,
user,
show: true,
onModalSubmit: emptyFunction,
onModalDismissed: emptyFunction,
onHide: jest.fn(),
onExited: jest.fn(),
passwordConfig: {
minimumLength: 10,
requireLowercase: true,

Просмотреть файл

@@ -20,6 +20,7 @@ interface PasswordConfig {
}
type State = {
show: boolean;
serverErrorNewPass: React.ReactNode;
serverErrorCurrentPass: React.ReactNode;
}
@@ -27,9 +28,8 @@ type State = {
export type Props = {
user?: UserProfile;
currentUserId: string;
show: boolean;
onModalSubmit: (user?: UserProfile) => void;
onModalDismissed: () => void;
onSuccess?: () => void;
onExited: () => void;
passwordConfig: PasswordConfig;
actions: {
updateUserPassword: (userId: string, currentPassword: string, password: string) => Promise<ActionResult>;
@@ -39,14 +39,12 @@ export type Props = {
export default class ResetPasswordModal extends React.PureComponent<Props, State> {
private currentPasswordRef: React.RefObject<HTMLInputElement>;
private passwordRef: React.RefObject<HTMLInputElement>;
public static defaultProps: Partial<Props> = {
show: false,
};
public constructor(props: Props) {
super(props);
this.state = {
show: true,
serverErrorNewPass: null,
serverErrorCurrentPass: null,
};
@@ -98,15 +96,16 @@ export default class ResetPasswordModal extends React.PureComponent<Props, State
this.setState({serverErrorCurrentPass: result.error.message});
return;
}
this.props.onModalSubmit(this.props.user);
this.props.onSuccess?.();
this.setState({show: false});
};
private doCancel = (): void => {
this.setState({
show: false,
serverErrorNewPass: null,
serverErrorCurrentPass: null,
});
this.props.onModalDismissed();
};
public render(): JSX.Element {
@@ -177,8 +176,9 @@ export default class ResetPasswordModal extends React.PureComponent<Props, State
return (
<Modal
dialogClassName='a11y__modal'
show={this.props.show}
show={this.state.show}
onHide={this.doCancel}
onExited={this.props.onExited}
role='dialog'
aria-labelledby='resetPasswordModalLabel'
>

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@@ -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<typeof connector>;
export default connector(SystemUserDetail);

Просмотреть файл

@@ -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;
}
}

Просмотреть файл

@@ -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<Params>),
};
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(<SystemUserDetail {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot if user is inactive', () => {
const props = {
...defaultProps,
user: {
...defaultProps.user,
delete_at: 1561683854166,
},
};
const wrapper = shallowWithIntl(<SystemUserDetail {...props}/>);
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(<SystemUserDetail {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot if no nickname is defined', () => {
const props = {
...defaultProps,
user: {
...defaultProps.user,
nickname: null,
},
};
const wrapper = shallowWithIntl(<SystemUserDetail {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot if LDAP Authentication', () => {
const props = {
...defaultProps,
user: {
...defaultProps.user,
auth_service: Constants.LDAP_SERVICE,
},
};
const wrapper = shallowWithIntl(<SystemUserDetail {...props}/>);
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(<SystemUserDetail {...props}/>);
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');
});
});

Просмотреть файл

@@ -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<ActionResult>;
setNavigationBlocked: (blocked: boolean) => void;
addUserToTeam: (teamId: string, userId: string) => Promise<unknown>;
};
} & WrappedComponentProps;
export type Params = {
user_id?: UserProfile['id'];
};
export type Props = PropsFromRedux & RouteComponentProps<Params> & WrappedComponentProps;
export type State = {
user?: UserProfile;
emailField: string;
isLoading: boolean;
error?: string | null;
isSaveNeeded: boolean;
isSaving: boolean;
teams: TeamMembership[];
teamIds: Array<Team['id']>;
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<Props & RouteComponentProps, State> {
errorMessageRef: React.RefObject<HTMLDivElement>;
errorMessageRefCurrent: React.ReactInstance | undefined;
public static defaultProps = {
user: {
email: '',
} as UserProfile,
mfaEnabled: false,
};
constructor(props: Props & RouteComponentProps) {
export class SystemUserDetail extends PureComponent<Props, State> {
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<UserProfile, ServerError>;
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<boolean, ServerError>;
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<boolean, ServerError>;
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<boolean, ServerError>;
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<HTMLInputElement>) => {
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<HTMLButtonElement>) => {
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<UserProfile, ServerError>;
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<HTMLButtonElement, MouseEvent>): void => {
e.preventDefault();
this.props.actions.updateUserActive(this.props.user.id, true).
then((data) => this.onUpdateActiveResult(data.error));
};
handleShowDeactivateMemberModal = (e: React.MouseEvent<HTMLButtonElement, 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<HTMLButtonElement, MouseEvent>): void => {
e.preventDefault();
adminResetMfa(this.props.user.id, null, null);
};
handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>): 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<HTMLButtonElement, 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 = (
<FormattedMessage
id='deactivate_member_modal.title'
defaultMessage='Deactivate {username}'
values={{
username: user.username,
}}
/>
);
let warning;
if (user.auth_service !== '' && user.auth_service !== Constants.EMAIL_SERVICE) {
warning = (
<strong>
<br/>
<br/>
<FormattedMessage
id='deactivate_member_modal.sso_warning'
defaultMessage='You must also deactivate this user in the SSO provider or they will be reactivated on next login or sync.'
/>
</strong>
);
}
const message = (
<div>
<FormattedMessage
id='deactivate_member_modal.desc'
defaultMessage='This action deactivates {username}. They will be logged out and not have access to any teams or channels on this system. Are you sure you want to deactivate {username}?'
values={{
username: user.username,
}}
/>
{warning}
</div>
);
const confirmButtonClass = 'btn btn-danger';
const deactivateMemberButton = (
<FormattedMessage
id='deactivate_member_modal.deactivate'
defaultMessage='Deactivate'
/>
);
return (
<ConfirmModal
show={this.state.showDeactivateMemberModal}
title={title}
message={message}
confirmButtonClass={confirmButtonClass}
confirmButtonText={deactivateMemberButton}
onConfirm={this.handleDeactivateMember}
onCancel={this.handleDeactivateCancel}
/>
);
toggleOpenTeamSelectorModal = () => {
this.setState({showTeamSelectorModal: true});
};
renderActivateDeactivate = (): React.ReactNode => {
if (this.props.user.delete_at > 0) {
return (
<AdminButtonOutline
onClick={this.handleMakeActive}
className='admin-btn-default'
disabled={this.props.isDisabled}
>
{this.props.intl.formatMessage({id: 'admin.user_item.makeActive', defaultMessage: 'Activate'})}
</AdminButtonOutline>
);
}
return (
<AdminButtonOutline
onClick={this.handleShowDeactivateMemberModal}
className='admin-btn-default'
disabled={this.props.isDisabled}
>
{this.props.intl.formatMessage({id: 'admin.user_item.makeInactive', defaultMessage: 'Deactivate'})}
</AdminButtonOutline>
);
toggleCloseTeamSelectorModal = () => {
this.setState({showTeamSelectorModal: false});
};
renderRemoveMFA = (): React.ReactNode => {
if (this.props.user.mfa_active) {
return (
<AdminButtonOutline
onClick={this.handleResetMfa}
className='admin-btn-default'
disabled={this.props.isDisabled}
>
{this.props.intl.formatMessage({id: 'admin.user_item.resetMfa', defaultMessage: 'Remove MFA'})}
</AdminButtonOutline>
);
}
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 (
<Redirect to={{pathname: '/admin_console/user_management/users'}}/>
);
}
if (user.id) {
deactivateMemberModal = this.renderDeactivateMemberModal(user);
}
render() {
return (
<div className='SystemUserDetail wrapper--fixed'>
<AdminHeader withBackButton={true}>
@@ -379,58 +301,105 @@ class SystemUserDetail extends React.PureComponent<Props & RouteComponentProps,
</AdminHeader>
<div className='admin-console__wrapper'>
<div className='admin-console__content'>
{/* User details */}
<AdminUserCard
user={user}
user={this.state.user}
isLoading={this.state.isLoading}
body={
<React.Fragment>
<span className='SystemUserDetail__position'>{user.position}</span>
<span className='SystemUserDetail__field-label'>{this.props.intl.formatMessage({id: 'admin.userManagement.userDetail.email', defaultMessage: 'Email'})}</span>
<div>
<EmailIcon className='SystemUserDetail__field-icon'/>
<input
className='SystemUserDetail__input form-control'
type='text'
value={this.state.user.email}
onChange={this.handleEmailChange}
disabled={this.props.isDisabled}
<>
<span>{this.state?.user?.position ?? ''}</span>
<label>
<FormattedMessage
id='admin.userManagement.userDetail.email'
defaultMessage='Email'
/>
</div>
<span className='SystemUserDetail__field-label'>{this.props.intl.formatMessage({id: 'admin.userManagement.userDetail.username', defaultMessage: 'Username'})}</span>
<div>
<AtIcon className='SystemUserDetail__field-icon'/>
<span className='SystemUserDetail__field-text'>{user.username}</span>
</div>
<span className='SystemUserDetail__field-label'>{this.props.intl.formatMessage({id: 'admin.userManagement.userDetail.authenticationMethod', defaultMessage: 'Authentication Method'})}</span>
<div className='SystemUserDetail__field-text'>
<SheidOutlineIcon className='SystemUserDetail__field-icon'/>
<span className='SystemUserDetail__field-text'>{this.getAuthenticationText()}</span>
</div>
</React.Fragment>
<EmailIcon/>
<input
className='form-control'
type='text'
value={this.state.emailField}
onChange={this.handleEmailChange}
disabled={this.state.error !== null || this.state.isSaving}
/>
</label>
<label>
<FormattedMessage
id='admin.userManagement.userDetail.username'
defaultMessage='Username'
/>
<AtIcon/>
<span>{this.state?.user?.username}</span>
</label>
<label>
<FormattedMessage
id='admin.userManagement.userDetail.authenticationMethod'
defaultMessage='Authentication Method'
/>
<SheidOutlineIcon/>
<span>{getUserAuthenticationTextField(this.props.intl, this.props.mfaEnabled, this.state.user)}</span>
</label>
</>
}
footer={
<React.Fragment>
<AdminButtonOutline
onClick={this.doPasswordReset}
className='admin-btn-default'
disabled={this.props.isDisabled}
<>
<button
className='btn btn-secondary'
onClick={this.toggleOpenModalResetPassword}
>
{this.props.intl.formatMessage({id: 'admin.user_item.resetPwd', defaultMessage: 'Reset Password'})}
</AdminButtonOutline>
{this.renderActivateDeactivate()}
{this.renderRemoveMFA()}
</React.Fragment>
<FormattedMessage
id='admin.user_item.resetPwd'
defaultMessage='Reset Password'
/>
</button>
{this.state.user?.mfa_active && (
<button
className='btn btn-secondary'
onClick={this.handleRemoveMFA}
>
<FormattedMessage
id='admin.user_item.resetMfa'
defaultMessage='Remove MFA'
/>
</button>
)}
{this.state.user?.delete_at !== 0 && (
<button
className='btn btn-secondary'
onClick={this.handleActivateUser}
>
<FormattedMessage
id='admin.user_item.makeActive'
defaultMessage='Activate'
/>
</button>
)}
{this.state.user?.delete_at === 0 && (
<button
className='btn btn-secondary btn-danger'
onClick={this.toggleOpenModalDeactivateMember}
>
<FormattedMessage
id='admin.user_item.deactivate'
defaultMessage='Deactivate'
/>
</button>
)}
</>
}
/>
{/* User's team details */}
<AdminPanel
subtitle={defineMessage({id: 'admin.userManagement.userDetail.teamsSubtitle', defaultMessage: 'Teams to which this user belongs'})}
title={defineMessage({id: 'admin.userManagement.userDetail.teamsTitle', defaultMessage: 'Team Membership'})}
button={(
subtitle={defineMessage({id: 'admin.userManagement.userDetail.teamsSubtitle', defaultMessage: 'Teams to which this user belongs'})}
button={
<div className='add-team-button'>
<button
type='button'
className='btn btn-primary'
onClick={this.openAddTeam}
disabled={this.props.isDisabled}
onClick={this.toggleOpenTeamSelectorModal}
disabled={this.state.isLoading || this.state.error !== null}
>
<FormattedMessage
id='admin.userManagement.userDetail.addTeam'
@@ -438,58 +407,121 @@ class SystemUserDetail extends React.PureComponent<Props & RouteComponentProps,
/>
</button>
</div>
)}
}
>
<TeamList
userId={this.props.user.id}
userDetailCallback={this.setTeamsData}
refreshTeams={this.state.refreshTeams}
readOnly={this.props.isDisabled}
/>
{this.state.isLoading && (
<div className='teamlistLoading'>
<LoadingSpinner/>
</div>
)}
{!this.state.isLoading && this.state.user?.id && (
<TeamList
userId={this.state.user.id}
userDetailCallback={this.handleTeamsLoaded}
refreshTeams={this.state.refreshTeams}
/>
)}
</AdminPanel>
</div>
</div>
{/* Footer */}
<div className='admin-console-save'>
<SaveButton
saving={this.state.saving}
disabled={!this.state.saveNeeded}
saving={this.state.isSaving}
disabled={!this.state.isSaveNeeded || this.state.isLoading || this.state.error !== null || this.state.isSaving}
onClick={this.handleSubmit}
savingMessage={this.props.intl.formatMessage({id: 'admin.saving', defaultMessage: 'Saving Config...'})}
/>
<div
className='error-message'
ref={this.errorMessageRef}
>
<FormError error={this.state.serverError}/>
<div className='error-message'>
<FormError error={this.state.error}/>
</div>
<Overlay
show={this.state.errorTooltip}
placement='top'
target={this.errorMessageRefCurrent}
>
<Tooltip id='error-tooltip' >
{this.state.serverError}
</Tooltip>
</Overlay>
</div>
<ResetPasswordModal
user={user}
show={this.state.showPasswordModal}
onModalSubmit={this.doPasswordResetSubmit}
onModalDismissed={this.doPasswordResetDismiss}
{/* mounting of Modals */}
<ConfirmModal
show={this.state.showDeactivateMemberModal}
title={
<FormattedMessage
id='deactivate_member_modal.title'
defaultMessage='Deactivate {username}'
values={{
username: this.state.user?.username ?? '',
}}
/>
}
message={
<div>
<FormattedMessage
id='deactivate_member_modal.desc'
defaultMessage='This action deactivates {username}. They will be logged out and not have access to any teams or channels on this system. Are you sure you want to deactivate {username}?'
values={{
username: this.state.user?.username ?? '',
}}
/>
{this.state.user?.auth_service !== '' && this.state.user?.auth_service !== Constants.EMAIL_SERVICE && (
<strong>
<br/>
<br/>
<FormattedMessage
id='deactivate_member_modal.sso_warning'
defaultMessage='You must also deactivate this user in the SSO provider or they will be reactivated on next login or sync.'
/>
</strong>
)}
</div>
}
confirmButtonClass='btn btn-danger'
confirmButtonText={
<FormattedMessage
id='deactivate_member_modal.deactivate'
defaultMessage='Deactivate'
/>
}
onConfirm={this.handleDeactivateMember}
onCancel={this.toggleCloseModalDeactivateMember}
/>
{deactivateMemberModal}
{this.state.addTeamOpen &&
{this.state.showTeamSelectorModal && (
<TeamSelectorModal
onModalDismissed={this.closeAddTeam}
onTeamsSelected={this.addTeams}
onModalDismissed={this.toggleCloseTeamSelectorModal}
onTeamsSelected={this.handleAddUserToTeams}
alreadySelected={this.state.teamIds}
excludeGroupConstrained={true}
/>
}
)}
</div>
);
}
}
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;
}

Просмотреть файл

@@ -142,6 +142,19 @@ export default class TeamList extends React.PureComponent<Props, State> {
}
};
private renderRow = (item: TeamWithMembership): JSX.Element => {
return (
<TeamRow
key={item.id}
team={item}
doRemoveUserFromTeam={this.doRemoveUserFromTeam}
doMakeUserTeamAdmin={this.doMakeUserTeamAdmin}
doMakeUserTeamMember={this.doMakeUserTeamMember}
readOnly={this.props.readOnly}
/>
);
};
public render(): JSX.Element {
let serverError = null;
if (this.state.serverError) {
@@ -167,17 +180,4 @@ export default class TeamList extends React.PureComponent<Props, State> {
</React.Fragment>
);
}
private renderRow = (item: TeamWithMembership): JSX.Element => {
return (
<TeamRow
key={item.id}
team={item}
doRemoveUserFromTeam={this.doRemoveUserFromTeam}
doMakeUserTeamAdmin={this.doMakeUserTeamAdmin}
doMakeUserTeamMember={this.doMakeUserTeamMember}
readOnly={this.props.readOnly}
/>
);
};
}

Просмотреть файл

@@ -1,67 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/admin_console/system_users should match default snapshot 1`] = `
<div
className="wrapper--fixed"
>
<AdminHeader>
<MemoizedFormattedMessage
defaultMessage="{siteName} Users"
id="admin.system_users.title"
values={
Object {
"siteName": "Site name",
}
}
/>
<RevokeSessionsButton />
</AdminHeader>
<div
className="admin-console__wrapper ignore-marking"
>
<div
className="admin-console__content"
>
<div
className="more-modal__list member-list-holder"
>
<div
className="system-users__filter-row"
>
<SystemUsersSearch
onChange={[Function]}
onSearch={[Function]}
value=""
/>
<SystemUsersFilterTeam
onChange={[Function]}
onFilter={[Function]}
options={Array []}
value=""
/>
<SystemUsersFilterRole
onChange={[Function]}
onFilter={[Function]}
value=""
/>
</div>
<Connect(SystemUsersList)
enableUserAccessTokens={false}
experimentalEnableAuthenticationTransfer={false}
filter=""
loading={true}
mfaEnabled={false}
nextPage={[Function]}
onTermChange={[Function]}
search={[Function]}
teamId=""
teams={Array []}
term=""
total={0}
usersPerPage={50}
/>
</div>
</div>
</div>
</div>
`;

Просмотреть файл

@@ -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',
}

Просмотреть файл

@@ -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<typeof connector>;
export default connect(mapStateToProps, mapDispatchToProps)(SystemUsers);

Просмотреть файл

@@ -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() {
</SystemPermissionGate>
);
}
export default RevokeSessionsButton;

Просмотреть файл

@@ -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;
}
}

Просмотреть файл

@@ -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(<SystemUsers {...props}/>);
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(<SystemUsers {...props}/>);
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(<SystemUsers {...props}/>);
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(<SystemUsers {...props}/>);
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(<SystemUsers {...props}/>);
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(<SystemUsers {...props}/>);
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(<SystemUsers {...props}/>);
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'});
});
});

Просмотреть файл

@@ -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<string, UserProfile>;
actions: {
/**
* Function to get teams
*/
getTeams: (startInde: number, endIndex: number) => void;
/**
* Function to get statistics for a team
*/
getTeamStats: (teamId: string) => Promise<ActionResult>;
/**
* Function to get a user
*/
getUser: (id: string) => Promise<ActionResult>;
/**
* Function to get a user access token
*/
getUserAccessToken: (tokenId: string) => Promise<ActionResult<UserAccessToken>>;
loadProfilesAndTeamMembers: (page: number, maxItemsPerPage: number, teamId: string, options: Record<string, string | boolean>) => void;
loadProfilesWithoutTeam: (page: number, maxItemsPerPage: number, options: Record<string, string | boolean>) => void;
getProfiles: (page: number, maxItemsPerPage: number, options: Record<string, string | boolean>) => void;
setSystemUsersSearch: (searchTerm: string, teamId: string, filter: string) => void;
searchProfiles: (term: string, options?: any) => Promise<ActionResult<UserProfile[]>>;
/**
* 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<string|MessageDescriptor|[MessageDescriptor, {[key: string]: any}]> = [[messages.title, {siteName: ''}]];
export class SystemUsers extends React.PureComponent<Props, State> {
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<UserReportWithError[]>([]);
const [userCount, setUserCount] = useState<number | undefined>();
const [loadingState, setLoadingState] = useState<LoadingStates>(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<HTMLSelectElement>) => {
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<HTMLSelectElement>) => {
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 (
<div className='wrapper--fixed'>
<AdminHeader>
<FormattedMessage
{...messages.title}
values={{siteName: this.props.siteName}}
/>
<RevokeSessionsButton/>
</AdminHeader>
<div className='admin-console__wrapper ignore-marking'>
<div className='admin-console__content'>
<div className='more-modal__list member-list-holder'>
<div className='system-users__filter-row'>
<SystemUsersSearch
value={this.props.searchTerm}
onChange={this.handleSearchFiltersChange}
onSearch={this.onSearch}
/>
<SystemUsersFilterTeam
options={this.props.teams}
value={this.props.teamId}
onChange={this.handleSearchFiltersChange}
onFilter={this.onFilter}
/>
<SystemUsersFilterRole
value={this.props.filter}
onChange={this.handleSearchFiltersChange}
onFilter={this.onFilter}
function handleDismissMySqlNotice() {
setShowMySqlBanner(false);
props.savePreferences(props.currentUser.id, [{
category: Preferences.CATEGORY_REPORTING,
name: Preferences.HIDE_MYSQL_STATS_NOTIFICATION,
user_id: props.currentUser.id,
value: 'true',
}]);
}
// Handlers for table actions
function handleRowClick(userId: UserReport['id']) {
if (userId.length !== 0) {
history.push(`/admin_console/user_management/user/${userId}`);
}
}
function handlePreviousPageClick() {
if (!userReports.length) {
return;
}
props.setAdminConsoleUsersManagementTableProperties({
pageIndex: props.tablePropertyPageIndex - 1,
cursorDirection: CursorPaginationDirection.prev,
cursorUserId: userReports[0].id,
cursorColumnValue: getSortableColumnValueBySortColumn(userReports[0], props.tablePropertySortColumn),
});
}
function handleNextPageClick() {
if (!userReports.length) {
return;
}
props.setAdminConsoleUsersManagementTableProperties({
pageIndex: props.tablePropertyPageIndex + 1,
cursorDirection: CursorPaginationDirection.next,
cursorUserId: userReports[userReports.length - 1].id,
cursorColumnValue: getSortableColumnValueBySortColumn(userReports[userReports.length - 1], props.tablePropertySortColumn),
});
}
function handleSortingChange(updateFn: (currentSortingState: SortingState) => 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<UserReportWithError>) {
setUserReports(userReports.map((user) => {
if (user.id === userId) {
return {
...user,
error: undefined,
...updatedReport,
};
}
return user;
}));
}
const columns: Array<ColumnDef<UserReport, any>> = useMemo(
() => [
{
id: ColumnNames.username,
accessorKey: 'username',
header: formatMessage({
id: 'admin.system_users.list.userDetails',
defaultMessage: 'User details',
}),
cell: (info: CellContext<UserReportWithError, null>) => {
return (
<div>
<div className='profilePictureContainer'>
<img
className='profilePicture'
src={imageURLForUser(info.row.original.id)}
aria-hidden='true'
/>
</div>
<SystemUsersList
loading={this.state.loading}
search={this.doSearch}
nextPage={this.nextPage}
usersPerPage={USERS_PER_PAGE}
total={this.props.totalUsers}
teams={this.props.teams}
teamId={this.props.teamId}
filter={this.props.filter}
term={this.props.searchTerm}
onTermChange={this.handleTermChange}
mfaEnabled={this.props.mfaEnabled}
enableUserAccessTokens={this.props.enableUserAccessTokens}
experimentalEnableAuthenticationTransfer={this.props.experimentalEnableAuthenticationTransfer}
/>
<div
className='displayName'
title={getDisplayName(info.row.original)}
>
{getDisplayName(info.row.original) || ''}
</div>
<div
className='userName'
title={info.row.original.username}
>
{info.row.original.username}
</div>
{info.row.original.error &&
<div
className='error'
title={info.row.original.error.message}
>
{info.row.original.error.message}
</div>}
</div>
);
},
enableHiding: false,
enablePinning: true,
enableSorting: true,
},
{
id: ColumnNames.email,
accessorKey: 'email',
header: formatMessage({
id: 'admin.system_users.list.email',
defaultMessage: 'Email',
}),
cell: (info: CellContext<UserReport, string>) => 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<UserReport, number>) => <ElapsedDurationCell date={info.getValue()}/>,
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<UserReport, number | undefined>) => <ElapsedDurationCell date={info.getValue()}/>,
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<UserReport, number | undefined>) => <ElapsedDurationCell date={info.getValue()}/>,
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<UserReport, number | undefined>) => <ElapsedDurationCell date={info.getValue()}/>,
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<UserReport, number | undefined>) => 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<UserReport, number | undefined>) => 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<UserReport, null>) => (
<SystemUsersListAction
rowIndex={info.cell.row.index}
tableId={tableId}
user={info.row.original}
currentUser={props.currentUser}
updateUser={(updatedUser) => 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<UserReport>(),
getSortedRowModel: getSortedRowModel<UserReport>(),
onPaginationChange: handlePaginationChange as OnChangeFn<PaginationState>,
onSortingChange: handleSortingChange as OnChangeFn<SortingState>,
onColumnVisibilityChange: handleColumnVisibilityChange as OnChangeFn<VisibilityState>,
manualSorting: true,
enableSortingRemoval: false,
enableMultiSort: false,
manualFiltering: true,
manualPagination: true,
renderFallbackValue: '',
});
return (
<div
className='wrapper--fixed'
data-testid='systemUsersSection'
>
<AdminHeader>
<FormattedMessage
{...messages.title}
values={{siteName: props.siteName}}
>
{(formatMessageChunk) => (
<span id='systemUsersTable-headerId'>{formatMessageChunk}</span>
)}
</FormattedMessage>
<RevokeSessionsButton/>
</AdminHeader>
<div className='admin-console__wrapper'>
{showMySqlBanner &&
<AlertBanner
className='systemUsers__mySqlAlertBanner'
mode='warning'
title={
<FormattedMessage
id='admin.system_users.mysql_stats.title'
defaultMessage='Some statistics are unavailable for servers using MySQL'
/>
}
message={
<>
<FormattedMessage
id='admin.system_users.mysql_stats.desc'
defaultMessage='Use of MySQL may limit the availability of some statistics features. We recommend transitioning from MySQL to PostgreSQL to fully leverage improved performance and comprehensive analytics. While youre still using MySQL, please use the export functionality to view all user statistics.'
/>
<div className='systemUsers__mySqlAlertBanner-buttons'>
<button
type='button'
className='btn btn-primary'
onClick={() => window.open('https://mattermost.com/pl/user-stats-learn-more', '_blank')}
>
<FormattedMessage
id='admin.system_users.mysql_stats.learn_more'
defaultMessage='Learn more'
/>
</button>
<button
type='button'
className='btn btn-tertiary'
onClick={handleDismissMySqlNotice}
>
<FormattedMessage
id='admin.system_users.mysql_stats.dismiss'
defaultMessage='Dismiss'
/>
</button>
</div>
</>
}
onDismiss={handleDismissMySqlNotice}
/>
}
<div className='admin-console__container ignore-marking'>
<div className='admin-console__filters-rows'>
<SystemUsersSearch
searchTerm={props.tablePropertySearchTerm}
/>
<SystemUsersFilterPopover
filterTeam={props.tablePropertyFilterTeam}
filterTeamLabel={props.tablePropertyFilterTeamLabel}
filterRole={props.tablePropertyFilterRole}
filterStatus={props.tablePropertyFilterStatus}
/>
<SystemUsersColumnTogglerMenu
isMySql={props.isMySql}
allColumns={table.getAllLeafColumns()}
visibleColumnsLength={table.getVisibleLeafColumns()?.length ?? 0}
/>
<SystemUsersDateRangeMenu
dateRange={props.tablePropertyDateRange}
/>
<SystemUsersExport
currentUserId={props.currentUser.id}
dateRange={props.tablePropertyDateRange}
/>
</div>
<AdminConsoleListTable<UserReport>
table={table}
/>
</div>
</div>
);
}
</div>
);
}
export default SystemUsers;

Просмотреть файл

@@ -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<Column<UserReport>>;
visibleColumnsLength: number;
}
export function SystemUsersColumnTogglerMenu(props: Props) {
const {formatMessage} = useIntl();
function getColumnName(columnId: CoreColumn<UserReport, unknown>['id']) {
switch (columnId) {
case ColumnNames.username:
return (
<FormattedMessage
id='admin.system_users.list.userDetails'
defaultMessage='User details'
/>
);
case ColumnNames.email:
return (
<FormattedMessage
id='admin.system_users.list.email'
defaultMessage='Email'
/>
);
case ColumnNames.createAt:
return (
<FormattedMessage
id='admin.system_users.list.memberSince'
defaultMessage='Member since'
/>
);
case ColumnNames.lastLoginAt:
return (
<FormattedMessage
id='admin.system_users.list.lastLoginAt'
defaultMessage='Last login'
/>
);
case ColumnNames.lastStatusAt:
return (
<FormattedMessage
id='admin.system_users.list.lastActivity'
defaultMessage='Last activity'
/>
);
case ColumnNames.lastPostDate:
return (
<FormattedMessage
id='admin.system_users.list.lastPost'
defaultMessage='Last post'
/>
);
case ColumnNames.daysActive:
return (
<FormattedMessage
id='admin.system_users.list.daysActive'
defaultMessage='Days active'
/>
);
case ColumnNames.totalPosts:
return (
<FormattedMessage
id='admin.system_users.list.totalPosts'
defaultMessage='Messages posted'
/>
);
case ColumnNames.actions:
return (
<FormattedMessage
id='admin.system_users.list.actions'
defaultMessage='Actions'
/>
);
default:
return <span/>;
}
}
return (
<div className='systemUsersColumnToggler'>
<Menu.Container
menuButton={{
id: 'systemUsersColumnTogglerMenuButton',
class: 'inputWithMenu',
'aria-label': formatMessage({
id: 'admin.system_users.column_toggler.menuButtonAriaLabel',
defaultMessage:
'Open menu to select columns to display',
}),
as: 'div',
children: (
<Input
label={formatMessage({
id: 'admin.system_users.column_toggler.placeholder',
defaultMessage: 'Columns',
})}
name='colXC'
value={formatMessage(
{
id: 'admin.system_users.column_toggler.menuButtonText',
defaultMessage: '{selectedCount} selected',
},
{
selectedCount: props.visibleColumnsLength,
},
)}
readOnly={true}
inputSuffix={
<i className='icon icon-chevron-down'/>
}
/>
),
}}
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 = (
<i className='icon icon-checkbox-marked'/>
);
} else {
leadingElement = (
<i className='icon icon-checkbox-blank-outline'/>
);
}
const postStatsColumns: string[] = [ColumnNames.lastPostDate, ColumnNames.daysActive, ColumnNames.totalPosts];
if (props.isMySql && postStatsColumns.includes(column.id)) {
return (
<WithTooltip
key={column.id}
id={column.id}
title={formatMessage({id: 'admin.system_users.column_toggler.mysql_unavailable.title', defaultMessage: 'Not available for servers using MySQL'})}
hint={formatMessage({id: 'admin.system_users.column_toggler.mysql_unavailable.desc', defaultMessage: 'Please use the export functionality to view these values'})}
placement='left'
>
<Menu.Item
className='systemUsersColumnToggler__lockedItem'
role='menuitemcheckbox'
labels={getColumnName(column.id)}
disabled={true}
leadingElement={leadingElement}
trailingElements={<Tag text={formatMessage({id: 'admin.system_users.column_toggler.mysql_unavailable.label', defaultMessage: 'Not available'})}/>}
onClick={column.getToggleVisibilityHandler()}
/>
</WithTooltip>
);
}
return (
<Menu.Item
key={column.id}
id={column.id}
role='menuitemcheckbox'
labels={getColumnName(column.id)}
disabled={!column.getCanHide()}
leadingElement={leadingElement}
onClick={column.getToggleVisibilityHandler()}
/>
);
})}
</Menu.Container>
</div>
);
}

Просмотреть файл

@@ -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;
}
}

Просмотреть файл

@@ -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 (
<div className='systemUsersDateRangeSelector'>
<Menu.Container
menuButton={{
id: 'systemUsersDateRangeSelectorMenuButton',
class: 'inputWithMenu',
'aria-label': formatMessage({
id: 'admin.system_users.date_range_selector.menuButtonAriaLabel',
defaultMessage:
'Open menu to select columns to display',
}),
as: 'div',
children: (
<Input
label={formatMessage({
id: 'admin.system_users.date_range_selector.label',
defaultMessage: 'Duration',
})}
name='colXC'
value={getSelectedDateRange(props.dateRange)}
readOnly={true}
inputSuffix={
<i className='icon icon-chevron-down'/>
}
/>
),
}}
menu={{
id: 'systemUsersDateRangeSelectorMenu',
'aria-label': formatMessage({
id: 'admin.system_users.date_range_selector.dropdownAriaLabel',
defaultMessage: 'Date range menu',
}),
width: '250px',
}}
>
<Menu.Item
key={ReportDuration.AllTime}
id={ReportDuration.AllTime}
labels={
<FormattedMessage
id='admin.system_users.date_range_selector.date_range.all_time'
defaultMessage='All time'
/>
}
trailingElements={props.dateRange === ReportDuration.AllTime && <i className='icon icon-check'/>}
onClick={() => updateDateRange()}
/>
<Menu.Item
key={ReportDuration.Last30Days}
id={ReportDuration.Last30Days}
labels={
<>
<FormattedMessage
id='admin.system_users.date_range_selector.date_range.last_30_days'
defaultMessage='Last 30 days'
/>
<FormattedMessage
id='admin.system_users.date_range_selector.date_range.sublabel'
defaultMessage='{startDate} - {endDate}'
values={{
startDate: formatDate(get30DaysBack(now)),
endDate: formatDate(now),
}}
/>
</>
}
trailingElements={props.dateRange === ReportDuration.Last30Days && <i className='icon icon-check'/>}
onClick={() => updateDateRange(ReportDuration.Last30Days)}
/>
<Menu.Item
key={ReportDuration.PreviousMonth}
id={ReportDuration.PreviousMonth}
labels={
<>
<FormattedMessage
id='admin.system_users.date_range_selector.date_range.previous_month'
defaultMessage='Previous month'
/>
<FormattedMessage
id='admin.system_users.date_range_selector.date_range.sublabel'
defaultMessage='{startDate} - {endDate}'
values={{
startDate: formatDate(getBeginningOfLastMonth(now)),
endDate: formatDate(getEndOfLastMonth(now)),
}}
/>
</>
}
trailingElements={props.dateRange === ReportDuration.PreviousMonth && <i className='icon icon-check'/>}
onClick={() => updateDateRange(ReportDuration.PreviousMonth)}
/>
<Menu.Item
key={ReportDuration.Last6Months}
id={ReportDuration.Last6Months}
labels={
<>
<FormattedMessage
id='admin.system_users.date_range_selector.date_range.last_6_months'
defaultMessage='Last 6 months'
/>
<FormattedMessage
id='admin.system_users.date_range_selector.date_range.sublabel'
defaultMessage='{startDate} - {endDate}'
values={{
startDate: formatDate(get6MonthsBack(now), {month: 'numeric', year: 'numeric'}),
endDate: formatDate(now, {month: 'numeric', year: 'numeric'}),
}}
/>
</>
}
trailingElements={props.dateRange === ReportDuration.Last6Months && <i className='icon icon-check'/>}
onClick={() => updateDateRange(ReportDuration.Last6Months)}
/>
<Menu.Separator/>
<Menu.Item
key='trailing_message'
id='trailing_message'
className='systemUsersDateRangeSelector__trailing-message'
labels={
<>
<span/>
<FormattedMessage
id='admin.system_users.date_range_selector.trailing_message'
defaultMessage='Note: This filter will only affect values in the <strong>Last Post, Days Active, and Messages Posted</strong> columns.'
values={{
strong: (chunks: React.ReactNode) => (<strong>{chunks}</strong>),
}}
/>
</>
}
disabled={true}
/>
</Menu.Container>
</div>
);
}

Просмотреть файл

@@ -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);
}
}

Просмотреть файл

@@ -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`] = `
<div>
<FormattedMarkdownMessage
defaultMessage="This action deactivates {username}. They will be logged out and not have access to any teams or channels on this system.\\\\n"
id="deactivate_member_modal.desc"
values={
Object {
"username": "some-user",
}
}
/>
<FormattedMarkdownMessage
defaultMessage="Are you sure you want to deactivate {username}?"
id="deactivate_member_modal.desc.confirm"
values={
Object {
"username": "some-user",
}
}
/>
</div>
`;
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`] = `
<div>
<FormattedMarkdownMessage
defaultMessage="This action deactivates {username}. They will be logged out and not have access to any teams or channels on this system.\\\\n"
id="deactivate_member_modal.desc"
values={
Object {
"username": "some-user",
}
}
/>
<FormattedMarkdownMessage
defaultMessage="Are you sure you want to deactivate {username}?"
id="deactivate_member_modal.desc.confirm"
values={
Object {
"username": "some-user",
}
}
/>
</div>
`;
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`] = `
<div>
<React.Fragment>
<ul>
<li>
<Memo(MemoizedFormattedMessage)
defaultMessage="This action deactivates {username}"
id="deactivate_member_modal.desc.for_users_with_bot_accounts1"
values={
Object {
"username": "some-user",
}
}
/>
</li>
<li>
<Memo(MemoizedFormattedMessage)
defaultMessage="They will be logged out and not have access to any teams or channels on this system."
id="deactivate_member_modal.desc.for_users_with_bot_accounts2"
/>
</li>
<li>
<Memo(MemoizedFormattedMessage)
defaultMessage="Bot accounts they manage will be disabled along with their integrations. To enable them again, go to <linkBots>Integrations > Bot Accounts</linkBots>. <linkDocumentation>Learn more about bot accounts</linkDocumentation>."
id="deactivate_member_modal.desc.for_users_with_bot_accounts3"
values={
Object {
"linkBots": [Function],
"linkDocumentation": [Function],
"siteURL": "http://localhost:8065",
}
}
/>
</li>
</ul>
<p />
<p />
</React.Fragment>
<FormattedMarkdownMessage
defaultMessage="Are you sure you want to deactivate {username}?"
id="deactivate_member_modal.desc.confirm"
values={
Object {
"username": "some-user",
}
}
/>
</div>
`;
exports[`components/admin_console/system_users/system_users_dropdown/system_users_dropdown should match snapshot with license 1`] = `
<Fragment>
<ConfirmModal
confirmButtonClass="btn btn-danger"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Deactivate"
id="deactivate_member_modal.deactivate"
/>
}
message={
<div>
<FormattedMarkdownMessage
defaultMessage="This action deactivates {username}. They will be logged out and not have access to any teams or channels on this system.\\\\n"
id="deactivate_member_modal.desc"
values={
Object {
"username": "some-user",
}
}
/>
<FormattedMarkdownMessage
defaultMessage="Are you sure you want to deactivate {username}?"
id="deactivate_member_modal.desc.confirm"
values={
Object {
"username": "some-user",
}
}
/>
</div>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Deactivate {username}"
id="deactivate_member_modal.title"
values={
Object {
"username": "some-user",
}
}
/>
}
/>
<ConfirmModal
confirmButtonClass="btn btn-danger"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Revoke"
id="revoke_user_sessions_modal.revoke"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="This action revokes all sessions for {username}. They will be logged out from all devices. Are you sure you want to revoke all sessions for {username}?"
id="revoke_user_sessions_modal.desc"
values={
Object {
"username": "some-user",
}
}
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Revoke Sessions for {username}"
id="revoke_user_sessions_modal.title"
values={
Object {
"username": "some-user",
}
}
/>
}
/>
<ConfirmModal
confirmButtonClass="btn btn-danger"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Promote"
id="promote_to_user_modal.promote"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="This action promotes the guest {username} to a member. It will allow the user to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to promote guest {username} to member?"
id="promote_to_user_modal.desc"
values={
Object {
"username": "some-user",
}
}
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Promote guest {username} to member"
id="promote_to_user_modal.title"
values={
Object {
"username": "some-user",
}
}
/>
}
/>
<ConfirmModal
confirmButtonClass="btn btn-danger"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Demote"
id="demote_to_user_modal.demote"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="This action demotes the user {username} to a guest. It will restrict the user's ability to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to demote user {username} to guest?"
id="demote_to_user_modal.desc"
values={
Object {
"username": "some-user",
}
}
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Demote User {username} to Guest"
id="demote_to_user_modal.title"
values={
Object {
"username": "some-user",
}
}
/>
}
/>
<ConfirmModal
cancelButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="No"
id="create_group_memberships_modal.cancel"
/>
}
confirmButtonClass="btn btn-danger"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Yes"
id="create_group_memberships_modal.create"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="You're about to add or re-add {username} to teams and channels based on their LDAP group membership. You can revert this change at any time."
id="create_group_memberships_modal.desc"
values={
Object {
"username": "some-user",
}
}
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Re-add {username} to teams and channels"
id="create_group_memberships_modal.title"
values={
Object {
"username": "some-user",
}
}
/>
}
/>
<MenuWrapper
animationComponent={[Function]}
className=""
isDisabled={false}
>
<div
className="text-right"
>
<a>
<span>
<MemoizedFormattedMessage
defaultMessage="Member"
id="admin.user_item.member"
/>
</span>
<span
className="caret"
/>
</a>
</div>
<Menu
ariaLabel="User Actions Menu"
openLeft={true}
openUp={false}
>
<MenuItemAction
disabled={false}
onClick={[Function]}
show={false}
text="Activate"
/>
<MenuItemAction
disabled={false}
onClick={[Function]}
show={true}
text="Deactivate"
/>
<MenuItemAction
onClick={[Function]}
show={false}
text="Manage Roles"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Manage Teams"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Manage Tokens"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Remove MFA"
/>
<MenuItemAction
onClick={[Function]}
show={false}
text="Switch to Email/Password"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Reset Password"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Update Email"
/>
<MenuItemAction
onClick={[Function]}
show={false}
text="Promote to Member"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Demote to Guest"
/>
<Connect(SystemPermissionGate)
permissions={
Array [
"revoke_user_access_token",
]
}
>
<MenuItemAction
onClick={[Function]}
show={true}
text="Revoke Sessions"
/>
</Connect(SystemPermissionGate)>
<Connect(SystemPermissionGate)
permissions={
Array [
"sysconsole_write_user_management_groups",
]
}
>
<MenuItemAction
onClick={[Function]}
show={false}
text="Re-sync user via LDAP groups"
/>
</Connect(SystemPermissionGate)>
</Menu>
</MenuWrapper>
</Fragment>
`;
exports[`components/admin_console/system_users/system_users_dropdown/system_users_dropdown should match snapshot without license 1`] = `
<Fragment>
<ConfirmModal
confirmButtonClass="btn btn-danger"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Deactivate"
id="deactivate_member_modal.deactivate"
/>
}
message={
<div>
<FormattedMarkdownMessage
defaultMessage="This action deactivates {username}. They will be logged out and not have access to any teams or channels on this system.\\\\n"
id="deactivate_member_modal.desc"
values={
Object {
"username": "some-user",
}
}
/>
<FormattedMarkdownMessage
defaultMessage="Are you sure you want to deactivate {username}?"
id="deactivate_member_modal.desc.confirm"
values={
Object {
"username": "some-user",
}
}
/>
</div>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Deactivate {username}"
id="deactivate_member_modal.title"
values={
Object {
"username": "some-user",
}
}
/>
}
/>
<ConfirmModal
confirmButtonClass="btn btn-danger"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Revoke"
id="revoke_user_sessions_modal.revoke"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="This action revokes all sessions for {username}. They will be logged out from all devices. Are you sure you want to revoke all sessions for {username}?"
id="revoke_user_sessions_modal.desc"
values={
Object {
"username": "some-user",
}
}
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Revoke Sessions for {username}"
id="revoke_user_sessions_modal.title"
values={
Object {
"username": "some-user",
}
}
/>
}
/>
<ConfirmModal
confirmButtonClass="btn btn-danger"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Promote"
id="promote_to_user_modal.promote"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="This action promotes the guest {username} to a member. It will allow the user to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to promote guest {username} to member?"
id="promote_to_user_modal.desc"
values={
Object {
"username": "some-user",
}
}
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Promote guest {username} to member"
id="promote_to_user_modal.title"
values={
Object {
"username": "some-user",
}
}
/>
}
/>
<ConfirmModal
confirmButtonClass="btn btn-danger"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Demote"
id="demote_to_user_modal.demote"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="This action demotes the user {username} to a guest. It will restrict the user's ability to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to demote user {username} to guest?"
id="demote_to_user_modal.desc"
values={
Object {
"username": "some-user",
}
}
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Demote User {username} to Guest"
id="demote_to_user_modal.title"
values={
Object {
"username": "some-user",
}
}
/>
}
/>
<ConfirmModal
cancelButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="No"
id="create_group_memberships_modal.cancel"
/>
}
confirmButtonClass="btn btn-danger"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Yes"
id="create_group_memberships_modal.create"
/>
}
message={
<Memo(MemoizedFormattedMessage)
defaultMessage="You're about to add or re-add {username} to teams and channels based on their LDAP group membership. You can revert this change at any time."
id="create_group_memberships_modal.desc"
values={
Object {
"username": "some-user",
}
}
/>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Re-add {username} to teams and channels"
id="create_group_memberships_modal.title"
values={
Object {
"username": "some-user",
}
}
/>
}
/>
<MenuWrapper
animationComponent={[Function]}
className=""
isDisabled={false}
>
<div
className="text-right"
>
<a>
<span>
<MemoizedFormattedMessage
defaultMessage="Member"
id="admin.user_item.member"
/>
</span>
<span
className="caret"
/>
</a>
</div>
<Menu
ariaLabel="User Actions Menu"
openLeft={true}
openUp={false}
>
<MenuItemAction
disabled={false}
onClick={[Function]}
show={false}
text="Activate"
/>
<MenuItemAction
disabled={false}
onClick={[Function]}
show={true}
text="Deactivate"
/>
<MenuItemAction
onClick={[Function]}
show={false}
text="Manage Roles"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Manage Teams"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Manage Tokens"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Remove MFA"
/>
<MenuItemAction
onClick={[Function]}
show={false}
text="Switch to Email/Password"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Reset Password"
/>
<MenuItemAction
onClick={[Function]}
show={true}
text="Update Email"
/>
<MenuItemAction
onClick={[Function]}
show={false}
text="Promote to Member"
/>
<MenuItemAction
onClick={[Function]}
show={false}
text="Demote to Guest"
/>
<Connect(SystemPermissionGate)
permissions={
Array [
"revoke_user_access_token",
]
}
>
<MenuItemAction
onClick={[Function]}
show={true}
text="Revoke Sessions"
/>
</Connect(SystemPermissionGate)>
<Connect(SystemPermissionGate)
permissions={
Array [
"sysconsole_write_user_management_groups",
]
}
>
<MenuItemAction
onClick={[Function]}
show={false}
text="Re-sync user via LDAP groups"
/>
</Connect(SystemPermissionGate)>
</Menu>
</MenuWrapper>
</Fragment>
`;

Просмотреть файл

@@ -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);

Просмотреть файл

@@ -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<SystemUsersDropdown>(<SystemUsersDropdown {...requiredProps}/>);
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<SystemUsersDropdown>(<SystemUsersDropdown {...ldapUserProps}/>);
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<SystemUsersDropdown>(<SystemUsersDropdown {...props}/>);
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<SystemUsersDropdown>(<SystemUsersDropdown {...requiredProps}/>);
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<SystemUsersDropdown>(<SystemUsersDropdown {...ldapUserProps}/>);
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<SystemUsersDropdown>(<SystemUsersDropdown {...props}/>);
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<SystemUsersDropdown>(<SystemUsersDropdown {...requiredProps}/>);
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<SystemUsersDropdown>(<SystemUsersDropdown {...props}/>);
await wrapper.instance().handleRevokeSessions();
expect(onError).toHaveBeenCalled();
});
test('handleShowDeactivateMemberModal should not call the loadBots if the setting is not true', async () => {
const wrapper = shallow<SystemUsersDropdown>(<SystemUsersDropdown {...requiredProps}/>);
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<SystemUsersDropdown>(<SystemUsersDropdown {...{...requiredProps, config: overrideConfig, bots: {}}}/>);
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<SystemUsersDropdown>(<SystemUsersDropdown {...{...requiredProps, ...overrideProps}}/>);
const ConfirmModal = () => wrapper.instance().renderDeactivateMemberModal();
const modal = shallow(<ConfirmModal/>);
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<SystemUsersDropdown>(<SystemUsersDropdown {...{...requiredProps, ...overrideProps}}/>);
wrapper.setState({showDeactivateMemberModal: true});
const ConfirmModal = () => wrapper.instance().renderDeactivateMemberModal();
const modal = shallow(<ConfirmModal/>);
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<SystemUsersDropdown>(<SystemUsersDropdown {...{...requiredProps, ...overrideProps}}/>);
wrapper.setState({showDeactivateMemberModal: true});
const ConfirmModal = () => wrapper.instance().renderDeactivateMemberModal();
const modal = shallow(<ConfirmModal/>);
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<SystemUsersDropdown>(<SystemUsersDropdown {...{...requiredProps, ...overrideProps}}/>);
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<SystemUsersDropdown>(<SystemUsersDropdown {...{...requiredProps, ...overrideProps}}/>);
expect(wrapper.find(Menu.ItemAction).find({text: 'Manage Roles'}).props().show).toBe(true);
});
test('should match snapshot with license', async () => {
const wrapper = shallow(<SystemUsersDropdown {...requiredProps}/>);
expect(wrapper).toMatchSnapshot();
});
test('should match snapshot without license', async () => {
const wrapper = shallow(<SystemUsersDropdown {...{...requiredProps, isLicensed: false}}/>);
expect(wrapper).toMatchSnapshot();
});
});

Просмотреть файл

@@ -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<AdminConfig>;
bots: Record<string, Bot>;
isLicensed: boolean;
actions: {
updateUserActive: (id: string, active: boolean) => Promise<ActionResult>;
revokeAllSessionsForUser: (id: string) => Promise<ActionResult<boolean>>;
promoteGuestToUser: (id: string) => Promise<ActionResult>;
demoteUserToGuest: (id: string) => Promise<ActionResult>;
loadBots: (page?: number, size?: number) => Promise<unknown>;
createGroupTeamsAndChannels: (userId: string) => Promise<ActionResult>;
};
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<Props, State> {
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<HTMLButtonElement>, 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<HTMLButtonElement>, 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 = (
<FormattedMessage
id='deactivate_member_modal.title'
defaultMessage='Deactivate {username}'
values={{
username: this.props.user.username,
}}
/>
);
const defaultMessage = (
<FormattedMarkdownMessage
id='deactivate_member_modal.desc'
defaultMessage='This action deactivates {username}. They will be logged out and not have access to any teams or channels on this system.\n'
values={{
username: user.username,
}}
/>);
let warning;
if (user.auth_service !== '' && user.auth_service !== Constants.EMAIL_SERVICE) {
warning = (
<strong>
<br/>
<br/>
<FormattedMessage
id='deactivate_member_modal.sso_warning'
defaultMessage='You must also deactivate this user in the SSO provider or they will be reactivated on next login or sync.'
/>
</strong>
);
}
const confirmationMessage = (
<FormattedMarkdownMessage
id='deactivate_member_modal.desc.confirm'
defaultMessage='Are you sure you want to deactivate {username}?'
values={{
username: user.username,
}}
/>);
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 = (
<>
<ul>
<li>
<FormattedMessage
id='deactivate_member_modal.desc.for_users_with_bot_accounts1'
defaultMessage='This action deactivates {username}'
values={{
username: user.username,
}}
/>
</li>
<li>
<FormattedMessage
id='deactivate_member_modal.desc.for_users_with_bot_accounts2'
defaultMessage='They will be logged out and not have access to any teams or channels on this system.'
/>
</li>
<li>
<FormattedMessage
id='deactivate_member_modal.desc.for_users_with_bot_accounts3'
defaultMessage='Bot accounts they manage will be disabled along with their integrations. To enable them again, go to <linkBots>Integrations > Bot Accounts</linkBots>. <linkDocumentation>Learn more about bot accounts</linkDocumentation>.'
values={{
siteURL: getSiteURL(),
linkBots: (msg: React.ReactNode) => (
<a
href={`${getSiteURL()}/_redirect/integrations/bots`}
>
{msg}
</a>
),
linkDocumentation: (msg: React.ReactNode) => (
<ExternalLink
href='https://mattermost.com/pl/default-bot-accounts'
location='system_users_dropdown'
>
{msg}
</ExternalLink>
),
}}
/>
</li>
</ul>
<p/>
<p/>
</>
);
break;
}
}
}
const message = (
<div>
{messageForUsersWithBotAccounts || defaultMessage}
{confirmationMessage}
{warning}
</div>
);
const confirmButtonClass = 'btn btn-danger';
const deactivateMemberButton = (
<FormattedMessage
id='deactivate_member_modal.deactivate'
defaultMessage='Deactivate'
/>
);
return (
<ConfirmModal
show={this.state.showDeactivateMemberModal}
title={title}
message={message}
confirmButtonClass={confirmButtonClass}
confirmButtonText={deactivateMemberButton}
onConfirm={this.handleDeactivateMember}
onCancel={this.handleDeactivateCancel}
/>
);
};
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 = (
<FormattedMessage
id='promote_to_user_modal.title'
defaultMessage='Promote guest {username} to member'
values={{
username: this.props.user.username,
}}
/>
);
const message = (
<FormattedMessage
id='promote_to_user_modal.desc'
defaultMessage='This action promotes the guest {username} to a member. It will allow the user to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to promote guest {username} to member?'
values={{
username: this.props.user.username,
}}
/>
);
const promoteUserButton = (
<FormattedMessage
id='promote_to_user_modal.promote'
defaultMessage='Promote'
/>
);
return (
<ConfirmModal
show={this.state.showPromoteToUserModal}
title={title}
message={message}
confirmButtonClass='btn btn-danger'
confirmButtonText={promoteUserButton}
onConfirm={this.handlePromoteToUserConfirm}
onCancel={this.handlePromoteToUserCancel}
/>
);
};
renderDemoteToGuestModal = () => {
const title = (
<FormattedMessage
id='demote_to_user_modal.title'
defaultMessage='Demote User {username} to Guest'
values={{
username: this.props.user.username,
}}
/>
);
const message = (
<FormattedMessage
id='demote_to_user_modal.desc'
defaultMessage={'This action demotes the user {username} to a guest. It will restrict the user\'s ability to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to demote user {username} to guest?'}
values={{
username: this.props.user.username,
}}
/>
);
const demoteGuestButton = (
<FormattedMessage
id='demote_to_user_modal.demote'
defaultMessage='Demote'
/>
);
return (
<ConfirmModal
show={this.state.showDemoteToGuestModal}
title={title}
message={message}
confirmButtonClass='btn btn-danger'
confirmButtonText={demoteGuestButton}
onConfirm={this.handleDemoteToGuestConfirm}
onCancel={this.handleDemoteToGuestCancel}
/>
);
};
renderRevokeSessionsModal = () => {
const title = (
<FormattedMessage
id='revoke_user_sessions_modal.title'
defaultMessage='Revoke Sessions for {username}'
values={{
username: this.props.user.username,
}}
/>
);
const message = (
<FormattedMessage
id='revoke_user_sessions_modal.desc'
defaultMessage='This action revokes all sessions for {username}. They will be logged out from all devices. Are you sure you want to revoke all sessions for {username}?'
values={{
username: this.props.user.username,
}}
/>
);
const revokeUserButtonButton = (
<FormattedMessage
id='revoke_user_sessions_modal.revoke'
defaultMessage='Revoke'
/>
);
return (
<ConfirmModal
show={this.state.showRevokeSessionsModal}
title={title}
message={message}
confirmButtonClass='btn btn-danger'
confirmButtonText={revokeUserButtonButton}
onConfirm={this.handleRevokeSessions}
onCancel={this.handleRevokeSessionsCancel}
/>
);
};
renderCreateGroupSyncablesMembershipsModal = () => {
const title = (
<FormattedMessage
id='create_group_memberships_modal.title'
defaultMessage='Re-add {username} to teams and channels'
values={{
username: this.props.user.username,
}}
/>
);
const message = (
<FormattedMessage
id='create_group_memberships_modal.desc'
defaultMessage="You're about to add or re-add {username} to teams and channels based on their LDAP group membership. You can revert this change at any time."
values={{
username: this.props.user.username,
}}
/>
);
const createGroupMembershipsButton = (
<FormattedMessage
id='create_group_memberships_modal.create'
defaultMessage='Yes'
/>
);
const cancelGroupMembershipsButton = (
<FormattedMessage
id='create_group_memberships_modal.cancel'
defaultMessage='No'
/>
);
return (
<ConfirmModal
show={this.state.showCreateGroupMembershipsModal}
title={title}
message={message}
confirmButtonClass='btn btn-danger'
cancelButtonText={cancelGroupMembershipsButton}
confirmButtonText={createGroupMembershipsButton}
onConfirm={this.handleCreateGroupSyncableMemberships}
onCancel={this.handleCreateGroupSyncableMembershipsCancel}
/>
);
};
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 (
<div className='light mt-1'>
<FormattedMessage
key='admin.user_item.userAccessToken'
id={messageId}
/>
</div>
);
};
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 <div/>;
}
let currentRoles = (
<FormattedMessage
id='admin.user_item.member'
defaultMessage='Member'
/>
);
if (isGuest) {
currentRoles = (
<FormattedMessage
id='team_members_dropdown.guest'
defaultMessage='Guest'
/>
);
}
if (user.roles.length > 0 && UserUtils.isSystemAdmin(user.roles)) {
currentRoles = (
<FormattedMessage
id='team_members_dropdown.systemAdmin'
defaultMessage='System Admin'
/>
);
}
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 = (
<FormattedMessage
id='admin.user_item.inactive'
defaultMessage='Deactivated'
/>
);
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 (
<React.Fragment>
{deactivateMemberModal}
{revokeSessionsModal}
{promoteToUserModal}
{demoteToGuestModal}
{createGroupSyncablesMembershipsModal}
<MenuWrapper
isDisabled={isDisabled}
>
<div className='text-right'>
<a>
<span>{currentRoles} </span>
{!isDisabled &&
<span className='caret'/>
}
</a>
{this.renderAccessToken()}
</div>
<Menu
openLeft={true}
openUp={totalUsers > TOTAL_USERS_TO_OPEN_UP && totalUsers - index <= ROWS_FROM_BOTTOM_TO_OPEN_UP}
ariaLabel={Utils.localizeMessage('admin.user_item.menuAriaLabel', 'User Actions Menu')}
>
<Menu.ItemAction
show={showMakeActive}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => this.handleMakeActive(e, disableActivationToggle)}
text={Utils.localizeMessage('admin.user_item.makeActive', 'Activate')}
disabled={disableActivationToggle}
{...getExtraText(disableActivationToggle)}
/>
<Menu.ItemAction
show={showMakeNotActive}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => this.handleShowDeactivateMemberModal(e, disableActivationToggle)}
text={Utils.localizeMessage('admin.user_item.makeInactive', 'Deactivate')}
disabled={disableActivationToggle}
{...getExtraText(disableActivationToggle)}
/>
<Menu.ItemAction
show={showManageRoles}
onClick={this.handleManageRoles}
text={Utils.localizeMessage('admin.user_item.manageRoles', 'Manage Roles')}
/>
<Menu.ItemAction
show={showManageTeams}
onClick={this.handleManageTeams}
text={Utils.localizeMessage('admin.user_item.manageTeams', 'Manage Teams')}
/>
<Menu.ItemAction
show={this.props.enableUserAccessTokens}
onClick={this.handleManageTokens}
text={Utils.localizeMessage('admin.user_item.manageTokens', 'Manage Tokens')}
/>
<Menu.ItemAction
show={showMfaReset}
onClick={this.handleResetMfa}
text={Utils.localizeMessage('admin.user_item.resetMfa', 'Remove MFA')}
/>
<Menu.ItemAction
show={Boolean(user.auth_service) && this.props.experimentalEnableAuthenticationTransfer}
onClick={this.handleResetPassword}
text={Utils.localizeMessage('admin.user_item.switchToEmail', 'Switch to Email/Password')}
/>
<Menu.ItemAction
show={!user.auth_service}
onClick={this.handleResetPassword}
text={Utils.localizeMessage('admin.user_item.resetPwd', 'Reset Password')}
/>
<Menu.ItemAction
show={!user.auth_service}
onClick={this.handleResetEmail}
text={Utils.localizeMessage('admin.user_item.resetEmail', 'Update Email')}
/>
<Menu.ItemAction
show={isGuest}
onClick={this.handlePromoteToUser}
text={Utils.localizeMessage('admin.user_item.promoteToMember', 'Promote to Member')}
/>
<Menu.ItemAction
show={!isGuest && user.id !== currentUser.id && isLicensed && config.GuestAccountsSettings?.Enable}
onClick={this.handleDemoteToGuest}
text={Utils.localizeMessage('admin.user_item.demoteToGuest', 'Demote to Guest')}
/>
<SystemPermissionGate permissions={[Permissions.REVOKE_USER_ACCESS_TOKEN]}>
<Menu.ItemAction
show={showRevokeSessions}
onClick={this.handleShowRevokeSessionsModal}
text={Utils.localizeMessage('admin.user_item.revokeSessions', 'Revoke Sessions')}
/>
</SystemPermissionGate>
<SystemPermissionGate permissions={[Permissions.SYSCONSOLE_WRITE_USERMANAGEMENT_GROUPS]}>
<Menu.ItemAction
show={user.auth_service === Constants.LDAP_SERVICE}
onClick={this.handleShowCreateGroupSyncableMembershipsModal}
text={Utils.localizeMessage('admin.user_item.createGroupTeamChannelMemberships', 'Re-sync user via LDAP groups')}
/>
</SystemPermissionGate>
</Menu>
</MenuWrapper>
</React.Fragment>
);
}
}

Просмотреть файл

@@ -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 = (
<FormattedMessage
id='export_error_modal.title'
defaultMessage='Export could not be initiated'
/>
);
let message = (
<>
<FormattedMessage
id='export_error_modal.desc'
defaultMessage='Were not able to initiate an export of this data at the moment. Please wait a few minutes and try again.'
/>
<div className='error'>{error.message}</div>
</>
);
if (isInProgress) {
title = (
<FormattedMessage
id='export_error_modal.inProgress.title'
defaultMessage='Export is in progress'
/>
);
message = (
<FormattedMessage
id='export_error_modal.inProgress.desc'
defaultMessage={'You\'ve already started an export of this data. Please wait a few more minutes to access the CSV file or to generate the report again.'}
/>
);
}
return (
<ConfirmModalRedux
title={title}
message={message}
confirmButtonText={
<FormattedMessage
id='generic.okay'
defaultMessage='Okay'
/>
}
onExited={onExited}
/>
);
}

Просмотреть файл

@@ -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 = (
<FormattedMessage
id='export_user_data_modal.title'
defaultMessage='Export user data'
/>
);
let message = (
<FormattedMessage
id='export_user_data_modal.dange_range.all_time'
defaultMessage={'You\'re about to export user data for all time. When the export is ready, a CSV file will be sent to you in a Mattermost direct message. This export will take a few minutes.'}
/>
);
if (dateRange === ReportDuration.Last30Days) {
message = (
<FormattedMessage
id='export_user_data_modal.dange_range.last_30_days'
defaultMessage={'You\'re about to export user data for the last 30 days. When the export is ready, a CSV file will be sent to you in a Mattermost direct message. This export will take a few minutes.'}
/>
);
} else if (dateRange === ReportDuration.PreviousMonth) {
message = (
<FormattedMessage
id='export_user_data_modal.dange_range.previous_month'
defaultMessage={'You\'re about to export user data for the previous month. When the export is ready, a CSV file will be sent to you in a Mattermost direct message. This export will take a few minutes.'}
/>
);
} else if (dateRange === ReportDuration.Last6Months) {
message = (
<FormattedMessage
id='export_user_data_modal.dange_range.last_6_months'
defaultMessage={'You\'re about to export user data for the last 6 months. When the export is ready, a CSV file will be sent to you in a Mattermost direct message. This export will take a few minutes.'}
/>
);
}
const exportDataButton = (
<FormattedMessage
id='export_user_data_modal.export_data'
defaultMessage='Export data'
/>
);
const checkboxText = (
<FormattedMessage
id='export_user_data_modal.do_not_show'
defaultMessage='Do not show this again'
/>
);
return (
<ConfirmModalRedux
title={title}
message={message}
confirmButtonText={exportDataButton}
showCheckbox={true}
checkboxText={checkboxText}
checkboxInFooter={true}
onConfirm={onConfirm}
onExited={onExited}
/>
);
}

Просмотреть файл

@@ -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 = (
<button
onClick={handleExport}
className='btn btn-md btn-tertiary'
>
<span className='icon icon-download-outline'/>
<FormattedMessage
id='admin.system_users.exportButton'
defaultMessage='Export'
/>
</button>
);
if (!isLicensed) {
return (
<>
<WithTooltip
id='sharedTooltip'
title={formatMessage({id: 'admin.system_users.exportButton.notLicensed.title', defaultMessage: 'Professional feature'})}
hint={formatMessage({id: 'admin.system_users.exportButton.notLicensed.hint', defaultMessage: 'This feature is available on the professional plan'})}
placement='top'
>
{button}
</WithTooltip>
<div className='system-users-export__keyIndicator'>
<i className='icon icon-key-variant'/>
</div>
</>
);
}
return button;
}

Просмотреть файл

@@ -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;
}
}
}

Просмотреть файл

@@ -0,0 +1,5 @@
.upgrade-export-data-modal__svg-image {
display: flex;
justify-content: center;
margin-top: 24px;
}

Просмотреть файл

@@ -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 = (
<FormattedMessage
id='upgrade_export_data_modal.title'
defaultMessage='Upgrade to export data reports'
/>
);
const message = (
<>
<FormattedMessage
id='upgrade_export_data_modal.desc'
defaultMessage='Export detailed data reports with ease and analyse user statistics conveniently. Upgrade to the Professional plan to gain access to data export.'
/>
<div className='upgrade-export-data-modal__svg-image'>
<WomanUpArrowsAndCloudsSvg
width={250}
height={188}
/>
</div>
</>
);
const viewPlansButton = (
<FormattedMessage
id='upgrade_export_data_modal.view_plans'
defaultMessage='View Plans'
/>
);
return (
<ConfirmModalRedux
title={title}
message={message}
confirmButtonText={viewPlansButton}
onConfirm={confirm}
onExited={onExited}
/>
);
}

Просмотреть файл

@@ -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<void>;
};
function SystemUsersFilterRole(props: Props) {
const {formatMessage} = useIntl();
function handleChange(e: ChangeEvent<HTMLSelectElement>) {
const filter = e?.target?.value ?? '';
props.onChange({filter});
props.onFilter({filter});
}
return (
<label>
<span className='system-users__filter-label'>
<FormattedMessage
id='filtered_user_list.userStatus'
defaultMessage='User Status:'
/>
</span>
<select
id='selectUserStatus'
className='form-control system-users__filter'
value={props.value}
onChange={handleChange}
>
<option value=''>
{formatMessage({id: 'admin.system_users.allUsers', defaultMessage: 'All Users'})}
</option>
<option value={UserFilters.SYSTEM_ADMIN}>
{formatMessage({id: 'admin.system_users.system_admin', defaultMessage: 'System Admin'})}
</option>
<option value={UserFilters.SYSTEM_GUEST}>
{formatMessage({id: 'admin.system_users.guest', defaultMessage: 'Guest'})}
</option>
<option value={UserFilters.ACTIVE}>
{formatMessage({id: 'admin.system_users.active', defaultMessage: 'Activated'})}
</option>
<option value={UserFilters.INACTIVE}>
{formatMessage({id: 'admin.system_users.inactive', defaultMessage: 'Deactivated'})}
</option>
</select>
</label>
);
}
export default SystemUsersFilterRole;

Просмотреть файл

@@ -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<void>;
};
function SystemUsersFilterTeam(props: Props) {
const {formatMessage} = useIntl();
function handleChange(e: ChangeEvent<HTMLSelectElement>) {
const teamId = e?.target?.value ?? '';
props.onChange({teamId});
props.onFilter({teamId});
}
return (
<label>
<span className='system-users__team-filter-label'>
<FormattedMessage
id='filtered_user_list.team'
defaultMessage='Team:'
/>
</span>
<select
className='form-control system-users__team-filter'
value={props.value}
onChange={handleChange}
>
<option value={SearchUserTeamFilter.ALL_USERS}>
{formatMessage({
id: 'admin.system_users.allUsers',
defaultMessage: 'All Users',
})}
</option>
<option value={SearchUserTeamFilter.NO_TEAM}>
{formatMessage({
id: 'admin.system_users.noTeams',
defaultMessage: 'No Teams',
})}
</option>
{props.options?.map((team) => (
<option
key={team.id}
value={team.id}
>
{team.display_name}
</option>
))}
</select>
</label>
);
}
export default SystemUsersFilterTeam;

Просмотреть файл

@@ -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<Pick<AdminConsoleUserManagementTableProperties, 'filterTeam' | 'filterTeamLabel' | 'filterRole' | 'filterStatus'>>;
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<FiltersState>({});
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 (
<div className='systemUsersFilterContainer'>
<button
{...getReferenceProps()}
ref={floatingRefs.setReference}
className='btn btn-md btn-tertiary'
aria-controls='systemUsersFilterPopover'
>
<i className='icon icon-filter-variant'/>
{formatMessage({id: 'admin.system_users.filtersMenu', defaultMessage: 'Filters ({count})'}, {count: filtersCount})}
</button>
{isMounted && (
<FloatingFocusManager
context={floatingContext}
>
<StyledPopoverContainer
{...getFloatingProps()}
id='systemUsersFilterPopover'
ref={floatingRefs.setFloating}
style={Object.assign({}, floatingStyles, floatingTransistionStyles)}
className='systemUsersFilterPopoverContainer'
aria-labelledby='header'
>
<h4 id='header'>
{formatMessage({id: 'admin.system_users.filtersPopover.title', defaultMessage: 'Filter by'})}
</h4>
<div className='body'>
<SystemUsersFilterTeam
initialValue={props.filterTeam}
initialLabel={props.filterTeamLabel}
onChange={handleTeamFilterChange}
/>
<SystemUsersFilterRole
initialValue={props.filterRole}
onChange={handleRoleFilterChange}
/>
<SystemUsersFiltersStatus
initialValue={props.filterStatus}
onChange={handleStatusFilterChange}
/>
</div>
<div className='footer'>
<button
className='btn btn-md btn-primary'
onClick={handleApplyFilters}
type='submit'
>
{formatMessage({id: 'admin.system_users.filtersPopover.apply', defaultMessage: 'Apply'})}
</button>
</div>
</StyledPopoverContainer>
</FloatingFocusManager>
)}
</div>
);
}

Просмотреть файл

@@ -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 (
<DropdownInput<OptionType>
name='filterStatus'
showLegend={true}
isSearchable={false}
legend={formatMessage({id: 'admin.system_users.filters.status.title', defaultMessage: 'Status'})}
options={options}
value={value}
onChange={handleChange}
/>
);
}

Просмотреть файл

@@ -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;
}
}

Просмотреть файл

@@ -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 (
<DropdownInput<OptionType>
name='filterRole'
showLegend={true}
isSearchable={false}
legend={formatMessage({id: 'admin.system_users.filters.role.title', defaultMessage: 'Role'})}
options={options}
value={value}
onChange={handleChange}
/>
);
}

Просмотреть файл

@@ -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);
}

Просмотреть файл

@@ -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<OptionsType<OptionType>>();
const [pageNumber, setPageNumber] = useState(0);
const [value, setValue] = useState<ValueType<OptionType>>(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<OptionType>) {
setValue(value);
props.onChange((value as OptionType).value as string, (value as OptionType).label as string);
}
useEffect(() => {
loadListInPageNumber(0);
}, []);
return (
<div
className='DropdownInput Input_container'
>
<fieldset
className={classNames('Input_fieldset Input_fieldset___legend', props.className, {
Input_fieldset___error: props.error || error,
})}
>
<legend className='Input_legend Input_legend___focus'>
{formatMessage({id: 'admin.system_users.filters.team.title', defaultMessage: 'Team'})}
</legend>
<div className='Input_wrapper'>
<AsyncSelect
id='asyncTeamSelect'
inputId='asyncTeamSelectInput'
classNamePrefix='DropDown'
className={classNames('Input Input__focus', props.className)}
styles={styles}
isMulti={false}
isClearable={false}
hideSelectedOptions={false}
cacheOptions={false}
placeholder={''} // Since we have a default value, we don't need placeholder
loadingMessage={() => 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,
}}
/>
</div>
</fieldset>
<InputError
message={props.error || error}
/>
</div>
);
}
const styles: Partial<StylesConfig> = {
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<OptionType>) => {
return (
<div className='asyncTeamSelectInput__indicatorsContainer'>
<components.IndicatorsContainer {...props}>
<i className='icon icon-chevron-down'/>
</components.IndicatorsContainer>
</div>
);
};
const Control = (props: ControlProps<OptionType>) => {
return (
<div className='asyncTeamSelectInput__controlContainer'>
<components.Control {...props}/>
</div>
);
};
const Option = (props: OptionProps<OptionType>) => {
return (
<div
className={classNames('asyncTeamSelectInput__option', {
selected: props.isSelected,
focused: props.isFocused,
})}
>
<components.Option {...props}/>
</div>
);
};
const LoadingIndicator = () => {
return (
<LoadingSpinner/>
);
};

Просмотреть файл

@@ -1,501 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/admin_console/system_users/list should match default snapshot 1`] = `
<div>
<injectIntl(SearchableUserList)
actionProps={
Object {
"doEmailReset": [Function],
"doManageRoles": [Function],
"doManageTeams": [Function],
"doManageTokens": [Function],
"doPasswordReset": [Function],
"enableUserAccessTokens": false,
"experimentalEnableAuthenticationTransfer": false,
"mfaEnabled": false,
}
}
actions={
Array [
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
},
]
}
enableUserAccessTokens={false}
experimentalEnableAuthenticationTransfer={false}
extraInfo={Object {}}
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 []}
usersPerPage={0}
/>
<Connect(ManageTeamsModal)
onModalDismissed={[Function]}
show={false}
/>
<Connect(ManageRolesModal)
onModalDismissed={[Function]}
show={false}
/>
<Connect(ManageTokensModal)
onModalDismissed={[Function]}
show={false}
/>
<Connect(ResetPasswordModal)
onModalDismissed={[Function]}
onModalSubmit={[Function]}
show={false}
/>
<Connect(ResetEmailModal)
onModalDismissed={[Function]}
onModalSubmit={[Function]}
show={false}
/>
</div>
`;
exports[`components/admin_console/system_users/list should match default snapshot, with users and mfa disabled 1`] = `
<div>
<injectIntl(SearchableUserList)
actionProps={
Object {
"doEmailReset": [Function],
"doManageRoles": [Function],
"doManageTeams": [Function],
"doManageTokens": [Function],
"doPasswordReset": [Function],
"enableUserAccessTokens": false,
"experimentalEnableAuthenticationTransfer": false,
"mfaEnabled": false,
}
}
actions={
Array [
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
},
]
}
enableUserAccessTokens={false}
experimentalEnableAuthenticationTransfer={false}
extraInfo={
Object {
"id1": Array [
<FormattedMarkdownMessage
defaultMessage="**Sign-in Method:** Email"
id="admin.user_item.authServiceEmail"
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**User ID:** {userID}"
id="admin.user_item.user_id"
values={
Object {
"userID": "id1",
}
}
/>,
],
"id2": Array [
<FormattedMarkdownMessage
defaultMessage="**Sign-in Method:** Email"
id="admin.user_item.authServiceEmail"
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**User ID:** {userID}"
id="admin.user_item.user_id"
values={
Object {
"userID": "id2",
}
}
/>,
],
"id3": Array [
<FormattedMarkdownMessage
defaultMessage="**Sign-in Method:** {service}"
id="admin.user_item.authServiceNotEmail"
values={
Object {
"service": "LDAP",
}
}
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**User ID:** {userID}"
id="admin.user_item.user_id"
values={
Object {
"userID": "id3",
}
}
/>,
],
"id4": Array [
<FormattedMarkdownMessage
defaultMessage="**Sign-in Method:** {service}"
id="admin.user_item.authServiceNotEmail"
values={
Object {
"service": "SAML",
}
}
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**User ID:** {userID}"
id="admin.user_item.user_id"
values={
Object {
"userID": "id4",
}
}
/>,
],
"id5": Array [
<FormattedMarkdownMessage
defaultMessage="**Sign-in Method:** {service}"
id="admin.user_item.authServiceNotEmail"
values={
Object {
"service": "Other Service",
}
}
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**User ID:** {userID}"
id="admin.user_item.user_id"
values={
Object {
"userID": "id5",
}
}
/>,
],
}
}
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}
/>
<Connect(ManageTeamsModal)
onModalDismissed={[Function]}
show={false}
/>
<Connect(ManageRolesModal)
onModalDismissed={[Function]}
show={false}
/>
<Connect(ManageTokensModal)
onModalDismissed={[Function]}
show={false}
/>
<Connect(ResetPasswordModal)
onModalDismissed={[Function]}
onModalSubmit={[Function]}
show={false}
/>
<Connect(ResetEmailModal)
onModalDismissed={[Function]}
onModalSubmit={[Function]}
show={false}
/>
</div>
`;
exports[`components/admin_console/system_users/list should match default snapshot, with users and mfa enabled 1`] = `
<div>
<injectIntl(SearchableUserList)
actionProps={
Object {
"doEmailReset": [Function],
"doManageRoles": [Function],
"doManageTeams": [Function],
"doManageTokens": [Function],
"doPasswordReset": [Function],
"enableUserAccessTokens": false,
"experimentalEnableAuthenticationTransfer": false,
"mfaEnabled": true,
}
}
actions={
Array [
Object {
"$$typeof": Symbol(react.memo),
"WrappedComponent": [Function],
"compare": null,
"type": [Function],
},
]
}
enableUserAccessTokens={false}
experimentalEnableAuthenticationTransfer={false}
extraInfo={
Object {
"id1": Array [
<FormattedMarkdownMessage
defaultMessage="**Sign-in Method:** Email"
id="admin.user_item.authServiceEmail"
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**User ID:** {userID}"
id="admin.user_item.user_id"
values={
Object {
"userID": "id1",
}
}
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**MFA**: No"
id="admin.user_item.mfaNo"
/>,
],
"id2": Array [
<FormattedMarkdownMessage
defaultMessage="**Sign-in Method:** Email"
id="admin.user_item.authServiceEmail"
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**User ID:** {userID}"
id="admin.user_item.user_id"
values={
Object {
"userID": "id2",
}
}
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**MFA**: No"
id="admin.user_item.mfaNo"
/>,
],
"id3": Array [
<FormattedMarkdownMessage
defaultMessage="**Sign-in Method:** {service}"
id="admin.user_item.authServiceNotEmail"
values={
Object {
"service": "LDAP",
}
}
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**User ID:** {userID}"
id="admin.user_item.user_id"
values={
Object {
"userID": "id3",
}
}
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**MFA**: No"
id="admin.user_item.mfaNo"
/>,
],
"id4": Array [
<FormattedMarkdownMessage
defaultMessage="**Sign-in Method:** {service}"
id="admin.user_item.authServiceNotEmail"
values={
Object {
"service": "SAML",
}
}
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**User ID:** {userID}"
id="admin.user_item.user_id"
values={
Object {
"userID": "id4",
}
}
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**MFA**: No"
id="admin.user_item.mfaNo"
/>,
],
"id5": Array [
<FormattedMarkdownMessage
defaultMessage="**Sign-in Method:** {service}"
id="admin.user_item.authServiceNotEmail"
values={
Object {
"service": "Other Service",
}
}
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**User ID:** {userID}"
id="admin.user_item.user_id"
values={
Object {
"userID": "id5",
}
}
/>,
", ",
<FormattedMarkdownMessage
defaultMessage="**MFA**: No"
id="admin.user_item.mfaNo"
/>,
],
}
}
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}
/>
<Connect(ManageTeamsModal)
onModalDismissed={[Function]}
show={false}
/>
<Connect(ManageRolesModal)
onModalDismissed={[Function]}
show={false}
/>
<Connect(ManageTokensModal)
onModalDismissed={[Function]}
show={false}
/>
<Connect(ResetPasswordModal)
onModalDismissed={[Function]}
onModalSubmit={[Function]}
show={false}
/>
<Connect(ResetEmailModal)
onModalDismissed={[Function]}
onModalSubmit={[Function]}
show={false}
/>
</div>
`;

Просмотреть файл

@@ -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);

Просмотреть файл

@@ -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);
});
});
});

Просмотреть файл

@@ -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;
});
}

Просмотреть файл

@@ -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(<SystemUsersList {...props}/>);
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(
<SystemUsersList
{...props}
mfaEnabled={true}
/>,
);
expect(wrapper).toMatchSnapshot();
});
it('and mfa disabled', () => {
const wrapper = shallow(
<SystemUsersList
{...props}
mfaEnabled={false}
/>,
);
expect(wrapper).toMatchSnapshot();
});
});
describe('should reset page', () => {
it('when team changes', () => {
const wrapper = shallow(
<SystemUsersList {...defaultProps}/>,
);
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(
<SystemUsersList {...defaultProps}/>,
);
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(
<SystemUsersList {...defaultProps}/>,
);
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);
});
});
});

Просмотреть файл

@@ -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<Props, State> {
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(
<FormattedMarkdownMessage
key='admin.user_item.authServiceNotEmail'
id='admin.user_item.authServiceNotEmail'
defaultMessage='**Sign-in Method:** {service}'
values={{
service,
}}
/>,
);
} else {
info.push(
<FormattedMarkdownMessage
key='admin.user_item.authServiceEmail'
id='admin.user_item.authServiceEmail'
defaultMessage='**Sign-in Method:** Email'
/>,
);
}
info.push(', ');
const userID = user.id;
info.push(
<FormattedMarkdownMessage
key='admin.user_item.user_id'
id='admin.user_item.user_id'
defaultMessage='**User ID:** {userID}'
values={{
userID,
}}
/>,
);
if (this.props.mfaEnabled) {
info.push(', ');
if (user.mfa_active) {
info.push(
<FormattedMarkdownMessage
key='admin.user_item.mfaYes'
id='admin.user_item.mfaYes'
defaultMessage='**MFA**: Yes'
/>,
);
} else {
info.push(
<FormattedMarkdownMessage
key='admin.user_item.mfaNo'
id='admin.user_item.mfaNo'
defaultMessage='**MFA**: No'
/>,
);
}
}
return info;
}
renderCount(count: number, total: number, startCount: number, endCount: number, isSearch: boolean) {
if (total) {
if (isSearch) {
return (
<FormattedMessage
id='system_users_list.countSearch'
defaultMessage='{count, number} {count, plural, one {user} other {users}} of {total, number} total'
values={{
count,
total,
}}
/>
);
} else if (startCount !== 0 || endCount !== total) {
return (
<FormattedMessage
id='system_users_list.countPage'
defaultMessage='{startCount, number} - {endCount, number} {count, plural, one {user} other {users}} of {total, number} total'
values={{
count,
startCount: startCount + 1,
endCount,
total,
}}
/>
);
}
return (
<FormattedMessage
id='system_users_list.count'
defaultMessage='{count, number} {count, plural, one {user} other {users}}'
values={{
count,
}}
/>
);
}
return null;
}
render() {
const extraInfo: {[key: string]: Array<string | JSX.Element>} = {};
if (this.props.users) {
for (const user of this.props.users) {
extraInfo[user.id] = this.getInfoForUser(user);
}
}
return (
<div>
<SearchableUserList
{...this.props}
renderCount={this.renderCount}
extraInfo={extraInfo}
actions={[SystemUsersDropdown]}
actionProps={{
mfaEnabled: this.props.mfaEnabled,
enableUserAccessTokens: this.props.enableUserAccessTokens,
experimentalEnableAuthenticationTransfer: this.props.experimentalEnableAuthenticationTransfer,
doPasswordReset: this.doPasswordReset,
doEmailReset: this.doEmailReset,
doManageTeams: this.doManageTeams,
doManageRoles: this.doManageRoles,
doManageTokens: this.doManageTokens,
}}
nextPage={this.nextPage}
previousPage={this.previousPage}
page={this.state.page}
rowComponentType={UserListRowWithError}
noBuiltInFilters={true}
/>
<ManageTeamsModal
user={this.state.user}
show={this.state.showManageTeamsModal}
onModalDismissed={this.doManageTeamsDismiss}
/>
<ManageRolesModal
user={this.state.user}
show={this.state.showManageRolesModal}
onModalDismissed={this.doManageRolesDismiss}
/>
<ManageTokensModal
user={this.state.user}
show={this.state.showManageTokensModal}
onModalDismissed={this.doManageTokensDismiss}
/>
<ResetPasswordModal
user={this.state.user}
show={this.state.showPasswordModal}
onModalSubmit={this.doPasswordResetSubmit}
onModalDismissed={this.doPasswordResetDismiss}
/>
<ResetEmailModal
user={this.state.user}
show={this.state.showEmailModal}
onModalSubmit={this.doEmailResetSubmit}
onModalDismissed={this.doEmailResetDismiss}
/>
</div>
);
}
}

Просмотреть файл

@@ -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 = (
<FormattedMessage
id='create_group_memberships_modal.title'
defaultMessage='Re-add {username} to teams and channels'
values={{
username: user.username,
}}
/>
);
const message = (
<FormattedMessage
id='create_group_memberships_modal.desc'
defaultMessage="You're about to add or re-add {username} to teams and channels based on their LDAP group membership. You can revert this change at any time."
values={{
username: user.username,
}}
/>
);
const createGroupMembershipsButton = (
<FormattedMessage
id='create_group_memberships_modal.create'
defaultMessage='Yes'
/>
);
const cancelGroupMembershipsButton = (
<FormattedMessage
id='create_group_memberships_modal.cancel'
defaultMessage='No'
/>
);
return (
<ConfirmModalRedux
title={title}
message={message}
confirmButtonClass='btn btn-danger'
cancelButtonText={cancelGroupMembershipsButton}
confirmButtonText={createGroupMembershipsButton}
onConfirm={confirm}
onExited={onExited}
/>
);
}

Просмотреть файл

@@ -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 = (
<FormattedMessage
id='deactivate_member_modal.title'
defaultMessage='Deactivate {username}'
values={{
username: user.username,
}}
/>
);
const defaultMessage = (
<FormattedMessage
id='deactivate_member_modal.desc'
defaultMessage='This action deactivates {username}. They will be logged out and not have access to any teams or channels on this system.\n'
values={{
username: user.username,
}}
/>);
let warning;
if (user.auth_service !== '' && user.auth_service !== Constants.EMAIL_SERVICE) {
warning = (
<strong>
<br/>
<br/>
<FormattedMessage
id='deactivate_member_modal.sso_warning'
defaultMessage='You must also deactivate this user in the SSO provider or they will be reactivated on next login or sync.'
/>
</strong>
);
}
const confirmationMessage = (
<FormattedMessage
id='deactivate_member_modal.desc.confirm'
defaultMessage='Are you sure you want to deactivate {username}?'
values={{
username: user.username,
}}
/>);
let messageForUsersWithBotAccounts;
if (config.ServiceSettings?.DisableBotsWhenOwnerIsDeactivated) {
for (const bot of Object.values(bots)) {
if ((bot.owner_id === user.id) && (bot.delete_at === 0)) {
messageForUsersWithBotAccounts = (
<>
<ul>
<li>
<FormattedMessage
id='deactivate_member_modal.desc.for_users_with_bot_accounts1'
defaultMessage='This action deactivates {username}'
values={{
username: user.username,
}}
/>
</li>
<li>
<FormattedMessage
id='deactivate_member_modal.desc.for_users_with_bot_accounts2'
defaultMessage='They will be logged out and not have access to any teams or channels on this system.'
/>
</li>
<li>
<FormattedMessage
id='deactivate_member_modal.desc.for_users_with_bot_accounts3'
defaultMessage='Bot accounts they manage will be disabled along with their integrations. To enable them again, go to <linkBots>Integrations > Bot Accounts</linkBots>. <linkDocumentation>Learn more about bot accounts</linkDocumentation>.'
values={{
siteURL,
linkBots: (msg: React.ReactNode) => (
<a
href={`${siteURL}/_redirect/integrations/bots`}
>
{msg}
</a>
),
linkDocumentation: (msg: React.ReactNode) => (
<ExternalLink
href='https://mattermost.com/pl/default-bot-accounts'
location='system_users_dropdown'
>
{msg}
</ExternalLink>
),
}}
/>
</li>
</ul>
<p/>
<p/>
</>
);
break;
}
}
}
const message = (
<div>
{messageForUsersWithBotAccounts || defaultMessage}
{confirmationMessage}
{warning}
</div>
);
const confirmButtonClass = 'btn btn-danger';
const deactivateMemberButton = (
<FormattedMessage
id='deactivate_member_modal.deactivate'
defaultMessage='Deactivate'
/>
);
return (
<ConfirmModalRedux
title={title}
message={message}
confirmButtonClass={confirmButtonClass}
confirmButtonText={deactivateMemberButton}
onConfirm={deactivateMember}
onExited={onExited}
/>
);
}

Просмотреть файл

@@ -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 = (
<FormattedMessage
id='demote_to_user_modal.title'
defaultMessage='Demote User {username} to Guest'
values={{
username: user.username,
}}
/>
);
const message = (
<FormattedMessage
id='demote_to_user_modal.desc'
defaultMessage={'This action demotes the user {username} to a guest. It will restrict the user\'s ability to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to demote user {username} to guest?'}
values={{
username: user.username,
}}
/>
);
const demoteGuestButton = (
<FormattedMessage
id='demote_to_user_modal.demote'
defaultMessage='Demote'
/>
);
return (
<ConfirmModalRedux
title={title}
message={message}
confirmButtonClass='btn btn-danger'
confirmButtonText={demoteGuestButton}
onConfirm={confirm}
onExited={onExited}
/>
);
}

Просмотреть файл

@@ -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<UserProfile>) => 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 (
<FormattedMessage
id='admin.system_users.list.actions.deactivated'
defaultMessage='Deactivated'
/>
);
} else if (user.roles.length > 0 && isSystemAdmin(userRoles)) {
return (
<FormattedMessage
id='admin.system_users.list.actions.userAdmin'
defaultMessage='System Admin'
/>
);
} else if (isGuest(userRoles)) {
return (
<FormattedMessage
id='admin.system_users.list.actions.userGuest'
defaultMessage='Guest'
/>
);
}
return (
<FormattedMessage
id='admin.system_users.list.actions.userMember'
defaultMessage='Member'
/>
);
}
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 (
<Menu.Container
menuButton={{
id: menuButtonId,
class: classNames('btn btn-quaternary btn-sm', {
disabled: isDisabled,
}),
disabled: isDisabled,
children: (
<>
{getTranslatedUserRole(user.roles)}
{!isDisabled && (
<i
aria-hidden='true'
className='icon icon-chevron-down'
/>
)}
</>
),
}}
menu={{
id: menuId,
'aria-label': formatMessage({
id: 'admin.system_users.list.actions.menu.dropdownAriaLabel',
defaultMessage: 'User actions menu',
}),
}}
>
{user.delete_at > 0 &&
<Menu.Item
id={`${menuItemIdPrefix}-active`}
labels={
<FormattedMessage
id='admin.system_users.list.actions.menu.activate'
defaultMessage='Activate'
/>
}
disabled={user.auth_service === Constants.LDAP_SERVICE}
onClick={async () => {
if (user.auth_service === Constants.LDAP_SERVICE) {
return;
}
const {error} = await dispatch(updateUserActive(user.id, true));
if (error) {
onError(error);
} else {
updateUser({delete_at: 0});
}
}}
/>}
{user.delete_at === 0 &&
<Menu.Item
id={`${menuItemIdPrefix}-deactivate`}
isDestructive={true}
labels={
<FormattedMessage
id='admin.system_users.list.actions.menu.deactivate'
defaultMessage='Deactivate'
/>
}
onClick={() => {
dispatch(openModal({
modalId: ModalIdentifiers.DEACTIVATE_MEMBER_MODAL,
dialogType: DeactivateMemberModal,
dialogProps: {
user,
onError,
onSuccess: onDeactivateMember,
},
}));
}}
/>}
{isSystemAdmin(currentUser.roles) &&
<Menu.Item
id={`${menuItemIdPrefix}-manageRoles`}
labels={
<FormattedMessage
id='admin.system_users.list.actions.menu.manageRoles'
defaultMessage='Manage roles'
/>
}
onClick={() => {
dispatch(openModal({
modalId: ModalIdentifiers.MANAGE_ROLES_MODAL,
dialogType: ManageRolesModal,
dialogProps: {
user,
onSuccess: onUpdateRoles,
},
}));
}}
/>}
<Menu.Item
id={`${menuItemIdPrefix}-manageTeams`}
labels={
<FormattedMessage
id='admin.system_users.list.actions.menu.manageTeams'
defaultMessage='Manage teams'
/>
}
onClick={() => {
dispatch(openModal({
modalId: ModalIdentifiers.MANAGE_TEAMS_MODAL,
dialogType: ManageTeamsModal,
dialogProps: {user},
}));
}}
/>
{config.EnableUserAccessTokens === 'true' &&
<Menu.Item
id={`${menuItemIdPrefix}-manageTokens`}
labels={
<FormattedMessage
id='admin.system_users.list.actions.menu.manageTokens'
defaultMessage='Manage tokens'
/>
}
onClick={() => {
dispatch(openModal({
modalId: ModalIdentifiers.MANAGE_TOKENS_MODAL,
dialogType: ManageTokensModal,
dialogProps: {user},
}));
}}
/>}
{!user.auth_service &&
<Menu.Item
id={`${menuItemIdPrefix}-resetPassword`}
labels={
<FormattedMessage
id='admin.system_users.list.actions.menu.resetPassword'
defaultMessage='Reset password'
/>
}
onClick={() => {
dispatch(openModal({
modalId: ModalIdentifiers.RESET_PASSWORD_MODAL,
dialogType: ResetPasswordModal,
dialogProps: {user},
}));
}}
/>}
{user.mfa_active && config.EnableMultifactorAuthentication &&
<Menu.Item
id={`${menuItemIdPrefix}-removeMFA`}
labels={
<FormattedMessage
id='admin.system_users.list.actions.menu.removeMFA'
defaultMessage='Remove MFA'
/>
}
onClick={() => {
adminResetMfa(user.id, null, onError).then(() => {
updateUser({mfa_active: false});
});
}}
/>}
{Boolean(user.auth_service) && config.ExperimentalEnableAuthenticationTransfer === 'true' &&
<Menu.Item
id={`${menuItemIdPrefix}-switchToEmailPassword`}
labels={
<FormattedMessage
id='admin.system_users.list.actions.menu.switchToEmailPassword'
defaultMessage='Switch to Email/Password'
/>
}
onClick={() => {
dispatch(openModal({
modalId: ModalIdentifiers.RESET_PASSWORD_MODAL,
dialogType: ResetPasswordModal,
dialogProps: {
user,
onSuccess: onSwitchToEmailPassword,
},
}));
}}
/>}
{!user.auth_service &&
<Menu.Item
id={`${menuItemIdPrefix}-updateEmail`}
labels={
<FormattedMessage
id='admin.system_users.list.actions.menu.updateEmail'
defaultMessage='Update email'
/>
}
onClick={() => {
dispatch(openModal({
modalId: ModalIdentifiers.RESET_EMAIL_MODAL,
dialogType: ResetEmailModal,
dialogProps: {
user,
onSuccess: onUpdateEmail,
},
}));
}}
/>}
{isGuest(user.roles) &&
<Menu.Item
id={`${menuItemIdPrefix}-promoteToMember`}
labels={
<FormattedMessage
id='admin.system_users.list.actions.menu.promoteToMember'
defaultMessage='Promote to member'
/>
}
onClick={() => {
dispatch(openModal({
modalId: ModalIdentifiers.PROMOTE_TO_MEMBER_MODAL,
dialogType: PromoteToMemberModal,
dialogProps: {
user,
onError,
onSuccess: onPromoteToMember,
},
}));
}}
/>}
{!isGuest(user.roles) && user.id !== currentUser.id && isLicensed && config.EnableGuestAccounts === 'true' &&
<Menu.Item
id={`${menuItemIdPrefix}-demoteToGuest`}
labels={
<FormattedMessage
id='admin.system_users.list.actions.menu.demoteToGuest'
defaultMessage='Demote to guest'
/>
}
onClick={() => {
dispatch(openModal({
modalId: ModalIdentifiers.DEMOTE_TO_GUEST_MODAL,
dialogType: DemoteToGuestModal,
dialogProps: {
user,
onError,
onSuccess: onDemoteToGuest,
},
}));
}}
/>}
<SystemPermissionGate permissions={[Permissions.REVOKE_USER_ACCESS_TOKEN]}>
{!user.delete_at &&
<Menu.Item
id={`${menuItemIdPrefix}-removeSessions`}
labels={
<FormattedMessage
id='admin.system_users.list.actions.menu.removeSessions'
defaultMessage='Remove sessions'
/>
}
onClick={() => {
dispatch(openModal({
modalId: ModalIdentifiers.REVOKE_SESSIONS_MODAL,
dialogType: RevokeSessionsModal,
dialogProps: {user, currentUser, onError},
}));
}}
/>}
</SystemPermissionGate>
<SystemPermissionGate permissions={[Permissions.SYSCONSOLE_WRITE_USERMANAGEMENT_GROUPS]}>
{user.auth_service === Constants.LDAP_SERVICE &&
<Menu.Item
id={`${menuItemIdPrefix}-resyncUserViaLdapGroups`}
labels={
<FormattedMessage
id='admin.system_users.list.actions.menu.resyncUserViaLdapGroups'
defaultMessage='Re-sync user via LDAP groups'
/>
}
onClick={() => {
dispatch(openModal({
modalId: ModalIdentifiers.CREATE_GROUP_SYNCABLES_MEMBERSHIP_MODAL,
dialogType: CreateGroupSyncablesMembershipsModal,
dialogProps: {user, onError},
}));
}}
/>}
</SystemPermissionGate>
</Menu.Container>
);
}

Просмотреть файл

@@ -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 {promoteGuestToUser} from 'mattermost-redux/actions/users';
import ConfirmModalRedux from 'components/confirm_modal_redux';
type Props = {
user: UserProfile;
onSuccess: () => void;
onExited: () => void;
onError: (error: ServerError) => void;
}
export default function PromoteToMemberModal({user, onExited, onSuccess, onError}: Props) {
const dispatch = useDispatch();
async function confirm() {
const {error} = await dispatch(promoteGuestToUser(user.id));
if (error) {
onError(error);
} else {
onSuccess();
}
}
const title = (
<FormattedMessage
id='promote_to_user_modal.title'
defaultMessage='Promote guest {username} to member'
values={{
username: user.username,
}}
/>
);
const message = (
<FormattedMessage
id='promote_to_user_modal.desc'
defaultMessage='This action promotes the guest {username} to a member. It will allow the user to join public channels and interact with users outside of the channels they are currently members of. Are you sure you want to promote guest {username} to member?'
values={{
username: user.username,
}}
/>
);
const promoteUserButton = (
<FormattedMessage
id='promote_to_user_modal.promote'
defaultMessage='Promote'
/>
);
return (
<ConfirmModalRedux
title={title}
message={message}
confirmButtonClass='btn btn-danger'
confirmButtonText={promoteUserButton}
onConfirm={confirm}
onExited={onExited}
/>
);
}

Просмотреть файл

@@ -0,0 +1,73 @@
// 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 {revokeAllSessionsForUser} from 'mattermost-redux/actions/users';
import {emitUserLoggedOutEvent} from 'actions/global_actions';
import ConfirmModalRedux from 'components/confirm_modal_redux';
type Props = {
user: UserProfile;
currentUser: UserProfile;
onExited: () => void;
onError: (error: ServerError) => void;
}
export default function RevokeSessionsModal({user, currentUser, onExited, onError}: Props) {
const dispatch = useDispatch();
async function confirm() {
const {data, error} = await dispatch(revokeAllSessionsForUser(user.id));
if (data && user.id === currentUser.id) {
emitUserLoggedOutEvent();
} else if (error) {
onError(error);
}
}
const title = (
<FormattedMessage
id='revoke_user_sessions_modal.title'
defaultMessage='Revoke Sessions for {username}'
values={{
username: user.username,
}}
/>
);
const message = (
<FormattedMessage
id='revoke_user_sessions_modal.desc'
defaultMessage='This action revokes all sessions for {username}. They will be logged out from all devices. Are you sure you want to revoke all sessions for {username}?'
values={{
username: user.username,
}}
/>
);
const revokeUserButtonButton = (
<FormattedMessage
id='revoke_user_sessions_modal.revoke'
defaultMessage='Revoke'
/>
);
return (
<ConfirmModalRedux
title={title}
message={message}
confirmButtonClass='btn btn-danger'
confirmButtonText={revokeUserButtonButton}
onConfirm={confirm}
onExited={onExited}
/>
);
}

Просмотреть файл

@@ -1,55 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import debounce from 'lodash/debounce';
import type {ChangeEvent} from 'react';
import React, {useCallback, useEffect} from 'react';
import React, {useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import Constants from 'utils/constants';
import {setAdminConsoleUsersManagementTableProperties} from 'actions/views/admin';
import Input from 'components/widgets/inputs/input/input';
import type {AdminConsoleUserManagementTableProperties} from 'types/store/views';
import './system_users_search.scss';
type Props = {
value?: string;
onChange: ({searchTerm, teamId, filter}: {searchTerm?: string; teamId?: string; filter?: string}) => void;
onSearch: (value: string) => void;
};
searchTerm: AdminConsoleUserManagementTableProperties['searchTerm'];
}
function SystemUsersSearch(props: Props) {
export function SystemUsersSearch(props: Props) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const timeout = useRef<NodeJS.Timeout>();
const debouncedSearch = useCallback(debounce((value: string) => {
props.onSearch(value);
}, Constants.SEARCH_TIMEOUT_MILLISECONDS), []);
const [inputValue, setInputValue] = useState(props.searchTerm);
useEffect(() => {
return () => {
debouncedSearch.cancel();
};
}, []);
function handleChange(event: ChangeEvent<HTMLInputElement>) {
const {target: {value}} = event;
setInputValue(value);
function handleChange(e: ChangeEvent<HTMLInputElement>) {
const searchTerm = e?.target?.value?.trim() ?? '';
props.onChange({searchTerm});
clearTimeout(timeout.current);
timeout.current = setTimeout(() => {
dispatch(setAdminConsoleUsersManagementTableProperties({searchTerm: value}));
}, 500);
}
if (searchTerm.length > 0) {
debouncedSearch(searchTerm);
}
function handleClear() {
setInputValue('');
dispatch(setAdminConsoleUsersManagementTableProperties({searchTerm: ''}));
}
return (
<div className='system-users__filter'>
<input
id='searchUsers'
className='form-control filter-textbox'
placeholder={formatMessage({
id: 'filtered_user_list.search',
defaultMessage: 'Search users',
})}
value={props.value}
<Input
type='text'
clearable={true}
name='searchTerm'
containerClassName='systemUsersSearch'
placeholder={formatMessage({id: 'admin.system_users.search.placeholder', defaultMessage: 'Search users'})}
inputPrefix={<i className={'icon icon-magnify'}/>}
onChange={handleChange}
onClear={handleClear}
value={inputValue}
/>
</div>
);
}
export default SystemUsersSearch;

Просмотреть файл

@@ -0,0 +1,4 @@
.systemUsersSearch {
width: 360px;
margin-inline-end: 8px;
}

Просмотреть файл

@@ -0,0 +1,75 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {UserReport} from '@mattermost/types/reports';
import {ColumnNames, StatusFilter} from '../constants';
import {getSortColumnForOptions, getSortDirectionForOptions, getSortableColumnValueBySortColumn, getStatusFilterOption} from './index';
describe('getSortColumnForOptions', () => {
it('should return correct sort column for email', () => {
const result = getSortColumnForOptions(ColumnNames.email);
expect(result.sort_column).toBe('Email');
});
it('should return correct sort column for createAt', () => {
const result = getSortColumnForOptions(ColumnNames.createAt);
expect(result.sort_column).toBe('CreateAt');
});
it('should default to username if no id is provided', () => {
const result = getSortColumnForOptions();
expect(result.sort_column).toBe('Username');
});
});
describe('getSortDirectionForOptions', () => {
it('should return ascending sort direction if desc is false', () => {
const result = getSortDirectionForOptions(false);
expect(result.sort_direction).toBe('asc');
});
it('should return descending sort direction if desc is true', () => {
const result = getSortDirectionForOptions(true);
expect(result.sort_direction).toBe('desc');
});
});
describe('getSortableColumnValueBySortColumn', () => {
it('should return email value when sortColumn is email', () => {
const row = {email: 'test@example.com', id: 'testId'} as UserReport;
const result = getSortableColumnValueBySortColumn(row, ColumnNames.email);
expect(result).toBe('test@example.com');
});
it('should return create_at value as string when sortColumn is createAt', () => {
const row = {create_at: 1234} as UserReport;
const result = getSortableColumnValueBySortColumn(row, ColumnNames.createAt);
expect(typeof result).toBe('string');
expect(result).toBe('1234');
});
it('should return username value by default', () => {
const row = {username: 'testuser', id: 'testId', create_at: 22122} as UserReport;
const result = getSortableColumnValueBySortColumn(row, 'someOtherColumn');
expect(result).toBe('testuser');
});
});
describe('getStatusFilterOption', () => {
it('should return hide_inactive true for Active status', () => {
const result = getStatusFilterOption(StatusFilter.Active);
expect(result).toEqual({hide_inactive: true});
});
it('should return hide_active true for Deactivated status', () => {
const result = getStatusFilterOption(StatusFilter.Deactivated);
expect(result).toEqual({hide_active: true});
});
it('should return an empty object for other status', () => {
const result = getStatusFilterOption('SomeOtherStatus' as any);
expect(result).toEqual({});
});
});

Просмотреть файл

@@ -0,0 +1,202 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {SortingState} from '@tanstack/react-table';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import {UserReportSortColumns, ReportSortDirection} from '@mattermost/types/reports';
import type {UserReportOptions, UserReport} from '@mattermost/types/reports';
import type {Team} from '@mattermost/types/teams';
import {PAGE_SIZES} from 'components/admin_console/list_table';
import type {AdminConsoleUserManagementTableProperties} from 'types/store/views';
import {ColumnNames, RoleFilters, StatusFilter, TeamFilters} from '../constants';
import type {TableOptions} from '../system_users';
import type {OptionType as TeamFilterOptionType} from '../system_users_filters_popover/system_users_filter_team';
export function convertTableOptionsToUserReportOptions(tableOptions?: TableOptions): UserReportOptions {
return {
page_size: tableOptions?.pageSize || PAGE_SIZES[0],
from_column_value: tableOptions?.fromColumnValue,
from_id: tableOptions?.fromId,
direction: tableOptions?.direction,
...getSortColumnForOptions(tableOptions?.sortColumn),
...getSortDirectionForOptions(tableOptions?.sortIsDescending),
...getSearchFilterOption(tableOptions?.searchTerm),
...getTeamFilterOption(tableOptions?.filterTeam),
...getStatusFilterOption(tableOptions?.filterStatus),
...getRoleFilterOption(tableOptions?.filterRole),
date_range: tableOptions?.dateRange,
};
}
/**
* Converts the sorting column name to API compatible sorting column name. Default sorting column name is by username.
*/
export function getSortColumnForOptions(id?: SortingState[0]['id']): Pick<UserReportOptions, 'sort_column'> {
let sortColumn: UserReportOptions['sort_column'];
if (id === ColumnNames.email) {
sortColumn = UserReportSortColumns.email;
} else if (id === ColumnNames.createAt) {
sortColumn = UserReportSortColumns.createAt;
} else {
// Default sorting to first User details column
sortColumn = UserReportSortColumns.username;
}
return {
sort_column: sortColumn,
};
}
/**
* Converts the sorting direction to API compatible sorting direction. Default sorting direction is ascending.
*/
export function getSortDirectionForOptions(desc?: SortingState[0]['desc']): Pick<UserReportOptions, 'sort_direction'> {
let sortDirection: UserReportOptions['sort_direction'];
if (desc) {
sortDirection = ReportSortDirection.descending;
} else {
sortDirection = ReportSortDirection.ascending;
}
return {
sort_direction: sortDirection,
};
}
/**
* It returns the value of that column on which sorting is applied.
*/
export function getSortableColumnValueBySortColumn(row: UserReport, sortColumn: AdminConsoleUserManagementTableProperties['sortColumn']): string {
switch (sortColumn) {
case ColumnNames.email:
return row.email;
case ColumnNames.createAt:
return String(row.create_at);
default:
return row.username;
}
}
export function getTeamFilterOption(teamId?: string): Partial<Pick<UserReportOptions, 'team_filter' | 'has_no_team'>> {
if (!teamId || teamId === TeamFilters.AllTeams) {
return {
team_filter: undefined,
has_no_team: undefined,
};
} else if (teamId === TeamFilters.NoTeams) {
return {
team_filter: undefined,
has_no_team: true,
};
}
return {
team_filter: teamId,
has_no_team: undefined,
};
}
export function getStatusFilterOption(status?: string): Partial<Pick<UserReportOptions, 'hide_active' | 'hide_inactive'>> {
if (status === StatusFilter.Active) {
return {
hide_inactive: true,
};
} else if (status === StatusFilter.Deactivated) {
return {
hide_active: true,
};
}
return {
hide_active: undefined,
hide_inactive: undefined,
};
}
export function getPaginationInfo(pageIndex: number, pageSize: number, currentLength: number, total?: number) {
if (!currentLength) {
return (
<FormattedMessage
id='admin.system_users_list.pagination.no_users'
defaultMessage='0 users'
/>
);
}
const firstPage = (pageIndex * pageSize) + 1;
const lastPage = (pageIndex * pageSize) + currentLength;
const totalItems = total || 0;
return (
<FormattedMessage
id='admin.system_users_list.pagination'
defaultMessage='Showing {firstPage} - {lastPage} of {totalItems} users'
values={{
firstPage,
lastPage,
totalItems,
}}
/>
);
}
export function getDefaultSelectedValueFromList<T extends {value: string}>(value: string, options: T[]) {
const option = options.find((option) => option.value === value);
if (option) {
return option;
}
return options[0];
}
export function getDefaultSelectedTeam(teamId: Team['id'] | string, label?: string): TeamFilterOptionType {
if (!teamId || teamId === TeamFilters.AllTeams) {
return {
value: TeamFilters.AllTeams,
label: (
<FormattedMessage
id='admin.system_users.filters.team.allTeams'
defaultMessage='All Teams'
/>
),
};
} else if (teamId === TeamFilters.NoTeams) {
return {
value: TeamFilters.NoTeams,
label: (
<FormattedMessage
id='admin.system_users.filters.team.noTeams'
defaultMessage='No Teams'
/>
),
};
}
return {
value: teamId,
label: label || '',
};
}
export function getRoleFilterOption(role?: string): Pick<UserReportOptions, 'role_filter'> {
if (!role || role === RoleFilters.Any) {
return {role_filter: undefined};
}
return {role_filter: role};
}
export function getSearchFilterOption(search?: string): Pick<UserReportOptions, 'search_term'> {
if (!search || search.trim().length === 0) {
return {search_term: undefined};
}
return {search_term: search};
}

Просмотреть файл

@@ -0,0 +1,3 @@
.modal-confirm .modal-footer .checkbox {
margin-right: auto;
}

Просмотреть файл

@@ -5,6 +5,8 @@ import React from 'react';
import {Modal} from 'react-bootstrap';
import {FormattedMessage} from 'react-intl';
import './confirm_modal.scss';
type Props = {
/*
@@ -52,6 +54,11 @@ type Props = {
*/
checkboxText?: React.ReactNode;
/*
* If true, show the checkbox in the footer instead of the modal body
*/
checkboxInFooter?: boolean;
/*
* Function called when the confirm button or ENTER is pressed. Passes `true` if the checkbox is checked
*/
@@ -179,9 +186,10 @@ export default class ConfirmModal extends React.Component<Props, State> {
</Modal.Header>
<Modal.Body id='confirmModalBody'>
{this.props.message}
{checkbox}
{!this.props.checkboxInFooter && checkbox}
</Modal.Body>
<Modal.Footer>
{this.props.checkboxInFooter && checkbox}
{cancelButton}
<button
autoFocus={true}

Просмотреть файл

@@ -1,8 +1,6 @@
$dropdown_input_index: 999999;
.DropdownInput {
z-index: $dropdown_input_index;
&.Input_container {
margin-top: 20px;
}

Просмотреть файл

@@ -47,6 +47,14 @@ const baseStyles = {
...provided,
display: 'none',
}),
menu: (provided: CSSProperties) => ({
...provided,
zIndex: 100,
}),
menuPortal: (provided: CSSProperties) => ({
...provided,
zIndex: 100,
}),
};
const IndicatorsContainer = (props: any) => {

Просмотреть файл

@@ -11,7 +11,6 @@ import type {
ReactNode,
MouseEvent,
KeyboardEvent,
SyntheticEvent,
} from 'react';
import {useDispatch, useSelector} from 'react-redux';
@@ -42,6 +41,7 @@ type MenuButtonProps = {
'aria-label'?: string;
disabled?: boolean;
class?: string;
as?: keyof JSX.IntrinsicElements;
children: ReactNode;
}
@@ -137,7 +137,7 @@ export function Menu(props: Props) {
}
}
function handleMenuButtonClick(event: SyntheticEvent<HTMLButtonElement>) {
function handleMenuButtonClick(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
@@ -157,7 +157,7 @@ export function Menu(props: Props) {
}),
);
} else {
setAnchorElement(event.currentTarget);
setAnchorElement(event.currentTarget as HTMLElement);
}
}
@@ -168,8 +168,10 @@ export function Menu(props: Props) {
// We construct the menu button so we can set onClick correctly here to support both web and mobile view
function renderMenuButton() {
const MenuButtonComponent = props.menuButton?.as ?? 'button';
const triggerElement = (
<button
<MenuButtonComponent
id={props.menuButton.id}
data-testid={props.menuButton.dateTestId}
aria-controls={props.menu.id}
@@ -182,7 +184,7 @@ export function Menu(props: Props) {
onMouseDown={handleMenuButtonMouseDown}
>
{props.menuButton.children}
</button>
</MenuButtonComponent>
);
if (props.menuButtonTooltip && props.menuButtonTooltip.text && !isMobileView) {

Просмотреть файл

@@ -13,7 +13,9 @@ import type {
ReactElement,
ReactNode,
KeyboardEvent,
MouseEvent} from 'react';
MouseEvent,
AriaRole,
} from 'react';
import {useSelector} from 'react-redux';
import {getIsMobileView} from 'selectors/views/browser';
@@ -79,6 +81,8 @@ export interface Props extends MuiMenuItemProps {
onClick?: (event: MouseEvent<HTMLLIElement> | KeyboardEvent<HTMLLIElement>) => void;
role?: AriaRole;
/**
* ONLY to support submenus. Avoid passing children to this component. Support for children is only added to support submenus.
*/
@@ -112,6 +116,7 @@ export function MenuItem(props: Props) {
isLabelsRowLayout,
children,
onClick,
role = 'menuitem',
...otherProps
} = props;
@@ -122,19 +127,26 @@ export function MenuItem(props: Props) {
function handleClick(event: MouseEvent<HTMLLIElement> | KeyboardEvent<HTMLLIElement>) {
if (isCorrectKeyPressedOnMenuItem(event)) {
// close submenu first if it is open
if (subMenuContext.close) {
subMenuContext.close();
}
// If the menu item is a checkbox or radio button, we don't want to close the menu when it is clicked.
// see https://www.w3.org/WAI/ARIA/apg/patterns/menubar/
if (isRoleCheckboxOrRadio(role)) {
event.stopPropagation();
} else {
// close submenu first if it is open
if (subMenuContext.close) {
subMenuContext.close();
}
// And then close the menu
if (menuContext.close) {
menuContext.close();
// And then close the menu
if (menuContext.close) {
menuContext.close();
}
}
if (onClick) {
if (isMobileView) {
// If the menu is in mobile view, we execute the click event immediately.
// If the menu is in mobile view, we execute the click event immediately.
// If the menu item is a checkbox or radio button, we execute the click event immediately.
if (isMobileView || isRoleCheckboxOrRadio(role)) {
onClick(event);
} else {
// Clone the event since we delay the click handler until after the menu has closed.
@@ -160,6 +172,7 @@ export function MenuItem(props: Props) {
isLabelsRowLayout={isLabelsRowLayout}
onClick={handleClick}
onKeyDown={handleClick}
role={role}
{...otherProps}
>
{leadingElement && <div className='leading-element'>{leadingElement}</div>}
@@ -176,7 +189,7 @@ interface MenuItemStyledProps extends MuiMenuItemProps {
isLabelsRowLayout?: boolean;
}
const MenuItemStyled = styled(MuiMenuItem, {
export const MenuItemStyled = styled(MuiMenuItem, {
shouldForwardProp: (prop) => prop !== 'isDestructive' &&
prop !== 'hasSecondaryLabel' && prop !== 'isLabelsRowLayout',
})<MenuItemStyledProps>(
@@ -307,3 +320,8 @@ function isCorrectKeyPressedOnMenuItem(event: MouseEvent<HTMLLIElement> | Keyboa
return false;
}
function isRoleCheckboxOrRadio(role: AriaRole) {
return role === 'menuitemcheckbox' || role === 'menuitemradio';
}

Просмотреть файл

@@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import styled from 'styled-components';
/**
* This is a styled div that should be used as a container for popovers.
* It has basic styling common to all popovers containers, however, it can be extended to add more styling.
*/
export const StyledPopoverContainer = styled.div`
border-radius: 4px;
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
background-color: var(--center-channel-bg);
box-shadow: var(--elevation-4);
z-index: 9999;
`;

Просмотреть файл

@@ -19,7 +19,6 @@
.Input {
position: relative;
z-index: 2;
left: 0;
width: 100%;
padding: 0;
@@ -121,7 +120,6 @@
.Input_fieldset {
position: relative;
z-index: 3;
display: flex;
width: 100%;
box-sizing: border-box;
@@ -170,7 +168,7 @@
.Input_legend {
position: absolute;
z-index: 4;
z-index: 1;
top: -8px;
display: flex;
width: auto;

Просмотреть файл

@@ -2427,19 +2427,84 @@
"admin.support.termsTitle": "Terms of Use Link:",
"admin.system_roles_feature_discovery.copy": "Use System Roles to give designated users read and/or write access to select sections of System Console.",
"admin.system_roles_feature_discovery.title": "Provide controlled access to the System Console with Mattermost Enterprise",
"admin.system_users.active": "Activated",
"admin.system_users.allUsers": "All Users",
"admin.system_users.guest": "Guest",
"admin.system_users.inactive": "Deactivated",
"admin.system_users_list.pagination": "Showing {firstPage} - {lastPage} of {totalItems} users",
"admin.system_users_list.pagination.no_users": "0 users",
"admin.system_users.column_toggler.dropdownAriaLabel": "Columns visibility menu",
"admin.system_users.column_toggler.menuButtonAriaLabel": "Open menu to select columns to display",
"admin.system_users.column_toggler.menuButtonText": "{selectedCount} selected",
"admin.system_users.column_toggler.mysql_unavailable.desc": "Please use the export functionality to view these values",
"admin.system_users.column_toggler.mysql_unavailable.label": "Not available",
"admin.system_users.column_toggler.mysql_unavailable.title": "Not available for servers using MySQL",
"admin.system_users.column_toggler.placeholder": "Columns",
"admin.system_users.date_range_selector.date_range.all_time": "All time",
"admin.system_users.date_range_selector.date_range.last_30_days": "Last 30 days",
"admin.system_users.date_range_selector.date_range.last_6_months": "Last 6 months",
"admin.system_users.date_range_selector.date_range.previous_month": "Previous month",
"admin.system_users.date_range_selector.date_range.sublabel": "{startDate} - {endDate}",
"admin.system_users.date_range_selector.dropdownAriaLabel": "Date range menu",
"admin.system_users.date_range_selector.label": "Duration",
"admin.system_users.date_range_selector.menuButtonAriaLabel": "Open menu to select columns to display",
"admin.system_users.date_range_selector.trailing_message": "Note: This filter will only affect values in the <strong>Last Post, Days Active, and Messages Posted</strong> columns.",
"admin.system_users.exportButton": "Export",
"admin.system_users.exportButton.notLicensed.hint": "This feature is available on the professional plan",
"admin.system_users.exportButton.notLicensed.title": "Professional feature",
"admin.system_users.filters.role.any": "Any",
"admin.system_users.filters.role.system_admin": "System Admin",
"admin.system_users.filters.role.system_guest": "Guest",
"admin.system_users.filters.role.system_user": "Member",
"admin.system_users.filters.role.title": "Role",
"admin.system_users.filters.status.active": "Active users",
"admin.system_users.filters.status.any": "Any",
"admin.system_users.filters.status.deactive": "Deactivated users",
"admin.system_users.filters.status.title": "Status",
"admin.system_users.filters.team.allTeams": "All Teams",
"admin.system_users.filters.team.errorLoading": "Error while loading teams",
"admin.system_users.filters.team.errorSearching": "Error while searching teams",
"admin.system_users.filters.team.noTeams": "No Teams",
"admin.system_users.filters.team.title": "Team",
"admin.system_users.filtersMenu": "Filters ({count})",
"admin.system_users.filtersPopover.apply": "Apply",
"admin.system_users.filtersPopover.title": "Filter by",
"admin.system_users.list.actions": "Actions",
"admin.system_users.list.actions.deactivated": "Deactivated",
"admin.system_users.list.actions.menu.activate": "Activate",
"admin.system_users.list.actions.menu.deactivate": "Deactivate",
"admin.system_users.list.actions.menu.demoteToGuest": "Demote to guest",
"admin.system_users.list.actions.menu.dropdownAriaLabel": "User actions menu",
"admin.system_users.list.actions.menu.manageRoles": "Manage roles",
"admin.system_users.list.actions.menu.manageTeams": "Manage teams",
"admin.system_users.list.actions.menu.manageTokens": "Manage tokens",
"admin.system_users.list.actions.menu.promoteToMember": "Promote to member",
"admin.system_users.list.actions.menu.removeMFA": "Remove MFA",
"admin.system_users.list.actions.menu.removeSessions": "Remove sessions",
"admin.system_users.list.actions.menu.resetPassword": "Reset password",
"admin.system_users.list.actions.menu.resyncUserViaLdapGroups": "Re-sync user via LDAP groups",
"admin.system_users.list.actions.menu.switchToEmailPassword": "Switch to Email/Password",
"admin.system_users.list.actions.menu.updateEmail": "Update email",
"admin.system_users.list.actions.userAdmin": "System Admin",
"admin.system_users.list.actions.userGuest": "Guest",
"admin.system_users.list.actions.userMember": "Member",
"admin.system_users.list.caption": "System Users",
"admin.system_users.list.daysActive": "Days active",
"admin.system_users.list.email": "Email",
"admin.system_users.list.lastActivity": "Last activity",
"admin.system_users.list.lastLoginAt": "Last login",
"admin.system_users.list.lastPost": "Last post",
"admin.system_users.list.memberSince": "Member since",
"admin.system_users.list.memberSince.days": "{days} days",
"admin.system_users.list.memberSince.today": "Today",
"admin.system_users.list.memberSince.yesterday": "Yesterday",
"admin.system_users.noTeams": "No Teams",
"admin.system_users.list.totalPosts": "Messages posted",
"admin.system_users.list.userDetails": "User details",
"admin.system_users.mysql_stats.desc": "Use of MySQL may limit the availability of some statistics features. We recommend transitioning from MySQL to PostgreSQL to fully leverage improved performance and comprehensive analytics. While youre still using MySQL, please use the export functionality to view all user statistics.",
"admin.system_users.mysql_stats.dismiss": "Dismiss",
"admin.system_users.mysql_stats.learn_more": "Learn more",
"admin.system_users.mysql_stats.title": "Some statistics are unavailable for servers using MySQL",
"admin.system_users.revoke_all_sessions_button": "Revoke All Sessions",
"admin.system_users.revoke_all_sessions_modal_message": "This action revokes all sessions in the system. All users will be logged out from all devices, including your session. Are you sure you want to revoke all sessions?",
"admin.system_users.revoke_all_sessions_modal_title": "Revoke all sessions in the system",
"admin.system_users.revokeAllSessions": "Revoke All Sessions",
"admin.system_users.system_admin": "System Admin",
"admin.system_users.search.placeholder": "Search users",
"admin.system_users.title": "{siteName} Users",
"admin.systemUserDetail.teamList.header.name": "Name",
"admin.systemUserDetail.teamList.header.role": "Role",
@@ -2606,46 +2671,35 @@
"admin.user_grid.shared_member": "Shared Member",
"admin.user_grid.system_admin": "System Admin",
"admin.user_grid.team_admin": "Team Admin",
"admin.user_item.authServiceEmail": "**Sign-in Method:** Email",
"admin.user_item.authServiceNotEmail": "**Sign-in Method:** {service}",
"admin.user_item.createGroupTeamChannelMemberships": "Re-sync user via LDAP groups",
"admin.user_item.demoteToGuest": "Demote to Guest",
"admin.user_item.deactivate": "Deactivate",
"admin.user_item.emailTitle": "**Email:** {email}",
"admin.user_item.guest": "Guest",
"admin.user_item.inactive": "Deactivated",
"admin.user_item.invalidEmail": "Invalid email address",
"admin.user_item.makeActive": "Activate",
"admin.user_item.makeInactive": "Deactivate",
"admin.user_item.makeMember": "Make Team Member",
"admin.user_item.makeTeamAdmin": "Make Team Admin",
"admin.user_item.managedByLdap": "Managed by LDAP",
"admin.user_item.manageRoles": "Manage Roles",
"admin.user_item.manageTeams": "Manage Teams",
"admin.user_item.manageTokens": "Manage Tokens",
"admin.user_item.member": "Member",
"admin.user_item.menuAriaLabel": "User Actions Menu",
"admin.user_item.mfaNo": "**MFA**: No",
"admin.user_item.mfaYes": "**MFA**: Yes",
"admin.user_item.promoteToMember": "Promote to Member",
"admin.user_item.remoteUser": "Remote user",
"admin.user_item.resetEmail": "Update Email",
"admin.user_item.resetMfa": "Remove MFA",
"admin.user_item.resetPwd": "Reset Password",
"admin.user_item.revokeSessions": "Revoke Sessions",
"admin.user_item.switchToEmail": "Switch to Email/Password",
"admin.user_item.sysAdmin": "System Admin",
"admin.user_item.teamAdmin": "Team Admin",
"admin.user_item.teamMember": "Team Member",
"admin.user_item.user_id": "**User ID:** {userID}",
"admin.user_item.userAccessTokenPostAll": "(can create post:all personal access tokens)",
"admin.user_item.userAccessTokenPostAllPublic": "(can create post:channels personal access tokens)",
"admin.user_item.userAccessTokenYes": "(can create personal access tokens)",
"admin.user_item.userActivateFailed": "Failed to activate user",
"admin.user_item.userDeactivateFailed": "Failed to deactivate user",
"admin.user_item.userMFARemoveFailed": "Failed to remove user's MFA",
"admin.user_item.userNotFound": "Cannot load User",
"admin.user_item.userUpdateFailed": "Failed to update user",
"admin.userManagement.userDetail.addTeam": "Add Team",
"admin.userManagement.userDetail.authenticationMethod": "Authentication Method",
"admin.userManagement.userDetail.email": "Email",
"admin.userManagement.userDetail.mfa": "MFA",
"admin.userManagement.userDetail.notFound": "User not found",
"admin.userManagement.userDetail.teamsSubtitle": "Teams to which this user belongs",
"admin.userManagement.userDetail.teamsTitle": "Team Membership",
"admin.userManagement.userDetail.userId": "User ID:",
"admin.userManagement.userDetail.userId": "User ID: {userId}",
"admin.userManagement.userDetail.username": "Username",
"admin.viewArchivedChannelsHelpText": "When true, allows users to view, share and search for content of channels that have been archived. Users can only view the content in channels of which they were a member before the channel was archived.",
"admin.viewArchivedChannelsTitle": "Allow users to view archived channels:",
@@ -2659,9 +2713,11 @@
"admin.webserverModeUncompressed": "Uncompressed",
"admin.webserverModeUncompressedDescription": "The Mattermost server will serve static files uncompressed.",
"adminConsole.list.table.exactTime.at": "at",
"adminConsole.list.table.genericError": "There was an error loading the data, please try again",
"adminConsole.list.table.genericLoading": "Loading",
"adminConsole.list.table.genericNoData": "No data",
"adminConsole.list.table.pagination.next": "Go to next page",
"adminConsole.list.table.pagination.previous": "Go to previous page",
"adminConsole.list.table.pinnedColumn": "This column is pinned",
"adminConsole.list.table.rowCount.label": "Show {count} rows per page",
"adminConsole.list.table.rowsCount.(show)rowsPerPage": "Show",
"adminConsole.list.table.rowsCount.10": "10",
@@ -3260,7 +3316,7 @@
"create_comment.commentTitle": "Comment",
"create_group_memberships_modal.cancel": "No",
"create_group_memberships_modal.create": "Yes",
"create_group_memberships_modal.desc": "This will add (or re-add) {username} to all of the available teams and channels based on their group memberships. You can revert this change at any time.",
"create_group_memberships_modal.desc": "You're about to add or re-add {username} to teams and channels based on their LDAP group membership. You can revert this change at any time.",
"create_group_memberships_modal.title": "Re-add {username} to teams and channels",
"create_post.comment": "Comment",
"create_post.deactivated": "You are viewing an archived channel with a **deactivated user**. New messages cannot be posted.",
@@ -3339,8 +3395,8 @@
"datetime.yesterday": "yesterday",
"deactivate_member_modal.deactivate": "Deactivate",
"deactivate_member_modal.desc": "This action deactivates {username}. They will be logged out and not have access to any teams or channels on this system.\n",
"deactivate_member_modal.desc.confirm": " Are you sure you want to deactivate {username}?",
"deactivate_member_modal.desc.for_users_with_bot_accounts1": "This action deactivates {username}.",
"deactivate_member_modal.desc.confirm": "Are you sure you want to deactivate {username}?",
"deactivate_member_modal.desc.for_users_with_bot_accounts1": "This action deactivates {username}",
"deactivate_member_modal.desc.for_users_with_bot_accounts2": "They will be logged out and not have access to any teams or channels on this system.",
"deactivate_member_modal.desc.for_users_with_bot_accounts3": "Bot accounts they manage will be disabled along with their integrations. To enable them again, go to <linkBots>Integrations > Bot Accounts</linkBots>. <linkDocumentation>Learn more about bot accounts</linkDocumentation>.",
"deactivate_member_modal.sso_warning": "You must also deactivate this user in the SSO provider or they will be reactivated on next login or sync.",
@@ -3528,6 +3584,17 @@
"error.plugin_not_found.title": "Plugin Not Found",
"error.team_not_found.message": "The team you're requesting is private or does not exist. Please contact your Administrator for an invitation.",
"error.team_not_found.title": "Team Not Found",
"export_error_modal.desc": "Were not able to initiate an export of this data at the moment. Please wait a few minutes and try again.",
"export_error_modal.inProgress.desc": "You've already started an export of this data. Please wait a few more minutes to access the CSV file or to generate the report again.",
"export_error_modal.inProgress.title": "Export is in progress",
"export_error_modal.title": "Export could not be initiated",
"export_user_data_modal.dange_range.all_time": "You're about to export user data for all time. When the export is ready, a CSV file will be sent to you in a Mattermost direct message. This export will take a few minutes.",
"export_user_data_modal.dange_range.last_30_days": "You're about to export user data for the last 30 days. When the export is ready, a CSV file will be sent to you in a Mattermost direct message. This export will take a few minutes.",
"export_user_data_modal.dange_range.last_6_months": "You're about to export user data for the last 6 months. When the export is ready, a CSV file will be sent to you in a Mattermost direct message. This export will take a few minutes.",
"export_user_data_modal.dange_range.previous_month": "You're about to export user data for the previous month. When the export is ready, a CSV file will be sent to you in a Mattermost direct message. This export will take a few minutes.",
"export_user_data_modal.do_not_show": "Do not show this again",
"export_user_data_modal.export_data": "Export data",
"export_user_data_modal.title": "Export user data",
"feature_restricted_modal.agreement": "By selecting <highlight>Try free for {trialLength} days</highlight>, I agree to the <linkEvaluation>Mattermost Software Evaluation Agreement</linkEvaluation>, <linkPrivacy>Privacy Policy</linkPrivacy>, and receiving product emails.",
"feature_restricted_modal.button.notify": "Notify admin",
"feature_restricted_modal.button.plans": "View plans",
@@ -3575,8 +3642,6 @@
"filtered_user_list.next": "Next",
"filtered_user_list.prev": "Previous",
"filtered_user_list.search": "Search users",
"filtered_user_list.team": "Team:",
"filtered_user_list.userStatus": "User Status:",
"FIVE_HUNDRED_TO_1000": "501-1000",
"flag_post.flag": "Save Message",
"flag_post.unflag": "Remove from Saved",
@@ -5105,9 +5170,6 @@
"system_notice.title": "Notice from Mattermost",
"system_notice.title.gm_as_dm": "Updates to Group Messages",
"system_noticy.body.gm_as_dm": "You will now be notified for all activity in your group messages along with a notification badge for every new message.{br}{br}You can configure this in notification preferences for each group message.",
"system_users_list.count": "{count, number} {count, plural, one {user} other {users}}",
"system_users_list.countPage": "{startCount, number} - {endCount, number} {count, plural, one {user} other {users}} of {total, number} total",
"system_users_list.countSearch": "{count, number} {count, plural, one {user} other {users}} of {total, number} total",
"tag.default.beta": "BETA",
"tag.default.bot": "BOT",
"tag.default.guest": "GUEST",
@@ -5248,6 +5310,9 @@
"update_outgoing_webhook.confirm": "Edit Outgoing Webhook",
"update_outgoing_webhook.question": "Your changes may break the existing outgoing webhook. Are you sure you would like to update it?",
"update_outgoing_webhook.update": "Update",
"upgrade_export_data_modal.desc": "Export detailed data reports with ease and analyse user statistics conveniently. Upgrade to the Professional plan to gain access to data export.",
"upgrade_export_data_modal.title": "Upgrade to export data reports",
"upgrade_export_data_modal.view_plans": "View Plans",
"upgradeLink.warn.upgrade_now": "Upgrade now",
"upload_overlay.info": "Drop a file to upload it.",
"upsell_advantages.more": "And more...",

Просмотреть файл

@@ -77,6 +77,11 @@ const Preferences = {
CATEGORY_DRAFTS: 'drafts',
DRAFTS_TOUR_TIP_SHOWED: 'drafts_tour_tip_showed',
CATEGORY_REPORTING: 'reporting',
HIDE_BATCH_EXPORT_CONFIRM_MODAL: 'hide_batch_export_confirm_modal',
HIDE_MYSQL_STATS_NOTIFICATION: 'hide_mysql_stats_notifcation',
CATEGORY_THEME: 'theme',
THEMES: {
denim: {

Просмотреть файл

@@ -4,17 +4,21 @@
import type {AnyAction} from 'redux';
import {combineReducers} from 'redux';
import {CursorPaginationDirection} from '@mattermost/types/reports';
import {UserTypes} from 'mattermost-redux/action_types';
import {ActionTypes} from 'utils/constants';
const initialState = {
import type {AdminConsoleUserManagementTableProperties} from 'types/store/views';
const navigationBlockInitialState = {
blocked: false,
onNavigationConfirmed: null,
showNavigationPrompt: false,
};
function navigationBlock(state = initialState, action: AnyAction) {
function navigationBlock(state = navigationBlockInitialState, action: AnyAction) {
switch (action.type) {
case ActionTypes.SET_NAVIGATION_BLOCKED:
return {...state, blocked: action.blocked};
@@ -39,7 +43,7 @@ function navigationBlock(state = initialState, action: AnyAction) {
};
case UserTypes.LOGOUT_SUCCESS:
return initialState;
return navigationBlockInitialState;
default:
return state;
}
@@ -54,7 +58,36 @@ export function needsLoggedInLimitReachedCheck(state = false, action: AnyAction)
}
}
export const adminConsoleUserManagementTablePropertiesInitialState: AdminConsoleUserManagementTableProperties = {
sortColumn: '',
sortIsDescending: false,
pageSize: 0,
pageIndex: 0,
cursorDirection: CursorPaginationDirection.next,
cursorUserId: '',
cursorColumnValue: '',
columnVisibility: {},
searchTerm: '',
filterTeam: '',
filterTeamLabel: '',
filterStatus: '',
filterRole: '',
};
export function adminConsoleUserManagementTableProperties(state = adminConsoleUserManagementTablePropertiesInitialState, action: AnyAction) {
switch (action.type) {
case ActionTypes.SET_ADMIN_CONSOLE_USER_MANAGEMENT_TABLE_PROPERTIES: {
return {...state, ...action.data};
}
case ActionTypes.CLEAR_ADMIN_CONSOLE_USER_MANAGEMENT_TABLE_PROPERTIES:
return adminConsoleUserManagementTablePropertiesInitialState;
default:
return state;
}
}
export default combineReducers({
navigationBlock,
needsLoggedInLimitReachedCheck,
adminConsoleUserManagementTableProperties,
});

Просмотреть файл

@@ -11,7 +11,6 @@ describe('Reducers.Search', () => {
popoverSearch: '',
channelMembersRhsSearch: '',
modalFilters: {},
systemUsersSearch: {},
userGridSearch: {},
teamListSearch: '',
channelListSearch: {},
@@ -21,7 +20,6 @@ describe('Reducers.Search', () => {
const nextState = searchReducer(
{
modalSearch: '',
systemUsersSearch: {},
},
{},
);

Просмотреть файл

@@ -63,19 +63,6 @@ function modalFilters(state: ViewsState['search']['modalFilters'] = {}, action:
}
}
function systemUsersSearch(state: Partial<ViewsState['search']['systemUsersSearch']> = {}, action: AnyAction) {
switch (action.type) {
case SearchTypes.SET_SYSTEM_USERS_SEARCH: {
return action.data;
}
case UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
function userGridSearch(state: Partial<ViewsState['search']['userGridSearch']> = {}, action: AnyAction) {
switch (action.type) {
case SearchTypes.SET_USER_GRID_SEARCH: {
@@ -142,7 +129,6 @@ export default combineReducers({
popoverSearch,
channelMembersRhsSearch,
modalFilters,
systemUsersSearch,
userGridSearch,
teamListSearch,
channelListSearch,

Просмотреть файл

@@ -111,3 +111,17 @@ input::-webkit-file-upload-button {
.input-group-addon {
color: var(--center-channel-color);
}
// This class is used to make the input field look like a dropdown to be used in menus
.inputWithMenu {
cursor: pointer;
input.form-control {
width: 100px;
cursor: pointer;
&:read-only {
background-color: $white;
}
}
}

Просмотреть файл

@@ -39,8 +39,18 @@
flex-direction: column;
}
& > .admin-console__container {
overflow: auto;
.admin-console__filters-rows {
display: flex;
flex-wrap: wrap;
align-items: center;
padding-block-end: 12px;
padding-block-start: 24px;
padding-inline-end: 24px;
padding-inline-start: 24px;
row-gap: 12px;
}
.admin-console__container {
background-color: var(--sys-center-channel-bg);
box-shadow: 0 2px 3px 0 alpha-color($black, 0.08);
}
@@ -143,7 +153,9 @@
&:focus:not(.Input) {
border-color: #66afe9;
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.075), 0 0 8px rgba(102, 175, 233, 0.6);
box-shadow:
inset 0 1px 1px rgba(0, 0, 0, 0.075),
0 0 8px rgba(102, 175, 233, 0.6);
outline: 0;
}
@@ -692,7 +704,7 @@
font-size: 13px;
}
>svg {
> svg {
vertical-align: bottom;
}
}
@@ -875,9 +887,13 @@
}
.system-users__filter-row {
position: sticky;
left: 0;
display: flex;
margin-right: 15px;
margin-left: 15px;
padding-block-end: 12px;
padding-block-start: 24px;
padding-inline-end: 24px;
padding-inline-start: 24px;
.system-users__filter {
flex: 1 1 140px;
@@ -1023,7 +1039,7 @@
border-width: 5px 5px 2.5px;
border-style: solid;
border-color: #999 transparent transparent;
content: '';
content: "";
pointer-events: none;
}
}

Просмотреть файл

@@ -18,3 +18,7 @@ export function getOnNavigationConfirmed(state: GlobalState) {
export function getNeedsLoggedInLimitReachedCheck(state: GlobalState): boolean {
return state.views.admin.needsLoggedInLimitReachedCheck;
}
export function getAdminConsoleUserManagementTableProperties(state: GlobalState) {
return state.views.admin.adminConsoleUserManagementTableProperties;
}

Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше