diff options
Diffstat (limited to 'webapp/components')
76 files changed, 3233 insertions, 1757 deletions
diff --git a/webapp/components/about_build_modal.jsx b/webapp/components/about_build_modal.jsx index e73d842d0..4fd946401 100644 --- a/webapp/components/about_build_modal.jsx +++ b/webapp/components/about_build_modal.jsx @@ -6,6 +6,7 @@ import {Modal} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; import React from 'react'; +import Constants from 'utils/constants.jsx'; export default class AboutBuildModal extends React.Component { constructor(props) { @@ -20,6 +21,7 @@ export default class AboutBuildModal extends React.Component { render() { const config = global.window.mm_config; const license = global.window.mm_license; + const mattermostLogo = Constants.MATTERMOST_ICON_SVG; let title = ( <FormattedMessage @@ -28,6 +30,28 @@ export default class AboutBuildModal extends React.Component { /> ); + let subTitle = ( + <FormattedMessage + id='about.teamEditionSt' + defaultMessage='All your team communication in one place, instantly searchable and accessible anywhere.' + /> + ); + + let learnMore = ( + <div> + <FormattedMessage + id='about.teamEditionLearn' + defaultMessage='Join the Mattermost community at ' + /> + <a + target='_blank' + href='http://www.mattermost.org/' + > + {'mattermost.org'} + </a> + </div> + ); + let licensee; if (config.BuildEnterpriseReady === 'true') { title = ( @@ -36,6 +60,29 @@ export default class AboutBuildModal extends React.Component { defaultMessage='Enterprise Edition' /> ); + + subTitle = ( + <FormattedMessage + id='about.enterpriseEditionSt' + defaultMessage='Modern enterprise communication from behind your firewall.' + /> + ); + + learnMore = ( + <div> + <FormattedMessage + id='about.enterpriseEditionLearn' + defaultMessage='Learn more about Enterprise Edition at ' + /> + <a + target='_blank' + href='http://about.mattermost.com/' + > + {'about.mattermost.com'} + </a> + </div> + ); + if (license.IsLicensed === 'true') { title = ( <FormattedMessage @@ -44,14 +91,12 @@ export default class AboutBuildModal extends React.Component { /> ); licensee = ( - <div className='row form-group'> - <div className='col-sm-3 info__label'> - <FormattedMessage - id='about.licensed' - defaultMessage='Licensed by:' - /> - </div> - <div className='col-sm-9'>{license.Company}</div> + <div className='form-group'> + <FormattedMessage + id='about.licensed' + defaultMessage='Licensed by:' + /> + {license.Company} </div> ); } @@ -59,6 +104,7 @@ export default class AboutBuildModal extends React.Component { return ( <Modal + dialogClassName='about-modal' show={this.props.show} onHide={this.doHide} > @@ -71,57 +117,54 @@ export default class AboutBuildModal extends React.Component { </Modal.Title> </Modal.Header> <Modal.Body> - <h4>{'Mattermost'} {title}</h4> - {licensee} - <div className='row form-group'> - <div className='col-sm-3 info__label'> - <FormattedMessage - id='about.version' - defaultMessage='Version:' + <div className='about-modal__content'> + <div className='about-modal__logo'> + <span + className='icon' + dangerouslySetInnerHTML={{__html: mattermostLogo}} /> </div> - <div className='col-sm-9'>{config.Version}</div> - </div> - <div className='row form-group'> - <div className='col-sm-3 info__label'> - <FormattedMessage - id='about.number' - defaultMessage='Build Number:' - /> + <div> + <h3 className='about-modal__title'>{'Mattermost'} {title}</h3> + <p className='about-modal__subtitle padding-bottom'>{subTitle}</p> + <div className='form-group less'> + <div> + <FormattedMessage + id='about.version' + defaultMessage='Version:' + /> + {config.Version} ({config.BuildNumber}) + </div> + </div> + {licensee} </div> - <div className='col-sm-9'>{config.BuildNumber}</div> </div> - <div className='row form-group'> - <div className='col-sm-3 info__label'> + <div className='about-modal__footer'> + {learnMore} + <div className='form-group about-modal__copyright'> <FormattedMessage - id='about.date' - defaultMessage='Build Date:' + id='about.copyright' + defaultMessage='Copyright 2016 Mattermost, Inc. All rights reserved' /> </div> - <div className='col-sm-9'>{config.BuildDate}</div> </div> - <div className='row form-group'> - <div className='col-sm-3 info__label'> + <div className='about-modal__hash form-group padding-top x2'> + <p> <FormattedMessage id='about.hash' defaultMessage='Build Hash:' /> - </div> - <div className='col-sm-9'>{config.BuildHash}</div> + {config.BuildHash} + </p> + <p> + <FormattedMessage + id='about.date' + defaultMessage='Build Date:' + /> + {config.BuildDate} + </p> </div> </Modal.Body> - <Modal.Footer> - <button - type='button' - className='btn btn-default' - onClick={this.doHide} - > - <FormattedMessage - id='about.close' - defaultMessage='Close' - /> - </button> - </Modal.Footer> </Modal> ); } diff --git a/webapp/components/admin_console/admin_navbar_dropdown.jsx b/webapp/components/admin_console/admin_navbar_dropdown.jsx index 527f97959..729d4b14d 100644 --- a/webapp/components/admin_console/admin_navbar_dropdown.jsx +++ b/webapp/components/admin_console/admin_navbar_dropdown.jsx @@ -64,7 +64,7 @@ export default class AdminNavbarDropdown extends React.Component { > <li> <Link - to={Utils.getWindowLocationOrigin() + '/' + this.state.currentTeam.name + '/channels/town-square'} + to={'/' + this.state.currentTeam.name + '/channels/town-square'} > <FormattedMessage id='admin.nav.switch' diff --git a/webapp/components/admin_console/compliance_settings.jsx b/webapp/components/admin_console/compliance_settings.jsx index fb2ae26f9..206bb0faa 100644 --- a/webapp/components/admin_console/compliance_settings.jsx +++ b/webapp/components/admin_console/compliance_settings.jsx @@ -223,7 +223,7 @@ export default class ComplianceSettings extends React.Component { </label> <p className='help-text'> <FormattedMessage - id='admin.compliance.enableDesc' + id='admin.compliance.enableDailyDesc' defaultMessage='When true, Mattermost will generate a daily compliance report.' /> </p> diff --git a/webapp/components/admin_console/service_settings.jsx b/webapp/components/admin_console/service_settings.jsx index 881d22d76..2c3f4081c 100644 --- a/webapp/components/admin_console/service_settings.jsx +++ b/webapp/components/admin_console/service_settings.jsx @@ -87,6 +87,10 @@ class ServiceSettings extends React.Component { config.ServiceSettings.EnableCommands = ReactDOM.findDOMNode(this.refs.EnableCommands).checked; config.ServiceSettings.EnableOnlyAdminIntegrations = ReactDOM.findDOMNode(this.refs.EnableOnlyAdminIntegrations).checked; + if (this.refs.EnableMultifactorAuthentication) { + config.ServiceSettings.EnableMultifactorAuthentication = ReactDOM.findDOMNode(this.refs.EnableMultifactorAuthentication).checked; + } + //config.ServiceSettings.EnableOAuthServiceProvider = ReactDOM.findDOMNode(this.refs.EnableOAuthServiceProvider).checked; var MaximumLoginAttempts = DefaultMaximumLoginAttempts; @@ -173,6 +177,58 @@ class ServiceSettings extends React.Component { saveClass = 'btn btn-primary'; } + let mfaSetting; + if (global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.MFA === 'true') { + mfaSetting = ( + <div className='form-group'> + <label + className='control-label col-sm-4' + htmlFor='EnableMultifactorAuthentication' + > + <FormattedMessage + id='admin.service.mfaTitle' + defaultMessage='Enable Multi-factor Authentication:' + /> + </label> + <div className='col-sm-8'> + <label className='radio-inline'> + <input + type='radio' + name='EnableMultifactorAuthentication' + value='true' + ref='EnableMultifactorAuthentication' + defaultChecked={this.props.config.ServiceSettings.EnableMultifactorAuthentication} + onChange={this.handleChange} + /> + <FormattedMessage + id='admin.service.true' + defaultMessage='true' + /> + </label> + <label className='radio-inline'> + <input + type='radio' + name='EnableMultifactorAuthentication' + value='false' + defaultChecked={!this.props.config.ServiceSettings.EnableMultifactorAuthentication} + onChange={this.handleChange} + /> + <FormattedMessage + id='admin.service.false' + defaultMessage='false' + /> + </label> + <p className='help-text'> + <FormattedMessage + id='admin.service.mfaDesc' + defaultMessage='When true, users will be given the option to add multi-factor authentication to their account. They will need a smartphone and an authenticator app such as Google Authenticator.' + /> + </p> + </div> + </div> + ); + } + return ( <div className='wrapper--fixed'> @@ -773,6 +829,8 @@ class ServiceSettings extends React.Component { </div> </div> + {mfaSetting} + <div className='form-group'> <label className='control-label col-sm-4' diff --git a/webapp/components/admin_console/user_item.jsx b/webapp/components/admin_console/user_item.jsx index 91f567d4d..c00050584 100644 --- a/webapp/components/admin_console/user_item.jsx +++ b/webapp/components/admin_console/user_item.jsx @@ -333,7 +333,7 @@ export default class UserItem extends React.Component { <div> <FormattedMessage id='admin.user_item.confirmDemoteDescription' - defaultMessage="If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you\'ll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command." + defaultMessage="If you demote yourself from the System Admin role and there is not another user with System Admin privileges, you'll need to re-assign a System Admin by accessing the Mattermost server through a terminal and running the following command." /> <br/> <br/> diff --git a/webapp/components/analytics/team_analytics.jsx b/webapp/components/analytics/team_analytics.jsx index efc965f24..9b4eb1f94 100644 --- a/webapp/components/analytics/team_analytics.jsx +++ b/webapp/components/analytics/team_analytics.jsx @@ -154,7 +154,7 @@ class TeamAnalytics extends React.Component { <TableChart title={ <FormattedMessage - id='analytics.team.activeUsers' + id='analytics.team.recentUsers' defaultMessage='Recent Active Users' /> } diff --git a/webapp/components/backstage/add_incoming_webhook.jsx b/webapp/components/backstage/add_incoming_webhook.jsx new file mode 100644 index 000000000..83027c6b3 --- /dev/null +++ b/webapp/components/backstage/add_incoming_webhook.jsx @@ -0,0 +1,198 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import * as AsyncClient from 'utils/async_client.jsx'; +import {browserHistory} from 'react-router'; + +import ChannelSelect from 'components/channel_select.jsx'; +import {FormattedMessage} from 'react-intl'; +import FormError from 'components/form_error.jsx'; +import {Link} from 'react-router'; +import SpinnerButton from 'components/spinner_button.jsx'; + +export default class AddIncomingWebhook extends React.Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + + this.updateName = this.updateName.bind(this); + this.updateDescription = this.updateDescription.bind(this); + this.updateChannelId = this.updateChannelId.bind(this); + + this.state = { + name: '', + description: '', + channelId: '', + saving: false, + serverError: '', + clientError: null + }; + } + + handleSubmit(e) { + e.preventDefault(); + + if (this.state.saving) { + return; + } + + this.setState({ + saving: true, + serverError: '', + clientError: '' + }); + + if (!this.state.channelId) { + this.setState({ + saving: false, + clientError: ( + <FormattedMessage + id='add_incoming_webhook.channelRequired' + defaultMessage='A valid channel is required' + /> + ) + }); + + return; + } + + const hook = { + channel_id: this.state.channelId + }; + + AsyncClient.addIncomingHook( + hook, + () => { + browserHistory.push('/settings/integrations/installed'); + }, + (err) => { + this.setState({ + serverError: err.message + }); + } + ); + } + + updateName(e) { + this.setState({ + name: e.target.value + }); + } + + updateDescription(e) { + this.setState({ + description: e.target.value + }); + } + + updateChannelId(e) { + this.setState({ + channelId: e.target.value + }); + } + + render() { + return ( + <div className='backstage-content row'> + <div className='add-incoming-webhook'> + <div className='backstage-header'> + <h1> + <FormattedMessage + id='add_incoming_webhook.header' + defaultMessage='Add Incoming Webhook' + /> + </h1> + </div> + </div> + <div className='backstage-form'> + <form className='form-horizontal'> + <div className='form-group'> + <label + className='control-label col-sm-3' + htmlFor='name' + > + <FormattedMessage + id='add_incoming_webhook.name' + defaultMessage='Name' + /> + </label> + <div className='col-md-5 col-sm-9'> + <input + id='name' + type='text' + className='form-control' + value={this.state.name} + onChange={this.updateName} + /> + </div> + </div> + <div className='form-group'> + <label + className='control-label col-sm-3' + htmlFor='description' + > + <FormattedMessage + id='add_incoming_webhook.description' + defaultMessage='Description' + /> + </label> + <div className='col-md-5 col-sm-9'> + <input + id='description' + type='text' + className='form-control' + value={this.state.description} + onChange={this.updateDescription} + /> + </div> + </div> + <div className='form-group'> + <label + className='control-label col-sm-3' + htmlFor='channelId' + > + <FormattedMessage + id='add_incoming_webhook.channel' + defaultMessage='Channel' + /> + </label> + <div className='col-md-5 col-sm-9'> + <ChannelSelect + id='channelId' + value={this.state.channelId} + onChange={this.updateChannelId} + /> + </div> + </div> + <div className='backstage-form__footer'> + <FormError errors={[this.state.serverError, this.state.clientError]}/> + <Link + className='btn btn-sm' + to={'/settings/integrations/add'} + > + <FormattedMessage + id='add_incoming_webhook.cancel' + defaultMessage='Cancel' + /> + </Link> + <SpinnerButton + className='btn btn-primary' + type='submit' + spinning={this.state.saving} + onClick={this.handleSubmit} + > + <FormattedMessage + id='add_incoming_webhook.save' + defaultMessage='Save' + /> + </SpinnerButton> + </div> + </form> + </div> + </div> + ); + } +} diff --git a/webapp/components/backstage/add_integration.jsx b/webapp/components/backstage/add_integration.jsx new file mode 100644 index 000000000..5f4a69bfe --- /dev/null +++ b/webapp/components/backstage/add_integration.jsx @@ -0,0 +1,76 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import {FormattedMessage} from 'react-intl'; +import AddIntegrationOption from './add_integration_option.jsx'; + +import WebhookIcon from 'images/webhook_icon.jpg'; + +export default class AddIntegration extends React.Component { + render() { + const options = []; + + if (window.mm_config.EnableIncomingWebhooks === 'true') { + options.push( + <AddIntegrationOption + key='incomingWebhook' + image={WebhookIcon} + title={ + <FormattedMessage + id='add_integration.incomingWebhook.title' + defaultMessage='Incoming Webhook' + /> + } + description={ + <FormattedMessage + id='add_integration.incomingWebhook.description' + defaultMessage='Create webhook URLs for use in external integrations.' + /> + } + link={'/settings/integrations/add/incoming_webhook'} + /> + ); + } + + if (window.mm_config.EnableOutgoingWebhooks === 'true') { + options.push( + <AddIntegrationOption + key='outgoingWebhook' + image={WebhookIcon} + title={ + <FormattedMessage + id='add_integration.outgoingWebhook.title' + defaultMessage='Outgoing Webhook' + /> + } + description={ + <FormattedMessage + id='add_integration.outgoingWebhook.description' + defaultMessage='Create webhooks to send new message events to an external integration.' + /> + } + link={'/settings/integrations/add/outgoing_webhook'} + /> + ); + } + + return ( + <div className='backstage-content row'> + <div className='backstage-header'> + <h1> + <FormattedMessage + id='add_integration.header' + defaultMessage='Add Integration' + /> + </h1> + </div> + <div> + {options} + </div> + </div> + ); + } +} + diff --git a/webapp/components/backstage/add_integration_option.jsx b/webapp/components/backstage/add_integration_option.jsx new file mode 100644 index 000000000..b17ebb185 --- /dev/null +++ b/webapp/components/backstage/add_integration_option.jsx @@ -0,0 +1,39 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import {Link} from 'react-router'; + +export default class AddIntegrationOption extends React.Component { + static get propTypes() { + return { + image: React.PropTypes.string.isRequired, + title: React.PropTypes.node.isRequired, + description: React.PropTypes.node.isRequired, + link: React.PropTypes.string.isRequired + }; + } + + render() { + const {image, title, description, link} = this.props; + + return ( + <Link + to={link} + className='add-integration' + > + <img + className='add-integration__image' + src={image} + /> + <div className='add-integration__title'> + {title} + </div> + <div className='add-integration__description'> + {description} + </div> + </Link> + ); + } +} diff --git a/webapp/components/backstage/add_outgoing_webhook.jsx b/webapp/components/backstage/add_outgoing_webhook.jsx new file mode 100644 index 000000000..5d98138df --- /dev/null +++ b/webapp/components/backstage/add_outgoing_webhook.jsx @@ -0,0 +1,270 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import * as AsyncClient from 'utils/async_client.jsx'; +import {browserHistory} from 'react-router'; + +import ChannelSelect from 'components/channel_select.jsx'; +import {FormattedMessage} from 'react-intl'; +import FormError from 'components/form_error.jsx'; +import {Link} from 'react-router'; +import SpinnerButton from 'components/spinner_button.jsx'; + +export default class AddOutgoingWebhook extends React.Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + + this.updateName = this.updateName.bind(this); + this.updateDescription = this.updateDescription.bind(this); + this.updateChannelId = this.updateChannelId.bind(this); + this.updateTriggerWords = this.updateTriggerWords.bind(this); + this.updateCallbackUrls = this.updateCallbackUrls.bind(this); + + this.state = { + name: '', + description: '', + channelId: '', + triggerWords: '', + callbackUrls: '', + saving: false, + serverError: '', + clientError: null + }; + } + + handleSubmit(e) { + e.preventDefault(); + + if (this.state.saving) { + return; + } + + this.setState({ + saving: true, + serverError: '', + clientError: '' + }); + + if (!this.state.channelId && !this.state.triggerWords) { + this.setState({ + saving: false, + clientError: ( + <FormattedMessage + id='add_outgoing_webhook.triggerWordsOrChannelRequired' + defaultMessage='A valid channel or a list of trigger words is required' + /> + ) + }); + + return; + } + + if (!this.state.callbackUrls) { + this.setState({ + saving: false, + clientError: ( + <FormattedMessage + id='add_outgoing_webhook.callbackUrlsRequired' + defaultMessage='One or more callback URLs are required' + /> + ) + }); + + return; + } + + const hook = { + channel_id: this.state.channelId, + trigger_words: this.state.triggerWords.split('\n').map((word) => word.trim()), + callback_urls: this.state.callbackUrls.split('\n').map((url) => url.trim()) + }; + + AsyncClient.addOutgoingHook( + hook, + () => { + browserHistory.push('/settings/integrations/installed'); + }, + (err) => { + this.setState({ + serverError: err.message + }); + } + ); + } + + updateName(e) { + this.setState({ + name: e.target.value + }); + } + + updateDescription(e) { + this.setState({ + description: e.target.value + }); + } + + 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() { + return ( + <div className='backstage-content row'> + <div className='add-outgoing-webhook'> + <div className='backstage-header'> + <h1> + <FormattedMessage + id='add_outgoing_webhook.header' + defaultMessage='Add Outgoing Webhook' + /> + </h1> + </div> + </div> + <div className='backstage-form'> + <form className='form-horizontal'> + <div className='form-group'> + <label + className='control-label col-sm-3' + htmlFor='name' + > + <FormattedMessage + id='add_outgoing_webhook.name' + defaultMessage='Name' + /> + </label> + <div className='col-md-5 col-sm-9'> + <input + id='name' + type='text' + className='form-control' + value={this.state.name} + onChange={this.updateName} + /> + </div> + </div> + <div className='form-group'> + <label + className='control-label col-sm-3' + htmlFor='description' + > + <FormattedMessage + id='add_outgoing_webhook.description' + defaultMessage='Description' + /> + </label> + <div className='col-md-5 col-sm-9'> + <input + id='description' + type='text' + className='form-control' + value={this.state.description} + onChange={this.updateDescription} + /> + </div> + </div> + <div className='form-group'> + <label + className='control-label col-sm-3' + htmlFor='channelId' + > + <FormattedMessage + id='add_outgoing_webhook.channel' + defaultMessage='Channel' + /> + </label> + <div className='col-md-5 col-sm-9'> + <ChannelSelect + id='channelId' + value={this.state.channelId} + onChange={this.updateChannelId} + /> + </div> + </div> + <div className='form-group'> + <label + className='control-label col-sm-3' + htmlFor='triggerWords' + > + <FormattedMessage + id='add_outgoing_webhook.triggerWords' + defaultMessage='Trigger Words (One Per Line)' + /> + </label> + <div className='col-md-5 col-sm-9'> + <textarea + id='triggerWords' + rows='3' + className='form-control' + value={this.state.triggerWords} + onChange={this.updateTriggerWords} + /> + </div> + </div> + <div className='form-group'> + <label + className='control-label col-sm-3' + htmlFor='callbackUrls' + > + <FormattedMessage + id='add_outgoing_webhook.callbackUrls' + defaultMessage='Callback URLs (One Per Line)' + /> + </label> + <div className='col-md-5 col-sm-9'> + <textarea + id='callbackUrls' + rows='3' + className='form-control' + value={this.state.callbackUrls} + onChange={this.updateCallbackUrls} + /> + </div> + </div> + <div className='backstage-form__footer'> + <FormError errors={[this.state.serverError, this.state.clientError]}/> + <Link + className='btn btn-sm' + to={'/settings/integrations/add'} + > + <FormattedMessage + id='add_outgoing_webhook.cancel' + defaultMessage='Cancel' + /> + </Link> + <SpinnerButton + className='btn btn-primary' + type='submit' + spinning={this.state.saving} + onClick={this.handleSubmit} + > + <FormattedMessage + id='add_outgoing_webhook.save' + defaultMessage='Save' + /> + </SpinnerButton> + </div> + </form> + </div> + </div> + ); + } +} diff --git a/webapp/components/backstage/backstage_category.jsx b/webapp/components/backstage/backstage_category.jsx new file mode 100644 index 000000000..913c7562c --- /dev/null +++ b/webapp/components/backstage/backstage_category.jsx @@ -0,0 +1,68 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import {Link} from 'react-router'; + +export default class BackstageCategory extends React.Component { + static get propTypes() { + return { + name: React.PropTypes.string.isRequired, + title: React.PropTypes.node.isRequired, + icon: React.PropTypes.string.isRequired, + parentLink: React.PropTypes.string, + children: React.PropTypes.arrayOf(React.PropTypes.element) + }; + } + + static get defaultProps() { + return { + parentLink: '', + children: [] + }; + } + + static get contextTypes() { + return { + router: React.PropTypes.object.isRequired + }; + } + + render() { + const {name, title, icon, parentLink, children} = this.props; + + const link = parentLink + '/' + name; + + let clonedChildren = null; + if (children.length > 0 && this.context.router.isActive(link)) { + clonedChildren = ( + <ul className='sections'> + { + React.Children.map(children, (child) => { + return React.cloneElement(child, { + parentLink: link + }); + }) + } + </ul> + ); + } + + return ( + <li className='backstage-sidebar__category'> + <Link + to={link} + className='category-title' + activeClassName='category-title--active' + > + <i className={'fa ' + icon}/> + <span className='category-title__text'> + {title} + </span> + </Link> + {clonedChildren} + </li> + ); + } +} diff --git a/webapp/components/backstage/backstage_navbar.jsx b/webapp/components/backstage/backstage_navbar.jsx new file mode 100644 index 000000000..d1dac6043 --- /dev/null +++ b/webapp/components/backstage/backstage_navbar.jsx @@ -0,0 +1,61 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import TeamStore from 'stores/team_store.jsx'; + +import {FormattedMessage} from 'react-intl'; +import {Link} from 'react-router'; + +export default class BackstageNavbar extends React.Component { + constructor(props) { + super(props); + + this.handleChange = this.handleChange.bind(this); + + this.state = { + team: TeamStore.getCurrent() + }; + } + + componentDidMount() { + TeamStore.addChangeListener(this.handleChange); + } + + componentWillUnmount() { + TeamStore.removeChangeListener(this.handleChange); + } + + handleChange() { + this.setState({ + team: TeamStore.getCurrent() + }); + } + + render() { + if (!this.state.team) { + return null; + } + + return ( + <div className='backstage-navbar row'> + <Link + className='backstage-navbar__back' + to={`/${this.state.team.display_name}/channels/town-square`} + > + <i className='fa fa-angle-left'/> + <span> + <FormattedMessage + id='backstage_navbar.backToMattermost' + defaultMessage='Back to {siteName}' + values={{ + siteName: global.window.mm_config.SiteName + }} + /> + </span> + </Link> + </div> + ); + } +} diff --git a/webapp/components/backstage/backstage_section.jsx b/webapp/components/backstage/backstage_section.jsx new file mode 100644 index 000000000..d6ce2b258 --- /dev/null +++ b/webapp/components/backstage/backstage_section.jsx @@ -0,0 +1,80 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import {Link} from 'react-router'; + +export default class BackstageSection extends React.Component { + static get propTypes() { + return { + name: React.PropTypes.string.isRequired, + title: React.PropTypes.node.isRequired, + parentLink: React.PropTypes.string, + subsection: React.PropTypes.bool, + children: React.PropTypes.arrayOf(React.PropTypes.element) + }; + } + + static get defaultProps() { + return { + parentLink: '', + subsection: false, + children: [] + }; + } + + static get contextTypes() { + return { + router: React.PropTypes.object.isRequired + }; + } + + getLink() { + return this.props.parentLink + '/' + this.props.name; + } + + render() { + const {title, subsection, children} = this.props; + + const link = this.getLink(); + + let clonedChildren = null; + if (children.length > 0) { + clonedChildren = ( + <ul className='subsections'> + { + React.Children.map(children, (child) => { + return React.cloneElement(child, { + parentLink: link, + subsection: true + }); + }) + } + </ul> + ); + } + + let className = 'section'; + if (subsection) { + className = 'subsection'; + } + + return ( + <li className={className}> + <Link + className={`${className}-title`} + activeClassName={`${className}-title--active`} + onlyActiveOnIndex={true} + onClick={this.handleClick} + to={link} + > + <span className={`${className}-title__text`}> + {title} + </span> + </Link> + {clonedChildren} + </li> + ); + } +} diff --git a/webapp/components/backstage/backstage_sidebar.jsx b/webapp/components/backstage/backstage_sidebar.jsx new file mode 100644 index 000000000..13c4f8b50 --- /dev/null +++ b/webapp/components/backstage/backstage_sidebar.jsx @@ -0,0 +1,68 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import BackstageCategory from './backstage_category.jsx'; +import BackstageSection from './backstage_section.jsx'; +import {FormattedMessage} from 'react-intl'; + +export default class BackstageSidebar extends React.Component { + render() { + return ( + <div className='backstage-sidebar'> + <ul> + <BackstageCategory + name='integrations' + parentLink={'/settings'} + icon='fa-link' + title={ + <FormattedMessage + id='backstage_sidebar.integrations' + defaultMessage='Integrations' + /> + } + > + <BackstageSection + name='installed' + title={( + <FormattedMessage + id='backstage_sidebar.integrations.installed' + defaultMessage='Installed Integrations' + /> + )} + /> + <BackstageSection + name='add' + title={( + <FormattedMessage + id='backstage_sidebar.integrations.add' + defaultMessage='Add Integration' + /> + )} + > + <BackstageSection + name='incoming_webhook' + title={( + <FormattedMessage + id='backstage_sidebar.integrations.add.incomingWebhook' + defaultMessage='Incoming Webhook' + /> + )} + /> + <BackstageSection + name='outgoing_webhook' + title={( + <FormattedMessage + id='backstage_sidebar.integrations.add.outgoingWebhook' + defaultMessage='Outgoing Webhook' + /> + )} + /> + </BackstageSection> + </BackstageCategory> + </ul> + </div> + ); + } +} diff --git a/webapp/components/backstage/installed_incoming_webhook.jsx b/webapp/components/backstage/installed_incoming_webhook.jsx new file mode 100644 index 000000000..f65cf6327 --- /dev/null +++ b/webapp/components/backstage/installed_incoming_webhook.jsx @@ -0,0 +1,71 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import ChannelStore from 'stores/channel_store.jsx'; +import * as Utils from 'utils/utils.jsx'; + +import {FormattedMessage} from 'react-intl'; + +export default class InstalledIncomingWebhook extends React.Component { + static get propTypes() { + return { + incomingWebhook: React.PropTypes.object.isRequired, + onDeleteClick: React.PropTypes.func.isRequired + }; + } + + constructor(props) { + super(props); + + this.handleDeleteClick = this.handleDeleteClick.bind(this); + } + + handleDeleteClick(e) { + e.preventDefault(); + + this.props.onDeleteClick(this.props.incomingWebhook); + } + + render() { + const incomingWebhook = this.props.incomingWebhook; + + const channel = ChannelStore.get(incomingWebhook.channel_id); + const channelName = channel ? channel.display_name : 'cannot find channel'; + + return ( + <div className='backstage-list__item'> + <div className='item-details'> + <div className='item-details__row'> + <span className='item-details__name'> + {channelName} + </span> + <span className='item-details__type'> + <FormattedMessage + id='installed_integrations.incomingWebhookType' + defaultMessage='(Incoming Webhook)' + /> + </span> + </div> + <div className='item-details__row'> + <span className='item-details__description'> + {Utils.getWindowLocationOrigin() + '/hooks/' + incomingWebhook.id} + </span> + </div> + </div> + <div className='item-actions'> + <a + href='#' + onClick={this.handleDeleteClick} + > + <FormattedMessage + id='installed_integrations.delete' + defaultMessage='Delete' + /> + </a> + </div> + </div> + ); + } +} diff --git a/webapp/components/backstage/installed_integrations.jsx b/webapp/components/backstage/installed_integrations.jsx new file mode 100644 index 000000000..fe84ae81a --- /dev/null +++ b/webapp/components/backstage/installed_integrations.jsx @@ -0,0 +1,293 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import * as AsyncClient from 'utils/async_client.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; +import IntegrationStore from 'stores/integration_store.jsx'; +import * as Utils from 'utils/utils.jsx'; + +import {FormattedMessage} from 'react-intl'; +import InstalledIncomingWebhook from './installed_incoming_webhook.jsx'; +import InstalledOutgoingWebhook from './installed_outgoing_webhook.jsx'; +import {Link} from 'react-router'; + +export default class InstalledIntegrations extends React.Component { + constructor(props) { + super(props); + + this.handleIntegrationChange = this.handleIntegrationChange.bind(this); + this.updateFilter = this.updateFilter.bind(this); + this.updateTypeFilter = this.updateTypeFilter.bind(this); + + this.deleteIncomingWebhook = this.deleteIncomingWebhook.bind(this); + this.regenOutgoingWebhookToken = this.regenOutgoingWebhookToken.bind(this); + this.deleteOutgoingWebhook = this.deleteOutgoingWebhook.bind(this); + + this.state = { + incomingWebhooks: [], + outgoingWebhooks: [], + typeFilter: '', + filter: '' + }; + } + + componentWillMount() { + IntegrationStore.addChangeListener(this.handleIntegrationChange); + + if (window.mm_config.EnableIncomingWebhooks === 'true') { + if (IntegrationStore.hasReceivedIncomingWebhooks()) { + this.setState({ + incomingWebhooks: IntegrationStore.getIncomingWebhooks() + }); + } else { + AsyncClient.listIncomingHooks(); + } + } + + if (window.mm_config.EnableOutgoingWebhooks === 'true') { + if (IntegrationStore.hasReceivedOutgoingWebhooks()) { + this.setState({ + outgoingWebhooks: IntegrationStore.getOutgoingWebhooks() + }); + } else { + AsyncClient.listOutgoingHooks(); + } + } + } + + componentWillUnmount() { + IntegrationStore.removeChangeListener(this.handleIntegrationChange); + } + + handleIntegrationChange() { + this.setState({ + incomingWebhooks: IntegrationStore.getIncomingWebhooks(), + outgoingWebhooks: IntegrationStore.getOutgoingWebhooks() + }); + } + + updateTypeFilter(e, typeFilter) { + e.preventDefault(); + + this.setState({ + typeFilter + }); + } + + updateFilter(e) { + this.setState({ + filter: e.target.value + }); + } + + deleteIncomingWebhook(incomingWebhook) { + AsyncClient.deleteIncomingHook(incomingWebhook.id); + } + + regenOutgoingWebhookToken(outgoingWebhook) { + AsyncClient.regenOutgoingHookToken(outgoingWebhook.id); + } + + deleteOutgoingWebhook(outgoingWebhook) { + AsyncClient.deleteOutgoingHook(outgoingWebhook.id); + } + + renderTypeFilters(incomingWebhooks, outgoingWebhooks) { + const fields = []; + + if (incomingWebhooks.length > 0 || outgoingWebhooks.length > 0) { + let filterClassName = 'filter-sort'; + if (this.state.typeFilter === '') { + filterClassName += ' filter-sort--active'; + } + + fields.push( + <a + key='allFilter' + className={filterClassName} + href='#' + onClick={(e) => this.updateTypeFilter(e, '')} + > + <FormattedMessage + id='installed_integrations.allFilter' + defaultMessage='All ({count})' + values={{ + count: incomingWebhooks.length + outgoingWebhooks.length + }} + /> + </a> + ); + } + + if (incomingWebhooks.length > 0) { + fields.push( + <span + key='incomingWebhooksDivider' + className='divider' + > + {'|'} + </span> + ); + + let filterClassName = 'filter-sort'; + if (this.state.typeFilter === 'incomingWebhooks') { + filterClassName += ' filter-sort--active'; + } + + fields.push( + <a + key='incomingWebhooksFilter' + className={filterClassName} + href='#' + onClick={(e) => this.updateTypeFilter(e, 'incomingWebhooks')} + > + <FormattedMessage + id='installed_integrations.incomingWebhooksFilter' + defaultMessage='Incoming Webhooks ({count})' + values={{ + count: incomingWebhooks.length + }} + /> + </a> + ); + } + + if (outgoingWebhooks.length > 0) { + fields.push( + <span + key='outgoingWebhooksDivider' + className='divider' + > + {'|'} + </span> + ); + + let filterClassName = 'filter-sort'; + if (this.state.typeFilter === 'outgoingWebhooks') { + filterClassName += ' filter-sort--active'; + } + + fields.push( + <a + key='outgoingWebhooksFilter' + className={filterClassName} + href='#' + onClick={(e) => this.updateTypeFilter(e, 'outgoingWebhooks')} + > + <FormattedMessage + id='installed_integrations.outgoingWebhooksFilter' + defaultMessage='Outgoing Webhooks ({count})' + values={{ + count: outgoingWebhooks.length + }} + /> + </a> + ); + } + + return ( + <div className='backstage-filters__sort'> + {fields} + </div> + ); + } + + render() { + const incomingWebhooks = this.state.incomingWebhooks; + const outgoingWebhooks = this.state.outgoingWebhooks; + + const filter = this.state.filter.toLowerCase(); + + const integrations = []; + if (!this.state.typeFilter || this.state.typeFilter === 'incomingWebhooks') { + for (const incomingWebhook of incomingWebhooks) { + if (filter) { + const channel = ChannelStore.get(incomingWebhook.channel_id); + + if (!channel || channel.name.toLowerCase().indexOf(filter) === -1) { + continue; + } + } + + integrations.push( + <InstalledIncomingWebhook + key={incomingWebhook.id} + incomingWebhook={incomingWebhook} + onDeleteClick={this.deleteIncomingWebhook} + /> + ); + } + } + + if (!this.state.typeFilter || this.state.typeFilter === 'outgoingWebhooks') { + for (const outgoingWebhook of outgoingWebhooks) { + if (filter) { + const channel = ChannelStore.get(outgoingWebhook.channel_id); + + if (!channel || channel.name.toLowerCase().indexOf(filter) === -1) { + continue; + } + } + + integrations.push( + <InstalledOutgoingWebhook + key={outgoingWebhook.id} + outgoingWebhook={outgoingWebhook} + onRegenToken={this.regenOutgoingWebhookToken} + onDelete={this.deleteOutgoingWebhook} + /> + ); + } + } + + return ( + <div className='backstage-content row'> + <div className='installed-integrations'> + <div className='backstage-header'> + <h1> + <FormattedMessage + id='installed_integrations.header' + defaultMessage='Installed Integrations' + /> + </h1> + <Link + className='add-integrations-link' + to={'/settings/integrations/add'} + > + <button + type='button' + className='btn btn-primary' + > + <span> + <FormattedMessage + id='installed_integrations.add' + defaultMessage='Add Integration' + /> + </span> + </button> + </Link> + </div> + <div className='backstage-filters'> + {this.renderTypeFilters(this.state.incomingWebhooks, this.state.outgoingWebhooks)} + <div className='backstage-filter__search'> + <i className='fa fa-search'></i> + <input + type='search' + className='form-control' + placeholder={Utils.localizeMessage('installed_integrations.search', 'Search Integrations')} + value={this.state.filter} + onChange={this.updateFilter} + style={{flexGrow: 0, flexShrink: 0}} + /> + </div> + </div> + <div className='backstage-list'> + {integrations} + </div> + </div> + </div> + ); + } +} diff --git a/webapp/components/backstage/installed_outgoing_webhook.jsx b/webapp/components/backstage/installed_outgoing_webhook.jsx new file mode 100644 index 000000000..fee427260 --- /dev/null +++ b/webapp/components/backstage/installed_outgoing_webhook.jsx @@ -0,0 +1,91 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import ChannelStore from 'stores/channel_store.jsx'; +import * as Utils from 'utils/utils.jsx'; + +import {FormattedMessage} from 'react-intl'; + +export default class InstalledOutgoingWebhook extends React.Component { + static get propTypes() { + return { + outgoingWebhook: React.PropTypes.object.isRequired, + onRegenToken: React.PropTypes.func.isRequired, + onDelete: React.PropTypes.func.isRequired + }; + } + + constructor(props) { + super(props); + + this.handleRegenToken = this.handleRegenToken.bind(this); + this.handleDelete = this.handleDelete.bind(this); + } + + handleRegenToken(e) { + e.preventDefault(); + + this.props.onRegenToken(this.props.outgoingWebhook); + } + + handleDelete(e) { + e.preventDefault(); + + this.props.onDelete(this.props.outgoingWebhook); + } + + render() { + const outgoingWebhook = this.props.outgoingWebhook; + + const channel = ChannelStore.get(outgoingWebhook.channel_id); + const channelName = channel ? channel.display_name : 'cannot find channel'; + + return ( + <div className='backstage-list__item'> + <div className='item-details'> + <div className='item-details__row'> + <span className='item-details__name'> + {channelName} + </span> + <span className='item-details__type'> + <FormattedMessage + id='installed_integrations.outgoingWebhookType' + defaultMessage='(Outgoing Webhook)' + /> + </span> + </div> + <div className='item-details__row'> + <span className='item-details__description'> + {Utils.getWindowLocationOrigin() + '/hooks/' + outgoingWebhook.id} + {' - '} + {outgoingWebhook.token} + </span> + </div> + </div> + <div className='actions'> + <a + href='#' + onClick={this.handleRegenToken} + > + <FormattedMessage + id='installed_integrations.regenToken' + defaultMessage='Regen Token' + /> + </a> + {' - '} + <a + href='#' + onClick={this.handleDelete} + > + <FormattedMessage + id='installed_integrations.delete' + defaultMessage='Delete' + /> + </a> + </div> + </div> + ); + } +} diff --git a/webapp/components/center_panel.jsx b/webapp/components/center_panel.jsx deleted file mode 100644 index 62b12c1d2..000000000 --- a/webapp/components/center_panel.jsx +++ /dev/null @@ -1,145 +0,0 @@ -// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. -// See License.txt for license information. - -import TutorialIntroScreens from './tutorial/tutorial_intro_screens.jsx'; -import CreatePost from './create_post.jsx'; -import PostsViewContainer from './posts_view_container.jsx'; -import PostFocusView from './post_focus_view.jsx'; -import ChannelHeader from './channel_header.jsx'; -import Navbar from './navbar.jsx'; -import FileUploadOverlay from './file_upload_overlay.jsx'; - -import PreferenceStore from 'stores/preference_store.jsx'; -import ChannelStore from 'stores/channel_store.jsx'; -import UserStore from 'stores/user_store.jsx'; - -import * as Utils from 'utils/utils.jsx'; - -import {FormattedMessage} from 'react-intl'; - -import Constants from 'utils/constants.jsx'; -const TutorialSteps = Constants.TutorialSteps; -const Preferences = Constants.Preferences; - -import React from 'react'; -import {Link} from 'react-router'; - -export default class CenterPanel extends React.Component { - constructor(props) { - super(props); - - this.getStateFromStores = this.getStateFromStores.bind(this); - this.validState = this.validState.bind(this); - this.onStoresChange = this.onStoresChange.bind(this); - - this.state = this.getStateFromStores(); - } - getStateFromStores() { - const tutorialStep = PreferenceStore.getInt(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), 999); - return { - showTutorialScreens: tutorialStep <= TutorialSteps.INTRO_SCREENS, - showPostFocus: ChannelStore.getPostMode() === ChannelStore.POST_MODE_FOCUS, - user: UserStore.getCurrentUser(), - channel: ChannelStore.getCurrent(), - profiles: JSON.parse(JSON.stringify(UserStore.getProfiles())) - }; - } - validState() { - return this.state.user && this.state.channel && this.state.profiles; - } - onStoresChange() { - this.setState(this.getStateFromStores()); - } - componentDidMount() { - PreferenceStore.addChangeListener(this.onStoresChange); - ChannelStore.addChangeListener(this.onStoresChange); - UserStore.addChangeListener(this.onStoresChange); - } - componentWillUnmount() { - PreferenceStore.removeChangeListener(this.onStoresChange); - ChannelStore.removeChangeListener(this.onStoresChange); - UserStore.removeChangeListener(this.onStoresChange); - } - render() { - if (!this.validState()) { - return null; - } - const channel = this.state.channel; - var handleClick = null; - let postsContainer; - let createPost; - if (this.state.showTutorialScreens) { - postsContainer = <TutorialIntroScreens/>; - createPost = null; - } else if (this.state.showPostFocus) { - postsContainer = <PostFocusView profiles={this.state.profiles}/>; - - handleClick = function clickHandler(e) { - e.preventDefault(); - Utils.switchChannel(channel); - }; - - createPost = ( - <div - id='archive-link-home' - onClick={handleClick} - > - <Link to=''> - <FormattedMessage - id='center_panel.recent' - defaultMessage='Click here to jump to recent messages. ' - /> - <i className='fa fa-arrow-down'></i> - </Link> - </div> - ); - } else { - postsContainer = <PostsViewContainer profiles={this.state.profiles}/>; - createPost = ( - <div - className='post-create__container' - id='post-create' - > - <CreatePost/> - </div> - ); - } - - return ( - <div className='inner-wrap channel__wrap'> - <div className='row header'> - <div id='navbar'> - <Navbar/> - </div> - </div> - <div className='row main'> - <FileUploadOverlay - id='file_upload_overlay' - overlayType='center' - /> - <div - id='app-content' - className='app__content' - > - <div - id='channel-header' - className='channel-header' - > - <ChannelHeader - user={this.state.user} - /> - </div> - {postsContainer} - {createPost} - </div> - </div> - </div> - ); - } -} - -CenterPanel.defaultProps = { -}; - -CenterPanel.propTypes = { -}; diff --git a/webapp/components/channel_header.jsx b/webapp/components/channel_header.jsx index 369fa2dbb..482aabc01 100644 --- a/webapp/components/channel_header.jsx +++ b/webapp/components/channel_header.jsx @@ -26,6 +26,7 @@ import * as Utils from 'utils/utils.jsx'; import * as TextFormatting from 'utils/text_formatting.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; import * as Client from 'utils/client.jsx'; +import * as GlobalActions from 'action_creators/global_actions.jsx'; import Constants from 'utils/constants.jsx'; import {FormattedMessage} from 'react-intl'; @@ -53,11 +54,11 @@ export default class ChannelHeader extends React.Component { this.state = state; } getStateFromStores() { - const extraInfo = ChannelStore.getCurrentExtraInfo(); + const extraInfo = ChannelStore.getExtraInfo(this.props.channelId); return { - channel: ChannelStore.getCurrent(), - memberChannel: ChannelStore.getCurrentMember(), + channel: ChannelStore.get(this.props.channelId), + memberChannel: ChannelStore.getMember(this.props.channelId), users: extraInfo.members, userCount: extraInfo.member_count, searchVisible: SearchStore.getSearchResults() !== null, @@ -105,7 +106,7 @@ export default class ChannelHeader extends React.Component { }); const townsquare = ChannelStore.getByName('town-square'); - Utils.switchChannel(townsquare); + GlobalActions.emitChannelClickEvent(townsquare); }, (err) => { AsyncClient.dispatchError(err, 'handleLeave'); @@ -215,9 +216,9 @@ export default class ChannelHeader extends React.Component { if (!isDirect) { popoverListMembers = ( <PopoverListMembers + channel={channel} members={this.state.users} memberCount={this.state.userCount} - channelId={channel.id} /> ); } @@ -433,7 +434,10 @@ export default class ChannelHeader extends React.Component { } return ( - <div> + <div + id='channel-header' + className='channel-header' + > <table className='channel-header alt'> <tbody> <tr> @@ -518,4 +522,5 @@ export default class ChannelHeader extends React.Component { } ChannelHeader.propTypes = { + channelId: React.PropTypes.string.isRequired }; diff --git a/webapp/components/channel_invite_button.jsx b/webapp/components/channel_invite_button.jsx index e4af9f9ce..1fcd461ea 100644 --- a/webapp/components/channel_invite_button.jsx +++ b/webapp/components/channel_invite_button.jsx @@ -65,6 +65,7 @@ export default class ChannelInviteButton extends React.Component { render() { return ( <SpinnerButton + className='btn btn-sm btn-primary' onClick={this.handleClick} spinning={this.state.addingUser} > diff --git a/webapp/components/channel_notifications_modal.jsx b/webapp/components/channel_notifications_modal.jsx index cc1162b77..564776876 100644 --- a/webapp/components/channel_notifications_modal.jsx +++ b/webapp/components/channel_notifications_modal.jsx @@ -1,6 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import $ from 'jquery'; import {Modal} from 'react-bootstrap'; import SettingItemMin from './setting_item_min.jsx'; import SettingItemMax from './setting_item_max.jsx'; @@ -33,6 +34,7 @@ export default class ChannelNotificationsModal extends React.Component { }; } updateSection(section) { + $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update'); this.setState({activeSection: section}); } componentWillReceiveProps(nextProps) { diff --git a/webapp/components/channel_select.jsx b/webapp/components/channel_select.jsx new file mode 100644 index 000000000..8622d1f57 --- /dev/null +++ b/webapp/components/channel_select.jsx @@ -0,0 +1,79 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import Constants from 'utils/constants.jsx'; +import ChannelStore from 'stores/channel_store.jsx'; +import * as Utils from 'utils/utils.jsx'; + +export default class ChannelSelect extends React.Component { + static get propTypes() { + return { + onChange: React.PropTypes.func, + value: React.PropTypes.string + }; + } + + constructor(props) { + super(props); + + this.handleChannelChange = this.handleChannelChange.bind(this); + + this.state = { + channels: [] + }; + } + + componentWillMount() { + this.setState({ + channels: ChannelStore.getAll() + }); + + ChannelStore.addChangeListener(this.handleChannelChange); + } + + componentWillUnmount() { + ChannelStore.removeChangeListener(this.handleChannelChange); + } + + handleChannelChange() { + this.setState({ + channels: ChannelStore.getAll() + }); + } + + render() { + const options = [ + <option + key='' + value='' + > + {Utils.localizeMessage('channel_select.placeholder', '--- Select a channel ---')} + </option> + ]; + + this.state.channels.forEach((channel) => { + if (channel.type !== Constants.DM_CHANNEL) { + options.push( + <option + key={channel.id} + value={channel.id} + > + {channel.display_name} + </option> + ); + } + }); + + return ( + <select + className='form-control' + value={this.props.value} + onChange={this.props.onChange} + > + {options} + </select> + ); + } +} diff --git a/webapp/components/channel_view.jsx b/webapp/components/channel_view.jsx index 34e1666d0..4cca5aa98 100644 --- a/webapp/components/channel_view.jsx +++ b/webapp/components/channel_view.jsx @@ -1,14 +1,63 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import CenterPanel from 'components/center_panel.jsx'; - import React from 'react'; +import ChannelHeader from 'components/channel_header.jsx'; +import PostsViewContainer from 'components/posts_view_container.jsx'; +import CreatePost from 'components/create_post.jsx'; + +import ChannelStore from 'stores/channel_store.jsx'; + export default class ChannelView extends React.Component { + constructor(props) { + super(props); + + this.getStateFromStores = this.getStateFromStores.bind(this); + this.isStateValid = this.isStateValid.bind(this); + this.updateState = this.updateState.bind(this); + + this.state = this.getStateFromStores(props); + } + getStateFromStores(props) { + const channel = ChannelStore.getByName(props.params.channel); + const channelId = channel ? channel.id : ''; + return { + channelId + }; + } + isStateValid() { + return this.state.channelId !== ''; + } + updateState() { + this.setState(this.getStateFromStores(this.props)); + } + componentDidMount() { + ChannelStore.addChangeListener(this.updateState); + } + componentWillUnmount() { + ChannelStore.removeChangeListener(this.updateState); + } + componentWillReceiveProps(nextProps) { + this.setState(this.getStateFromStores(nextProps)); + } render() { return ( - <CenterPanel/> + <div + id='app-content' + className='app__content' + > + <ChannelHeader + channelId={this.state.channelId} + /> + <PostsViewContainer profiles={this.props.profiles}/> + <div + className='post-create__container' + id='post-create' + > + <CreatePost/> + </div> + </div> ); } } @@ -16,5 +65,6 @@ ChannelView.defaultProps = { }; ChannelView.propTypes = { - params: React.PropTypes.object + params: React.PropTypes.object.isRequired, + profiles: React.PropTypes.object }; diff --git a/webapp/components/claim/components/email_to_ldap.jsx b/webapp/components/claim/components/email_to_ldap.jsx index 1f51f9cd5..1ceb42a27 100644 --- a/webapp/components/claim/components/email_to_ldap.jsx +++ b/webapp/components/claim/components/email_to_ldap.jsx @@ -21,7 +21,7 @@ export default class EmailToLDAP extends React.Component { e.preventDefault(); var state = {}; - const password = ReactDOM.findDOMNode(this.refs.password).value.trim(); + const password = ReactDOM.findDOMNode(this.refs.emailpassword).value.trim(); if (!password) { state.error = Utils.localizeMessage('claim.email_to_ldap.pwdError', 'Please enter your password.'); this.setState(state); @@ -105,12 +105,18 @@ export default class EmailToLDAP extends React.Component { }} /> </p> + <input + type='text' + style={{display: 'none'}} + name='fakeusernameremembered' + /> <div className={formClass}> <input type='password' className='form-control' - name='password' - ref='password' + name='emailPassword' + ref='emailpassword' + autoComplete='off' placeholder={Utils.localizeMessage('claim.email_to_ldap.pwd', 'Password')} spellCheck='false' /> @@ -131,6 +137,7 @@ export default class EmailToLDAP extends React.Component { className='form-control' name='ldapId' ref='ldapid' + autoComplete='off' placeholder={Utils.localizeMessage('claim.email_to_ldap.ldapId', 'LDAP ID')} spellCheck='false' /> @@ -141,6 +148,7 @@ export default class EmailToLDAP extends React.Component { className='form-control' name='ldapPassword' ref='ldappassword' + autoComplete='off' placeholder={Utils.localizeMessage('claim.email_to_ldap.ldapPwd', 'LDAP Password')} spellCheck='false' /> diff --git a/webapp/components/error_page.jsx b/webapp/components/error_page.jsx new file mode 100644 index 000000000..53f0fce82 --- /dev/null +++ b/webapp/components/error_page.jsx @@ -0,0 +1,58 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import $ from 'jquery'; + +import React from 'react'; +import {Link} from 'react-router'; + +import * as Utils from 'utils/utils.jsx'; + +export default class ErrorPage extends React.Component { + componentDidMount() { + $('body').attr('class', 'sticky error'); + } + componentWillUnmount() { + $('body').attr('class', ''); + } + render() { + let title = this.props.location.query.title; + if (!title || title === '') { + title = Utils.localizeMessage('error.generic.title', 'Error'); + } + + let message = this.props.location.query.message; + if (!message || message === '') { + message = Utils.localizeMessage('error.generic.message', 'An error has occoured.'); + } + + let link = this.props.location.query.link; + if (!link || link === '') { + link = '/'; + } + + let linkMessage = this.props.location.query.linkmessage; + if (!linkMessage || linkMessage === '') { + linkMessage = Utils.localizeMessage('error.generic.link_message', 'Back to Mattermost'); + } + + return ( + <div className='container-fluid'> + <div className='error__container'> + <div className='error__icon'> + <i className='fa fa-exclamation-triangle'/> + </div> + <h2>{title}</h2> + <p>{message}</p> + <Link to={link}>{linkMessage}</Link> + </div> + </div> + ); + } +} + +ErrorPage.defaultProps = { +}; +ErrorPage.propTypes = { + location: React.PropTypes.object +}; diff --git a/webapp/components/form_error.jsx b/webapp/components/form_error.jsx new file mode 100644 index 000000000..b7d1de16a --- /dev/null +++ b/webapp/components/form_error.jsx @@ -0,0 +1,50 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +export default class FormError extends React.Component { + static get propTypes() { + // accepts either a single error or an array of errors + return { + error: React.PropTypes.node, + errors: React.PropTypes.arrayOf(React.PropTypes.node) + }; + } + + static get defaultProps() { + return { + error: null, + errors: [] + }; + } + + render() { + if (!this.props.error && this.props.errors.length === 0) { + return null; + } + + // look for the first truthy error to display + let message = this.props.error; + + if (!message) { + for (const error of this.props.errors) { + if (error) { + message = error; + } + } + } + + if (!message) { + return null; + } + + return ( + <div className='form-group has-error'> + <label className='control-label'> + {message} + </label> + </div> + ); + } +} diff --git a/webapp/components/invite_member_modal.jsx b/webapp/components/invite_member_modal.jsx index 1f8fd6133..81c3a9629 100644 --- a/webapp/components/invite_member_modal.jsx +++ b/webapp/components/invite_member_modal.jsx @@ -50,6 +50,7 @@ class InviteMemberModal extends React.Component { constructor(props) { super(props); + this.teamChange = this.teamChange.bind(this); this.handleToggle = this.handleToggle.bind(this); this.handleSubmit = this.handleSubmit.bind(this); this.handleHide = this.handleHide.bind(this); @@ -68,16 +69,27 @@ class InviteMemberModal extends React.Component { emailEnabled: global.window.mm_config.SendEmailNotifications === 'true', userCreationEnabled: global.window.mm_config.EnableUserCreation === 'true', showConfirmModal: false, - isSendingEmails: false + isSendingEmails: false, + teamType: null }; } + teamChange() { + const team = TeamStore.getCurrent(); + const teamType = team ? team.type : null; + this.setState({ + teamType + }); + } + componentDidMount() { ModalStore.addModalListener(ActionTypes.TOGGLE_INVITE_MEMBER_MODAL, this.handleToggle); + TeamStore.addChangeListener(this.teamChange); } componentWillUnmount() { ModalStore.removeModalListener(ActionTypes.TOGGLE_INVITE_MEMBER_MODAL, this.handleToggle); + TeamStore.removeChangeListener(this.teamChange); } handleToggle(value) { @@ -224,7 +236,7 @@ class InviteMemberModal extends React.Component { var currentUser = UserStore.getCurrentUser(); const {formatMessage} = this.props.intl; - if (currentUser != null) { + if (currentUser != null && this.state.teamType != null) { var inviteSections = []; var inviteIds = this.state.inviteIds; for (var i = 0; i < inviteIds.length; i++) { @@ -398,7 +410,7 @@ class InviteMemberModal extends React.Component { ); } else if (this.state.userCreationEnabled) { var teamInviteLink = null; - if (currentUser && TeamStore.getCurrent().type === 'O') { + if (currentUser && this.state.teamType === 'O') { var link = ( <a href='#' diff --git a/webapp/components/logged_in.jsx b/webapp/components/logged_in.jsx index c6f7b50b1..0c4571083 100644 --- a/webapp/components/logged_in.jsx +++ b/webapp/components/logged_in.jsx @@ -10,13 +10,17 @@ import BrowserStore from 'stores/browser_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; +const TutorialSteps = Constants.TutorialSteps; +const Preferences = Constants.Preferences; import ErrorBar from 'components/error_bar.jsx'; import * as Websockets from 'action_creators/websocket_actions.jsx'; +import LoadingScreen from 'components/loading_screen.jsx'; import {browserHistory} from 'react-router'; import SidebarRight from 'components/sidebar_right.jsx'; import SidebarRightMenu from 'components/sidebar_right_menu.jsx'; +import Navbar from 'components/navbar.jsx'; // Modals import GetPostLinkModal from 'components/get_post_link_modal.jsx'; @@ -41,6 +45,14 @@ export default class LoggedIn extends React.Component { super(params); this.onUserChanged = this.onUserChanged.bind(this); + + this.state = { + user: null, + profiles: null + }; + } + isValidState() { + return this.state.user != null && this.state.profiles != null; } onUserChanged() { // Grab the current user @@ -66,6 +78,20 @@ export default class LoggedIn extends React.Component { Utils.applyTheme(Constants.THEMES.default); } } + + // Go to tutorial if we are first arrivign + const tutorialStep = PreferenceStore.getInt(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), 999); + if (tutorialStep <= TutorialSteps.INTRO_SCREENS) { + browserHistory.push(Utils.getTeamURLFromAddressBar() + '/tutorial'); + } + + // Get profiles + const profiles = UserStore.getProfiles(); + + this.setState({ + user, + profiles + }); } componentWillMount() { // Emit view action @@ -177,6 +203,8 @@ export default class LoggedIn extends React.Component { Websockets.close(); UserStore.removeChangeListener(this.onUserChanged); + Utils.resetTheme(); + $('body').off('click.userpopover'); $('body').off('mouseenter mouseleave', '.post'); $('body').off('mouseenter mouseleave', '.post.post--comment.same--root'); @@ -186,14 +214,46 @@ export default class LoggedIn extends React.Component { $(window).off('keydown.preventBackspace'); } render() { + if (!this.isValidState()) { + return <LoadingScreen/>; + } + + let content = []; + if (this.props.children) { + content = this.props.children; + } else { + content.push( + this.props.navbar + ); + content.push( + this.props.sidebar + ); + content.push( + <div + key='inner-wrap' + className='inner-wrap channel__wrap' + > + <div className='row header'> + <div id='navbar'> + <Navbar/> + </div> + </div> + <div className='row main'> + {React.cloneElement(this.props.center, { + user: this.state.user, + profiles: this.state.profiles + })} + </div> + </div> + ); + } return ( <div className='channel-view'> <ErrorBar/> <div className='container-fluid'> <SidebarRight/> <SidebarRightMenu/> - {this.props.sidebar} - {this.props.center} + {content} <GetPostLinkModal/> <GetTeamInviteLinkModal/> @@ -216,8 +276,12 @@ LoggedIn.defaultProps = { }; LoggedIn.propTypes = { - children: React.PropTypes.object, - sidebar: React.PropTypes.object, - center: React.PropTypes.object, + children: React.PropTypes.oneOfType([ + React.PropTypes.arrayOf(React.PropTypes.element), + React.PropTypes.element + ]), + navbar: React.PropTypes.element, + sidebar: React.PropTypes.element, + center: React.PropTypes.element, params: React.PropTypes.object }; diff --git a/webapp/components/login_email.jsx b/webapp/components/login/components/login_email.jsx index d54c32ff9..b1f484c08 100644 --- a/webapp/components/login_email.jsx +++ b/webapp/components/login/components/login_email.jsx @@ -2,69 +2,40 @@ // See License.txt for license information. import * as Utils from 'utils/utils.jsx'; -import * as Client from 'utils/client.jsx'; import UserStore from 'stores/user_store.jsx'; -import {browserHistory} from 'react-router'; +import Constants from 'utils/constants.jsx'; -import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'react-intl'; - -var holders = defineMessages({ - badTeam: { - id: 'login_email.badTeam', - defaultMessage: 'Bad team name' - }, - emailReq: { - id: 'login_email.emailReq', - defaultMessage: 'An email is required' - }, - pwdReq: { - id: 'login_email.pwdReq', - defaultMessage: 'A password is required' - }, - email: { - id: 'login_email.email', - defaultMessage: 'Email' - }, - pwd: { - id: 'login_email.pwd', - defaultMessage: 'Password' - } -}); +import {FormattedMessage} from 'react-intl'; import React from 'react'; -class LoginEmail extends React.Component { +export default class LoginEmail extends React.Component { constructor(props) { super(props); this.handleSubmit = this.handleSubmit.bind(this); this.state = { - serverError: '' + serverError: props.serverError }; } + componentWillReceiveProps(nextProps) { + this.setState({serverError: nextProps.serverError}); + } handleSubmit(e) { e.preventDefault(); - const {formatMessage} = this.props.intl; var state = {}; - const name = this.props.teamName; - if (!name) { - state.serverError = formatMessage(holders.badTeam); - this.setState(state); - return; - } - const email = this.refs.email.value.trim(); if (!email) { - state.serverError = formatMessage(holders.emailReq); + state.serverError = Utils.localizeMessage('login_email.emailReq', 'An email is required'); this.setState(state); return; } const password = this.refs.password.value.trim(); if (!password) { - state.serverError = formatMessage(holders.pwdReq); + state.serverError = Utils.localizeMessage('login_email.pwdReq', 'A password is required'); this.setState(state); return; } @@ -72,21 +43,7 @@ class LoginEmail extends React.Component { state.serverError = ''; this.setState(state); - Client.loginByEmail(name, email, password, - () => { - UserStore.setLastEmail(email); - browserHistory.push('/' + name + '/channels/town-square'); - }, - (err) => { - if (err.id === 'api.user.login.not_verified.app_error') { - browserHistory.push('/verify_email?teamname=' + encodeURIComponent(name) + '&email=' + encodeURIComponent(email)); - return; - } - state.serverError = err.message; - this.valid = false; - this.setState(state); - } - ); + this.props.submit(Constants.EMAIL_SERVICE, email, password); } render() { let serverError; @@ -110,7 +67,6 @@ class LoginEmail extends React.Component { priorEmail = decodeURIComponent(emailParam); } - const {formatMessage} = this.props.intl; return ( <form onSubmit={this.handleSubmit}> <div className='signup__email-container'> @@ -125,7 +81,7 @@ class LoginEmail extends React.Component { name='email' defaultValue={priorEmail} ref='email' - placeholder={formatMessage(holders.email)} + placeholder={Utils.localizeMessage('login_email.email', 'Email')} spellCheck='false' /> </div> @@ -136,7 +92,7 @@ class LoginEmail extends React.Component { className='form-control' name='password' ref='password' - placeholder={formatMessage(holders.pwd)} + placeholder={Utils.localizeMessage('login_email.pwd', 'Password')} spellCheck='false' /> </div> @@ -160,8 +116,6 @@ LoginEmail.defaultProps = { }; LoginEmail.propTypes = { - intl: intlShape.isRequired, - teamName: React.PropTypes.string.isRequired + submit: React.PropTypes.func.isRequired, + serverError: React.PropTypes.string }; - -export default injectIntl(LoginEmail); diff --git a/webapp/components/login_ldap.jsx b/webapp/components/login/components/login_ldap.jsx index 59ff973dc..a2013710f 100644 --- a/webapp/components/login_ldap.jsx +++ b/webapp/components/login/components/login_ldap.jsx @@ -2,68 +2,39 @@ // See License.txt for license information. import * as Utils from 'utils/utils.jsx'; -import * as Client from 'utils/client.jsx'; +import Constants from 'utils/constants.jsx'; -import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'react-intl'; -import {browserHistory} from 'react-router'; - -const holders = defineMessages({ - badTeam: { - id: 'login_ldap.badTeam', - defaultMessage: 'Bad team name' - }, - idReq: { - id: 'login_ldap.idlReq', - defaultMessage: 'An LDAP ID is required' - }, - pwdReq: { - id: 'login_ldap.pwdReq', - defaultMessage: 'An LDAP password is required' - }, - username: { - id: 'login_ldap.username', - defaultMessage: 'LDAP Username' - }, - pwd: { - id: 'login_ldap.pwd', - defaultMessage: 'LDAP Password' - } -}); +import {FormattedMessage} from 'react-intl'; import React from 'react'; -class LoginLdap extends React.Component { +export default class LoginLdap extends React.Component { constructor(props) { super(props); this.handleSubmit = this.handleSubmit.bind(this); this.state = { - serverError: '' + serverError: props.serverError }; } + componentWillReceiveProps(nextProps) { + this.setState({serverError: nextProps.serverError}); + } handleSubmit(e) { e.preventDefault(); - const {formatMessage} = this.props.intl; - var state = {}; - - const teamName = this.props.teamName; - if (!teamName) { - state.serverError = formatMessage(holders.badTeam); - this.setState(state); - return; - } + const state = {}; const id = this.refs.id.value.trim(); if (!id) { - state.serverError = formatMessage(holders.idReq); + state.serverError = Utils.localizeMessage('login_ldap.idlReq', 'An LDAP ID is required'); this.setState(state); return; } const password = this.refs.password.value.trim(); if (!password) { - state.serverError = formatMessage(holders.pwdReq); + state.serverError = Utils.localizeMessage('login_ldap.pwdReq', 'An LDAP password is required'); this.setState(state); return; } @@ -71,20 +42,7 @@ class LoginLdap extends React.Component { state.serverError = ''; this.setState(state); - Client.loginByLdap(teamName, id, password, - () => { - const redirect = Utils.getUrlParameter('redirect'); - if (redirect) { - browserHistory.push(decodeURIComponent(redirect)); - } else { - browserHistory.push('/' + teamName + '/channels/town-square'); - } - }, - (err) => { - state.serverError = err.message; - this.setState(state); - } - ); + this.props.submit(Constants.LDAP_SERVICE, id, password); } render() { let serverError; @@ -93,7 +51,7 @@ class LoginLdap extends React.Component { serverError = <label className='control-label'>{this.state.serverError}</label>; errorClass = ' has-error'; } - const {formatMessage} = this.props.intl; + return ( <form onSubmit={this.handleSubmit}> <div className='signup__email-container'> @@ -105,7 +63,7 @@ class LoginLdap extends React.Component { autoFocus={true} className='form-control' ref='id' - placeholder={formatMessage(holders.username)} + placeholder={Utils.localizeMessage('login_ldap.username', 'LDAP Username')} spellCheck='false' /> </div> @@ -114,7 +72,7 @@ class LoginLdap extends React.Component { type='password' className='form-control' ref='password' - placeholder={formatMessage(holders.pwd)} + placeholder={Utils.localizeMessage('login_ldap.pwd', 'LDAP Password')} spellCheck='false' /> </div> @@ -138,8 +96,6 @@ LoginLdap.defaultProps = { }; LoginLdap.propTypes = { - intl: intlShape.isRequired, - teamName: React.PropTypes.string.isRequired + serverError: React.PropTypes.string, + submit: React.PropTypes.func.isRequired }; - -export default injectIntl(LoginLdap); diff --git a/webapp/components/login/components/login_mfa.jsx b/webapp/components/login/components/login_mfa.jsx new file mode 100644 index 000000000..f8ebf1e82 --- /dev/null +++ b/webapp/components/login/components/login_mfa.jsx @@ -0,0 +1,92 @@ +// Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import * as Utils from 'utils/utils.jsx'; + +import {FormattedMessage} from 'react-intl'; + +import React from 'react'; + +export default class LoginMfa extends React.Component { + constructor(props) { + super(props); + + this.handleSubmit = this.handleSubmit.bind(this); + + this.state = { + serverError: '' + }; + } + handleSubmit(e) { + e.preventDefault(); + const state = {}; + + const token = this.refs.token.value.trim(); + if (!token) { + state.serverError = Utils.localizeMessage('login_mfa.tokenReq', 'Please enter an MFA token'); + this.setState(state); + return; + } + + state.serverError = ''; + this.setState(state); + + this.props.submit(this.props.method, this.props.loginId, this.props.password, token); + } + render() { + let serverError; + let errorClass = ''; + if (this.state.serverError) { + serverError = <label className='control-label'>{this.state.serverError}</label>; + errorClass = ' has-error'; + } + + return ( + <form onSubmit={this.handleSubmit}> + <div className='signup__email-container'> + <p> + <FormattedMessage + id='login_mfa.enterToken' + defaultMessage="To complete the sign in process, please enter a token from your smartphone's authenticator" + /> + </p> + <div className={'form-group' + errorClass}> + {serverError} + </div> + <div className={'form-group' + errorClass}> + <input + type='text' + className='form-control' + name='token' + ref='token' + placeholder={Utils.localizeMessage('login_mfa.token', 'MFA Token')} + spellCheck='false' + autoComplete='off' + autoFocus={true} + /> + </div> + <div className='form-group'> + <button + type='submit' + className='btn btn-primary' + > + <FormattedMessage + id='login_mfa.submit' + defaultMessage='Submit' + /> + </button> + </div> + </div> + </form> + ); + } +} +LoginMfa.defaultProps = { +}; + +LoginMfa.propTypes = { + method: React.PropTypes.string.isRequired, + loginId: React.PropTypes.string.isRequired, + password: React.PropTypes.string.isRequired, + submit: React.PropTypes.func.isRequired +}; diff --git a/webapp/components/login_username.jsx b/webapp/components/login/components/login_username.jsx index 71874fa1a..3cb213994 100644 --- a/webapp/components/login_username.jsx +++ b/webapp/components/login/components/login_username.jsx @@ -2,42 +2,10 @@ // See License.txt for license information. import * as Utils from 'utils/utils.jsx'; -import * as Client from 'utils/client.jsx'; import UserStore from 'stores/user_store.jsx'; +import Constants from 'utils/constants.jsx'; -import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'react-intl'; -import {browserHistory} from 'react-router'; - -var holders = defineMessages({ - badTeam: { - id: 'login_username.badTeam', - defaultMessage: 'Bad team name' - }, - usernameReq: { - id: 'login_username.usernameReq', - defaultMessage: 'A username is required' - }, - pwdReq: { - id: 'login_username.pwdReq', - defaultMessage: 'A password is required' - }, - verifyEmailError: { - id: 'login_username.verifyEmailError', - defaultMessage: 'Please verify your email address. Check your inbox for an email.' - }, - userNotFoundError: { - id: 'login_username.userNotFoundError', - defaultMessage: "We couldn't find an existing account matching your username for this team." - }, - username: { - id: 'login_username.username', - defaultMessage: 'Username' - }, - pwd: { - id: 'login_username.pwd', - defaultMessage: 'Password' - } -}); +import {FormattedMessage} from 'react-intl'; import React from 'react'; @@ -48,31 +16,26 @@ export default class LoginUsername extends React.Component { this.handleSubmit = this.handleSubmit.bind(this); this.state = { - serverError: '' + serverError: props.serverError }; } + componentWillReceiveProps(nextProps) { + this.setState({serverError: nextProps.serverError}); + } handleSubmit(e) { e.preventDefault(); - const {formatMessage} = this.props.intl; - var state = {}; - - const name = this.props.teamName; - if (!name) { - state.serverError = formatMessage(holders.badTeam); - this.setState(state); - return; - } + const state = {}; const username = this.refs.username.value.trim(); if (!username) { - state.serverError = formatMessage(holders.usernameReq); + state.serverError = Utils.localizeMessage('login_username.usernameReq', 'A username is required'); this.setState(state); return; } const password = this.refs.password.value.trim(); if (!password) { - state.serverError = formatMessage(holders.pwdReq); + state.serverError = Utils.localizeMessage('login_username.pwdReq', 'A password is required'); this.setState(state); return; } @@ -80,30 +43,7 @@ export default class LoginUsername extends React.Component { state.serverError = ''; this.setState(state); - Client.loginByUsername(name, username, password, - () => { - UserStore.setLastUsername(username); - - const redirect = Utils.getUrlParameter('redirect'); - if (redirect) { - browserHistory.push(decodeURIComponent(redirect)); - } else { - browserHistory.push('/' + name + '/channels/town-square'); - } - }, - (err) => { - if (err.id === 'api.user.login.not_verified.app_error') { - state.serverError = formatMessage(holders.verifyEmailError); - } else if (err.id === 'store.sql_user.get_by_username.app_error') { - state.serverError = formatMessage(holders.userNotFoundError); - } else { - state.serverError = err.message; - } - - this.valid = false; - this.setState(state); - } - ); + this.props.submit(Constants.USERNAME_SERVICE, username, password); } render() { let serverError; @@ -127,7 +67,6 @@ export default class LoginUsername extends React.Component { priorUsername = decodeURIComponent(emailParam); } - const {formatMessage} = this.props.intl; return ( <form onSubmit={this.handleSubmit}> <div className='signup__email-container'> @@ -142,7 +81,7 @@ export default class LoginUsername extends React.Component { name='username' defaultValue={priorUsername} ref='username' - placeholder={formatMessage(holders.username)} + placeholder={Utils.localizeMessage('login_username.username', 'Username')} spellCheck='false' /> </div> @@ -153,7 +92,7 @@ export default class LoginUsername extends React.Component { className='form-control' name='password' ref='password' - placeholder={formatMessage(holders.pwd)} + placeholder={Utils.localizeMessage('login_username.pwd', 'Password')} spellCheck='false' /> </div> @@ -177,8 +116,6 @@ LoginUsername.defaultProps = { }; LoginUsername.propTypes = { - intl: intlShape.isRequired, - teamName: React.PropTypes.string.isRequired + serverError: React.PropTypes.string, + submit: React.PropTypes.func.isRequired }; - -export default injectIntl(LoginUsername); diff --git a/webapp/components/login.jsx b/webapp/components/login/login.jsx index e8180895d..e867af47a 100644 --- a/webapp/components/login.jsx +++ b/webapp/components/login/login.jsx @@ -1,14 +1,17 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import LoginEmail from './login_email.jsx'; -import LoginUsername from './login_username.jsx'; -import LoginLdap from './login_ldap.jsx'; +import LoginEmail from './components/login_email.jsx'; +import LoginUsername from './components/login_username.jsx'; +import LoginLdap from './components/login_ldap.jsx'; +import LoginMfa from './components/login_mfa.jsx'; + +import TeamStore from 'stores/team_store.jsx'; +import UserStore from 'stores/user_store.jsx'; -import * as Utils from 'utils/utils.jsx'; import * as Client from 'utils/client.jsx'; +import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; -import TeamStore from 'stores/team_store.jsx'; import {FormattedMessage} from 'react-intl'; import {browserHistory, Link} from 'react-router'; @@ -21,8 +24,12 @@ export default class Login extends React.Component { this.getStateFromStores = this.getStateFromStores.bind(this); this.onTeamChange = this.onTeamChange.bind(this); + this.preSubmit = this.preSubmit.bind(this); + this.submit = this.submit.bind(this); - this.state = this.getStateFromStores(); + const state = this.getStateFromStores(); + state.doneCheckLogin = false; + this.state = state; } componentDidMount() { TeamStore.addChangeListener(this.onTeamChange); @@ -39,61 +46,95 @@ export default class Login extends React.Component { } getStateFromStores() { return { - currentTeam: TeamStore.getByName(this.props.params.team), - doneCheckLogin: false + currentTeam: TeamStore.getByName(this.props.params.team) }; } onTeamChange() { this.setState(this.getStateFromStores()); } - render() { - const currentTeam = this.state.currentTeam; - if (currentTeam == null || !this.state.doneCheckLogin) { - return <div/>; + preSubmit(method, loginId, password) { + if (global.window.mm_config.EnableMultifactorAuthentication !== 'true') { + this.submit(method, loginId, password, ''); + return; } - const teamDisplayName = currentTeam.display_name; - const teamName = currentTeam.name; - const ldapEnabled = global.window.mm_config.EnableLdap === 'true'; - const usernameSigninEnabled = global.window.mm_config.EnableSignInWithUsername === 'true'; + Client.checkMfa(method, this.state.currentTeam.name, loginId, + (data) => { + if (data.mfa_required === 'true') { + this.setState({showMfa: true, method, loginId, password}); + } else { + this.submit(method, loginId, password, ''); + } + }, + (err) => { + if (method === Constants.EMAIL_SERVICE) { + this.setState({serverEmailError: err.message}); + } else if (method === Constants.USERNAME_SERVICE) { + this.setState({serverUsernameError: err.message}); + } else if (method === Constants.LDAP_SERVICE) { + this.setState({serverLdapError: err.message}); + } + } + ); + } + submit(method, loginId, password, token) { + this.setState({showMfa: false, serverEmailError: null, serverUsernameError: null, serverLdapError: null}); - let loginMessage = []; - if (global.window.mm_config.EnableSignUpWithGitLab === 'true') { - loginMessage.push( - <Link - className='btn btn-custom-login gitlab' - key='gitlab' - to={'/api/v1/oauth/gitlab/login?team=' + encodeURIComponent(teamName)} - > - <span className='icon'/> - <span> - <FormattedMessage - id='login.gitlab' - defaultMessage='with GitLab' - /> - </span> - </Link> + const team = this.state.currentTeam.name; + + if (method === Constants.EMAIL_SERVICE) { + Client.loginByEmail(team, loginId, password, token, + () => { + UserStore.setLastEmail(loginId); + browserHistory.push('/' + team + '/channels/town-square'); + }, + (err) => { + if (err.id === 'api.user.login.not_verified.app_error') { + browserHistory.push('/verify_email?teamname=' + encodeURIComponent(name) + '&email=' + encodeURIComponent(loginId)); + return; + } + this.setState({serverEmailError: err.message}); + } ); - } + } else if (method === Constants.USERNAME_SERVICE) { + Client.loginByUsername(team, loginId, password, token, + () => { + UserStore.setLastUsername(loginId); - if (global.window.mm_config.EnableSignUpWithGoogle === 'true') { - loginMessage.push( - <Link - className='btn btn-custom-login google' - key='google' - to={'/api/v1/oauth/google/login?team=' + encodeURIComponent(teamName)} - > - <span className='icon'/> - <span> - <FormattedMessage - id='login.google' - defaultMessage='with Google Apps' - /> - </span> - </Link> + const redirect = Utils.getUrlParameter('redirect'); + if (redirect) { + browserHistory.push(decodeURIComponent(redirect)); + } else { + browserHistory.push('/' + team + '/channels/town-square'); + } + }, + (err) => { + if (err.id === 'api.user.login.not_verified.app_error') { + this.setState({serverUsernameError: Utils.localizeMessage('login_username.verifyEmailError', 'Please verify your email address. Check your inbox for an email.')}); + } else if (err.id === 'store.sql_user.get_by_username.app_error') { + this.setState({serverUsernameError: Utils.localizeMessage('login_username.userNotFoundError', 'We couldn\'t find an existing account matching your username for this team.')}); + } else { + this.setState({serverUsernameError: err.message}); + } + } + ); + } else if (method === Constants.LDAP_SERVICE) { + Client.loginByLdap(team, loginId, password, token, + () => { + const redirect = Utils.getUrlParameter('redirect'); + if (redirect) { + browserHistory.push(decodeURIComponent(redirect)); + } else { + browserHistory.push('/' + team + '/channels/town-square'); + } + }, + (err) => { + this.setState({serverLdapError: err.message}); + } ); } - + } + createLoginOptions(currentTeam) { const extraParam = Utils.getUrlParameter('extra'); let extraBox = ''; if (extraParam) { @@ -130,44 +171,126 @@ export default class Login extends React.Component { } } - let emailSignup; - if (global.window.mm_config.EnableSignInWithEmail === 'true') { - emailSignup = ( - <LoginEmail - teamName={teamName} - /> + const teamName = currentTeam.name; + const ldapEnabled = global.window.mm_config.EnableLdap === 'true'; + const gitlabSigninEnabled = global.window.mm_config.EnableSignUpWithGitLab === 'true'; + const googleSigninEnabled = global.window.mm_config.EnableSignUpWithGoogle === 'true'; + const usernameSigninEnabled = global.window.mm_config.EnableSignInWithUsername === 'true'; + const emailSigninEnabled = global.window.mm_config.EnableSignInWithEmail === 'true'; + + const oauthLogins = []; + if (gitlabSigninEnabled) { + oauthLogins.push( + <Link + className='btn btn-custom-login gitlab' + key='gitlab' + to={'/api/v1/oauth/gitlab/login?team=' + encodeURIComponent(teamName)} + > + <span className='icon'/> + <span> + <FormattedMessage + id='login.gitlab' + defaultMessage='with GitLab' + /> + </span> + </Link> ); } - if (loginMessage.length > 0 && emailSignup) { - loginMessage = ( - <div> - {loginMessage} - <div className='or__container'> + if (googleSigninEnabled) { + oauthLogins.push( + <Link + className='btn btn-custom-login google' + key='google' + to={'/api/v1/oauth/google/login?team=' + encodeURIComponent(teamName)} + > + <span className='icon'/> + <span> <FormattedMessage - id='login.or' - defaultMessage='or' + id='login.google' + defaultMessage='with Google Apps' /> + </span> + </Link> + ); + } + + let emailLogin; + if (emailSigninEnabled) { + emailLogin = ( + <LoginEmail + teamName={teamName} + serverError={this.state.serverEmailError} + submit={this.preSubmit} + /> + ); + + if (oauthLogins.length > 0) { + emailLogin = ( + <div> + <div className='or__container'> + <FormattedMessage + id='login.or' + defaultMessage='or' + /> + </div> + {emailLogin} </div> - </div> + ); + } + } + + let usernameLogin; + if (usernameSigninEnabled) { + usernameLogin = ( + <LoginUsername + teamName={teamName} + serverError={this.state.serverUsernameError} + submit={this.preSubmit} + /> ); + + if (emailSigninEnabled || oauthLogins.length > 0) { + usernameLogin = ( + <div> + <div className='or__container'> + <FormattedMessage + id='login.or' + defaultMessage='or' + /> + </div> + {usernameLogin} + </div> + ); + } } - let forgotPassword; - if (emailSignup) { - forgotPassword = ( - <div className='form-group'> - <Link to={'/' + teamName + '/reset_password'}> - <FormattedMessage - id='login.forgot' - defaultMessage='I forgot my password' - /> - </Link> - </div> + let ldapLogin; + if (ldapEnabled) { + ldapLogin = ( + <LoginLdap + teamName={teamName} + serverError={this.state.serverLdapError} + submit={this.preSubmit} + /> ); + + if (emailSigninEnabled || usernameSigninEnabled || oauthLogins.length > 0) { + ldapLogin = ( + <div> + <div className='or__container'> + <FormattedMessage + id='login.or' + defaultMessage='or' + /> + </div> + {ldapLogin} + </div> + ); + } } - let userSignUp = null; + let userSignUp; if (currentTeam.allow_open_invite) { userSignUp = ( <div> @@ -190,7 +313,21 @@ export default class Login extends React.Component { ); } - let teamSignUp = null; + let forgotPassword; + if (usernameSigninEnabled || emailSigninEnabled) { + forgotPassword = ( + <div className='form-group'> + <Link to={'/' + teamName + '/reset_password'}> + <FormattedMessage + id='login.forgot' + defaultMessage='I forgot my password' + /> + </Link> + </div> + ); + } + + let teamSignUp; if (global.window.mm_config.EnableTeamCreation === 'true' && !Utils.isMobileApp()) { teamSignUp = ( <div className='margin--extra'> @@ -207,54 +344,37 @@ export default class Login extends React.Component { ); } - let ldapLogin = null; - if (global.window.mm_config.EnableLdap === 'true') { - ldapLogin = ( - <LoginLdap - teamName={teamName} - /> - ); - } - - if (ldapEnabled && (loginMessage.length > 0 || emailSignup || usernameSigninEnabled)) { - ldapLogin = ( - <div> - <div className='or__container'> - <FormattedMessage - id='login.or' - defaultMessage='or' - /> - </div> - <LoginLdap - teamName={teamName} - /> - </div> - ); + return ( + <div> + {extraBox} + {oauthLogins} + {emailLogin} + {usernameLogin} + {ldapLogin} + {userSignUp} + {forgotPassword} + {teamSignUp} + </div> + ); + } + render() { + const currentTeam = this.state.currentTeam; + if (currentTeam == null || !this.state.doneCheckLogin) { + return <div/>; } - let usernameLogin = null; - if (global.window.mm_config.EnableSignInWithUsername === 'true') { - usernameLogin = ( - <LoginUsername - teamName={teamName} + let content; + if (this.state.showMfa) { + content = ( + <LoginMfa + method={this.state.method} + loginId={this.state.loginId} + password={this.state.password} + submit={this.submit} /> ); - } - - if (usernameSigninEnabled && (loginMessage.length > 0 || emailSignup || ldapEnabled)) { - usernameLogin = ( - <div> - <div className='or__container'> - <FormattedMessage - id='login.or' - defaultMessage='or' - /> - </div> - <LoginUsername - teamName={teamName} - /> - </div> - ); + } else { + content = this.createLoginOptions(currentTeam); } return ( @@ -275,7 +395,7 @@ export default class Login extends React.Component { defaultMessage='Sign in to:' /> </h5> - <h2 className='signup-team__name'>{teamDisplayName}</h2> + <h2 className='signup-team__name'>{currentTeam.display_name}</h2> <h2 className='signup-team__subdomain'> <FormattedMessage id='login.on' @@ -285,14 +405,7 @@ export default class Login extends React.Component { }} /> </h2> - {extraBox} - {loginMessage} - {emailSignup} - {usernameLogin} - {ldapLogin} - {userSignUp} - {forgotPassword} - {teamSignUp} + {content} </div> </div> </div> diff --git a/webapp/components/more_channels.jsx b/webapp/components/more_channels.jsx index d0eeec1ef..811bb8101 100644 --- a/webapp/components/more_channels.jsx +++ b/webapp/components/more_channels.jsx @@ -9,6 +9,7 @@ import * as AsyncClient from 'utils/async_client.jsx'; import ChannelStore from 'stores/channel_store.jsx'; import LoadingScreen from './loading_screen.jsx'; import NewChannelFlow from './new_channel_flow.jsx'; +import * as GlobalActions from 'action_creators/global_actions.jsx'; import {FormattedMessage} from 'react-intl'; @@ -64,8 +65,7 @@ export default class MoreChannels extends React.Component { client.joinChannel(channel.id, () => { $(ReactDOM.findDOMNode(this.refs.modal)).modal('hide'); - AsyncClient.getChannel(channel.id); - Utils.switchChannel(channel); + GlobalActions.emitChannelClickEvent(channel); this.setState({joiningChannel: -1}); }, (err) => { diff --git a/webapp/components/more_direct_channels.jsx b/webapp/components/more_direct_channels.jsx index d1446059d..29d64517e 100644 --- a/webapp/components/more_direct_channels.jsx +++ b/webapp/components/more_direct_channels.jsx @@ -5,6 +5,7 @@ import {Modal} from 'react-bootstrap'; import FilteredUserList from './filtered_user_list.jsx'; import UserStore from 'stores/user_store.jsx'; import * as Utils from 'utils/utils.jsx'; +import * as GlobalActions from 'action_creators/global_actions.jsx'; import {FormattedMessage} from 'react-intl'; import SpinnerButton from 'components/spinner_button.jsx'; @@ -68,7 +69,7 @@ export default class MoreDirectChannels extends React.Component { Utils.openDirectChannelToUser( teammate, (channel) => { - Utils.switchChannel(channel); + GlobalActions.emitChannelClickEvent(channel); this.setState({loadingDMChannel: -1}); this.handleHide(); }, @@ -85,6 +86,7 @@ export default class MoreDirectChannels extends React.Component { createJoinDirectChannelButton({user}) { return ( <SpinnerButton + className='btn btm-sm btn-primary' spinning={this.state.loadingDMChannel === user.id} onClick={this.handleShowDirectChannel.bind(this, user)} > diff --git a/webapp/components/msg_typing.jsx b/webapp/components/msg_typing.jsx index b2d414287..631eea78d 100644 --- a/webapp/components/msg_typing.jsx +++ b/webapp/components/msg_typing.jsx @@ -40,13 +40,15 @@ class MsgTyping extends React.Component { } updateTypingText(typingUsers) { - if (!typingUsers) { - return; + let text = ''; + let users = {}; + let numUsers = 0; + if (typingUsers) { + users = Object.keys(typingUsers); + numUsers = users.length; } - const users = Object.keys(typingUsers); - let text = ''; - switch (users.length) { + switch (numUsers) { case 0: text = ''; break; diff --git a/webapp/components/navbar.jsx b/webapp/components/navbar.jsx index e58e142d0..5afd7e683 100644 --- a/webapp/components/navbar.jsx +++ b/webapp/components/navbar.jsx @@ -45,6 +45,7 @@ export default class Navbar extends React.Component { this.showEditChannelHeaderModal = this.showEditChannelHeaderModal.bind(this); this.showRenameChannelModal = this.showRenameChannelModal.bind(this); this.hideRenameChannelModal = this.hideRenameChannelModal.bind(this); + this.isStateValid = this.isStateValid.bind(this); this.createCollapseButtons = this.createCollapseButtons.bind(this); this.createDropdown = this.createDropdown.bind(this); @@ -64,7 +65,7 @@ export default class Navbar extends React.Component { currentUser: UserStore.getCurrentUser() }; } - stateValid() { + isStateValid() { return this.state.channel && this.state.member && this.state.users && this.state.currentUser; } componentDidMount() { @@ -422,7 +423,7 @@ export default class Navbar extends React.Component { return buttons; } render() { - if (!this.stateValid()) { + if (!this.isStateValid()) { return null; } diff --git a/webapp/components/navbar_dropdown.jsx b/webapp/components/navbar_dropdown.jsx index 7e42a71ea..da1ae237e 100644 --- a/webapp/components/navbar_dropdown.jsx +++ b/webapp/components/navbar_dropdown.jsx @@ -59,6 +59,7 @@ export default class NavbarDropdown extends React.Component { var isAdmin = false; var isSystemAdmin = false; var teamSettings = null; + let integrationsLink = null; if (currentUser != null) { isAdmin = Utils.isAdmin(currentUser.roles); @@ -125,6 +126,21 @@ export default class NavbarDropdown extends React.Component { ); } + if (window.mm_config.EnableIncomingWebhooks === 'true' || window.mm_config.EnableOutgoingWebhooks === 'true') { + if (isAdmin || window.EnableAdminOnlyIntegrations !== 'true') { + integrationsLink = ( + <li> + <Link to={'/settings/integrations'}> + <FormattedMessage + id='navbar_dropdown.integrations' + defaultMessage='Integrations' + /> + </Link> + </li> + ); + } + } + if (isSystemAdmin) { sysAdminLink = ( <li> @@ -238,6 +254,7 @@ export default class NavbarDropdown extends React.Component { </li> {adminDivider} {teamSettings} + {integrationsLink} {manageLink} {sysAdminLink} {teams} diff --git a/webapp/components/new_channel_flow.jsx b/webapp/components/new_channel_flow.jsx index 30035ee5d..8c66ef3ce 100644 --- a/webapp/components/new_channel_flow.jsx +++ b/webapp/components/new_channel_flow.jsx @@ -2,9 +2,9 @@ // See License.txt for license information. import * as Utils from 'utils/utils.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; import * as Client from 'utils/client.jsx'; import UserStore from 'stores/user_store.jsx'; +import * as GlobalActions from 'action_creators/global_actions.jsx'; import NewChannelModal from './new_channel_modal.jsx'; import ChangeURLModal from './change_url_modal.jsx'; @@ -110,8 +110,7 @@ class NewChannelFlow extends React.Component { Client.createChannel(channel, (data) => { this.props.onModalDismissed(); - AsyncClient.getChannel(data.id); - Utils.switchChannel(data); + GlobalActions.emitChannelClickEvent(data); }, (err) => { if (err.id === 'model.channel.is_valid.2_or_more.app_error') { @@ -247,4 +246,4 @@ NewChannelFlow.propTypes = { onModalDismissed: React.PropTypes.func.isRequired }; -export default injectIntl(NewChannelFlow);
\ No newline at end of file +export default injectIntl(NewChannelFlow); diff --git a/webapp/components/permalink_view.jsx b/webapp/components/permalink_view.jsx new file mode 100644 index 000000000..2c32d643d --- /dev/null +++ b/webapp/components/permalink_view.jsx @@ -0,0 +1,93 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import ChannelHeader from 'components/channel_header.jsx'; +import PostFocusView from 'components/post_focus_view.jsx'; + +import ChannelStore from 'stores/channel_store.jsx'; +import TeamStore from 'stores/team_store.jsx'; + +import {Link} from 'react-router'; +import {FormattedMessage} from 'react-intl'; + +export default class PermalinkView extends React.Component { + constructor(props) { + super(props); + + this.getStateFromStores = this.getStateFromStores.bind(this); + this.isStateValid = this.isStateValid.bind(this); + this.updateState = this.updateState.bind(this); + + this.state = this.getStateFromStores(props); + } + getStateFromStores(props) { + const postId = props.params.postid; + const channel = ChannelStore.getCurrent(); + const channelId = channel ? channel.id : ''; + const channelName = channel ? channel.name : ''; + const team = TeamStore.getCurrent(); + const teamName = team ? team.name : ''; + return { + channelId, + channelName, + teamName, + postId + }; + } + isStateValid() { + return this.state.channelId !== '' && this.state.teamName; + } + updateState() { + this.setState(this.getStateFromStores(this.props)); + } + componentDidMount() { + ChannelStore.addChangeListener(this.updateState); + TeamStore.addChangeListener(this.updateState); + } + componentWillUnmount() { + ChannelStore.removeChangeListener(this.updateState); + TeamStore.removeChangeListener(this.updateState); + } + componentWillReceiveProps(nextProps) { + this.setState(this.getStateFromStores(nextProps)); + } + render() { + if (!this.isStateValid()) { + return null; + } + return ( + <div + id='app-content' + className='app__content' + > + <ChannelHeader + channelId={this.state.channelId} + /> + <PostFocusView profiles={this.props.profiles}/> + <div + id='archive-link-home' + > + <Link + to={'/' + this.state.teamName + '/channels/' + this.state.channelName} + > + <FormattedMessage + id='center_panel.recent' + defaultMessage='Click here to jump to recent messages. ' + /> + <i className='fa fa-arrow-down'></i> + </Link> + </div> + </div> + ); + } +} + +PermalinkView.defaultProps = { +}; + +PermalinkView.propTypes = { + params: React.PropTypes.object.isRequired, + profiles: React.PropTypes.object +}; diff --git a/webapp/components/popover_list_members.jsx b/webapp/components/popover_list_members.jsx index 819c7f590..226a1889c 100644 --- a/webapp/components/popover_list_members.jsx +++ b/webapp/components/popover_list_members.jsx @@ -6,10 +6,9 @@ import $ from 'jquery'; import UserStore from 'stores/user_store.jsx'; import {Popover, Overlay} from 'react-bootstrap'; import * as Utils from 'utils/utils.jsx'; +import * as GlobalActions from 'action_creators/global_actions.jsx'; import Constants from 'utils/constants.jsx'; -import ChannelStore from 'stores/channel_store.jsx'; - import {FormattedMessage} from 'react-intl'; import React from 'react'; @@ -36,7 +35,7 @@ export default class PopoverListMembers extends React.Component { Utils.openDirectChannelToUser( teammate, (channel, channelAlreadyExisted) => { - Utils.switchChannel(channel); + GlobalActions.emitChannelClickEvent(channel); if (channelAlreadyExisted) { this.closePopover(); } @@ -56,7 +55,6 @@ export default class PopoverListMembers extends React.Component { const members = this.props.members; const teamMembers = UserStore.getProfilesUsernameMap(); const currentUserId = UserStore.getCurrentId(); - const ch = ChannelStore.getCurrent(); if (members && teamMembers) { members.sort((a, b) => { @@ -68,7 +66,7 @@ export default class PopoverListMembers extends React.Component { members.forEach((m, i) => { let button = ''; - if (currentUserId !== m.id && ch.type !== 'D') { + if (currentUserId !== m.id && this.props.channel.type !== 'D') { button = ( <a href='#' @@ -176,7 +174,7 @@ export default class PopoverListMembers extends React.Component { } PopoverListMembers.propTypes = { + channel: React.PropTypes.object.isRequired, members: React.PropTypes.array.isRequired, - memberCount: React.PropTypes.number, - channelId: React.PropTypes.string.isRequired + memberCount: React.PropTypes.number }; diff --git a/webapp/components/post.jsx b/webapp/components/post.jsx index f2818188a..bbf8d9bf6 100644 --- a/webapp/components/post.jsx +++ b/webapp/components/post.jsx @@ -129,6 +129,7 @@ export default class Post extends React.Component { const post = this.props.post; const parentPost = this.props.parentPost; const posts = this.props.posts; + const mattermostLogo = Constants.MATTERMOST_ICON_SVG; if (!post.props) { post.props = {}; @@ -188,9 +189,9 @@ export default class Post extends React.Component { if (post.props && post.props.from_webhook && global.window.mm_config.EnablePostIconOverride === 'true') { if (post.props.override_icon_url) { src = post.props.override_icon_url; + } else { + src = Constants.DEFAULT_WEBHOOK_LOGO; } - } else if (Utils.isSystemMessage(post)) { - src = Constants.SYSTEM_MESSAGE_PROFILE_IMAGE; } profilePic = ( @@ -200,6 +201,15 @@ export default class Post extends React.Component { width='36' /> ); + + if (Utils.isSystemMessage(post)) { + profilePic = ( + <span + className='icon' + dangerouslySetInnerHTML={{__html: mattermostLogo}} + /> + ); + } } return ( diff --git a/webapp/components/post_body_additional_content.jsx b/webapp/components/post_body_additional_content.jsx index 2cd82f213..452597dde 100644 --- a/webapp/components/post_body_additional_content.jsx +++ b/webapp/components/post_body_additional_content.jsx @@ -70,7 +70,7 @@ export default class PostBodyAdditionalContent extends React.Component { return this.getSlackAttachment(); } - const link = Utils.extractLinks(this.props.post.message)[0]; + const link = Utils.extractFirstLink(this.props.post.message); if (!link) { return null; } diff --git a/webapp/components/post_info.jsx b/webapp/components/post_info.jsx index 0aa71edd7..2d41b0e54 100644 --- a/webapp/components/post_info.jsx +++ b/webapp/components/post_info.jsx @@ -22,7 +22,7 @@ export default class PostInfo extends React.Component { } dropdownPosition(e) { var position = $('#post-list').height() - $(e.target).offset().top; - var dropdown = $(e.target).next('.dropdown-menu'); + var dropdown = $(e.target).closest('.col__reply').find('.dropdown-menu'); if (position < dropdown.height()) { dropdown.addClass('bottom'); } diff --git a/webapp/components/posts_view.jsx b/webapp/components/posts_view.jsx index 8b4b0c662..ffe04daa1 100644 --- a/webapp/components/posts_view.jsx +++ b/webapp/components/posts_view.jsx @@ -173,24 +173,15 @@ export default class PostsView extends React.Component { const postFromWebhook = Boolean(post.props && post.props.from_webhook); const prevPostFromWebhook = Boolean(prevPost.props && prevPost.props.from_webhook); const prevPostUserId = Utils.isSystemMessage(prevPost) ? '' : prevPost.user_id; - let prevWebhookName = ''; - if (prevPost.props && prevPost.props.override_username) { - prevWebhookName = prevPost.props.override_username; - } - let curWebhookName = ''; - if (post.props && post.props.override_username) { - curWebhookName = post.props.override_username; - } // consider posts from the same user if: // the previous post was made by the same user as the current post, // the previous post was made within 5 minutes of the current post, - // the previous post and current post are both from webhooks or both not, - // the previous post and current post have the same webhook usernames + // the current post is not from a webhook + // the previous post is not from a webhook if (prevPostUserId === postUserId && post.create_at - prevPost.create_at <= 1000 * 60 * 5 && - postFromWebhook === prevPostFromWebhook && - prevWebhookName === curWebhookName) { + !postFromWebhook && !prevPostFromWebhook) { sameUser = true; } @@ -213,13 +204,11 @@ export default class PostsView extends React.Component { // the previous post was made by the same user as the current post, // the previous post is not a comment, // the current post is not a comment, - // the previous post and current post are both from webhooks or both not, - // the previous post and current post have the same webhook usernames + // the current post is not from a webhook if (prevPostUserId === postUserId && !prevPostIsComment && !postIsComment && - postFromWebhook === prevPostFromWebhook && - prevWebhookName === curWebhookName) { + !postFromWebhook) { hideProfilePic = true; } } @@ -319,7 +308,7 @@ export default class PostsView extends React.Component { if (this.props.scrollType === PostsView.SCROLL_TYPE_BOTTOM) { this.scrollToBottom(); } else if (this.props.scrollType === PostsView.SCROLL_TYPE_NEW_MESSAGE) { - window.requestAnimationFrame(() => { + window.setTimeout(window.requestAnimationFrame(() => { // If separator exists scroll to it. Otherwise scroll to bottom. if (this.refs.newMessageSeparator) { var objDiv = this.refs.postlist; @@ -327,7 +316,7 @@ export default class PostsView extends React.Component { } else if (this.refs.postlist) { this.refs.postlist.scrollTop = this.refs.postlist.scrollHeight; } - }); + }), 0); } else if (this.props.scrollType === PostsView.SCROLL_TYPE_POST && this.props.scrollPostId) { window.requestAnimationFrame(() => { const postNode = ReactDOM.findDOMNode(this.refs[this.props.scrollPostId]); @@ -384,6 +373,8 @@ export default class PostsView extends React.Component { } componentWillUnmount() { window.removeEventListener('resize', this.handleResize); + this.scrollStopAction.cancel(); + PreferenceStore.removeChangeListener(this.updateState); } componentDidUpdate() { if (this.props.postList != null) { @@ -570,7 +561,9 @@ function FloatingTimestamp({isScrolling, post}) { return ( <div className={className}> - <span>{dateString}</span> + <div> + <span>{dateString}</span> + </div> </div> ); } diff --git a/webapp/components/posts_view_container.jsx b/webapp/components/posts_view_container.jsx index 7e334d4b0..a49c77f8d 100644 --- a/webapp/components/posts_view_container.jsx +++ b/webapp/components/posts_view_container.jsx @@ -8,7 +8,6 @@ import ChannelStore from 'stores/channel_store.jsx'; import PostStore from 'stores/post_store.jsx'; import UserStore from 'stores/user_store.jsx'; -import * as Utils from 'utils/utils.jsx'; import * as GlobalActions from 'action_creators/global_actions.jsx'; import Constants from 'utils/constants.jsx'; @@ -158,17 +157,6 @@ export default class PostsViewContainer extends React.Component { this.setState({scrollType: PostsView.SCROLL_TYPE_FREE}); } } - shouldComponentUpdate(nextProps, nextState) { - if (!Utils.areObjectsEqual(this.state, nextState)) { - return true; - } - - if (!Utils.areObjectsEqual(this.props, nextProps)) { - return true; - } - - return false; - } render() { const postLists = this.state.postLists; const channels = this.state.channels; diff --git a/webapp/components/removed_from_channel_modal.jsx b/webapp/components/removed_from_channel_modal.jsx index cdd51bd6e..45018ac99 100644 --- a/webapp/components/removed_from_channel_modal.jsx +++ b/webapp/components/removed_from_channel_modal.jsx @@ -6,7 +6,7 @@ import ReactDOM from 'react-dom'; import ChannelStore from 'stores/channel_store.jsx'; import UserStore from 'stores/user_store.jsx'; import BrowserStore from 'stores/browser_store.jsx'; -import * as utils from 'utils/utils.jsx'; +import * as GlobalActions from 'action_creators/global_actions.jsx'; import {FormattedMessage} from 'react-intl'; @@ -33,7 +33,7 @@ export default class RemovedFromChannelModal extends React.Component { } var townSquare = ChannelStore.getByName('town-square'); - setTimeout(() => utils.switchChannel(townSquare), 1); + setTimeout(() => GlobalActions.emitChannelClickEvent(townSquare), 1); this.setState(newState); } diff --git a/webapp/components/rename_channel_modal.jsx b/webapp/components/rename_channel_modal.jsx index 72828984c..ced3c2d2b 100644 --- a/webapp/components/rename_channel_modal.jsx +++ b/webapp/components/rename_channel_modal.jsx @@ -4,7 +4,7 @@ import ReactDOM from 'react-dom'; import * as Utils from 'utils/utils.jsx'; import * as Client from 'utils/client.jsx'; -import * as AsyncClient from 'utils/async_client.jsx'; +import * as GlobalActions from 'action_creators/global_actions.jsx'; import Constants from 'utils/constants.jsx'; import {intlShape, injectIntl, defineMessages, FormattedMessage} from 'react-intl'; @@ -165,8 +165,7 @@ export default class RenameChannelModal extends React.Component { Client.updateChannel( channel, () => { - AsyncClient.getChannel(channel.id); - Utils.updateAddressBar(channel.name); + GlobalActions.emitChannelClickEvent(channel); this.handleHide(); }, diff --git a/webapp/components/rhs_root_post.jsx b/webapp/components/rhs_root_post.jsx index 26b392aa1..7a7c5f692 100644 --- a/webapp/components/rhs_root_post.jsx +++ b/webapp/components/rhs_root_post.jsx @@ -217,6 +217,8 @@ export default class RhsRootPost extends React.Component { if (post.props && post.props.from_webhook && global.window.mm_config.EnablePostIconOverride === 'true') { if (post.props.override_icon_url) { src = post.props.override_icon_url; + } else { + src = Constants.DEFAULT_WEBHOOK_LOGO; } } else if (Utils.isSystemMessage(post)) { src = Constants.SYSTEM_MESSAGE_PROFILE_IMAGE; diff --git a/webapp/components/root.jsx b/webapp/components/root.jsx index 9963bc5dd..3b85b23fd 100644 --- a/webapp/components/root.jsx +++ b/webapp/components/root.jsx @@ -69,7 +69,7 @@ export default class Root extends React.Component { FastClick.attach(document.body); // Get our localizaiton - GlobalActions.newLocalizationSelected('en'); + GlobalActions.loadBrowserLocale(); } componentWillUnmount() { LocalizationStore.removeChangeListener(this.localizationChanged); diff --git a/webapp/components/search_results_item.jsx b/webapp/components/search_results_item.jsx index 35769d06b..75cbcb2a0 100644 --- a/webapp/components/search_results_item.jsx +++ b/webapp/components/search_results_item.jsx @@ -1,7 +1,6 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import $ from 'jquery'; import UserStore from 'stores/user_store.jsx'; import UserProfile from './user_profile.jsx'; import * as GlobalActions from 'action_creators/global_actions.jsx'; @@ -10,28 +9,16 @@ import * as TextFormatting from 'utils/text_formatting.jsx'; import Constants from 'utils/constants.jsx'; import {FormattedMessage, FormattedDate} from 'react-intl'; - import React from 'react'; +import {Link} from 'react-router'; export default class SearchResultsItem extends React.Component { constructor(props) { super(props); - this.handleClick = this.handleClick.bind(this); this.handleFocusRHSClick = this.handleFocusRHSClick.bind(this); } - handleClick(e) { - e.preventDefault(); - - GlobalActions.emitPostFocusEvent(this.props.post.id); - - if ($(window).width() < 768) { - $('.sidebar--right').removeClass('move--left'); - $('.inner-wrap').removeClass('move--left'); - } - } - handleFocusRHSClick(e) { e.preventDefault(); GlobalActions.emitPostFocusRightHandSideFromSearch(this.props.post, this.props.isMentionSearch); @@ -99,16 +86,15 @@ export default class SearchResultsItem extends React.Component { </time> </li> <li> - <a - href='#' + <Link + to={'/' + window.location.pathname.split('/')[1] + '/pl/' + this.props.post.id} className='search-item__jump' - onClick={this.handleClick} > <FormattedMessage id='search_item.jump' defaultMessage='Jump' /> - </a> + </Link> </li> <li> <a diff --git a/webapp/components/sidebar.jsx b/webapp/components/sidebar.jsx index c0d4755ed..500e73cf2 100644 --- a/webapp/components/sidebar.jsx +++ b/webapp/components/sidebar.jsx @@ -15,7 +15,6 @@ import TeamStore from 'stores/team_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import * as AsyncClient from 'utils/async_client.jsx'; -import * as Client from 'utils/client.jsx'; import * as Utils from 'utils/utils.jsx'; import Constants from 'utils/constants.jsx'; @@ -29,7 +28,7 @@ import {Tooltip, OverlayTrigger} from 'react-bootstrap'; import loadingGif from 'images/load.gif'; import React from 'react'; -import {browserHistory} from 'react-router'; +import {browserHistory, Link} from 'react-router'; import favicon from 'images/favicon/favicon-16x16.png'; import redFavicon from 'images/favicon/redfavicon-16x16.png'; @@ -139,7 +138,9 @@ export default class Sidebar extends React.Component { unreadCounts: JSON.parse(JSON.stringify(ChannelStore.getUnreadCounts())), showTutorialTip: tutorialStep === TutorialSteps.CHANNEL_POPOVER, currentTeam: TeamStore.getCurrent(), - currentUser: UserStore.getCurrentUser() + currentUser: UserStore.getCurrentUser(), + townSquare: ChannelStore.getByName(Constants.DEFAULT_CHANNEL), + offTopic: ChannelStore.getByName(Constants.OFFTOPIC_CHANNEL) }; } @@ -239,7 +240,9 @@ export default class Sidebar extends React.Component { }); } - handleLeaveDirectChannel(channel) { + handleLeaveDirectChannel(e, channel) { + e.preventDefault(); + if (!this.isLeaving.get(channel.id)) { this.isLeaving.set(channel.id, true); @@ -259,7 +262,7 @@ export default class Sidebar extends React.Component { } if (channel.id === this.state.activeId) { - Utils.switchChannel(ChannelStore.getByName(Constants.DEFAULT_CHANNEL)); + browserHistory.push('/' + this.state.currentTeam.name + '/channels/town-square'); } } @@ -289,6 +292,16 @@ export default class Sidebar extends React.Component { createTutorialTip() { const screens = []; + let townSquareDisplayName = Constants.DEFAULT_CHANNEL_UI_NAME; + if (this.state.townSquare) { + townSquareDisplayName = this.state.townSquare.display_name; + } + + let offTopicDisplayName = Constants.OFFTOPIC_CHANNEL_UI_NAME; + if (this.state.offTopic) { + offTopicDisplayName = this.state.offTopic.display_name; + } + screens.push( <div> <FormattedHTMLMessage @@ -302,10 +315,14 @@ export default class Sidebar extends React.Component { <div> <FormattedHTMLMessage id='sidebar.tutorialScreen2' - defaultMessage='<h4>"Town Square" and "Off-Topic" channels</h4> + defaultMessage='<h4>"{townsquare}" and "{offtopic}" channels</h4> <p>Here are two public channels to start:</p> - <p><strong>Town Square</strong> is a place for team-wide communication. Everyone in your team is a member of this channel.</p> - <p><strong>Off-Topic</strong> is a place for fun and humor outside of work-related channels. You and your team can decide what other channels to create.</p>' + <p><strong>{townsquare}</strong> is a place for team-wide communication. Everyone in your team is a member of this channel.</p> + <p><strong>{offtopic}</strong> is a place for fun and humor outside of work-related channels. You and your team can decide what other channels to create.</p>' + values={{ + townsquare: townSquareDisplayName, + offtopic: offTopicDisplayName + }} /> </div> ); @@ -406,48 +423,6 @@ export default class Sidebar extends React.Component { icon = <div className='status'><i className='fa fa-lock'></i></div>; } - // set up click handler to switch channels (or create a new channel for non-existant ones) - var handleClick = null; - - if (!channel.fake) { - handleClick = function clickHandler(e) { - if (e.target.attributes.getNamedItem('data-close')) { - handleClose(channel); - } else { - Utils.switchChannel(channel); - } - - e.preventDefault(); - }; - } else if (channel.fake) { - // It's a direct message channel that doesn't exist yet so let's create it now - var otherUserId = Utils.getUserIdFromChannelName(channel); - - if (this.state.loadingDMChannel === -1) { - handleClick = function clickHandler(e) { - e.preventDefault(); - - if (e.target.attributes.getNamedItem('data-close')) { - handleClose(channel); - } else { - this.setState({loadingDMChannel: index}); - - Client.createDirectChannel(channel, otherUserId, - (data) => { - this.setState({loadingDMChannel: -1}); - AsyncClient.getChannel(data.id); - Utils.switchChannel(data); - }, - () => { - this.setState({loadingDMChannel: -1}); - browserHistory('/' + this.state.currentTeam.name); - } - ); - } - }.bind(this); - } - } - let closeButton = null; const removeTooltip = ( <Tooltip id='remove-dm-tooltip'> @@ -464,12 +439,12 @@ export default class Sidebar extends React.Component { placement='top' overlay={removeTooltip} > - <span - className='btn-close' - data-close='true' - > - {'×'} - </span> + <span + onClick={(e) => handleClose(e, channel)} + className='btn-close' + > + {'×'} + </span> </OverlayTrigger> ); @@ -481,23 +456,29 @@ export default class Sidebar extends React.Component { tutorialTip = this.createTutorialTip(); } + let link = ''; + if (channel.fake) { + link = '/' + this.state.currentTeam.name + '/channels/' + channel.name + '?fakechannel=' + encodeURIComponent(JSON.stringify(channel)); + } else { + link = '/' + this.state.currentTeam.name + '/channels/' + channel.name; + } + return ( <li key={channel.name} ref={channel.name} className={linkClass} > - <a + <Link + to={link} className={rowClass} - href={'#'} - onClick={handleClick} > {icon} {status} {channel.display_name} {badge} {closeButton} - </a> + </Link> {tutorialTip} </li> ); @@ -600,6 +581,7 @@ export default class Sidebar extends React.Component { <div className='sidebar--left' id='sidebar-left' + key='sidebar-left' > <NewChannelFlow show={showChannelModal} diff --git a/webapp/components/sidebar_right.jsx b/webapp/components/sidebar_right.jsx index a2e3914f3..594674929 100644 --- a/webapp/components/sidebar_right.jsx +++ b/webapp/components/sidebar_right.jsx @@ -29,7 +29,7 @@ export default class SidebarRight extends React.Component { this.doStrangeThings = this.doStrangeThings.bind(this); this.state = { - searchVisible: !!SearchStore.getSearchResults(), + searchVisible: SearchStore.getSearchResults() !== null, isMentionSearch: SearchStore.getIsMentionSearch(), postRightVisible: !!PostStore.getSelectedPost(), fromSearch: false, @@ -111,7 +111,7 @@ export default class SidebarRight extends React.Component { } onSearchChange() { this.setState({ - searchVisible: !!SearchStore.getSearchResults(), + searchVisible: SearchStore.getSearchResults() !== null, isMentionSearch: SearchStore.getIsMentionSearch() }); } diff --git a/webapp/components/signup_team.jsx b/webapp/components/signup_team.jsx index e6b27e745..3ad47500d 100644 --- a/webapp/components/signup_team.jsx +++ b/webapp/components/signup_team.jsx @@ -138,6 +138,24 @@ export default class TeamSignUp extends React.Component { } let signupMethod = null; + let goBack = ( + <div className='signup-header'> + <a + href='#' + onClick={ + (e) => { + e.preventDefault(); + this.updatePage('choose'); + } + } + > + <span className='fa fa-chevron-left'/> + <FormattedMessage + id='web.header.back' + /> + </a> + </div> + ); if (global.window.mm_config.EnableTeamCreation !== 'true') { if (teamListing == null) { @@ -154,9 +172,12 @@ export default class TeamSignUp extends React.Component { updatePage={this.updatePage} /> ); + goBack = null; } else if (this.state.page === 'email') { signupMethod = ( - <EmailSignUpPage/> + <div> + <EmailSignUpPage/> + </div> ); } else if (this.state.page === 'ldap') { return ( @@ -180,24 +201,28 @@ export default class TeamSignUp extends React.Component { defaultMessage='No team creation method has been enabled. Please contact an administrator for access.' /> ); + goBack = null; } return ( - <div className='col-sm-12'> - <div className='signup-team__container'> - <img - className='signup-team-logo' - src={logoImage} - /> - <h1>{global.window.mm_config.SiteName}</h1> - <h4 className='color--light'> - <FormattedMessage - id='web.root.singup_info' + <div> + {goBack} + <div className='col-sm-12'> + <div className='signup-team__container'> + <img + className='signup-team-logo' + src={logoImage} /> - </h4> - <div id='signup-team'> - {teamListing} - {signupMethod} + <h1>{global.window.mm_config.SiteName}</h1> + <h4 className='color--light'> + <FormattedMessage + id='web.root.singup_info' + /> + </h4> + <div id='signup-team'> + {teamListing} + {signupMethod} + </div> </div> </div> </div> diff --git a/webapp/components/signup_team_complete/components/signup_team_complete.jsx b/webapp/components/signup_team_complete/components/signup_team_complete.jsx index 8b2096499..e5d310151 100644 --- a/webapp/components/signup_team_complete/components/signup_team_complete.jsx +++ b/webapp/components/signup_team_complete/components/signup_team_complete.jsx @@ -8,7 +8,7 @@ import {FormattedMessage} from 'react-intl'; import {browserHistory} from 'react-router'; import React from 'react'; -import Link from 'react-router'; +import {Link} from 'react-router'; export default class SignupTeamComplete extends React.Component { constructor(props) { @@ -55,7 +55,7 @@ export default class SignupTeamComplete extends React.Component { <div> <div className='signup-header'> <Link to='/'> - <span classNameName='fa fa-chevron-left'/> + <span className='fa fa-chevron-left'/> <FormattedMessage id='web.header.back'/> </Link> </div> diff --git a/webapp/components/signup_user_complete.jsx b/webapp/components/signup_user_complete.jsx index 5460daf29..e9f9d9d88 100644 --- a/webapp/components/signup_user_complete.jsx +++ b/webapp/components/signup_user_complete.jsx @@ -1,18 +1,21 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. -import ReactDOM from 'react-dom'; +import LoadingScreen from 'components/loading_screen.jsx'; +import LoginLdap from 'components/login/components/login_ldap.jsx'; + +import BrowserStore from 'stores/browser_store.jsx'; +import UserStore from 'stores/user_store.jsx'; + import * as Utils from 'utils/utils.jsx'; import * as Client from 'utils/client.jsx'; -import UserStore from 'stores/user_store.jsx'; -import BrowserStore from 'stores/browser_store.jsx'; import Constants from 'utils/constants.jsx'; -import LoadingScreen from 'components/loading_screen.jsx'; import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; import {browserHistory, Link} from 'react-router'; import React from 'react'; +import ReactDOM from 'react-dom'; import logoImage from 'images/logo.png'; @@ -314,13 +317,13 @@ class SignupUserComplete extends React.Component { </div> ); - var signupMessage = []; + let signupMessage = []; if (global.window.mm_config.EnableSignUpWithGitLab === 'true') { signupMessage.push( - <Link + <a className='btn btn-custom-login gitlab' key='gitlab' - to={'/api/v1/oauth/gitlab/signup' + window.location.search + '&team=' + encodeURIComponent(this.state.teamName)} + href={'/api/v1/oauth/gitlab/signup' + window.location.search + '&team=' + encodeURIComponent(this.state.teamName)} > <span className='icon'/> <span> @@ -329,16 +332,16 @@ class SignupUserComplete extends React.Component { defaultMessage='with GitLab' /> </span> - </Link> + </a> ); } if (global.window.mm_config.EnableSignUpWithGoogle === 'true') { signupMessage.push( - <Link + <a className='btn btn-custom-login google' key='google' - to={'/api/v1/oauth/google/signup' + window.location.search + '&team=' + encodeURIComponent(this.state.teamName)} + href={'/api/v1/oauth/google/signup' + window.location.search + '&team=' + encodeURIComponent(this.state.teamName)} > <span className='icon'/> <span> @@ -347,11 +350,26 @@ class SignupUserComplete extends React.Component { defaultMessage='with Google' /> </span> - </Link> + </a> ); } - var emailSignup; + let ldapSignup; + if (global.window.mm_config.EnableLdap === 'true' && global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.LDAP) { + ldapSignup = ( + <div className='inner__content'> + <h5><strong> + <FormattedMessage + id='signup_user_completed.withLdap' + defaultMessage='With your LDAP credentials' + /> + </strong></h5> + <LoginLdap teamName={this.state.teamName}/> + </div> + ); + } + + let emailSignup; if (global.window.mm_config.EnableSignUpWithEmail === 'true') { emailSignup = ( <div> @@ -397,24 +415,24 @@ class SignupUserComplete extends React.Component { {passwordError} </div> </div> + <p className='margin--extra'> + <button + type='submit' + onClick={this.handleSubmit} + className='btn-primary btn' + > + <FormattedMessage + id='signup_user_completed.create' + defaultMessage='Create Account' + /> + </button> + </p> </div> - <p className='margin--extra'> - <button - type='submit' - onClick={this.handleSubmit} - className='btn-primary btn' - > - <FormattedMessage - id='signup_user_completed.create' - defaultMessage='Create Account' - /> - </button> - </p> </div> ); } - if (signupMessage.length > 0 && emailSignup) { + if (signupMessage.length > 0 && (emailSignup || ldapSignup)) { signupMessage = ( <div> {signupMessage} @@ -428,7 +446,21 @@ class SignupUserComplete extends React.Component { ); } - if (signupMessage.length === 0 && !emailSignup) { + if (ldapSignup && emailSignup) { + ldapSignup = ( + <div> + {ldapSignup} + <div className='or__container'> + <FormattedMessage + id='signup_user_completed.or' + defaultMessage='or' + /> + </div> + </div> + ); + } + + if (signupMessage.length === 0 && !emailSignup && !ldapSignup) { emailSignup = ( <div> <FormattedMessage @@ -449,7 +481,7 @@ class SignupUserComplete extends React.Component { </div> <div className='col-sm-12'> <div className='signup-team__container padding--less'> - <form> + <div> <img className='signup-team-logo' src={logoImage} @@ -477,9 +509,10 @@ class SignupUserComplete extends React.Component { /> </h4> {signupMessage} + {ldapSignup} {emailSignup} {serverError} - </form> + </div> </div> </div> </div> diff --git a/webapp/components/spinner_button.jsx b/webapp/components/spinner_button.jsx index fcc9af8cd..becf395c5 100644 --- a/webapp/components/spinner_button.jsx +++ b/webapp/components/spinner_button.jsx @@ -14,20 +14,10 @@ export default class SpinnerButton extends React.Component { }; } - constructor(props) { - super(props); - - this.handleClick = this.handleClick.bind(this); - } - - handleClick(e) { - if (this.props.onClick) { - this.props.onClick(e); - } - } - render() { - if (this.props.spinning) { + const {spinning, children, ...props} = this.props; // eslint-disable-line no-use-before-define + + if (spinning) { return ( <img className='spinner-button__gif' @@ -38,10 +28,10 @@ export default class SpinnerButton extends React.Component { return ( <button - onClick={this.handleClick} - className='btn btn-sm btn-primary' + className='btn btn-primary' + {...props} > - {this.props.children} + {children} </button> ); } diff --git a/webapp/components/suggestion/search_suggestion_list.jsx b/webapp/components/suggestion/search_suggestion_list.jsx index b15cc4243..57aaee8ff 100644 --- a/webapp/components/suggestion/search_suggestion_list.jsx +++ b/webapp/components/suggestion/search_suggestion_list.jsx @@ -2,6 +2,7 @@ // See License.txt for license information. import $ from 'jquery'; +import React from 'react'; import ReactDOM from 'react-dom'; import Constants from 'utils/constants.jsx'; import SuggestionList from './suggestion_list.jsx'; diff --git a/webapp/components/team_general_tab.jsx b/webapp/components/team_general_tab.jsx index 776c84b48..c27e8ca59 100644 --- a/webapp/components/team_general_tab.jsx +++ b/webapp/components/team_general_tab.jsx @@ -84,6 +84,7 @@ class GeneralTab extends React.Component { } updateSection(section) { + $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update'); this.setState(this.setupInitialState(this.props)); this.props.updateSection(section); } diff --git a/webapp/components/team_settings_modal.jsx b/webapp/components/team_settings_modal.jsx index c19787993..657643367 100644 --- a/webapp/components/team_settings_modal.jsx +++ b/webapp/components/team_settings_modal.jsx @@ -62,6 +62,7 @@ class TeamSettingsModal extends React.Component { } } updateSection(section) { + $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update'); this.setState({activeSection: section}); } render() { diff --git a/webapp/components/team_signup_with_sso.jsx b/webapp/components/team_signup_with_sso.jsx index 9a46b2d6b..78396eea8 100644 --- a/webapp/components/team_signup_with_sso.jsx +++ b/webapp/components/team_signup_with_sso.jsx @@ -64,7 +64,7 @@ class SSOSignUpPage extends React.Component { this.props.service, (data) => { if (data.follow_link) { - browserHistory.push(data.follow_link); + window.location.href = data.follow_link; } else { browserHistory.push('/' + team.name + '/channels/town-square'); } diff --git a/webapp/components/textbox.jsx b/webapp/components/textbox.jsx index 371c581e5..c77e1f9a3 100644 --- a/webapp/components/textbox.jsx +++ b/webapp/components/textbox.jsx @@ -130,8 +130,8 @@ export default class Textbox extends React.Component { const helpText = ( <div - style={{visibility: hasText ? 'visible' : 'hidden', opacity: hasText ? '0.5' : '0'}} - className='help_format_text' + style={{visibility: hasText ? 'visible' : 'hidden', opacity: hasText ? '0.3' : '0'}} + className='help__format-text' > <b> <FormattedMessage @@ -208,8 +208,8 @@ export default class Textbox extends React.Component { dangerouslySetInnerHTML={{__html: this.state.preview ? TextFormatting.formatText(this.props.messageText) : ''}} > </div> - {helpText} <div className='help__text'> + {helpText} {previewLink} <a target='_blank' diff --git a/webapp/components/tutorial/tutorial_intro_screens.jsx b/webapp/components/tutorial/tutorial_intro_screens.jsx index 913a30483..0358a6a65 100644 --- a/webapp/components/tutorial/tutorial_intro_screens.jsx +++ b/webapp/components/tutorial/tutorial_intro_screens.jsx @@ -2,7 +2,6 @@ // See License.txt for license information. import UserStore from 'stores/user_store.jsx'; -import ChannelStore from 'stores/channel_store.jsx'; import TeamStore from 'stores/team_store.jsx'; import PreferenceStore from 'stores/preference_store.jsx'; import * as Utils from 'utils/utils.jsx'; @@ -11,6 +10,7 @@ import * as AsyncClient from 'utils/async_client.jsx'; import Constants from 'utils/constants.jsx'; import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; +import {browserHistory} from 'react-router'; const Preferences = Constants.Preferences; @@ -19,6 +19,12 @@ const NUM_SCREENS = 3; import React from 'react'; export default class TutorialIntroScreens extends React.Component { + static get propTypes() { + return { + townSquare: React.PropTypes.object, + offTopic: React.PropTypes.object + }; + } constructor(props) { super(props); @@ -34,7 +40,7 @@ export default class TutorialIntroScreens extends React.Component { return; } - Utils.switchChannel(ChannelStore.getByName(Constants.DEFAULT_CHANNEL)); + browserHistory.push(TeamStore.getCurrentTeamUrl() + '/channels/town-square'); const step = PreferenceStore.getInt(Preferences.TUTORIAL_STEP, UserStore.getCurrentId(), 0); @@ -52,6 +58,8 @@ export default class TutorialIntroScreens extends React.Component { UserStore.getCurrentId(), '999' ); + + browserHistory.push(TeamStore.getCurrentTeamUrl() + '/channels/town-square'); } createScreen() { switch (this.state.currentScreen) { @@ -151,6 +159,11 @@ export default class TutorialIntroScreens extends React.Component { ); } + let townSquareDisplayName = Constants.DEFAULT_CHANNEL_UI_NAME; + if (this.props.townSquare) { + townSquareDisplayName = this.props.townSquare.display_name; + } + return ( <div> <h3> @@ -169,7 +182,10 @@ export default class TutorialIntroScreens extends React.Component { {supportInfo} <FormattedMessage id='tutorial_intro.end' - defaultMessage='Click “Next” to enter Town Square. This is the first channel teammates see when they sign up. Use it for posting updates everyone needs to know.' + defaultMessage='Click “Next” to enter {channel}. This is the first channel teammates see when they sign up. Use it for posting updates everyone needs to know.' + values={{ + channel: townSquareDisplayName + }} /> {circles} </div> diff --git a/webapp/components/tutorial/tutorial_tip.jsx b/webapp/components/tutorial/tutorial_tip.jsx index 3508e29a2..deca70794 100644 --- a/webapp/components/tutorial/tutorial_tip.jsx +++ b/webapp/components/tutorial/tutorial_tip.jsx @@ -15,6 +15,9 @@ import {Overlay} from 'react-bootstrap'; import React from 'react'; +import tutorialGif from 'images/tutorialTip.gif'; +import tutorialGifWhite from 'images/tutorialTipWhite.gif'; + export default class TutorialTip extends React.Component { constructor(props) { super(props); @@ -90,16 +93,16 @@ export default class TutorialTip extends React.Component { } } - var tipColor = ''; + var tutorialGifImage = tutorialGif; if (this.props.overlayClass === 'tip-overlay--header' || this.props.overlayClass === 'tip-overlay--sidebar') { - tipColor = 'White'; + tutorialGifImage = tutorialGifWhite; } return ( <div className={'tip-div ' + this.props.overlayClass}> <img className='tip-button' - src={'/static/images/tutorialTip' + tipColor + '.gif'} + src={tutorialGifImage} width='35' onClick={this.toggle} ref='target' diff --git a/webapp/components/tutorial/tutorial_view.jsx b/webapp/components/tutorial/tutorial_view.jsx new file mode 100644 index 000000000..5f2c1a257 --- /dev/null +++ b/webapp/components/tutorial/tutorial_view.jsx @@ -0,0 +1,44 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import TutorialIntroScreens from './tutorial_intro_screens.jsx'; + +import ChannelStore from 'stores/channel_store.jsx'; +import Constants from 'utils/constants.jsx'; + +import React from 'react'; + +export default class TutorialView extends React.Component { + constructor(props) { + super(props); + + this.handleChannelChange = this.handleChannelChange.bind(this); + + this.state = { + townSquare: ChannelStore.getByName(Constants.DEFAULT_CHANNEL) + }; + } + componentDidMount() { + ChannelStore.addChangeListener(this.handleChannelChange); + } + componentWillUnmount() { + ChannelStore.removeChangeListener(this.handleChannelChange); + } + handleChannelChange() { + this.setState({ + townSquare: ChannelStore.getByName(Constants.DEFAULT_CHANNEL) + }); + } + render() { + return ( + <div + id='app-content' + className='app__content' + > + <TutorialIntroScreens + townSquare={this.state.townSquare} + /> + </div> + ); + } +} diff --git a/webapp/components/user_settings/manage_incoming_hooks.jsx b/webapp/components/user_settings/manage_incoming_hooks.jsx deleted file mode 100644 index b61b331ce..000000000 --- a/webapp/components/user_settings/manage_incoming_hooks.jsx +++ /dev/null @@ -1,225 +0,0 @@ -// 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_outgoing_hooks.jsx b/webapp/components/user_settings/manage_outgoing_hooks.jsx deleted file mode 100644 index 8adec09ce..000000000 --- a/webapp/components/user_settings/manage_outgoing_hooks.jsx +++ /dev/null @@ -1,397 +0,0 @@ -// 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 index c35748b41..326120957 100644 --- a/webapp/components/user_settings/premade_theme_chooser.jsx +++ b/webapp/components/user_settings/premade_theme_chooser.jsx @@ -7,6 +7,8 @@ import Constants from 'utils/constants.jsx'; import React from 'react'; +import {FormattedMessage} from 'react-intl'; + export default class PremadeThemeChooser extends React.Component { constructor(props) { super(props); @@ -50,6 +52,17 @@ export default class PremadeThemeChooser extends React.Component { return ( <div className='row appearance-section'> {premadeThemes} + <div className='col-sm-12 padding-bottom x2'> + <a + href='http://docs.mattermost.com/help/settings/theme-colors.html#custom-themes' + target='_blank' + > + <FormattedMessage + id='user.settings.display.theme.otherThemes' + defaultMessage='See other themes' + /> + </a> + </div> </div> ); } diff --git a/webapp/components/user_settings/user_settings_advanced.jsx b/webapp/components/user_settings/user_settings_advanced.jsx index 4fcdc9a41..61e0e1dad 100644 --- a/webapp/components/user_settings/user_settings_advanced.jsx +++ b/webapp/components/user_settings/user_settings_advanced.jsx @@ -1,6 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import $ from 'jquery'; import * as AsyncClient from 'utils/async_client.jsx'; import SettingItemMin from '../setting_item_min.jsx'; import SettingItemMax from '../setting_item_max.jsx'; @@ -151,6 +152,7 @@ class AdvancedSettingsDisplay extends React.Component { } updateSection(section) { + $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update'); this.props.updateSection(section); } diff --git a/webapp/components/user_settings/user_settings_display.jsx b/webapp/components/user_settings/user_settings_display.jsx index e56156049..d169e01b5 100644 --- a/webapp/components/user_settings/user_settings_display.jsx +++ b/webapp/components/user_settings/user_settings_display.jsx @@ -1,6 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import $ from 'jquery'; import SettingItemMin from '../setting_item_min.jsx'; import SettingItemMax from '../setting_item_max.jsx'; import ManageLanguages from './manage_languages.jsx'; @@ -83,6 +84,7 @@ export default class UserSettingsDisplay extends React.Component { this.setState({selectedFont}); } updateSection(section) { + $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update'); this.updateState(); this.props.updateSection(section); } @@ -302,7 +304,7 @@ export default class UserSettingsDisplay extends React.Component { describe = ( <FormattedMessage id='user.settings.display.showUsername' - defaultMessage='Show username (team default)' + defaultMessage='Show username (default)' /> ); } else if (this.state.nameFormat === 'full_name') { diff --git a/webapp/components/user_settings/user_settings_general.jsx b/webapp/components/user_settings/user_settings_general.jsx index 2129847aa..eddbc1efe 100644 --- a/webapp/components/user_settings/user_settings_general.jsx +++ b/webapp/components/user_settings/user_settings_general.jsx @@ -1,6 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import $ from 'jquery'; import SettingItemMin from '../setting_item_min.jsx'; import SettingItemMax from '../setting_item_max.jsx'; import SettingPicture from '../setting_picture.jsx'; @@ -13,7 +14,7 @@ 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'; +import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedHTMLMessage, FormattedDate} from 'react-intl'; const holders = defineMessages({ usernameReserved: { @@ -36,18 +37,6 @@ const holders = defineMessages({ 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' @@ -72,10 +61,6 @@ const holders = defineMessages({ id: 'user.settings.general.username', defaultMessage: 'Username' }, - email: { - id: 'user.settings.general.email', - defaultMessage: 'Email' - }, profilePicture: { id: 'user.settings.general.profilePicture', defaultMessage: 'Profile Picture' @@ -286,6 +271,7 @@ class UserSettingsGeneralTab extends React.Component { } } updateSection(section) { + $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update'); const emailChangeInProgress = this.state.emailChangeInProgress; this.setState(Object.assign({}, this.setupInitialState(this.props), {emailChangeInProgress, clientError: '', serverError: '', emailError: ''})); this.submitActive = false; @@ -297,9 +283,224 @@ class UserSettingsGeneralTab extends React.Component { 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}; } + createEmailSection() { + let emailSection; + + if (this.props.activeSection === 'email') { + const emailEnabled = global.window.mm_config.SendEmailNotifications === 'true'; + const emailVerificationEnabled = global.window.mm_config.RequireEmailVerification === 'true'; + const inputs = []; + + 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.emailGitlabCantUpdate' + defaultMessage='Login occurs through GitLab. Email cannot be updated. Email address used for notifications is {email}.' + values={{ + email: this.state.email + }} + /> + </div> + {helpText} + </div> + ); + } else if (this.props.user.auth_service === Constants.LDAP_SERVICE) { + inputs.push( + <div + key='oauthEmailInfo' + className='form-group' + > + <div className='setting-list__hint'> + <FormattedMessage + id='user.settings.general.emailLdapCantUpdate' + defaultMessage='Login occurs through LDAP. Email cannot be updated. Email address used for notifications is {email}.' + values={{ + email: this.state.email + }} + /> + </div> + {helpText} + </div> + ); + } + + emailSection = ( + <SettingItemMax + title={ + <FormattedMessage + id='user.settings.general.email' + defaultMessage='Email' + /> + } + inputs={inputs} + submit={submit} + server_error={this.state.serverError} + client_error={this.state.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 = ( + <FormattedHTMLMessage + id='user.settings.general.newAddress' + defaultMessage='New Address: {email}<br />Check your email to verify the above address.' + values={{ + email: newEmail + }} + /> + ); + } else { + describe = ( + <FormattedMessage + id='user.settings.general.checkEmailNoAddress' + defaultMessage='Check your email to verify your new address' + /> + ); + } + } else { + describe = UserStore.getCurrentUser().email; + } + } else if (this.props.user.auth_service === Constants.GITLAB_SERVICE) { + describe = ( + <FormattedMessage + id='user.settings.general.loginGitlab' + defaultMessage='Login done through GitLab ({email})' + values={{ + email: this.state.email + }} + /> + ); + } else if (this.props.user.auth_service === Constants.LDAP_SERVICE) { + describe = ( + <FormattedMessage + id='user.settings.general.loginLdap' + defaultMessage='Login done through LDAP ({email})' + values={{ + email: this.state.email + }} + /> + ); + } + + emailSection = ( + <SettingItemMin + title={ + <FormattedMessage + id='user.settings.general.email' + defaultMessage='Email' + /> + } + describe={describe} + updateSection={() => { + this.updateSection('email'); + }} + /> + ); + } + + return emailSection; + } render() { const user = this.props.user; - const {formatMessage, formatHTMLMessage} = this.props.intl; + const {formatMessage} = this.props.intl; let clientError = null; if (this.state.clientError) { @@ -309,10 +510,6 @@ class UserSettingsGeneralTab extends React.Component { if (this.state.serverError) { serverError = this.state.serverError; } - let emailError = null; - if (this.state.emailError) { - emailError = this.state.emailError; - } let nameSection; const inputs = []; @@ -407,20 +604,27 @@ class UserSettingsGeneralTab extends React.Component { /> ); } else { - let fullName = ''; + let describe = ''; if (user.first_name && user.last_name) { - fullName = user.first_name + ' ' + user.last_name; + describe = user.first_name + ' ' + user.last_name; } else if (user.first_name) { - fullName = user.first_name; + describe = user.first_name; } else if (user.last_name) { - fullName = user.last_name; + describe = user.last_name; + } else { + describe = ( + <FormattedMessage + id='user.settings.general.emptyName' + defaultMessage="Click 'Edit' to add your full name" + /> + ); } nameSection = ( <SettingItemMin title={formatMessage(holders.fullName)} - describe={fullName} + describe={describe} updateSection={() => { this.updateSection('name'); }} @@ -481,10 +685,22 @@ class UserSettingsGeneralTab extends React.Component { /> ); } else { + let describe = ''; + if (user.nickname) { + describe = user.nickname; + } else { + describe = ( + <FormattedMessage + id='user.settings.general.emptyNickname' + defaultMessage="Click 'Edit' to add a nickname" + /> + ); + } + nicknameSection = ( <SettingItemMin title={formatMessage(holders.nickname)} - describe={UserStore.getCurrentUser().nickname} + describe={describe} updateSection={() => { this.updateSection('nickname'); }} @@ -557,152 +773,7 @@ class UserSettingsGeneralTab extends React.Component { ); } - 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'); - }} - /> - ); - } + const emailSection = this.createEmailSection(); let pictureSection; if (this.props.activeSection === 'picture') { diff --git a/webapp/components/user_settings/user_settings_integrations.jsx b/webapp/components/user_settings/user_settings_integrations.jsx index 94fc184bd..37081b863 100644 --- a/webapp/components/user_settings/user_settings_integrations.jsx +++ b/webapp/components/user_settings/user_settings_integrations.jsx @@ -1,31 +1,14 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import $ from 'jquery'; 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' @@ -47,77 +30,14 @@ class UserSettingsIntegrationsTab extends React.Component { this.state = {}; } updateSection(section) { + $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update'); 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( @@ -185,10 +105,6 @@ class UserSettingsIntegrationsTab extends React.Component { /> </h3> <div className='divider-dark first'/> - {incomingHooksSection} - <div className='divider-light'/> - {outgoingHooksSection} - <div className='divider-dark'/> {commandHooksSection} <div className='divider-dark'/> </div> @@ -207,4 +123,4 @@ UserSettingsIntegrationsTab.propTypes = { collapseModal: React.PropTypes.func.isRequired }; -export default injectIntl(UserSettingsIntegrationsTab);
\ No newline at end of file +export default injectIntl(UserSettingsIntegrationsTab); diff --git a/webapp/components/user_settings/user_settings_modal.jsx b/webapp/components/user_settings/user_settings_modal.jsx index d1c1f0fe2..b71547baf 100644 --- a/webapp/components/user_settings/user_settings_modal.jsx +++ b/webapp/components/user_settings/user_settings_modal.jsx @@ -9,7 +9,6 @@ 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'; @@ -113,7 +112,6 @@ class UserSettingsModal extends React.Component { return; } - this.resetTheme(); this.deactivateTab(); this.props.onModalDismissed(); return; @@ -220,22 +218,10 @@ class UserSettingsModal extends React.Component { 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) { diff --git a/webapp/components/user_settings/user_settings_notifications.jsx b/webapp/components/user_settings/user_settings_notifications.jsx index fe2db6727..b119c42f9 100644 --- a/webapp/components/user_settings/user_settings_notifications.jsx +++ b/webapp/components/user_settings/user_settings_notifications.jsx @@ -1,6 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import $ from 'jquery'; import ReactDOM from 'react-dom'; import SettingItemMin from '../setting_item_min.jsx'; import SettingItemMax from '../setting_item_max.jsx'; @@ -162,6 +163,7 @@ class NotificationsTab extends React.Component { this.updateState(); this.props.updateSection(''); e.preventDefault(); + $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update'); } updateSection(section) { this.updateState(); diff --git a/webapp/components/user_settings/user_settings_security.jsx b/webapp/components/user_settings/user_settings_security.jsx index 283d2c425..ff5a898a9 100644 --- a/webapp/components/user_settings/user_settings_security.jsx +++ b/webapp/components/user_settings/user_settings_security.jsx @@ -1,6 +1,7 @@ // Copyright (c) 2015 Mattermost, Inc. All Rights Reserved. // See License.txt for license information. +import $ from 'jquery'; import SettingItemMin from '../setting_item_min.jsx'; import SettingItemMax from '../setting_item_max.jsx'; import AccessHistoryModal from '../access_history_modal.jsx'; @@ -30,14 +31,6 @@ const holders = defineMessages({ 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' @@ -55,12 +48,16 @@ class SecurityTab extends React.Component { super(props); this.submitPassword = this.submitPassword.bind(this); + this.activateMfa = this.activateMfa.bind(this); + this.deactivateMfa = this.deactivateMfa.bind(this); this.updateCurrentPassword = this.updateCurrentPassword.bind(this); this.updateNewPassword = this.updateNewPassword.bind(this); this.updateConfirmPassword = this.updateConfirmPassword.bind(this); + this.updateMfaToken = this.updateMfaToken.bind(this); this.getDefaultState = this.getDefaultState.bind(this); this.createPasswordSection = this.createPasswordSection.bind(this); this.createSignInSection = this.createSignInSection.bind(this); + this.showQrCode = this.showQrCode.bind(this); this.state = this.getDefaultState(); } @@ -69,7 +66,9 @@ class SecurityTab extends React.Component { currentPassword: '', newPassword: '', confirmPassword: '', - authService: this.props.user.auth_service + authService: this.props.user.auth_service, + mfaShowQr: false, + mfaToken: '' }; } submitPassword(e) { @@ -120,6 +119,51 @@ class SecurityTab extends React.Component { } ); } + activateMfa() { + const data = {}; + data.activate = true; + data.token = this.state.mfaToken; + + Client.updateMfa(data, + () => { + this.props.updateSection(''); + AsyncClient.getMe(); + this.setState(this.getDefaultState()); + }, + (err) => { + const state = this.getDefaultState(); + if (err.message) { + state.serverError = err.message; + } else { + state.serverError = err; + } + state.mfaError = ''; + this.setState(state); + } + ); + } + deactivateMfa() { + const data = {}; + data.activate = false; + + Client.updateMfa(data, + () => { + this.props.updateSection(''); + AsyncClient.getMe(); + this.setState(this.getDefaultState()); + }, + (err) => { + const state = this.getDefaultState(); + if (err.message) { + state.serverError = err.message; + } else { + state.serverError = err; + } + state.mfaError = ''; + this.setState(state); + } + ); + } updateCurrentPassword(e) { this.setState({currentPassword: e.target.value}); } @@ -129,123 +173,335 @@ class SecurityTab extends React.Component { updateConfirmPassword(e) { this.setState({confirmPassword: e.target.value}); } - createPasswordSection() { + updateMfaToken(e) { + this.setState({mfaToken: e.target.value}); + } + showQrCode(e) { + e.preventDefault(); + this.setState({mfaShowQr: true}); + } + createMfaSection() { let updateSectionStatus; - const {formatMessage} = this.props.intl; - - if (this.props.activeSection === 'password' && this.props.user.auth_service === '') { - const inputs = []; + let submit; + + if (this.props.activeSection === 'mfa') { + let content; + let extraInfo; + if (this.props.user.mfa_active) { + content = ( + <div key='mfaQrCode'> + <a + className='btn btn-primary' + href='#' + onClick={this.deactivateMfa} + > + <FormattedMessage + id='user.settings.mfa.remove' + defaultMessage='Remove MFA from your account' + /> + </a> + <br/> + </div> + ); - inputs.push( - <div - key='currentPasswordUpdateForm' - className='form-group' - > - <label className='col-sm-5 control-label'> + extraInfo = ( + <span> <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} + id='user.settings.mfa.removeHelp' + defaultMessage='Removing multi-factor authentication will make your account more vulnerable to attacks.' /> + </span> + ); + } else if (this.state.mfaShowQr) { + content = ( + <div key='mfaButton'> + <label className='col-sm-5 control-label'> + <FormattedMessage + id='user.settings.mfa.qrCode' + defaultMessage='QR Code' + /> + </label> + <div className='col-sm-7'> + <img + className='qr-code-img' + src={'/api/v1/users/generate_mfa_qr?time=' + this.props.user.update_at} + /> + </div> + <br/> + <label className='col-sm-5 control-label'> + <FormattedMessage + id='user.settings.mfa.enterToken' + defaultMessage='Token' + /> + </label> + <div className='col-sm-7'> + <input + className='form-control' + type='text' + onChange={this.updateMfaToken} + value={this.state.mfaToken} + /> + </div> </div> - </div> - ); - inputs.push( - <div - key='newPasswordUpdateForm' - className='form-group' - > - <label className='col-sm-5 control-label'> + ); + + extraInfo = ( + <span> <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} + id='user.settings.mfa.addHelpQr' + defaultMessage='Please scan the QR code with the Google Authenticator app on your smartphone and fill in the token with one provided by the app.' /> + </span> + ); + + submit = this.activateMfa; + } else { + content = ( + <div key='mfaQrCode'> + <a + className='btn btn-primary' + href='#' + onClick={this.showQrCode} + > + <FormattedMessage + id='user.settings.mfa.add' + defaultMessage='Add MFA to your account' + /> + </a> + <br/> </div> - </div> - ); + ); + + extraInfo = ( + <span> + <FormattedMessage + id='user.settings.mfa.addHelp' + defaultMessage='To add multi-factor authentication to your account you must have a smartphone with Google Authenticator installed.' + /> + </span> + ); + } + + const inputs = []; inputs.push( <div - key='retypeNewPasswordUpdateForm' + key='mfaSetting' 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> + {content} </div> ); updateSectionStatus = function resetSection(e) { this.props.updateSection(''); - this.setState({currentPassword: '', newPassword: '', confirmPassword: '', serverError: null, passwordError: null}); + this.setState({mfaToken: '', mfaShowQr: false, mfaError: null}); e.preventDefault(); }.bind(this); return ( <SettingItemMax - title={formatMessage(holders.password)} + title={Utils.localizeMessage('user.settings.mfa.title', 'Multi-factor Authentication')} inputs={inputs} - submit={this.submitPassword} + extraInfo={extraInfo} + submit={submit} server_error={this.state.serverError} - client_error={this.state.passwordError} + client_error={this.state.mfaError} updateSection={updateSectionStatus} /> ); } - var describe; - var d = new Date(this.props.user.last_password_update); + let describe; + if (this.props.user.mfa_active) { + describe = Utils.localizeMessage('user.settings.security.active', 'Active'); + } else { + describe = Utils.localizeMessage('user.settings.security.inactive', 'Inactive'); + } + + updateSectionStatus = function updateSection() { + this.props.updateSection('mfa'); + }.bind(this); - const hours12 = !Utils.isMilitaryTime(); - describe = ( - <FormattedMessage - id='user.settings.security.lastUpdated' - defaultMessage='Last updated {date} at {time}' - values={{ - date: ( - <FormattedDate - value={d} - day='2-digit' - month='short' - year='numeric' - /> - ), - time: ( - <FormattedTime - value={d} - hour12={hours12} - hour='2-digit' - minute='2-digit' - /> - ) - }} + return ( + <SettingItemMin + title={Utils.localizeMessage('user.settings.mfa.title', 'Multi-factor Authentication')} + describe={describe} + updateSection={updateSectionStatus} /> ); + } + createPasswordSection() { + let updateSectionStatus; + + if (this.props.activeSection === 'password') { + const inputs = []; + let submit; + + if (this.props.user.auth_service === '') { + submit = this.submitPassword; + + 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> + ); + } 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.security.passwordGitlabCantUpdate' + defaultMessage='Login occurs through GitLab. Password cannot be updated.' + /> + </div> + </div> + ); + } else if (this.props.user.auth_service === Constants.LDAP_SERVICE) { + inputs.push( + <div + key='oauthEmailInfo' + className='form-group' + > + <div className='setting-list__hint'> + <FormattedMessage + id='user.settings.security.passwordLdapCantUpdate' + defaultMessage='Login occurs through LDAP. Password cannot be updated.' + /> + </div> + </div> + ); + } + + updateSectionStatus = function resetSection(e) { + this.props.updateSection(''); + this.setState({currentPassword: '', newPassword: '', confirmPassword: '', serverError: null, passwordError: null}); + e.preventDefault(); + $('.settings-modal .modal-body').scrollTop(0).perfectScrollbar('update'); + }.bind(this); + + return ( + <SettingItemMax + title={ + <FormattedMessage + id='user.settings.security.password' + defaultMessage='Password' + /> + } + inputs={inputs} + submit={submit} + server_error={this.state.serverError} + client_error={this.state.passwordError} + updateSection={updateSectionStatus} + /> + ); + } + + let describe; + + if (this.props.user.auth_service === '') { + const d = new Date(this.props.user.last_password_update); + const hours12 = !Utils.isMilitaryTime(); + + describe = ( + <FormattedMessage + id='user.settings.security.lastUpdated' + defaultMessage='Last updated {date} at {time}' + values={{ + date: ( + <FormattedDate + value={d} + day='2-digit' + month='short' + year='numeric' + /> + ), + time: ( + <FormattedTime + value={d} + hour12={hours12} + hour='2-digit' + minute='2-digit' + /> + ) + }} + /> + ); + } else if (this.props.user.auth_service === Constants.GITLAB_SERVICE) { + describe = ( + <FormattedMessage + id='user.settings.security.loginGitlab' + defaultMessage='Login done through Gitlab' + /> + ); + } else if (this.props.user.auth_service === Constants.LDAP_SERVICE) { + describe = ( + <FormattedMessage + id='user.settings.security.loginLdap' + defaultMessage='Login done through LDAP' + /> + ); + } updateSectionStatus = function updateSection() { this.props.updateSection('password'); @@ -253,7 +509,12 @@ class SecurityTab extends React.Component { return ( <SettingItemMin - title={formatMessage(holders.password)} + title={ + <FormattedMessage + id='user.settings.security.password' + defaultMessage='Password' + /> + } describe={describe} updateSection={updateSectionStatus} /> @@ -264,7 +525,6 @@ class SecurityTab extends React.Component { const user = this.props.user; if (this.props.activeSection === 'signin') { - const inputs = []; const teamName = TeamStore.getCurrent().name; let emailOption; @@ -346,6 +606,7 @@ class SecurityTab extends React.Component { ); } + const inputs = []; inputs.push( <div key='userSignInOption'> {emailOption} @@ -411,16 +672,22 @@ class SecurityTab extends React.Component { } 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; + numMethods = global.window.mm_config.EnableLdap === 'true' ? numMethods + 1 : numMethods; - if (global.window.mm_config.EnableSignUpWithEmail && numMethods > 0) { + let signInSection; + if (global.window.mm_config.EnableSignUpWithEmail === 'true' && numMethods > 0) { signInSection = this.createSignInSection(); } + let mfaSection; + if (global.window.mm_config.EnableMultifactorAuthentication === 'true' && global.window.mm_license.IsLicensed === 'true') { + mfaSection = this.createMfaSection(); + } + return ( <div> <div className='modal-header'> @@ -459,6 +726,8 @@ class SecurityTab extends React.Component { <div className='divider-dark first'/> {passwordSection} <div className='divider-light'/> + {mfaSection} + <div className='divider-light'/> {signInSection} <div className='divider-dark'/> <br></br> diff --git a/webapp/components/user_settings/user_settings_theme.jsx b/webapp/components/user_settings/user_settings_theme.jsx index 3414fe2e2..14991037d 100644 --- a/webapp/components/user_settings/user_settings_theme.jsx +++ b/webapp/components/user_settings/user_settings_theme.jsx @@ -40,7 +40,6 @@ export default class ThemeSetting extends React.Component { 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); @@ -62,12 +61,17 @@ export default class ThemeSetting extends React.Component { } } componentWillReceiveProps(nextProps) { - if (!this.props.selected && nextProps.selected) { + if (this.props.selected && !nextProps.selected) { this.resetFields(); } } componentWillUnmount() { UserStore.removeChangeListener(this.onChange); + + if (this.props.selected) { + const state = this.getStateFromStores(); + Utils.applyTheme(state.theme); + } } getStateFromStores() { const user = UserStore.getCurrentUser(); @@ -147,11 +151,6 @@ export default class ThemeSetting extends React.Component { updateType(type) { this.setState({type}); } - deactivate() { - const state = this.getStateFromStores(); - - Utils.applyTheme(state.theme); - } resetFields() { const state = this.getStateFromStores(); state.serverError = null; |