Commit ca2889d0 authored by enahum's avatar enahum Committed by Harrison Healey
Browse files

PLT-3484 OAuth2 Service Provider (#3632)

* PLT-3484 OAuth2 Service Provider

* PM text review for OAuth 2.0 Service Provider

* PLT-3484 OAuth2 Service Provider UI tweaks (#3668)

* Tweaks to help text

* Pushing OAuth improvements (#3680)

* Re-arrange System Console for OAuth 2.0 Provider
parent fb7e027e
......@@ -308,13 +308,6 @@ export function showLeaveTeamModal() {
});
}
export function showRegisterAppModal() {
AppDispatcher.handleViewAction({
type: ActionTypes.TOGGLE_REGISTER_APP_MODAL,
value: true
});
}
export function emitSuggestionPretextChanged(suggestionId, pretext) {
AppDispatcher.handleViewAction({
type: ActionTypes.SUGGESTION_PRETEXT_CHANGED,
......
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import Client from 'client/web_client.jsx';
import AppDispatcher from '../dispatcher/app_dispatcher.jsx';
import Constants from 'utils/constants.jsx';
const ActionTypes = Constants.ActionTypes;
export function listOAuthApps(userId, onSuccess, onError) {
Client.listOAuthApps(
(data) => {
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_OAUTHAPPS,
userId,
oauthApps: data
});
if (onSuccess) {
onSuccess(data);
}
},
onError
);
}
export function deleteOAuthApp(id, userId, onSuccess, onError) {
Client.deleteOAuthApp(
id,
() => {
AppDispatcher.handleServerAction({
type: ActionTypes.REMOVED_OAUTHAPP,
userId,
id
});
if (onSuccess) {
onSuccess();
}
},
onError
);
}
export function registerOAuthApp(app, onSuccess, onError) {
Client.registerOAuthApp(
app,
(data) => {
AppDispatcher.handleServerAction({
type: ActionTypes.RECEIVED_OAUTHAPP,
oauthApp: data
});
if (onSuccess) {
onSuccess();
}
},
onError
);
}
\ No newline at end of file
......@@ -1498,6 +1498,36 @@ export default class Client {
end(this.handleResponse.bind(this, 'allowOAuth2', success, error));
}
listOAuthApps(success, error) {
request.
get(`${this.getOAuthRoute()}/list`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
send().
end(this.handleResponse.bind(this, 'getOAuthApps', success, error));
}
deleteOAuthApp(id, success, error) {
request.
post(`${this.getOAuthRoute()}/delete`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
send({id}).
end(this.handleResponse.bind(this, 'deleteOAuthApp', success, error));
}
getOAuthAppInfo(id, success, error) {
request.
get(`${this.getOAuthRoute()}/app/${id}`).
set(this.defaultHeaders).
type('application/json').
accept('application/json').
send().
end(this.handleResponse.bind(this, 'getOAuthAppInfo', success, error));
}
// Routes for Hooks
addIncomingHook(hook, success, error) {
......
......@@ -8,7 +8,6 @@ import Client from 'client/web_client.jsx';
import FormError from 'components/form_error.jsx';
import SaveButton from 'components/admin_console/save_button.jsx';
import Constants from 'utils/constants.jsx';
export default class AdminSettings extends React.Component {
static get propTypes() {
......@@ -22,7 +21,6 @@ export default class AdminSettings extends React.Component {
this.handleChange = this.handleChange.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.state = Object.assign(this.getStateFromConfig(props.config), {
saveNeeded: false,
......@@ -38,20 +36,6 @@ export default class AdminSettings extends React.Component {
});
}
componentDidMount() {
document.addEventListener('keydown', this.onKeyDown);
}
componentWillUnmount() {
document.removeEventListener('keydown', this.onKeyDown);
}
onKeyDown(e) {
if (e.keyCode === Constants.KeyCodes.ENTER) {
this.handleSubmit(e);
}
}
handleSubmit(e) {
e.preventDefault();
......@@ -118,6 +102,7 @@ export default class AdminSettings extends React.Component {
<form
className='form-horizontal'
role='form'
onSubmit={this.handleSubmit}
>
{this.renderSettings()}
<div className='form-group'>
......
......@@ -521,11 +521,11 @@ export default class AdminSidebar extends React.Component {
}
>
<AdminSidebarSection
name='webhooks'
name='custom'
title={
<FormattedMessage
id='admin.sidebar.webhooks'
defaultMessage='Webhooks and Commands'
id='admin.sidebar.customIntegrations'
defaultMessage='Custom Integrations'
/>
}
/>
......
......@@ -24,6 +24,7 @@ export default class WebhookSettings extends AdminSettings {
config.ServiceSettings.EnableOnlyAdminIntegrations = this.state.enableOnlyAdminIntegrations;
config.ServiceSettings.EnablePostUsernameOverride = this.state.enablePostUsernameOverride;
config.ServiceSettings.EnablePostIconOverride = this.state.enablePostIconOverride;
config.ServiceSettings.EnableOAuthServiceProvider = this.state.enableOAuthServiceProvider;
return config;
}
......@@ -35,7 +36,8 @@ export default class WebhookSettings extends AdminSettings {
enableCommands: config.ServiceSettings.EnableCommands,
enableOnlyAdminIntegrations: config.ServiceSettings.EnableOnlyAdminIntegrations,
enablePostUsernameOverride: config.ServiceSettings.EnablePostUsernameOverride,
enablePostIconOverride: config.ServiceSettings.EnablePostIconOverride
enablePostIconOverride: config.ServiceSettings.EnablePostIconOverride,
enableOAuthServiceProvider: config.ServiceSettings.EnableOAuthServiceProvider
};
}
......@@ -43,8 +45,8 @@ export default class WebhookSettings extends AdminSettings {
return (
<h3>
<FormattedMessage
id='admin.integrations.webhook'
defaultMessage='Webhooks and Commands'
id='admin.integrations.custom'
defaultMessage='Custom Integrations'
/>
</h3>
);
......@@ -104,6 +106,23 @@ export default class WebhookSettings extends AdminSettings {
value={this.state.enableCommands}
onChange={this.handleChange}
/>
<BooleanSetting
id='enableOAuthServiceProvider'
label={
<FormattedMessage
id='admin.oauth.providerTitle'
defaultMessage='Enable OAuth 2.0 Service Provider: '
/>
}
helpText={
<FormattedMessage
id='admin.oauth.providerDescription'
defaultMessage='When true, Mattermost can act as an OAuth 2.0 service provider allowing external applications to authorize API requests to Mattermost.'
/>
}
value={this.state.enableOAuthServiceProvider}
onChange={this.handleChange}
/>
<BooleanSetting
id='enableOnlyAdminIntegrations'
label={
......
......@@ -10,6 +10,13 @@ import React from 'react';
import icon50 from 'images/icon50x50.png';
export default class Authorize extends React.Component {
static get propTypes() {
return {
location: React.PropTypes.object.isRequired,
params: React.PropTypes.object.isRequired
};
}
constructor(props) {
super(props);
......@@ -18,17 +25,31 @@ export default class Authorize extends React.Component {
this.state = {};
}
componentWillMount() {
Client.getOAuthAppInfo(
this.props.location.query.client_id,
(app) => {
this.setState({app});
}
);
}
componentDidMount() {
// if we get to this point remove the antiClickjack blocker
const blocker = document.getElementById('antiClickjack');
if (blocker) {
blocker.parentNode.removeChild(blocker);
}
}
handleAllow() {
const responseType = this.props.responseType;
const clientId = this.props.clientId;
const redirectUri = this.props.redirectUri;
const state = this.props.state;
const scope = this.props.scope;
const params = this.props.location.query;
Client.allowOAuth2(responseType, clientId, redirectUri, state, scope,
Client.allowOAuth2(params.response_type, params.client_id, params.redirect_uri, params.state, params.scope,
(data) => {
if (data.redirect) {
window.location.replace(data.redirect);
window.location.href = data.redirect;
}
},
() => {
......@@ -36,28 +57,42 @@ export default class Authorize extends React.Component {
}
);
}
handleDeny() {
window.location.replace(this.props.redirectUri + '?error=access_denied');
window.location.replace(this.props.location.query.redirect_uri + '?error=access_denied');
}
render() {
const app = this.state.app;
if (!app) {
return null;
}
let icon;
if (app.icon_url) {
icon = app.icon_url;
} else {
icon = icon50;
}
return (
<div className='container-fluid'>
<div className='prompt'>
<div className='prompt__heading'>
<div className='prompt__app-icon'>
<img
src={icon50}
src={icon}
width='50'
height='50'
alt=''
/>
</div>
<div className='text'>
<FormattedMessage
<FormattedHTMLMessage
id='authorize.title'
defaultMessage='An application would like to connect to your {teamName} account'
defaultMessage='<strong>{appName}</strong> would like to connect to your <strong>Mattermost</strong> user account'
values={{
teamName: this.props.teamName
appName: app.name
}}
/>
</div>
......@@ -67,7 +102,7 @@ export default class Authorize extends React.Component {
id='authorize.app'
defaultMessage='The app <strong>{appName}</strong> would like the ability to access and modify your basic information.'
values={{
appName: this.props.appName
appName: app.name
}}
/>
</p>
......@@ -76,14 +111,14 @@ export default class Authorize extends React.Component {
id='authorize.access'
defaultMessage='Allow <strong>{appName}</strong> access?'
values={{
appName: this.props.appName
appName: app.name
}}
/>
</h2>
<div className='prompt__buttons'>
<button
type='submit'
className='btn authorize-btn'
className='btn btn-link authorize-btn'
onClick={this.handleDeny}
>
<FormattedMessage
......@@ -107,13 +142,3 @@ export default class Authorize extends React.Component {
);
}
}
Authorize.propTypes = {
appName: React.PropTypes.string,
teamName: React.PropTypes.string,
responseType: React.PropTypes.string,
clientId: React.PropTypes.string,
redirectUri: React.PropTypes.string,
state: React.PropTypes.string,
scope: React.PropTypes.string
};
......@@ -39,20 +39,22 @@ export default class BackstageSidebar extends React.Component {
}
renderIntegrations() {
if (window.mm_config.EnableIncomingWebhooks !== 'true' &&
window.mm_config.EnableOutgoingWebhooks !== 'true' &&
window.mm_config.EnableCommands !== 'true') {
const config = window.mm_config;
if (config.EnableIncomingWebhooks !== 'true' &&
config.EnableOutgoingWebhooks !== 'true' &&
config.EnableCommands !== 'true' &&
config.EnableOAuthServiceProvider !== 'true') {
return null;
}
if (window.mm_config.EnableOnlyAdminIntegrations !== 'false' &&
if (config.EnableOnlyAdminIntegrations !== 'false' &&
!Utils.isSystemAdmin(this.props.user.roles) &&
!TeamStore.isTeamAdmin(this.props.user.id, this.props.team.id)) {
return null;
}
let incomingWebhooks = null;
if (window.mm_config.EnableIncomingWebhooks === 'true') {
if (config.EnableIncomingWebhooks === 'true') {
incomingWebhooks = (
<BackstageSection
name='incoming_webhooks'
......@@ -67,7 +69,7 @@ export default class BackstageSidebar extends React.Component {
}
let outgoingWebhooks = null;
if (window.mm_config.EnableOutgoingWebhooks === 'true') {
if (config.EnableOutgoingWebhooks === 'true') {
outgoingWebhooks = (
<BackstageSection
name='outgoing_webhooks'
......@@ -82,7 +84,7 @@ export default class BackstageSidebar extends React.Component {
}
let commands = null;
if (window.mm_config.EnableCommands === 'true') {
if (config.EnableCommands === 'true') {
commands = (
<BackstageSection
name='commands'
......@@ -96,6 +98,21 @@ export default class BackstageSidebar extends React.Component {
);
}
let oauthApps = null;
if (config.EnableOAuthServiceProvider === 'true') {
oauthApps = (
<BackstageSection
name='oauth2-apps'
title={
<FormattedMessage
id='backstage_sidebar.integrations.oauthApps'
defaultMessage='OAuth 2.0 Applications'
/>
}
/>
);
}
return (
<BackstageCategory
name='integrations'
......@@ -111,6 +128,7 @@ export default class BackstageSidebar extends React.Component {
{incomingWebhooks}
{outgoingWebhooks}
{commands}
{oauthApps}
</BackstageCategory>
);
}
......
// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved.
// See License.txt for license information.
import React from 'react';
import * as OAuthActions from 'actions/oauth_actions.jsx';
import BackstageHeader from 'components/backstage/components/backstage_header.jsx';
import {FormattedMessage} from 'react-intl';
import FormError from 'components/form_error.jsx';
import {browserHistory, Link} from 'react-router/es6';
import SpinnerButton from 'components/spinner_button.jsx';
export default class AddOAuthApp extends React.Component {
static get propTypes() {
return {
team: React.propTypes.object.isRequired
};
}
constructor(props) {
super(props);
this.handleSubmit = this.handleSubmit.bind(this);
this.updateName = this.updateName.bind(this);
this.updateTrusted = this.updateTrusted.bind(this);
this.updateDescription = this.updateDescription.bind(this);
this.updateHomepage = this.updateHomepage.bind(this);
this.updateIconUrl = this.updateIconUrl.bind(this);
this.updateCallbackUrls = this.updateCallbackUrls.bind(this);
this.imageLoaded = this.imageLoaded.bind(this);
this.image = new Image();
this.image.onload = this.imageLoaded;
this.state = {
name: '',
description: '',
homepage: '',
icon_url: '',
callbackUrls: '',
is_trusted: false,
has_icon: false,
saving: false,
serverError: '',
clientError: null
};
}
imageLoaded() {
this.setState({
has_icon: true,
icon_url: this.refs.icon_url.value
});
}
handleSubmit(e) {
e.preventDefault();
if (this.state.saving) {
return;
}
this.setState({
saving: true,
serverError: '',
clientError: ''
});
if (!this.state.name) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_oauth_app.nameRequired'
defaultMessage='Name for the OAuth 2.0 application is required.'
/>
)
});
return;
}
if (!this.state.description) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_oauth_app.descriptionRequired'
defaultMessage='Description for the OAuth 2.0 application is required.'
/>
)
});
return;
}
if (!this.state.homepage) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_oauth_app.homepageRequired'
defaultMessage='Homepage for the OAuth 2.0 application is required.'
/>
)
});
return;
}
const callbackUrls = [];
for (let callbackUrl of this.state.callbackUrls.split('\n')) {
callbackUrl = callbackUrl.trim();
if (callbackUrl.length > 0) {
callbackUrls.push(callbackUrl);
}
}
if (callbackUrls.length === 0) {
this.setState({
saving: false,
clientError: (
<FormattedMessage
id='add_oauth_app.callbackUrlsRequired'
defaultMessage='One or more callback URLs are required.'
/>
)
});
return;
}
const app = {
name: this.state.name,
callback_urls: callbackUrls,
homepage: this.state.homepage,
description: this.state.description,
is_trusted: this.state.is_trusted,
icon_url: this.state.icon_url
};
OAuthActions.registerOAuthApp(
app,
() => {
browserHistory.push('/' + this.props.team.name + '/integrations/oauth2-apps');
},
(err) => {
this.setState({
saving: false,
serverError: err.message
});
}
);
}
updateName(e) {
this.setState({
name: e.target.value
});