Sysadmin manage user settings (#27583)

* Opened modal from system console

* WIP

* WIP

* WIP

* Handled saving user

* Successfully updated user based settings

* WIP

* WIP

* All settings are updating well

* Fixed modal style

* Added admin mode indicators in modal

* Added confirmation dialog

* Lint fixes

* Added license check

* Added permission check

* Fixed i18n file order

* type fix

* Updated snapshots

* Handled performance debugging setting

* Some styling tweaks

* Fixed text alighnment

* Updated license required from professional to enterprise

* Handled long user names

* review fixes

* Added manage setting option in user list page context menu

* Added loader

* Minor reordering

* Removed confirm modal

* Updated snapshots for removed modal

* Added some tests

* Lint fix

* Used new selector in user detail page

* Used new selector in user list page

* Updated tests

* Fixed an incorrect default test
This commit is contained in:
Harshil Sharma 2024-07-12 10:22:04 +05:30 committed by GitHub
parent 0df1a62f61
commit 87d983cc7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
59 changed files with 1629 additions and 200 deletions

View File

@ -21,7 +21,7 @@ func (api *API) InitReports() {
}
func getUsersForReporting(c *Context, w http.ResponseWriter, r *http.Request) {
if !(c.IsSystemAdmin()) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementUsers) {
c.SetPermissionError(model.PermissionSysconsoleReadUserManagementUsers)
return
}
@ -52,7 +52,7 @@ func getUsersForReporting(c *Context, w http.ResponseWriter, r *http.Request) {
}
func getUserCountForReporting(c *Context, w http.ResponseWriter, r *http.Request) {
if !(c.IsSystemAdmin()) {
if !c.App.SessionHasPermissionTo(*c.AppContext.Session(), model.PermissionSysconsoleReadUserManagementUsers) {
c.SetPermissionError(model.PermissionSysconsoleReadUserManagementUsers)
return
}

View File

@ -864,7 +864,7 @@ func (a *App) SetDefaultProfileImage(c request.CTX, user *model.User) *model.App
}
options := a.Config().GetSanitizeOptions()
updatedUser.SanitizeProfile(options)
updatedUser.SanitizeProfile(options, false)
message := model.NewWebSocketEvent(model.WebsocketEventUserUpdated, "", "", "", nil, "")
message.Add("user", updatedUser)
@ -1117,7 +1117,7 @@ func (a *App) GetSanitizeOptions(asAdmin bool) map[string]bool {
func (a *App) SanitizeProfile(user *model.User, asAdmin bool) {
options := a.ch.srv.userService.GetSanitizeOptions(asAdmin)
user.SanitizeProfile(options)
user.SanitizeProfile(options, asAdmin)
}
func (a *App) UpdateUserAsUser(c request.CTX, user *model.User, asAdmin bool) (*model.User, *model.AppError) {
@ -2558,7 +2558,7 @@ func (a *App) invalidateUserCacheAndPublish(rctx request.CTX, userID string) {
}
options := a.Config().GetSanitizeOptions()
user.SanitizeProfile(options)
user.SanitizeProfile(options, false)
message := model.NewWebSocketEvent(model.WebsocketEventUserUpdated, "", "", "", nil, "")
message.Add("user", user)

View File

@ -42,7 +42,7 @@ func (us *UserService) sanitizeProfiles(users []*model.User, asAdmin bool) []*mo
func (us *UserService) SanitizeProfile(user *model.User, asAdmin bool) {
options := us.GetSanitizeOptions(asAdmin)
user.SanitizeProfile(options)
user.SanitizeProfile(options, asAdmin)
}
func (us *UserService) GetSanitizeOptions(asAdmin bool) map[string]bool {

View File

@ -1144,7 +1144,7 @@ func (s *SqlPostStore) prepareThreadedResponse(posts []*postWithExtra, extended,
return nil, err
}
for _, user := range users {
user.SanitizeProfile(sanitizeOptions)
user.SanitizeProfile(sanitizeOptions, false)
usersMap[user.Id] = user
}
} else {

View File

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

View File

@ -695,19 +695,22 @@ func (u *User) SanitizeInput(isAdmin bool) {
u.Email = strings.TrimSpace(u.Email)
}
func (u *User) ClearNonProfileFields() {
func (u *User) ClearNonProfileFields(asAdmin bool) {
u.Password = ""
u.AuthData = NewString("")
u.MfaSecret = ""
u.EmailVerified = false
u.AllowMarketing = false
u.NotifyProps = StringMap{}
u.LastPasswordUpdate = 0
u.FailedAttempts = 0
if !asAdmin {
u.NotifyProps = StringMap{}
}
}
func (u *User) SanitizeProfile(options map[string]bool) {
u.ClearNonProfileFields()
func (u *User) SanitizeProfile(options map[string]bool, asAdmin bool) {
u.ClearNonProfileFields(asAdmin)
u.Sanitize(options)
}

View File

@ -551,12 +551,12 @@ func TestSanitizeProfile(t *testing.T) {
Props: StringMap{UserPropsKeyRemoteEmail: "remote@doe.com"},
}
user.SanitizeProfile(nil)
user.SanitizeProfile(nil, false)
require.Equal(t, "john@doe.com", user.Email)
require.Equal(t, "remote@doe.com", user.Props[UserPropsKeyRemoteEmail])
user.SanitizeProfile(map[string]bool{"email": false})
user.SanitizeProfile(map[string]bool{"email": false}, false)
require.Empty(t, user.Email)
require.Empty(t, user.Props[UserPropsKeyRemoteEmail])

View File

@ -17,7 +17,7 @@
height: 92px;
flex-direction: row;
align-items: flex-start;
padding: 30px 20px 12px 30px;
padding: 0 20px 0 30px;
background-color: #295eb9;
}
@ -31,18 +31,23 @@
}
.AdminUserCard__footer {
display: flex;
flex-direction: row;
padding: 20px;
border-top: solid 1px rgba(0, 0, 0, 0.2);
background-color: #fff;
}
.AdminUserCard__user-info {
overflow: hidden;
min-width: 0;
align-self: flex-end;
padding: 0;
margin-left: 20px;
color: #fff;
font-size: 20px;
font-weight: normal;
text-overflow: ellipsis;
}
.AdminUserCard__user-nickname {

View File

@ -411,3 +411,630 @@ exports[`SystemUserDetail should match snapshot if MFA is enabled 1`] = `
/>
</div>
`;
exports[`SystemUserDetail should not show manage user settings button when user doesnt have permission 1`] = `
<div
className="SystemUserDetail wrapper--fixed"
>
<AdminHeader
withBackButton={true}
>
<div>
<Connect(Component)
className="fa fa-angle-left back"
to="/admin_console/user_management/users"
/>
<MemoizedFormattedMessage
defaultMessage="User Configuration"
id="admin.systemUserDetail.title"
/>
</div>
</AdminHeader>
<div
className="admin-console__wrapper"
>
<div
className="admin-console__content"
>
<AdminUserCard
body={
<React.Fragment>
<span>
</span>
<label>
<Memo(MemoizedFormattedMessage)
defaultMessage="Email"
id="admin.userManagement.userDetail.email"
/>
<Memo(EmailIcon) />
<input
className="form-control"
disabled={false}
onChange={[Function]}
type="text"
value=""
/>
</label>
<label>
<Memo(MemoizedFormattedMessage)
defaultMessage="Username"
id="admin.userManagement.userDetail.username"
/>
<AtIcon />
<span />
</label>
<label>
<Memo(MemoizedFormattedMessage)
defaultMessage="Authentication Method"
id="admin.userManagement.userDetail.authenticationMethod"
/>
<Memo(ShieldOutlineIcon) />
<span>
</span>
</label>
</React.Fragment>
}
footer={
<React.Fragment>
<button
className="btn btn-secondary"
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Reset Password"
id="admin.user_item.resetPwd"
/>
</button>
<button
className="btn btn-secondary"
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Activate"
id="admin.user_item.makeActive"
/>
</button>
</React.Fragment>
}
isLoading={true}
/>
<AdminPanel
button={
<div
className="add-team-button"
>
<button
className="btn btn-primary"
disabled={true}
onClick={[Function]}
type="button"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Add Team"
id="admin.userManagement.userDetail.addTeam"
/>
</button>
</div>
}
subtitle={
Object {
"defaultMessage": "Teams to which this user belongs",
"id": "admin.userManagement.userDetail.teamsSubtitle",
}
}
title={
Object {
"defaultMessage": "Team Membership",
"id": "admin.userManagement.userDetail.teamsTitle",
}
}
>
<div
className="teamlistLoading"
>
<Memo(LoadingSpinner) />
</div>
</AdminPanel>
</div>
</div>
<div
className="admin-console-save"
>
<SaveButton
btnClass=""
defaultMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="Save"
id="save_button.save"
/>
}
disabled={true}
extraClasses=""
onClick={[Function]}
saving={false}
savingMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="Saving"
id="save_button.saving"
/>
}
/>
<div
className="error-message"
>
<FormError
error={null}
errors={Array []}
/>
</div>
</div>
<ConfirmModal
confirmButtonClass="btn btn-danger"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Deactivate"
id="deactivate_member_modal.deactivate"
/>
}
message={
<div>
<Memo(MemoizedFormattedMessage)
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}?"
id="deactivate_member_modal.desc"
values={
Object {
"username": "",
}
}
/>
<strong>
<br />
<br />
<Memo(MemoizedFormattedMessage)
defaultMessage="You must also deactivate this user in the SSO provider or they will be reactivated on next login or sync."
id="deactivate_member_modal.sso_warning"
/>
</strong>
</div>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Deactivate {username}"
id="deactivate_member_modal.title"
values={
Object {
"username": "",
}
}
/>
}
/>
</div>
`;
exports[`SystemUserDetail should show manage user settings button as activated 1`] = `
<div
className="SystemUserDetail wrapper--fixed"
>
<AdminHeader
withBackButton={true}
>
<div>
<Connect(Component)
className="fa fa-angle-left back"
to="/admin_console/user_management/users"
/>
<MemoizedFormattedMessage
defaultMessage="User Configuration"
id="admin.systemUserDetail.title"
/>
</div>
</AdminHeader>
<div
className="admin-console__wrapper"
>
<div
className="admin-console__content"
>
<AdminUserCard
body={
<React.Fragment>
<span>
</span>
<label>
<Memo(MemoizedFormattedMessage)
defaultMessage="Email"
id="admin.userManagement.userDetail.email"
/>
<Memo(EmailIcon) />
<input
className="form-control"
disabled={false}
onChange={[Function]}
type="text"
value=""
/>
</label>
<label>
<Memo(MemoizedFormattedMessage)
defaultMessage="Username"
id="admin.userManagement.userDetail.username"
/>
<AtIcon />
<span />
</label>
<label>
<Memo(MemoizedFormattedMessage)
defaultMessage="Authentication Method"
id="admin.userManagement.userDetail.authenticationMethod"
/>
<Memo(ShieldOutlineIcon) />
<span>
</span>
</label>
</React.Fragment>
}
footer={
<React.Fragment>
<button
className="btn btn-secondary"
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Reset Password"
id="admin.user_item.resetPwd"
/>
</button>
<button
className="btn btn-secondary"
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Activate"
id="admin.user_item.makeActive"
/>
</button>
<button
className="manageUserSettingsBtn btn btn-tertiary"
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Manage User Settings"
id="admin.user_item.manageSettings"
/>
</button>
</React.Fragment>
}
isLoading={true}
/>
<AdminPanel
button={
<div
className="add-team-button"
>
<button
className="btn btn-primary"
disabled={true}
onClick={[Function]}
type="button"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Add Team"
id="admin.userManagement.userDetail.addTeam"
/>
</button>
</div>
}
subtitle={
Object {
"defaultMessage": "Teams to which this user belongs",
"id": "admin.userManagement.userDetail.teamsSubtitle",
}
}
title={
Object {
"defaultMessage": "Team Membership",
"id": "admin.userManagement.userDetail.teamsTitle",
}
}
>
<div
className="teamlistLoading"
>
<Memo(LoadingSpinner) />
</div>
</AdminPanel>
</div>
</div>
<div
className="admin-console-save"
>
<SaveButton
btnClass=""
defaultMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="Save"
id="save_button.save"
/>
}
disabled={true}
extraClasses=""
onClick={[Function]}
saving={false}
savingMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="Saving"
id="save_button.saving"
/>
}
/>
<div
className="error-message"
>
<FormError
error={null}
errors={Array []}
/>
</div>
</div>
<ConfirmModal
confirmButtonClass="btn btn-danger"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Deactivate"
id="deactivate_member_modal.deactivate"
/>
}
message={
<div>
<Memo(MemoizedFormattedMessage)
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}?"
id="deactivate_member_modal.desc"
values={
Object {
"username": "",
}
}
/>
<strong>
<br />
<br />
<Memo(MemoizedFormattedMessage)
defaultMessage="You must also deactivate this user in the SSO provider or they will be reactivated on next login or sync."
id="deactivate_member_modal.sso_warning"
/>
</strong>
</div>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Deactivate {username}"
id="deactivate_member_modal.title"
values={
Object {
"username": "",
}
}
/>
}
/>
</div>
`;
exports[`SystemUserDetail should show manage user settings button as disabled when no license 1`] = `
<div
className="SystemUserDetail wrapper--fixed"
>
<AdminHeader
withBackButton={true}
>
<div>
<Connect(Component)
className="fa fa-angle-left back"
to="/admin_console/user_management/users"
/>
<MemoizedFormattedMessage
defaultMessage="User Configuration"
id="admin.systemUserDetail.title"
/>
</div>
</AdminHeader>
<div
className="admin-console__wrapper"
>
<div
className="admin-console__content"
>
<AdminUserCard
body={
<React.Fragment>
<span>
</span>
<label>
<Memo(MemoizedFormattedMessage)
defaultMessage="Email"
id="admin.userManagement.userDetail.email"
/>
<Memo(EmailIcon) />
<input
className="form-control"
disabled={false}
onChange={[Function]}
type="text"
value=""
/>
</label>
<label>
<Memo(MemoizedFormattedMessage)
defaultMessage="Username"
id="admin.userManagement.userDetail.username"
/>
<AtIcon />
<span />
</label>
<label>
<Memo(MemoizedFormattedMessage)
defaultMessage="Authentication Method"
id="admin.userManagement.userDetail.authenticationMethod"
/>
<Memo(ShieldOutlineIcon) />
<span>
</span>
</label>
</React.Fragment>
}
footer={
<React.Fragment>
<button
className="btn btn-secondary"
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Reset Password"
id="admin.user_item.resetPwd"
/>
</button>
<button
className="btn btn-secondary"
onClick={[Function]}
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Activate"
id="admin.user_item.makeActive"
/>
</button>
</React.Fragment>
}
isLoading={true}
/>
<AdminPanel
button={
<div
className="add-team-button"
>
<button
className="btn btn-primary"
disabled={true}
onClick={[Function]}
type="button"
>
<Memo(MemoizedFormattedMessage)
defaultMessage="Add Team"
id="admin.userManagement.userDetail.addTeam"
/>
</button>
</div>
}
subtitle={
Object {
"defaultMessage": "Teams to which this user belongs",
"id": "admin.userManagement.userDetail.teamsSubtitle",
}
}
title={
Object {
"defaultMessage": "Team Membership",
"id": "admin.userManagement.userDetail.teamsTitle",
}
}
>
<div
className="teamlistLoading"
>
<Memo(LoadingSpinner) />
</div>
</AdminPanel>
</div>
</div>
<div
className="admin-console-save"
>
<SaveButton
btnClass=""
defaultMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="Save"
id="save_button.save"
/>
}
disabled={true}
extraClasses=""
onClick={[Function]}
saving={false}
savingMessage={
<Memo(MemoizedFormattedMessage)
defaultMessage="Saving"
id="save_button.saving"
/>
}
/>
<div
className="error-message"
>
<FormError
error={null}
errors={Array []}
/>
</div>
</div>
<ConfirmModal
confirmButtonClass="btn btn-danger"
confirmButtonText={
<Memo(MemoizedFormattedMessage)
defaultMessage="Deactivate"
id="deactivate_member_modal.deactivate"
/>
}
message={
<div>
<Memo(MemoizedFormattedMessage)
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}?"
id="deactivate_member_modal.desc"
values={
Object {
"username": "",
}
}
/>
<strong>
<br />
<br />
<Memo(MemoizedFormattedMessage)
defaultMessage="You must also deactivate this user in the SSO provider or they will be reactivated on next login or sync."
id="deactivate_member_modal.sso_warning"
/>
</strong>
</div>
}
modalClass=""
onCancel={[Function]}
onConfirm={[Function]}
show={false}
title={
<Memo(MemoizedFormattedMessage)
defaultMessage="Deactivate {username}"
id="deactivate_member_modal.title"
values={
Object {
"username": "",
}
}
/>
}
/>
</div>
`;

View File

@ -6,20 +6,27 @@ import {connect} from 'react-redux';
import type {GlobalState} from '@mattermost/types/store';
import {getUserPreferences} from 'mattermost-redux/actions/preferences';
import {addUserToTeam} from 'mattermost-redux/actions/teams';
import {updateUserActive, getUser, patchUser, updateUserMfa} from 'mattermost-redux/actions/users';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {setNavigationBlocked} from 'actions/admin_actions.jsx';
import {openModal} from 'actions/views/modals';
import {getShowLockedManageUserSettings, getShowManageUserSettings} from 'selectors/admin_console';
import SystemUserDetail from './system_user_detail';
function mapStateToProps(state: GlobalState) {
const config = getConfig(state);
const showManageUserSettings = getShowManageUserSettings(state);
const showLockedManageUserSettings = getShowLockedManageUserSettings(state);
return {
mfaEnabled: config?.EnableMultifactorAuthentication === 'true' || false,
showManageUserSettings,
showLockedManageUserSettings,
};
}
@ -31,6 +38,7 @@ const mapDispatchToProps = {
addUserToTeam,
setNavigationBlocked,
openModal,
getUserPreferences,
};
const connector = connect(mapStateToProps, mapDispatchToProps);

View File

@ -37,4 +37,11 @@
align-items: center;
justify-content: center;
}
.AdminUserCard__footer {
.manageUserSettingsBtn {
margin-left: auto;
cursor: pointer;
}
}
}

View File

@ -16,6 +16,8 @@ import {shallowWithIntl, type MockIntl} from 'tests/helpers/intl-test-helper';
describe('SystemUserDetail', () => {
const defaultProps: Props = {
showManageUserSettings: false,
showLockedManageUserSettings: false,
mfaEnabled: false,
patchUser: jest.fn(),
updateUserMfa: jest.fn(),
@ -24,6 +26,7 @@ describe('SystemUserDetail', () => {
setNavigationBlocked: jest.fn(),
addUserToTeam: jest.fn(),
openModal: jest.fn(),
getUserPreferences: jest.fn(),
intl: {
formatMessage: jest.fn(),
} as MockIntl,
@ -50,6 +53,33 @@ describe('SystemUserDetail', () => {
const wrapper = shallowWithIntl(<SystemUserDetail {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should show manage user settings button as activated', () => {
const props = {
...defaultProps,
showManageUserSettings: true,
};
const wrapper = shallowWithIntl(<SystemUserDetail {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should show manage user settings button as disabled when no license', () => {
const props = {
...defaultProps,
showLockedManageUserSettings: false,
};
const wrapper = shallowWithIntl(<SystemUserDetail {...props}/>);
expect(wrapper).toMatchSnapshot();
});
test('should not show manage user settings button when user doesnt have permission', () => {
const props = {
...defaultProps,
showManageUserSettings: false,
};
const wrapper = shallowWithIntl(<SystemUserDetail {...props}/>);
expect(wrapper).toMatchSnapshot();
});
});
describe('getUserAuthenticationTextField', () => {

View File

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React, {PureComponent} from 'react';
import type {ChangeEvent, MouseEvent} from 'react';
import type {IntlShape, WrappedComponentProps} from 'react-intl';
@ -18,16 +19,19 @@ import AdminUserCard from 'components/admin_console/admin_user_card/admin_user_c
import BlockableLink from 'components/admin_console/blockable_link';
import ResetPasswordModal from 'components/admin_console/reset_password_modal';
import TeamList from 'components/admin_console/system_user_detail/team_list';
import {ConfirmManageUserSettingsModal} from 'components/admin_console/system_users/system_users_list_actions/confirmManageUserSettingsModal';
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 UserSettingsModal from 'components/user_settings/modal';
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 WithTooltip from 'components/with_tooltip';
import {Constants, ModalIdentifiers} from 'utils/constants';
import {toTitleCase} from 'utils/utils';
@ -284,6 +288,37 @@ export class SystemUserDetail extends PureComponent<Props, State> {
this.setState({showTeamSelectorModal: false});
};
openConfirmEditUserSettingsModal = () => {
if (!this.state.user) {
return;
}
this.props.openModal({
modalId: ModalIdentifiers.CONFIRM_MANAGE_USER_SETTINGS_MODAL,
dialogType: ConfirmManageUserSettingsModal,
dialogProps: {
user: this.state.user,
onConfirm: this.openUserSettingsModal,
},
});
};
openUserSettingsModal = async () => {
if (!this.state.user) {
return;
}
this.props.openModal({
modalId: ModalIdentifiers.USER_SETTINGS,
dialogType: UserSettingsModal,
dialogProps: {
adminMode: true,
isContentProductSettings: true,
userID: this.state.user.id,
},
});
};
render() {
return (
<div className='SystemUserDetail wrapper--fixed'>
@ -385,14 +420,61 @@ export class SystemUserDetail extends PureComponent<Props, State> {
/>
</button>
)}
{
this.props.showManageUserSettings &&
<button
className='manageUserSettingsBtn btn btn-tertiary'
onClick={this.openConfirmEditUserSettingsModal}
>
<FormattedMessage
id='admin.user_item.manageSettings'
defaultMessage='Manage User Settings'
/>
</button>
}
{
this.props.showLockedManageUserSettings &&
<WithTooltip
id='adminUserSettingUpdateDisabled'
title={defineMessage({
id: 'generic.enterprise_feature',
defaultMessage: 'Enterprise feature',
})}
hint={defineMessage({
id: 'admin.user_item.manageSettings.disabled_tooltip',
defaultMessage: 'Please upgrade to Enterprise to manage user settings',
})}
placement='top'
>
<button
className='manageUserSettingsBtn btn disabled'
>
<div className='RestrictedIndicator__content'>
<i className={classNames('RestrictedIndicator__icon-tooltip', 'icon', 'icon-key-variant')}/>
</div>
<FormattedMessage
id='admin.user_item.manageSettings'
defaultMessage='Manage User Settings'
/>
</button>
</WithTooltip>
}
</>
}
/>
{/* User's team details */}
<AdminPanel
title={defineMessage({id: 'admin.userManagement.userDetail.teamsTitle', defaultMessage: 'Team Membership'})}
subtitle={defineMessage({id: 'admin.userManagement.userDetail.teamsSubtitle', defaultMessage: 'Teams to which this user belongs'})}
title={defineMessage({
id: 'admin.userManagement.userDetail.teamsTitle',
defaultMessage: 'Team Membership',
})}
subtitle={defineMessage({
id: 'admin.userManagement.userDetail.teamsSubtitle',
defaultMessage: 'Teams to which this user belongs',
})}
button={
<div className='add-team-button'>
<button
@ -479,6 +561,7 @@ export class SystemUserDetail extends PureComponent<Props, State> {
onConfirm={this.handleDeactivateMember}
onCancel={this.toggleCloseModalDeactivateMember}
/>
{this.state.showTeamSelectorModal && (
<TeamSelectorModal
onModalDismissed={this.toggleCloseTeamSelectorModal}

View File

@ -0,0 +1,56 @@
// 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 {UserProfile} from '@mattermost/types/users';
import ConfirmModalRedux from 'components/confirm_modal_redux';
import {getDisplayName} from 'utils/utils';
type Props = {
user: UserProfile;
onConfirm: () => void;
onExited: () => void;
onHide: () => void;
}
export function ConfirmManageUserSettingsModal(props: Props) {
const title = (
<FormattedMessage
id='userSettings.adminMode.modal_header'
defaultMessage="Manage {userDisplayName}'s Settings"
values={{userDisplayName: getDisplayName(props.user)}}
/>
);
const message = (
<FormattedMessage
id='admin.user_item.manageSettings.confirm_dialog.body'
defaultMessage="You are about to access {userDisplayName}'s account settings. Any modifications you make will take effect immediately in their account. {userDisplayName} retains the ability to view and modify these settings at any time.<br></br><br></br> Are you sure you want to proceed with managing {userDisplayName}'s settings?"
values={{
userDisplayName: getDisplayName(props.user),
br: (x: React.ReactNode) => (<><br/>{x}</>),
}}
/>
);
const confirmButtonText = (
<FormattedMessage
id='admin.user_item.manageSettings'
defaultMessage='Manage User Settings'
/>
);
return (
<ConfirmModalRedux
title={title}
message={message}
confirmButtonText={confirmButtonText}
onConfirm={props.onConfirm}
onExited={props.onExited}
/>
);
}

View File

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import classNames from 'classnames';
import React from 'react';
import React, {useCallback} from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
@ -18,14 +18,19 @@ import {isSystemAdmin, isGuest} from 'mattermost-redux/utils/user_utils';
import {adminResetMfa} from 'actions/admin_actions';
import {openModal} from 'actions/views/modals';
import {getShowManageUserSettings} from 'selectors/admin_console';
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 {
ConfirmManageUserSettingsModal,
} from 'components/admin_console/system_users/system_users_list_actions/confirmManageUserSettingsModal';
import * as Menu from 'components/menu';
import SystemPermissionGate from 'components/permissions_gates/system_permission_gate';
import UserSettingsModal from 'components/user_settings/modal';
import Constants, {ModalIdentifiers} from 'utils/constants';
@ -49,6 +54,7 @@ export function SystemUsersListAction({user, currentUser, tableId, rowIndex, onE
const dispatch = useDispatch();
const config = useSelector(getConfig);
const isLicensed = useSelector(getLicense)?.IsLicensed === 'true';
const showManageUserSettings = useSelector(getShowManageUserSettings);
function getTranslatedUserRole(userRoles: UserProfile['roles']) {
if (user.delete_at > 0) {
@ -96,6 +102,18 @@ export function SystemUsersListAction({user, currentUser, tableId, rowIndex, onE
const onPromoteToMember = () => updateUser({roles: user.roles.replace(General.SYSTEM_GUEST_ROLE, '')});
const onDemoteToGuest = () => updateUser({roles: `${user.roles} ${General.SYSTEM_GUEST_ROLE}`});
const openUserSettingsModal = useCallback(() => {
dispatch(openModal({
modalId: ModalIdentifiers.USER_SETTINGS,
dialogType: UserSettingsModal,
dialogProps: {
adminMode: true,
isContentProductSettings: true,
userID: user.id,
},
}));
}, [dispatch, user.id]);
return (
<Menu.Container
menuButton={{
@ -209,6 +227,29 @@ export function SystemUsersListAction({user, currentUser, tableId, rowIndex, onE
}}
/>
{
showManageUserSettings &&
<Menu.Item
id={`${menuItemIdPrefix}-manageTeams`}
labels={
<FormattedMessage
id='admin.user_item.manageSettings'
defaultMessage='Manage User Settings'
/>
}
onClick={() => {
dispatch(openModal({
modalId: ModalIdentifiers.CONFIRM_MANAGE_USER_SETTINGS_MODAL,
dialogType: ConfirmManageUserSettingsModal,
dialogProps: {
user,
onConfirm: openUserSettingsModal,
},
}));
}}
/>
}
{config.ServiceSettings?.EnableUserAccessTokens &&
<Menu.Item
id={`${menuItemIdPrefix}-manageTokens`}

View File

@ -6,7 +6,7 @@ import React, {useCallback, useState} from 'react';
import ConfirmModal from 'components/confirm_modal';
type Props = Omit<React.ComponentProps<typeof ConfirmModal>, 'show'> & {
onExited: () => void;
onExited?: () => void;
};
export default function ConfirmModalRedux(props: Props) {

View File

@ -8,34 +8,48 @@ import type {Dispatch} from 'redux';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {updateUserActive, revokeAllSessionsForUser} from 'mattermost-redux/actions/users';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {get, getUnreadScrollPositionPreference, makeGetCategory, syncedDraftsAreAllowed} from 'mattermost-redux/selectors/entities/preferences';
import {
get,
getFromPreferences, getUnreadScrollPositionFromPreference,
getUnreadScrollPositionPreference,
makeGetCategory, makeGetUserCategory,
syncedDraftsAreAllowed,
} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {Preferences} from 'utils/constants';
import type {GlobalState} from 'types/store';
import type {OwnProps} from './user_settings_advanced';
import AdvancedSettingsDisplay from './user_settings_advanced';
function makeMapStateToProps() {
const getAdvancedSettingsCategory = makeGetCategory();
function makeMapStateToProps(state: GlobalState, props: OwnProps) {
const getAdvancedSettingsCategory = props.adminMode ? makeGetUserCategory(props.currentUser.id) : makeGetCategory();
return (state: GlobalState) => {
return (state: GlobalState, props: OwnProps) => {
const config = getConfig(state);
const enablePreviewFeatures = config.EnablePreviewFeatures === 'true';
const enableUserDeactivation = config.EnableUserDeactivation === 'true';
const enableJoinLeaveMessage = config.EnableJoinLeaveMessageByDefault === 'true';
let getPreference = (prefCategory: string, prefName: string, defaultValue: string) => get(state, prefCategory, prefName, defaultValue);
if (props.adminMode && props.userPreferences) {
// This ties the function to the current value of userPreferences for the current execution of this function
const preferences = props.userPreferences;
getPreference = (prefCategory, prefName, defaultValue) => getFromPreferences(preferences, prefCategory, prefName, defaultValue);
}
return {
advancedSettingsCategory: getAdvancedSettingsCategory(state, Preferences.CATEGORY_ADVANCED_SETTINGS),
sendOnCtrlEnter: get(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', 'false'),
codeBlockOnCtrlEnter: get(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'code_block_ctrl_enter', 'true'),
formatting: get(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', 'true'),
joinLeave: get(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'join_leave', enableJoinLeaveMessage.toString()),
syncDrafts: get(state, Preferences.CATEGORY_ADVANCED_SETTINGS, 'sync_drafts', 'true'),
currentUser: getCurrentUser(state),
unreadScrollPosition: getUnreadScrollPositionPreference(state),
sendOnCtrlEnter: getPreference(Preferences.CATEGORY_ADVANCED_SETTINGS, 'send_on_ctrl_enter', 'false'),
codeBlockOnCtrlEnter: getPreference(Preferences.CATEGORY_ADVANCED_SETTINGS, 'code_block_ctrl_enter', 'true'),
formatting: getPreference(Preferences.CATEGORY_ADVANCED_SETTINGS, 'formatting', 'true'),
joinLeave: getPreference(Preferences.CATEGORY_ADVANCED_SETTINGS, 'join_leave', enableJoinLeaveMessage.toString()),
syncDrafts: getPreference(Preferences.CATEGORY_ADVANCED_SETTINGS, 'sync_drafts', 'true'),
currentUser: props.adminMode && props.currentUser ? props.currentUser : getCurrentUser(state),
unreadScrollPosition: props.adminMode && props.userPreferences ? getUnreadScrollPositionFromPreference(props.userPreferences) : getUnreadScrollPositionPreference(state),
enablePreviewFeatures,
enableUserDeactivation,
syncedDraftsAreAllowed: syncedDraftsAreAllowed(state),

View File

@ -8,26 +8,37 @@ import type {Dispatch} from 'redux';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {Preferences} from 'mattermost-redux/constants';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences';
import {get as getPreference, getFromPreferences} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import type {GlobalState} from 'types/store';
import type {OwnProps} from './join_leave_section';
import JoinLeaveSection from './join_leave_section';
export function mapStateToProps(state: GlobalState) {
export function mapStateToProps(state: GlobalState, props: OwnProps) {
const config = getConfig(state);
const enableJoinLeaveMessage = config.EnableJoinLeaveMessageByDefault === 'true';
const joinLeave = getPreference(
state,
Preferences.CATEGORY_ADVANCED_SETTINGS,
Preferences.ADVANCED_FILTER_JOIN_LEAVE,
enableJoinLeaveMessage.toString(),
);
let joinLeave: string;
if (props.adminMode && props.userPreferences) {
joinLeave = getFromPreferences(
props.userPreferences,
Preferences.CATEGORY_ADVANCED_SETTINGS,
Preferences.ADVANCED_FILTER_JOIN_LEAVE,
enableJoinLeaveMessage.toString(),
);
} else {
joinLeave = getPreference(
state,
Preferences.CATEGORY_ADVANCED_SETTINGS,
Preferences.ADVANCED_FILTER_JOIN_LEAVE,
enableJoinLeaveMessage.toString(),
);
}
return {
currentUserId: getCurrentUserId(state),
currentUserId: props.adminMode ? props.currentUserId : getCurrentUserId(state),
joinLeave,
};
}

View File

@ -149,7 +149,7 @@ describe('mapStateToProps', () => {
} as unknown as GlobalState;
test('configuration default to true', () => {
const props = mapStateToProps(initialState);
const props = mapStateToProps(initialState, {adminMode: false, currentUserId: ''});
expect(props.joinLeave).toEqual('true');
});
@ -163,7 +163,7 @@ describe('mapStateToProps', () => {
},
},
});
const props = mapStateToProps(testState);
const props = mapStateToProps(testState, {currentUserId: '', adminMode: false});
expect(props.joinLeave).toEqual('false');
});
@ -186,7 +186,7 @@ describe('mapStateToProps', () => {
},
},
});
const props = mapStateToProps(testState);
const props = mapStateToProps(testState, {adminMode: false, currentUserId: ''});
expect(props.joinLeave).toEqual('true');
});
@ -204,7 +204,42 @@ describe('mapStateToProps', () => {
},
},
});
const props = mapStateToProps(testState);
const props = mapStateToProps(testState, {adminMode: false, currentUserId: ''});
expect(props.joinLeave).toEqual('false');
});
test('should read from preferences in props in admin mode', () => {
const testState = mergeObjects(initialState, {
entities: {
general: {
config: {
EnableJoinLeaveMessageByDefault: 'false',
},
},
},
});
const userPreferences = {
[getPreferenceKey(Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.ADVANCED_FILTER_JOIN_LEAVE)]: {
category: Preferences.CATEGORY_ADVANCED_SETTINGS,
name: Preferences.ADVANCED_FILTER_JOIN_LEAVE,
user_id: 'user_1',
value: 'true',
},
};
const propsWithAdminMode = mapStateToProps(testState, {
currentUserId: 'user_1',
adminMode: true,
userPreferences,
});
expect(propsWithAdminMode.joinLeave).toEqual('true');
const propsWithoutAdminMode = mapStateToProps(testState, {
currentUserId: 'user_1',
adminMode: false,
userPreferences,
});
expect(propsWithoutAdminMode.joinLeave).toEqual('false');
});
});

View File

@ -5,7 +5,7 @@ import React from 'react';
import type {ReactNode, RefObject} from 'react';
import {FormattedMessage} from 'react-intl';
import type {PreferenceType} from '@mattermost/types/preferences';
import type {PreferencesType, PreferenceType} from '@mattermost/types/preferences';
import {Preferences} from 'mattermost-redux/constants';
@ -16,10 +16,15 @@ import type SettingItemMinComponent from 'components/setting_item_min';
import {AdvancedSections} from 'utils/constants';
import {a11yFocus} from 'utils/utils';
type Props = {
export type OwnProps = {
adminMode?: boolean;
currentUserId: string;
userPreferences?: PreferencesType;
}
type Props = OwnProps & {
active: boolean;
areAllSectionsInactive: boolean;
currentUserId: string;
joinLeave?: string;
onUpdateSection: (section?: string) => void;
renderOnOffLabel: (label: string) => ReactNode;

View File

@ -7,20 +7,28 @@ import type {ConnectedProps} from 'react-redux';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {Preferences} from 'mattermost-redux/constants';
import {isPerformanceDebuggingEnabled} from 'mattermost-redux/selectors/entities/general';
import {getBool} from 'mattermost-redux/selectors/entities/preferences';
import {getBool, getBoolFromPreferences, getUserPreferences} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import type {GlobalState} from 'types/store';
import type {OwnProps} from './performance_debugging_section';
import PerformanceDebuggingSection from './performance_debugging_section';
function mapStateToProps(state: GlobalState) {
function mapStateToProps(state: GlobalState, props: OwnProps) {
let getPreference = (prefCategory: string, prefName: string) => getBool(state, prefCategory, prefName);
if (props.adminMode && props.currentUserId) {
const userPreferences = getUserPreferences(state, props.currentUserId);
getPreference = (prefCategory: string, prefName: string) => getBoolFromPreferences(userPreferences, prefCategory, prefName);
}
return {
currentUserId: getCurrentUserId(state),
disableClientPlugins: getBool(state, Preferences.CATEGORY_PERFORMANCE_DEBUGGING, Preferences.NAME_DISABLE_CLIENT_PLUGINS),
disableTelemetry: getBool(state, Preferences.CATEGORY_PERFORMANCE_DEBUGGING, Preferences.NAME_DISABLE_TELEMETRY),
disableTypingMessages: getBool(state, Preferences.CATEGORY_PERFORMANCE_DEBUGGING, Preferences.NAME_DISABLE_TYPING_MESSAGES),
currentUserId: props.adminMode ? props.currentUserId : getCurrentUserId(state),
disableClientPlugins: getPreference(Preferences.CATEGORY_PERFORMANCE_DEBUGGING, Preferences.NAME_DISABLE_CLIENT_PLUGINS),
disableTelemetry: getPreference(Preferences.CATEGORY_PERFORMANCE_DEBUGGING, Preferences.NAME_DISABLE_TELEMETRY),
disableTypingMessages: getPreference(Preferences.CATEGORY_PERFORMANCE_DEBUGGING, Preferences.NAME_DISABLE_TYPING_MESSAGES),
performanceDebuggingEnabled: isPerformanceDebuggingEnabled(state),
};
}

View File

@ -14,7 +14,12 @@ import {AdvancedSections} from 'utils/constants';
import type {PropsFromRedux} from './index';
type Props = PropsFromRedux & {
export type OwnProps = {
adminMode?: boolean;
currentUserId?: string;
}
type Props = PropsFromRedux & OwnProps & {
active: boolean;
areAllSectionsInactive: boolean;
onUpdateSection: (section?: string) => void;
@ -111,6 +116,10 @@ function PerformanceDebuggingSectionExpanded(props: Props) {
const [disableTypingMessages, setDisableTypingMessages] = useState(props.disableTypingMessages);
const handleSubmit = useCallback(() => {
if (!props.currentUserId) {
return;
}
const preferences = [];
if (disableClientPlugins !== props.disableClientPlugins) {
@ -138,7 +147,7 @@ function PerformanceDebuggingSectionExpanded(props: Props) {
});
}
if (preferences.length !== 0) {
if (preferences.length !== 0 && props.currentUserId) {
props.savePreferences(props.currentUserId, preferences);
}

View File

@ -7,7 +7,7 @@ import React from 'react';
import type {ReactNode} from 'react';
import {FormattedMessage, defineMessages} from 'react-intl';
import type {PreferenceType} from '@mattermost/types/preferences';
import type {PreferencesType, PreferenceType} from '@mattermost/types/preferences';
import type {UserProfile} from '@mattermost/types/users';
import type {ActionResult} from 'mattermost-redux/types/actions';
@ -40,8 +40,13 @@ type Settings = {
sync_drafts: Props['syncDrafts'];
};
export type Props = {
export type OwnProps = {
adminMode?: boolean;
currentUser: UserProfile;
userPreferences?: PreferencesType;
}
export type Props = OwnProps & {
advancedSettingsCategory: PreferenceType[];
sendOnCtrlEnter: string;
codeBlockOnCtrlEnter: string;
@ -161,6 +166,10 @@ export default class AdvancedSettingsDisplay extends React.PureComponent<Props,
};
handleSubmit = async (settings: string[]): Promise<void> => {
if (!this.props.currentUser) {
return;
}
const preferences: PreferenceType[] = [];
const {actions, currentUser} = this.props;
const userId = currentUser.id;
@ -796,7 +805,7 @@ export default class AdvancedSettingsDisplay extends React.PureComponent<Props,
let makeConfirmationModal: ReactNode = '';
const currentUser = this.props.currentUser;
if (currentUser.auth_service === '' && this.props.enableUserDeactivation) {
if (currentUser.auth_service === '' && this.props.enableUserDeactivation && !this.props.adminMode) {
const active = this.props.activeSection === 'deactivateAccount';
let max = null;
if (active) {
@ -928,6 +937,9 @@ export default class AdvancedSettingsDisplay extends React.PureComponent<Props,
areAllSectionsInactive={this.props.activeSection === ''}
onUpdateSection={this.handleUpdateSection}
renderOnOffLabel={this.renderOnOffLabel}
adminMode={this.props.adminMode}
userPreferences={this.props.userPreferences}
currentUserId={this.props.currentUser.id}
/>
{previewFeaturesSectionDivider}
{previewFeaturesSection}
@ -935,6 +947,8 @@ export default class AdvancedSettingsDisplay extends React.PureComponent<Props,
active={this.props.activeSection === AdvancedSections.PERFORMANCE_DEBUGGING}
onUpdateSection={this.handleUpdateSection}
areAllSectionsInactive={this.props.activeSection === ''}
adminMode={this.props.adminMode}
currentUserId={this.props.currentUser.id}
/>
{unreadScrollPositionSectionDivider}
{unreadScrollPositionSection}

View File

@ -319,6 +319,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
describe="English (US)"
max={
<Memo(Connect(injectIntl(ManageLanguage)))
adminMode={false}
locale="en"
updateSection={[Function]}
user={
@ -671,6 +672,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
describe="English (US)"
max={
<Memo(Connect(injectIntl(ManageLanguage)))
adminMode={false}
locale="en"
updateSection={[Function]}
user={
@ -1023,6 +1025,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
describe="English (US)"
max={
<Memo(Connect(injectIntl(ManageLanguage)))
adminMode={false}
locale="en"
updateSection={[Function]}
user={
@ -1375,6 +1378,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
describe="English (US)"
max={
<Memo(Connect(injectIntl(ManageLanguage)))
adminMode={false}
locale="en"
updateSection={[Function]}
user={
@ -1654,6 +1658,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
describe="English (US)"
max={
<Memo(Connect(injectIntl(ManageLanguage)))
adminMode={false}
locale="en"
updateSection={[Function]}
user={
@ -1915,6 +1920,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
describe="English (US)"
max={
<Memo(Connect(injectIntl(ManageLanguage)))
adminMode={false}
locale="en"
updateSection={[Function]}
user={
@ -2267,6 +2273,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
describe="English (US)"
max={
<Memo(Connect(injectIntl(ManageLanguage)))
adminMode={false}
locale="en"
updateSection={[Function]}
user={
@ -2637,6 +2644,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
describe="English (US)"
max={
<Memo(Connect(injectIntl(ManageLanguage)))
adminMode={false}
locale="en"
updateSection={[Function]}
user={
@ -2916,6 +2924,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
describe="English (US)"
max={
<Memo(Connect(injectIntl(ManageLanguage)))
adminMode={false}
locale="en"
updateSection={[Function]}
user={
@ -3195,6 +3204,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
describe="English (US)"
max={
<Memo(Connect(injectIntl(ManageLanguage)))
adminMode={false}
locale="en"
updateSection={[Function]}
user={
@ -3474,6 +3484,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
describe="English (US)"
max={
<Memo(Connect(injectIntl(ManageLanguage)))
adminMode={false}
locale="en"
updateSection={[Function]}
user={
@ -3766,6 +3777,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
describe="English (US)"
max={
<Memo(Connect(injectIntl(ManageLanguage)))
adminMode={false}
locale="en"
updateSection={[Function]}
user={
@ -4045,6 +4057,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should match snaps
describe="English (US)"
max={
<Memo(Connect(injectIntl(ManageLanguage)))
adminMode={false}
locale="en"
updateSection={[Function]}
user={
@ -4300,6 +4313,7 @@ exports[`components/user_settings/display/UserSettingsDisplay should not show la
describe="English (US)"
max={
<Memo(Connect(injectIntl(ManageLanguage)))
adminMode={false}
locale="en"
updateSection={[Function]}
user={

View File

@ -7,14 +7,23 @@ import type {Dispatch} from 'redux';
import timezones from 'timezones.json';
import {CollapsedThreads} from '@mattermost/types/config';
import type {UserProfile} from '@mattermost/types/users';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {autoUpdateTimezone} from 'mattermost-redux/actions/timezone';
import {updateMe} from 'mattermost-redux/actions/users';
import {patchUser, updateMe} from 'mattermost-redux/actions/users';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {get, isCollapsedThreadsAllowed, getCollapsedThreadsPreference} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentTimezoneFull, getCurrentTimezoneLabel} from 'mattermost-redux/selectors/entities/timezone';
import {
get,
isCollapsedThreadsAllowed,
getCollapsedThreadsPreference,
getFromPreferences,
} from 'mattermost-redux/selectors/entities/preferences';
import {
generateCurrentTimezoneLabel,
getCurrentTimezoneFull,
getCurrentTimezoneLabel,
getTimezoneForUserProfile,
} from 'mattermost-redux/selectors/entities/timezone';
import {getCurrentUserId, getUser} from 'mattermost-redux/selectors/entities/users';
import {getUserCurrentTimezone} from 'mattermost-redux/utils/timezone_utils';
@ -23,20 +32,17 @@ import {Preferences} from 'utils/constants';
import type {GlobalState} from 'types/store';
import type {OwnProps} from './user_settings_display';
import UserSettingsDisplay from './user_settings_display';
type OwnProps = {
user: UserProfile;
}
export function makeMapStateToProps() {
return (state: GlobalState, props: OwnProps) => {
const config = getConfig(state);
const currentUserId = getCurrentUserId(state);
const userTimezone = getCurrentTimezoneFull(state);
const userTimezone = props.adminMode ? getTimezoneForUserProfile(props.user) : getCurrentTimezoneFull(state);
const automaticTimezoneNotSet = userTimezone && userTimezone.useAutomaticTimezone && !userTimezone.automaticTimezone;
const shouldAutoUpdateTimezone = !userTimezone || automaticTimezoneNotSet;
const timezoneLabel = getCurrentTimezoneLabel(state);
const timezoneLabel = props.adminMode ? generateCurrentTimezoneLabel(getUserCurrentTimezone(userTimezone)) : getCurrentTimezoneLabel(state);
const allowCustomThemes = config.AllowCustomThemes === 'true';
const enableLinkPreviews = config.EnableLinkPreviews === 'true';
const enableThemeSelection = config.EnableThemeSelection === 'true';
@ -46,7 +52,8 @@ export function makeMapStateToProps() {
const lastActiveTimeEnabled = config.EnableLastActiveTime === 'true';
let lastActiveDisplay = true;
if (getUser(state, currentUserId).props?.show_last_active === 'false') {
const user = props.adminMode ? props.user : getUser(state, currentUserId);
if (user.props?.show_last_active === 'false') {
lastActiveDisplay = false;
}
@ -55,6 +62,12 @@ export function makeMapStateToProps() {
userLocale = config.DefaultClientLocale as string;
}
let getPreference = (prefCategory: string, prefName: string, defaultValue: string) => get(state, prefCategory, prefName, defaultValue);
if (props.adminMode && props.userPreferences) {
const preferences = props.userPreferences;
getPreference = (prefCategory: string, prefName: string, defaultValue: string) => getFromPreferences(preferences, prefCategory, prefName, defaultValue);
}
return {
lockTeammateNameDisplay,
allowCustomThemes,
@ -68,18 +81,18 @@ export function makeMapStateToProps() {
userTimezone,
shouldAutoUpdateTimezone,
currentUserTimezone: getUserCurrentTimezone(userTimezone) as string,
availabilityStatusOnPosts: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.AVAILABILITY_STATUS_ON_POSTS, Preferences.AVAILABILITY_STATUS_ON_POSTS_DEFAULT),
militaryTime: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, Preferences.USE_MILITARY_TIME_DEFAULT),
teammateNameDisplay: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT, configTeammateNameDisplay),
channelDisplayMode: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT),
messageDisplay: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT),
colorizeUsernames: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.COLORIZE_USERNAMES, Preferences.COLORIZE_USERNAMES_DEFAULT),
collapseDisplay: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.COLLAPSE_DISPLAY, Preferences.COLLAPSE_DISPLAY_DEFAULT),
availabilityStatusOnPosts: getPreference(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.AVAILABILITY_STATUS_ON_POSTS, Preferences.AVAILABILITY_STATUS_ON_POSTS_DEFAULT),
militaryTime: getPreference(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.USE_MILITARY_TIME, Preferences.USE_MILITARY_TIME_DEFAULT),
teammateNameDisplay: getPreference(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.NAME_NAME_FORMAT, configTeammateNameDisplay),
channelDisplayMode: getPreference(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CHANNEL_DISPLAY_MODE, Preferences.CHANNEL_DISPLAY_MODE_DEFAULT),
messageDisplay: getPreference(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.MESSAGE_DISPLAY, Preferences.MESSAGE_DISPLAY_DEFAULT),
colorizeUsernames: getPreference(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.COLORIZE_USERNAMES, Preferences.COLORIZE_USERNAMES_DEFAULT),
collapseDisplay: getPreference(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.COLLAPSE_DISPLAY, Preferences.COLLAPSE_DISPLAY_DEFAULT),
collapsedReplyThreadsAllowUserPreference: isCollapsedThreadsAllowed(state) && getConfig(state).CollapsedThreads !== CollapsedThreads.ALWAYS_ON,
collapsedReplyThreads: getCollapsedThreadsPreference(state),
clickToReply: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CLICK_TO_REPLY, Preferences.CLICK_TO_REPLY_DEFAULT),
linkPreviewDisplay: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.LINK_PREVIEW_DISPLAY, Preferences.LINK_PREVIEW_DISPLAY_DEFAULT),
oneClickReactionsOnPosts: get(state, Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.ONE_CLICK_REACTIONS_ENABLED, Preferences.ONE_CLICK_REACTIONS_ENABLED_DEFAULT),
clickToReply: getPreference(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.CLICK_TO_REPLY, Preferences.CLICK_TO_REPLY_DEFAULT),
linkPreviewDisplay: getPreference(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.LINK_PREVIEW_DISPLAY, Preferences.LINK_PREVIEW_DISPLAY_DEFAULT),
oneClickReactionsOnPosts: getPreference(Preferences.CATEGORY_DISPLAY_SETTINGS, Preferences.ONE_CLICK_REACTIONS_ENABLED, Preferences.ONE_CLICK_REACTIONS_ENABLED_DEFAULT),
emojiPickerEnabled,
lastActiveDisplay,
lastActiveTimeEnabled,
@ -93,6 +106,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
autoUpdateTimezone,
savePreferences,
updateMe,
patchUser,
}, dispatch),
};
}

View File

@ -5,7 +5,7 @@ import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {updateMe} from 'mattermost-redux/actions/users';
import {patchUser, updateMe} from 'mattermost-redux/actions/users';
import {getLanguages} from 'i18n/i18n';
@ -23,6 +23,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
updateMe,
patchUser,
}, dispatch),
};
}

View File

@ -23,6 +23,7 @@ describe('components/user_settings/display/manage_languages/manage_languages', (
updateSection: jest.fn(),
actions: {
updateMe: jest.fn(() => Promise.resolve({})),
patchUser: jest.fn(() => Promise.resolve({})),
},
};

View File

@ -20,6 +20,7 @@ import {isKeyPressed} from 'utils/keyboard';
type Actions = {
updateMe: (user: UserProfile) => Promise<ActionResult>;
patchUser: (user: UserProfile) => Promise<ActionResult>;
};
type Props = {
@ -29,6 +30,7 @@ type Props = {
locales: Record<string, Language>;
updateSection: (section: string) => void;
actions: Actions;
adminMode?: boolean;
};
type SelectedOption = {
@ -122,9 +124,10 @@ export class ManageLanguage extends React.PureComponent<Props, State> {
submitUser = (user: UserProfile) => {
this.setState({isSaving: true});
this.props.actions.updateMe(user).then((res) => {
const action = this.props.adminMode ? this.props.actions.patchUser : this.props.actions.updateMe;
action(user).then((res) => {
if ('data' in res) {
// Do nothing since changing the locale essentially refreshes the page
this.setState({isSaving: false});
} else if ('error' in res) {
let serverError;
const {error} = res;

View File

@ -8,7 +8,7 @@ import timezones from 'timezones.json';
import type {GlobalState} from '@mattermost/types/store';
import {updateMe} from 'mattermost-redux/actions/users';
import {patchUser, updateMe} from 'mattermost-redux/actions/users';
import {getCurrentTimezoneLabel} from 'mattermost-redux/selectors/entities/timezone';
import ManageTimezones from './manage_timezones';
@ -17,6 +17,7 @@ function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
updateMe,
patchUser,
}, dispatch),
};
}

View File

@ -24,6 +24,7 @@ describe('components/user_settings/display/manage_timezones/manage_timezones', (
updateSection: jest.fn(),
actions: {
updateMe: jest.fn(() => Promise.resolve({})),
patchUser: jest.fn(() => Promise.resolve({})),
},
};

View File

@ -18,6 +18,7 @@ import {getBrowserTimezone} from 'utils/timezone';
type Actions = {
updateMe: (user: UserProfile) => Promise<ActionResult>;
patchUser: (user: UserProfile) => Promise<ActionResult>;
}
type Props = {
@ -29,6 +30,7 @@ type Props = {
timezones: Timezone[];
timezoneLabel: string;
actions: Actions;
adminMode?: boolean;
}
type SelectedOption = {
value: string;
@ -97,7 +99,7 @@ export default class ManageTimezones extends React.PureComponent<Props, State> {
};
submitUser = () => {
const {user, actions} = this.props;
const {user} = this.props;
const {useAutomaticTimezone, automaticTimezone, manualTimezone} = this.state;
const timezone = {
@ -111,7 +113,8 @@ export default class ManageTimezones extends React.PureComponent<Props, State> {
timezone,
};
actions.updateMe(updatedUser).
const action = this.props.adminMode ? this.props.actions.patchUser : this.props.actions.updateMe;
action(updatedUser).
then((res) => {
if ('data' in res) {
this.props.updateSection('');

View File

@ -27,6 +27,7 @@ describe('components/user_settings/display/UserSettingsDisplay', () => {
};
const requiredProps = {
adminMode: false,
user: user as UserProfile,
updateSection: jest.fn(),
activeSection: '',
@ -72,6 +73,7 @@ describe('components/user_settings/display/UserSettingsDisplay', () => {
autoUpdateTimezone: jest.fn(),
savePreferences: jest.fn(),
updateMe: jest.fn(),
patchUser: jest.fn(),
},
configTeammateNameDisplay: '',

View File

@ -9,7 +9,7 @@ import type {MessageDescriptor} from 'react-intl';
import {FormattedMessage, defineMessage} from 'react-intl';
import type {Timezone} from 'timezones.json';
import type {PreferenceType} from '@mattermost/types/preferences';
import type {PreferencesType, PreferenceType} from '@mattermost/types/preferences';
import type {UserProfile, UserTimezone} from '@mattermost/types/users';
import type {ActionResult} from 'mattermost-redux/types/actions';
@ -20,8 +20,8 @@ import SettingItem from 'components/setting_item';
import SettingItemMax from 'components/setting_item_max';
import ThemeSetting from 'components/user_settings/display/user_settings_theme';
import type {Language} from 'i18n/i18n';
import {getLanguageInfo} from 'i18n/i18n';
import type {Language} from 'i18n/i18n';
import Constants from 'utils/constants';
import {getBrowserTimezone} from 'utils/timezone';
import {a11yFocus} from 'utils/utils';
@ -81,7 +81,13 @@ type SectionProps ={
onSubmit?: () => void;
}
type Props = {
export type OwnProps = {
user: UserProfile;
adminMode?: boolean;
userPreferences?: PreferencesType;
}
type Props = OwnProps & {
user: UserProfile;
updateSection: (section: string) => void;
activeSection?: string;
@ -120,6 +126,7 @@ type Props = {
savePreferences: (userId: string, preferences: PreferenceType[]) => void;
autoUpdateTimezone: (deviceTimezone: string) => void;
updateMe: (user: UserProfile) => Promise<ActionResult>;
patchUser: (user: UserProfile) => Promise<ActionResult>;
};
}
@ -208,7 +215,8 @@ export default class UserSettingsDisplay extends React.PureComponent<Props, Stat
},
};
actions.updateMe(updatedUser).
const action = this.props.adminMode ? actions.patchUser : actions.updateMe;
action(updatedUser).
then((res) => {
if ('data' in res) {
this.props.updateSection('');
@ -873,6 +881,7 @@ export default class UserSettingsDisplay extends React.PureComponent<Props, Stat
automaticTimezone={userTimezone.automaticTimezone}
manualTimezone={userTimezone.manualTimezone}
updateSection={this.updateSection}
adminMode={this.props.adminMode}
/>
);
}
@ -1076,6 +1085,7 @@ export default class UserSettingsDisplay extends React.PureComponent<Props, Stat
user={this.props.user}
locale={userLocale}
updateSection={this.updateSection}
adminMode={this.props.adminMode}
/>
)}
/>
@ -1088,7 +1098,7 @@ export default class UserSettingsDisplay extends React.PureComponent<Props, Stat
}
let themeSection;
if (this.props.enableThemeSelection) {
if (this.props.enableThemeSelection && !this.props.adminMode) {
themeSection = (
<div>
<ThemeSetting

View File

@ -4,6 +4,14 @@
justify-content: space-between;
.userSettingDesktopHeaderInfo {
margin-top: -20px;
span {
.btn {
height: unset;
}
}
a {
font-size: 12px;
}

View File

@ -3,6 +3,7 @@
import React from 'react';
import type {PreferencesType} from '@mattermost/types/preferences';
import type {UserProfile} from '@mattermost/types/users';
import type {PluginConfiguration} from 'types/plugins/user_settings';
@ -26,6 +27,8 @@ export type Props = {
setEnforceFocus: () => void;
setRequireConfirm: () => void;
pluginSettings: {[tabName: string]: PluginConfiguration};
userPreferences?: PreferencesType;
adminMode?: boolean;
};
export default function UserSettings(props: Props) {
@ -64,6 +67,8 @@ export default function UserSettings(props: Props) {
updateSection={props.updateSection}
closeModal={props.closeModal}
collapseModal={props.collapseModal}
adminMode={props.adminMode}
userPreferences={props.userPreferences}
/>
</div>
);
@ -78,6 +83,8 @@ export default function UserSettings(props: Props) {
collapseModal={props.collapseModal}
setEnforceFocus={props.setEnforceFocus}
setRequireConfirm={props.setRequireConfirm}
adminMode={props.adminMode}
userPreferences={props.userPreferences}
/>
</div>
);
@ -89,6 +96,9 @@ export default function UserSettings(props: Props) {
updateSection={props.updateSection}
closeModal={props.closeModal}
collapseModal={props.collapseModal}
adminMode={props.adminMode}
currentUserId={props.user.id}
userPreferences={props.userPreferences}
/>
</div>
);
@ -100,6 +110,9 @@ export default function UserSettings(props: Props) {
updateSection={props.updateSection}
closeModal={props.closeModal}
collapseModal={props.collapseModal}
adminMode={props.adminMode}
currentUser={props.user}
userPreferences={props.userPreferences}
/>
</div>
);

View File

@ -6,9 +6,11 @@ import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {sendVerificationEmail} from 'mattermost-redux/actions/users';
import {getUserPreferences} from 'mattermost-redux/actions/preferences';
import {getUser, sendVerificationEmail} from 'mattermost-redux/actions/users';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {getCurrentUser} from 'mattermost-redux/selectors/entities/users';
import {getUserPreferences as getUserPreferencesSelector} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUser, getUser as getUserSelector} from 'mattermost-redux/selectors/entities/users';
import {getPluginUserSettings} from 'selectors/plugins';
@ -18,14 +20,19 @@ import type {GlobalState} from 'types/store';
const UserSettingsModalAsync = makeAsyncComponent('UserSettingsModal', lazy(() => import('./user_settings_modal')));
function mapStateToProps(state: GlobalState) {
import type {OwnProps} from './user_settings_modal';
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const config = getConfig(state);
const sendEmailNotifications = config.SendEmailNotifications === 'true';
const requireEmailVerification = config.RequireEmailVerification === 'true';
const currentUser = ownProps.adminMode && ownProps.userID ? getUserSelector(state, ownProps.userID) : getCurrentUser(state);
return {
currentUser: getCurrentUser(state),
currentUser,
userPreferences: ownProps.adminMode && ownProps.userID ? getUserPreferencesSelector(state, ownProps.userID) : undefined,
sendEmailNotifications,
requireEmailVerification,
pluginSettings: getPluginUserSettings(state),
@ -36,6 +43,8 @@ function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
sendVerificationEmail,
getUserPreferences,
getUser,
}, dispatch),
};
}

View File

@ -4,9 +4,10 @@
import React from 'react';
import {Modal} from 'react-bootstrap';
import ReactDOM from 'react-dom';
import {injectIntl} from 'react-intl';
import {FormattedMessage, injectIntl} from 'react-intl';
import type {IntlShape} from 'react-intl';
import type {PreferencesType} from '@mattermost/types/preferences';
import type {UserProfile} from '@mattermost/types/users';
import type {ActionResult} from 'mattermost-redux/types/actions';
@ -14,20 +15,31 @@ import type {ActionResult} from 'mattermost-redux/types/actions';
import ConfirmModal from 'components/confirm_modal';
import SettingsSidebar from 'components/settings_sidebar';
import UserSettings from 'components/user_settings';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import SmartLoader from 'components/widgets/smartLoader';
import Constants from 'utils/constants';
import {cmdOrCtrlPressed, isKeyPressed} from 'utils/keyboard';
import {stopTryNotificationRing} from 'utils/notification_sounds';
import {getDisplayName} from 'utils/utils';
import type {PluginConfiguration} from 'types/plugins/user_settings';
export type Props = {
currentUser: UserProfile;
export type OwnProps = {
userID?: string;
adminMode?: boolean;
currentUser?: UserProfile;
isContentProductSettings: boolean;
userPreferences?: PreferencesType;
}
export type Props = OwnProps & {
onExited: () => void;
intl: IntlShape;
isContentProductSettings: boolean;
actions: {
sendVerificationEmail: (email: string) => Promise<ActionResult>;
getUserPreferences: (userID: string) => Promise<unknown>;
getUser: (userID: string) => Promise<unknown>;
};
pluginSettings: {[pluginId: string]: PluginConfiguration};
}
@ -39,6 +51,7 @@ type State = {
enforceFocus?: boolean;
show: boolean;
resendStatus: string;
loading: boolean;
}
class UserSettingsModal extends React.PureComponent<Props, State> {
@ -57,6 +70,7 @@ class UserSettingsModal extends React.PureComponent<Props, State> {
enforceFocus: true,
show: true,
resendStatus: '',
loading: false,
};
this.requireConfirm = false;
@ -84,6 +98,22 @@ class UserSettingsModal extends React.PureComponent<Props, State> {
componentDidMount() {
document.addEventListener('keydown', this.handleKeyDown);
if (this.props.adminMode && this.props.userID) {
this.setState({loading: true});
if (!this.props.userPreferences) {
this.props.actions.getUserPreferences(this.props.userID);
}
if (!this.props.currentUser) {
this.props.actions.getUser(this.props.userID);
}
}
if (!this.props.adminMode) {
this.setState({loading: false});
}
}
componentWillUnmount() {
@ -97,6 +127,10 @@ class UserSettingsModal extends React.PureComponent<Props, State> {
}
}
setLoadingFinished = () => {
this.setState({loading: false});
};
handleKeyDown = (e: KeyboardEvent) => {
if (cmdOrCtrlPressed(e) && e.shiftKey && isKeyPressed(e, Constants.KeyCodes.A)) {
e.preventDefault();
@ -275,17 +309,25 @@ class UserSettingsModal extends React.PureComponent<Props, State> {
render() {
const {formatMessage} = this.props.intl;
if (this.props.currentUser == null) {
return (<div/>);
}
const modalTitle = this.props.isContentProductSettings ? formatMessage({
id: 'global_header.productSettings',
defaultMessage: 'Settings',
}) : formatMessage({
id: 'user.settings.modal.title',
defaultMessage: 'Profile',
});
let modalTitle: string;
if (this.props.adminMode && this.props.currentUser) {
modalTitle = formatMessage({
id: 'userSettings.adminMode.modal_header',
defaultMessage: "{userDisplayName}'s Settings",
}, {
userDisplayName: getDisplayName(this.props.currentUser),
});
} else {
modalTitle = this.props.isContentProductSettings ? formatMessage({
id: 'global_header.productSettings',
defaultMessage: 'Settings',
}) : formatMessage({
id: 'user.settings.modal.title',
defaultMessage: 'Profile',
});
}
return (
<Modal
@ -308,37 +350,63 @@ class UserSettingsModal extends React.PureComponent<Props, State> {
>
{modalTitle}
</Modal.Title>
{
this.props.adminMode &&
<div className='adminModeBadge'>
<FormattedMessage
id='userSettings.adminMode.admin_mode_badge'
defaultMessage='Admin Mode'
/>
</div>
}
</Modal.Header>
<Modal.Body ref={this.modalBodyRef}>
<div className='settings-table'>
<div className='settings-links'>
<SettingsSidebar
tabs={this.props.isContentProductSettings ? this.getUserSettingsTabs() : this.getProfileSettingsTab()}
pluginTabs={this.props.isContentProductSettings ? this.getPluginsSettingsTab() : []}
activeTab={this.state.active_tab}
updateTab={this.updateTab}
/>
</div>
<div className='settings-content minimize-settings'>
<UserSettings
activeTab={this.state.active_tab}
activeSection={this.state.active_section}
updateSection={this.updateSection}
updateTab={this.updateTab}
closeModal={this.closeModal}
collapseModal={this.collapseModal}
setEnforceFocus={(enforceFocus?: boolean) => this.setState({enforceFocus})}
setRequireConfirm={
(requireConfirm?: boolean, customConfirmAction?: () => () => void) => {
this.requireConfirm = requireConfirm!;
this.customConfirmAction = customConfirmAction!;
{
this.props.adminMode &&
<SmartLoader
loading={this.props.adminMode && (!this.props.userPreferences || !this.props.currentUser)}
className='loadingIndicator'
onLoaded={this.setLoadingFinished}
>
<LoadingSpinner/>
</SmartLoader>
}
{
!this.state.loading && this.props.currentUser &&
<div className='settings-table'>
<div className='settings-links'>
<SettingsSidebar
tabs={this.props.isContentProductSettings ? this.getUserSettingsTabs() : this.getProfileSettingsTab()}
pluginTabs={this.props.isContentProductSettings ? this.getPluginsSettingsTab() : []}
activeTab={this.state.active_tab}
updateTab={this.updateTab}
/>
</div>
<div className='settings-content minimize-settings'>
<UserSettings
activeTab={this.state.active_tab}
activeSection={this.state.active_section}
updateSection={this.updateSection}
updateTab={this.updateTab}
closeModal={this.closeModal}
collapseModal={this.collapseModal}
setEnforceFocus={(enforceFocus?: boolean) => this.setState({enforceFocus})}
setRequireConfirm={
(requireConfirm?: boolean, customConfirmAction?: () => () => void) => {
this.requireConfirm = requireConfirm!;
this.customConfirmAction = customConfirmAction!;
}
}
}
pluginSettings={this.props.pluginSettings}
user={this.props.currentUser}
/>
pluginSettings={this.props.pluginSettings}
user={this.props.currentUser}
adminMode={this.props.adminMode}
userPreferences={this.props.userPreferences}
/>
</div>
</div>
</div>
}
</Modal.Body>
<ConfirmModal
title={formatMessage({id: 'user.settings.modal.confirmTitle', defaultMessage: 'Discard Changes?'})}
@ -346,7 +414,10 @@ class UserSettingsModal extends React.PureComponent<Props, State> {
id: 'user.settings.modal.confirmMsg',
defaultMessage: 'You have unsaved changes, are you sure you want to discard them?',
})}
confirmButtonText={formatMessage({id: 'user.settings.modal.confirmBtns', defaultMessage: 'Yes, Discard'})}
confirmButtonText={formatMessage({
id: 'user.settings.modal.confirmBtns',
defaultMessage: 'Yes, Discard',
})}
show={this.state.showConfirmModal}
onConfirm={this.handleConfirm}
onCancel={this.handleCancelConfirmation}

View File

@ -3,10 +3,13 @@
import {connect, type ConnectedProps} from 'react-redux';
import {updateMe} from 'mattermost-redux/actions/users';
import {patchUser, updateMe} from 'mattermost-redux/actions/users';
import {getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
import {
isCollapsedThreadsEnabled,
isCollapsedThreadsEnabledForUser,
} from 'mattermost-redux/selectors/entities/preferences';
import {isCallsEnabled, isCallsRingingEnabledOnServer} from 'selectors/calls';
@ -14,9 +17,11 @@ import {isEnterpriseOrCloudOrSKUStarterFree} from 'utils/license_utils';
import type {GlobalState} from 'types/store';
import type {OwnProps} from './user_settings_notifications';
import UserSettingsNotifications from './user_settings_notifications';
const mapStateToProps = (state: GlobalState) => {
const mapStateToProps = (state: GlobalState, props: OwnProps) => {
// server config, related to server configuration, not the user
const config = getConfig(state);
const sendPushNotifications = config.SendPushNotifications === 'true';
@ -30,16 +35,16 @@ const mapStateToProps = (state: GlobalState) => {
return {
sendPushNotifications,
enableAutoResponder,
isCollapsedThreadsEnabled: isCollapsedThreadsEnabled(state),
isCollapsedThreadsEnabled: props.adminMode && props.userPreferences ? isCollapsedThreadsEnabledForUser(state, props.userPreferences) : isCollapsedThreadsEnabled(state),
isCallsRingingEnabled: isCallsEnabled(state, '0.17.0') && isCallsRingingEnabledOnServer(state),
isEnterpriseOrCloudOrSKUStarterFree: isEnterpriseOrCloudOrSKUStarterFree(license, subscriptionProduct, isEnterpriseReady),
isEnterpriseReady,
};
};
const mapDispatchToProps = {
updateMe,
patchUser,
};
const connector = connect(mapStateToProps, mapDispatchToProps);

View File

@ -18,6 +18,7 @@ describe('components/user_settings/display/UserSettingsDisplay', () => {
closeModal: jest.fn(),
collapseModal: jest.fn(),
updateMe: jest.fn(() => Promise.resolve({})),
patchUser: jest.fn(() => Promise.resolve({})),
isCollapsedThreadsEnabled: true,
sendPushNotifications: false,
enableAutoResponder: false,

View File

@ -11,6 +11,7 @@ import type {Styles as ReactSelectStyles, ValueType} from 'react-select';
import CreatableReactSelect from 'react-select/creatable';
import {LightbulbOutlineIcon} from '@mattermost/compass-icons/components';
import type {PreferencesType} from '@mattermost/types/preferences';
import type {UserNotifyProps, UserProfile} from '@mattermost/types/users';
import ExternalLink from 'components/external_link';
@ -40,12 +41,14 @@ type MultiInputValue = {
value: string;
}
type OwnProps = {
export type OwnProps = {
user: UserProfile;
updateSection: (section: string) => void;
activeSection: string;
closeModal: () => void;
collapseModal: () => void;
adminMode?: boolean;
userPreferences?: PreferencesType;
}
export type Props = PropsFromRedux & OwnProps & WrappedComponentProps;
@ -284,7 +287,20 @@ class NotificationsTab extends React.PureComponent<Props, State> {
this.setState({isSaving: true});
stopTryNotificationRing();
const {data: updatedUser, error} = await this.props.updateMe({notify_props: data});
let updatedUser: UserProfile | undefined;
let error;
if (this.props.adminMode) {
const payloadUser = {...this.props.user, notify_props: data};
const response = await this.props.patchUser(payloadUser);
updatedUser = response.data;
error = response.error;
} else {
const response = await this.props.updateMe({notify_props: data});
updatedUser = response.data;
error = response.error;
}
if (updatedUser) {
this.handleUpdateSection('');
this.setState(getDefaultStateFromProps(this.props));

View File

@ -4,17 +4,18 @@
import {connect} from 'react-redux';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {getVisibleDmGmLimit} from 'mattermost-redux/selectors/entities/preferences';
import {getUserVisibleDmGmLimit, getVisibleDmGmLimit} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import type {GlobalState} from 'types/store';
import type {OwnProps} from './limit_visible_gms_dms';
import LimitVisibleGMsDMs from './limit_visible_gms_dms';
function mapStateToProps(state: GlobalState) {
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
return {
currentUserId: getCurrentUserId(state),
dmGmLimit: getVisibleDmGmLimit(state),
currentUserId: ownProps.adminMode ? ownProps.currentUserId : getCurrentUserId(state),
dmGmLimit: ownProps.adminMode && ownProps.userPreferences ? getUserVisibleDmGmLimit(ownProps.userPreferences) : getVisibleDmGmLimit(state),
};
}

View File

@ -7,7 +7,7 @@ import {FormattedMessage} from 'react-intl';
import ReactSelect from 'react-select';
import type {ValueType} from 'react-select';
import type {PreferenceType} from '@mattermost/types/preferences';
import type {PreferencesType, PreferenceType} from '@mattermost/types/preferences';
import {Preferences} from 'mattermost-redux/constants';
import type {ActionResult} from 'mattermost-redux/types/actions';
@ -23,10 +23,15 @@ type Limit = {
label: string;
};
type Props = {
export type OwnProps = {
adminMode?: boolean;
currentUserId?: string;
userPreferences?: PreferencesType;
}
type Props = OwnProps & {
active: boolean;
areAllSectionsInactive: boolean;
currentUserId: string;
savePreferences: (userId: string, preferences: PreferenceType[]) => Promise<ActionResult>;
dmGmLimit: number;
updateSection: (section: string) => void;
@ -99,6 +104,10 @@ export default class LimitVisibleGMsDMs extends React.PureComponent<Props, State
};
handleSubmit = async () => {
if (!this.props.currentUserId) {
return;
}
this.setState({isSaving: true});
await this.props.savePreferences(this.props.currentUserId, [{

View File

@ -4,17 +4,23 @@
import {connect} from 'react-redux';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {shouldShowUnreadsCategory} from 'mattermost-redux/selectors/entities/preferences';
import {getConfig} from 'mattermost-redux/selectors/entities/general';
import {
calculateUserShouldShowUnreadsCategory,
shouldShowUnreadsCategory,
} from 'mattermost-redux/selectors/entities/preferences';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import type {GlobalState} from 'types/store';
import type {OwnProps} from './show_unreads_category';
import ShowUnreadsCategory from './show_unreads_category';
function mapStateToProps(state: GlobalState) {
function mapStateToProps(state: GlobalState, props: OwnProps) {
const serverDefault = getConfig(state).ExperimentalGroupUnreadChannels;
return {
currentUserId: getCurrentUserId(state),
showUnreadsCategory: shouldShowUnreadsCategory(state),
currentUserId: props.adminMode ? props.currentUserId : getCurrentUserId(state),
showUnreadsCategory: props.adminMode && props.userPreferences ? calculateUserShouldShowUnreadsCategory(props.userPreferences, serverDefault) : shouldShowUnreadsCategory(state),
};
}

View File

@ -5,7 +5,7 @@ import React from 'react';
import type {RefObject} from 'react';
import {FormattedMessage} from 'react-intl';
import type {PreferenceType} from '@mattermost/types/preferences';
import type {PreferencesType, PreferenceType} from '@mattermost/types/preferences';
import {Preferences} from 'mattermost-redux/constants';
import type {ActionResult} from 'mattermost-redux/types/actions';
@ -16,10 +16,15 @@ import type SettingItemMinComponent from 'components/setting_item_min';
import {a11yFocus} from 'utils/utils';
type Props = {
export type OwnProps = {
adminMode?: boolean;
currentUserId?: string;
userPreferences?: PreferencesType;
}
type Props = OwnProps & {
active: boolean;
areAllSectionsInactive: boolean;
currentUserId: string;
savePreferences: (userId: string, preferences: PreferenceType[]) => Promise<ActionResult>;
showUnreadsCategory: boolean;
updateSection: (section: string) => void;
@ -75,6 +80,11 @@ export default class ShowUnreadsCategory extends React.PureComponent<Props, Stat
};
handleSubmit = async () => {
if (!this.props.currentUserId) {
// Only for type safety, won't actually happen
return;
}
this.setState({isSaving: true});
await this.props.savePreferences(this.props.currentUserId, [{

View File

@ -4,6 +4,8 @@
import React from 'react';
import {FormattedMessage} from 'react-intl';
import type {PreferencesType} from '@mattermost/types/preferences';
import LimitVisibleGMsDMs from './limit_visible_gms_dms';
import ShowUnreadsCategory from './show_unreads_category';
@ -15,6 +17,9 @@ export interface Props {
activeSection: string;
closeModal: () => void;
collapseModal: () => void;
adminMode?: boolean;
currentUserId?: string;
userPreferences?: PreferencesType;
}
export default function UserSettingsSidebar(props: Props): JSX.Element {
@ -48,12 +53,18 @@ export default function UserSettingsSidebar(props: Props): JSX.Element {
active={props.activeSection === 'showUnreadsCategory'}
updateSection={props.updateSection}
areAllSectionsInactive={props.activeSection === ''}
adminMode={props.adminMode}
currentUserId={props.currentUserId}
userPreferences={props.userPreferences}
/>
<div className='divider-dark'/>
<LimitVisibleGMsDMs
active={props.activeSection === 'limitVisibleGMsDMs'}
updateSection={props.updateSection}
areAllSectionsInactive={props.activeSection === ''}
adminMode={props.adminMode}
currentUserId={props.currentUserId}
userPreferences={props.userPreferences}
/>
<div className='divider-dark'/>
</div>

View File

@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {type ReactNode, useEffect, useState} from 'react';
const DEFAULT_MIN_LOADER_DURATION = 1500;
type Props = {
loading: boolean;
children: ReactNode;
className?: string;
onLoaded: () => void;
}
const SmartLoader = ({loading, children, className, onLoaded}: Props) => {
const [timeoutFinished, setTimeoutFinished] = useState(false);
useEffect(() => {
setTimeout(() => {
setTimeoutFinished(true);
}, DEFAULT_MIN_LOADER_DURATION);
}, []);
useEffect(() => {
if (!loading && timeoutFinished) {
onLoaded();
}
}, [loading, timeoutFinished, onLoaded]);
return loading || !timeoutFinished ? (
<div className={`SmartLoader ${className}`}>
{children}
</div>
) : null;
};
export default SmartLoader;

View File

@ -2702,6 +2702,9 @@
"admin.user_item.makeActive": "Activate",
"admin.user_item.makeMember": "Make Team Member",
"admin.user_item.makeTeamAdmin": "Make Team Admin",
"admin.user_item.manageSettings": "Manage User Settings",
"admin.user_item.manageSettings.confirm_dialog.body": "You are about to access {userDisplayName}'s account settings. Any modifications you make will take effect immediately in their account. {userDisplayName} retains the ability to view and modify these settings at any time.<br></br><br></br> Are you sure you want to proceed with managing {userDisplayName}'s settings?",
"admin.user_item.manageSettings.disabled_tooltip": "Please upgrade to Enterprise to manage user settings",
"admin.user_item.manageTeams": "Manage Teams",
"admin.user_item.member": "Member",
"admin.user_item.menuAriaLabel": "User Actions Menu",
@ -3768,6 +3771,7 @@
"generic_modal.confirm": "Confirm",
"generic.close": "Close",
"generic.done": "Done",
"generic.enterprise_feature": "Enterprise Feature",
"generic.next": "Next",
"generic.okay": "Okay",
"generic.previous": "Previous",
@ -5713,6 +5717,8 @@
"userGuideHelp.trainingResources": "Training resources",
"users_limits_announcement_bar.copyText": "User limits exceeded. Contact administrator with: {ErrorCode}",
"users_limits_announcement_bar.ctaText": "Learn More",
"userSettings.adminMode.admin_mode_badge": "Admin Mode",
"userSettings.adminMode.modal_header": "Manage {userDisplayName}'s Settings",
"userSettingsModal.pluginPreferences.header": "PLUGIN PREFERENCES",
"version_bar.new": "A new version of Mattermost is available.",
"version_bar.refresh": "Refresh the app now",

View File

@ -7,4 +7,6 @@ export default keyMirror({
RECEIVED_PREFERENCES: null,
RECEIVED_ALL_PREFERENCES: null,
DELETED_PREFERENCES: null,
RECEIVED_USER_PREFERENCES: null,
RECEIVED_USER_ALL_PREFERENCES: null,
});

View File

@ -48,6 +48,14 @@ export function getMyPreferences() {
});
}
// used for fetching some other user's preferences other than current user
export function getUserPreferences(userID: string) {
return bindClientFunc({
clientFunc: () => Client4.getUserPreferences(userID),
onSuccess: PreferenceTypes.RECEIVED_USER_ALL_PREFERENCES,
});
}
export function setActionsMenuInitialisationState(initializationState: Record<string, boolean>): ThunkActionFunc<void> {
return async (dispatch, getState) => {
const state = getState();
@ -77,11 +85,15 @@ export function setCustomStatusInitialisationState(initializationState: Record<s
}
export function savePreferences(userId: string, preferences: PreferenceType[]): ActionFuncAsync {
return async (dispatch) => {
return async (dispatch, getState) => {
(async function savePreferencesWrapper() {
const state = getState();
const currentUserId = getCurrentUserId(state);
const actionType = userId === currentUserId ? PreferenceTypes.RECEIVED_PREFERENCES : PreferenceTypes.RECEIVED_USER_PREFERENCES;
try {
dispatch({
type: PreferenceTypes.RECEIVED_PREFERENCES,
type: actionType,
data: preferences,
});

View File

@ -4,7 +4,7 @@
import type {AnyAction} from 'redux';
import {combineReducers} from 'redux';
import type {PreferenceType} from '@mattermost/types/preferences';
import type {PreferencesType, PreferenceType} from '@mattermost/types/preferences';
import {PreferenceTypes, UserTypes} from 'mattermost-redux/action_types';
@ -24,6 +24,24 @@ function setAllPreferences(preferences: PreferenceType[]): any {
return nextState;
}
function setAllUserPreferences(preferences: PreferenceType[]): {[key: string]: PreferencesType} {
const nextState: {[key: string]: PreferencesType} = {};
if (preferences.length === 0) {
return nextState;
}
const userID = preferences[0].user_id;
nextState[userID] = {};
if (preferences) {
for (const preference of preferences) {
nextState[userID][getKey(preference)] = preference;
}
}
return nextState;
}
function myPreferences(state: Record<string, PreferenceType> = {}, action: AnyAction) {
switch (action.type) {
case PreferenceTypes.RECEIVED_ALL_PREFERENCES:
@ -62,8 +80,37 @@ function myPreferences(state: Record<string, PreferenceType> = {}, action: AnyAc
}
}
function userPreferences(state: Record<string, PreferencesType> = {}, action: AnyAction) {
switch (action.type) {
case PreferenceTypes.RECEIVED_USER_ALL_PREFERENCES:
return setAllUserPreferences(action.data);
case PreferenceTypes.RECEIVED_USER_PREFERENCES: {
const nextState = {...state};
const data = action.data as PreferenceType[];
if (action.data && data.length > 0) {
const userID = data[0].user_id;
nextState[userID] = nextState[userID] ? {...nextState[userID]} : {};
for (const preference of action.data) {
nextState[preference.user_id][getKey(preference)] = preference;
}
}
return nextState;
}
case UserTypes.LOGOUT_SUCCESS:
return {};
default:
return state;
}
}
export default combineReducers({
// object where the key is the category-name and has the corresponding value
myPreferences,
userPreferences,
});

View File

@ -2,7 +2,7 @@
// See LICENSE.txt for license information.
import {CollapsedThreads} from '@mattermost/types/config';
import type {PreferenceType} from '@mattermost/types/preferences';
import type {PreferencesType, PreferenceType} from '@mattermost/types/preferences';
import type {GlobalState} from '@mattermost/types/store';
import {General, Preferences} from 'mattermost-redux/constants';
@ -16,6 +16,10 @@ export function getMyPreferences(state: GlobalState): { [x: string]: PreferenceT
return state.entities.preferences.myPreferences;
}
export function getUserPreferences(state: GlobalState, userID: string): { [x: string]: PreferenceType } {
return state.entities.preferences.userPreferences[userID];
}
export function get(state: GlobalState, category: string, name: string, defaultValue: any = '') {
const key = getPreferenceKey(category, name);
const prefs = getMyPreferences(state);
@ -27,11 +31,26 @@ export function get(state: GlobalState, category: string, name: string, defaultV
return prefs[key].value;
}
export function getFromPreferences(preferences: PreferencesType, category: string, name: string, defaultValue: any = '') {
const key = getPreferenceKey(category, name);
if (!(key in preferences)) {
return defaultValue;
}
return preferences[key].value;
}
export function getBool(state: GlobalState, category: string, name: string, defaultValue = false): boolean {
const value = get(state, category, name, String(defaultValue));
return value !== 'false';
}
export function getBoolFromPreferences(userPreferences: PreferencesType, category: string, name: string, defaultValue = false): boolean {
const value = getFromPreferences(userPreferences, category, name, String(defaultValue));
return value !== 'false';
}
export function getInt(state: GlobalState, category: string, name: string, defaultValue = 0): number {
const value = get(state, category, name, defaultValue);
return parseInt(value, 10);
@ -57,6 +76,26 @@ export function makeGetCategory(): (state: GlobalState, category: string) => Pre
);
}
export function makeGetUserCategory(userID: string): (state: GlobalState, category: string) => PreferenceType[] {
return createSelector(
'makeGetCategory',
(state) => getUserPreferences(state, userID),
(state: GlobalState, category: string) => category,
(preferences, category) => {
const prefix = category + '--';
const prefsInCategory: PreferenceType[] = [];
for (const key in preferences) {
if (key.startsWith(prefix)) {
prefsInCategory.push(preferences[key]);
}
}
return prefsInCategory;
},
);
}
const getDirectShowCategory = makeGetCategory();
export function getDirectShowPreferences(state: GlobalState) {
@ -180,31 +219,44 @@ export function makeGetStyleFromTheme<Style>(): (state: GlobalState, getStyleFro
);
}
export function calculateUserShouldShowUnreadsCategory(userPreferences: PreferencesType, serverDefault?: string): boolean {
const userPreference = getFromPreferences(userPreferences, Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.SHOW_UNREAD_SECTION);
const oldUserPreference = getFromPreferences(userPreferences, Preferences.CATEGORY_SIDEBAR_SETTINGS, '');
return calculateShouldShowUnreadsCategory(userPreference, oldUserPreference, serverDefault);
}
export function calculateShouldShowUnreadsCategory(userPreference: string, oldUserPreference: string, serverDefault?: string): boolean {
// Prefer the show_unread_section user preference over the previous version
if (userPreference) {
return userPreference === 'true';
}
if (oldUserPreference) {
return JSON.parse(oldUserPreference).unreads_at_top === 'true';
}
// The user setting is not set, so use the system default
return serverDefault === General.DEFAULT_ON;
}
// shouldShowUnreadsCategory returns true if the user has unereads grouped separately with the new sidebar enabled.
export const shouldShowUnreadsCategory: (state: GlobalState) => boolean = createSelector(
'shouldShowUnreadsCategory',
(state: GlobalState) => get(state, Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.SHOW_UNREAD_SECTION),
(state: GlobalState) => get(state, Preferences.CATEGORY_SIDEBAR_SETTINGS, ''),
(state: GlobalState) => getConfig(state).ExperimentalGroupUnreadChannels,
(userPreference, oldUserPreference, serverDefault) => {
// Prefer the show_unread_section user preference over the previous version
if (userPreference) {
return userPreference === 'true';
}
if (oldUserPreference) {
return JSON.parse(oldUserPreference).unreads_at_top === 'true';
}
// The user setting is not set, so use the system default
return serverDefault === General.DEFAULT_ON;
},
calculateShouldShowUnreadsCategory,
);
export function getUnreadScrollPositionPreference(state: GlobalState): string {
return get(state, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.UNREAD_SCROLL_POSITION, Preferences.UNREAD_SCROLL_POSITION_START_FROM_LEFT);
}
export function getUnreadScrollPositionFromPreference(userPreferences: PreferencesType): string {
return getFromPreferences(userPreferences, Preferences.CATEGORY_ADVANCED_SETTINGS, Preferences.UNREAD_SCROLL_POSITION, Preferences.UNREAD_SCROLL_POSITION_START_FROM_LEFT);
}
export function getCollapsedThreadsPreference(state: GlobalState): string {
const configValue = getConfig(state)?.CollapsedThreads;
let preferenceDefault = Preferences.COLLAPSED_REPLY_THREADS_OFF;
@ -221,6 +273,22 @@ export function getCollapsedThreadsPreference(state: GlobalState): string {
);
}
export function getCollapsedThreadsPreferenceFromPreferences(state: GlobalState, userPreferences: PreferencesType): string {
const configValue = getConfig(state)?.CollapsedThreads;
let preferenceDefault = Preferences.COLLAPSED_REPLY_THREADS_OFF;
if (configValue === CollapsedThreads.DEFAULT_ON || configValue === CollapsedThreads.ALWAYS_ON) {
preferenceDefault = Preferences.COLLAPSED_REPLY_THREADS_ON;
}
return getFromPreferences(
userPreferences,
Preferences.CATEGORY_DISPLAY_SETTINGS,
Preferences.COLLAPSED_REPLY_THREADS,
preferenceDefault,
);
}
export function isCollapsedThreadsAllowed(state: GlobalState): boolean {
return Boolean(getConfig(state)) && getConfig(state).CollapsedThreads !== undefined && getConfig(state).CollapsedThreads !== CollapsedThreads.DISABLED;
}
@ -232,6 +300,13 @@ export function isCollapsedThreadsEnabled(state: GlobalState): boolean {
return isAllowed && (userPreference === Preferences.COLLAPSED_REPLY_THREADS_ON || getConfig(state).CollapsedThreads === CollapsedThreads.ALWAYS_ON);
}
export function isCollapsedThreadsEnabledForUser(state: GlobalState, userPreferences: PreferencesType): boolean {
const isAllowed = isCollapsedThreadsAllowed(state);
const userPreference = getCollapsedThreadsPreferenceFromPreferences(state, userPreferences);
return isAllowed && (userPreference === Preferences.COLLAPSED_REPLY_THREADS_ON || getConfig(state).CollapsedThreads === CollapsedThreads.ALWAYS_ON);
}
export function isGroupChannelManuallyVisible(state: GlobalState, channelId: string): boolean {
return getBool(state, Preferences.CATEGORY_GROUP_CHANNEL_SHOW, channelId, false);
}
@ -264,6 +339,12 @@ export function getVisibleDmGmLimit(state: GlobalState) {
return getInt(state, Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS, defaultLimit);
}
export function getUserVisibleDmGmLimit(userPreferences: PreferencesType) {
const defaultLimit = 40;
const value = getFromPreferences(userPreferences, Preferences.CATEGORY_SIDEBAR_SETTINGS, Preferences.LIMIT_VISIBLE_DMS_GMS, defaultLimit);
return parseInt(value, 10);
}
export function onboardingTourTipsEnabled(state: GlobalState): boolean {
return getFeatureFlagValue(state, 'OnboardingTourTips') === 'true';
}

View File

@ -10,7 +10,7 @@ import {getTimezoneLabel, getUserCurrentTimezone} from 'mattermost-redux/utils/t
import {getCurrentUser} from './common';
function getTimezoneForUserProfile(profile: UserProfile) {
export function getTimezoneForUserProfile(profile: UserProfile) {
if (profile && profile.timezone) {
return {
...profile.timezone,
@ -41,14 +41,16 @@ export const getCurrentTimezone = createSelector(
},
);
export function generateCurrentTimezoneLabel(timezone: string) {
if (!timezone) {
return '';
}
return getTimezoneLabel(timezones, timezone);
}
export const getCurrentTimezoneLabel = createSelector(
'getCurrentTimezoneLabel',
getCurrentTimezone,
(timezone) => {
if (!timezone) {
return '';
}
return getTimezoneLabel(timezones, timezone);
},
generateCurrentTimezoneLabel,
);

View File

@ -97,6 +97,7 @@ const state: GlobalState = {
},
preferences: {
myPreferences: {},
userPreferences: {},
},
bots: {
accounts: {},

View File

@ -318,10 +318,12 @@
}
.modal-title {
width: 100%;
background: transparent;
color: v(center-channel-color);
font-size: 22px;
line-height: 28px;
word-break: break-word;
}
}

View File

@ -72,6 +72,36 @@
flex-direction: column;
padding: 0;
margin: 0 auto;
.loadingIndicator {
display: block;
width: 32px;
height: 32px;
margin: 36px auto 48px;
font-size: 32px;
}
}
.modal-header {
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
.adminModeBadge {
display: flex;
width: 86px;
min-width: 86px;
height: 22px;
align-items: center;
padding: 2px var(--spacing-xxxs, 6px);
border-radius: 4px;
margin-top: 10px;
margin-bottom: auto;
margin-left: 24px;
background: var(--error-text);
color: white;
font-size: 12px;
font-weight: 600;
gap: 6px;
}
}
li {
@ -189,6 +219,7 @@
display: flex;
width: 100%;
max-width: 1000px;
min-height: 475px;
min-height: 0;
flex: 1;
margin: 0 auto;
@ -203,11 +234,11 @@
.settings-links {
overflow: auto;
width: 180px;
background: var(--sidebar-bg);
width: 232px;
padding: 16px;
background: rgba(var(--center-channel-color-rgb), 0.04);
.nav {
position: fixed;
margin: 0;
}
}
@ -439,11 +470,11 @@
}
.divider-dark {
border-bottom: 1px solid #aaa;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.12);
}
.divider-light {
border-bottom: 1px solid lightgrey;
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
& + .divider-light {
display: none;
@ -530,23 +561,29 @@
}
.nav-pills > li button {
color: rgba(var(--sidebar-text-rgb), 0.75);
color: rgba(var(--center-channel-color-rgb), 0.75);
}
}
.nav-pills {
> li {
margin: 0;
margin-bottom: 8px;
button {
overflow: hidden;
width: 100%;
padding: 8px 15px;
border-radius: 0;
padding: 6px 15px;
border-radius: 4px;
color: $gray;
font-weight: 600;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
&:hover {
background-color: rgba(var(--center-channel-color-rgb), 0.04);
color: rgba(var(--center-channel-color-rgb), 0.8);
}
}
img {
@ -565,14 +602,6 @@
text-align: center;
}
&:hover {
button,
button:hover,
button:focus {
background: var(--sidebar-text-hover-bg);
}
}
&.active {
div {
background-color: #e1e1e1;
@ -596,7 +625,8 @@
}
button {
@include alpha-property(background-color, $black, 0.1);
background: rgba(var(--button-bg-rgb), 0.08);
color: v(button-bg);
}
}
}

View File

@ -3,12 +3,16 @@
import cloneDeep from 'lodash/cloneDeep';
import Permissions from 'mattermost-redux/constants/permissions';
import {ResourceToSysConsolePermissionsTable, RESOURCE_KEYS} from 'mattermost-redux/constants/permissions_sysconsole';
import {createSelector} from 'mattermost-redux/selectors/create_selector';
import {getMySystemPermissions} from 'mattermost-redux/selectors/entities/roles_helpers';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getMySystemPermissions, haveISystemPermission} from 'mattermost-redux/selectors/entities/roles_helpers';
import AdminDefinition from 'components/admin_console/admin_definition';
import {isEnterpriseOrE20License} from '../utils/license_utils';
export const getAdminDefinition = createSelector(
'getAdminDefinition',
() => AdminDefinition,
@ -49,3 +53,29 @@ export const getConsoleAccess = createSelector(
return consoleAccess;
},
);
export const getShowManageUserSettings = createSelector(
'showManageUserSettings',
getLicense,
(state) => state,
(license, state) => {
const hasWriteUserManagementPermission = haveISystemPermission(state, {permission: Permissions.SYSCONSOLE_WRITE_USERMANAGEMENT_USERS});
const isEnterprise = isEnterpriseOrE20License(license);
return hasWriteUserManagementPermission && isEnterprise;
},
);
export const getShowLockedManageUserSettings = createSelector(
'showLockedManageUserSettings',
getLicense,
(state) => state,
(license, state) => {
const hasWriteUserManagementPermission = haveISystemPermission(state, {permission: Permissions.SYSCONSOLE_WRITE_USERMANAGEMENT_USERS});
const isEnterprise = isEnterpriseOrE20License(license);
return hasWriteUserManagementPermission && !isEnterprise;
},
);

View File

@ -461,6 +461,7 @@ export const ModalIdentifiers = {
EXPORT_ERROR_MODAL: 'export_error_modal',
CHANNEL_BOOKMARK_DELETE: 'channel_bookmark_delete',
CHANNEL_BOOKMARK_CREATE: 'channel_bookmark_create',
CONFIRM_MANAGE_USER_SETTINGS_MODAL: 'confirm_switch_to_settings',
};
export const UserStatuses = {

View File

@ -2491,6 +2491,13 @@ export default class Client4 {
);
};
getUserPreferences = (userId: string) => {
return this.doFetch<PreferenceType[]>(
`${this.getPreferencesRoute(userId)}`,
{method: 'get'},
);
};
deletePreferences = (userId: string, preferences: PreferenceType[]) => {
return this.doFetch<StatusOK>(
`${this.getPreferencesRoute(userId)}/delete`,

View File

@ -49,6 +49,11 @@ export type GlobalState = {
myPreferences: {
[x: string]: PreferenceType;
};
userPreferences: {
[userID: string]: {
[x: string]: PreferenceType;
};
};
};
admin: AdminState;
jobs: JobsState;