diff options
Diffstat (limited to 'webapp/components/user_settings')
17 files changed, 6215 insertions, 0 deletions
diff --git a/webapp/components/user_settings/custom_theme_chooser.jsx b/webapp/components/user_settings/custom_theme_chooser.jsx new file mode 100644 index 000000000..9fbdd1251 --- /dev/null +++ b/webapp/components/user_settings/custom_theme_chooser.jsx @@ -0,0 +1,394 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import Constants from 'utils/constants.jsx'; +import 'bootstrap-colorpicker'; + +import {Popover, OverlayTrigger} from 'react-bootstrap'; + +import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl'; + +const messages = defineMessages({ + sidebarBg: { + id: 'user.settings.custom_theme.sidebarBg', + defaultMessage: 'Sidebar BG' + }, + sidebarText: { + id: 'user.settings.custom_theme.sidebarText', + defaultMessage: 'Sidebar Text' + }, + sidebarHeaderBg: { + id: 'user.settings.custom_theme.sidebarHeaderBg', + defaultMessage: 'Sidebar Header BG' + }, + sidebarHeaderTextColor: { + id: 'user.settings.custom_theme.sidebarHeaderTextColor', + defaultMessage: 'Sidebar Header Text' + }, + sidebarUnreadText: { + id: 'user.settings.custom_theme.sidebarUnreadText', + defaultMessage: 'Sidebar Unread Text' + }, + sidebarTextHoverBg: { + id: 'user.settings.custom_theme.sidebarTextHoverBg', + defaultMessage: 'Sidebar Text Hover BG' + }, + sidebarTextActiveBorder: { + id: 'user.settings.custom_theme.sidebarTextActiveBorder', + defaultMessage: 'Sidebar Text Active Border' + }, + sidebarTextActiveColor: { + id: 'user.settings.custom_theme.sidebarTextActiveColor', + defaultMessage: 'Sidebar Text Active Color' + }, + onlineIndicator: { + id: 'user.settings.custom_theme.onlineIndicator', + defaultMessage: 'Online Indicator' + }, + awayIndicator: { + id: 'user.settings.custom_theme.awayIndicator', + defaultMessage: 'Away Indicator' + }, + mentionBj: { + id: 'user.settings.custom_theme.mentionBj', + defaultMessage: 'Mention Jewel BG' + }, + mentionColor: { + id: 'user.settings.custom_theme.mentionColor', + defaultMessage: 'Mention Jewel Text' + }, + centerChannelBg: { + id: 'user.settings.custom_theme.centerChannelBg', + defaultMessage: 'Center Channel BG' + }, + centerChannelColor: { + id: 'user.settings.custom_theme.centerChannelColor', + defaultMessage: 'Center Channel Text' + }, + newMessageSeparator: { + id: 'user.settings.custom_theme.newMessageSeparator', + defaultMessage: 'New Message Separator' + }, + linkColor: { + id: 'user.settings.custom_theme.linkColor', + defaultMessage: 'Link Color' + }, + buttonBg: { + id: 'user.settings.custom_theme.buttonBg', + defaultMessage: 'Button BG' + }, + buttonColor: { + id: 'user.settings.custom_theme.buttonColor', + defaultMessage: 'Button Text' + }, + mentionHighlightBg: { + id: 'user.settings.custom_theme.mentionHighlightBg', + defaultMessage: 'Mention Highlight BG' + }, + mentionHighlightLink: { + id: 'user.settings.custom_theme.mentionHighlightLink', + defaultMessage: 'Mention Highlight Link' + }, + codeTheme: { + id: 'user.settings.custom_theme.codeTheme', + defaultMessage: 'Code Theme' + } +}); + +import React from 'react'; + +class CustomThemeChooser extends React.Component { + constructor(props) { + super(props); + + this.onPickerChange = this.onPickerChange.bind(this); + this.onInputChange = this.onInputChange.bind(this); + this.pasteBoxChange = this.pasteBoxChange.bind(this); + this.toggleContent = this.toggleContent.bind(this); + + this.state = {}; + } + componentDidMount() { + $('.color-picker').colorpicker({ + format: 'hex' + }); + $('.color-picker').on('changeColor', this.onPickerChange); + } + componentDidUpdate() { + const theme = this.props.theme; + Constants.THEME_ELEMENTS.forEach((element) => { + if (theme.hasOwnProperty(element.id) && element.id !== 'codeTheme') { + $('#' + element.id).data('colorpicker').color.setColor(theme[element.id]); + $('#' + element.id).colorpicker('update'); + } + }); + } + onPickerChange(e) { + const theme = this.props.theme; + theme[e.target.id] = e.color.toHex(); + theme.type = 'custom'; + this.props.updateTheme(theme); + } + onInputChange(e) { + const theme = this.props.theme; + theme[e.target.parentNode.id] = e.target.value; + theme.type = 'custom'; + this.props.updateTheme(theme); + } + pasteBoxChange(e) { + const text = e.target.value; + + if (text.length === 0) { + return; + } + + const colors = text.split(','); + + const theme = {type: 'custom'}; + let index = 0; + Constants.THEME_ELEMENTS.forEach((element) => { + if (index < colors.length - 1) { + theme[element.id] = colors[index]; + } + index++; + }); + theme.codeTheme = colors[colors.length - 1]; + + this.props.updateTheme(theme); + } + toggleContent(e) { + e.stopPropagation(); + if ($(e.target).hasClass('theme-elements__header')) { + $(e.target).next().slideToggle(); + $(e.target).toggleClass('open'); + } else { + $(e.target).closest('.theme-elements__header').next().slideToggle(); + $(e.target).closest('.theme-elements__header').toggleClass('open'); + } + } + render() { + const {formatMessage} = this.props.intl; + const theme = this.props.theme; + + const sidebarElements = []; + const centerChannelElements = []; + const linkAndButtonElements = []; + let colors = ''; + Constants.THEME_ELEMENTS.forEach((element, index) => { + if (element.id === 'codeTheme') { + const codeThemeOptions = []; + let codeThemeURL = ''; + + element.themes.forEach((codeTheme, codeThemeIndex) => { + if (codeTheme.id === theme[element.id]) { + codeThemeURL = codeTheme.iconURL; + } + codeThemeOptions.push( + <option + key={'code-theme-key' + codeThemeIndex} + value={codeTheme.id} + > + {codeTheme.uiName} + </option> + ); + }); + + var popoverContent = ( + <Popover + bsStyle='info' + id='code-popover' + className='code-popover' + > + <img + width='200' + src={codeThemeURL} + /> + </Popover> + ); + + centerChannelElements.push( + <div + className='col-sm-6 form-group' + key={'custom-theme-key' + index} + > + <label className='custom-label'>{formatMessage(messages[element.id])}</label> + <div + className='input-group theme-group group--code dropdown' + id={element.id} + > + <select + className='form-control' + type='text' + value={theme[element.id]} + onChange={this.onInputChange} + > + {codeThemeOptions} + </select> + <OverlayTrigger + placement='top' + overlay={popoverContent} + ref='headerOverlay' + > + <span className='input-group-addon'> + <img + src={codeThemeURL} + /> + </span> + </OverlayTrigger> + </div> + </div> + ); + } else if (element.group === 'centerChannelElements') { + centerChannelElements.push( + <div + className='col-sm-6 form-group element' + key={'custom-theme-key' + index} + > + <label className='custom-label'>{formatMessage(messages[element.id])}</label> + <div + className='input-group color-picker' + id={element.id} + > + <input + className='form-control' + type='text' + value={theme[element.id]} + onChange={this.onInputChange} + /> + <span className='input-group-addon'><i></i></span> + </div> + </div> + ); + + colors += theme[element.id] + ','; + } else if (element.group === 'sidebarElements') { + sidebarElements.push( + <div + className='col-sm-6 form-group element' + key={'custom-theme-key' + index} + > + <label className='custom-label'>{formatMessage(messages[element.id])}</label> + <div + className='input-group color-picker' + id={element.id} + > + <input + className='form-control' + type='text' + value={theme[element.id]} + onChange={this.onInputChange} + /> + <span className='input-group-addon'><i></i></span> + </div> + </div> + ); + + colors += theme[element.id] + ','; + } else { + linkAndButtonElements.push( + <div + className='col-sm-6 form-group element' + key={'custom-theme-key' + index} + > + <label className='custom-label'>{formatMessage(messages[element.id])}</label> + <div + className='input-group color-picker' + id={element.id} + > + <input + className='form-control' + type='text' + value={theme[element.id]} + onChange={this.onInputChange} + /> + <span className='input-group-addon'><i></i></span> + </div> + </div> + ); + + colors += theme[element.id] + ','; + } + }); + + colors += theme.codeTheme; + + const pasteBox = ( + <div className='col-sm-12'> + <label className='custom-label'> + <FormattedMessage + id='user.settings.custom_theme.copyPaste' + defaultMessage='Copy and paste to share theme colors:' + /> + </label> + <input + type='text' + className='form-control' + value={colors} + onChange={this.pasteBoxChange} + /> + </div> + ); + + return ( + <div className='appearance-section padding-top'> + <div className='theme-elements row'> + <div + className='theme-elements__header' + onClick={this.toggleContent} + > + {'Sidebar Styles'} + <div className='header__icon'> + <i className='fa fa-plus'></i> + <i className='fa fa-minus'></i> + </div> + </div> + <div className='theme-elements__body'> + {sidebarElements} + </div> + </div> + <div className='theme-elements row'> + <div + className='theme-elements__header' + onClick={this.toggleContent} + > + {'Center Channel Styles'} + <div className='header__icon'> + <i className='fa fa-plus'></i> + <i className='fa fa-minus'></i> + </div> + </div> + <div className='theme-elements__body'> + {centerChannelElements} + </div> + </div> + <div className='theme-elements row form-group'> + <div + className='theme-elements__header' + onClick={this.toggleContent} + > + {'Link and Button Styles'} + <div className='header__icon'> + <i className='fa fa-plus'></i> + <i className='fa fa-minus'></i> + </div> + </div> + <div className='theme-elements__body'> + {linkAndButtonElements} + </div> + </div> + <div className='row'> + {pasteBox} + </div> + </div> + ); + } +} + +CustomThemeChooser.propTypes = { + intl: intlShape.isRequired, + theme: React.PropTypes.object.isRequired, + updateTheme: React.PropTypes.func.isRequired +}; + +export default injectIntl(CustomThemeChooser); diff --git a/webapp/components/user_settings/import_theme_modal.jsx b/webapp/components/user_settings/import_theme_modal.jsx new file mode 100644 index 000000000..2fc75ca13 --- /dev/null +++ b/webapp/components/user_settings/import_theme_modal.jsx @@ -0,0 +1,218 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import ReactDOM from 'react-dom'; +import ModalStore from 'stores/modal_store.jsx'; +import UserStore from 'stores/user_store.jsx'; +import * as Utils from 'utils/utils.jsx'; +import * as Client from 'utils/client.jsx'; +import {Modal} from 'react-bootstrap'; + +import AppDispatcher from '../../dispatcher/app_dispatcher.jsx'; +import Constants from 'utils/constants.jsx'; + +import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl'; + +const holders = defineMessages({ + submitError: { + id: 'user.settings.import_theme.submitError', + defaultMessage: 'Invalid format, please try copying and pasting in again.' + } +}); + +const ActionTypes = Constants.ActionTypes; + +import React from 'react'; + +class ImportThemeModal extends React.Component { + constructor(props) { + super(props); + + this.updateShow = this.updateShow.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleChange = this.handleChange.bind(this); + + this.state = { + inputError: '', + show: false + }; + } + componentDidMount() { + ModalStore.addModalListener(ActionTypes.TOGGLE_IMPORT_THEME_MODAL, this.updateShow); + } + componentWillUnmount() { + ModalStore.removeModalListener(ActionTypes.TOGGLE_IMPORT_THEME_MODAL, this.updateShow); + } + updateShow(show) { + this.setState({show}); + } + handleSubmit(e) { + e.preventDefault(); + + const text = ReactDOM.findDOMNode(this.refs.input).value; + + if (!this.isInputValid(text)) { + this.setState({inputError: this.props.intl.formatMessage(holders.submitError)}); + return; + } + + const colors = text.split(','); + const theme = {type: 'custom'}; + + theme.sidebarBg = colors[0]; + theme.sidebarText = colors[5]; + theme.sidebarUnreadText = colors[5]; + theme.sidebarTextHoverBg = colors[4]; + theme.sidebarTextActiveBorder = colors[2]; + theme.sidebarTextActiveColor = colors[3]; + theme.sidebarHeaderBg = colors[1]; + theme.sidebarHeaderTextColor = colors[5]; + theme.onlineIndicator = colors[6]; + theme.awayIndicator = '#E0B333'; + theme.mentionBj = colors[7]; + theme.mentionColor = '#ffffff'; + theme.centerChannelBg = '#ffffff'; + theme.centerChannelColor = '#333333'; + theme.newMessageSeparator = '#F80'; + theme.linkColor = '#2389d7'; + theme.buttonBg = '#26a970'; + theme.buttonColor = '#ffffff'; + theme.mentionHighlightBg = '#fff2bb'; + theme.mentionHighlightLink = '#2f81b7'; + theme.codeTheme = 'github'; + + let user = UserStore.getCurrentUser(); + user.theme_props = theme; + + Client.updateUser(user, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_ME, + me: data + }); + + this.setState({show: false}); + Utils.applyTheme(theme); + }, + (err) => { + var state = this.getStateFromStores(); + state.serverError = err; + this.setState(state); + } + ); + } + isInputValid(text) { + if (text.length === 0) { + return false; + } + + if (text.indexOf(' ') !== -1) { + return false; + } + + if (text.length > 0 && text.indexOf(',') === -1) { + return false; + } + + if (text.length > 0) { + const colors = text.split(','); + + if (colors.length !== 8) { + return false; + } + + for (let i = 0; i < colors.length; i++) { + if (colors[i].length !== 7 && colors[i].length !== 4) { + return false; + } + + if (colors[i].charAt(0) !== '#') { + return false; + } + } + } + + return true; + } + handleChange(e) { + if (this.isInputValid(e.target.value)) { + this.setState({inputError: null}); + } else { + this.setState({inputError: this.props.intl.formatMessage(holders.submitError)}); + } + } + render() { + return ( + <span> + <Modal + show={this.state.show} + onHide={() => this.setState({show: false})} + > + <Modal.Header closeButton={true}> + <Modal.Title> + <FormattedMessage + id='user.settings.import_theme.importHeader' + defaultMessage='Import Slack Theme' + /> + </Modal.Title> + </Modal.Header> + <form + role='form' + className='form-horizontal' + > + <Modal.Body> + <p> + <FormattedMessage + id='user.settings.import_theme.importBody' + defaultMessage='To import a theme, go to a Slack team and look for “Preferences -> Sidebar Theme”. Open the custom theme option, copy the theme color values and paste them here:' + /> + </p> + <div className='form-group less'> + <div className='col-sm-9'> + <input + ref='input' + type='text' + className='form-control' + onChange={this.handleChange} + /> + <div className='input__help'> + {this.state.inputError} + </div> + </div> + </div> + </Modal.Body> + <Modal.Footer> + <button + type='button' + className='btn btn-default' + onClick={() => this.setState({show: false})} + > + <FormattedMessage + id='user.settings.import_theme.cancel' + defaultMessage='Cancel' + /> + </button> + <button + onClick={this.handleSubmit} + type='submit' + className='btn btn-primary' + tabIndex='3' + > + <FormattedMessage + id='user.settings.import_theme.submit' + defaultMessage='Submit' + /> + </button> + </Modal.Footer> + </form> + </Modal> + </span> + ); + } +} + +ImportThemeModal.propTypes = { + intl: intlShape.isRequired +}; + +export default injectIntl(ImportThemeModal); diff --git a/webapp/components/user_settings/manage_command_hooks.jsx b/webapp/components/user_settings/manage_command_hooks.jsx new file mode 100644 index 000000000..ce353ad64 --- /dev/null +++ b/webapp/components/user_settings/manage_command_hooks.jsx @@ -0,0 +1,681 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import LoadingScreen from '../loading_screen.jsx'; + +import * as Client from 'utils/client.jsx'; + +import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedHTMLMessage} from 'react-intl'; + +const holders = defineMessages({ + requestTypePost: { + id: 'user.settings.cmds.request_type_post', + defaultMessage: 'POST' + }, + requestTypeGet: { + id: 'user.settings.cmds.request_type_get', + defaultMessage: 'GET' + }, + addDisplayNamePlaceholder: { + id: 'user.settings.cmds.add_display_name.placeholder', + defaultMessage: 'Example: "Search patient records"' + }, + addUsernamePlaceholder: { + id: 'user.settings.cmds.add_username.placeholder', + defaultMessage: 'Username' + }, + addTriggerPlaceholder: { + id: 'user.settings.cmds.add_trigger.placeholder', + defaultMessage: 'Command trigger e.g. "hello" not including the slash' + }, + addAutoCompleteDescPlaceholder: { + id: 'user.settings.cmds.auto_complete_desc.placeholder', + defaultMessage: 'Example: "Returns search results for patient records"' + }, + addAutoCompleteHintPlaceholder: { + id: 'user.settings.cmds.auto_complete_hint.placeholder', + defaultMessage: 'Example: [Patient Name]' + }, + adUrlPlaceholder: { + id: 'user.settings.cmds.url.placeholder', + defaultMessage: 'Must start with http:// or https://' + }, + autocompleteYes: { + id: 'user.settings.cmds.auto_complete.yes', + defaultMessage: 'yes' + }, + autocompleteNo: { + id: 'user.settings.cmds.auto_complete.no', + defaultMessage: 'no' + } +}); + +import React from 'react'; + +export default class ManageCommandCmds extends React.Component { + constructor() { + super(); + + this.getCmds = this.getCmds.bind(this); + this.addNewCmd = this.addNewCmd.bind(this); + this.emptyCmd = this.emptyCmd.bind(this); + this.updateTrigger = this.updateTrigger.bind(this); + this.updateURL = this.updateURL.bind(this); + this.updateMethod = this.updateMethod.bind(this); + this.updateUsername = this.updateUsername.bind(this); + this.updateIconURL = this.updateIconURL.bind(this); + this.updateDisplayName = this.updateDisplayName.bind(this); + this.updateAutoComplete = this.updateAutoComplete.bind(this); + this.updateAutoCompleteDesc = this.updateAutoCompleteDesc.bind(this); + this.updateAutoCompleteHint = this.updateAutoCompleteHint.bind(this); + + this.state = {cmds: [], cmd: this.emptyCmd(), getCmdsComplete: false}; + } + + static propTypes() { + return { + intl: intlShape.isRequired + }; + } + + emptyCmd() { + var cmd = {}; + cmd.url = ''; + cmd.trigger = ''; + cmd.method = 'P'; + cmd.username = ''; + cmd.icon_url = ''; + cmd.auto_complete = false; + cmd.auto_complete_desc = ''; + cmd.auto_complete_hint = ''; + cmd.display_name = ''; + return cmd; + } + + componentDidMount() { + this.getCmds(); + } + + addNewCmd(e) { + e.preventDefault(); + + if (this.state.cmd.trigger === '' || this.state.cmd.url === '') { + return; + } + + var cmd = this.state.cmd; + if (cmd.trigger.length !== 0) { + cmd.trigger = cmd.trigger.trim(); + } + cmd.url = cmd.url.trim(); + + Client.addCommand( + cmd, + (data) => { + let cmds = Object.assign([], this.state.cmds); + if (!cmds) { + cmds = []; + } + cmds.push(data); + this.setState({cmds, addError: null, cmd: this.emptyCmd()}); + }, + (err) => { + this.setState({addError: err.message}); + } + ); + } + + removeCmd(id) { + const data = {}; + data.id = id; + + Client.deleteCommand( + data, + () => { + const cmds = this.state.cmds; + let index = -1; + for (let i = 0; i < cmds.length; i++) { + if (cmds[i].id === id) { + index = i; + break; + } + } + + if (index !== -1) { + cmds.splice(index, 1); + } + + this.setState({cmds}); + }, + (err) => { + this.setState({editError: err.message}); + } + ); + } + + regenToken(id) { + const regenData = {}; + regenData.id = id; + + Client.regenCommandToken( + regenData, + (data) => { + const cmds = Object.assign([], this.state.cmds); + for (let i = 0; i < cmds.length; i++) { + if (cmds[i].id === id) { + cmds[i] = data; + break; + } + } + + this.setState({cmds, editError: null}); + }, + (err) => { + this.setState({editError: err.message}); + } + ); + } + + getCmds() { + Client.listTeamCommands( + (data) => { + if (data) { + this.setState({cmds: data, getCmdsComplete: true, editError: null}); + } + }, + (err) => { + this.setState({editError: err.message}); + } + ); + } + + updateTrigger(e) { + var cmd = this.state.cmd; + cmd.trigger = e.target.value; + this.setState(cmd); + } + + updateURL(e) { + var cmd = this.state.cmd; + cmd.url = e.target.value; + this.setState(cmd); + } + + updateMethod(e) { + var cmd = this.state.cmd; + cmd.method = e.target.value; + this.setState(cmd); + } + + updateUsername(e) { + var cmd = this.state.cmd; + cmd.username = e.target.value; + this.setState(cmd); + } + + updateIconURL(e) { + var cmd = this.state.cmd; + cmd.icon_url = e.target.value; + this.setState(cmd); + } + + updateDisplayName(e) { + var cmd = this.state.cmd; + cmd.display_name = e.target.value; + this.setState(cmd); + } + + updateAutoComplete(e) { + var cmd = this.state.cmd; + cmd.auto_complete = e.target.checked; + this.setState(cmd); + } + + updateAutoCompleteDesc(e) { + var cmd = this.state.cmd; + cmd.auto_complete_desc = e.target.value; + this.setState(cmd); + } + + updateAutoCompleteHint(e) { + var cmd = this.state.cmd; + cmd.auto_complete_hint = e.target.value; + this.setState(cmd); + } + + render() { + let addError; + if (this.state.addError) { + addError = <label className='has-error'>{this.state.addError}</label>; + } + + let editError; + if (this.state.editError) { + addError = <label className='has-error'>{this.state.editError}</label>; + } + + const cmds = []; + this.state.cmds.forEach((cmd) => { + let triggerDiv; + if (cmd.trigger && cmd.trigger.length !== 0) { + triggerDiv = ( + <div className='padding-top x2'> + <strong> + <FormattedMessage + id='user.settings.cmds.trigger' + defaultMessage='Command Trigger Word: ' + /> + </strong>{cmd.trigger} + </div> + ); + } + + cmds.push( + <div + key={cmd.id} + className='webhook__item webcmd__item' + > + {triggerDiv} + <div className='padding-top x2 webcmd__url'> + <strong> + <FormattedMessage + id='user.settings.cmds.url' + defaultMessage='Request URL: ' + /> + </strong><span className='word-break--all'>{cmd.url}</span> + </div> + <div className='padding-top x2'> + <strong> + <FormattedMessage + id='user.settings.cmds.request_type' + defaultMessage='Request Method: ' + /> + </strong> + <span className='word-break--all'> + { + cmd.method === 'P' ? + <FormattedMessage + id='user.settings.cmds.request_type_post' + defaultMessage='POST' + /> : + <FormattedMessage + id='user.settings.cmds.request_type_get' + defaultMessage='GET' + /> + } + </span> + </div> + <div className='padding-top x2'> + <strong> + <FormattedMessage + id='user.settings.cmds.username' + defaultMessage='Response Username: ' + /> + </strong><span className='word-break--all'>{cmd.username}</span> + </div> + <div className='padding-top x2'> + <strong> + <FormattedMessage + id='user.settings.cmds.icon_url' + defaultMessage='Response Icon: ' + /> + </strong><span className='word-break--all'>{cmd.icon_url}</span> + </div> + <div className='padding-top x2'> + <strong> + <FormattedMessage + id='user.settings.cmds.auto_complete' + defaultMessage='Autocomplete: ' + /> + </strong><span className='word-break--all'>{cmd.auto_complete ? this.props.intl.formatMessage(holders.autocompleteYes) : this.props.intl.formatMessage(holders.autocompleteNo)}</span> + </div> + <div className='padding-top x2'> + <strong> + <FormattedMessage + id='user.settings.cmds.auto_complete_hint' + defaultMessage='Autocomplete Hint: ' + /> + </strong><span className='word-break--all'>{cmd.auto_complete_hint}</span> + </div> + <div className='padding-top x2'> + <strong> + <FormattedMessage + id='user.settings.cmds.auto_complete_desc' + defaultMessage='Autocomplete Description: ' + /> + </strong><span className='word-break--all'>{cmd.auto_complete_desc}</span> + </div> + <div className='padding-top x2'> + <strong> + <FormattedMessage + id='user.settings.cmds.display_name' + defaultMessage='Descriptive Label: ' + /> + </strong><span className='word-break--all'>{cmd.display_name}</span> + </div> + <div className='padding-top'> + <strong> + <FormattedMessage + id='user.settings.cmds.token' + defaultMessage='Token: ' + /> + </strong>{cmd.token} + </div> + <div className='padding-top'> + <a + className='text-danger' + href='#' + onClick={this.regenToken.bind(this, cmd.id)} + > + <FormattedMessage + id='user.settings.cmds.regen' + defaultMessage='Regen Token' + /> + </a> + <a + className='webhook__remove webcmd__remove' + href='#' + onClick={this.removeCmd.bind(this, cmd.id)} + > + <span aria-hidden='true'>{'×'}</span> + </a> + </div> + <div className='padding-top x2 divider-light'></div> + </div> + ); + }); + + let displayCmds; + if (!this.state.getCmdsComplete) { + displayCmds = <LoadingScreen/>; + } else if (cmds.length > 0) { + displayCmds = cmds; + } else { + displayCmds = ( + <div className='padding-top x2'> + <FormattedMessage + id='user.settings.cmds.none' + defaultMessage='None' + /> + </div> + ); + } + + const existingCmds = ( + <div className='webhooks__container webcmds__container'> + <label className='control-label padding-top x2'> + <FormattedMessage + id='user.settings.cmds.existing' + defaultMessage='Existing commands' + /> + </label> + <div className='padding-top divider-light'></div> + <div className='webhooks__list webcmds__list'> + {displayCmds} + </div> + </div> + ); + + const disableButton = this.state.cmd.trigger === '' || this.state.cmd.url === ''; + + return ( + <div key='addCommandCmd'> + <FormattedHTMLMessage + id='user.settings.cmds.add_desc' + defaultMessage='Create slash commands to send events to external integrations and receive a response. For example typing `/patient Joe Smith` could bring back search results from your internal health records management system for the name “Joe Smith”. Please see <a href="http://docs.mattermost.com/developer/slash-commands.html">Slash commands documentation</a> for detailed instructions. View all slash commands configured on this team below.' + /> + <div><label className='control-label padding-top x2'> + <FormattedMessage + id='user.settings.cmds.add_new' + defaultMessage='Add a new command' + /> + </label></div> + <div className='padding-top divider-light'></div> + <div className='padding-top'> + + <div className='padding-top x2'> + <label className='control-label'> + <FormattedMessage + id='user.settings.cmds.trigger' + defaultMessage='Command Trigger Word: ' + /> + </label> + <div className='padding-top'> + <input + ref='trigger' + className='form-control' + value={this.state.cmd.trigger} + onChange={this.updateTrigger} + placeholder={this.props.intl.formatMessage(holders.addTriggerPlaceholder)} + /> + </div> + <div className='padding-top'> + <FormattedMessage + id='user.settings.cmds.trigger_desc' + defaultMessage='Examples: /patient, /client, /employee Reserved: /echo, /join, /logout, /me, /shrug' + /> + </div> + </div> + + <div className='padding-top x2'> + <label className='control-label'> + <FormattedMessage + id='user.settings.cmds.url' + defaultMessage='Request URL: ' + /> + </label> + <div className='padding-top'> + <input + ref='URL' + className='form-control' + value={this.state.cmd.url} + rows={1} + onChange={this.updateURL} + placeholder={this.props.intl.formatMessage(holders.adUrlPlaceholder)} + /> + </div> + <div className='padding-top'> + <FormattedMessage + id='user.settings.cmds.url_desc' + defaultMessage='The callback URL to receive the HTTP POST or GET event request when the slash command is run.' + /> + </div> + </div> + + <div className='padding-top x2'> + <label className='control-label'> + <FormattedMessage + id='user.settings.cmds.request_type' + defaultMessage='Request Method: ' + /> + </label> + <div className='padding-top'> + <select + ref='method' + className='form-control' + value={this.state.cmd.method} + onChange={this.updateMethod} + > + <option value='P'> + {this.props.intl.formatMessage(holders.requestTypePost)} + </option> + <option value='G'> + {this.props.intl.formatMessage(holders.requestTypeGet)} + </option> + </select> + </div> + <div className='padding-top'> + <FormattedMessage + id='user.settings.cmds.request_type_desc' + defaultMessage='The type of command request issued to the Request URL.' + /> + </div> + </div> + + <div className='padding-top x2'> + <label className='control-label'> + <FormattedMessage + id='user.settings.cmds.username' + defaultMessage='Response Username: ' + /> + </label> + <div className='padding-top'> + <input + ref='username' + className='form-control' + value={this.state.cmd.username} + onChange={this.updateUsername} + placeholder={this.props.intl.formatMessage(holders.addUsernamePlaceholder)} + /> + </div> + <div className='padding-top'> + <FormattedMessage + id='user.settings.cmds.username_desc' + defaultMessage='Choose a username override for responses for this slash command. Usernames can consist of up to 22 characters consisting of lowercase letters, numbers and they symbols "-", "_", and "." .' + /> + </div> + </div> + + <div className='padding-top x2'> + <label className='control-label'> + <FormattedMessage + id='user.settings.cmds.icon_url' + defaultMessage='Response Icon: ' + /> + </label> + <div className='padding-top'> + <input + ref='iconURL' + className='form-control' + value={this.state.cmd.icon_url} + onChange={this.updateIconURL} + placeholder='https://www.example.com/myicon.png' + /> + </div> + <div className='padding-top'> + <FormattedMessage + id='user.settings.cmds.icon_url_desc' + defaultMessage='Choose a profile picture override for the post responses to this slash command. Enter the URL of a .png or .jpg file at least 128 pixels by 128 pixels.' + /> + </div> + </div> + + <div className='padding-top x2'> + <label className='control-label'> + <FormattedMessage + id='user.settings.cmds.auto_complete' + defaultMessage='Autocomplete: ' + /> + </label> + <div className='padding-top'> + <div className='checkbox'> + <label> + <input + type='checkbox' + checked={this.state.cmd.auto_complete} + onChange={this.updateAutoComplete} + /> + <FormattedMessage + id='user.settings.cmds.auto_complete_help' + defaultMessage=' Show this command in the autocomplete list.' + /> + </label> + </div> + </div> + </div> + + <div className='padding-top x2'> + <label className='control-label'> + <FormattedMessage + id='user.settings.cmds.auto_complete_hint' + defaultMessage='Autocomplete Hint: ' + /> + </label> + <div className='padding-top'> + <input + ref='autoCompleteHint' + className='form-control' + value={this.state.cmd.auto_complete_hint} + onChange={this.updateAutoCompleteHint} + placeholder={this.props.intl.formatMessage(holders.addAutoCompleteHintPlaceholder)} + /> + </div> + <div className='padding-top'> + <FormattedMessage + id='user.settings.cmds.auto_complete_hint_desc' + defaultMessage='Optional hint in the autocomplete list about parameters needed for command.' + /> + </div> + </div> + + <div className='padding-top x2'> + <label className='control-label'> + <FormattedMessage + id='user.settings.cmds.auto_complete_desc' + defaultMessage='Autocomplete Description: ' + /> + </label> + <div className='padding-top'> + <input + ref='autoCompleteDesc' + className='form-control' + value={this.state.cmd.auto_complete_desc} + onChange={this.updateAutoCompleteDesc} + placeholder={this.props.intl.formatMessage(holders.addAutoCompleteDescPlaceholder)} + /> + </div> + <div className='padding-top'> + <FormattedMessage + id='user.settings.cmds.auto_complete_desc_desc' + defaultMessage='Optional short description of slash command for the autocomplete list.' + /> + </div> + </div> + + <div className='padding-top x2'> + <label className='control-label'> + <FormattedMessage + id='user.settings.cmds.display_name' + defaultMessage='Descriptive Label: ' + /> + </label> + <div className='padding-top'> + <input + ref='displayName' + className='form-control' + value={this.state.cmd.display_name} + onChange={this.updateDisplayName} + placeholder={this.props.intl.formatMessage(holders.addDisplayNamePlaceholder)} + /> + </div> + <div className='padding-top'> + <FormattedMessage + id='user.settings.cmds.cmd_display_name' + defaultMessage='Brief description of slash command to show in listings.' + /> + </div> + {addError} + </div> + + <div className='padding-top x2 padding-bottom'> + <a + className={'btn btn-sm btn-primary'} + href='#' + disabled={disableButton} + onClick={this.addNewCmd} + > + <FormattedMessage + id='user.settings.cmds.add' + defaultMessage='Add' + /> + </a> + </div> + </div> + {existingCmds} + {editError} + </div> + ); + } +} + +export default injectIntl(ManageCommandCmds); diff --git a/webapp/components/user_settings/manage_incoming_hooks.jsx b/webapp/components/user_settings/manage_incoming_hooks.jsx new file mode 100644 index 000000000..b61b331ce --- /dev/null +++ b/webapp/components/user_settings/manage_incoming_hooks.jsx @@ -0,0 +1,225 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as Client from 'utils/client.jsx'; +import * as Utils from 'utils/utils.jsx'; +import Constants from 'utils/constants.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; +import LoadingScreen from '../loading_screen.jsx'; + +import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; + +import React from 'react'; + +export default class ManageIncomingHooks extends React.Component { + constructor() { + super(); + + this.getHooks = this.getHooks.bind(this); + this.addNewHook = this.addNewHook.bind(this); + this.updateChannelId = this.updateChannelId.bind(this); + + this.state = {hooks: [], channelId: ChannelStore.getByName(Constants.DEFAULT_CHANNEL).id, getHooksComplete: false}; + } + componentDidMount() { + this.getHooks(); + } + addNewHook() { + const hook = {}; + hook.channel_id = this.state.channelId; + + Client.addIncomingHook( + hook, + (data) => { + let hooks = this.state.hooks; + if (!hooks) { + hooks = []; + } + hooks.push(data); + this.setState({hooks}); + }, + (err) => { + this.setState({serverError: err}); + } + ); + } + removeHook(id) { + const data = {}; + data.id = id; + + Client.deleteIncomingHook( + data, + () => { + const hooks = this.state.hooks; + let index = -1; + for (let i = 0; i < hooks.length; i++) { + if (hooks[i].id === id) { + index = i; + break; + } + } + + if (index !== -1) { + hooks.splice(index, 1); + } + + this.setState({hooks}); + }, + (err) => { + this.setState({serverError: err}); + } + ); + } + getHooks() { + Client.listIncomingHooks( + (data) => { + const state = this.state; + + if (data) { + state.hooks = data; + } + + state.getHooksComplete = true; + this.setState(state); + }, + (err) => { + this.setState({serverError: err}); + } + ); + } + updateChannelId(e) { + this.setState({channelId: e.target.value}); + } + render() { + let serverError; + if (this.state.serverError) { + serverError = <label className='has-error'>{this.state.serverError}</label>; + } + + const channels = ChannelStore.getAll(); + const options = []; + channels.forEach((channel) => { + if (channel.type !== Constants.DM_CHANNEL) { + options.push( + <option + key={'incoming-hook' + channel.id} + value={channel.id} + > + {channel.display_name} + </option> + ); + } + }); + + let disableButton = ''; + if (this.state.channelId === '') { + disableButton = ' disable'; + } + + const hooks = []; + this.state.hooks.forEach((hook) => { + const c = ChannelStore.get(hook.channel_id); + if (c) { + hooks.push( + <div + key={hook.id} + className='webhook__item' + > + <div className='padding-top x2 webhook__url'> + <strong>{'URL: '}</strong> + <span className='word-break--all'>{Utils.getWindowLocationOrigin() + '/hooks/' + hook.id}</span> + </div> + <div className='padding-top'> + <strong> + <FormattedMessage + id='user.settings.hooks_in.channel' + defaultMessage='Channel: ' + /> + </strong>{c.display_name} + </div> + <a + className={'webhook__remove'} + href='#' + onClick={this.removeHook.bind(this, hook.id)} + > + <span aria-hidden='true'>{'×'}</span> + </a> + <div className='padding-top x2 divider-light'></div> + </div> + ); + } + }); + + let displayHooks; + if (!this.state.getHooksComplete) { + displayHooks = <LoadingScreen/>; + } else if (hooks.length > 0) { + displayHooks = hooks; + } else { + displayHooks = ( + <div className='padding-top x2'> + <FormattedMessage + id='user.settings.hooks_in.none' + defaultMessage='None' + /> + </div> + ); + } + + const existingHooks = ( + <div className='webhooks__container'> + <label className='control-label padding-top x2'> + <FormattedMessage + id='user.settings.hooks_in.existing' + defaultMessage='Existing incoming webhooks' + /> + </label> + <div className='padding-top divider-light'></div> + <div className='webhooks__list'> + {displayHooks} + </div> + </div> + ); + + return ( + <div key='addIncomingHook'> + <FormattedHTMLMessage + id='user.settings.hooks_in.description' + defaultMessage='Create webhook URLs for use in external integrations. Please see <a href="http://docs.mattermost.com/developer/webhooks-incoming.html" target="_blank">incoming webhooks documentation</a> to learn more. View all incoming webhooks configured on this team below.' + /> + <div><label className='control-label padding-top x2'> + <FormattedMessage + id='user.settings.hooks_in.addTitle' + defaultMessage='Add a new incoming webhook' + /> + </label></div> + <div className='row padding-top'> + <div className='col-sm-10 padding-bottom'> + <select + ref='channelName' + className='form-control' + value={this.state.channelId} + onChange={this.updateChannelId} + > + {options} + </select> + {serverError} + </div> + <div className='col-sm-2 col-xs-4 no-padding--left padding-bottom'> + <a + className={'btn form-control no-padding btn-sm btn-primary' + disableButton} + href='#' + onClick={this.addNewHook} + > + <FormattedMessage + id='user.settings.hooks_in.add' + defaultMessage='Add' + /> + </a> + </div> + </div> + {existingHooks} + </div> + ); + } +} diff --git a/webapp/components/user_settings/manage_languages.jsx b/webapp/components/user_settings/manage_languages.jsx new file mode 100644 index 000000000..094eaa127 --- /dev/null +++ b/webapp/components/user_settings/manage_languages.jsx @@ -0,0 +1,124 @@ +// Copyright (c) 2015 Spinpunch, Inc. All Rights Reserved. +// See License.txt for license information. + +import SettingItemMax from '../setting_item_max.jsx'; + +import * as Client from 'utils/client.jsx'; +import * as I18n from 'i18n/i18n.jsx'; +import * as GlobalActions from 'action_creators/global_actions.jsx'; + +import {FormattedMessage} from 'react-intl'; + +import React from 'react'; + +export default class ManageLanguage extends React.Component { + constructor(props) { + super(props); + + this.setupInitialState = this.setupInitialState.bind(this); + this.setLanguage = this.setLanguage.bind(this); + this.changeLanguage = this.changeLanguage.bind(this); + this.submitUser = this.submitUser.bind(this); + this.state = this.setupInitialState(props); + } + setupInitialState(props) { + var user = props.user; + return { + locale: user.locale + }; + } + setLanguage(e) { + this.setState({locale: e.target.value}); + } + changeLanguage(e) { + e.preventDefault(); + + var user = this.props.user; + var locale = this.state.locale; + + user.locale = locale; + + this.submitUser(user); + } + submitUser(user) { + Client.updateUser(user, + () => { + GlobalActions.newLocalizationSelected(user.locale); + }, + (err) => { + let serverError; + if (err.message) { + serverError = err.message; + } else { + serverError = err; + } + this.setState({serverError}); + } + ); + } + render() { + let serverError; + if (this.state.serverError) { + serverError = <label className='has-error'>{this.state.serverError}</label>; + } + + const options = []; + const languages = I18n.getLanguages(); + for (const key in languages) { + if (languages.hasOwnProperty(key)) { + const lang = languages[key]; + options.push( + <option + key={lang.value} + value={lang.value} + > + {lang.name} + </option> + ); + } + } + + const input = ( + <div key='changeLanguage'> + <br/> + <label className='control-label'> + <FormattedMessage + id='user.settings.languages.change' + defaultMessage='Change interface language' + /> + </label> + <div className='padding-top'> + <select + ref='language' + className='form-control' + value={this.state.locale} + onChange={this.setLanguage} + > + {options} + </select> + {serverError} + </div> + </div> + ); + + return ( + <SettingItemMax + title={ + <FormattedMessage + id='user.settings.display.language' + defaultMessage='Language' + /> + } + width='medium' + submit={this.changeLanguage} + inputs={[input]} + updateSection={this.props.updateSection} + /> + ); + } +} + +ManageLanguage.propTypes = { + user: React.PropTypes.object.isRequired, + updateSection: React.PropTypes.func.isRequired +}; diff --git a/webapp/components/user_settings/manage_outgoing_hooks.jsx b/webapp/components/user_settings/manage_outgoing_hooks.jsx new file mode 100644 index 000000000..8adec09ce --- /dev/null +++ b/webapp/components/user_settings/manage_outgoing_hooks.jsx @@ -0,0 +1,397 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import LoadingScreen from '../loading_screen.jsx'; + +import ChannelStore from 'stores/channel_store.jsx'; + +import * as Client from 'utils/client.jsx'; +import Constants from 'utils/constants.jsx'; + +import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedHTMLMessage} from 'react-intl'; + +const holders = defineMessages({ + optional: { + id: 'user.settings.hooks_out.optional', + defaultMessage: 'Optional if channel selected' + }, + callbackHolder: { + id: 'user.settings.hooks_out.callbackHolder', + defaultMessage: 'Each URL must start with http:// or https://' + }, + select: { + id: 'user.settings.hooks_out.select', + defaultMessage: '--- Select a channel ---' + } +}); + +import React from 'react'; + +class ManageOutgoingHooks extends React.Component { + constructor() { + super(); + + this.getHooks = this.getHooks.bind(this); + this.addNewHook = this.addNewHook.bind(this); + this.updateChannelId = this.updateChannelId.bind(this); + this.updateTriggerWords = this.updateTriggerWords.bind(this); + this.updateCallbackURLs = this.updateCallbackURLs.bind(this); + + this.state = {hooks: [], channelId: '', triggerWords: '', callbackURLs: '', getHooksComplete: false}; + } + componentDidMount() { + this.getHooks(); + } + addNewHook(e) { + e.preventDefault(); + + if ((this.state.channelId === '' && this.state.triggerWords === '') || + this.state.callbackURLs === '') { + return; + } + + const hook = {}; + hook.channel_id = this.state.channelId; + if (this.state.triggerWords.length !== 0) { + hook.trigger_words = this.state.triggerWords.trim().split(','); + } + hook.callback_urls = this.state.callbackURLs.split('\n').map((url) => url.trim()); + + Client.addOutgoingHook( + hook, + (data) => { + let hooks = Object.assign([], this.state.hooks); + if (!hooks) { + hooks = []; + } + hooks.push(data); + this.setState({hooks, addError: null, channelId: '', triggerWords: '', callbackURLs: ''}); + }, + (err) => { + this.setState({addError: err.message}); + } + ); + } + removeHook(id) { + const data = {}; + data.id = id; + + Client.deleteOutgoingHook( + data, + () => { + const hooks = this.state.hooks; + let index = -1; + for (let i = 0; i < hooks.length; i++) { + if (hooks[i].id === id) { + index = i; + break; + } + } + + if (index !== -1) { + hooks.splice(index, 1); + } + + this.setState({hooks}); + }, + (err) => { + this.setState({editError: err.message}); + } + ); + } + regenToken(id) { + const regenData = {}; + regenData.id = id; + + Client.regenOutgoingHookToken( + regenData, + (data) => { + const hooks = Object.assign([], this.state.hooks); + for (let i = 0; i < hooks.length; i++) { + if (hooks[i].id === id) { + hooks[i] = data; + break; + } + } + + this.setState({hooks, editError: null}); + }, + (err) => { + this.setState({editError: err.message}); + } + ); + } + getHooks() { + Client.listOutgoingHooks( + (data) => { + if (data) { + this.setState({hooks: data, getHooksComplete: true, editError: null}); + } + }, + (err) => { + this.setState({editError: err.message}); + } + ); + } + updateChannelId(e) { + this.setState({channelId: e.target.value}); + } + updateTriggerWords(e) { + this.setState({triggerWords: e.target.value}); + } + updateCallbackURLs(e) { + this.setState({callbackURLs: e.target.value}); + } + render() { + let addError; + if (this.state.addError) { + addError = <label className='has-error'>{this.state.addError}</label>; + } + let editError; + if (this.state.editError) { + addError = <label className='has-error'>{this.state.editError}</label>; + } + + const channels = ChannelStore.getAll(); + const options = []; + options.push( + <option + key='select-channel' + value='' + > + {this.props.intl.formatMessage(holders.select)} + </option> + ); + + channels.forEach((channel) => { + if (channel.type === Constants.OPEN_CHANNEL) { + options.push( + <option + key={'outgoing-hook' + channel.id} + value={channel.id} + > + {channel.display_name} + </option> + ); + } + }); + + const hooks = []; + this.state.hooks.forEach((hook) => { + const c = ChannelStore.get(hook.channel_id); + + if (!c && hook.channel_id && hook.channel_id.length !== 0) { + return; + } + + let channelDiv; + if (c) { + channelDiv = ( + <div className='padding-top'> + <strong> + <FormattedMessage + id='user.settings.hooks_out.channel' + defaultMessage='Channel: ' + /> + </strong>{c.display_name} + </div> + ); + } + + let triggerDiv; + if (hook.trigger_words && hook.trigger_words.length !== 0) { + triggerDiv = ( + <div className='padding-top'> + <strong> + <FormattedMessage + id='user.settings.hooks_out.trigger' + defaultMessage='Trigger Words: ' + /> + </strong>{hook.trigger_words.join(', ')} + </div> + ); + } + + hooks.push( + <div + key={hook.id} + className='webhook__item' + > + <div className='padding-top x2 webhook__url'> + <strong>{'URLs: '}</strong><span className='word-break--all'>{hook.callback_urls.join(', ')}</span> + </div> + {channelDiv} + {triggerDiv} + <div className='padding-top'> + <strong>{'Token: '}</strong>{hook.token} + </div> + <div className='padding-top'> + <a + className='text-danger' + href='#' + onClick={this.regenToken.bind(this, hook.id)} + > + <FormattedMessage + id='user.settings.hooks_out.regen' + defaultMessage='Regen Token' + /> + </a> + <a + className='webhook__remove' + href='#' + onClick={this.removeHook.bind(this, hook.id)} + > + <span aria-hidden='true'>{'×'}</span> + </a> + </div> + <div className='padding-top x2 divider-light'></div> + </div> + ); + }); + + let displayHooks; + if (!this.state.getHooksComplete) { + displayHooks = <LoadingScreen/>; + } else if (hooks.length > 0) { + displayHooks = hooks; + } else { + displayHooks = ( + <div className='padding-top x2'> + <FormattedMessage + id='user.settings.hooks_out.none' + defaultMessage='None' + /> + </div> + ); + } + + const existingHooks = ( + <div className='webhooks__container'> + <label className='control-label padding-top x2'> + <FormattedMessage + id='user.settings.hooks_out.existing' + defaultMessage='Existing outgoing webhooks' + /> + </label> + <div className='padding-top divider-light'></div> + <div className='webhooks__list'> + {displayHooks} + </div> + </div> + ); + + const disableButton = (this.state.channelId === '' && this.state.triggerWords === '') || this.state.callbackURLs === ''; + + return ( + <div key='addOutgoingHook'> + <FormattedHTMLMessage + id='user.settings.hooks_out.addDescription' + defaultMessage='Create webhooks to send new message events to an external integration. Please see <a href="http://docs.mattermost.com/developer/webhooks-outgoing.html" target="_blank">outgoing webhooks documentation</a> to learn more. View all outgoing webhooks configured on this team below.' + /> + <div><label className='control-label padding-top x2'> + <FormattedMessage + id='user.settings.hooks_out.addTitle' + defaultMessage='Add a new outgoing webhook' + /> + </label></div> + <div className='padding-top divider-light'></div> + <div className='padding-top'> + <div> + <label className='control-label'> + <FormattedMessage + id='user.settings.hooks_out.channel' + defaultMessage='Channel: ' + /> + </label> + <div className='padding-top'> + <select + ref='channelName' + className='form-control' + value={this.state.channelId} + onChange={this.updateChannelId} + > + {options} + </select> + </div> + <div className='padding-top'> + <FormattedMessage + id='user.settings.hooks_out.only' + defaultMessage='Only public channels can be used' + /> + </div> + </div> + <div className='padding-top x2'> + <label className='control-label'> + <FormattedMessage + id='user.settings.hooks_out.trigger' + defaultMessage='Trigger Words: ' + /> + </label> + <div className='padding-top'> + <input + ref='triggerWords' + className='form-control' + value={this.state.triggerWords} + onChange={this.updateTriggerWords} + placeholder={this.props.intl.formatMessage(holders.optional)} + /> + </div> + <div className='padding-top'> + <FormattedMessage + id='user.settings.hooks_out.comma' + defaultMessage='Comma separated words to trigger on' + /> + </div> + </div> + <div className='padding-top x2'> + <label className='control-label'> + <FormattedMessage + id='user.settings.hooks_out.callback' + defaultMessage='Callback URLs: ' + /> + </label> + <div className='padding-top'> + <textarea + ref='callbackURLs' + className='form-control no-resize' + value={this.state.callbackURLs} + resize={false} + rows={3} + onChange={this.updateCallbackURLs} + placeholder={this.props.intl.formatMessage(holders.callbackHolder)} + /> + </div> + <div className='padding-top'> + <FormattedMessage + id='user.settings.hooks_out.callbackDesc' + defaultMessage='New line separated URLs that will receive the HTTP POST event' + /> + </div> + {addError} + </div> + <div className='padding-top padding-bottom'> + <a + className={'btn btn-sm btn-primary'} + href='#' + disabled={disableButton} + onClick={this.addNewHook} + > + <FormattedMessage + id='user.settings.hooks_out.add' + defaultMessage='Add' + /> + </a> + </div> + </div> + {existingHooks} + {editError} + </div> + ); + } +} + +ManageOutgoingHooks.propTypes = { + intl: intlShape.isRequired +}; + +export default injectIntl(ManageOutgoingHooks); diff --git a/webapp/components/user_settings/premade_theme_chooser.jsx b/webapp/components/user_settings/premade_theme_chooser.jsx new file mode 100644 index 000000000..c35748b41 --- /dev/null +++ b/webapp/components/user_settings/premade_theme_chooser.jsx @@ -0,0 +1,61 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import * as Utils from 'utils/utils.jsx'; +import Constants from 'utils/constants.jsx'; + +import React from 'react'; + +export default class PremadeThemeChooser extends React.Component { + constructor(props) { + super(props); + this.state = {}; + } + render() { + const theme = this.props.theme; + + const premadeThemes = []; + for (const k in Constants.THEMES) { + if (Constants.THEMES.hasOwnProperty(k)) { + const premadeTheme = $.extend(true, {}, Constants.THEMES[k]); + + let activeClass = ''; + if (premadeTheme.type === theme.type) { + activeClass = 'active'; + } + + premadeThemes.push( + <div + className='col-xs-6 col-sm-3 premade-themes' + key={'premade-theme-key' + k} + > + <div + className={activeClass} + onClick={() => this.props.updateTheme(premadeTheme)} + > + <label> + <img + className='img-responsive' + src={premadeTheme.image} + /> + <div className='theme-label'>{Utils.toTitleCase(premadeTheme.type)}</div> + </label> + </div> + </div> + ); + } + } + + return ( + <div className='row appearance-section'> + {premadeThemes} + </div> + ); + } +} + +PremadeThemeChooser.propTypes = { + theme: React.PropTypes.object.isRequired, + updateTheme: React.PropTypes.func.isRequired +}; diff --git a/webapp/components/user_settings/user_settings.jsx b/webapp/components/user_settings/user_settings.jsx new file mode 100644 index 000000000..904232da9 --- /dev/null +++ b/webapp/components/user_settings/user_settings.jsx @@ -0,0 +1,160 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import UserStore from 'stores/user_store.jsx'; +import * as utils from 'utils/utils.jsx'; +import NotificationsTab from './user_settings_notifications.jsx'; +import SecurityTab from './user_settings_security.jsx'; +import GeneralTab from './user_settings_general.jsx'; +import DeveloperTab from './user_settings_developer.jsx'; +import IntegrationsTab from './user_settings_integrations.jsx'; +import DisplayTab from './user_settings_display.jsx'; +import AdvancedTab from './user_settings_advanced.jsx'; + +import React from 'react'; + +export default class UserSettings extends React.Component { + constructor(props) { + super(props); + + this.getActiveTab = this.getActiveTab.bind(this); + this.onListenerChange = this.onListenerChange.bind(this); + + this.state = {user: UserStore.getCurrentUser()}; + } + + componentDidMount() { + UserStore.addChangeListener(this.onListenerChange); + } + + componentWillUnmount() { + UserStore.removeChangeListener(this.onListenerChange); + } + + getActiveTab() { + return this.refs.activeTab; + } + + onListenerChange() { + var user = UserStore.getCurrentUser(); + if (!utils.areObjectsEqual(this.state.user, user)) { + this.setState({user}); + } + } + + render() { + if (this.props.activeTab === 'general') { + return ( + <div> + <GeneralTab + ref='activeTab' + user={this.state.user} + activeSection={this.props.activeSection} + updateSection={this.props.updateSection} + updateTab={this.props.updateTab} + closeModal={this.props.closeModal} + collapseModal={this.props.collapseModal} + /> + </div> + ); + } else if (this.props.activeTab === 'security') { + return ( + <div> + <SecurityTab + ref='activeTab' + user={this.state.user} + activeSection={this.props.activeSection} + updateSection={this.props.updateSection} + updateTab={this.props.updateTab} + closeModal={this.props.closeModal} + collapseModal={this.props.collapseModal} + setEnforceFocus={this.props.setEnforceFocus} + /> + </div> + ); + } else if (this.props.activeTab === 'notifications') { + return ( + <div> + <NotificationsTab + ref='activeTab' + user={this.state.user} + activeSection={this.props.activeSection} + updateSection={this.props.updateSection} + updateTab={this.props.updateTab} + closeModal={this.props.closeModal} + collapseModal={this.props.collapseModal} + /> + </div> + ); + } else if (this.props.activeTab === 'developer') { + return ( + <div> + <DeveloperTab + ref='activeTab' + activeSection={this.props.activeSection} + updateSection={this.props.updateSection} + closeModal={this.props.closeModal} + collapseModal={this.props.collapseModal} + /> + </div> + ); + } else if (this.props.activeTab === 'integrations') { + return ( + <div> + <IntegrationsTab + ref='activeTab' + user={this.state.user} + activeSection={this.props.activeSection} + updateSection={this.props.updateSection} + updateTab={this.props.updateTab} + closeModal={this.props.closeModal} + collapseModal={this.props.collapseModal} + /> + </div> + ); + } else if (this.props.activeTab === 'display') { + return ( + <div> + <DisplayTab + ref='activeTab' + user={this.state.user} + activeSection={this.props.activeSection} + updateSection={this.props.updateSection} + updateTab={this.props.updateTab} + closeModal={this.props.closeModal} + collapseModal={this.props.collapseModal} + setEnforceFocus={this.props.setEnforceFocus} + setRequireConfirm={this.props.setRequireConfirm} + /> + </div> + ); + } else if (this.props.activeTab === 'advanced') { + return ( + <div> + <AdvancedTab + ref='activeTab' + user={this.state.user} + activeSection={this.props.activeSection} + updateSection={this.props.updateSection} + updateTab={this.props.updateTab} + closeModal={this.props.closeModal} + collapseModal={this.props.collapseModal} + /> + </div> + ); + } + + return <div/>; + } +} + +UserSettings.propTypes = { + activeTab: React.PropTypes.string, + activeSection: React.PropTypes.string, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func, + closeModal: React.PropTypes.func.isRequired, + collapseModal: React.PropTypes.func.isRequired, + setEnforceFocus: React.PropTypes.func.isRequired, + setRequireConfirm: React.PropTypes.func.isRequired +}; diff --git a/webapp/components/user_settings/user_settings_advanced.jsx b/webapp/components/user_settings/user_settings_advanced.jsx new file mode 100644 index 000000000..7c496f57b --- /dev/null +++ b/webapp/components/user_settings/user_settings_advanced.jsx @@ -0,0 +1,345 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as Client from 'utils/client.jsx'; +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; +import Constants from 'utils/constants.jsx'; +import PreferenceStore from 'stores/preference_store.jsx'; + +import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl'; + +const PreReleaseFeatures = Constants.PRE_RELEASE_FEATURES; + +const holders = defineMessages({ + sendTitle: { + id: 'user.settings.advance.sendTitle', + defaultMessage: 'Send messages on Ctrl + Enter' + }, + on: { + id: 'user.settings.advance.on', + defaultMessage: 'On' + }, + off: { + id: 'user.settings.advance.off', + defaultMessage: 'Off' + }, + preReleaseTitle: { + id: 'user.settings.advance.preReleaseTitle', + defaultMessage: 'Preview pre-release features' + }, + feature: { + id: 'user.settings.advance.feature', + defaultMessage: ' Feature ' + }, + features: { + id: 'user.settings.advance.features', + defaultMessage: ' Features ' + }, + enabled: { + id: 'user.settings.advance.enabled', + defaultMessage: 'enabled' + }, + MARKDOWN_PREVIEW: { + id: 'user.settings.advance.markdown_preview', + defaultMessage: 'Show markdown preview option in message input box' + }, + EMBED_PREVIEW: { + id: 'user.settings.advance.embed_preview', + defaultMessage: 'Show preview snippet of links below message' + }, + EMBED_TOGGLE: { + id: 'user.settings.advance.embed_toggle', + defaultMessage: 'Show toggle for all embed previews' + } +}); + +import React from 'react'; + +class AdvancedSettingsDisplay extends React.Component { + constructor(props) { + super(props); + + this.updateSection = this.updateSection.bind(this); + this.updateSetting = this.updateSetting.bind(this); + this.toggleFeature = this.toggleFeature.bind(this); + this.saveEnabledFeatures = this.saveEnabledFeatures.bind(this); + + const preReleaseFeaturesKeys = Object.keys(PreReleaseFeatures); + const advancedSettings = PreferenceStore.getCategory(Constants.Preferences.CATEGORY_ADVANCED_SETTINGS); + const settings = { + send_on_ctrl_enter: PreferenceStore.getPreference( + Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, + 'send_on_ctrl_enter', + {value: 'false'} + ).value + }; + + let enabledFeatures = 0; + advancedSettings.forEach((setting) => { + preReleaseFeaturesKeys.forEach((key) => { + const feature = PreReleaseFeatures[key]; + if (setting.name === Constants.FeatureTogglePrefix + feature.label) { + settings[setting.name] = setting.value; + if (setting.value === 'true') { + enabledFeatures++; + } + } + }); + }); + + this.state = {preReleaseFeatures: PreReleaseFeatures, settings, preReleaseFeaturesKeys, enabledFeatures}; + } + + updateSetting(setting, value) { + const settings = this.state.settings; + settings[setting] = value; + this.setState(settings); + } + + toggleFeature(feature, checked) { + const settings = this.state.settings; + settings[Constants.FeatureTogglePrefix + feature] = String(checked); + + let enabledFeatures = 0; + Object.keys(this.state.settings).forEach((setting) => { + if (setting.lastIndexOf(Constants.FeatureTogglePrefix) === 0 && this.state.settings[setting] === 'true') { + enabledFeatures++; + } + }); + + this.setState({settings, enabledFeatures}); + } + + saveEnabledFeatures() { + const features = []; + Object.keys(this.state.settings).forEach((setting) => { + if (setting.lastIndexOf(Constants.FeatureTogglePrefix) === 0) { + features.push(setting); + } + }); + + this.handleSubmit(features); + } + + handleSubmit(settings) { + const preferences = []; + + (Array.isArray(settings) ? settings : [settings]).forEach((setting) => { + preferences.push( + PreferenceStore.setPreference( + Constants.Preferences.CATEGORY_ADVANCED_SETTINGS, + setting, + String(this.state.settings[setting]) + ) + ); + }); + + Client.savePreferences(preferences, + () => { + PreferenceStore.emitChange(); + this.updateSection(''); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + + updateSection(section) { + this.props.updateSection(section); + } + + render() { + const serverError = this.state.serverError || null; + const {formatMessage} = this.props.intl; + let ctrlSendSection; + + if (this.props.activeSection === 'advancedCtrlSend') { + const ctrlSendActive = [ + this.state.settings.send_on_ctrl_enter === 'true', + this.state.settings.send_on_ctrl_enter === 'false' + ]; + + const inputs = [ + <div key='ctrlSendSetting'> + <div className='radio'> + <label> + <input + type='radio' + checked={ctrlSendActive[0]} + onChange={this.updateSetting.bind(this, 'send_on_ctrl_enter', 'true')} + /> + <FormattedMessage + id='user.settings.advance.on' + defaultMessage='On' + /> + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + type='radio' + checked={ctrlSendActive[1]} + onChange={this.updateSetting.bind(this, 'send_on_ctrl_enter', 'false')} + /> + <FormattedMessage + id='user.settings.advance.off' + defaultMessage='Off' + /> + </label> + <br/> + </div> + <div> + <br/> + <FormattedMessage + id='user.settings.advance.sendDesc' + defaultMessage="If enabled 'Enter' inserts a new line and 'Ctrl + Enter' submits the message." + /> + </div> + </div> + ]; + + ctrlSendSection = ( + <SettingItemMax + title={formatMessage(holders.sendTitle)} + inputs={inputs} + submit={() => this.handleSubmit('send_on_ctrl_enter')} + server_error={serverError} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + ctrlSendSection = ( + <SettingItemMin + title={formatMessage(holders.sendTitle)} + describe={this.state.settings.send_on_ctrl_enter === 'true' ? formatMessage(holders.on) : formatMessage(holders.off)} + updateSection={() => this.props.updateSection('advancedCtrlSend')} + /> + ); + } + + let previewFeaturesSection; + let previewFeaturesSectionDivider; + if (this.state.preReleaseFeaturesKeys.length > 0) { + previewFeaturesSectionDivider = ( + <div className='divider-light'/> + ); + + if (this.props.activeSection === 'advancedPreviewFeatures') { + const inputs = []; + + this.state.preReleaseFeaturesKeys.forEach((key) => { + const feature = this.state.preReleaseFeatures[key]; + inputs.push( + <div key={'advancedPreviewFeatures_' + feature.label}> + <div className='checkbox'> + <label> + <input + type='checkbox' + checked={this.state.settings[Constants.FeatureTogglePrefix + feature.label] === 'true'} + onChange={(e) => { + this.toggleFeature(feature.label, e.target.checked); + }} + /> + {formatMessage(holders[key])} + </label> + </div> + </div> + ); + }); + + inputs.push( + <div key='advancedPreviewFeatures_helptext'> + <br/> + <FormattedMessage + id='user.settings.advance.preReleaseDesc' + defaultMessage="Check any pre-released features you'd like to preview. You may also need to refresh the page before the setting will take effect." + /> + </div> + ); + + previewFeaturesSection = ( + <SettingItemMax + title={formatMessage(holders.preReleaseTitle)} + inputs={inputs} + submit={this.saveEnabledFeatures} + server_error={serverError} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + previewFeaturesSection = ( + <SettingItemMin + title={formatMessage(holders.preReleaseTitle)} + describe={this.state.enabledFeatures + (this.state.enabledFeatures === 1 ? formatMessage(holders.feature) : formatMessage(holders.features)) + formatMessage(holders.enabled)} + updateSection={() => this.props.updateSection('advancedPreviewFeatures')} + /> + ); + } + } + + return ( + <div> + <div className='modal-header'> + <button + type='button' + className='close' + data-dismiss='modal' + aria-label='Close' + onClick={this.props.closeModal} + > + <span aria-hidden='true'>{'×'}</span> + </button> + <h4 + className='modal-title' + ref='title' + > + <div className='modal-back'> + <i + className='fa fa-angle-left' + onClick={this.props.collapseModal} + /> + </div> + <FormattedMessage + id='user.settings.advance.title' + defaultMessage='Advanced Settings' + /> + </h4> + </div> + <div className='user-settings'> + <h3 className='tab-header'> + <FormattedMessage + id='user.settings.advance.title' + defaultMessage='Advanced Settings' + /> + </h3> + <div className='divider-dark first'/> + {ctrlSendSection} + {previewFeaturesSectionDivider} + {previewFeaturesSection} + <div className='divider-dark'/> + </div> + </div> + ); + } +} + +AdvancedSettingsDisplay.propTypes = { + intl: intlShape.isRequired, + user: React.PropTypes.object, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func, + activeSection: React.PropTypes.string, + closeModal: React.PropTypes.func.isRequired, + collapseModal: React.PropTypes.func.isRequired +}; + +export default injectIntl(AdvancedSettingsDisplay); diff --git a/webapp/components/user_settings/user_settings_developer.jsx b/webapp/components/user_settings/user_settings_developer.jsx new file mode 100644 index 000000000..cabb021cb --- /dev/null +++ b/webapp/components/user_settings/user_settings_developer.jsx @@ -0,0 +1,138 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; +import * as GlobalActions from 'action_creators/global_actions.jsx'; + +import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl'; + +const holders = defineMessages({ + applicationsPreview: { + id: 'user.settings.developer.applicationsPreview', + defaultMessage: 'Applications (Preview)' + }, + thirdParty: { + id: 'user.settings.developer.thirdParty', + defaultMessage: 'Open to register a new third-party application' + } +}); + +import React from 'react'; + +class DeveloperTab extends React.Component { + constructor(props) { + super(props); + + this.register = this.register.bind(this); + + this.state = {}; + } + register() { + this.props.closeModal(); + GlobalActions.showRegisterAppModal(); + } + render() { + var appSection; + var self = this; + const {formatMessage} = this.props.intl; + if (this.props.activeSection === 'app') { + var inputs = []; + + inputs.push( + <div + key='registerbtn' + className='form-group' + > + <div className='col-sm-7'> + <a + className='btn btn-sm btn-primary' + onClick={this.register} + > + <FormattedMessage + id='user.settings.developer.register' + defaultMessage='Register New Application' + /> + </a> + </div> + </div> + ); + + appSection = ( + <SettingItemMax + title={formatMessage(holders.applicationsPreview)} + inputs={inputs} + updateSection={function updateSection(e) { + self.props.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + appSection = ( + <SettingItemMin + title={formatMessage(holders.applicationsPreview)} + describe={formatMessage(holders.thirdParty)} + updateSection={function updateSection() { + self.props.updateSection('app'); + }} + /> + ); + } + + return ( + <div> + <div className='modal-header'> + <button + type='button' + className='close' + data-dismiss='modal' + aria-label='Close' + onClick={this.props.closeModal} + > + <span aria-hidden='true'>{'×'}</span> + </button> + <h4 + className='modal-title' + ref='title' + > + <div className='modal-back'> + <i + className='fa fa-angle-left' + onClick={this.props.collapseModal} + /> + </div> + <FormattedMessage + id='user.settings.developer.title' + defaultMessage='Developer Settings' + /> + </h4> + </div> + <div className='user-settings'> + <h3 className='tab-header'> + <FormattedMessage + id='user.settings.developer.title' + defaultMessage='Developer Settings' + /> + </h3> + <div className='divider-dark first'/> + {appSection} + <div className='divider-dark'/> + </div> + </div> + ); + } +} + +DeveloperTab.defaultProps = { + activeSection: '' +}; +DeveloperTab.propTypes = { + intl: intlShape.isRequired, + activeSection: React.PropTypes.string, + updateSection: React.PropTypes.func, + closeModal: React.PropTypes.func.isRequired, + collapseModal: React.PropTypes.func.isRequired +}; + +export default injectIntl(DeveloperTab);
\ No newline at end of file diff --git a/webapp/components/user_settings/user_settings_display.jsx b/webapp/components/user_settings/user_settings_display.jsx new file mode 100644 index 000000000..58d4493cb --- /dev/null +++ b/webapp/components/user_settings/user_settings_display.jsx @@ -0,0 +1,494 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; +import ManageLanguages from './manage_languages.jsx'; +import ThemeSetting from './user_settings_theme.jsx'; + +import PreferenceStore from 'stores/preference_store.jsx'; +import * as Utils from 'utils/utils.jsx'; +import * as I18n from 'i18n/i18n.jsx'; + +import Constants from 'utils/constants.jsx'; + +import {savePreferences} from 'utils/client.jsx'; +import {FormattedMessage} from 'react-intl'; + +function getDisplayStateFromStores() { + const militaryTime = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', {value: 'false'}); + const nameFormat = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', {value: 'username'}); + const selectedFont = PreferenceStore.getPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'selected_font', {value: Constants.DEFAULT_FONT}); + + return { + militaryTime: militaryTime.value, + nameFormat: nameFormat.value, + selectedFont: selectedFont.value + }; +} + +import React from 'react'; + +export default class UserSettingsDisplay extends React.Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + this.handleClockRadio = this.handleClockRadio.bind(this); + this.handleNameRadio = this.handleNameRadio.bind(this); + this.handleFont = this.handleFont.bind(this); + this.updateSection = this.updateSection.bind(this); + this.updateState = this.updateState.bind(this); + this.deactivate = this.deactivate.bind(this); + + this.state = getDisplayStateFromStores(); + } + handleSubmit() { + const timePreference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'use_military_time', this.state.militaryTime); + const namePreference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'name_format', this.state.nameFormat); + const fontPreference = PreferenceStore.setPreference(Constants.Preferences.CATEGORY_DISPLAY_SETTINGS, 'selected_font', this.state.selectedFont); + + savePreferences([timePreference, namePreference, fontPreference], + () => { + PreferenceStore.emitChange(); + this.updateSection(''); + }, + (err) => { + this.setState({serverError: err.message}); + } + ); + } + handleClockRadio(militaryTime) { + this.setState({militaryTime}); + } + handleNameRadio(nameFormat) { + this.setState({nameFormat}); + } + handleFont(selectedFont) { + Utils.applyFont(selectedFont); + this.setState({selectedFont}); + } + updateSection(section) { + this.updateState(); + this.props.updateSection(section); + } + updateState() { + const newState = getDisplayStateFromStores(); + if (!Utils.areObjectsEqual(newState, this.state)) { + this.handleFont(newState.selectedFont); + this.setState(newState); + } + } + deactivate() { + this.updateState(); + } + render() { + const serverError = this.state.serverError || null; + let clockSection; + let nameFormatSection; + let fontSection; + let languagesSection; + + if (this.props.activeSection === 'clock') { + const clockFormat = [false, false]; + if (this.state.militaryTime === 'true') { + clockFormat[1] = true; + } else { + clockFormat[0] = true; + } + + const handleUpdateClockSection = (e) => { + this.updateSection(''); + e.preventDefault(); + }; + + const inputs = [ + <div key='userDisplayClockOptions'> + <div className='radio'> + <label> + <input + type='radio' + checked={clockFormat[0]} + onChange={this.handleClockRadio.bind(this, 'false')} + /> + <FormattedMessage + id='user.settings.display.normalClock' + defaultMessage='12-hour clock (example: 4:00 PM)' + /> + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + type='radio' + checked={clockFormat[1]} + onChange={this.handleClockRadio.bind(this, 'true')} + /> + <FormattedMessage + id='user.settings.display.militaryClock' + defaultMessage='24-hour clock (example: 16:00)' + /> + </label> + <br/> + </div> + <div> + <br/> + <FormattedMessage + id='user.settings.display.preferTime' + defaultMessage='Select how you prefer time displayed.' + /> + </div> + </div> + ]; + + clockSection = ( + <SettingItemMax + title={ + <FormattedMessage + id='user.settings.display.clockDisplay' + defaultMessage='Clock Display' + /> + } + inputs={inputs} + submit={this.handleSubmit} + server_error={serverError} + updateSection={handleUpdateClockSection} + /> + ); + } else { + let describe; + if (this.state.militaryTime === 'true') { + describe = ( + <FormattedMessage + id='user.settings.display.militaryClock' + defaultMessage='24-hour clock (example: 16:00)' + /> + ); + } else { + describe = ( + <FormattedMessage + id='user.settings.display.normalClock' + defaultMessage='12-hour clock (example: 4:00 PM)' + /> + ); + } + + const handleUpdateClockSection = () => { + this.props.updateSection('clock'); + }; + + clockSection = ( + <SettingItemMin + title={ + <FormattedMessage + id='user.settings.display.clockDisplay' + defaultMessage='Clock Display' + /> + } + describe={describe} + updateSection={handleUpdateClockSection} + /> + ); + } + + const showUsername = ( + <FormattedMessage + id='user.settings.display.showUsername' + defaultMessage='Show username (team default)' + /> + ); + const showNickname = ( + <FormattedMessage + id='user.settings.display.showNickname' + defaultMessage='Show nickname if one exists, otherwise show first and last name' + /> + ); + const showFullName = ( + <FormattedMessage + id='user.settings.display.showFullname' + defaultMessage='Show first and last name' + /> + ); + if (this.props.activeSection === 'name_format') { + const nameFormat = [false, false, false]; + if (this.state.nameFormat === 'nickname_full_name') { + nameFormat[0] = true; + } else if (this.state.nameFormat === 'full_name') { + nameFormat[2] = true; + } else { + nameFormat[1] = true; + } + + const inputs = [ + <div key='userDisplayNameOptions'> + <div className='radio'> + <label> + <input + type='radio' + checked={nameFormat[1]} + onChange={this.handleNameRadio.bind(this, 'username')} + /> + {showUsername} + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + type='radio' + checked={nameFormat[0]} + onChange={this.handleNameRadio.bind(this, 'nickname_full_name')} + /> + {showNickname} + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + type='radio' + checked={nameFormat[2]} + onChange={this.handleNameRadio.bind(this, 'full_name')} + /> + {showFullName} + </label> + <br/> + </div> + <div> + <br/> + <FormattedMessage + id='user.settings.display.nameOptsDesc' + defaultMessage="Set how to display other user's names in posts and the Direct Messages list." + /> + </div> + </div> + ]; + + nameFormatSection = ( + <SettingItemMax + title={ + <FormattedMessage + id='user.settings.display.teammateDisplay' + defaultMessage='Teammate Name Display' + /> + } + inputs={inputs} + submit={this.handleSubmit} + server_error={serverError} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + let describe; + if (this.state.nameFormat === 'username') { + describe = ( + <FormattedMessage + id='user.settings.display.showUsername' + defaultMessage='Show username (team default)' + /> + ); + } else if (this.state.nameFormat === 'full_name') { + describe = ( + <FormattedMessage + id='user.settings.display.showFullname' + defaultMessage='Show first and last name' + /> + ); + } else { + describe = ( + <FormattedMessage + id='user.settings.display.showNickname' + defaultMessage='Show nickname if one exists, otherwise show first and last name' + /> + ); + } + + nameFormatSection = ( + <SettingItemMin + title={ + <FormattedMessage + id='user.settings.display.teammateDisplay' + defaultMessage='Teammate Name Display' + /> + } + describe={describe} + updateSection={() => { + this.props.updateSection('name_format'); + }} + /> + ); + } + + if (this.props.activeSection === 'font') { + const options = []; + Object.keys(Constants.FONTS).forEach((fontName, idx) => { + const className = Constants.FONTS[fontName]; + options.push( + <option + key={'font_' + idx} + value={fontName} + className={className} + > + {fontName} + </option> + ); + }); + + const inputs = [ + <div key='userDisplayNameOptions'> + <div + className='dropdown' + > + <select + className='form-control' + type='text' + value={this.state.selectedFont} + onChange={(e) => this.handleFont(e.target.value)} + > + {options} + </select> + </div> + <div> + <br/> + <FormattedMessage + id='user.settings.display.fontDesc' + defaultMessage='Select the font displayed in the Mattermost user interface.' + /> + </div> + </div> + ]; + + fontSection = ( + <SettingItemMax + title={ + <FormattedMessage + id='user.settings.display.fontTitle' + defaultMessage='Display Font' + /> + } + inputs={inputs} + submit={this.handleSubmit} + server_error={serverError} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + fontSection = ( + <SettingItemMin + title={ + <FormattedMessage + id='user.settings.display.fontTitle' + defaultMessage='Display Font' + /> + } + describe={this.state.selectedFont} + updateSection={() => { + this.props.updateSection('font'); + }} + /> + ); + } + + if (this.props.activeSection === 'languages') { + languagesSection = ( + <ManageLanguages + user={this.props.user} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + var locale = I18n.getLanguageInfo(this.props.user.locale).name; + + languagesSection = ( + <SettingItemMin + title={ + <FormattedMessage + id='user.settings.display.language' + defaultMessage='Language' + /> + } + width='medium' + describe={locale} + updateSection={() => { + this.updateSection('languages'); + }} + /> + ); + } + + return ( + <div> + <div className='modal-header'> + <button + type='button' + className='close' + data-dismiss='modal' + aria-label='Close' + onClick={this.props.closeModal} + > + <span aria-hidden='true'>{'×'}</span> + </button> + <h4 + className='modal-title' + ref='title' + > + <div className='modal-back'> + <i + className='fa fa-angle-left' + onClick={this.props.collapseModal} + /> + </div> + <FormattedMessage + id='user.settings.display.title' + defaultMessage='Display Settings' + /> + </h4> + </div> + <div className='user-settings'> + <h3 className='tab-header'> + <FormattedMessage + id='user.settings.display.title' + defaultMessage='Display Settings' + /> + </h3> + <div className='divider-dark first'/> + <ThemeSetting + selected={this.props.activeSection === 'theme'} + updateSection={this.updateSection} + setRequireConfirm={this.props.setRequireConfirm} + setEnforceFocus={this.props.setEnforceFocus} + /> + <div className='divider-dark'/> + {fontSection} + <div className='divider-dark'/> + {clockSection} + <div className='divider-dark'/> + {nameFormatSection} + <div className='divider-dark'/> + {languagesSection} + </div> + </div> + ); + } +} + +UserSettingsDisplay.propTypes = { + user: React.PropTypes.object, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func, + activeSection: React.PropTypes.string, + closeModal: React.PropTypes.func.isRequired, + collapseModal: React.PropTypes.func.isRequired, + setRequireConfirm: React.PropTypes.func.isRequired, + setEnforceFocus: React.PropTypes.func.isRequired +}; diff --git a/webapp/components/user_settings/user_settings_general.jsx b/webapp/components/user_settings/user_settings_general.jsx new file mode 100644 index 000000000..42c7e1a77 --- /dev/null +++ b/webapp/components/user_settings/user_settings_general.jsx @@ -0,0 +1,817 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; +import SettingPicture from '../setting_picture.jsx'; + +import UserStore from 'stores/user_store.jsx'; +import ErrorStore from 'stores/error_store.jsx'; + +import * as Client from 'utils/client.jsx'; +import Constants from 'utils/constants.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; +import * as Utils from 'utils/utils.jsx'; + +import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedDate} from 'react-intl'; + +const holders = defineMessages({ + usernameReserved: { + id: 'user.settings.general.usernameReserved', + defaultMessage: 'This username is reserved, please choose a new one.' + }, + usernameRestrictions: { + id: 'user.settings.general.usernameRestrictions', + defaultMessage: "Username must begin with a letter, and contain between {min} to {max} lowercase characters made up of numbers, letters, and the symbols '.', '-' and '_'." + }, + validEmail: { + id: 'user.settings.general.validEmail', + defaultMessage: 'Please enter a valid email address' + }, + emailMatch: { + id: 'user.settings.general.emailMatch', + defaultMessage: 'The new emails you entered do not match.' + }, + checkEmail: { + id: 'user.settings.general.checkEmail', + defaultMessage: 'Check your email at {email} to verify the address.' + }, + newAddress: { + id: 'user.settings.general.newAddress', + defaultMessage: 'New Address: {email}<br />Check your email to verify the above address.' + }, + checkEmailNoAddress: { + id: 'user.settings.general.checkEmailNoAddress', + defaultMessage: 'Check your email to verify your new address' + }, + loginGitlab: { + id: 'user.settings.general.loginGitlab', + defaultMessage: 'Log in done through GitLab' + }, + validImage: { + id: 'user.settings.general.validImage', + defaultMessage: 'Only JPG or PNG images may be used for profile pictures' + }, + imageTooLarge: { + id: 'user.settings.general.imageTooLarge', + defaultMessage: 'Unable to upload profile image. File is too large.' + }, + uploadImage: { + id: 'user.settings.general.uploadImage', + defaultMessage: "Click 'Edit' to upload an image." + }, + imageUpdated: { + id: 'user.settings.general.imageUpdated', + defaultMessage: 'Image last updated {date}' + }, + fullName: { + id: 'user.settings.general.fullName', + defaultMessage: 'Full Name' + }, + nickname: { + id: 'user.settings.general.nickname', + defaultMessage: 'Nickname' + }, + username: { + id: 'user.settings.general.username', + defaultMessage: 'Username' + }, + email: { + id: 'user.settings.general.email', + defaultMessage: 'Email' + }, + profilePicture: { + id: 'user.settings.general.profilePicture', + defaultMessage: 'Profile Picture' + }, + close: { + id: 'user.settings.general.close', + defaultMessage: 'Close' + } +}); + +import React from 'react'; + +class UserSettingsGeneralTab extends React.Component { + constructor(props) { + super(props); + this.submitActive = false; + + this.submitUsername = this.submitUsername.bind(this); + this.submitNickname = this.submitNickname.bind(this); + this.submitName = this.submitName.bind(this); + this.submitEmail = this.submitEmail.bind(this); + this.submitUser = this.submitUser.bind(this); + this.submitPicture = this.submitPicture.bind(this); + + this.updateUsername = this.updateUsername.bind(this); + this.updateFirstName = this.updateFirstName.bind(this); + this.updateLastName = this.updateLastName.bind(this); + this.updateNickname = this.updateNickname.bind(this); + this.updateEmail = this.updateEmail.bind(this); + this.updateConfirmEmail = this.updateConfirmEmail.bind(this); + this.updatePicture = this.updatePicture.bind(this); + this.updateSection = this.updateSection.bind(this); + + this.state = this.setupInitialState(props); + } + submitUsername(e) { + e.preventDefault(); + + const user = Object.assign({}, this.props.user); + const username = this.state.username.trim().toLowerCase(); + + const {formatMessage} = this.props.intl; + const usernameError = Utils.isValidUsername(username); + if (usernameError === 'Cannot use a reserved word as a username.') { + this.setState({clientError: formatMessage(holders.usernameReserved)}); + return; + } else if (usernameError) { + this.setState({clientError: formatMessage(holders.usernameRestrictions, {min: Constants.MIN_USERNAME_LENGTH, max: Constants.MAX_USERNAME_LENGTH})}); + return; + } + + if (user.username === username) { + this.updateSection(''); + return; + } + + user.username = username; + + this.submitUser(user, false); + } + submitNickname(e) { + e.preventDefault(); + + const user = Object.assign({}, this.props.user); + const nickname = this.state.nickname.trim(); + + if (user.nickname === nickname) { + this.updateSection(''); + return; + } + + user.nickname = nickname; + + this.submitUser(user, false); + } + submitName(e) { + e.preventDefault(); + + const user = Object.assign({}, this.props.user); + const firstName = this.state.firstName.trim(); + const lastName = this.state.lastName.trim(); + + if (user.first_name === firstName && user.last_name === lastName) { + this.updateSection(''); + return; + } + + user.first_name = firstName; + user.last_name = lastName; + + this.submitUser(user, false); + } + submitEmail(e) { + e.preventDefault(); + + const user = Object.assign({}, this.props.user); + const email = this.state.email.trim().toLowerCase(); + const confirmEmail = this.state.confirmEmail.trim().toLowerCase(); + + const {formatMessage} = this.props.intl; + if (email === '' || !Utils.isEmail(email)) { + this.setState({emailError: formatMessage(holders.validEmail), clientError: '', serverError: ''}); + return; + } + + if (email !== confirmEmail) { + this.setState({emailError: formatMessage(holders.emailMatch), clientError: '', serverError: ''}); + return; + } + + if (user.email === email) { + this.updateSection(''); + return; + } + + user.email = email; + this.submitUser(user, true); + } + submitUser(user, emailUpdated) { + Client.updateUser(user, + () => { + this.updateSection(''); + AsyncClient.getMe(); + const verificationEnabled = global.window.mm_config.SendEmailNotifications === 'true' && global.window.mm_config.RequireEmailVerification === 'true' && emailUpdated; + + if (verificationEnabled) { + ErrorStore.storeLastError({message: this.props.intl.formatMessage(holders.checkEmail, {email: user.email})}); + ErrorStore.emitChange(); + this.setState({emailChangeInProgress: true}); + } + }, + (err) => { + let serverError; + if (err.message) { + serverError = err.message; + } else { + serverError = err; + } + this.setState({serverError, emailError: '', clientError: ''}); + } + ); + } + submitPicture(e) { + e.preventDefault(); + + if (!this.state.picture) { + return; + } + + if (!this.submitActive) { + return; + } + + const {formatMessage} = this.props.intl; + const picture = this.state.picture; + + if (picture.type !== 'image/jpeg' && picture.type !== 'image/png') { + this.setState({clientError: formatMessage(holders.validImage)}); + return; + } else if (picture.size > Constants.MAX_FILE_SIZE) { + this.setState({clientError: formatMessage(holders.imageTooLarge)}); + return; + } + + var formData = new FormData(); + formData.append('image', picture, picture.name); + this.setState({loadingPicture: true}); + + Client.uploadProfileImage(formData, + () => { + this.submitActive = false; + AsyncClient.getMe(); + window.location.reload(); + }, + (err) => { + var state = this.setupInitialState(this.props); + state.serverError = err.message; + this.setState(state); + } + ); + } + updateUsername(e) { + this.setState({username: e.target.value}); + } + updateFirstName(e) { + this.setState({firstName: e.target.value}); + } + updateLastName(e) { + this.setState({lastName: e.target.value}); + } + updateNickname(e) { + this.setState({nickname: e.target.value}); + } + updateEmail(e) { + this.setState({email: e.target.value}); + } + updateConfirmEmail(e) { + this.setState({confirmEmail: e.target.value}); + } + updatePicture(e) { + if (e.target.files && e.target.files[0]) { + this.setState({picture: e.target.files[0]}); + + this.submitActive = true; + this.setState({clientError: null}); + } else { + this.setState({picture: null}); + } + } + updateSection(section) { + const emailChangeInProgress = this.state.emailChangeInProgress; + this.setState(Object.assign({}, this.setupInitialState(this.props), {emailChangeInProgress, clientError: '', serverError: '', emailError: ''})); + this.submitActive = false; + this.props.updateSection(section); + } + setupInitialState(props) { + const user = props.user; + + return {username: user.username, firstName: user.first_name, lastName: user.last_name, nickname: user.nickname, + email: user.email, confirmEmail: '', picture: null, loadingPicture: false, emailChangeInProgress: false}; + } + render() { + const user = this.props.user; + const {formatMessage, formatHTMLMessage} = this.props.intl; + + let clientError = null; + if (this.state.clientError) { + clientError = this.state.clientError; + } + let serverError = null; + if (this.state.serverError) { + serverError = this.state.serverError; + } + let emailError = null; + if (this.state.emailError) { + emailError = this.state.emailError; + } + + let nameSection; + const inputs = []; + + if (this.props.activeSection === 'name') { + inputs.push( + <div + key='firstNameSetting' + className='form-group' + > + <label className='col-sm-5 control-label'> + <FormattedMessage + id='user.settings.general.firstName' + defaultMessage='First Name' + /> + </label> + <div className='col-sm-7'> + <input + className='form-control' + type='text' + onChange={this.updateFirstName} + value={this.state.firstName} + /> + </div> + </div> + ); + + inputs.push( + <div + key='lastNameSetting' + className='form-group' + > + <label className='col-sm-5 control-label'> + <FormattedMessage + id='user.settings.general.lastName' + defaultMessage='Last Name' + /> + </label> + <div className='col-sm-7'> + <input + className='form-control' + type='text' + onChange={this.updateLastName} + value={this.state.lastName} + /> + </div> + </div> + ); + + function notifClick(e) { + e.preventDefault(); + this.updateSection(''); + this.props.updateTab('notifications'); + } + + const notifLink = ( + <a + href='#' + onClick={notifClick.bind(this)} + > + <FormattedMessage + id='user.settings.general.notificationsLink' + defaultMessage='Notifications' + /> + </a> + ); + + const extraInfo = ( + <span> + <FormattedMessage + id='user.settings.general.notificationsExtra' + defaultMessage='By default, you will receive mention notifications when someone types your first name. Go to {notify} settings to change this default.' + values={{ + notify: (notifLink) + }} + /> + </span> + ); + + nameSection = ( + <SettingItemMax + title={formatMessage(holders.fullName)} + inputs={inputs} + submit={this.submitName} + server_error={serverError} + client_error={clientError} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + extraInfo={extraInfo} + /> + ); + } else { + let fullName = ''; + + if (user.first_name && user.last_name) { + fullName = user.first_name + ' ' + user.last_name; + } else if (user.first_name) { + fullName = user.first_name; + } else if (user.last_name) { + fullName = user.last_name; + } + + nameSection = ( + <SettingItemMin + title={formatMessage(holders.fullName)} + describe={fullName} + updateSection={() => { + this.updateSection('name'); + }} + /> + ); + } + + let nicknameSection; + if (this.props.activeSection === 'nickname') { + let nicknameLabel = ( + <FormattedMessage + id='user.settings.general.nickname' + defaultMessage='Nickname' + /> + ); + if (Utils.isMobile()) { + nicknameLabel = ''; + } + + inputs.push( + <div + key='nicknameSetting' + className='form-group' + > + <label className='col-sm-5 control-label'>{nicknameLabel}</label> + <div className='col-sm-7'> + <input + className='form-control' + type='text' + onChange={this.updateNickname} + value={this.state.nickname} + /> + </div> + </div> + ); + + const extraInfo = ( + <span> + <FormattedMessage + id='user.settings.general.nicknameExtra' + defaultMessage='Use Nickname for a name you might be called that is different from your first name and username. This is most often used when two or more people have similar sounding names and usernames.' + /> + </span> + ); + + nicknameSection = ( + <SettingItemMax + title={formatMessage(holders.nickname)} + inputs={inputs} + submit={this.submitNickname} + server_error={serverError} + client_error={clientError} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + extraInfo={extraInfo} + /> + ); + } else { + nicknameSection = ( + <SettingItemMin + title={formatMessage(holders.nickname)} + describe={UserStore.getCurrentUser().nickname} + updateSection={() => { + this.updateSection('nickname'); + }} + /> + ); + } + + let usernameSection; + if (this.props.activeSection === 'username') { + let usernameLabel = ( + <FormattedMessage + id='user.settings.general.username' + defaultMessage='Username' + /> + ); + if (Utils.isMobile()) { + usernameLabel = ''; + } + + inputs.push( + <div + key='usernameSetting' + className='form-group' + > + <label className='col-sm-5 control-label'>{usernameLabel}</label> + <div className='col-sm-7'> + <input + maxLength={Constants.MAX_USERNAME_LENGTH} + className='form-control' + type='text' + onChange={this.updateUsername} + value={this.state.username} + /> + </div> + </div> + ); + + const extraInfo = ( + <span> + <FormattedMessage + id='user.settings.general.usernameInfo' + defaultMessage='Pick something easy for teammates to recognize and recall.' + /> + </span> + ); + + usernameSection = ( + <SettingItemMax + title={formatMessage(holders.username)} + inputs={inputs} + submit={this.submitUsername} + server_error={serverError} + client_error={clientError} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + extraInfo={extraInfo} + /> + ); + } else { + usernameSection = ( + <SettingItemMin + title={formatMessage(holders.username)} + describe={UserStore.getCurrentUser().username} + updateSection={() => { + this.updateSection('username'); + }} + /> + ); + } + + let emailSection; + if (this.props.activeSection === 'email') { + const emailEnabled = global.window.mm_config.SendEmailNotifications === 'true'; + const emailVerificationEnabled = global.window.mm_config.RequireEmailVerification === 'true'; + let helpText = ( + <FormattedMessage + id='user.settings.general.emailHelp1' + defaultMessage='Email is used for sign-in, notifications, and password reset. Email requires verification if changed.' + /> + ); + + if (!emailEnabled) { + helpText = ( + <div className='setting-list__hint text-danger'> + <FormattedMessage + id='user.settings.general.emailHelp2' + defaultMessage='Email has been disabled by your system administrator. No notification emails will be sent until it is enabled.' + /> + </div> + ); + } else if (!emailVerificationEnabled) { + helpText = ( + <FormattedMessage + id='user.settings.general.emailHelp3' + defaultMessage='Email is used for sign-in, notifications, and password reset.' + /> + ); + } else if (this.state.emailChangeInProgress) { + const newEmail = UserStore.getCurrentUser().email; + if (newEmail) { + helpText = ( + <FormattedMessage + id='user.settings.general.emailHelp4' + defaultMessage='A verification email was sent to {email}.' + values={{ + email: newEmail + }} + /> + ); + } + } + + let submit = null; + + if (this.props.user.auth_service === '') { + inputs.push( + <div key='emailSetting'> + <div className='form-group'> + <label className='col-sm-5 control-label'> + <FormattedMessage + id='user.settings.general.primaryEmail' + defaultMessage='Primary Email' + /> + </label> + <div className='col-sm-7'> + <input + className='form-control' + type='text' + onChange={this.updateEmail} + value={this.state.email} + /> + </div> + </div> + </div> + ); + + inputs.push( + <div key='confirmEmailSetting'> + <div className='form-group'> + <label className='col-sm-5 control-label'> + <FormattedMessage + id='user.settings.general.confirmEmail' + defaultMessage='Confirm Email' + /> + </label> + <div className='col-sm-7'> + <input + className='form-control' + type='text' + onChange={this.updateConfirmEmail} + value={this.state.confirmEmail} + /> + </div> + </div> + {helpText} + </div> + ); + + submit = this.submitEmail; + } else if (this.props.user.auth_service === Constants.GITLAB_SERVICE) { + inputs.push( + <div + key='oauthEmailInfo' + className='form-group' + > + <div className='setting-list__hint'> + <FormattedMessage + id='user.settings.general.emailCantUpdate' + defaultMessage='Log in occurs through GitLab. Email cannot be updated.' + /> + </div> + {helpText} + </div> + ); + } + + emailSection = ( + <SettingItemMax + title='Email' + inputs={inputs} + submit={submit} + server_error={serverError} + client_error={emailError} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + let describe = ''; + if (this.props.user.auth_service === '') { + if (this.state.emailChangeInProgress) { + const newEmail = UserStore.getCurrentUser().email; + if (newEmail) { + describe = formatHTMLMessage(holders.newAddress, {email: newEmail}); + } else { + describe = formatMessage(holders.checkEmailNoAddress); + } + } else { + describe = UserStore.getCurrentUser().email; + } + } else if (this.props.user.auth_service === Constants.GITLAB_SERVICE) { + describe = formatMessage(holders.loginGitlab); + } + + emailSection = ( + <SettingItemMin + title={formatMessage(holders.email)} + describe={describe} + updateSection={() => { + this.updateSection('email'); + }} + /> + ); + } + + let pictureSection; + if (this.props.activeSection === 'picture') { + pictureSection = ( + <SettingPicture + title={formatMessage(holders.profilePicture)} + submit={this.submitPicture} + src={'/api/v1/users/' + user.id + '/image?time=' + user.last_picture_update} + server_error={serverError} + client_error={clientError} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + picture={this.state.picture} + pictureChange={this.updatePicture} + submitActive={this.submitActive} + loadingPicture={this.state.loadingPicture} + /> + ); + } else { + let minMessage = formatMessage(holders.uploadImage); + if (user.last_picture_update) { + minMessage = formatMessage(holders.imageUpdated, { + date: ( + <FormattedDate + value={new Date(user.last_picture_update)} + day='2-digit' + month='short' + year='numeric' + /> + ) + }); + } + pictureSection = ( + <SettingItemMin + title={formatMessage(holders.profilePicture)} + describe={minMessage} + updateSection={() => { + this.updateSection('picture'); + }} + /> + ); + } + + return ( + <div> + <div className='modal-header'> + <button + type='button' + className='close' + data-dismiss='modal' + aria-label={formatMessage(holders.close)} + onClick={this.props.closeModal} + > + <span aria-hidden='true'>{'×'}</span> + </button> + <h4 + className='modal-title' + ref='title' + > + <div className='modal-back'> + <i + className='fa fa-angle-left' + onClick={this.props.collapseModal} + /> + </div> + <FormattedMessage + id='user.settings.general.title' + defaultMessage='General Settings' + /> + </h4> + </div> + <div className='user-settings'> + <h3 className='tab-header'> + <FormattedMessage + id='user.settings.general.title' + defaultMessage='General Settings' + /> + </h3> + <div className='divider-dark first'/> + {nameSection} + <div className='divider-light'/> + {usernameSection} + <div className='divider-light'/> + {nicknameSection} + <div className='divider-light'/> + {emailSection} + <div className='divider-light'/> + {pictureSection} + <div className='divider-dark'/> + </div> + </div> + ); + } +} + +UserSettingsGeneralTab.propTypes = { + intl: intlShape.isRequired, + user: React.PropTypes.object.isRequired, + updateSection: React.PropTypes.func.isRequired, + updateTab: React.PropTypes.func.isRequired, + activeSection: React.PropTypes.string.isRequired, + closeModal: React.PropTypes.func.isRequired, + collapseModal: React.PropTypes.func.isRequired +}; + +export default injectIntl(UserSettingsGeneralTab); diff --git a/webapp/components/user_settings/user_settings_integrations.jsx b/webapp/components/user_settings/user_settings_integrations.jsx new file mode 100644 index 000000000..94fc184bd --- /dev/null +++ b/webapp/components/user_settings/user_settings_integrations.jsx @@ -0,0 +1,210 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; +import ManageIncomingHooks from './manage_incoming_hooks.jsx'; +import ManageOutgoingHooks from './manage_outgoing_hooks.jsx'; +import ManageCommandHooks from './manage_command_hooks.jsx'; + +import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl'; + +const holders = defineMessages({ + inName: { + id: 'user.settings.integrations.incomingWebhooks', + defaultMessage: 'Incoming Webhooks' + }, + inDesc: { + id: 'user.settings.integrations.incomingWebhooksDescription', + defaultMessage: 'Manage your incoming webhooks' + }, + outName: { + id: 'user.settings.integrations.outWebhooks', + defaultMessage: 'Outgoing Webhooks' + }, + outDesc: { + id: 'user.settings.integrations.outWebhooksDescription', + defaultMessage: 'Manage your outgoing webhooks' + }, + cmdName: { + id: 'user.settings.integrations.commands', + defaultMessage: 'Slash Commands' + }, + cmdDesc: { + id: 'user.settings.integrations.commandsDescription', + defaultMessage: 'Manage your slash commands' + } +}); + +import React from 'react'; + +class UserSettingsIntegrationsTab extends React.Component { + constructor(props) { + super(props); + + this.updateSection = this.updateSection.bind(this); + + this.state = {}; + } + updateSection(section) { + this.props.updateSection(section); + } + render() { + let incomingHooksSection; + let outgoingHooksSection; + let commandHooksSection; + var inputs = []; + const {formatMessage} = this.props.intl; + + if (global.window.mm_config.EnableIncomingWebhooks === 'true') { + if (this.props.activeSection === 'incoming-hooks') { + inputs.push( + <ManageIncomingHooks key='incoming-hook-ui'/> + ); + + incomingHooksSection = ( + <SettingItemMax + title={formatMessage(holders.inName)} + width='medium' + inputs={inputs} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + incomingHooksSection = ( + <SettingItemMin + title={formatMessage(holders.inName)} + width='medium' + describe={formatMessage(holders.inDesc)} + updateSection={() => { + this.updateSection('incoming-hooks'); + }} + /> + ); + } + } + + if (global.window.mm_config.EnableOutgoingWebhooks === 'true') { + if (this.props.activeSection === 'outgoing-hooks') { + inputs.push( + <ManageOutgoingHooks key='outgoing-hook-ui'/> + ); + + outgoingHooksSection = ( + <SettingItemMax + title={formatMessage(holders.outName)} + width='medium' + inputs={inputs} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + outgoingHooksSection = ( + <SettingItemMin + title={formatMessage(holders.outName)} + width='medium' + describe={formatMessage(holders.outDesc)} + updateSection={() => { + this.updateSection('outgoing-hooks'); + }} + /> + ); + } + } + + if (global.window.mm_config.EnableCommands === 'true') { + if (this.props.activeSection === 'command-hooks') { + inputs.push( + <ManageCommandHooks key='command-hook-ui'/> + ); + + commandHooksSection = ( + <SettingItemMax + title={formatMessage(holders.cmdName)} + width='medium' + inputs={inputs} + updateSection={(e) => { + this.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + commandHooksSection = ( + <SettingItemMin + title={formatMessage(holders.cmdName)} + width='medium' + describe={formatMessage(holders.cmdDesc)} + updateSection={() => { + this.updateSection('command-hooks'); + }} + /> + ); + } + } + + return ( + <div> + <div className='modal-header'> + <button + type='button' + className='close' + data-dismiss='modal' + aria-label='Close' + onClick={this.props.closeModal} + > + <span aria-hidden='true'>{'×'}</span> + </button> + <h4 + className='modal-title' + ref='title' + > + <div className='modal-back'> + <i + className='fa fa-angle-left' + onClick={this.props.collapseModal} + /> + </div> + <FormattedMessage + id='user.settings.integrations.title' + defaultMessage='Integration Settings' + /> + </h4> + </div> + <div className='user-settings'> + <h3 className='tab-header'> + <FormattedMessage + id='user.settings.integrations.title' + defaultMessage='Integration Settings' + /> + </h3> + <div className='divider-dark first'/> + {incomingHooksSection} + <div className='divider-light'/> + {outgoingHooksSection} + <div className='divider-dark'/> + {commandHooksSection} + <div className='divider-dark'/> + </div> + </div> + ); + } +} + +UserSettingsIntegrationsTab.propTypes = { + intl: intlShape.isRequired, + user: React.PropTypes.object, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func, + activeSection: React.PropTypes.string, + closeModal: React.PropTypes.func.isRequired, + collapseModal: React.PropTypes.func.isRequired +}; + +export default injectIntl(UserSettingsIntegrationsTab);
\ No newline at end of file diff --git a/webapp/components/user_settings/user_settings_modal.jsx b/webapp/components/user_settings/user_settings_modal.jsx new file mode 100644 index 000000000..bd1df6ea5 --- /dev/null +++ b/webapp/components/user_settings/user_settings_modal.jsx @@ -0,0 +1,341 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import ReactDOM from 'react-dom'; +import ConfirmModal from '../confirm_modal.jsx'; +import UserSettings from './user_settings.jsx'; +import SettingsSidebar from '../settings_sidebar.jsx'; + +import UserStore from 'stores/user_store.jsx'; +import * as Utils from 'utils/utils.jsx'; +import Constants from 'utils/constants.jsx'; + +import {Modal} from 'react-bootstrap'; + +import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl'; + +const holders = defineMessages({ + general: { + id: 'user.settings.modal.general', + defaultMessage: 'General' + }, + security: { + id: 'user.settings.modal.security', + defaultMessage: 'Security' + }, + notifications: { + id: 'user.settings.modal.notifications', + defaultMessage: 'Notifications' + }, + developer: { + id: 'user.settings.modal.developer', + defaultMessage: 'Developer' + }, + integrations: { + id: 'user.settings.modal.integrations', + defaultMessage: 'Integrations' + }, + display: { + id: 'user.settings.modal.display', + defaultMessage: 'Display' + }, + advanced: { + id: 'user.settings.modal.advanced', + defaultMessage: 'Advanced' + }, + confirmTitle: { + id: 'user.settings.modal.confirmTitle', + defaultMessage: 'Discard Changes?' + }, + confirmMsg: { + id: 'user.settings.modal.confirmMsg', + defaultMessage: 'You have unsaved changes, are you sure you want to discard them?' + }, + confirmBtns: { + id: 'user.settings.modal.confirmBtns', + defaultMessage: 'Yes, Discard' + } +}); + +import React from 'react'; + +class UserSettingsModal extends React.Component { + constructor(props) { + super(props); + + this.handleShow = this.handleShow.bind(this); + this.handleHide = this.handleHide.bind(this); + this.handleHidden = this.handleHidden.bind(this); + this.handleCollapse = this.handleCollapse.bind(this); + this.handleConfirm = this.handleConfirm.bind(this); + this.handleCancelConfirmation = this.handleCancelConfirmation.bind(this); + + this.deactivateTab = this.deactivateTab.bind(this); + this.closeModal = this.closeModal.bind(this); + this.collapseModal = this.collapseModal.bind(this); + + this.updateTab = this.updateTab.bind(this); + this.updateSection = this.updateSection.bind(this); + this.onUserChanged = this.onUserChanged.bind(this); + + this.state = { + active_tab: 'general', + active_section: '', + showConfirmModal: false, + enforceFocus: true, + currentUser: UserStore.getCurrentUser() + }; + + this.requireConfirm = false; + } + + onUserChanged() { + this.setState({currentUser: UserStore.getCurrentUser()}); + } + + componentDidMount() { + if (this.props.show) { + this.handleShow(); + } + UserStore.addChangeListener(this.onUserChanged); + } + + componentDidUpdate(prevProps) { + if (this.props.show && !prevProps.show) { + this.handleShow(); + } + UserStore.removeChangeListener(this.onUserChanged); + } + + handleShow() { + if ($(window).width() > 768) { + $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 200); + } else { + $(ReactDOM.findDOMNode(this.refs.modalBody)).css('max-height', $(window).height() - 50); + } + } + + // Called when the close button is pressed on the main modal + handleHide() { + if (this.requireConfirm) { + this.afterConfirm = () => this.handleHide(); + this.showConfirmModal(); + + return; + } + + this.resetTheme(); + this.deactivateTab(); + this.props.onModalDismissed(); + return; + } + + // called after the dialog is fully hidden and faded out + handleHidden() { + this.setState({ + active_tab: 'general', + active_section: '' + }); + } + + // Called to hide the settings pane when on mobile + handleCollapse() { + $(ReactDOM.findDOMNode(this.refs.modalBody)).closest('.modal-dialog').removeClass('display--content'); + + this.deactivateTab(); + + this.setState({ + active_tab: '', + active_section: '' + }); + } + + handleConfirm() { + this.setState({ + showConfirmModal: false, + enforceFocus: true + }); + + this.requireConfirm = false; + + if (this.afterConfirm) { + this.afterConfirm(); + this.afterConfirm = null; + } + } + + handleCancelConfirmation() { + this.setState({ + showConfirmModal: false, + enforceFocus: true + }); + + this.afterConfirm = null; + } + + showConfirmModal(afterConfirm) { + this.setState({ + showConfirmModal: true, + enforceFocus: false + }); + + if (afterConfirm) { + this.afterConfirm = afterConfirm; + } + } + + // Called to let settings tab perform cleanup before being closed + deactivateTab() { + const activeTab = this.refs.userSettings.getActiveTab(); + if (activeTab && activeTab.deactivate) { + activeTab.deactivate(); + } + } + + // Called by settings tabs when their close button is pressed + closeModal() { + if (this.requireConfirm) { + this.showConfirmModal(this.closeModal); + } else { + this.handleHide(); + } + } + + // Called by settings tabs when their back button is pressed + collapseModal() { + if (this.requireConfirm) { + this.showConfirmModal(this.collapseModal); + } else { + this.handleCollapse(); + } + } + + updateTab(tab, skipConfirm) { + if (!skipConfirm && this.requireConfirm) { + this.showConfirmModal(() => this.updateTab(tab, true)); + } else { + this.deactivateTab(); + + this.setState({ + active_tab: tab, + active_section: '' + }); + } + } + + updateSection(section, skipConfirm) { + if (!skipConfirm && this.requireConfirm) { + this.showConfirmModal(() => this.updateSection(section, true)); + } else { + if (this.state.active_section === 'theme' && section !== 'theme') { + this.resetTheme(); + } + this.setState({active_section: section}); + } + } + + resetTheme() { + const user = UserStore.getCurrentUser(); + if (user.theme_props == null) { + Utils.applyTheme(Constants.THEMES.default); + } else { + Utils.applyTheme(user.theme_props); + } + } + + render() { + const {formatMessage} = this.props.intl; + if (this.state.currentUser == null) { + return (<div/>); + } + var isAdmin = Utils.isAdmin(this.state.currentUser.roles); + var tabs = []; + + tabs.push({name: 'general', uiName: formatMessage(holders.general), icon: 'glyphicon glyphicon-cog'}); + tabs.push({name: 'security', uiName: formatMessage(holders.security), icon: 'glyphicon glyphicon-lock'}); + tabs.push({name: 'notifications', uiName: formatMessage(holders.notifications), icon: 'glyphicon glyphicon-exclamation-sign'}); + if (global.window.mm_config.EnableOAuthServiceProvider === 'true') { + tabs.push({name: 'developer', uiName: formatMessage(holders.developer), icon: 'glyphicon glyphicon-th'}); + } + + if (global.window.mm_config.EnableIncomingWebhooks === 'true' || global.window.mm_config.EnableOutgoingWebhooks === 'true' || global.window.mm_config.EnableCommands === 'true') { + var show = global.window.mm_config.EnableOnlyAdminIntegrations !== 'true'; + + if (global.window.mm_config.EnableOnlyAdminIntegrations === 'true' && isAdmin) { + show = true; + } + + if (show) { + tabs.push({name: 'integrations', uiName: formatMessage(holders.integrations), icon: 'glyphicon glyphicon-transfer'}); + } + } + + tabs.push({name: 'display', uiName: formatMessage(holders.display), icon: 'glyphicon glyphicon-eye-open'}); + tabs.push({name: 'advanced', uiName: formatMessage(holders.advanced), icon: 'glyphicon glyphicon-list-alt'}); + + return ( + <Modal + dialogClassName='settings-modal' + show={this.props.show} + onHide={this.handleHide} + onExited={this.handleHidden} + enforceFocus={this.state.enforceFocus} + > + <Modal.Header closeButton={true}> + <Modal.Title> + <FormattedMessage + id='user.settings.modal.title' + defaultMessage='Account Settings' + /> + </Modal.Title> + </Modal.Header> + <Modal.Body ref='modalBody'> + <div className='settings-table'> + <div className='settings-links'> + <SettingsSidebar + tabs={tabs} + activeTab={this.state.active_tab} + updateTab={this.updateTab} + /> + </div> + <div className='settings-content minimize-settings'> + <UserSettings + ref='userSettings' + activeTab={this.state.active_tab} + activeSection={this.state.active_section} + updateSection={this.updateSection} + updateTab={this.updateTab} + closeModal={this.closeModal} + collapseModal={this.collapseModal} + setEnforceFocus={(enforceFocus) => this.setState({enforceFocus})} + setRequireConfirm={ + (requireConfirm) => { + this.requireConfirm = requireConfirm; + return; + } + } + /> + </div> + </div> + </Modal.Body> + <ConfirmModal + title={formatMessage(holders.confirmTitle)} + message={formatMessage(holders.confirmMsg)} + confirmButton={formatMessage(holders.confirmBtns)} + show={this.state.showConfirmModal} + onConfirm={this.handleConfirm} + onCancel={this.handleCancelConfirmation} + /> + </Modal> + ); + } +} + +UserSettingsModal.propTypes = { + intl: intlShape.isRequired, + show: React.PropTypes.bool.isRequired, + onModalDismissed: React.PropTypes.func.isRequired +}; + +export default injectIntl(UserSettingsModal); diff --git a/webapp/components/user_settings/user_settings_notifications.jsx b/webapp/components/user_settings/user_settings_notifications.jsx new file mode 100644 index 000000000..fe2db6727 --- /dev/null +++ b/webapp/components/user_settings/user_settings_notifications.jsx @@ -0,0 +1,834 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import ReactDOM from 'react-dom'; +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; + +import UserStore from 'stores/user_store.jsx'; + +import * as Client from 'utils/client.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; +import * as Utils from 'utils/utils.jsx'; + +import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl'; + +function getNotificationsStateFromStores() { + var user = UserStore.getCurrentUser(); + var soundNeeded = !Utils.isBrowserFirefox(); + + var sound = 'true'; + if (user.notify_props && user.notify_props.desktop_sound) { + sound = user.notify_props.desktop_sound; + } + var desktop = 'default'; + if (user.notify_props && user.notify_props.desktop) { + desktop = user.notify_props.desktop; + } + var email = 'true'; + if (user.notify_props && user.notify_props.email) { + email = user.notify_props.email; + } + + var usernameKey = false; + var mentionKey = false; + var customKeys = ''; + var firstNameKey = false; + var allKey = false; + var channelKey = false; + + if (user.notify_props) { + if (user.notify_props.mention_keys) { + var keys = user.notify_props.mention_keys.split(','); + + if (keys.indexOf(user.username) === -1) { + usernameKey = false; + } else { + usernameKey = true; + keys.splice(keys.indexOf(user.username), 1); + } + + if (keys.indexOf('@' + user.username) === -1) { + mentionKey = false; + } else { + mentionKey = true; + keys.splice(keys.indexOf('@' + user.username), 1); + } + + customKeys = keys.join(','); + } + + if (user.notify_props.first_name) { + firstNameKey = user.notify_props.first_name === 'true'; + } + + if (user.notify_props.all) { + allKey = user.notify_props.all === 'true'; + } + + if (user.notify_props.channel) { + channelKey = user.notify_props.channel === 'true'; + } + } + + return {notifyLevel: desktop, enableEmail: email, soundNeeded: soundNeeded, enableSound: sound, + usernameKey: usernameKey, mentionKey: mentionKey, customKeys: customKeys, customKeysChecked: customKeys.length > 0, + firstNameKey: firstNameKey, allKey: allKey, channelKey: channelKey}; +} + +const holders = defineMessages({ + desktop: { + id: 'user.settings.notifications.desktop', + defaultMessage: 'Send desktop notifications' + }, + desktopSounds: { + id: 'user.settings.notifications.desktopSounds', + defaultMessage: 'Desktop notification sounds' + }, + emailNotifications: { + id: 'user.settings.notifications.emailNotifications', + defaultMessage: 'Email notifications' + }, + wordsTrigger: { + id: 'user.settings.notifications.wordsTrigger', + defaultMessage: 'Words that trigger mentions' + }, + close: { + id: 'user.settings.notifications.close', + defaultMessage: 'Close' + } +}); + +import React from 'react'; + +class NotificationsTab extends React.Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + this.handleCancel = this.handleCancel.bind(this); + this.updateSection = this.updateSection.bind(this); + this.updateState = this.updateState.bind(this); + this.onListenerChange = this.onListenerChange.bind(this); + this.handleNotifyRadio = this.handleNotifyRadio.bind(this); + this.handleEmailRadio = this.handleEmailRadio.bind(this); + this.handleSoundRadio = this.handleSoundRadio.bind(this); + this.updateUsernameKey = this.updateUsernameKey.bind(this); + this.updateMentionKey = this.updateMentionKey.bind(this); + this.updateFirstNameKey = this.updateFirstNameKey.bind(this); + this.updateAllKey = this.updateAllKey.bind(this); + this.updateChannelKey = this.updateChannelKey.bind(this); + this.updateCustomMentionKeys = this.updateCustomMentionKeys.bind(this); + this.onCustomChange = this.onCustomChange.bind(this); + + this.state = getNotificationsStateFromStores(); + } + handleSubmit() { + var data = {}; + data.user_id = this.props.user.id; + data.email = this.state.enableEmail; + data.desktop_sound = this.state.enableSound; + data.desktop = this.state.notifyLevel; + + var mentionKeys = []; + if (this.state.usernameKey) { + mentionKeys.push(this.props.user.username); + } + if (this.state.mentionKey) { + mentionKeys.push('@' + this.props.user.username); + } + + var stringKeys = mentionKeys.join(','); + if (this.state.customKeys.length > 0 && this.state.customKeysChecked) { + stringKeys += ',' + this.state.customKeys; + } + + data.mention_keys = stringKeys; + data.first_name = this.state.firstNameKey.toString(); + data.all = this.state.allKey.toString(); + data.channel = this.state.channelKey.toString(); + + Client.updateUserNotifyProps(data, + function success() { + this.props.updateSection(''); + AsyncClient.getMe(); + }.bind(this), + function failure(err) { + this.setState({serverError: err.message}); + }.bind(this) + ); + } + handleCancel(e) { + this.updateState(); + this.props.updateSection(''); + e.preventDefault(); + } + updateSection(section) { + this.updateState(); + this.props.updateSection(section); + } + updateState() { + const newState = getNotificationsStateFromStores(); + if (!Utils.areObjectsEqual(newState, this.state)) { + this.setState(newState); + } + } + componentDidMount() { + UserStore.addChangeListener(this.onListenerChange); + } + componentWillUnmount() { + UserStore.removeChangeListener(this.onListenerChange); + } + onListenerChange() { + this.updateState(); + } + handleNotifyRadio(notifyLevel) { + this.setState({notifyLevel: notifyLevel}); + ReactDOM.findDOMNode(this.refs.wrapper).focus(); + } + handleEmailRadio(enableEmail) { + this.setState({enableEmail: enableEmail}); + ReactDOM.findDOMNode(this.refs.wrapper).focus(); + } + handleSoundRadio(enableSound) { + this.setState({enableSound: enableSound}); + ReactDOM.findDOMNode(this.refs.wrapper).focus(); + } + updateUsernameKey(val) { + this.setState({usernameKey: val}); + } + updateMentionKey(val) { + this.setState({mentionKey: val}); + } + updateFirstNameKey(val) { + this.setState({firstNameKey: val}); + } + updateAllKey(val) { + this.setState({allKey: val}); + } + updateChannelKey(val) { + this.setState({channelKey: val}); + } + updateCustomMentionKeys() { + var checked = ReactDOM.findDOMNode(this.refs.customcheck).checked; + + if (checked) { + var text = ReactDOM.findDOMNode(this.refs.custommentions).value; + + // remove all spaces and split string into individual keys + this.setState({customKeys: text.replace(/ /g, ''), customKeysChecked: true}); + } else { + this.setState({customKeys: '', customKeysChecked: false}); + } + } + onCustomChange() { + ReactDOM.findDOMNode(this.refs.customcheck).checked = true; + this.updateCustomMentionKeys(); + } + render() { + const {formatMessage} = this.props.intl; + var serverError = null; + if (this.state.serverError) { + serverError = this.state.serverError; + } + + var user = this.props.user; + + var desktopSection; + var handleUpdateDesktopSection; + if (this.props.activeSection === 'desktop') { + var notifyActive = [false, false, false]; + if (this.state.notifyLevel === 'mention') { + notifyActive[1] = true; + } else if (this.state.notifyLevel === 'none') { + notifyActive[2] = true; + } else { + notifyActive[0] = true; + } + + let inputs = []; + + inputs.push( + <div key='userNotificationLevelOption'> + <div className='radio'> + <label> + <input type='radio' + checked={notifyActive[0]} + onChange={this.handleNotifyRadio.bind(this, 'all')} + /> + <FormattedMessage + id='user.settings.notification.allActivity' + defaultMessage='For all activity' + /> + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + type='radio' + checked={notifyActive[1]} + onChange={this.handleNotifyRadio.bind(this, 'mention')} + /> + <FormattedMessage + id='user.settings.notifications.onlyMentions' + defaultMessage='Only for mentions and direct messages' + /> + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + type='radio' + checked={notifyActive[2]} + onChange={this.handleNotifyRadio.bind(this, 'none')} + /> + <FormattedMessage + id='user.settings.notifications.never' + defaultMessage='Never' + /> + </label> + </div> + </div> + ); + + const extraInfo = ( + <span> + <FormattedMessage + id='user.settings.notifications.info' + defaultMessage='Desktop notifications are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.' + /> + </span> + ); + + desktopSection = ( + <SettingItemMax + title={formatMessage(holders.desktop)} + extraInfo={extraInfo} + inputs={inputs} + submit={this.handleSubmit} + server_error={serverError} + updateSection={this.handleCancel} + /> + ); + } else { + let describe = ''; + if (this.state.notifyLevel === 'mention') { + describe = ( + <FormattedMessage + id='user.settings.notifications.onlyMentions' + defaultMessage='Only for mentions and direct messages' + /> + ); + } else if (this.state.notifyLevel === 'none') { + describe = ( + <FormattedMessage + id='user.settings.notifications.never' + defaultMessage='Never' + /> + ); + } else { + describe = ( + <FormattedMessage + id='user.settings.notification.allActivity' + defaultMessage='For all activity' + /> + ); + } + + handleUpdateDesktopSection = function updateDesktopSection() { + this.props.updateSection('desktop'); + }.bind(this); + + desktopSection = ( + <SettingItemMin + title={formatMessage(holders.desktop)} + describe={describe} + updateSection={handleUpdateDesktopSection} + /> + ); + } + + var soundSection; + var handleUpdateSoundSection; + if (this.props.activeSection === 'sound' && this.state.soundNeeded) { + var soundActive = [false, false]; + if (this.state.enableSound === 'false') { + soundActive[1] = true; + } else { + soundActive[0] = true; + } + + let inputs = []; + + inputs.push( + <div key='userNotificationSoundOptions'> + <div className='radio'> + <label> + <input + type='radio' + checked={soundActive[0]} + onChange={this.handleSoundRadio.bind(this, 'true')} + /> + <FormattedMessage + id='user.settings.notifications.on' + defaultMessage='On' + /> + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + type='radio' + checked={soundActive[1]} + onChange={this.handleSoundRadio.bind(this, 'false')} + /> + <FormattedMessage + id='user.settings.notifications.off' + defaultMessage='Off' + /> + </label> + <br/> + </div> + </div> + ); + + const extraInfo = ( + <span> + <FormattedMessage + id='user.settings.notifications.sounds_info' + defaultMessage='Desktop notifications sounds are available on Firefox, Safari, Chrome, Internet Explorer, and Edge.' + /> + </span> + ); + + soundSection = ( + <SettingItemMax + title={formatMessage(holders.desktopSounds)} + extraInfo={extraInfo} + inputs={inputs} + submit={this.handleSubmit} + server_error={serverError} + updateSection={this.handleCancel} + /> + ); + } else { + let describe = ''; + if (!this.state.soundNeeded) { + describe = ( + <FormattedMessage + id='user.settings.notification.soundConfig' + defaultMessage='Please configure notification sounds in your browser settings' + /> + ); + } else if (this.state.enableSound === 'false') { + describe = ( + <FormattedMessage + id='user.settings.notifications.off' + defaultMessage='Off' + /> + ); + } else { + describe = ( + <FormattedMessage + id='user.settings.notifications.on' + defaultMessage='On' + /> + ); + } + + handleUpdateSoundSection = function updateSoundSection() { + this.props.updateSection('sound'); + }.bind(this); + + soundSection = ( + <SettingItemMin + title={formatMessage(holders.desktopSounds)} + describe={describe} + updateSection={handleUpdateSoundSection} + disableOpen={!this.state.soundNeeded} + /> + ); + } + + var emailSection; + var handleUpdateEmailSection; + if (this.props.activeSection === 'email') { + var emailActive = [false, false]; + if (this.state.enableEmail === 'false') { + emailActive[1] = true; + } else { + emailActive[0] = true; + } + + let inputs = []; + + inputs.push( + <div key='userNotificationEmailOptions'> + <div className='radio'> + <label> + <input + type='radio' + checked={emailActive[0]} + onChange={this.handleEmailRadio.bind(this, 'true')} + /> + <FormattedMessage + id='user.settings.notifications.on' + defaultMessage='On' + /> + </label> + <br/> + </div> + <div className='radio'> + <label> + <input + type='radio' + checked={emailActive[1]} + onChange={this.handleEmailRadio.bind(this, 'false')} + /> + <FormattedMessage + id='user.settings.notifications.off' + defaultMessage='Off' + /> + </label> + <br/> + </div> + <div><br/> + <FormattedMessage + id='user.settings.notifications.emailInfo' + defaultMessage='Email notifications are sent for mentions and direct messages after you’ve been offline for more than 60 seconds or away from {siteName} for more than 5 minutes.' + values={{ + siteName: global.window.mm_config.SiteName + }} + /> + </div> + </div> + ); + + emailSection = ( + <SettingItemMax + title={formatMessage(holders.emailNotifications)} + inputs={inputs} + submit={this.handleSubmit} + server_error={serverError} + updateSection={this.handleCancel} + /> + ); + } else { + let describe = ''; + if (this.state.enableEmail === 'false') { + describe = ( + <FormattedMessage + id='user.settings.notifications.off' + defaultMessage='Off' + /> + ); + } else { + describe = ( + <FormattedMessage + id='user.settings.notifications.on' + defaultMessage='On' + /> + ); + } + + handleUpdateEmailSection = function updateEmailSection() { + this.props.updateSection('email'); + }.bind(this); + + emailSection = ( + <SettingItemMin + title={formatMessage(holders.emailNotifications)} + describe={describe} + updateSection={handleUpdateEmailSection} + /> + ); + } + + var keysSection; + var handleUpdateKeysSection; + if (this.props.activeSection === 'keys') { + let inputs = []; + + let handleUpdateFirstNameKey; + let handleUpdateUsernameKey; + let handleUpdateMentionKey; + let handleUpdateAllKey; + let handleUpdateChannelKey; + + if (user.first_name) { + handleUpdateFirstNameKey = function handleFirstNameKeyChange(e) { + this.updateFirstNameKey(e.target.checked); + }.bind(this); + inputs.push( + <div key='userNotificationFirstNameOption'> + <div className='checkbox'> + <label> + <input + type='checkbox' + checked={this.state.firstNameKey} + onChange={handleUpdateFirstNameKey} + /> + <FormattedMessage + id='user.settings.notifications.sensitiveName' + defaultMessage='Your case sensitive first name "{first_name}"' + values={{ + first_name: user.first_name + }} + /> + </label> + </div> + </div> + ); + } + + handleUpdateUsernameKey = function handleUsernameKeyChange(e) { + this.updateUsernameKey(e.target.checked); + }.bind(this); + inputs.push( + <div key='userNotificationUsernameOption'> + <div className='checkbox'> + <label> + <input + type='checkbox' + checked={this.state.usernameKey} + onChange={handleUpdateUsernameKey} + /> + <FormattedMessage + id='user.settings.notifications.sensitiveUsername' + defaultMessage='Your non-case sensitive username "{username}"' + values={{ + username: user.username + }} + /> + </label> + </div> + </div> + ); + + handleUpdateMentionKey = function handleMentionKeyChange(e) { + this.updateMentionKey(e.target.checked); + }.bind(this); + inputs.push( + <div key='userNotificationMentionOption'> + <div className='checkbox'> + <label> + <input + type='checkbox' + checked={this.state.mentionKey} + onChange={handleUpdateMentionKey} + /> + <FormattedMessage + id='user.settings.notifications.usernameMention' + defaultMessage='Your username mentioned "@{username}"' + values={{ + username: user.username + }} + /> + </label> + </div> + </div> + ); + + handleUpdateAllKey = function handleAllKeyChange(e) { + this.updateAllKey(e.target.checked); + }.bind(this); + inputs.push( + <div key='userNotificationAllOption'> + <div className='checkbox hidden'> + <label> + <input + type='checkbox' + checked={this.state.allKey} + onChange={handleUpdateAllKey} + /> + <FormattedMessage + id='user.settings.notifications.teamWide' + defaultMessage='Team-wide mentions "@all"' + /> + </label> + </div> + </div> + ); + + handleUpdateChannelKey = function handleChannelKeyChange(e) { + this.updateChannelKey(e.target.checked); + }.bind(this); + inputs.push( + <div key='userNotificationChannelOption'> + <div className='checkbox'> + <label> + <input + type='checkbox' + checked={this.state.channelKey} + onChange={handleUpdateChannelKey} + /> + <FormattedMessage + id='user.settings.notifications.channelWide' + defaultMessage='Channel-wide mentions "@channel"' + /> + </label> + </div> + </div> + ); + + inputs.push( + <div key='userNotificationCustomOption'> + <div className='checkbox'> + <label> + <input + ref='customcheck' + type='checkbox' + checked={this.state.customKeysChecked} + onChange={this.updateCustomMentionKeys} + /> + <FormattedMessage + id='user.settings.notifications.sensitiveWords' + defaultMessage='Other non-case sensitive words, separated by commas:' + /> + </label> + </div> + <input + ref='custommentions' + className='form-control mentions-input' + type='text' + defaultValue={this.state.customKeys} + onChange={this.onCustomChange} + /> + </div> + ); + + keysSection = ( + <SettingItemMax + title={formatMessage(holders.wordsTrigger)} + inputs={inputs} + submit={this.handleSubmit} + server_error={serverError} + updateSection={this.handleCancel} + /> + ); + } else { + let keys = []; + if (this.state.firstNameKey) { + keys.push(user.first_name); + } + if (this.state.usernameKey) { + keys.push(user.username); + } + if (this.state.mentionKey) { + keys.push('@' + user.username); + } + + // if (this.state.allKey) { + // keys.push('@all'); + // } + + if (this.state.channelKey) { + keys.push('@channel'); + } + if (this.state.customKeys.length > 0) { + keys = keys.concat(this.state.customKeys.split(',')); + } + + let describe = ''; + for (var i = 0; i < keys.length; i++) { + describe += '"' + keys[i] + '", '; + } + + if (describe.length > 0) { + describe = describe.substring(0, describe.length - 2); + } else { + describe = ( + <FormattedMessage + id='user.settings.notifications.noWords' + defaultMessage='No words configured' + /> + ); + } + + handleUpdateKeysSection = function updateKeysSection() { + this.props.updateSection('keys'); + }.bind(this); + + keysSection = ( + <SettingItemMin + title={formatMessage(holders.wordsTrigger)} + describe={describe} + updateSection={handleUpdateKeysSection} + /> + ); + } + + return ( + <div> + <div className='modal-header'> + <button + type='button' + className='close' + data-dismiss='modal' + aria-label={formatMessage(holders.close)} + onClick={this.props.closeModal} + > + <span aria-hidden='true'>{'×'}</span> + </button> + <h4 + className='modal-title' + ref='title' + > + <div className='modal-back'> + <i + className='fa fa-angle-left' + onClick={this.props.collapseModal} + /> + </div> + <FormattedMessage + id='user.settings.notifications.title' + defaultMessage='Notification Settings' + /> + </h4> + </div> + <div + ref='wrapper' + className='user-settings' + > + <h3 className='tab-header'> + <FormattedMessage + id='user.settings.notifications.header' + defaultMessage='Notifications' + /> + </h3> + <div className='divider-dark first'/> + {desktopSection} + <div className='divider-light'/> + {soundSection} + <div className='divider-light'/> + {emailSection} + <div className='divider-light'/> + {keysSection} + <div className='divider-dark'/> + </div> + </div> + + ); + } +} + +NotificationsTab.defaultProps = { + user: null, + activeSection: '', + activeTab: '' +}; +NotificationsTab.propTypes = { + intl: intlShape.isRequired, + user: React.PropTypes.object, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func, + activeSection: React.PropTypes.string, + activeTab: React.PropTypes.string, + closeModal: React.PropTypes.func.isRequired, + collapseModal: React.PropTypes.func.isRequired +}; + +export default injectIntl(NotificationsTab); diff --git a/webapp/components/user_settings/user_settings_security.jsx b/webapp/components/user_settings/user_settings_security.jsx new file mode 100644 index 000000000..8688c7f2f --- /dev/null +++ b/webapp/components/user_settings/user_settings_security.jsx @@ -0,0 +1,474 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; +import AccessHistoryModal from '../access_history_modal.jsx'; +import ActivityLogModal from '../activity_log_modal.jsx'; +import ToggleModalButton from '../toggle_modal_button.jsx'; + +import TeamStore from 'stores/team_store.jsx'; + +import * as Client from 'utils/client.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; +import * as Utils from 'utils/utils.jsx'; +import Constants from 'utils/constants.jsx'; + +import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedTime, FormattedDate} from 'react-intl'; + +const holders = defineMessages({ + currentPasswordError: { + id: 'user.settings.security.currentPasswordError', + defaultMessage: 'Please enter your current password' + }, + passwordLengthError: { + id: 'user.settings.security.passwordLengthError', + defaultMessage: 'New passwords must be at least {chars} characters' + }, + passwordMatchError: { + id: 'user.settings.security.passwordMatchError', + defaultMessage: 'The new passwords you entered do not match' + }, + password: { + id: 'user.settings.security.password', + defaultMessage: 'Password' + }, + lastUpdated: { + id: 'user.settings.security.lastUpdated', + defaultMessage: 'Last updated {date} at {time}' + }, + method: { + id: 'user.settings.security.method', + defaultMessage: 'Sign-in Method' + }, + close: { + id: 'user.settings.security.close', + defaultMessage: 'Close' + } +}); + +import React from 'react'; + +class SecurityTab extends React.Component { + constructor(props) { + super(props); + + this.submitPassword = this.submitPassword.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.state = this.getDefaultState(); + } + getDefaultState() { + return { + currentPassword: '', + newPassword: '', + confirmPassword: '', + authService: this.props.user.auth_service + }; + } + submitPassword(e) { + e.preventDefault(); + + var user = this.props.user; + var currentPassword = this.state.currentPassword; + var newPassword = this.state.newPassword; + var confirmPassword = this.state.confirmPassword; + + const {formatMessage} = this.props.intl; + if (currentPassword === '') { + this.setState({passwordError: formatMessage(holders.currentPasswordError), serverError: ''}); + return; + } + + if (newPassword.length < Constants.MIN_PASSWORD_LENGTH) { + this.setState({passwordError: formatMessage(holders.passwordLengthError, {chars: Constants.MIN_PASSWORD_LENGTH}), serverError: ''}); + return; + } + + if (newPassword !== confirmPassword) { + var defaultState = Object.assign(this.getDefaultState(), {passwordError: formatMessage(holders.passwordMatchError), serverError: ''}); + this.setState(defaultState); + return; + } + + var data = {}; + data.user_id = user.id; + data.current_password = currentPassword; + data.new_password = newPassword; + + Client.updatePassword(data, + () => { + this.props.updateSection(''); + AsyncClient.getMe(); + this.setState(this.getDefaultState()); + }, + (err) => { + var state = this.getDefaultState(); + if (err.message) { + state.serverError = err.message; + } else { + state.serverError = err; + } + state.passwordError = ''; + this.setState(state); + } + ); + } + updateCurrentPassword(e) { + this.setState({currentPassword: e.target.value}); + } + updateNewPassword(e) { + this.setState({newPassword: e.target.value}); + } + updateConfirmPassword(e) { + this.setState({confirmPassword: e.target.value}); + } + createPasswordSection() { + let updateSectionStatus; + const {formatMessage} = this.props.intl; + + if (this.props.activeSection === 'password' && this.props.user.auth_service === '') { + const inputs = []; + + inputs.push( + <div + key='currentPasswordUpdateForm' + className='form-group' + > + <label className='col-sm-5 control-label'> + <FormattedMessage + id='user.settings.security.currentPassword' + defaultMessage='Current Password' + /> + </label> + <div className='col-sm-7'> + <input + className='form-control' + type='password' + onChange={this.updateCurrentPassword} + value={this.state.currentPassword} + /> + </div> + </div> + ); + inputs.push( + <div + key='newPasswordUpdateForm' + className='form-group' + > + <label className='col-sm-5 control-label'> + <FormattedMessage + id='user.settings.security.newPassword' + defaultMessage='New Password' + /> + </label> + <div className='col-sm-7'> + <input + className='form-control' + type='password' + onChange={this.updateNewPassword} + value={this.state.newPassword} + /> + </div> + </div> + ); + inputs.push( + <div + key='retypeNewPasswordUpdateForm' + className='form-group' + > + <label className='col-sm-5 control-label'> + <FormattedMessage + id='user.settings.security.retypePassword' + defaultMessage='Retype New Password' + /> + </label> + <div className='col-sm-7'> + <input + className='form-control' + type='password' + onChange={this.updateConfirmPassword} + value={this.state.confirmPassword} + /> + </div> + </div> + ); + + updateSectionStatus = function resetSection(e) { + this.props.updateSection(''); + this.setState({currentPassword: '', newPassword: '', confirmPassword: '', serverError: null, passwordError: null}); + e.preventDefault(); + }.bind(this); + + return ( + <SettingItemMax + title={formatMessage(holders.password)} + inputs={inputs} + submit={this.submitPassword} + server_error={this.state.serverError} + client_error={this.state.passwordError} + updateSection={updateSectionStatus} + /> + ); + } + + var describe; + var d = new Date(this.props.user.last_password_update); + + const hours12 = !Utils.isMilitaryTime(); + describe = formatMessage(holders.lastUpdated, { + date: ( + <FormattedDate + value={d} + day='2-digit' + month='short' + year='numeric' + /> + ), + time: ( + <FormattedTime + value={d} + hour12={hours12} + hour='2-digit' + minute='2-digit' + /> + ) + }); + + updateSectionStatus = function updateSection() { + this.props.updateSection('password'); + }.bind(this); + + return ( + <SettingItemMin + title={formatMessage(holders.password)} + describe={describe} + updateSection={updateSectionStatus} + /> + ); + } + createSignInSection() { + let updateSectionStatus; + const user = this.props.user; + + if (this.props.activeSection === 'signin') { + const inputs = []; + const teamName = TeamStore.getCurrent().name; + + let emailOption; + if (global.window.mm_config.EnableSignUpWithEmail === 'true' && user.auth_service !== '') { + emailOption = ( + <div> + <a + className='btn btn-primary' + href={'/' + teamName + '/claim?email=' + encodeURIComponent(user.email) + '&old_type=' + user.auth_service} + > + <FormattedMessage + id='user.settings.security.switchEmail' + defaultMessage='Switch to using email and password' + /> + </a> + <br/> + </div> + ); + } + + let gitlabOption; + if (global.window.mm_config.EnableSignUpWithGitLab === 'true' && user.auth_service === '') { + gitlabOption = ( + <div> + <a + className='btn btn-primary' + href={'/' + teamName + '/claim?email=' + encodeURIComponent(user.email) + '&old_type=' + user.auth_service + '&new_type=' + Constants.GITLAB_SERVICE} + > + <FormattedMessage + id='user.settings.security.switchGitlab' + defaultMessage='Switch to using GitLab SSO' + /> + </a> + <br/> + </div> + ); + } + + let googleOption; + if (global.window.mm_config.EnableSignUpWithGoogle === 'true' && user.auth_service === '') { + googleOption = ( + <div> + <a + className='btn btn-primary' + href={'/' + teamName + '/claim?email=' + encodeURIComponent(user.email) + '&old_type=' + user.auth_service + '&new_type=' + Constants.GOOGLE_SERVICE} + > + <FormattedMessage + id='user.settings.security.switchGoogle' + defaultMessage='Switch to using Google SSO' + /> + </a> + <br/> + </div> + ); + } + + inputs.push( + <div key='userSignInOption'> + {emailOption} + {gitlabOption} + <br/> + {googleOption} + </div> + ); + + updateSectionStatus = function updateSection(e) { + this.props.updateSection(''); + this.setState({serverError: null}); + e.preventDefault(); + }.bind(this); + + const extraInfo = ( + <span> + <FormattedMessage + id='user.settings.security.oneSignin' + defaultMessage='You may only have one sign-in method at a time. Switching sign-in method will send an email notifying you if the change was successful.' + /> + </span> + ); + + return ( + <SettingItemMax + title={this.props.intl.formatMessage(holders.method)} + extraInfo={extraInfo} + inputs={inputs} + server_error={this.state.serverError} + updateSection={updateSectionStatus} + /> + ); + } + + updateSectionStatus = function updateSection() { + this.props.updateSection('signin'); + }.bind(this); + + let describe = ( + <FormattedMessage + id='user.settings.security.emailPwd' + defaultMessage='Email and Password' + /> + ); + if (this.props.user.auth_service === Constants.GITLAB_SERVICE) { + describe = ( + <FormattedMessage + id='user.settings.security.gitlab' + defaultMessage='GitLab SSO' + /> + ); + } + + return ( + <SettingItemMin + title={this.props.intl.formatMessage(holders.method)} + describe={describe} + updateSection={updateSectionStatus} + /> + ); + } + render() { + const passwordSection = this.createPasswordSection(); + let signInSection; + + let numMethods = 0; + numMethods = global.window.mm_config.EnableSignUpWithGitLab === 'true' ? numMethods + 1 : numMethods; + numMethods = global.window.mm_config.EnableSignUpWithGoogle === 'true' ? numMethods + 1 : numMethods; + + if (global.window.mm_config.EnableSignUpWithEmail && numMethods > 0) { + signInSection = this.createSignInSection(); + } + + return ( + <div> + <div className='modal-header'> + <button + type='button' + className='close' + data-dismiss='modal' + aria-label={this.props.intl.formatMessage(holders.close)} + onClick={this.props.closeModal} + > + <span aria-hidden='true'>{'×'}</span> + </button> + <h4 + className='modal-title' + ref='title' + > + <div className='modal-back'> + <i + className='fa fa-angle-left' + onClick={this.props.collapseModal} + /> + </div> + <FormattedMessage + id='user.settings.security.title' + defaultMessage='Security Settings' + /> + </h4> + </div> + <div className='user-settings'> + <h3 className='tab-header'> + <FormattedMessage + id='user.settings.security.title' + defaultMessage='Security Settings' + /> + </h3> + <div className='divider-dark first'/> + {passwordSection} + <div className='divider-light'/> + {signInSection} + <div className='divider-dark'/> + <br></br> + <ToggleModalButton + className='security-links theme' + dialogType={AccessHistoryModal} + > + <i className='fa fa-clock-o'></i> + <FormattedMessage + id='user.settings.security.viewHistory' + defaultMessage='View Access History' + /> + </ToggleModalButton> + <b> </b> + <ToggleModalButton + className='security-links theme' + dialogType={ActivityLogModal} + > + <i className='fa fa-clock-o'></i> + <FormattedMessage + id='user.settings.security.logoutActiveSessions' + defaultMessage='View and Logout of Active Sessions' + /> + </ToggleModalButton> + </div> + </div> + ); + } +} + +SecurityTab.defaultProps = { + user: {}, + activeSection: '' +}; +SecurityTab.propTypes = { + intl: intlShape.isRequired, + user: React.PropTypes.object, + activeSection: React.PropTypes.string, + updateSection: React.PropTypes.func, + updateTab: React.PropTypes.func, + closeModal: React.PropTypes.func.isRequired, + collapseModal: React.PropTypes.func.isRequired, + setEnforceFocus: React.PropTypes.func.isRequired +}; + +export default injectIntl(SecurityTab); diff --git a/webapp/components/user_settings/user_settings_theme.jsx b/webapp/components/user_settings/user_settings_theme.jsx new file mode 100644 index 000000000..3414fe2e2 --- /dev/null +++ b/webapp/components/user_settings/user_settings_theme.jsx @@ -0,0 +1,302 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; +import ReactDOM from 'react-dom'; +import CustomThemeChooser from './custom_theme_chooser.jsx'; +import PremadeThemeChooser from './premade_theme_chooser.jsx'; +import SettingItemMin from '../setting_item_min.jsx'; +import SettingItemMax from '../setting_item_max.jsx'; + +import UserStore from 'stores/user_store.jsx'; + +import AppDispatcher from '../../dispatcher/app_dispatcher.jsx'; +import * as Client from 'utils/client.jsx'; +import * as Utils from 'utils/utils.jsx'; + +import Constants from 'utils/constants.jsx'; + +import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl'; + +const ActionTypes = Constants.ActionTypes; + +const holders = defineMessages({ + themeTitle: { + id: 'user.settings.display.theme.title', + defaultMessage: 'Theme' + }, + themeDescribe: { + id: 'user.settings.display.theme.describe', + defaultMessage: 'Open to manage your theme' + } +}); + +import React from 'react'; + +export default class ThemeSetting extends React.Component { + constructor(props) { + super(props); + + this.onChange = this.onChange.bind(this); + this.submitTheme = this.submitTheme.bind(this); + this.updateTheme = this.updateTheme.bind(this); + this.deactivate = this.deactivate.bind(this); + this.resetFields = this.resetFields.bind(this); + this.handleImportModal = this.handleImportModal.bind(this); + + this.state = this.getStateFromStores(); + + this.originalTheme = Object.assign({}, this.state.theme); + } + componentDidMount() { + UserStore.addChangeListener(this.onChange); + + if (this.props.selected) { + $(ReactDOM.findDOMNode(this.refs[this.state.theme])).addClass('active-border'); + } + } + componentDidUpdate() { + if (this.props.selected) { + $('.color-btn').removeClass('active-border'); + $(ReactDOM.findDOMNode(this.refs[this.state.theme])).addClass('active-border'); + } + } + componentWillReceiveProps(nextProps) { + if (!this.props.selected && nextProps.selected) { + this.resetFields(); + } + } + componentWillUnmount() { + UserStore.removeChangeListener(this.onChange); + } + getStateFromStores() { + const user = UserStore.getCurrentUser(); + let theme = null; + + if ($.isPlainObject(user.theme_props) && !$.isEmptyObject(user.theme_props)) { + theme = Object.assign({}, user.theme_props); + } else { + theme = $.extend(true, {}, Constants.THEMES.default); + } + + let type = 'premade'; + if (theme.type === 'custom') { + type = 'custom'; + } + + if (!theme.codeTheme) { + theme.codeTheme = Constants.DEFAULT_CODE_THEME; + } + + return {theme, type}; + } + onChange() { + const newState = this.getStateFromStores(); + + if (!Utils.areObjectsEqual(this.state, newState)) { + this.setState(newState); + } + + this.props.setEnforceFocus(true); + } + scrollToTop() { + $('.ps-container.modal-body').scrollTop(0); + } + submitTheme(e) { + e.preventDefault(); + var user = UserStore.getCurrentUser(); + user.theme_props = this.state.theme; + + Client.updateUser(user, + (data) => { + AppDispatcher.handleServerAction({ + type: ActionTypes.RECEIVED_ME, + me: data + }); + + this.props.setRequireConfirm(false); + this.originalTheme = Object.assign({}, this.state.theme); + this.scrollToTop(); + this.props.updateSection(''); + }, + (err) => { + var state = this.getStateFromStores(); + state.serverError = err; + this.setState(state); + } + ); + } + updateTheme(theme) { + let themeChanged = this.state.theme.length === theme.length; + if (!themeChanged) { + for (const field in theme) { + if (theme.hasOwnProperty(field)) { + if (this.state.theme[field] !== theme[field]) { + themeChanged = true; + break; + } + } + } + } + + this.props.setRequireConfirm(themeChanged); + + this.setState({theme}); + Utils.applyTheme(theme); + } + updateType(type) { + this.setState({type}); + } + deactivate() { + const state = this.getStateFromStores(); + + Utils.applyTheme(state.theme); + } + resetFields() { + const state = this.getStateFromStores(); + state.serverError = null; + this.setState(state); + this.scrollToTop(); + + Utils.applyTheme(state.theme); + + this.props.setRequireConfirm(false); + } + handleImportModal() { + AppDispatcher.handleViewAction({ + type: ActionTypes.TOGGLE_IMPORT_THEME_MODAL, + value: true + }); + + this.props.setEnforceFocus(false); + } + render() { + const {formatMessage} = this.props.intl; + + var serverError; + if (this.state.serverError) { + serverError = this.state.serverError; + } + + const displayCustom = this.state.type === 'custom'; + + let custom; + let premade; + if (displayCustom) { + custom = ( + <div key='customThemeChooser'> + <CustomThemeChooser + theme={this.state.theme} + updateTheme={this.updateTheme} + /> + </div> + ); + } else { + premade = ( + <div key='premadeThemeChooser'> + <br/> + <PremadeThemeChooser + theme={this.state.theme} + updateTheme={this.updateTheme} + /> + </div> + ); + } + + let themeUI; + if (this.props.selected) { + let inputs = []; + + inputs.push( + <div + className='radio' + key='premadeThemeColorLabel' + > + <label> + <input type='radio' + checked={!displayCustom} + onChange={this.updateType.bind(this, 'premade')} + /> + <FormattedMessage + id='user.settings.display.theme.themeColors' + defaultMessage='Theme Colors' + /> + </label> + <br/> + </div> + ); + + inputs.push(premade); + + inputs.push( + <div + className='radio' + key='customThemeColorLabel' + > + <label> + <input type='radio' + checked={displayCustom} + onChange={this.updateType.bind(this, 'custom')} + /> + <FormattedMessage + id='user.settings.display.theme.customTheme' + defaultMessage='Custom Theme' + /> + </label> + </div> + ); + + inputs.push(custom); + + inputs.push( + <div key='importSlackThemeButton'> + <br/> + <a + className='theme' + onClick={this.handleImportModal} + > + <FormattedMessage + id='user.settings.display.theme.import' + defaultMessage='Import theme colors from Slack' + /> + </a> + </div> + ); + + themeUI = ( + <SettingItemMax + inputs={inputs} + submit={this.submitTheme} + server_error={serverError} + width='full' + updateSection={(e) => { + this.props.updateSection(''); + e.preventDefault(); + }} + /> + ); + } else { + themeUI = ( + <SettingItemMin + title={formatMessage(holders.themeTitle)} + describe={formatMessage(holders.themeDescribe)} + updateSection={() => { + this.props.updateSection('theme'); + }} + /> + ); + } + + return themeUI; + } +} + +ThemeSetting.propTypes = { + intl: intlShape.isRequired, + selected: React.PropTypes.bool.isRequired, + updateSection: React.PropTypes.func.isRequired, + setRequireConfirm: React.PropTypes.func.isRequired, + setEnforceFocus: React.PropTypes.func.isRequired +}; + +export default injectIntl(ThemeSetting); |