From 87d983cc7ffd87c26a9a219047684847c9663c29 Mon Sep 17 00:00:00 2001 From: Harshil Sharma <18575143+harshilsharma63@users.noreply.github.com> Date: Fri, 12 Jul 2024 10:22:04 +0530 Subject: [PATCH] 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 --- server/channels/api4/report.go | 4 +- server/channels/app/user.go | 6 +- server/channels/app/users/utils.go | 2 +- server/channels/store/sqlstore/post_store.go | 2 +- server/public/model/report.go | 2 +- server/public/model/user.go | 11 +- server/public/model/user_test.go | 4 +- .../admin_user_card/admin_user_card.scss | 7 +- .../system_user_detail.test.tsx.snap | 627 ++++++++++++++++++ .../admin_console/system_user_detail/index.ts | 8 + .../system_user_detail.scss | 7 + .../system_user_detail.test.tsx | 30 + .../system_user_detail/system_user_detail.tsx | 87 ++- .../confirmManageUserSettingsModal.tsx | 56 ++ .../system_users_list_actions/index.tsx | 43 +- .../confirm_modal_redux.tsx | 2 +- .../user_settings/advanced/index.ts | 36 +- .../advanced/join_leave_section/index.ts | 29 +- .../join_leave_section.test.tsx | 43 +- .../join_leave_section/join_leave_section.tsx | 11 +- .../performance_debugging_section/index.ts | 20 +- .../performance_debugging_section.tsx | 13 +- .../advanced/user_settings_advanced.tsx | 20 +- .../user_settings_display.test.tsx.snap | 14 + .../components/user_settings/display/index.ts | 56 +- .../display/manage_languages/index.ts | 3 +- .../manage_languages.test.tsx | 1 + .../manage_languages/manage_languages.tsx | 7 +- .../display/manage_timezones/index.ts | 3 +- .../manage_timezones.test.tsx | 1 + .../manage_timezones/manage_timezones.tsx | 7 +- .../display/user_settings_display.test.tsx | 2 + .../display/user_settings_display.tsx | 20 +- .../headers/setting_desktop_header.scss | 8 + .../src/components/user_settings/index.tsx | 13 + .../components/user_settings/modal/index.ts | 17 +- .../modal/user_settings_modal.tsx | 155 +++-- .../user_settings/notifications/index.ts | 15 +- .../user_settings_notifications.test.tsx | 1 + .../user_settings_notifications.tsx | 20 +- .../sidebar/limit_visible_gms_dms/index.ts | 9 +- .../limit_visible_gms_dms.tsx | 15 +- .../sidebar/show_unreads_category/index.ts | 14 +- .../show_unreads_category.tsx | 16 +- .../sidebar/user_settings_sidebar.tsx | 11 + .../components/widgets/smartLoader/index.tsx | 37 ++ webapp/channels/src/i18n/en.json | 6 + .../src/action_types/preferences.ts | 2 + .../src/actions/preferences.ts | 16 +- .../src/reducers/entities/preferences.ts | 49 +- .../src/selectors/entities/preferences.ts | 109 ++- .../src/selectors/entities/timezone.ts | 18 +- .../src/store/initial_state.ts | 1 + .../channels/src/sass/components/_modal.scss | 2 + .../channels/src/sass/routes/_settings.scss | 66 +- .../channels/src/selectors/admin_console.jsx | 32 +- webapp/channels/src/utils/constants.tsx | 1 + webapp/platform/client/src/client4.ts | 7 + webapp/platform/types/src/store.ts | 5 + 59 files changed, 1629 insertions(+), 200 deletions(-) create mode 100644 webapp/channels/src/components/admin_console/system_users/system_users_list_actions/confirmManageUserSettingsModal.tsx create mode 100644 webapp/channels/src/components/widgets/smartLoader/index.tsx diff --git a/server/channels/api4/report.go b/server/channels/api4/report.go index 7a06de1f62..9185b1284a 100644 --- a/server/channels/api4/report.go +++ b/server/channels/api4/report.go @@ -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 } diff --git a/server/channels/app/user.go b/server/channels/app/user.go index 121ad8cf4c..fb84335464 100644 --- a/server/channels/app/user.go +++ b/server/channels/app/user.go @@ -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) diff --git a/server/channels/app/users/utils.go b/server/channels/app/users/utils.go index e708a606d7..0c8368974c 100644 --- a/server/channels/app/users/utils.go +++ b/server/channels/app/users/utils.go @@ -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 { diff --git a/server/channels/store/sqlstore/post_store.go b/server/channels/store/sqlstore/post_store.go index d4cc9bb6a0..519c09d527 100644 --- a/server/channels/store/sqlstore/post_store.go +++ b/server/channels/store/sqlstore/post_store.go @@ -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 { diff --git a/server/public/model/report.go b/server/public/model/report.go index e8587e448f..022f58207f 100644 --- a/server/public/model/report.go +++ b/server/public/model/report.go @@ -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, diff --git a/server/public/model/user.go b/server/public/model/user.go index 5c3ad3d40b..b1882e2bd5 100644 --- a/server/public/model/user.go +++ b/server/public/model/user.go @@ -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) } diff --git a/server/public/model/user_test.go b/server/public/model/user_test.go index df17fc3157..0a8ad7faaa 100644 --- a/server/public/model/user_test.go +++ b/server/public/model/user_test.go @@ -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]) diff --git a/webapp/channels/src/components/admin_console/admin_user_card/admin_user_card.scss b/webapp/channels/src/components/admin_console/admin_user_card/admin_user_card.scss index f3134e0315..ec8e62ca49 100644 --- a/webapp/channels/src/components/admin_console/admin_user_card/admin_user_card.scss +++ b/webapp/channels/src/components/admin_console/admin_user_card/admin_user_card.scss @@ -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 { diff --git a/webapp/channels/src/components/admin_console/system_user_detail/__snapshots__/system_user_detail.test.tsx.snap b/webapp/channels/src/components/admin_console/system_user_detail/__snapshots__/system_user_detail.test.tsx.snap index bc27af1ea3..91432c72cc 100644 --- a/webapp/channels/src/components/admin_console/system_user_detail/__snapshots__/system_user_detail.test.tsx.snap +++ b/webapp/channels/src/components/admin_console/system_user_detail/__snapshots__/system_user_detail.test.tsx.snap @@ -411,3 +411,630 @@ exports[`SystemUserDetail should match snapshot if MFA is enabled 1`] = ` /> `; + +exports[`SystemUserDetail should not show manage user settings button when user doesnt have permission 1`] = ` +
+ +
+ + +
+
+
+
+ + + + + + + + + } + footer={ + + + + + } + isLoading={true} + /> + + +
+ } + subtitle={ + Object { + "defaultMessage": "Teams to which this user belongs", + "id": "admin.userManagement.userDetail.teamsSubtitle", + } + } + title={ + Object { + "defaultMessage": "Team Membership", + "id": "admin.userManagement.userDetail.teamsTitle", + } + } + > +
+ +
+ +
+
+
+ + } + disabled={true} + extraClasses="" + onClick={[Function]} + saving={false} + savingMessage={ + + } + /> +
+ +
+
+ + } + message={ +
+ + +
+
+ +
+
+ } + modalClass="" + onCancel={[Function]} + onConfirm={[Function]} + show={false} + title={ + + } + /> + +`; + +exports[`SystemUserDetail should show manage user settings button as activated 1`] = ` +
+ +
+ + +
+
+
+
+ + + + + + + + + } + footer={ + + + + + + } + isLoading={true} + /> + + +
+ } + subtitle={ + Object { + "defaultMessage": "Teams to which this user belongs", + "id": "admin.userManagement.userDetail.teamsSubtitle", + } + } + title={ + Object { + "defaultMessage": "Team Membership", + "id": "admin.userManagement.userDetail.teamsTitle", + } + } + > +
+ +
+ +
+
+
+ + } + disabled={true} + extraClasses="" + onClick={[Function]} + saving={false} + savingMessage={ + + } + /> +
+ +
+
+ + } + message={ +
+ + +
+
+ +
+
+ } + modalClass="" + onCancel={[Function]} + onConfirm={[Function]} + show={false} + title={ + + } + /> + +`; + +exports[`SystemUserDetail should show manage user settings button as disabled when no license 1`] = ` +
+ +
+ + +
+
+
+
+ + + + + + + + + } + footer={ + + + + + } + isLoading={true} + /> + + +
+ } + subtitle={ + Object { + "defaultMessage": "Teams to which this user belongs", + "id": "admin.userManagement.userDetail.teamsSubtitle", + } + } + title={ + Object { + "defaultMessage": "Team Membership", + "id": "admin.userManagement.userDetail.teamsTitle", + } + } + > +
+ +
+ +
+
+
+ + } + disabled={true} + extraClasses="" + onClick={[Function]} + saving={false} + savingMessage={ + + } + /> +
+ +
+
+ + } + message={ +
+ + +
+
+ +
+
+ } + modalClass="" + onCancel={[Function]} + onConfirm={[Function]} + show={false} + title={ + + } + /> + +`; diff --git a/webapp/channels/src/components/admin_console/system_user_detail/index.ts b/webapp/channels/src/components/admin_console/system_user_detail/index.ts index 3fed0ac32b..6efbb009b1 100644 --- a/webapp/channels/src/components/admin_console/system_user_detail/index.ts +++ b/webapp/channels/src/components/admin_console/system_user_detail/index.ts @@ -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); diff --git a/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.scss b/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.scss index 58925effbf..bdef0d7144 100644 --- a/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.scss +++ b/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.scss @@ -37,4 +37,11 @@ align-items: center; justify-content: center; } + + .AdminUserCard__footer { + .manageUserSettingsBtn { + margin-left: auto; + cursor: pointer; + } + } } diff --git a/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.test.tsx b/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.test.tsx index c87ea1f29e..9d9b0096c5 100644 --- a/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.test.tsx +++ b/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.test.tsx @@ -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(); expect(wrapper).toMatchSnapshot(); }); + + test('should show manage user settings button as activated', () => { + const props = { + ...defaultProps, + showManageUserSettings: true, + }; + const wrapper = shallowWithIntl(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should show manage user settings button as disabled when no license', () => { + const props = { + ...defaultProps, + showLockedManageUserSettings: false, + }; + const wrapper = shallowWithIntl(); + expect(wrapper).toMatchSnapshot(); + }); + + test('should not show manage user settings button when user doesnt have permission', () => { + const props = { + ...defaultProps, + showManageUserSettings: false, + }; + const wrapper = shallowWithIntl(); + expect(wrapper).toMatchSnapshot(); + }); }); describe('getUserAuthenticationTextField', () => { diff --git a/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.tsx b/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.tsx index 0e47c8ec4c..a11bbf26b5 100644 --- a/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.tsx +++ b/webapp/channels/src/components/admin_console/system_user_detail/system_user_detail.tsx @@ -1,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 { 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 (
@@ -385,14 +420,61 @@ export class SystemUserDetail extends PureComponent { /> )} + + { + this.props.showManageUserSettings && + + } + + { + this.props.showLockedManageUserSettings && + + + + } } /> {/* User's team details */}
); @@ -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} /> ); @@ -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} /> ); @@ -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} /> ); diff --git a/webapp/channels/src/components/user_settings/modal/index.ts b/webapp/channels/src/components/user_settings/modal/index.ts index 46799ce0d0..774aa871e8 100644 --- a/webapp/channels/src/components/user_settings/modal/index.ts +++ b/webapp/channels/src/components/user_settings/modal/index.ts @@ -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), }; } diff --git a/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx b/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx index 472035fff8..278961fcc3 100644 --- a/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx +++ b/webapp/channels/src/components/user_settings/modal/user_settings_modal.tsx @@ -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; + getUserPreferences: (userID: string) => Promise; + getUser: (userID: string) => Promise; }; pluginSettings: {[pluginId: string]: PluginConfiguration}; } @@ -39,6 +51,7 @@ type State = { enforceFocus?: boolean; show: boolean; resendStatus: string; + loading: boolean; } class UserSettingsModal extends React.PureComponent { @@ -57,6 +70,7 @@ class UserSettingsModal extends React.PureComponent { enforceFocus: true, show: true, resendStatus: '', + loading: false, }; this.requireConfirm = false; @@ -84,6 +98,22 @@ class UserSettingsModal extends React.PureComponent { 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 { } } + 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 { render() { const {formatMessage} = this.props.intl; - if (this.props.currentUser == null) { - return (
); - } - 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 ( { > {modalTitle} + + { + this.props.adminMode && +
+ +
+ } -
-
- -
-
- this.setState({enforceFocus})} - setRequireConfirm={ - (requireConfirm?: boolean, customConfirmAction?: () => () => void) => { - this.requireConfirm = requireConfirm!; - this.customConfirmAction = customConfirmAction!; + { + this.props.adminMode && + + + + } + + { + !this.state.loading && this.props.currentUser && +
+
+ +
+
+ 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} + /> +
-
+ } { 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} diff --git a/webapp/channels/src/components/user_settings/notifications/index.ts b/webapp/channels/src/components/user_settings/notifications/index.ts index b60330a92d..480f77673e 100644 --- a/webapp/channels/src/components/user_settings/notifications/index.ts +++ b/webapp/channels/src/components/user_settings/notifications/index.ts @@ -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); diff --git a/webapp/channels/src/components/user_settings/notifications/user_settings_notifications.test.tsx b/webapp/channels/src/components/user_settings/notifications/user_settings_notifications.test.tsx index 6c8d74ef67..d779f530b5 100644 --- a/webapp/channels/src/components/user_settings/notifications/user_settings_notifications.test.tsx +++ b/webapp/channels/src/components/user_settings/notifications/user_settings_notifications.test.tsx @@ -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, diff --git a/webapp/channels/src/components/user_settings/notifications/user_settings_notifications.tsx b/webapp/channels/src/components/user_settings/notifications/user_settings_notifications.tsx index d6305c1bb7..3488639681 100644 --- a/webapp/channels/src/components/user_settings/notifications/user_settings_notifications.tsx +++ b/webapp/channels/src/components/user_settings/notifications/user_settings_notifications.tsx @@ -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 { 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)); diff --git a/webapp/channels/src/components/user_settings/sidebar/limit_visible_gms_dms/index.ts b/webapp/channels/src/components/user_settings/sidebar/limit_visible_gms_dms/index.ts index d6c4114ef0..7f914f3e66 100644 --- a/webapp/channels/src/components/user_settings/sidebar/limit_visible_gms_dms/index.ts +++ b/webapp/channels/src/components/user_settings/sidebar/limit_visible_gms_dms/index.ts @@ -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), }; } diff --git a/webapp/channels/src/components/user_settings/sidebar/limit_visible_gms_dms/limit_visible_gms_dms.tsx b/webapp/channels/src/components/user_settings/sidebar/limit_visible_gms_dms/limit_visible_gms_dms.tsx index d520aa454a..331b386a53 100644 --- a/webapp/channels/src/components/user_settings/sidebar/limit_visible_gms_dms/limit_visible_gms_dms.tsx +++ b/webapp/channels/src/components/user_settings/sidebar/limit_visible_gms_dms/limit_visible_gms_dms.tsx @@ -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; dmGmLimit: number; updateSection: (section: string) => void; @@ -99,6 +104,10 @@ export default class LimitVisibleGMsDMs extends React.PureComponent { + if (!this.props.currentUserId) { + return; + } + this.setState({isSaving: true}); await this.props.savePreferences(this.props.currentUserId, [{ diff --git a/webapp/channels/src/components/user_settings/sidebar/show_unreads_category/index.ts b/webapp/channels/src/components/user_settings/sidebar/show_unreads_category/index.ts index 07deabfe94..843496de79 100644 --- a/webapp/channels/src/components/user_settings/sidebar/show_unreads_category/index.ts +++ b/webapp/channels/src/components/user_settings/sidebar/show_unreads_category/index.ts @@ -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), }; } diff --git a/webapp/channels/src/components/user_settings/sidebar/show_unreads_category/show_unreads_category.tsx b/webapp/channels/src/components/user_settings/sidebar/show_unreads_category/show_unreads_category.tsx index 2c1c401eb3..0f63bf0a4b 100644 --- a/webapp/channels/src/components/user_settings/sidebar/show_unreads_category/show_unreads_category.tsx +++ b/webapp/channels/src/components/user_settings/sidebar/show_unreads_category/show_unreads_category.tsx @@ -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; showUnreadsCategory: boolean; updateSection: (section: string) => void; @@ -75,6 +80,11 @@ export default class ShowUnreadsCategory extends React.PureComponent { + if (!this.props.currentUserId) { + // Only for type safety, won't actually happen + return; + } + this.setState({isSaving: true}); await this.props.savePreferences(this.props.currentUserId, [{ diff --git a/webapp/channels/src/components/user_settings/sidebar/user_settings_sidebar.tsx b/webapp/channels/src/components/user_settings/sidebar/user_settings_sidebar.tsx index 2dbfc4bd55..bbb5c1f223 100644 --- a/webapp/channels/src/components/user_settings/sidebar/user_settings_sidebar.tsx +++ b/webapp/channels/src/components/user_settings/sidebar/user_settings_sidebar.tsx @@ -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} />
diff --git a/webapp/channels/src/components/widgets/smartLoader/index.tsx b/webapp/channels/src/components/widgets/smartLoader/index.tsx new file mode 100644 index 0000000000..8d042facda --- /dev/null +++ b/webapp/channels/src/components/widgets/smartLoader/index.tsx @@ -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 ? ( +
+ {children} +
+ ) : null; +}; + +export default SmartLoader; diff --git a/webapp/channels/src/i18n/en.json b/webapp/channels/src/i18n/en.json index d0c58952be..5574ac462b 100644 --- a/webapp/channels/src/i18n/en.json +++ b/webapp/channels/src/i18n/en.json @@ -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.



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", diff --git a/webapp/channels/src/packages/mattermost-redux/src/action_types/preferences.ts b/webapp/channels/src/packages/mattermost-redux/src/action_types/preferences.ts index a3ad214ab0..846e4dbf27 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/action_types/preferences.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/action_types/preferences.ts @@ -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, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/actions/preferences.ts b/webapp/channels/src/packages/mattermost-redux/src/actions/preferences.ts index da03304219..7c3207f968 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/actions/preferences.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/actions/preferences.ts @@ -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): ThunkActionFunc { return async (dispatch, getState) => { const state = getState(); @@ -77,11 +85,15 @@ export function setCustomStatusInitialisationState(initializationState: Record { + 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, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/preferences.ts b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/preferences.ts index a2cafb2350..a3ce67cf59 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/preferences.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/reducers/entities/preferences.ts @@ -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 = {}, action: AnyAction) { switch (action.type) { case PreferenceTypes.RECEIVED_ALL_PREFERENCES: @@ -62,8 +80,37 @@ function myPreferences(state: Record = {}, action: AnyAc } } +function userPreferences(state: Record = {}, 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, }); diff --git a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts index b4212e71f9..6067467a39 100644 --- a/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts +++ b/webapp/channels/src/packages/mattermost-redux/src/selectors/entities/preferences.ts @@ -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