diff options
author | enahum <nahumhbl@gmail.com> | 2016-08-23 19:06:17 -0300 |
---|---|---|
committer | Joram Wilander <jwawilander@gmail.com> | 2016-08-23 18:06:17 -0400 |
commit | 9ab5a7996247c98ed6267b638e1b313e7c4eb8ff (patch) | |
tree | 95579883cd48370ee48259b2bec02b124df2f200 | |
parent | e406a92fbbfe36765ab66d9879a9c94546c7c281 (diff) | |
download | chat-9ab5a7996247c98ed6267b638e1b313e7c4eb8ff.tar.gz chat-9ab5a7996247c98ed6267b638e1b313e7c4eb8ff.tar.bz2 chat-9ab5a7996247c98ed6267b638e1b313e7c4eb8ff.zip |
PLT-3745 - Deauthorize OAuth Apps (#3852)
* Deauthorize OAuth APIs
* Deautorize OAuth Apps Account Settings
* Fix typo in client method
* Fix issues found by PM
* Show help text only when there is at least one authorized app
-rw-r--r-- | Makefile | 2 | ||||
-rw-r--r-- | api/oauth.go | 69 | ||||
-rw-r--r-- | api/oauth_test.go | 56 | ||||
-rw-r--r-- | i18n/en.json | 4 | ||||
-rw-r--r-- | model/client.go | 23 | ||||
-rw-r--r-- | store/sql_oauth_store.go | 50 | ||||
-rw-r--r-- | store/sql_oauth_store_test.go | 76 | ||||
-rw-r--r-- | store/store.go | 2 | ||||
-rw-r--r-- | webapp/client/client.jsx | 20 | ||||
-rw-r--r-- | webapp/components/user_settings/user_settings_security.jsx | 228 | ||||
-rw-r--r-- | webapp/i18n/en.json | 7 | ||||
-rw-r--r-- | webapp/sass/routes/_settings.scss | 36 |
12 files changed, 532 insertions, 41 deletions
@@ -159,7 +159,7 @@ test-server: start-docker prepare-enterprise rm -f cover.out echo "mode: count" > cover.out - $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=440s -covermode=count -coverprofile=capi.out ./api || exit 1 + $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=650s -covermode=count -coverprofile=capi.out ./api || exit 1 $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=60s -covermode=count -coverprofile=cmodel.out ./model || exit 1 $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=180s -covermode=count -coverprofile=cstore.out ./store || exit 1 $(GO) test $(GOFLAGS) -run=$(TESTS) -test.v -test.timeout=120s -covermode=count -coverprofile=cutils.out ./utils || exit 1 diff --git a/api/oauth.go b/api/oauth.go index 546b0bdca..6e7649d8d 100644 --- a/api/oauth.go +++ b/api/oauth.go @@ -29,7 +29,9 @@ func InitOAuth() { BaseRoutes.OAuth.Handle("/list", ApiUserRequired(getOAuthApps)).Methods("GET") BaseRoutes.OAuth.Handle("/app/{client_id}", ApiUserRequired(getOAuthAppInfo)).Methods("GET") BaseRoutes.OAuth.Handle("/allow", ApiUserRequired(allowOAuth)).Methods("GET") + BaseRoutes.OAuth.Handle("/authorized", ApiUserRequired(getAuthorizedApps)).Methods("GET") BaseRoutes.OAuth.Handle("/delete", ApiUserRequired(deleteOAuthApp)).Methods("POST") + BaseRoutes.OAuth.Handle("/{id:[A-Za-z0-9]+}/deauthorize", AppHandlerIndependent(deauthorizeOAuthApp)).Methods("POST") BaseRoutes.OAuth.Handle("/{service:[A-Za-z0-9]+}/complete", AppHandlerIndependent(completeOAuth)).Methods("GET") BaseRoutes.OAuth.Handle("/{service:[A-Za-z0-9]+}/login", AppHandlerIndependent(loginWithOAuth)).Methods("GET") BaseRoutes.OAuth.Handle("/{service:[A-Za-z0-9]+}/signup", AppHandlerIndependent(signupWithOAuth)).Methods("GET") @@ -227,6 +229,28 @@ func allowOAuth(c *Context, w http.ResponseWriter, r *http.Request) { w.Write([]byte(model.MapToJson(responseData))) } +func getAuthorizedApps(c *Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { + c.Err = model.NewLocAppError("getAuthorizedApps", "api.oauth.allow_oauth.turn_off.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + ochan := Srv.Store.OAuth().GetAuthorizedApps(c.Session.UserId) + if result := <-ochan; result.Err != nil { + c.Err = result.Err + return + } else { + apps := result.Data.([]*model.OAuthApp) + for k, a := range apps { + a.Sanitize() + apps[k] = a + } + + w.Write([]byte(model.OAuthAppListToJson(apps))) + } +} + func RevokeAccessToken(token string) *model.AppError { schan := Srv.Store.Session().Remove(token) @@ -879,6 +903,51 @@ func deleteOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) { ReturnStatusOK(w) } +func deauthorizeOAuthApp(c *Context, w http.ResponseWriter, r *http.Request) { + if !utils.Cfg.ServiceSettings.EnableOAuthServiceProvider { + c.Err = model.NewLocAppError("deleteOAuthApp", "api.oauth.allow_oauth.turn_off.app_error", nil, "") + c.Err.StatusCode = http.StatusNotImplemented + return + } + + params := mux.Vars(r) + id := params["id"] + + if len(id) == 0 { + c.SetInvalidParam("deauthorizeOAuthApp", "id") + return + } + + // revoke app sessions + if result := <-Srv.Store.OAuth().GetAccessDataByUserForApp(c.Session.UserId, id); result.Err != nil { + c.Err = result.Err + return + } else { + accessData := result.Data.([]*model.AccessData) + + for _, a := range accessData { + if err := RevokeAccessToken(a.Token); err != nil { + c.Err = err + return + } + + if rad := <-Srv.Store.OAuth().RemoveAccessData(a.Token); rad.Err != nil { + c.Err = rad.Err + return + } + } + } + + // Deauthorize the app + if err := (<-Srv.Store.Preference().Delete(c.Session.UserId, model.PREFERENCE_CATEGORY_AUTHORIZED_OAUTH_APP, id)).Err; err != nil { + c.Err = err + return + } + + c.LogAudit("success") + ReturnStatusOK(w) +} + func newSession(appName string, user *model.User) (*model.Session, *model.AppError) { // set new token an session session := &model.Session{UserId: user.Id, Roles: user.Roles, IsOAuth: true} diff --git a/api/oauth_test.go b/api/oauth_test.go index 47fc342ce..944b1a95b 100644 --- a/api/oauth_test.go +++ b/api/oauth_test.go @@ -222,6 +222,62 @@ func TestGetOAuthAppInfo(t *testing.T) { } } +func TestGetAuthorizedApps(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + Client := th.BasicClient + AdminClient := th.SystemAdminClient + + utils.Cfg.ServiceSettings.EnableOAuthServiceProvider = true + + app := &model.OAuthApp{Name: "TestApp5" + model.NewId(), Homepage: "https://nowhere.com", Description: "test", CallbackUrls: []string{"https://nowhere.com"}} + + app = AdminClient.Must(AdminClient.RegisterApp(app)).Data.(*model.OAuthApp) + + if _, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, "https://nowhere.com", "user", ""); err != nil { + t.Fatal(err) + } + + if result, err := Client.GetOAuthAuthorizedApps(); err != nil { + t.Fatal(err) + } else { + apps := result.Data.([]*model.OAuthApp) + + if len(apps) != 1 { + t.Fatal("incorrect number of apps should have been 1") + } + } +} + +func TestDeauthorizeApp(t *testing.T) { + th := Setup().InitBasic().InitSystemAdmin() + Client := th.BasicClient + AdminClient := th.SystemAdminClient + + utils.Cfg.ServiceSettings.EnableOAuthServiceProvider = true + + app := &model.OAuthApp{Name: "TestApp5" + model.NewId(), Homepage: "https://nowhere.com", Description: "test", CallbackUrls: []string{"https://nowhere.com"}} + + app = AdminClient.Must(AdminClient.RegisterApp(app)).Data.(*model.OAuthApp) + + if _, err := Client.AllowOAuth(model.AUTHCODE_RESPONSE_TYPE, app.Id, "https://nowhere.com", "user", ""); err != nil { + t.Fatal(err) + } + + if err := Client.OAuthDeauthorizeApp(app.Id); err != nil { + t.Fatal(err) + } + + if result, err := Client.GetOAuthAuthorizedApps(); err != nil { + t.Fatal(err) + } else { + apps := result.Data.([]*model.OAuthApp) + + if len(apps) != 0 { + t.Fatal("incorrect number of apps should have been 0") + } + } +} + func TestOAuthDeleteApp(t *testing.T) { th := Setup().InitBasic().InitSystemAdmin() Client := th.BasicClient diff --git a/i18n/en.json b/i18n/en.json index a75909b95..d026a6066 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -3932,6 +3932,10 @@ "translation": "We encountered an error finding the access token" }, { + "id": "store.sql_oauth.get_access_data_by_user_for_app.app_error", + "translation": "We encountered an error finding all the access tokens" + }, + { "id": "store.sql_oauth.get_app.find.app_error", "translation": "We couldn't find the requested app" }, diff --git a/model/client.go b/model/client.go index 2d154e49f..7ef35ef6f 100644 --- a/model/client.go +++ b/model/client.go @@ -1532,6 +1532,29 @@ func (c *Client) DeleteOAuthApp(id string) (*Result, *AppError) { } } +// GetOAuthAuthorizedApps returns the OAuth2 Apps authorized by the user. On success +// it returns a list of sanitized OAuth2 Authorized Apps by the user. +func (c *Client) GetOAuthAuthorizedApps() (*Result, *AppError) { + if r, err := c.DoApiGet("/oauth/authorized", "", ""); err != nil { + return nil, err + } else { + defer closeBody(r) + return &Result{r.Header.Get(HEADER_REQUEST_ID), + r.Header.Get(HEADER_ETAG_SERVER), OAuthAppListFromJson(r.Body)}, nil + } +} + +// OAuthDeauthorizeApp deauthorize a user an OAuth 2.0 app. On success +// it returns status OK or an AppError on fail. +func (c *Client) OAuthDeauthorizeApp(clientId string) *AppError { + if r, err := c.DoApiPost("/oauth/"+clientId+"/deauthorize", ""); err != nil { + return err + } else { + defer closeBody(r) + return nil + } +} + func (c *Client) GetAccessToken(data url.Values) (*Result, *AppError) { if r, err := c.DoPost("/oauth/access_token", data.Encode(), "application/x-www-form-urlencoded"); err != nil { return nil, err diff --git a/store/sql_oauth_store.go b/store/sql_oauth_store.go index 6db54bd4a..0ee9f1ad1 100644 --- a/store/sql_oauth_store.go +++ b/store/sql_oauth_store.go @@ -211,6 +211,29 @@ func (as SqlOAuthStore) GetApps() StoreChannel { return storeChannel } +func (as SqlOAuthStore) GetAuthorizedApps(userId string) StoreChannel { + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var apps []*model.OAuthApp + + if _, err := as.GetReplica().Select(&apps, + `SELECT o.* FROM OAuthApps AS o INNER JOIN + Preferences AS p ON p.Name=o.Id AND p.UserId=:UserId`, map[string]interface{}{"UserId": userId}); err != nil { + result.Err = model.NewLocAppError("SqlOAuthStore.GetAuthorizedApps", "store.sql_oauth.get_apps.find.app_error", nil, "err="+err.Error()) + } + + result.Data = apps + + storeChannel <- result + close(storeChannel) + }() + + return storeChannel +} + func (as SqlOAuthStore) DeleteApp(id string) StoreChannel { storeChannel := make(StoreChannel) @@ -294,6 +317,33 @@ func (as SqlOAuthStore) GetAccessData(token string) StoreChannel { return storeChannel } +func (as SqlOAuthStore) GetAccessDataByUserForApp(userId, clientId string) StoreChannel { + + storeChannel := make(StoreChannel) + + go func() { + result := StoreResult{} + + var accessData []*model.AccessData + + if _, err := as.GetReplica().Select(&accessData, + "SELECT * FROM OAuthAccessData WHERE UserId = :UserId AND ClientId = :ClientId", + map[string]interface{}{"UserId": userId, "ClientId": clientId}); err != nil { + result.Err = model.NewLocAppError("SqlOAuthStore.GetAccessDataByUserForApp", + "store.sql_oauth.get_access_data_by_user_for_app.app_error", nil, + "user_id="+userId+" client_id="+clientId) + } else { + result.Data = accessData + } + + storeChannel <- result + close(storeChannel) + + }() + + return storeChannel +} + func (as SqlOAuthStore) GetAccessDataByRefreshToken(token string) StoreChannel { storeChannel := make(StoreChannel) diff --git a/store/sql_oauth_store_test.go b/store/sql_oauth_store_test.go index a88b0ea48..ebf9ad59b 100644 --- a/store/sql_oauth_store_test.go +++ b/store/sql_oauth_store_test.go @@ -202,6 +202,82 @@ func TestOAuthStoreRemoveAuthDataByUser(t *testing.T) { } } +func TestOAuthGetAuthorizedApps(t *testing.T) { + Setup() + + a1 := model.OAuthApp{} + a1.CreatorId = model.NewId() + a1.Name = "TestApp" + model.NewId() + a1.CallbackUrls = []string{"https://nowhere.com"} + a1.Homepage = "https://nowhere.com" + Must(store.OAuth().SaveApp(&a1)) + + // allow the app + p := model.Preference{} + p.UserId = a1.CreatorId + p.Category = model.PREFERENCE_CATEGORY_AUTHORIZED_OAUTH_APP + p.Name = a1.Id + p.Value = "true" + Must(store.Preference().Save(&model.Preferences{p})) + + if result := <-store.OAuth().GetAuthorizedApps(a1.CreatorId); result.Err != nil { + t.Fatal(result.Err) + } else { + apps := result.Data.([]*model.OAuthApp) + if len(apps) == 0 { + t.Fatal("It should have return apps") + } + } +} + +func TestOAuthGetAccessDataByUserForApp(t *testing.T) { + Setup() + + a1 := model.OAuthApp{} + a1.CreatorId = model.NewId() + a1.Name = "TestApp" + model.NewId() + a1.CallbackUrls = []string{"https://nowhere.com"} + a1.Homepage = "https://nowhere.com" + Must(store.OAuth().SaveApp(&a1)) + + // allow the app + p := model.Preference{} + p.UserId = a1.CreatorId + p.Category = model.PREFERENCE_CATEGORY_AUTHORIZED_OAUTH_APP + p.Name = a1.Id + p.Value = "true" + Must(store.Preference().Save(&model.Preferences{p})) + + if result := <-store.OAuth().GetAuthorizedApps(a1.CreatorId); result.Err != nil { + t.Fatal(result.Err) + } else { + apps := result.Data.([]*model.OAuthApp) + if len(apps) == 0 { + t.Fatal("It should have return apps") + } + } + + // save the token + ad1 := model.AccessData{} + ad1.ClientId = a1.Id + ad1.UserId = a1.CreatorId + ad1.Token = model.NewId() + ad1.RefreshToken = model.NewId() + + if err := (<-store.OAuth().SaveAccessData(&ad1)).Err; err != nil { + t.Fatal(err) + } + + if result := <-store.OAuth().GetAccessDataByUserForApp(a1.CreatorId, a1.Id); result.Err != nil { + t.Fatal(result.Err) + } else { + accessData := result.Data.([]*model.AccessData) + if len(accessData) == 0 { + t.Fatal("It should have return access data") + } + } +} + func TestOAuthStoreDeleteApp(t *testing.T) { a1 := model.OAuthApp{} a1.CreatorId = model.NewId() diff --git a/store/store.go b/store/store.go index b9a55fa2e..78db41e77 100644 --- a/store/store.go +++ b/store/store.go @@ -188,6 +188,7 @@ type OAuthStore interface { GetApp(id string) StoreChannel GetAppByUser(userId string) StoreChannel GetApps() StoreChannel + GetAuthorizedApps(userId string) StoreChannel DeleteApp(id string) StoreChannel SaveAuthData(authData *model.AuthData) StoreChannel GetAuthData(code string) StoreChannel @@ -196,6 +197,7 @@ type OAuthStore interface { SaveAccessData(accessData *model.AccessData) StoreChannel UpdateAccessData(accessData *model.AccessData) StoreChannel GetAccessData(token string) StoreChannel + GetAccessDataByUserForApp(userId, clientId string) StoreChannel GetAccessDataByRefreshToken(token string) StoreChannel GetPreviousAccessData(userId, clientId string) StoreChannel RemoveAccessData(token string) StoreChannel diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx index 28d121011..5dda975f6 100644 --- a/webapp/client/client.jsx +++ b/webapp/client/client.jsx @@ -1553,6 +1553,26 @@ export default class Client { end(this.handleResponse.bind(this, 'getOAuthAppInfo', success, error)); } + getAuthorizedApps(success, error) { + request. + get(`${this.getOAuthRoute()}/authorized`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send(). + end(this.handleResponse.bind(this, 'getAuthorizedApps', success, error)); + } + + deauthorizeOAuthApp(id, success, error) { + request. + post(`${this.getOAuthRoute()}/${id}/deauthorize`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + send(). + end(this.handleResponse.bind(this, 'deauthorizeOAuthApp', success, error)); + } + // Routes for Hooks addIncomingHook(hook, success, error) { diff --git a/webapp/components/user_settings/user_settings_security.jsx b/webapp/components/user_settings/user_settings_security.jsx index 769959432..bcffa157c 100644 --- a/webapp/components/user_settings/user_settings_security.jsx +++ b/webapp/components/user_settings/user_settings_security.jsx @@ -16,33 +16,12 @@ import Constants from 'utils/constants.jsx'; import $ from 'jquery'; import React from 'react'; -import {intlShape, injectIntl, defineMessages, FormattedMessage, FormattedHTMLMessage, FormattedTime, FormattedDate} from 'react-intl'; +import {FormattedMessage, FormattedHTMLMessage, FormattedTime, FormattedDate} from 'react-intl'; import {Link} from 'react-router/es6'; -const holders = defineMessages({ - currentPasswordError: { - id: 'user.settings.security.currentPasswordError', - defaultMessage: 'Please enter your current password.' - }, - passwordLengthError: { - id: 'user.settings.security.passwordLengthError', - defaultMessage: 'New passwords must be at least {min} characters and at most {max} characters.' - }, - passwordMatchError: { - id: 'user.settings.security.passwordMatchError', - defaultMessage: 'The new passwords you entered do not match.' - }, - method: { - id: 'user.settings.security.method', - defaultMessage: 'Sign-in Method' - }, - close: { - id: 'user.settings.security.close', - defaultMessage: 'Close' - } -}); +import icon50 from 'images/icon50x50.png'; -class SecurityTab extends React.Component { +export default class SecurityTab extends React.Component { constructor(props) { super(props); @@ -56,7 +35,9 @@ class SecurityTab extends React.Component { this.getDefaultState = this.getDefaultState.bind(this); this.createPasswordSection = this.createPasswordSection.bind(this); this.createSignInSection = this.createSignInSection.bind(this); + this.createOAuthAppsSection = this.createOAuthAppsSection.bind(this); this.showQrCode = this.showQrCode.bind(this); + this.deauthorizeApp = this.deauthorizeApp.bind(this); this.state = this.getDefaultState(); } @@ -74,6 +55,16 @@ class SecurityTab extends React.Component { }; } + componentDidMount() { + Client.getAuthorizedApps( + (authorizedApps) => { + this.setState({authorizedApps, serverError: null}); //eslint-disable-line react/no-did-mount-set-state + }, + (err) => { + this.setState({serverError: err.message}); //eslint-disable-line react/no-did-mount-set-state + }); + } + submitPassword(e) { e.preventDefault(); @@ -82,9 +73,8 @@ class SecurityTab extends React.Component { var newPassword = this.state.newPassword; var confirmPassword = this.state.confirmPassword; - const {formatMessage} = this.props.intl; if (currentPassword === '') { - this.setState({passwordError: formatMessage(holders.currentPasswordError), serverError: ''}); + this.setState({passwordError: Utils.localizeMessage('user.settings.security.currentPasswordError', 'Please enter your current password.'), serverError: ''}); return; } @@ -98,7 +88,7 @@ class SecurityTab extends React.Component { } if (newPassword !== confirmPassword) { - var defaultState = Object.assign(this.getDefaultState(), {passwordError: formatMessage(holders.passwordMatchError), serverError: ''}); + var defaultState = Object.assign(this.getDefaultState(), {passwordError: Utils.localizeMessage('user.settings.security.passwordMatchError', 'The new passwords you entered do not match.'), serverError: ''}); this.setState(defaultState); return; } @@ -190,6 +180,23 @@ class SecurityTab extends React.Component { this.setState({mfaShowQr: true}); } + deauthorizeApp(e) { + e.preventDefault(); + const appId = e.currentTarget.getAttribute('data-app'); + Client.deauthorizeOAuthApp( + appId, + () => { + const authorizedApps = this.state.authorizedApps.filter((app) => { + return app.id !== appId; + }); + + this.setState({authorizedApps, serverError: null}); + }, + (err) => { + this.setState({serverError: err.message}); + }); + } + createMfaSection() { let updateSectionStatus; let submit; @@ -686,7 +693,7 @@ class SecurityTab extends React.Component { return ( <SettingItemMax - title={this.props.intl.formatMessage(holders.method)} + title={Utils.localizeMessage('user.settings.security.method', 'Sign-in Method')} extraInfo={extraInfo} inputs={inputs} server_error={this.state.serverError} @@ -744,36 +751,180 @@ class SecurityTab extends React.Component { return ( <SettingItemMin - title={this.props.intl.formatMessage(holders.method)} + title={Utils.localizeMessage('user.settings.security.method', 'Sign-in Method')} describe={describe} updateSection={updateSectionStatus} /> ); } + createOAuthAppsSection() { + let updateSectionStatus; + + if (this.props.activeSection === 'apps') { + let apps; + if (this.state.authorizedApps && this.state.authorizedApps.length > 0) { + apps = this.state.authorizedApps.map((app) => { + const homepage = ( + <a + href={app.homepage} + target='_blank' + rel='noopener noreferrer' + > + {app.homepage} + </a> + ); + + return ( + <div + key={app.id} + className='padding-bottom x2 authorized-app' + > + <div className='col-sm-10'> + <div className='authorized-app__name'> + {app.name} + <span className='authorized-app__url'> + {' -'} {homepage} + </span> + </div> + <div className='authorized-app__description'>{app.description}</div> + <div className='authorized-app__deauthorize'> + <a + href='#' + data-app={app.id} + onClick={this.deauthorizeApp} + > + <FormattedMessage + id='user.settings.security.deauthorize' + defaultMessage='Deauthorize' + /> + </a> + </div> + </div> + <div className='col-sm-2 pull-right'> + <img + alt={app.name} + src={app.icon_url || icon50} + /> + </div> + <br/> + </div> + ); + }); + } else { + apps = ( + <div className='padding-bottom x2 authorized-app'> + <div className='col-sm-12'> + <div className='setting-list__hint'> + <FormattedMessage + id='user.settings.security.noApps' + defaultMessage='No OAuth 2.0 Applications are authorized.' + /> + </div> + </div> + </div> + ); + } + + const inputs = []; + let wrapperClass; + let helpText; + if (Array.isArray(apps)) { + wrapperClass = 'authorized-apps__wrapper'; + + helpText = ( + <div className='authorized-apps__help'> + <FormattedMessage + id='user.settings.security.oauthAppsHelp' + defaultMessage='Applications act on your behalf to access your data based on the permissions you grant them.' + /> + </div> + ); + } + + inputs.push( + <div + className={wrapperClass} + key='authorizedApps' + > + {apps} + </div> + ); + + updateSectionStatus = function updateSection(e) { + this.props.updateSection(''); + this.setState({serverError: null}); + e.preventDefault(); + }.bind(this); + + const title = ( + <div> + <FormattedMessage + id='user.settings.security.oauthApps' + defaultMessage='OAuth 2.0 Applications' + /> + {helpText} + </div> + ); + + return ( + <SettingItemMax + title={title} + inputs={inputs} + server_error={this.state.serverError} + updateSection={updateSectionStatus} + width='full' + /> + ); + } + + updateSectionStatus = function updateSection() { + this.props.updateSection('apps'); + }.bind(this); + + return ( + <SettingItemMin + title={Utils.localizeMessage('user.settings.security.oauthApps', 'OAuth 2.0 Applications')} + describe={ + <FormattedMessage + id='user.settings.security.oauthAppsDescription' + defaultMessage="Click 'Edit' to manage your OAuth 2.0 Applications" + /> + } + updateSection={updateSectionStatus} + /> + ); + } + render() { const user = this.props.user; + const config = window.mm_config; const passwordSection = this.createPasswordSection(); 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; - numMethods = global.window.mm_config.EnableSaml === 'true' ? numMethods + 1 : numMethods; + numMethods = config.EnableSignUpWithGitLab === 'true' ? numMethods + 1 : numMethods; + numMethods = config.EnableSignUpWithGoogle === 'true' ? numMethods + 1 : numMethods; + numMethods = config.EnableLdap === 'true' ? numMethods + 1 : numMethods; + numMethods = config.EnableSaml === 'true' ? numMethods + 1 : numMethods; let signInSection; - if (global.window.mm_config.EnableSignUpWithEmail === 'true' && numMethods > 0) { + if (config.EnableSignUpWithEmail === 'true' && numMethods > 0) { signInSection = this.createSignInSection(); } let mfaSection; - if (global.window.mm_config.EnableMultifactorAuthentication === 'true' && + if (config.EnableMultifactorAuthentication === 'true' && global.window.mm_license.IsLicensed === 'true' && (user.auth_service === '' || user.auth_service === Constants.LDAP_SERVICE)) { mfaSection = this.createMfaSection(); } + let oauthSection; + if (config.EnableOAuthServiceProvider === 'true') { + oauthSection = this.createOAuthAppsSection(); + } + return ( <div> <div className='modal-header'> @@ -781,7 +932,7 @@ class SecurityTab extends React.Component { type='button' className='close' data-dismiss='modal' - aria-label={this.props.intl.formatMessage(holders.close)} + aria-label={Utils.localizeMessage('user.settings.security.close', 'Close')} onClick={this.props.closeModal} > <span aria-hidden='true'>{'×'}</span> @@ -814,6 +965,8 @@ class SecurityTab extends React.Component { <div className='divider-light'/> {mfaSection} <div className='divider-light'/> + {oauthSection} + <div className='divider-light'/> {signInSection} <div className='divider-dark'/> <br></br> @@ -849,7 +1002,6 @@ SecurityTab.defaultProps = { activeSection: '' }; SecurityTab.propTypes = { - intl: intlShape.isRequired, user: React.PropTypes.object, activeSection: React.PropTypes.string, updateSection: React.PropTypes.func, @@ -858,5 +1010,3 @@ SecurityTab.propTypes = { collapseModal: React.PropTypes.func.isRequired, setEnforceFocus: React.PropTypes.func.isRequired }; - -export default injectIntl(SecurityTab); diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 378812455..0fb75e8b8 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -1961,5 +1961,10 @@ "web.footer.terms": "Terms", "web.header.back": "Back", "web.root.signup_info": "All team communication in one place, searchable and accessible anywhere", - "youtube_video.notFound": "Video not found" + "youtube_video.notFound": "Video not found", + "user.settings.security.deauthorize": "Deauthorize", + "user.settings.security.noApps": "No OAuth 2.0 Applications are authorized.", + "user.settings.security.oauthApps": "OAuth 2.0 Applications", + "user.settings.security.oauthAppsDescription": "Click 'Edit' to manage your OAuth 2.0 Applications", + "user.settings.security.oauthAppsHelp": "Applications act on your behalf to access your data based on the permissions you grant them." } diff --git a/webapp/sass/routes/_settings.scss b/webapp/sass/routes/_settings.scss index 36a1acf76..6fa8c26a7 100644 --- a/webapp/sass/routes/_settings.scss +++ b/webapp/sass/routes/_settings.scss @@ -7,6 +7,42 @@ max-height: 300px; max-width: 560px; } + + .authorized-apps__help { + font-size: 13px; + font-weight: 400; + margin-top: 7px; + } + + .authorized-apps__wrapper { + background-color: #fff; + padding: 10px 0; + } + + .authorized-app { + display: inline-block; + width: 100%; + + &:not(:last-child) { + border-bottom: 1px solid #ccc; + margin-bottom: 10px; + } + + .authorized-app__name { + font-weight: 600; + } + + .authorized-app__url { + font-size: 13px; + font-weight: 400; + } + + .authorized-app__description, + .authorized-app__deauthorize { + font-size: 13px; + margin: 5px 0; + } + } } .modal { |