[MM-55999] Bring the revoke sessions button to the admin header and improve the styling of the header (#25590)

This commit is contained in:
M-ZubairAhmed 2023-12-04 16:29:17 +00:00 committed by GitHub
parent a09c040a12
commit 5a4dba8809
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 138 additions and 187 deletions

View File

@ -16,39 +16,47 @@ import {getAdminAccount} from '../../../support/env';
describe('System Console > User Management > Users', () => {
const admin = getAdminAccount();
it('MM-T940 Users - Revoke all sessions', () => {
it('MM-T940 Users - Revoke all sessions from a button in admin console', () => {
// # Login as System Admin
cy.apiAdminLogin();
cy.visit('/admin_console/user_management/users');
// * Verify the presence of Revoke All Sessions button
cy.get('#revoke-all-users').should('be.visible').and('not.have.class', 'btn-danger').click();
// * Verify the presence of Revoke All Sessions button and click on it
cy.findByText('Revoke All Sessions').should('be.visible').click();
// * Verify the confirmation message when users clicks on the Revoke All Sessions button
cy.get('#confirmModalLabel').should('be.visible').and('have.text', 'Revoke all sessions in the system');
cy.get('.modal-body').should('be.visible').and('have.text', 'This action revokes all sessions in the system. All users will be logged out from all devices. Are you sure you want to revoke all sessions?');
cy.get('#confirmModalButton').should('be.visible').and('have.class', 'btn-danger');
cy.get('#confirmModal').should('be.visible').within(() => {
// * Verify the presence of confirmation messages and buttons
cy.findByText('Revoke all sessions in the system').should('be.visible');
cy.findByText('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?').should('be.visible');
cy.findByText('Cancel').should('be.visible');
cy.findByText('Revoke All Sessions').should('be.visible');
// # Click on Cancel button in the confirmation message
cy.get('#cancelModalButton').click();
// # Click on Cancel button in the confirmation message
cy.findByText('Cancel').click();
});
// * Verify if Confirmation message is closed
cy.get('#confirmModal').should('not.exist');
// * Verify if the Admin's session is still active and user is still in the same page
// * Since we have cancelled the confirmation message, verify if the Admin's session is still active and user is still in the same page
cy.url().should('contain', '/admin_console/user_management/users');
// * Verify if the Admin's Session is still active and click on it and then confirm
cy.get('#revoke-all-users').should('be.visible').click();
cy.get('#confirmModalButton').click();
// # Open revoke all sessions modal again
cy.findByText('Revoke All Sessions').should('be.visible').click();
cy.get('#confirmModal').should('be.visible').within(() => {
// # Click on Revoke All Sessions button in the confirmation message
cy.findByText('Revoke All Sessions').click();
});
// * Verify if Admin User's session is expired and is redirected to login page
cy.url({timeout: TIMEOUTS.HALF_MIN}).should('include', '/login');
cy.get('.login-body-card', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible');
cy.findByText('Log in to your account').should('be.visible');
});
it('Verify for Regular Member', () => {
it('MM-T940-1 Users - Revoke all sessions with an API call', () => {
// # Login as System Admin
cy.apiAdminLogin();
@ -65,7 +73,7 @@ describe('System Console > User Management > Users', () => {
// * Verify if the regular member is logged out and redirected to login page
cy.url({timeout: TIMEOUTS.HALF_MIN}).should('include', '/login');
cy.get('.login-body-card', {timeout: TIMEOUTS.HALF_MIN}).should('be.visible');
cy.findByText('Log in to your account').should('be.visible');
});
});
});

View File

@ -540,7 +540,6 @@ const AdminDefinition: AdminDefinitionType = {
searchableStrings: [
['admin.system_users.title', {siteName: ''}],
],
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.USERS)),
isHidden: it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.USERS)),
schema: {
id: 'SystemUsers',
@ -549,7 +548,6 @@ const AdminDefinition: AdminDefinitionType = {
},
system_user_detail: {
url: 'user_management/user/:user_id',
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.USERS)),
isHidden: it.not(it.userHasReadPermissionOnResource(RESOURCE_KEYS.USER_MANAGEMENT.USERS)),
schema: {
id: 'SystemUserDetail',

View File

@ -14,6 +14,7 @@ exports[`components/admin_console/system_users should match default snapshot 1`]
}
}
/>
<RevokeSessionsButton />
</AdminHeader>
<div
className="admin-console__wrapper"
@ -41,56 +42,6 @@ exports[`components/admin_console/system_users should match default snapshot 1`]
usersPerPage={50}
/>
</div>
<Connect(SystemPermissionGate)
permissions={
Array [
"revoke_user_access_token",
]
}
>
<ConfirmModal
confirmButtonClass="btn btn-danger"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Revoke All Sessions"
id="admin.system_users.revoke_all_sessions_button"
/>
}
message={
<div>
<FormattedMarkdownMessage
defaultMessage="This action revokes all sessions in the system. All users will be logged out from all devices. Are you sure you want to revoke all sessions?"
id="admin.system_users.revoke_all_sessions_modal_message"
/>
</div>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Revoke all sessions in the system"
id="admin.system_users.revoke_all_sessions_modal_title"
/>
}
/>
<div
className="pt-3 pb-3"
>
<button
className="btn btn-tertiary"
id="revoke-all-users"
onClick={[Function]}
type="button"
>
<MemoizedFormattedMessage
defaultMessage="Revoke All Sessions"
id="admin.system_users.revokeAllSessions"
/>
</button>
</div>
</Connect(SystemPermissionGate)>
</div>
</div>
</div>

View File

@ -16,7 +16,6 @@ import {
getUserAccessToken,
getProfiles,
searchProfiles,
revokeSessionsForAllUsers,
getFilteredUsersStats,
} from 'mattermost-redux/actions/users';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
@ -106,7 +105,6 @@ function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
loadProfilesWithoutTeam,
getProfiles,
searchProfiles,
revokeSessionsForAllUsers,
logError,
getFilteredUsersStats,
}, dispatch),

View File

@ -12,7 +12,6 @@ exports[`components/admin_console/system_users/list should match default snapsho
"doPasswordReset": [Function],
"enableUserAccessTokens": false,
"experimentalEnableAuthenticationTransfer": false,
"isDisabled": false,
"mfaEnabled": false,
}
}
@ -96,7 +95,6 @@ exports[`components/admin_console/system_users/list should match default snapsho
"doPasswordReset": [Function],
"enableUserAccessTokens": false,
"experimentalEnableAuthenticationTransfer": false,
"isDisabled": false,
"mfaEnabled": false,
}
}
@ -299,7 +297,6 @@ exports[`components/admin_console/system_users/list should match default snapsho
"doPasswordReset": [Function],
"enableUserAccessTokens": false,
"experimentalEnableAuthenticationTransfer": false,
"isDisabled": false,
"mfaEnabled": true,
}
}

View File

@ -37,7 +37,6 @@ type Props = {
filter: string;
term: string;
onTermChange: (term: string) => void;
isDisabled?: boolean;
/**
* Whether MFA is licensed and enabled.
@ -345,7 +344,6 @@ export default class SystemUsersList extends React.PureComponent<Props, State> {
doManageTeams: this.doManageTeams,
doManageRoles: this.doManageRoles,
doManageTokens: this.doManageTokens,
isDisabled: this.props.isDisabled,
}}
nextPage={this.nextPage}
previousPage={this.previousPage}

View File

@ -0,0 +1,77 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch} from 'react-redux';
import type {ServerError} from '@mattermost/types/errors';
import {revokeSessionsForAllUsers} from 'mattermost-redux/actions/users';
import {Permissions} from 'mattermost-redux/constants';
import type {ActionResult} from 'mattermost-redux/types/actions';
import {emitUserLoggedOutEvent} from 'actions/global_actions';
import ConfirmModal from 'components/confirm_modal';
import SystemPermissionGate from 'components/permissions_gates/system_permission_gate';
function RevokeSessionsButton() {
const dispatch = useDispatch();
const [showModal, setShowModal] = useState(false);
function handleModalToggle() {
setShowModal((showModal) => !showModal);
}
async function handleModalConfirm() {
const {data} = await dispatch(revokeSessionsForAllUsers()) as ActionResult<boolean, ServerError>;
if (data) {
emitUserLoggedOutEvent();
} else {
setShowModal(false);
}
}
return (
<SystemPermissionGate permissions={[Permissions.REVOKE_USER_ACCESS_TOKEN]}>
<button
className='btn btn-tertiary btn-danger'
onClick={handleModalToggle}
>
<FormattedMessage
id='admin.system_users.revokeAllSessions'
defaultMessage='Revoke All Sessions'
/>
</button>
<ConfirmModal
show={showModal}
title={
<FormattedMessage
id='admin.system_users.revoke_all_sessions_modal_title'
defaultMessage='Revoke all sessions in the system'
/>
}
message={
<FormattedMessage
id='admin.system_users.revoke_all_sessions_modal_message'
defaultMessage='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?'
/>
}
confirmButtonClass='btn btn-danger'
confirmButtonText={
<FormattedMessage
id='admin.system_users.revoke_all_sessions_button'
defaultMessage='Revoke All Sessions'
/>
}
onConfirm={handleModalConfirm}
onCancel={handleModalToggle}
/>
</SystemPermissionGate>
);
}
export default RevokeSessionsButton;

View File

@ -36,7 +36,6 @@ describe('components/admin_console/system_users', () => {
loadProfilesWithoutTeam: jest.fn().mockResolvedValue({data: true}),
getProfiles: jest.fn().mockResolvedValue({data: []}),
searchProfiles: jest.fn().mockResolvedValue({data: []}),
revokeSessionsForAllUsers: jest.fn().mockResolvedValue({data: true}),
logError: jest.fn(),
getFilteredUsersStats: jest.fn(),
},

View File

@ -1,8 +1,7 @@
// 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 React, {type ChangeEvent} from 'react';
import {FormattedMessage, type IntlShape, injectIntl} from 'react-intl';
import type {ServerError} from '@mattermost/types/errors';
@ -10,21 +9,15 @@ import type {Team} from '@mattermost/types/teams';
import type {GetFilteredUsersStatsOpts, UserProfile, UsersStats} from '@mattermost/types/users';
import {debounce} from 'mattermost-redux/actions/helpers';
import {Permissions} from 'mattermost-redux/constants';
import type {ActionFunc} from 'mattermost-redux/types/actions';
import {emitUserLoggedOutEvent} from 'actions/global_actions';
import ConfirmModal from 'components/confirm_modal';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import SystemPermissionGate from 'components/permissions_gates/system_permission_gate';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import {Constants, UserSearchOptions, SearchUserTeamFilter, UserFilters} from 'utils/constants';
import {getUserOptionsFromFilter, searchUserOptionsFromFilter} from 'utils/filter_users';
import * as Utils from 'utils/utils';
import SystemUsersList from './list';
import RevokeSessionsButton from './revoke_sessions_button';
const USER_ID_LENGTH = 26;
const USERS_PER_PAGE = 50;
@ -62,7 +55,6 @@ type Props = {
teamId: string;
filter: string;
users: Record<string, UserProfile>;
isDisabled?: boolean;
actions: {
@ -91,11 +83,6 @@ type Props = {
setSystemUsersSearch: (searchTerm: string, teamId: string, filter: string) => void;
searchProfiles: (term: string, options?: any) => Promise<any> | ActionFunc;
/**
* Function to revoke all sessions in the system
*/
revokeSessionsForAllUsers: () => any;
/**
* Function to log errors
*/
@ -110,7 +97,6 @@ type Props = {
type State = {
loading: boolean;
searching: boolean;
showRevokeAllSessionsModal: boolean;
term?: string;
};
@ -121,7 +107,6 @@ export class SystemUsers extends React.PureComponent<Props, State> {
this.state = {
loading: true,
searching: false,
showRevokeAllSessionsModal: false,
};
}
@ -182,20 +167,6 @@ export class SystemUsers extends React.PureComponent<Props, State> {
handleTermChange = (term: string) => {
this.props.actions.setSystemUsersSearch(term, this.props.teamId, this.props.filter);
};
handleRevokeAllSessions = async () => {
const {data} = await this.props.actions.revokeSessionsForAllUsers();
if (data) {
emitUserLoggedOutEvent();
} else {
this.props.actions.logError({type: 'critical', message: 'Can\'t revoke all sessions'});
}
};
handleRevokeAllSessionsCancel = () => {
this.setState({showRevokeAllSessionsModal: false});
};
handleShowRevokeAllSessionsModal = () => {
this.setState({showRevokeAllSessionsModal: true});
};
nextPage = async (page: number) => {
const {teamId, filter} = this.props;
@ -267,44 +238,6 @@ export class SystemUsers extends React.PureComponent<Props, State> {
this.getUserById(id);
};
renderRevokeAllUsersModal = () => {
const title = (
<FormattedMessage
id='admin.system_users.revoke_all_sessions_modal_title'
defaultMessage='Revoke all sessions in the system'
/>
);
const message = (
<div>
<FormattedMarkdownMessage
id='admin.system_users.revoke_all_sessions_modal_message'
defaultMessage='This action revokes all sessions in the system. All users will be logged out from all devices. Are you sure you want to revoke all sessions?'
/>
</div>
);
const confirmButtonClass = 'btn btn-danger';
const revokeAllButton = (
<FormattedMessage
id='admin.system_users.revoke_all_sessions_button'
defaultMessage='Revoke All Sessions'
/>
);
return (
<ConfirmModal
show={this.state.showRevokeAllSessionsModal}
title={title}
message={message}
confirmButtonClass={confirmButtonClass}
confirmButtonText={revokeAllButton}
onConfirm={this.handleRevokeAllSessions}
onCancel={this.handleRevokeAllSessionsCancel}
/>
);
};
renderFilterRow = (doSearch: ((event: React.FormEvent<HTMLInputElement>) => void) | undefined) => {
const teams = this.props.teams.map((team) => (
<option
@ -337,8 +270,8 @@ export class SystemUsers extends React.PureComponent<Props, State> {
onChange={this.handleTeamChange}
value={this.props.teamId}
>
<option value={SearchUserTeamFilter.ALL_USERS}>{Utils.localizeMessage('admin.system_users.allUsers', 'All Users')}</option>
<option value={SearchUserTeamFilter.NO_TEAM}>{Utils.localizeMessage('admin.system_users.noTeams', 'No Teams')}</option>
<option value={SearchUserTeamFilter.ALL_USERS}>{this.props.intl.formatMessage({id: 'admin.system_users.allUsers', defaultMessage: 'All Users'})}</option>
<option value={SearchUserTeamFilter.NO_TEAM}>{this.props.intl.formatMessage({id: 'admin.system_users.noTeams', defaultMessage: 'No Teams'})}</option>
{teams}
</select>
</label>
@ -355,11 +288,11 @@ export class SystemUsers extends React.PureComponent<Props, State> {
value={this.props.filter}
onChange={this.handleFilterChange}
>
<option value=''>{Utils.localizeMessage('admin.system_users.allUsers', 'All Users')}</option>
<option value={UserFilters.SYSTEM_ADMIN}>{Utils.localizeMessage('admin.system_users.system_admin', 'System Admin')}</option>
<option value={UserFilters.SYSTEM_GUEST}>{Utils.localizeMessage('admin.system_users.guest', 'Guest')}</option>
<option value={UserFilters.ACTIVE}>{Utils.localizeMessage('admin.system_users.active', 'Active')}</option>
<option value={UserFilters.INACTIVE}>{Utils.localizeMessage('admin.system_users.inactive', 'Inactive')}</option>
<option value=''>{this.props.intl.formatMessage({id: 'admin.system_users.allUsers', defaultMessage: 'All Users'})}</option>
<option value={UserFilters.SYSTEM_ADMIN}>{this.props.intl.formatMessage({id: 'admin.system_users.system_admin', defaultMessage: 'System Admin'})}</option>
<option value={UserFilters.SYSTEM_GUEST}>{this.props.intl.formatMessage({id: 'admin.system_users.guest', defaultMessage: 'Guest'})}</option>
<option value={UserFilters.ACTIVE}>{this.props.intl.formatMessage({id: 'admin.system_users.active', defaultMessage: 'Active'})}</option>
<option value={UserFilters.INACTIVE}>{this.props.intl.formatMessage({id: 'admin.system_users.inactive', defaultMessage: 'Inactive'})}</option>
</select>
</label>
</div>
@ -367,8 +300,6 @@ export class SystemUsers extends React.PureComponent<Props, State> {
};
render() {
const revokeAllUsersModal = this.renderRevokeAllUsersModal();
return (
<div className='wrapper--fixed'>
<AdminHeader>
@ -379,6 +310,7 @@ export class SystemUsers extends React.PureComponent<Props, State> {
siteName: this.props.siteName,
}}
/>
<RevokeSessionsButton/>
</AdminHeader>
<div className='admin-console__wrapper'>
<div className='admin-console__content'>
@ -398,26 +330,8 @@ export class SystemUsers extends React.PureComponent<Props, State> {
mfaEnabled={this.props.mfaEnabled}
enableUserAccessTokens={this.props.enableUserAccessTokens}
experimentalEnableAuthenticationTransfer={this.props.experimentalEnableAuthenticationTransfer}
isDisabled={this.props.isDisabled}
/>
</div>
<SystemPermissionGate permissions={[Permissions.REVOKE_USER_ACCESS_TOKEN]}>
{revokeAllUsersModal}
<div className='pt-3 pb-3'>
<button
id='revoke-all-users'
type='button'
className='btn btn-tertiary'
onClick={() => this.handleShowRevokeAllSessionsModal()}
disabled={this.props.isDisabled}
>
<FormattedMessage
id='admin.system_users.revokeAllSessions'
defaultMessage='Revoke All Sessions'
/>
</button>
</div>
</SystemPermissionGate>
</div>
</div>
</div>

View File

@ -39,7 +39,6 @@ describe('components/admin_console/system_users/system_users_dropdown/system_use
currentUser: otherUser,
index: 0,
totalUsers: 10,
isDisabled: false,
actions: {
updateUserActive: jest.fn().mockResolvedValue({data: true}),
revokeAllSessionsForUser: jest.fn().mockResolvedValue({data: true}),

View File

@ -42,7 +42,6 @@ export type Props = {
config: DeepPartial<AdminConfig>;
bots: Record<string, Bot>;
isLicensed: boolean;
isDisabled: boolean;
actions: {
updateUserActive: (id: string, active: boolean) => Promise<{error: ServerError}>;
revokeAllSessionsForUser: (id: string) => Promise<{error: ServerError; data: any}>;
@ -575,13 +574,8 @@ export default class SystemUsersDropdown extends React.PureComponent<Props, Stat
render() {
const {currentUser, user, isLicensed, config} = this.props;
let isDisabled = this.props.isDisabled;
if (!isDisabled) {
// if not already disabled,
// disable if SystemAdmin being edited by non SystemAdmin
// ie, userManager with EditOtherUsers permissions
isDisabled = UserUtils.isSystemAdmin(user.roles) && !UserUtils.isSystemAdmin(currentUser.roles);
}
// 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) {

View File

@ -33,7 +33,7 @@ type Props = {
doManageTeams: (user: UserProfile) => void;
doManageRoles: (user: UserProfile) => void;
doManageTokens: (user: UserProfile) => void;
isDisabled: boolean | undefined;
isDisabled?: boolean;
};
actionUserProps?: {
[userId: string]: {

View File

@ -2423,7 +2423,7 @@
"admin.system_users.inactive": "Inactive",
"admin.system_users.noTeams": "No Teams",
"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. Are you sure you want to 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",

View File

@ -711,19 +711,21 @@ export function revokeAllSessionsForUser(userId: string): ActionFunc {
};
}
export function revokeSessionsForAllUsers(): ActionFunc {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
export function revokeSessionsForAllUsers(): ActionFunc<boolean, ServerError> {
return async (dispatch, getState) => {
try {
await Client4.revokeSessionsForAllUsers();
} catch (error) {
forceLogoutIfNecessary(error, dispatch, getState);
dispatch(logError(error));
return {error};
return {error: error as ServerError};
}
dispatch({
type: UserTypes.REVOKE_SESSIONS_FOR_ALL_USERS_SUCCESS,
data: null,
});
return {data: true};
};
}

View File

@ -270,6 +270,22 @@ button {
color: rgba(var(--center-channel-color-rgb), 0.32);
opacity: 1;
}
&.btn-danger {
background-color: rgba(var(--error-text-color-rgb), 0.08);
color: var(--error-text);
&:hover {
background-color: rgba(var(--error-text-color-rgb), 0.12);
color: var(--error-text);
}
&:active,
&:focus {
background-color: rgba(var(--error-text-color-rgb), 0.16);
color: var(--error-text);
}
}
}
&.btn-quaternary {

View File

@ -406,16 +406,16 @@
z-index: 100;
display: flex;
height: 65px;
-webkit-flex: 0 0 65px;
flex: 0 0 65px;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 10px 20px;
padding: 12px 20px;
border-bottom: 1px solid alpha-color($black, 0.1);
background: white;
font-size: 22px;
font-weight: normal;
line-height: 32px;
&.with-back {
padding: 0;