diff options
33 files changed, 1683 insertions, 245 deletions
diff --git a/api4/post_test.go b/api4/post_test.go index 53babc6e6..f136ba676 100644 --- a/api4/post_test.go +++ b/api4/post_test.go @@ -302,6 +302,61 @@ func TestCreatePostPublic(t *testing.T) { CheckNoError(t, resp) } +func TestCreatePostAll(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + defer TearDown() + Client := th.Client + + post := &model.Post{ChannelId: th.BasicChannel.Id, Message: "#hashtag a" + model.NewId() + "a"} + + user := model.User{Email: GenerateTestEmail(), Nickname: "Joram Wilander", Password: "hello1", Username: GenerateTestUsername(), Roles: model.ROLE_SYSTEM_USER.Id} + + directChannel, _ := app.CreateDirectChannel(th.BasicUser.Id, th.BasicUser2.Id) + + ruser, resp := Client.CreateUser(&user) + CheckNoError(t, resp) + + Client.Login(user.Email, user.Password) + + _, resp = Client.CreatePost(post) + CheckForbiddenStatus(t, resp) + + app.UpdateUserRoles(ruser.Id, model.ROLE_SYSTEM_USER.Id+" "+model.ROLE_SYSTEM_POST_ALL.Id) + app.InvalidateAllCaches() + + Client.Login(user.Email, user.Password) + + _, resp = Client.CreatePost(post) + CheckNoError(t, resp) + + post.ChannelId = th.BasicPrivateChannel.Id + _, resp = Client.CreatePost(post) + CheckNoError(t, resp) + + post.ChannelId = directChannel.Id + _, resp = Client.CreatePost(post) + CheckNoError(t, resp) + + app.UpdateUserRoles(ruser.Id, model.ROLE_SYSTEM_USER.Id) + app.JoinUserToTeam(th.BasicTeam, ruser, "") + app.UpdateTeamMemberRoles(th.BasicTeam.Id, ruser.Id, model.ROLE_TEAM_USER.Id+" "+model.ROLE_TEAM_POST_ALL.Id) + app.InvalidateAllCaches() + + Client.Login(user.Email, user.Password) + + post.ChannelId = th.BasicPrivateChannel.Id + _, resp = Client.CreatePost(post) + CheckNoError(t, resp) + + post.ChannelId = th.BasicChannel.Id + _, resp = Client.CreatePost(post) + CheckNoError(t, resp) + + post.ChannelId = directChannel.Id + _, resp = Client.CreatePost(post) + CheckForbiddenStatus(t, resp) +} + func TestUpdatePost(t *testing.T) { th := Setup().InitBasic().InitSystemAdmin() defer TearDown() diff --git a/app/authorization.go b/app/authorization.go index 9fc2edfb9..28f968f68 100644 --- a/app/authorization.go +++ b/app/authorization.go @@ -48,7 +48,7 @@ func SessionHasPermissionToChannel(session model.Session, channelId string, perm } channel, err := GetChannel(channelId) - if err == nil { + if err == nil && channel.TeamId != "" { return SessionHasPermissionToTeam(session, channel.TeamId, permission) } diff --git a/app/diagnostics.go b/app/diagnostics.go index 54fe843ac..603ceb8a5 100644 --- a/app/diagnostics.go +++ b/app/diagnostics.go @@ -165,6 +165,7 @@ func trackConfig() { "enable_post_username_override": utils.Cfg.ServiceSettings.EnablePostUsernameOverride, "enable_post_icon_override": utils.Cfg.ServiceSettings.EnablePostIconOverride, "enable_apiv3": *utils.Cfg.ServiceSettings.EnableAPIv3, + "enable_user_access_tokens": *utils.Cfg.ServiceSettings.EnableUserAccessTokens, "enable_custom_emoji": *utils.Cfg.ServiceSettings.EnableCustomEmoji, "enable_emoji_picker": *utils.Cfg.ServiceSettings.EnableEmojiPicker, "restrict_custom_emoji_creation": *utils.Cfg.ServiceSettings.RestrictCustomEmojiCreation, diff --git a/i18n/en.json b/i18n/en.json index c4940295b..3287af260 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3504,6 +3504,14 @@ "translation": "Team name missing from User's Team Membership." }, { + "id": "authentication.roles.system_post_all.name", + "translation": "Post in Public, Private and Direct Channels" + }, + { + "id": "authentication.roles.system_post_all.description", + "translation": "A role with the permission to post in any public, private or direct channel on the system" + }, + { "id": "authentication.roles.system_post_all_public.name", "translation": "Post in Public Channels" }, @@ -3512,6 +3520,14 @@ "translation": "A role with the permission to post in any public channel on the system" }, { + "id": "authentication.roles.team_post_all.name", + "translation": "Post in Public and Private Channels" + }, + { + "id": "authentication.roles.team_post_all.description", + "translation": "A role with the permission to post in any public or private channel on the team" + }, + { "id": "authentication.roles.team_post_all_public.name", "translation": "Post in Public Channels" }, diff --git a/model/authorization.go b/model/authorization.go index cf7e2b481..d413e294c 100644 --- a/model/authorization.go +++ b/model/authorization.go @@ -71,11 +71,13 @@ var PERMISSION_MANAGE_SYSTEM *Permission var ROLE_SYSTEM_USER *Role var ROLE_SYSTEM_ADMIN *Role +var ROLE_SYSTEM_POST_ALL *Role var ROLE_SYSTEM_POST_ALL_PUBLIC *Role var ROLE_SYSTEM_USER_ACCESS_TOKEN *Role var ROLE_TEAM_USER *Role var ROLE_TEAM_ADMIN *Role +var ROLE_TEAM_POST_ALL *Role var ROLE_TEAM_POST_ALL_PUBLIC *Role var ROLE_CHANNEL_USER *Role @@ -376,6 +378,16 @@ func InitalizeRoles() { } BuiltInRoles[ROLE_TEAM_USER.Id] = ROLE_TEAM_USER + ROLE_TEAM_POST_ALL = &Role{ + "team_post_all", + "authentication.roles.team_post_all.name", + "authentication.roles.team_post_all.description", + []string{ + PERMISSION_CREATE_POST.Id, + }, + } + BuiltInRoles[ROLE_TEAM_POST_ALL.Id] = ROLE_TEAM_POST_ALL + ROLE_TEAM_POST_ALL_PUBLIC = &Role{ "team_post_all_public", "authentication.roles.team_post_all_public.name", @@ -417,6 +429,16 @@ func InitalizeRoles() { } BuiltInRoles[ROLE_SYSTEM_USER.Id] = ROLE_SYSTEM_USER + ROLE_SYSTEM_POST_ALL = &Role{ + "system_post_all", + "authentication.roles.system_post_all.name", + "authentication.roles.system_post_all.description", + []string{ + PERMISSION_CREATE_POST.Id, + }, + } + BuiltInRoles[ROLE_SYSTEM_POST_ALL.Id] = ROLE_SYSTEM_POST_ALL + ROLE_SYSTEM_POST_ALL_PUBLIC = &Role{ "system_post_all_public", "authentication.roles.system_post_all_public.name", diff --git a/store/sql_upgrade.go b/store/sql_upgrade.go index a7b72124e..157a85507 100644 --- a/store/sql_upgrade.go +++ b/store/sql_upgrade.go @@ -282,6 +282,12 @@ func UpgradeDatabaseToVersion40(sqlStore SqlStore) { func UpgradeDatabaseToVersion41(sqlStore SqlStore) { // TODO: Uncomment following condition when version 4.1.0 is released // if shouldPerformUpgrade(sqlStore, VERSION_4_0_0, VERSION_4_1_0) { + + // Increase maximum length of the Users table Roles column. + if sqlStore.GetMaxLengthOfColumnIfExists("Users", "Roles") != "256" { + sqlStore.AlterColumnTypeIfExists("Users", "Roles", "varchar(256)", "varchar(256)") + } + sqlStore.RemoveTableIfExists("JobStatuses") // saveSchemaVersion(sqlStore, VERSION_4_1_0) // } diff --git a/store/sql_user_store.go b/store/sql_user_store.go index ab031ea19..64079c8d3 100644 --- a/store/sql_user_store.go +++ b/store/sql_user_store.go @@ -62,7 +62,7 @@ func NewSqlUserStore(sqlStore SqlStore) UserStore { table.ColMap("Nickname").SetMaxSize(64) table.ColMap("FirstName").SetMaxSize(64) table.ColMap("LastName").SetMaxSize(64) - table.ColMap("Roles").SetMaxSize(64) + table.ColMap("Roles").SetMaxSize(256) table.ColMap("Props").SetMaxSize(4000) table.ColMap("NotifyProps").SetMaxSize(2000) table.ColMap("Locale").SetMaxSize(5) diff --git a/webapp/components/admin_console/custom_integrations_settings.jsx b/webapp/components/admin_console/custom_integrations_settings.jsx index 18fdd22fd..3b5c51171 100644 --- a/webapp/components/admin_console/custom_integrations_settings.jsx +++ b/webapp/components/admin_console/custom_integrations_settings.jsx @@ -25,6 +25,7 @@ export default class WebhookSettings extends AdminSettings { config.ServiceSettings.EnablePostUsernameOverride = this.state.enablePostUsernameOverride; config.ServiceSettings.EnablePostIconOverride = this.state.enablePostIconOverride; config.ServiceSettings.EnableOAuthServiceProvider = this.state.enableOAuthServiceProvider; + config.ServiceSettings.EnableUserAccessTokens = this.state.enableUserAccessTokens; return config; } @@ -37,7 +38,8 @@ export default class WebhookSettings extends AdminSettings { enableOnlyAdminIntegrations: config.ServiceSettings.EnableOnlyAdminIntegrations, enablePostUsernameOverride: config.ServiceSettings.EnablePostUsernameOverride, enablePostIconOverride: config.ServiceSettings.EnablePostIconOverride, - enableOAuthServiceProvider: config.ServiceSettings.EnableOAuthServiceProvider + enableOAuthServiceProvider: config.ServiceSettings.EnableOAuthServiceProvider, + enableUserAccessTokens: config.ServiceSettings.EnableUserAccessTokens }; } @@ -172,6 +174,23 @@ export default class WebhookSettings extends AdminSettings { value={this.state.enablePostIconOverride} onChange={this.handleChange} /> + <BooleanSetting + id='enableUserAccessTokens' + label={ + <FormattedMessage + id='admin.service.userAccessTokensTitle' + defaultMessage='Enable User Access Tokens: ' + /> + } + helpText={ + <FormattedHTMLMessage + id='admin.service.userAccessTokensDescription' + defaultMessage='When true, users can create <a href="https://about.mattermost.com/default-user-access-tokens" target="_blank">user access tokens</a> for integrations in <strong>Account Settings > Security</strong>. They can be used to authenticate against the API and give full access to the account.<br/><br/>To manage who can create user access tokens, go to the <strong>System Console > Users</strong> page.' + /> + } + value={this.state.enableUserAccessTokens} + onChange={this.handleChange} + /> </SettingsGroup> ); } diff --git a/webapp/components/admin_console/manage_roles_modal/index.js b/webapp/components/admin_console/manage_roles_modal/index.js new file mode 100644 index 000000000..1ca243621 --- /dev/null +++ b/webapp/components/admin_console/manage_roles_modal/index.js @@ -0,0 +1,25 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {updateUserRoles} from 'mattermost-redux/actions/users'; + +import ManageRolesModal from './manage_roles_modal.jsx'; + +function mapStateToProps(state, ownProps) { + return { + ...ownProps, + userAccessTokensEnabled: state.entities.admin.config.ServiceSettings.EnableUserAccessTokens + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + updateUserRoles + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ManageRolesModal); diff --git a/webapp/components/admin_console/manage_roles_modal/manage_roles_modal.jsx b/webapp/components/admin_console/manage_roles_modal/manage_roles_modal.jsx new file mode 100644 index 000000000..2358f0241 --- /dev/null +++ b/webapp/components/admin_console/manage_roles_modal/manage_roles_modal.jsx @@ -0,0 +1,349 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as UserUtils from 'mattermost-redux/utils/user_utils'; +import {Client4} from 'mattermost-redux/client'; +import {General} from 'mattermost-redux/constants'; + +import {trackEvent} from 'actions/diagnostics_actions.jsx'; + +import React from 'react'; +import {Modal} from 'react-bootstrap'; +import PropTypes from 'prop-types'; +import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; + +function getStateFromProps(props) { + const roles = props.user && props.user.roles ? props.user.roles : ''; + + return { + error: null, + hasPostAllRole: UserUtils.hasPostAllRole(roles), + hasPostAllPublicRole: UserUtils.hasPostAllPublicRole(roles), + hasUserAccessTokenRole: UserUtils.hasUserAccessTokenRole(roles), + isSystemAdmin: UserUtils.isSystemAdmin(roles) + }; +} + +export default class ManageRolesModal extends React.PureComponent { + static propTypes = { + + /** + * Set to render the modal + */ + show: PropTypes.bool.isRequired, + + /** + * The user the roles are being managed for + */ + user: PropTypes.object, + + /** + * Set if user access tokens are enabled + */ + userAccessTokensEnabled: PropTypes.bool.isRequired, + + /** + * Function called when modal is dismissed + */ + onModalDismissed: PropTypes.func.isRequired, + + actions: PropTypes.shape({ + + /** + * Function to update a user's roles + */ + updateUserRoles: PropTypes.func.isRequired + }).isRequired + }; + + constructor(props) { + super(props); + this.state = getStateFromProps(props); + } + + componentWillReceiveProps(nextProps) { + const user = this.props.user || {}; + const nextUser = nextProps.user || {}; + if (user.id !== nextUser.id) { + this.setState(getStateFromProps(nextProps)); + } + } + + handleError = (error) => { + this.setState({ + error + }); + } + + handleSystemAdminChange = (e) => { + if (e.target.name === 'systemadmin') { + this.setState({isSystemAdmin: true}); + } else if (e.target.name === 'systemmember') { + this.setState({isSystemAdmin: false}); + } + }; + + handleUserAccessTokenChange = (e) => { + this.setState({ + hasUserAccessTokenRole: e.target.checked + }); + }; + + handlePostAllChange = (e) => { + this.setState({ + hasPostAllRole: e.target.checked + }); + }; + + handlePostAllPublicChange = (e) => { + this.setState({ + hasPostAllPublicRole: e.target.checked + }); + }; + + trackRoleChanges = (roles, oldRoles) => { + if (UserUtils.hasUserAccessTokenRole(roles) && !UserUtils.hasUserAccessTokenRole(oldRoles)) { + trackEvent('actions', 'add_roles', {role: General.SYSTEM_USER_ACCESS_TOKEN_ROLE}); + } else if (!UserUtils.hasUserAccessTokenRole(roles) && UserUtils.hasUserAccessTokenRole(oldRoles)) { + trackEvent('actions', 'remove_roles', {role: General.SYSTEM_USER_ACCESS_TOKEN_ROLE}); + } + + if (UserUtils.hasPostAllRole(roles) && !UserUtils.hasPostAllRole(oldRoles)) { + trackEvent('actions', 'add_roles', {role: General.SYSTEM_POST_ALL_ROLE}); + } else if (!UserUtils.hasPostAllRole(roles) && UserUtils.hasPostAllRole(oldRoles)) { + trackEvent('actions', 'remove_roles', {role: General.SYSTEM_POST_ALL_ROLE}); + } + + if (UserUtils.hasPostAllPublicRole(roles) && !UserUtils.hasPostAllPublicRole(oldRoles)) { + trackEvent('actions', 'add_roles', {role: General.SYSTEM_POST_ALL_PUBLIC_ROLE}); + } else if (!UserUtils.hasPostAllPublicRole(roles) && UserUtils.hasPostAllPublicRole(oldRoles)) { + trackEvent('actions', 'remove_roles', {role: General.SYSTEM_POST_ALL_PUBLIC_ROLE}); + } + } + + handleSave = async () => { + this.setState({error: null}); + + let roles = General.SYSTEM_USER_ROLE; + + if (this.state.isSystemAdmin) { + roles += ' ' + General.SYSTEM_ADMIN_ROLE; + } else if (this.state.hasUserAccessTokenRole) { + roles += ' ' + General.SYSTEM_USER_ACCESS_TOKEN_ROLE; + if (this.state.hasPostAllRole) { + roles += ' ' + General.SYSTEM_POST_ALL_ROLE; + } else if (this.state.hasPostAllPublicRole) { + roles += ' ' + General.SYSTEM_POST_ALL_PUBLIC_ROLE; + } + } + + const data = await this.props.actions.updateUserRoles(this.props.user.id, roles); + + this.trackRoleChanges(roles, this.props.user.roles); + + if (data) { + this.props.onModalDismissed(); + } else { + this.handleError( + <FormattedMessage + id='admin.manage_roles.saveError' + defaultMessage='Unable to save roles.' + /> + ); + } + } + + renderContents = () => { + const {user} = this.props; + + if (user == null) { + return <div/>; + } + + let name = UserUtils.getFullName(user); + if (name) { + name += ` (@${user.username})`; + } else { + name = `@${user.username}`; + } + + let additionalRoles; + if (this.state.hasUserAccessTokenRole || this.state.isSystemAdmin) { + additionalRoles = ( + <div> + <p> + <FormattedHTMLMessage + id='admin.manage_roles.additionalRoles' + defaultMessage='Select additional permissions for the account. <a href="https://about.mattermost.com/default-permissions" target="_blank">Read more about roles and permissions</a>.' + /> + </p> + <div className='checkbox'> + <label> + <input + type='checkbox' + ref='postall' + checked={this.state.hasPostAllRole || this.state.isSystemAdmin} + disabled={this.state.isSystemAdmin} + onChange={this.handlePostAllChange} + /> + <strong> + <FormattedMessage + id='admin.manage_roles.postAllRoleTitle' + defaultMessage='post:all' + /> + </strong> + <FormattedMessage + id='admin.manage_roles.postAllRole' + defaultMessage='Access to post to all Mattermost channels including direct messages.' + /> + </label> + </div> + <div className='checkbox'> + <label> + <input + type='checkbox' + ref='postallpublic' + checked={this.state.hasPostAllPublicRole || this.state.hasPostAllRole || this.state.isSystemAdmin} + disabled={this.state.hasPostAllRole || this.state.isSystemAdmin} + onChange={this.handlePostAllPublicChange} + /> + <strong> + <FormattedMessage + id='admin.manage_roles.postAllPublicRoleTitle' + defaultMessage='post:channels' + /> + </strong> + <FormattedMessage + id='admin.manage_roles.postAllPublicRole' + defaultMessage='Access to post to all Mattermost public channels.' + /> + </label> + </div> + </div> + ); + } + + let userAccessTokenContent; + if (this.props.userAccessTokensEnabled) { + userAccessTokenContent = ( + <div> + <div className='checkbox'> + <label> + <input + type='checkbox' + ref='postall' + checked={this.state.hasUserAccessTokenRole || this.state.isSystemAdmin} + disabled={this.state.isSystemAdmin} + onChange={this.handleUserAccessTokenChange} + /> + <FormattedHTMLMessage + id='admin.manage_roles.allowUserAccessTokens' + defaultMessage='Allow this account to generate <a href="https://about.mattermost.com/default-user-access-tokens" target="_blank">user access tokens</a>.' + /> + </label> + </div> + <div className='member-row--padded'> + {additionalRoles} + </div> + </div> + ); + } + + return ( + <div> + <div className='manage-teams__user'> + <img + className='manage-teams__profile-picture' + src={Client4.getProfilePictureUrl(user.id, user.last_picture_update)} + /> + <div className='manage-teams__info'> + <div className='manage-teams__name'> + {name} + </div> + <div className='manage-teams__email'> + {user.email} + </div> + </div> + </div> + <div> + <div className='manage-row--inner'> + <div className='radio-inline'> + <label> + <input + name='systemadmin' + type='radio' + checked={this.state.isSystemAdmin} + onChange={this.handleSystemAdminChange} + /> + <FormattedMessage + id='admin.manage_roles.systemAdmin' + defaultMessage='System Admin' + /> + </label> + </div> + <div className='radio-inline'> + <label> + <input + name='systemmember' + type='radio' + checked={!this.state.isSystemAdmin} + onChange={this.handleSystemAdminChange} + /> + <FormattedMessage + id='admin.manage_roles.systemMember' + defaultMessage='Member' + /> + </label> + </div> + </div> + {userAccessTokenContent} + </div> + </div> + ); + } + + render() { + return ( + <Modal + show={this.props.show} + onHide={this.props.onModalDismissed} + dialogClassName='manage-teams' + > + <Modal.Header closeButton={true}> + <Modal.Title> + <FormattedMessage + id='admin.manage_roles.manageRolesTitle' + defaultMessage='Manage Roles' + /> + </Modal.Title> + </Modal.Header> + <Modal.Body> + {this.renderContents()} + {this.state.error} + </Modal.Body> + <Modal.Footer> + <button + type='button' + className='btn btn-link' + onClick={this.props.onModalDismissed} + > + <FormattedMessage + id='admin.manage_roles.cancel' + defaultMessage='Cancel' + /> + </button> + <button + type='button' + className='btn btn-primary' + onClick={this.handleSave} + > + <FormattedMessage + id='admin.manage_roles.save' + defaultMessage='Save' + /> + </button> + </Modal.Footer> + </Modal> + ); + } +} diff --git a/webapp/components/admin_console/manage_teams_modal/manage_teams_modal.jsx b/webapp/components/admin_console/manage_teams_modal/manage_teams_modal.jsx index a579ab03c..21f9d762d 100644 --- a/webapp/components/admin_console/manage_teams_modal/manage_teams_modal.jsx +++ b/webapp/components/admin_console/manage_teams_modal/manage_teams_modal.jsx @@ -1,11 +1,10 @@ -import PropTypes from 'prop-types'; - // Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. // See License.txt for license information. import React from 'react'; import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; +import PropTypes from 'prop-types'; import * as TeamActions from 'actions/team_actions.jsx'; @@ -29,14 +28,6 @@ export default class ManageTeamsModal extends React.Component { constructor(props) { super(props); - this.loadTeamsAndTeamMembers = this.loadTeamsAndTeamMembers.bind(this); - - this.handleError = this.handleError.bind(this); - this.handleMemberChange = this.handleMemberChange.bind(this); - this.handleMemberRemove = this.handleMemberRemove.bind(this); - - this.renderContents = this.renderContents.bind(this); - this.state = { error: null, teams: null, @@ -66,7 +57,7 @@ export default class ManageTeamsModal extends React.Component { } } - loadTeamsAndTeamMembers(user = this.props.user) { + loadTeamsAndTeamMembers = (user = this.props.user) => { TeamActions.getTeamsForUser(user.id, (teams) => { this.setState({ teams: teams.sort(sortTeamsByDisplayName) @@ -80,13 +71,13 @@ export default class ManageTeamsModal extends React.Component { }); } - handleError(error) { + handleError = (error) => { this.setState({ error }); } - handleMemberChange() { + handleMemberChange = () => { TeamActions.getTeamMembersForUser(this.props.user.id, (teamMembers) => { this.setState({ teamMembers @@ -94,14 +85,14 @@ export default class ManageTeamsModal extends React.Component { }); } - handleMemberRemove(teamId) { + handleMemberRemove = (teamId) => { this.setState({ teams: this.state.teams.filter((team) => team.id !== teamId), teamMembers: this.state.teamMembers.filter((teamMember) => teamMember.team_id !== teamId) }); } - renderContents() { + renderContents = () => { const {user} = this.props; const {teams, teamMembers} = this.state; diff --git a/webapp/components/admin_console/manage_teams_modal/remove_from_team_button.jsx b/webapp/components/admin_console/manage_teams_modal/remove_from_team_button.jsx index 28e9fde8f..69579d46f 100644 --- a/webapp/components/admin_console/manage_teams_modal/remove_from_team_button.jsx +++ b/webapp/components/admin_console/manage_teams_modal/remove_from_team_button.jsx @@ -41,7 +41,7 @@ export default class RemoveFromTeamButton extends React.PureComponent { render() { return ( <button - className='btn btn-default' + className='btn btn-danger' onClick={this.handleClick} > <FormattedMessage diff --git a/webapp/components/admin_console/manage_tokens_modal/index.js b/webapp/components/admin_console/manage_tokens_modal/index.js new file mode 100644 index 000000000..9f7a31141 --- /dev/null +++ b/webapp/components/admin_console/manage_tokens_modal/index.js @@ -0,0 +1,27 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {getUserAccessTokensForUser} from 'mattermost-redux/actions/users'; + +import ManageTokensModal from './manage_tokens_modal.jsx'; + +function mapStateToProps(state, ownProps) { + const userId = ownProps.user ? ownProps.user.id : ''; + + return { + ...ownProps, + userAccessTokens: state.entities.admin.userAccessTokens[userId] + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + getUserAccessTokensForUser + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(ManageTokensModal); diff --git a/webapp/components/admin_console/manage_tokens_modal/manage_tokens_modal.jsx b/webapp/components/admin_console/manage_tokens_modal/manage_tokens_modal.jsx new file mode 100644 index 000000000..c31325291 --- /dev/null +++ b/webapp/components/admin_console/manage_tokens_modal/manage_tokens_modal.jsx @@ -0,0 +1,181 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import LoadingScreen from 'components/loading_screen.jsx'; +import RevokeTokenButton from 'components/admin_console/revoke_token_button'; + +import {Client4} from 'mattermost-redux/client'; +import * as UserUtils from 'mattermost-redux/utils/user_utils'; + +import React from 'react'; +import {Modal} from 'react-bootstrap'; +import PropTypes from 'prop-types'; +import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; + +export default class ManageTokensModal extends React.PureComponent { + static propTypes = { + + /** + * Set to render the modal + */ + show: PropTypes.bool.isRequired, + + /** + * The user the roles are being managed for + */ + user: PropTypes.object, + + /** + * The user access tokens for a user, object with token ids as keys + */ + userAccessTokens: PropTypes.object, + + /** + * Function called when modal is dismissed + */ + onModalDismissed: PropTypes.func.isRequired, + + actions: PropTypes.shape({ + + /** + * Function to get a user's access tokens + */ + getUserAccessTokensForUser: PropTypes.func.isRequired + }).isRequired + }; + + constructor(props) { + super(props); + this.state = {error: null}; + } + + componentWillReceiveProps(nextProps) { + const userId = this.props.user ? this.props.user.id : null; + const nextUserId = nextProps.user ? nextProps.user.id : null; + if (nextUserId && nextUserId !== userId) { + this.props.actions.getUserAccessTokensForUser(nextUserId, 0, 200); + } + } + + handleError = (error) => { + this.setState({ + error + }); + } + + renderContents = () => { + const {user, userAccessTokens} = this.props; + + if (!user) { + return <LoadingScreen/>; + } + + let name = UserUtils.getFullName(user); + if (name) { + name += ` (@${user.username})`; + } else { + name = `@${user.username}`; + } + + let tokenList; + if (userAccessTokens) { + const userAccessTokensList = Object.values(userAccessTokens); + + if (userAccessTokensList.length === 0) { + tokenList = ( + <div className='manage-row__empty'> + <FormattedMessage + id='admin.manage_tokens.userAccessTokensNone' + defaultMessage='No user access tokens.' + /> + </div> + ); + } else { + tokenList = userAccessTokensList.map((token) => { + return ( + <div + key={token.id} + className='manage-teams__team' + > + <div className='manage-teams__team-name'> + <div> + <FormattedMessage + id='admin.manage_tokens.userAccessTokensNameLabel' + defaultMessage='Name: ' + /> + {token.description} + </div> + <div> + <FormattedMessage + id='admin.manage_tokens.userAccessTokensIdLabel' + defaultMessage='Token ID: ' + /> + {token.id} + </div> + </div> + <div className='manage-teams__team-actions'> + <RevokeTokenButton + tokenId={token.id} + onError={this.handleError} + /> + </div> + </div> + ); + }); + } + } else { + tokenList = <LoadingScreen/>; + } + + return ( + <div> + <div className='manage-teams__user'> + <img + className='manage-teams__profile-picture' + src={Client4.getProfilePictureUrl(user.id, user.last_picture_update)} + /> + <div className='manage-teams__info'> + <div className='manage-teams__name'> + {name} + </div> + <div className='manage-teams__email'> + {user.email} + </div> + </div> + </div> + <div className='padding-top x2'> + <FormattedHTMLMessage + id='admin.manage_tokens.userAccessTokensDescription' + defaultMessage='User access tokens function similar to session tokens and can be used by integrations to <a href="https://about.mattermost.com/default-api-authentication" target="_blank">authenticate against the REST API</a>. Learn more about <a href="https://about.mattermost.com/default-user-access-tokens" target="_blank">user access tokens</a>.' + /> + </div> + <div className='manage-teams__teams'> + {tokenList} + </div> + </div> + ); + } + + render() { + return ( + <Modal + show={this.props.show} + onHide={this.props.onModalDismissed} + dialogClassName='manage-teams' + > + <Modal.Header closeButton={true}> + <Modal.Title> + <FormattedMessage + id='admin.manage_tokens.manageTokensTitle' + defaultMessage='Manage User Access Tokens' + /> + </Modal.Title> + </Modal.Header> + <Modal.Body> + {this.renderContents()} + {this.state.error} + </Modal.Body> + </Modal> + ); + } +} diff --git a/webapp/components/admin_console/revoke_token_button/index.js b/webapp/components/admin_console/revoke_token_button/index.js new file mode 100644 index 000000000..6fada1bcc --- /dev/null +++ b/webapp/components/admin_console/revoke_token_button/index.js @@ -0,0 +1,24 @@ +// Copyright (c) 2017 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import {connect} from 'react-redux'; +import {bindActionCreators} from 'redux'; +import {revokeUserAccessToken} from 'mattermost-redux/actions/users'; + +import RevokeTokenButton from './revoke_token_button.jsx'; + +function mapStateToProps(state, ownProps) { + return { + ...ownProps + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + revokeUserAccessToken + }, dispatch) + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(RevokeTokenButton); diff --git a/webapp/components/admin_console/revoke_token_button/revoke_token_button.jsx b/webapp/components/admin_console/revoke_token_button/revoke_token_button.jsx new file mode 100644 index 000000000..4829a0cde --- /dev/null +++ b/webapp/components/admin_console/revoke_token_button/revoke_token_button.jsx @@ -0,0 +1,56 @@ +// Copyright (c) 2017-present Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; +import PropTypes from 'prop-types'; +import {FormattedMessage} from 'react-intl'; + +import {trackEvent} from 'actions/diagnostics_actions.jsx'; + +export default class RevokeTokenButton extends React.PureComponent { + static propTypes = { + + /* + * Token id to revoke + */ + tokenId: PropTypes.string.isRequired, + + /* + * Function to call on error + */ + onError: PropTypes.func.isRequired, + + actions: PropTypes.shape({ + + /** + * Function to revoke a user access token + */ + revokeUserAccessToken: PropTypes.func.isRequired + }).isRequired + }; + + handleClick = async (e) => { + e.preventDefault(); + + const {error} = await this.props.actions.revokeUserAccessToken(this.props.tokenId); + trackEvent('system_console', 'revoke_user_access_token'); + + if (error) { + this.props.onError(error.message); + } + } + + render() { + return ( + <button + className='btn btn-danger' + onClick={this.handleClick} + > + <FormattedMessage + id='admin.revoke_token_button.delete' + defaultMessage='Delete' + /> + </button> + ); + } +} diff --git a/webapp/components/admin_console/system_users/index.js b/webapp/components/admin_console/system_users/index.js index 8f1c0dc35..261a11d7e 100644 --- a/webapp/components/admin_console/system_users/index.js +++ b/webapp/components/admin_console/system_users/index.js @@ -4,7 +4,7 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; import {getTeams, getTeamStats} from 'mattermost-redux/actions/teams'; -import {getUser} from 'mattermost-redux/actions/users'; +import {getUser, getUserAccessToken} from 'mattermost-redux/actions/users'; import {getTeamsList} from 'mattermost-redux/selectors/entities/teams'; @@ -22,7 +22,8 @@ function mapDispatchToProps(dispatch) { actions: bindActionCreators({ getTeams, getTeamStats, - getUser + getUser, + getUserAccessToken }, dispatch) }; } diff --git a/webapp/components/admin_console/system_users/system_users.jsx b/webapp/components/admin_console/system_users/system_users.jsx index 5c8aa9bfd..4fbdc26d8 100644 --- a/webapp/components/admin_console/system_users/system_users.jsx +++ b/webapp/components/admin_console/system_users/system_users.jsx @@ -54,7 +54,12 @@ export default class SystemUsers extends React.Component { /* * Function to get a user */ - getUser: PropTypes.func.isRequired + getUser: PropTypes.func.isRequired, + + /* + * Function to get a user access token + */ + getUserAccessToken: PropTypes.func.isRequired }).isRequired } @@ -240,7 +245,7 @@ export default class SystemUsers extends React.Component { (users) => { if (users.length === 0 && term.length === USER_ID_LENGTH) { // This term didn't match any users name, but it does look like it might be a user's ID - this.getUserById(term); + this.getUserByTokenOrId(term); } else { this.setState({loading: false}); } @@ -269,6 +274,22 @@ export default class SystemUsers extends React.Component { ); } + getUserByTokenOrId = async (id) => { + if (global.window.mm_config.EnableUserAccessTokens === 'true') { + const {data} = await this.props.actions.getUserAccessToken(id); + + if (data) { + this.term = data.user_id; + this.setState({term: data.user_id}); + this.updateUsersFromStore(this.state.teamId, data.user_id); + this.getUserById(data.user_id); + return; + } + } + + this.getUserById(id); + } + renderFilterRow(doSearch) { const teams = this.props.teams.map((team) => { return ( diff --git a/webapp/components/admin_console/system_users/system_users_dropdown.jsx b/webapp/components/admin_console/system_users/system_users_dropdown.jsx index fe53ade44..1dbb6b325 100644 --- a/webapp/components/admin_console/system_users/system_users_dropdown.jsx +++ b/webapp/components/admin_console/system_users/system_users_dropdown.jsx @@ -8,7 +8,7 @@ import UserStore from 'stores/user_store.jsx'; import Constants from 'utils/constants.jsx'; import * as Utils from 'utils/utils.jsx'; -import {updateUserRoles, updateActive} from 'actions/user_actions.jsx'; +import {updateActive} from 'actions/user_actions.jsx'; import {adminResetMfa} from 'actions/admin_actions.jsx'; import {FormattedMessage} from 'react-intl'; @@ -19,28 +19,36 @@ import React from 'react'; export default class SystemUsersDropdown extends React.Component { static propTypes = { + + /* + * User to manage with dropdown + */ user: PropTypes.object.isRequired, + + /* + * Function to open password reset, takes user as an argument + */ doPasswordReset: PropTypes.func.isRequired, - doManageTeams: PropTypes.func.isRequired + + /* + * Function to open manage teams, takes user as an argument + */ + doManageTeams: PropTypes.func.isRequired, + + /* + * Function to open manage roles, takes user as an argument + */ + doManageRoles: PropTypes.func.isRequired, + + /* + * Function to open manage tokens, takes user as an argument + */ + doManageTokens: PropTypes.func.isRequired }; constructor(props) { super(props); - this.handleMakeMember = this.handleMakeMember.bind(this); - this.handleMakeActive = this.handleMakeActive.bind(this); - this.handleShowDeactivateMemberModal = this.handleShowDeactivateMemberModal.bind(this); - this.handleDeactivateMember = this.handleDeactivateMember.bind(this); - this.handleDeactivateCancel = this.handleDeactivateCancel.bind(this); - this.handleMakeSystemAdmin = this.handleMakeSystemAdmin.bind(this); - this.handleManageTeams = this.handleManageTeams.bind(this); - this.handleResetPassword = this.handleResetPassword.bind(this); - this.handleResetMfa = this.handleResetMfa.bind(this); - this.handleDemoteSystemAdmin = this.handleDemoteSystemAdmin.bind(this); - this.handleDemoteSubmit = this.handleDemoteSubmit.bind(this); - this.handleDemoteCancel = this.handleDemoteCancel.bind(this); - this.renderDeactivateMemberModal = this.renderDeactivateMemberModal.bind(this); - this.state = { serverError: null, showDemoteModal: false, @@ -50,61 +58,39 @@ export default class SystemUsersDropdown extends React.Component { }; } - doMakeMember() { - updateUserRoles( - this.props.user.id, - 'system_user', - null, + handleMakeActive = (e) => { + e.preventDefault(); + updateActive(this.props.user.id, true, null, (err) => { this.setState({serverError: err.message}); } ); } - handleMakeMember(e) { + handleManageTeams = (e) => { e.preventDefault(); - const me = UserStore.getCurrentUser(); - if (this.props.user.id === me.id && me.roles.includes('system_admin')) { - this.handleDemoteSystemAdmin(this.props.user, 'member'); - } else { - this.doMakeMember(); - } - } - handleMakeActive(e) { - e.preventDefault(); - updateActive(this.props.user.id, true, null, - (err) => { - this.setState({serverError: err.message}); - } - ); + this.props.doManageTeams(this.props.user); } - handleMakeSystemAdmin(e) { + handleManageRoles = (e) => { e.preventDefault(); - updateUserRoles( - this.props.user.id, - 'system_user system_admin', - null, - (err) => { - this.setState({serverError: err.message}); - } - ); + this.props.doManageRoles(this.props.user); } - handleManageTeams(e) { + handleManageTokens = (e) => { e.preventDefault(); - this.props.doManageTeams(this.props.user); + this.props.doManageTokens(this.props.user); } - handleResetPassword(e) { + handleResetPassword = (e) => { e.preventDefault(); this.props.doPasswordReset(this.props.user); } - handleResetMfa(e) { + handleResetMfa = (e) => { e.preventDefault(); adminResetMfa(this.props.user.id, @@ -115,7 +101,7 @@ export default class SystemUsersDropdown extends React.Component { ); } - handleDemoteSystemAdmin(user, role) { + handleDemoteSystemAdmin = (user, role) => { this.setState({ serverError: this.state.serverError, showDemoteModal: true, @@ -124,7 +110,7 @@ export default class SystemUsersDropdown extends React.Component { }); } - handleDemoteCancel() { + handleDemoteCancel = () => { this.setState({ serverError: null, showDemoteModal: false, @@ -133,7 +119,7 @@ export default class SystemUsersDropdown extends React.Component { }); } - handleDemoteSubmit() { + handleDemoteSubmit = () => { if (this.state.role === 'member') { this.doMakeMember(); } @@ -147,13 +133,13 @@ export default class SystemUsersDropdown extends React.Component { } } - handleShowDeactivateMemberModal(e) { + handleShowDeactivateMemberModal = (e) => { e.preventDefault(); this.setState({showDeactivateMemberModal: true}); } - handleDeactivateMember() { + handleDeactivateMember = () => { updateActive(this.props.user.id, false, null, (err) => { this.setState({serverError: err.message}); @@ -163,11 +149,11 @@ export default class SystemUsersDropdown extends React.Component { this.setState({showDeactivateMemberModal: false}); } - handleDeactivateCancel() { + handleDeactivateCancel = () => { this.setState({showDeactivateMemberModal: false}); } - renderDeactivateMemberModal() { + renderDeactivateMemberModal = () => { const title = ( <FormattedMessage id='deactivate_member_modal.title' @@ -240,8 +226,6 @@ export default class SystemUsersDropdown extends React.Component { } const me = UserStore.getCurrentUser(); - let showMakeMember = Utils.isSystemAdmin(user.roles); - let showMakeSystemAdmin = !Utils.isSystemAdmin(user.roles); let showMakeActive = false; let showMakeNotActive = !Utils.isSystemAdmin(user.roles); let showManageTeams = true; @@ -255,8 +239,6 @@ export default class SystemUsersDropdown extends React.Component { defaultMessage='Inactive' /> ); - showMakeMember = false; - showMakeSystemAdmin = false; showMakeActive = true; showMakeNotActive = false; showManageTeams = false; @@ -267,44 +249,6 @@ export default class SystemUsersDropdown extends React.Component { disableActivationToggle = true; } - let makeSystemAdmin = null; - if (showMakeSystemAdmin) { - makeSystemAdmin = ( - <li role='presentation'> - <a - id='makeSystemAdmin' - role='menuitem' - href='#' - onClick={this.handleMakeSystemAdmin} - > - <FormattedMessage - id='admin.user_item.makeSysAdmin' - defaultMessage='Make System Admin' - /> - </a> - </li> - ); - } - - let makeMember = null; - if (showMakeMember) { - makeMember = ( - <li role='presentation'> - <a - id='makeMember' - role='menuitem' - href='#' - onClick={this.handleMakeMember} - > - <FormattedMessage - id='admin.user_item.makeMember' - defaultMessage='Make Member' - /> - </a> - </li> - ); - } - let menuClass = ''; if (disableActivationToggle) { menuClass = 'disabled'; @@ -427,6 +371,25 @@ export default class SystemUsersDropdown extends React.Component { ); } + let manageTokens; + if (global.window.mm_config.EnableUserAccessTokens === 'true') { + manageTokens = ( + <li role='presentation'> + <a + id='manageTokens' + role='menuitem' + href='#' + onClick={this.handleManageTokens} + > + <FormattedMessage + id='admin.user_item.manageTokens' + defaultMessage='Manage Tokens' + /> + </a> + </li> + ); + } + let makeDemoteModal = null; if (this.props.user.id === me.id) { const title = ( @@ -498,11 +461,23 @@ export default class SystemUsersDropdown extends React.Component { className='dropdown-menu member-menu' role='menu' > - {makeMember} {makeActive} {makeNotActive} - {makeSystemAdmin} + <li role='presentation'> + <a + id='manageRoles' + role='menuitem' + href='#' + onClick={this.handleManageRoles} + > + <FormattedMessage + id='admin.user_item.manageRoles' + defaultMessage='Manage Roles' + /> + </a> + </li> {manageTeams} + {manageTokens} {mfaReset} {passwordReset} </ul> diff --git a/webapp/components/admin_console/system_users/system_users_list.jsx b/webapp/components/admin_console/system_users/system_users_list.jsx index 6d58137ff..2863f9cec 100644 --- a/webapp/components/admin_console/system_users/system_users_list.jsx +++ b/webapp/components/admin_console/system_users/system_users_list.jsx @@ -6,6 +6,8 @@ import PropTypes from 'prop-types'; import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; import ManageTeamsModal from 'components/admin_console/manage_teams_modal/manage_teams_modal.jsx'; +import ManageRolesModal from 'components/admin_console/manage_roles_modal'; +import ManageTokensModal from 'components/admin_console/manage_tokens_modal'; import ResetPasswordModal from 'components/admin_console/reset_password_modal.jsx'; import SearchableUserList from 'components/searchable_user_list/searchable_user_list.jsx'; @@ -14,6 +16,7 @@ const dispatch = store.dispatch; const getState = store.getState; import {getUser} from 'mattermost-redux/actions/users'; +import * as UserUtils from 'mattermost-redux/utils/user_utils'; import {Constants} from 'utils/constants.jsx'; import * as Utils from 'utils/utils.jsx'; @@ -37,21 +40,12 @@ export default class SystemUsersList extends React.Component { constructor(props) { super(props); - this.nextPage = this.nextPage.bind(this); - this.previousPage = this.previousPage.bind(this); - this.search = this.search.bind(this); - - this.doManageTeams = this.doManageTeams.bind(this); - this.doManageTeamsDismiss = this.doManageTeamsDismiss.bind(this); - - this.doPasswordReset = this.doPasswordReset.bind(this); - this.doPasswordResetDismiss = this.doPasswordResetDismiss.bind(this); - this.doPasswordResetSubmit = this.doPasswordResetSubmit.bind(this); - this.state = { page: 0, showManageTeamsModal: false, + showManageRolesModal: false, + showManageTokensModal: false, showPasswordModal: false, user: null }; @@ -63,17 +57,17 @@ export default class SystemUsersList extends React.Component { } } - nextPage() { + nextPage = () => { this.setState({page: this.state.page + 1}); this.props.nextPage(this.state.page + 1); } - previousPage() { + previousPage = () => { this.setState({page: this.state.page - 1}); } - search(term) { + search = (term) => { this.props.search(term); if (term !== '') { @@ -81,35 +75,63 @@ export default class SystemUsersList extends React.Component { } } - doManageTeams(user) { + doManageTeams = (user) => { this.setState({ showManageTeamsModal: true, user }); } - doManageTeamsDismiss() { + doManageRoles = (user) => { + this.setState({ + showManageRolesModal: true, + user + }); + } + + doManageTokens = (user) => { + this.setState({ + showManageTokensModal: true, + user + }); + } + + doManageTeamsDismiss = () => { this.setState({ showManageTeamsModal: false, user: null }); } - doPasswordReset(user) { + doManageRolesDismiss = () => { + this.setState({ + showManageRolesModal: false, + user: null + }); + } + + doManageTokensDismiss = () => { + this.setState({ + showManageTokensModal: false, + user: null + }); + } + + doPasswordReset = (user) => { this.setState({ showPasswordModal: true, user }); } - doPasswordResetDismiss() { + doPasswordResetDismiss = () => { this.setState({ showPasswordModal: false, user: null }); } - doPasswordResetSubmit(user) { + doPasswordResetSubmit = (user) => { getUser(user.id)(dispatch, getState); this.setState({ @@ -174,6 +196,35 @@ export default class SystemUsersList extends React.Component { } } + const userAccessTokensEnabled = global.window.mm_config.EnableUserAccessTokens === 'true'; + if (userAccessTokensEnabled) { + const hasPostAllRole = UserUtils.hasPostAllRole(user.roles); + const hasPostAllPublicRole = UserUtils.hasPostAllPublicRole(user.roles); + const hasUserAccessTokenRole = UserUtils.hasUserAccessTokenRole(user.roles); + const isSystemAdmin = UserUtils.isSystemAdmin(user.roles); + + let messageId = 'admin.user_item.userAccessTokenNo'; + if (hasUserAccessTokenRole || isSystemAdmin) { + if (isSystemAdmin) { + messageId = 'admin.user_item.userAccessTokenAdmin'; + } else if (hasPostAllRole) { + messageId = 'admin.user_item.userAccessTokenPostAll'; + } else if (hasPostAllPublicRole) { + messageId = 'admin.user_item.userAccessTokenPostAllPublic'; + } else { + messageId = 'admin.user_item.userAccessTokenYes'; + } + } + + info.push(', '); + info.push( + <FormattedHTMLMessage + key='admin.user_item.userAccessToken' + id={messageId} + /> + ); + } + return info; } @@ -236,7 +287,9 @@ export default class SystemUsersList extends React.Component { actions={[SystemUsersDropdown]} actionProps={{ doPasswordReset: this.doPasswordReset, - doManageTeams: this.doManageTeams + doManageTeams: this.doManageTeams, + doManageRoles: this.doManageRoles, + doManageTokens: this.doManageTokens }} nextPage={this.nextPage} previousPage={this.previousPage} @@ -250,6 +303,16 @@ export default class SystemUsersList extends React.Component { show={this.state.showManageTeamsModal} onModalDismissed={this.doManageTeamsDismiss} /> + <ManageRolesModal + user={this.state.user} + show={this.state.showManageRolesModal} + onModalDismissed={this.doManageRolesDismiss} + /> + <ManageTokensModal + user={this.state.user} + show={this.state.showManageTokensModal} + onModalDismissed={this.doManageTokensDismiss} + /> <ResetPasswordModal user={this.state.user} show={this.state.showPasswordModal} diff --git a/webapp/components/setting_item_max.jsx b/webapp/components/setting_item_max.jsx index 8e3aaf12c..1f0af181e 100644 --- a/webapp/components/setting_item_max.jsx +++ b/webapp/components/setting_item_max.jsx @@ -17,7 +17,7 @@ export default class SettingItemMax extends React.Component { } onKeyDown(e) { - if (e.keyCode === Constants.KeyCodes.ENTER) { + if (e.keyCode === Constants.KeyCodes.ENTER && this.props.submit) { this.props.submit(e); } } @@ -60,8 +60,13 @@ export default class SettingItemMax extends React.Component { } var extraInfo = null; + let hintClass = 'setting-list__hint'; + if (this.props.infoPosition === 'top') { + hintClass = 'padding-bottom x2'; + } + if (this.props.extraInfo) { - extraInfo = (<div className='setting-list__hint'>{this.props.extraInfo}</div>); + extraInfo = (<div className={hintClass}>{this.props.extraInfo}</div>); } var submit = ''; @@ -95,15 +100,40 @@ export default class SettingItemMax extends React.Component { titleProp = this.props.title; } + let listContent = ( + <li className='setting-list-item'> + {inputs} + {extraInfo} + </li> + ); + + if (this.props.infoPosition === 'top') { + listContent = ( + <li> + {extraInfo} + {inputs} + </li> + ); + } + + let cancelButtonText; + if (this.props.cancelButtonText) { + cancelButtonText = this.props.cancelButtonText; + } else { + cancelButtonText = ( + <FormattedMessage + id='setting_item_max.cancel' + defaultMessage='Cancel' + /> + ); + } + return ( <ul className='section-max form-horizontal'> {title} <li className={widthClass}> <ul className='setting-list'> - <li className='setting-list-item'> - {inputs} - {extraInfo} - </li> + {listContent} <li className='setting-list-item'> <hr/> {this.props.submitExtra} @@ -116,10 +146,7 @@ export default class SettingItemMax extends React.Component { href='#' onClick={this.props.updateSection} > - <FormattedMessage - id='setting_item_max.cancel' - defaultMessage='Cancel' - /> + {cancelButtonText} </a> </li> </ul> @@ -134,9 +161,15 @@ SettingItemMax.propTypes = { client_error: PropTypes.string, server_error: PropTypes.string, extraInfo: PropTypes.element, + infoPosition: PropTypes.string, updateSection: PropTypes.func, submit: PropTypes.func, title: PropTypes.node, width: PropTypes.string, - submitExtra: PropTypes.node + submitExtra: PropTypes.node, + cancelButtonText: PropTypes.node +}; + +SettingItemMax.defaultProps = { + infoPosition: 'bottom' }; diff --git a/webapp/components/user_settings/user_settings_security/index.js b/webapp/components/user_settings/user_settings_security/index.js index cdbabd055..a3e83d7de 100644 --- a/webapp/components/user_settings/user_settings_security/index.js +++ b/webapp/components/user_settings/user_settings_security/index.js @@ -3,20 +3,30 @@ import {connect} from 'react-redux'; import {bindActionCreators} from 'redux'; -import {getMe} from 'mattermost-redux/actions/users'; +import {getMe, getUserAccessTokensForUser, createUserAccessToken, revokeUserAccessToken, clearUserAccessTokens} from 'mattermost-redux/actions/users'; +import * as UserUtils from 'mattermost-redux/utils/user_utils'; import SecurityTab from './user_settings_security.jsx'; function mapStateToProps(state, ownProps) { + const tokensEnabled = state.entities.general.config.EnableUserAccessTokens === 'true'; + const userHasTokenRole = UserUtils.hasUserAccessTokenRole(ownProps.user.roles) || UserUtils.isSystemAdmin(ownProps.user.roles); + return { - ...ownProps + ...ownProps, + userAccessTokens: state.entities.users.myUserAccessTokens, + canUseAccessTokens: tokensEnabled && userHasTokenRole }; } function mapDispatchToProps(dispatch) { return { actions: bindActionCreators({ - getMe + getMe, + getUserAccessTokensForUser, + createUserAccessToken, + revokeUserAccessToken, + clearUserAccessTokens }, dispatch) }; } diff --git a/webapp/components/user_settings/user_settings_security/user_settings_security.jsx b/webapp/components/user_settings/user_settings_security/user_settings_security.jsx index b8ec690a4..5c9ad67e3 100644 --- a/webapp/components/user_settings/user_settings_security/user_settings_security.jsx +++ b/webapp/components/user_settings/user_settings_security/user_settings_security.jsx @@ -6,6 +6,7 @@ import SettingItemMax from 'components/setting_item_max.jsx'; import AccessHistoryModal from 'components/access_history_modal'; import ActivityLogModal from 'components/activity_log_modal'; import ToggleModalButton from 'components/toggle_modal_button.jsx'; +import ConfirmModal from 'components/confirm_modal.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; @@ -13,15 +14,22 @@ import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; import {updatePassword, getAuthorizedApps, deactivateMfa, deauthorizeOAuthApp} from 'actions/user_actions.jsx'; +import {trackEvent} from 'actions/diagnostics_actions.jsx'; +import {isMobile} from 'utils/user_agent.jsx'; import $ from 'jquery'; import PropTypes from 'prop-types'; import React from 'react'; -import {FormattedMessage, FormattedTime, FormattedDate} from 'react-intl'; +import * as UserUtils from 'mattermost-redux/utils/user_utils'; +import {FormattedMessage, FormattedTime, FormattedDate, FormattedHTMLMessage} from 'react-intl'; import {browserHistory, Link} from 'react-router/es6'; import icon50 from 'images/icon50x50.png'; +const TOKEN_CREATING = 'creating'; +const TOKEN_CREATED = 'created'; +const TOKEN_NOT_CREATING = 'not_creating'; + export default class SecurityTab extends React.Component { static propTypes = { user: PropTypes.object, @@ -31,26 +39,45 @@ export default class SecurityTab extends React.Component { closeModal: PropTypes.func.isRequired, collapseModal: PropTypes.func.isRequired, setEnforceFocus: PropTypes.func.isRequired, + + /* + * The user access tokens for the user + */ + userAccessTokens: PropTypes.object, + + /* + * Set if access tokens are enabled and this user can use them + */ + canUseAccessTokens: PropTypes.bool, + actions: PropTypes.shape({ - getMe: PropTypes.func.isRequired + getMe: PropTypes.func.isRequired, + + /* + * Function to get user access tokens for a user + */ + getUserAccessTokensForUser: PropTypes.func.isRequired, + + /* + * Function to create a user access token + */ + createUserAccessToken: PropTypes.func.isRequired, + + /* + * Function to revoke a user access token + */ + revokeUserAccessToken: PropTypes.func.isRequired, + + /* + * Function to clear user access tokens locally + */ + clearUserAccessTokens: PropTypes.func.isRequired }).isRequired } constructor(props) { super(props); - this.submitPassword = this.submitPassword.bind(this); - this.setupMfa = this.setupMfa.bind(this); - this.removeMfa = this.removeMfa.bind(this); - this.updateCurrentPassword = this.updateCurrentPassword.bind(this); - this.updateNewPassword = this.updateNewPassword.bind(this); - this.updateConfirmPassword = this.updateConfirmPassword.bind(this); - this.getDefaultState = this.getDefaultState.bind(this); - this.createPasswordSection = this.createPasswordSection.bind(this); - this.createSignInSection = this.createSignInSection.bind(this); - this.createOAuthAppsSection = this.createOAuthAppsSection.bind(this); - this.deauthorizeApp = this.deauthorizeApp.bind(this); - this.state = this.getDefaultState(); } @@ -61,6 +88,8 @@ export default class SecurityTab extends React.Component { confirmPassword: '', passwordError: '', serverError: '', + tokenError: '', + showConfirmModal: false, authService: this.props.user.auth_service }; } @@ -73,11 +102,18 @@ export default class SecurityTab extends React.Component { }, (err) => { this.setState({serverError: err.message}); //eslint-disable-line react/no-did-mount-set-state - }); + } + ); + } + + if (this.props.canUseAccessTokens) { + this.props.actions.clearUserAccessTokens(); + const userId = this.props.user ? this.props.user.id : ''; + this.props.actions.getUserAccessTokensForUser(userId, 0, 200); } } - submitPassword(e) { + submitPassword = (e) => { e.preventDefault(); var user = this.props.user; @@ -127,12 +163,12 @@ export default class SecurityTab extends React.Component { ); } - setupMfa(e) { + setupMfa = (e) => { e.preventDefault(); browserHistory.push('/mfa/setup'); } - removeMfa() { + removeMfa = () => { deactivateMfa( () => { if (global.window.mm_license.MFA === 'true' && @@ -157,19 +193,19 @@ export default class SecurityTab extends React.Component { ); } - updateCurrentPassword(e) { + updateCurrentPassword = (e) => { this.setState({currentPassword: e.target.value}); } - updateNewPassword(e) { + updateNewPassword = (e) => { this.setState({newPassword: e.target.value}); } - updateConfirmPassword(e) { + updateConfirmPassword = (e) => { this.setState({confirmPassword: e.target.value}); } - deauthorizeApp(e) { + deauthorizeApp = (e) => { e.preventDefault(); const appId = e.currentTarget.getAttribute('data-app'); deauthorizeOAuthApp( @@ -183,10 +219,11 @@ export default class SecurityTab extends React.Component { }, (err) => { this.setState({serverError: err.message}); - }); + } + ); } - createMfaSection() { + createMfaSection = () => { let updateSectionStatus; let submit; @@ -321,7 +358,7 @@ export default class SecurityTab extends React.Component { ); } - createPasswordSection() { + createPasswordSection = () => { let updateSectionStatus; if (this.props.activeSection === 'password') { @@ -578,7 +615,7 @@ export default class SecurityTab extends React.Component { ); } - createSignInSection() { + createSignInSection = () => { let updateSectionStatus; const user = this.props.user; @@ -793,7 +830,7 @@ export default class SecurityTab extends React.Component { ); } - createOAuthAppsSection() { + createOAuthAppsSection = () => { let updateSectionStatus; if (this.props.activeSection === 'apps') { @@ -929,6 +966,368 @@ export default class SecurityTab extends React.Component { ); } + startCreatingToken = () => { + this.setState({tokenCreationState: TOKEN_CREATING}); + } + + stopCreatingToken = () => { + this.setState({tokenCreationState: TOKEN_NOT_CREATING}); + } + + handleCreateToken = async () => { + this.handleCancelConfirm(); + + const description = this.refs.newtokendescription ? this.refs.newtokendescription.value : ''; + + if (description === '') { + this.setState({tokenError: Utils.localizeMessage('user.settings.tokens.nameRequired', 'Please enter a name.')}); + return; + } + + this.setState({tokenError: ''}); + + const userId = this.props.user ? this.props.user.id : ''; + const {data, error} = await this.props.actions.createUserAccessToken(userId, description); + + if (data) { + this.setState({tokenCreationState: TOKEN_CREATED, newToken: data}); + } else if (error) { + this.setState({serverError: error.message}); + } + } + + handleCancelConfirm = () => { + this.setState({ + showConfirmModal: false, + confirmTitle: null, + confirmMessage: null, + confirmButton: null, + confirmComplete: null + }); + } + + confirmCreateToken = () => { + if (UserUtils.isSystemAdmin(this.props.user.roles)) { + this.setState({ + showConfirmModal: true, + confirmTitle: ( + <FormattedMessage + id='user.settings.tokens.confirmCreateTitle' + defaultMessage='Create System Admin User Access Token' + /> + ), + confirmMessage: ( + <div className='alert alert-danger'> + <FormattedHTMLMessage + id='user.settings.tokens.confirmCreateMessage' + defaultMessage='You are generating a user access token with System Admin permissions. Are you sure want to create this token?' + /> + </div> + ), + confirmButton: ( + <FormattedMessage + id='user.settings.tokens.confirmCreateButton' + defaultMessage='Yes, Create' + /> + ), + confirmComplete: () => { + this.handleCreateToken(); + trackEvent('settings', 'system_admin_create_user_access_token'); + } + }); + + return; + } + + this.handleCreateToken(); + } + + saveTokenKeyPress = (e) => { + if (e.which === Constants.KeyCodes.ENTER) { + this.confirmCreateToken(); + } + } + + confirmRevokeToken = (tokenId) => { + const token = this.props.userAccessTokens[tokenId]; + + this.setState({ + showConfirmModal: true, + confirmTitle: ( + <FormattedMessage + id='user.settings.tokens.confirmDeleteTitle' + defaultMessage='Delete {name} Token?' + values={{ + name: token.description + }} + /> + ), + confirmMessage: ( + <div className='alert alert-danger'> + <FormattedHTMLMessage + id='user.settings.tokens.confirmDeleteMessage' + defaultMessage='Any integrations using this token will no longer be able to access the Mattermost API. You cannot undo this action. Are you sure want to delete this token?' + /> + </div> + ), + confirmButton: ( + <FormattedMessage + id='user.settings.tokens.confirmDeleteButton' + defaultMessage='Yes, Delete' + /> + ), + confirmComplete: () => { + this.revokeToken(tokenId); + trackEvent('settings', 'revoke_user_access_token'); + } + }); + } + + revokeToken = async (tokenId) => { + const {error} = await this.props.actions.revokeUserAccessToken(tokenId); + if (error) { + this.setState({serverError: error.message}); + } + this.handleCancelConfirm(); + } + + createTokensSection = () => { + let updateSectionStatus; + + if (this.props.activeSection === 'tokens') { + const tokenList = []; + Object.values(this.props.userAccessTokens).forEach((token) => { + if (this.state.newToken && this.state.newToken.id === token.id) { + return; + } + + tokenList.push( + <div + key={token.id} + className='setting-box__item' + > + <div className='whitespace--nowrap overflow--ellipsis'> + <strong>{token.description}</strong> + </div> + <div className='setting-box__token-id whitespace--nowrap overflow--ellipsis'> + <FormattedMessage + id='user.settings.tokens.tokenId' + defaultMessage='Token ID: ' + /> + {token.id} + </div> + <div> + <a + name={token.id} + href='#' + onClick={(e) => { + e.preventDefault(); + this.confirmRevokeToken(token.id); + }} + > + <FormattedMessage + id='user.settings.tokens.delete' + defaultMessage='Delete' + /> + </a> + </div> + <hr className='margin-bottom margin-top x2'/> + </div> + ); + }); + + if (tokenList.length === 0) { + tokenList.push( + <FormattedMessage + key='notokens' + id='user.settings.tokens.userAccessTokensNone' + defaultMessage='No user access tokens.' + /> + ); + } + let extraInfo; + + if (isMobile()) { + extraInfo = ( + <span> + <FormattedHTMLMessage + id='user.settings.tokens.description_mobile' + defaultMessage='<a href="https://about.mattermost.com/default-user-access-tokens" target="_blank">User access tokens</a> function similar to session tokens and can be used by integrations to <a href="https://about.mattermost.com/default-api-authentication" target="_blank">authenticate against the REST API</a>. Create new tokens on your desktop.' + /> + </span> + ); + } else { + extraInfo = ( + <span> + <FormattedHTMLMessage + id='user.settings.tokens.description' + defaultMessage='<a href="https://about.mattermost.com/default-user-access-tokens" target="_blank">User access tokens</a> function similar to session tokens and can be used by integrations to <a href="https://about.mattermost.com/default-api-authentication" target="_blank">authenticate against the REST API</a>.' + /> + </span> + ); + } + + let newTokenSection; + if (this.state.tokenCreationState === TOKEN_CREATING) { + newTokenSection = ( + <div className='padding-left x2'> + <div className='row'> + <label className='col-sm-auto control-label padding-right x2'> + <FormattedMessage + id='user.settings.tokens.name' + defaultMessage='Name: ' + /> + </label> + <div className='col-sm-5'> + <input + ref='newtokendescription' + className='form-control' + type='text' + maxLength={64} + onKeyPress={this.saveTokenKeyPress} + /> + </div> + </div> + <div> + <div className='padding-top x2'> + <FormattedMessage + id='user.settings.tokens.nameDescription' + defaultMessage='Give a name for your token, so you remember what it’s used for. A token is generated after you hit "Save".' + /> + </div> + <div> + <label + id='clientError' + className='has-error margin-top margin-bottom' + > + {this.state.tokenError} + </label> + </div> + <button + className='btn btn-primary' + onClick={this.confirmCreateToken} + > + <FormattedMessage + id='user.settings.tokens.save' + defaultMessage='Save' + /> + </button> + <button + className='btn btn-default' + onClick={this.stopCreatingToken} + > + <FormattedMessage + id='user.settings.tokens.cancel' + defaultMessage='Cancel' + /> + </button> + </div> + </div> + ); + } else if (this.state.tokenCreationState === TOKEN_CREATED) { + newTokenSection = ( + <div + className='alert alert-warning' + > + <i className='fa fa-warning margin-right'/> + <FormattedMessage + id='user.settings.tokens.copy' + defaultMessage="Please copy the token below. You won't be able to see it again!" + /> + <br/> + <br/> + <FormattedMessage + id='user.settings.tokens.name' + defaultMessage='Name: ' + /> + {this.state.newToken.description} + <br/> + <FormattedMessage + id='user.settings.tokens.id' + defaultMessage='ID: ' + /> + {this.state.newToken.id} + <br/> + <strong> + <FormattedMessage + id='user.settings.tokens.token' + defaultMessage='Token: ' + /> + {this.state.newToken.token} + </strong> + </div> + ); + } else { + newTokenSection = ( + <a + className='btn btn-primary' + href='#' + onClick={this.startCreatingToken} + > + <FormattedMessage + id='user.settings.tokens.create' + defaultMessage='Create New Token' + /> + </a> + ); + } + + const inputs = []; + inputs.push( + <div + key='tokensSetting' + className='padding-top' + > + <div key='tokenList'> + <div className='alert alert-transparent'> + {tokenList} + </div> + <br/> + {newTokenSection} + </div> + </div> + ); + + updateSectionStatus = function resetSection(e) { + this.props.updateSection(''); + this.setState({newToken: null, tokenCreationState: TOKEN_NOT_CREATING, serverError: null, tokenError: ''}); + e.preventDefault(); + }.bind(this); + + return ( + <SettingItemMax + title={Utils.localizeMessage('user.settings.tokens.title', 'User Access Tokens')} + inputs={inputs} + extraInfo={extraInfo} + infoPosition='top' + server_error={this.state.serverError} + updateSection={updateSectionStatus} + width='full' + cancelButtonText={ + <FormattedMessage + id='user.settings.security.close' + defaultMessage='Close' + /> + } + /> + ); + } + + const describe = Utils.localizeMessage('user.settings.tokens.clickToEdit', "Click 'Edit' to manage your user access tokens"); + + updateSectionStatus = function updateSection() { + this.props.updateSection('tokens'); + }.bind(this); + + return ( + <SettingItemMin + title={Utils.localizeMessage('user.settings.tokens.title', 'User Access Tokens')} + describe={describe} + updateSection={updateSectionStatus} + /> + ); + } + render() { const user = this.props.user; const config = window.mm_config; @@ -959,6 +1358,11 @@ export default class SecurityTab extends React.Component { oauthSection = this.createOAuthAppsSection(); } + let tokensSection; + if (this.props.canUseAccessTokens) { + tokensSection = this.createTokensSection(); + } + return ( <div> <div className='modal-header'> @@ -1001,6 +1405,8 @@ export default class SecurityTab extends React.Component { <div className='divider-light'/> {oauthSection} <div className='divider-light'/> + {tokensSection} + <div className='divider-light'/> {signInSection} <div className='divider-dark'/> <br/> @@ -1014,7 +1420,7 @@ export default class SecurityTab extends React.Component { defaultMessage='View Access History' /> </ToggleModalButton> - <b/> + <br/> <ToggleModalButton className='security-links theme' dialogType={ActivityLogModal} @@ -1026,6 +1432,14 @@ export default class SecurityTab extends React.Component { /> </ToggleModalButton> </div> + <ConfirmModal + title={this.state.confirmTitle} + message={this.state.confirmMessage} + confirmButtonText={this.state.confirmButton} + show={this.state.showConfirmModal} + onConfirm={this.state.confirmComplete || (() => {})} //eslint-disable-line no-empty-function + onCancel={this.handleCancelConfirm} + /> </div> ); } diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 79d291e47..58674dd3c 100755 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -745,6 +745,10 @@ "admin.select_team.close": "Close", "admin.select_team.select": "Select", "admin.select_team.selectTeam": "Select Team", + "admin.service.userAccessTokensTitle": "Enable User Access Tokens: ", + "admin.service.userAccessTokensDescription": "When true, users can create <a href=\"https://about.mattermost.com/default-user-access-tokens\" target=\"_blank\">user access tokens</a> for integrations in <strong>Account Settings > Security</strong>. They can be used to authenticate against the API and give full access to the account.<br/><br/>To manage who can create user access tokens, go to the <strong>System Console > Users</strong> page.", + "admin.service.userAccessTokensNameLabel": "Name: ", + "admin.service.userAccessTokensIdLabel": "Token ID: ", "admin.service.attemptDescription": "Number of login attempts allowed before a user is locked out and required to reset their password via email.", "admin.service.attemptExample": "E.g.: \"10\"", "admin.service.attemptTitle": "Maximum Login Attempts:", @@ -954,6 +958,26 @@ "admin.team_analytics.activeUsers": "Active Users With Posts", "admin.team_analytics.totalPosts": "Total Posts", "admin.true": "true", + "admin.manage_tokens.userAccessTokensNone": "No user access tokens.", + "admin.manage_tokens.manageTokensTitle": "Manage User Access Tokens", + "admin.manage_tokens.userAccessTokensDescription": "User access tokens function similar to session tokens and can be used by integrations to <a href=\"https://about.mattermost.com/default-api-authentication\" target=\"_blank\">authenticate against the REST API</a>. Learn more about <a href=\"https://about.mattermost.com/default-user-access-tokens\" target=\"_blank\">user access tokens</a>.", + "admin.manage_roles.saveError": "Unable to save roles.", + "admin.manage_roles.additionalRoles": "Select additional permissions for the account. <a href=\"https://about.mattermost.com/default-permissions\" target=\"_blank\">Read more about roles and permissions</a>.", + "admin.manage_roles.postAllRoleTitle": "post:all", + "admin.manage_roles.postAllRole": "Access to post to all Mattermost channels including direct messages.", + "admin.manage_roles.postAllPublicRoleTitle": "post:channels", + "admin.manage_roles.postAllPublicRole": "Access to post to all Mattermost public channels.", + "admin.manage_roles.allowUserAccessTokens": "Allow this account to generate <a href=\"https://about.mattermost.com/default-user-access-tokens\" target=\"_blank\">user access tokens</a>.", + "admin.manage_roles.systemAdmin": "System Admin", + "admin.manage_roles.systemMember": "Member", + "admin.manage_roles.manageRolesTitle": "Manage Roles", + "admin.manage_roles.cancel": "Cancel", + "admin.manage_roles.save": "Save", + "admin.user_item.userAccessTokenNo": "<strong>User Access Tokens:</strong> No", + "admin.user_item.userAccessTokenAdmin": "<strong>User Access Tokens:</strong> Yes (with system_admin)", + "admin.user_item.userAccessTokenPostAll": "<strong>User Access Tokens:</strong> Yes (with post:all)", + "admin.user_item.userAccessTokenPostAllPublic": "<strong>User Access Tokens:</strong> Yes (with post:channels)", + "admin.user_item.userAccessTokenYes": "<strong>User Access Tokens:</strong> Yes", "admin.user_item.authServiceEmail": "<strong>Sign-in Method:</strong> Email", "admin.user_item.authServiceNotEmail": "<strong>Sign-in Method:</strong> {service}", "admin.user_item.confirmDemoteDescription": "If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you'll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command.", @@ -968,6 +992,8 @@ "admin.user_item.makeSysAdmin": "Make System Admin", "admin.user_item.makeTeamAdmin": "Make Team Admin", "admin.user_item.manageTeams": "Manage Teams", + "admin.user_item.manageRoles": "Manage Roles", + "admin.user_item.manageTokens": "Manage Tokens", "admin.user_item.member": "Member", "admin.user_item.mfaNo": "<strong>MFA</strong>: No", "admin.user_item.mfaYes": "<strong>MFA</strong>: Yes", @@ -2434,6 +2460,27 @@ "user.settings.push_notification.send": "Send mobile push notifications", "user.settings.push_notification.status": "Trigger push notifications when", "user.settings.push_notification.status_info": "Notification alerts are only pushed to your mobile device when your online status matches the selection above.", + "user.settings.tokens.confirmCreateTitle": "Create System Admin User Access Token", + "user.settings.tokens.confirmCreateMessage": "You are generating a user access token with System Admin permissions. Are you sure want to create this token?", + "user.settings.tokens.confirmCreateButton": "Yes, Create", + "user.settings.tokens.confirmDeleteTitle": "Delete {name} Token?", + "user.settings.tokens.confirmDeleteMessage": "Any integrations using this token will no longer be able to access the Mattermost API. You cannot undo this action. Are you sure want to delete this token?", + "user.settings.tokens.confirmDeleteButton": "Yes, Delete", + "user.settings.tokens.tokenId": "Token ID: ", + "user.settings.tokens.delete": "Delete", + "user.settings.tokens.userAccessTokensNone": "No user access tokens.", + "user.settings.tokens.description": "<a href=\"https://about.mattermost.com/default-user-access-tokens\" target=\"_blank\">User access tokens</a> function similar to session tokens and can be used by integrations to <a href=\"https://about.mattermost.com/default-api-authentication\" target=\"_blank\">authenticate against the REST API</a>.", + "user.settings.tokens.description_mobile": "<a href=\"https://about.mattermost.com/default-user-access-tokens\" target=\"_blank\">User access tokens</a> function similar to session tokens and can be used by integrations to <a href=\"https://about.mattermost.com/default-api-authentication\" target=\"_blank\">authenticate against the REST API</a>. Create new tokens on your desktop.", + "user.settings.tokens.name": "Name: ", + "user.settings.tokens.nameDescription": "Give a name for your token, so you remember what it’s used for. A token is generated after you hit \"Save\".", + "user.settings.tokens.save": "Save", + "user.settings.tokens.cancel": "Cancel", + "user.settings.tokens.id": "ID: ", + "user.settings.tokens.token": "Token: ", + "user.settings.tokens.copy": "Please copy the token below. You won't be able to see it again!", + "user.settings.tokens.create": "Create New Token", + "user.settings.tokens.title": "User Access Tokens", + "user.settings.tokens.clickToEdit": "Click 'Edit' to manage your user access tokens", "user.settings.security.active": "Active", "user.settings.security.close": "Close", "user.settings.security.currentPassword": "Current Password", diff --git a/webapp/sass/components/_alerts.scss b/webapp/sass/components/_alerts.scss index cb4c9c9e1..e0444de39 100644 --- a/webapp/sass/components/_alerts.scss +++ b/webapp/sass/components/_alerts.scss @@ -11,3 +11,4 @@ margin: 1px 0 0 10px; padding: 4px 10px; } + diff --git a/webapp/sass/layout/_content.scss b/webapp/sass/layout/_content.scss index 933f57c32..7bf6c08ad 100644 --- a/webapp/sass/layout/_content.scss +++ b/webapp/sass/layout/_content.scss @@ -87,3 +87,9 @@ .delete-message-text { margin-top: 10px; } + +.col-sm-auto { + padding-left: 15px; + padding-right: 15px; +} + diff --git a/webapp/sass/layout/_forms.scss b/webapp/sass/layout/_forms.scss index 143879e2c..a49acf3e3 100644 --- a/webapp/sass/layout/_forms.scss +++ b/webapp/sass/layout/_forms.scss @@ -91,35 +91,3 @@ } } } - -.padding-top { - padding-top: 7px; - - &.x2 { - padding-top: 14px; - } - - &.x3 { - padding-top: 21px; - } -} - -.padding-bottom { - padding-bottom: 7px; - - &.x2 { - padding-bottom: 14px; - } - - &.x3 { - padding-bottom: 21px; - } - - .control-label { - font-weight: 600; - - &.text-left { - text-align: left; - } - } -} diff --git a/webapp/sass/responsive/_tablet.scss b/webapp/sass/responsive/_tablet.scss index ef4c2e8e5..ef68d5175 100644 --- a/webapp/sass/responsive/_tablet.scss +++ b/webapp/sass/responsive/_tablet.scss @@ -212,12 +212,14 @@ } } } + .post { .attachment { .attachment__image { &.attachment__image--openraph { max-height: 70px; max-width: 300px; + &.loading { height: 70px; } @@ -229,6 +231,10 @@ // Tablet and desktop @media screen and (min-width: 768px) { + .col-sm-auto { + float: left; + } + .second-bar { display: none; } diff --git a/webapp/sass/routes/_admin-console.scss b/webapp/sass/routes/_admin-console.scss index 4fe45d9b8..2d4ca6be1 100644 --- a/webapp/sass/routes/_admin-console.scss +++ b/webapp/sass/routes/_admin-console.scss @@ -78,7 +78,7 @@ .log__panel { background-color: white; - border: 1px solid #ddd; + border: $border-gray; height: calc(100vh - 200px); margin-top: 10px; overflow: scroll; @@ -180,7 +180,7 @@ .banner { background: $white; - border: 1px solid #ddd; + border: $border-gray; font-size: .95em; margin: 2em 0; padding: .8em 1.5rem; @@ -535,11 +535,34 @@ .manage-teams { .manage-teams__user { align-items: center; - border-bottom-color: lightgray; - border-bottom-style: solid; - border-bottom-width: 1px; display: flex; - padding-bottom: 15px; + } + + .manage-teams__teams { + border-top: $border-gray; + margin: 1em 0 .3em; + + .btn-link { + &.danger { + color: #c55151; + } + } + } + + .member-row--padded { + padding-left: 20px; + + strong { + margin-right: 10px; + } + } + + .manage-row--inner { + padding: 15px 0 4px; + + & + div { + border-top: $border-gray; + } } .manage-teams__profile-picture { @@ -573,21 +596,31 @@ padding-right: 10px; } + .manage-teams__teams { + margin-top: 1em; + + .manage-row__empty { + padding: 9px 0; + } + } + .manage-teams__team { align-items: center; + border-bottom: $border-gray; display: flex; - padding: 10px; - } + padding: 7px 10px; - .manage-teams__team + .manage-teams__team { - border-top-color: lightgray; - border-top-style: solid; - border-top-width: 1px; + .btn { + font-size: .9em; + } + + .dropdown { + padding: 6px 0; + } } .manage-teams__team-name { flex: 1; - font-weight: bold; overflow: hidden; text-overflow: ellipsis; } diff --git a/webapp/sass/routes/_settings.scss b/webapp/sass/routes/_settings.scss index 3c5565194..f33417200 100644 --- a/webapp/sass/routes/_settings.scss +++ b/webapp/sass/routes/_settings.scss @@ -360,6 +360,22 @@ padding: 0; } + .setting-box__item { + &:first-child { + padding-top: 3px; + } + + &:last-child { + hr { + display: none; + } + } + } + + .setting-box__token-id { + margin: 4px 0; + } + .setting-list__hint { margin-top: 20px; } diff --git a/webapp/sass/utils/_modifiers.scss b/webapp/sass/utils/_modifiers.scss index aa89fc107..467b9a086 100644 --- a/webapp/sass/utils/_modifiers.scss +++ b/webapp/sass/utils/_modifiers.scss @@ -1,33 +1,105 @@ @charset 'UTF-8'; -.margin--right { - margin-right: 5px; +.padding-top { + padding-top: 7px; &.x2 { - margin-right: 10px; + padding-top: 14px; + } + + &.x3 { + padding-top: 21px; + } +} + +.padding-bottom { + padding-bottom: 7px; + + &.x2 { + padding-bottom: 14px; + } + + &.x3 { + padding-bottom: 21px; + } + + .control-label { + font-weight: 600; + + &.text-left { + text-align: left; + } } } -.margin--left { - margin-left: 5px; +.padding-left { + padding-left: 7px; &.x2 { - margin-left: 10px; + padding-left: 14px; + } + + &.x3 { + padding-left: 21px; } } -.padding--right { - padding-right: 5px; +.padding-right { + padding-right: 7px; &.x2 { - padding-right: 10px; + padding-right: 14px; + } + + &.x3 { + padding-right: 21px; } } -.padding--left { - padding-left: 5px; +.margin-right { + margin-right: 7px; &.x2 { - padding-left: 10px; + margin-right: 14px; + } + + &.x3 { + margin-right: 21px; + } +} + +.margin-left { + margin-left: 7px; + + &.x2 { + margin-left: 14px; + } + + &.x3 { + margin-left: 21px; + } +} + +.margin-top { + margin-top: 7px; + + &.x2 { + margin-top: 14px; + } + + &.x3 { + margin-top: 21px; + } +} + +.margin-bottom { + margin-bottom: 7px; + + &.x2 { + margin-bottom: 14px; + } + + &.x3 { + margin-bottom: 21px; } } diff --git a/webapp/utils/utils.jsx b/webapp/utils/utils.jsx index 658ccd74b..94a2cf286 100644 --- a/webapp/utils/utils.jsx +++ b/webapp/utils/utils.jsx @@ -579,7 +579,7 @@ export function applyTheme(theme) { if (theme.centerChannelBg) { changeCss('@media(min-width: 768px){.app__body .post:hover .post__header .col__reply, .app__body .post.post--hovered .post__header .col__reply', 'background:' + theme.centerChannelBg); changeCss('@media(max-width: 320px){.tutorial-steps__container', 'background:' + theme.centerChannelBg); - changeCss('.app__body .status-wrapper .status_dropdown__toggle .status .icon__container:after, .app__body .app__content, .app__body .markdown__table, .app__body .markdown__table tbody tr, .app__body .suggestion-list__content, .app__body .modal .modal-content, .app__body .modal .modal-footer, .app__body .post.post--compact .post-image__column, .app__body .suggestion-list__divider > span, .app__body .status-wrapper .status', 'background:' + theme.centerChannelBg); + changeCss('.app__body .status-wrapper .status_dropdown__toggle .status .icon__container:after, .app__body .app__content, .app__body .markdown__table, .app__body .markdown__table tbody tr, .app__body .suggestion-list__content, .app__body .modal .modal-content, .app__body .modal .modal-footer, .app__body .post.post--compact .post-image__column, .app__body .suggestion-list__divider > span, .app__body .status-wrapper .status, .app__body .alert.alert-transparent', 'background:' + theme.centerChannelBg); changeCss('#post-list .post-list-holder-by-time, .app__body .post .dropdown-menu a', 'background:' + theme.centerChannelBg); changeCss('#post-create', 'background:' + theme.centerChannelBg); changeCss('.app__body .date-separator .separator__text, .app__body .new-separator .separator__text', 'background:' + theme.centerChannelBg); @@ -601,7 +601,7 @@ export function applyTheme(theme) { if (theme.centerChannelColor) { changeCss('.app__body .mentions__name .status.status--group, .app__body .multi-select__note', 'background:' + changeOpacity(theme.centerChannelColor, 0.12)); - changeCss('.app__body .channel-header .channel-header__icon, .app__body .search-bar__container .search__form', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.12)); + changeCss('.app__body .alert.alert-transparent, .app__body .channel-header .channel-header__icon, .app__body .search-bar__container .search__form', 'border-color:' + changeOpacity(theme.centerChannelColor, 0.12)); changeCss('.app__body .post-list__arrows, .app__body .post .flag-icon__container', 'fill:' + changeOpacity(theme.centerChannelColor, 0.3)); changeCss('@media(min-width: 768px){.app__body .search__icon svg', 'stroke:' + changeOpacity(theme.centerChannelColor, 0.4)); changeCss('.app__body .channel-header__icon svg', 'fill:' + changeOpacity(theme.centerChannelColor, 0.4)); diff --git a/webapp/yarn.lock b/webapp/yarn.lock index 50d83248b..13d9c630c 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -5004,7 +5004,7 @@ math-expression-evaluator@^1.2.14: mattermost-redux@mattermost/mattermost-redux#master: version "0.0.1" - resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/9797cb8bd8fa61252336a7c6150bd364f7ca28b1" + resolved "https://codeload.github.com/mattermost/mattermost-redux/tar.gz/d3a8c94d59a687a957ca8808fbe1b9cb76077bce" dependencies: deep-equal "1.0.1" harmony-reflect "1.5.1" |