From b2ba34a2a6b9c00c09caa6806b1f84c5841be4ce Mon Sep 17 00:00:00 2001 From: Antoine RENELEAU Date: Mon, 22 Jun 2015 18:02:38 +0200 Subject: [PATCH 1/4] GitHub OAuth implementation --- README.md | 9 +++ app/index.html | 8 +- app/scripts/app.js | 28 ++++--- app/scripts/controllers/auth.js | 18 +++++ app/scripts/controllers/main.js | 16 +++- app/scripts/services/authManager.js | 115 ++++++++++++++++++++++++++++ app/styles/main.css | 81 +++++++++++++++++++- app/views/main.html | 12 +++ bower.json | 5 +- config/config.json.dist | 16 +++- config/config_test.json | 13 ++++ test/e2e/main.js | 47 ++++++++++++ 12 files changed, 351 insertions(+), 17 deletions(-) create mode 100644 app/scripts/controllers/auth.js create mode 100644 app/scripts/services/authManager.js diff --git a/README.md b/README.md index c5664da..eda731a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,15 @@ $ gulp serve:dist It will automatically open the dashboard in your browser. +## OAuth + +To use GTR with GitHub OAuth you must : +* Register a new application on GitHub (in Settings > Applications) +* Install [Gatekeeper](https://github.com/prose/gatekeeper) and launch it +* Set your "gatekeeperBaseUrl" and type in your app data (clientId and GitHub URL) in config/config.json (example in config.json.dist) + +Then, you should see the Auth button in the upper-right corner of the app ! + ## Use Use directly the page path in order to select a team. diff --git a/app/index.html b/app/index.html index c87f72c..a2e07fd 100644 --- a/app/index.html +++ b/app/index.html @@ -21,15 +21,17 @@

You are using an outdated browser. Please upgrade your browser to improve your experience.

- +
- + + + @@ -37,6 +39,8 @@ + + diff --git a/app/scripts/app.js b/app/scripts/app.js index 1ea9c97..34c9db6 100644 --- a/app/scripts/app.js +++ b/app/scripts/app.js @@ -2,25 +2,33 @@ angular .module('gtrApp', [ - 'ngRoute', + 'ui.router', + 'angular-uri', 'gtrApp.config' - ]).config(function ($routeProvider, config) { - $routeProvider - .when('/:team', { + ]).config(function ($stateProvider, $urlRouterProvider, config) { + $stateProvider + + .state('auth', { + url: '/auth', + controller: 'AuthCtrl', + reloadOnSearch: false + }) + + .state('main', { + url: '/:team', templateUrl: 'views/main.html', controller: 'MainCtrl', - resolve: {team: function($q, $route, config) { + resolve: {team: function($q, $stateParams, config) { var defer = $q.defer(); - if (config.teams[$route.current.params.team]) { - defer.resolve($route.current.params.team); + if (config.teams[$stateParams.team]) { + defer.resolve($stateParams.team); } else { defer.reject('Team does not exist'); } return defer.promise; }} - }) - .otherwise({ - redirectTo: '/' + Object.keys(config.teams)[0] }); + + $urlRouterProvider.otherwise('/' + Object.keys(config.teams)[0]); }); diff --git a/app/scripts/controllers/auth.js b/app/scripts/controllers/auth.js new file mode 100644 index 0000000..8e2063d --- /dev/null +++ b/app/scripts/controllers/auth.js @@ -0,0 +1,18 @@ +'use strict'; + +angular.module('gtrApp') + .controller('AuthCtrl', function ($window, URI, authManager) { + var authFailed = function(err) { + $window.alert('Authentication failed' + err.error ? ' : ' + err.error : ''); + $window.location.href = '/'; + }; + var searchArr = URI($window.location.href).search(true); + if (searchArr.code && searchArr.state) { + authManager.getAccessToken(searchArr.code, searchArr.state) + .then(function() { + $window.location.href = '/'; + }, authFailed); + } else { + authFailed(); + } + }); diff --git a/app/scripts/controllers/main.js b/app/scripts/controllers/main.js index 08514b0..99f666b 100644 --- a/app/scripts/controllers/main.js +++ b/app/scripts/controllers/main.js @@ -3,7 +3,15 @@ 'use strict'; angular.module('gtrApp') - .controller('MainCtrl', function ($scope, $location, $interval, PullFetcher, config, team) { + .controller('MainCtrl', function ($scope, $location, $interval, PullFetcher, authManager, config, team) { + var oauthEnabled = !angular.isUndefined(config.githubOAuth); + $scope.oauthEnabled = oauthEnabled; + if (oauthEnabled) { + authManager.authenticateTeams(); + $scope.loginUrls = authManager.getLoginUrls(); + $scope.logoutClientIds = authManager.getLogoutClientIds(); + } + $scope.pulls = PullFetcher.pulls; $scope.teams = config.teams; $scope.team = team; @@ -65,6 +73,12 @@ angular.module('gtrApp') return array; }; + $scope.logout = function(clientId) { + authManager.logout(clientId); + $scope.loginUrls = authManager.getLoginUrls(); + $scope.logoutClientIds = authManager.getLogoutClientIds(); + }; + $scope.$watch('team', function (team) { $location.path(team); }); diff --git a/app/scripts/services/authManager.js b/app/scripts/services/authManager.js new file mode 100644 index 0000000..d8c778c --- /dev/null +++ b/app/scripts/services/authManager.js @@ -0,0 +1,115 @@ +/* global _ */ + +'use strict'; + +angular.module('gtrApp') + .factory('authManager', function ($http, $q, $state, URI, config) { + return { + + getAccessTokens: function() { + if (!localStorage.githubOAuthAccessTokens) { + return {}; + } + + return JSON.parse(localStorage.githubOAuthAccessTokens); + }, + + setAccessTokens: function(accessTokens) { + localStorage.githubOAuthAccessTokens = JSON.stringify(accessTokens); + }, + + getLoginUrls: function() { + var accessTokens = this.getAccessTokens(); + var loginUrls = []; + _.forEach(config.githubOAuth.apps, function(appConfig) { + if (!accessTokens[appConfig.clientId]) { + var loginUrl = appConfig.url + '/login/oauth/authorize'; + var loginParams = { + client_id: appConfig.clientId, + redirect_uri: $state.href('auth', null, {absolute: true}), + scope: 'repo,read:org', + state: appConfig.clientId, + }; + + loginUrls.push({ + githubHostname: URI(appConfig.url).hostname(), + loginUrl: URI(loginUrl).addSearch(loginParams).toString(), + }); + } + }); + + return loginUrls; + }, + + getLogoutClientIds: function() { + var accessTokens = this.getAccessTokens(); + var logoutClientIds = []; + + _.forEach(accessTokens, function(accessToken, clientId) { + var appUrl = _.findWhere(config.githubOAuth.apps, {clientId:clientId}); + logoutClientIds.push({ + clientId:clientId, + githubHostname:URI(appUrl.url).hostname() + }); + }); + + return logoutClientIds; + }, + + getAccessToken: function(code, clientId) { + if(angular.isUndefined(code) || angular.isUndefined(clientId)) { + return false; + } + + var authManager = this; + var deferred = $q.defer(); + + var gatekeeperUrl = config.githubOAuth.gatekeeperBaseUrl + '/authenticate/' + clientId + '/' + code; + $http.get(gatekeeperUrl) + .success(function(data) { + if (data.token) { + var accessTokens = authManager.getAccessTokens(); + if (!accessTokens) { + accessTokens = {}; + } + accessTokens[clientId] = data.token; + authManager.setAccessTokens(accessTokens); + + deferred.resolve(data.token); + } else { + deferred.reject('No token found'); + } + }) + .error(function(reason) { + deferred.reject(reason); + }); + + return deferred.promise; + }, + + authenticateTeams: function() { + var accessTokens = this.getAccessTokens(); + if (accessTokens.length) { + // Add OAuth token on each team if found + _.forEach(config.teams, function(team) { + if (!team.token && team.oauthAppClientId && accessTokens[team.oauthAppClientId]) { + team.token = accessTokens[team.oauthAppClientId]; + } + }); + } + }, + + logout: function(clientId) { + if(angular.isUndefined(clientId)) { + return false; + } + + var accessTokens = this.getAccessTokens(); + if (accessTokens) { + delete accessTokens[clientId]; + this.setAccessTokens(accessTokens); + } + }, + + }; + }); diff --git a/app/styles/main.css b/app/styles/main.css index d5db390..5c6ced7 100644 --- a/app/styles/main.css +++ b/app/styles/main.css @@ -6,6 +6,7 @@ body { a { text-decoration: none; color: inherit; + cursor: pointer; } .header-top { @@ -25,7 +26,7 @@ a { .header-top select { float: right; margin-right: 6px; -} +} .header-users { background-color: #3cc0bf; @@ -87,3 +88,81 @@ ul.pulls .repo { line-height: 0; clear: both; } + +.popover-content { + padding: 9px 14px; +} + +* { + -moz-box-sizing: border-box; + box-sizing: border-box; +} + +.popover-container { + position: relative; +} + +.popover.bottom { + margin-top: 26px; +} +.popover { + border-color: #eee; + border-bottom: 2px solid #e4eaec; + border-radius: 3px; +} +.progress, .progress .progress-bar, .popover { + box-shadow: 0 0 0 #000; +} +.popover { + position: absolute; + top: 0; + left: -155px; + z-index: 9; + max-width: 276px; + padding: 1px; + text-align: left; + background-color: #fff; + background-clip: padding-box; + border: 1px solid #e1e1e1; + box-shadow: 0 5px 10px rgba(0,0,0,.2); + white-space: normal; +} + +.popover.bottom>.arrow { + right: 0; + margin-left: -22px; + border-top-width: 0; + border-bottom-color: #fff; + top: -11px; +} +.popover>.arrow { + border-width: 11px; +} +.popover>.arrow, .popover>.arrow:after { + position: absolute; + display: block; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; +} + +.popover li { + border-bottom: 1px solid #ccc; +} +.popover li:last-child { + border-bottom: 0; +} + +.popover .login-li { + color: #373; +} +.popover .logout-li { + color: #a44; +} + +ul { + list-style: none; + padding: 0; + margin: 0; +} diff --git a/app/views/main.html b/app/views/main.html index 52ec93e..8a61856 100644 --- a/app/views/main.html +++ b/app/views/main.html @@ -1,5 +1,17 @@
Github Team Reviewer by M6Web +
diff --git a/bower.json b/bower.json index eef5345..5c0a68a 100644 --- a/bower.json +++ b/bower.json @@ -3,11 +3,12 @@ "version": "0.0.0", "dependencies": { "angular": "~1.3.0", - "angular-route": "~1.3.0", "json3": "~3.3.1", "es5-shim": "~3.1.0", "normalize-css": "~3.0.2", - "lodash": "~3.9.3" + "lodash": "~3.9.3", + "angular-ui-router": "~0.2.15", + "angular-uri": "M6Web/angular-uri#v0.1.1" }, "devDependencies": { "angular-mocks": "~1.3.0" diff --git a/config/config.json.dist b/config/config.json.dist index da37da0..2b89c32 100644 --- a/config/config.json.dist +++ b/config/config.json.dist @@ -1,5 +1,18 @@ { "config": { + "githubOAuth": { + "gatekeeperBaseUrl": "http://localhost:9999", + "apps": [ + { + "url": "https://github.enterprise.fr", + "clientId":"" + }, + { + "url": "https://github.com", + "clientId":"" + } + ] + }, "refreshInterval": 90, "teams": { "myTeam": { @@ -8,7 +21,8 @@ "orgs": ["orgname1", "orgname2"], "apiUrl": "https://api.github.com", "token": "apiToken", - "descendingOrder": true + "descendingOrder": true, + "oauthAppClientId":"" } } } diff --git a/config/config_test.json b/config/config_test.json index b561fb5..b51f356 100644 --- a/config/config_test.json +++ b/config/config_test.json @@ -1,5 +1,18 @@ { "config": { + "githubOAuth": { + "gatekeeperBaseUrl": "http://gatekeeper:9999", + "apps": [ + { + "url": "https://github.somewhere.fr", + "clientId":"c13nt1D" + }, + { + "url": "https://github.com", + "clientId":"90n3y" + } + ] + }, "refreshInterval": 90, "teams": { "cytron": { diff --git a/test/e2e/main.js b/test/e2e/main.js index 9c8f8e5..6665e25 100644 --- a/test/e2e/main.js +++ b/test/e2e/main.js @@ -324,5 +324,52 @@ describe('Test GTR screen', function () { } ]); }); + + }); + + describe('Test OAuth', function () { + + it('should display Auth links', function () { + browser.get('/'); + + var authBtn = element(by.css('div[ng-show="oauthEnabled"]>a')); + expect(authBtn.isDisplayed()).toBeTruthy(); + + authBtn.click(); + + var firstLoginLink = element(by.css('div[ng-show="oauthEnabled"] li a[href="https://github.somewhere.fr/login/oauth/authorize?client_id=c13nt1D&redirect_uri=http%3A%2F%2Flocalhost%3A9000%2F%23%2Fauth&scope=repo%2Cread%3Aorg&state=c13nt1D"]')); + expect(firstLoginLink.isDisplayed()).toBeTruthy(); + + var secondLoginLink = element(by.css('div[ng-show="oauthEnabled"] li a[href="https://github.com/login/oauth/authorize?client_id=90n3y&redirect_uri=http%3A%2F%2Flocalhost%3A9000%2F%23%2Fauth&scope=repo%2Cread%3Aorg&state=90n3y"]')); + expect(secondLoginLink.isDisplayed()).toBeTruthy(); + + }); + + it('should authenticate when github code is passed in url', function () { + backend = new HttpBackend(browser); + // Gatekeeper + backend.whenGET('http://gatekeeper:9999/authenticate/c13nt1D/t3mpC0d3') + .respond({token:'d4t0k3n'}); + // Others + backend.whenGET(/.*/).passThrough(); + + browser.get('/?code=t3mpC0d3&state=c13nt1D#/auth'); + browser.driver.sleep(1000); + expect(browser.getLocationAbsUrl()).toEqual('/cytron'); + + element(by.css('div[ng-show="oauthEnabled"]>a')).click(); + var logoutBtn = element(by.css('div[ng-show="oauthEnabled"] li:last-child a')); + expect(logoutBtn.getText()).toEqual('Logout from github.somewhere.fr'); + + logoutBtn.click(); + + var firstLoginLink = element(by.css('div[ng-show="oauthEnabled"] li a[href="https://github.somewhere.fr/login/oauth/authorize?client_id=c13nt1D&redirect_uri=http%3A%2F%2Flocalhost%3A9000%2F%23%2Fauth&scope=repo%2Cread%3Aorg&state=c13nt1D"]')); + expect(firstLoginLink.isDisplayed()).toBeTruthy(); + + backend.clear(); + + }); + }); + }); From d96e2b6a9adf66a5834cbcfbc14a43a031af1c12 Mon Sep 17 00:00:00 2001 From: Antoine Reneleau Date: Fri, 7 Aug 2015 16:40:07 +0200 Subject: [PATCH 2/4] Use M6Web/gatekeeper --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index eda731a..160c15f 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ It will automatically open the dashboard in your browser. To use GTR with GitHub OAuth you must : * Register a new application on GitHub (in Settings > Applications) -* Install [Gatekeeper](https://github.com/prose/gatekeeper) and launch it +* Install [Gatekeeper](https://github.com/M6Web/gatekeeper) and launch it * Set your "gatekeeperBaseUrl" and type in your app data (clientId and GitHub URL) in config/config.json (example in config.json.dist) Then, you should see the Auth button in the upper-right corner of the app ! From a4836fe5b39975aeec4caa9fe7601f9de8966d7f Mon Sep 17 00:00:00 2001 From: Antoine RENELEAU Date: Wed, 12 Aug 2015 11:12:52 +0200 Subject: [PATCH 3/4] Update karma plugins --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index baad0cf..d082225 100644 --- a/package.json +++ b/package.json @@ -34,8 +34,8 @@ }, "devDependencies": { "httpbackend": "^0.5.0", - "karma-jasmine": "^0.1.5", - "karma-phantomjs-launcher": "^0.1.4", + "karma-jasmine": "^0.3.6", + "karma-phantomjs-launcher": "^0.2.1", "protractor": "^1.1.1" }, "engines": { From 7102c4ff7c2c839e1628e302af928423d4477ffd Mon Sep 17 00:00:00 2001 From: Antoine RENELEAU Date: Wed, 12 Aug 2015 14:51:18 +0200 Subject: [PATCH 4/4] Enhance readme and config example --- README.md | 11 +++++++++-- config/config.json.dist | 32 ++++++++++++++++++-------------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 160c15f..178842e 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,14 @@ Options : * *projects* : an array of Github repository's names (optional, default get all repositories), * *org* : an array of Github organizations, * *apiUrl* : url of your Github API (optional, default is `https://api.github.com`), - * *token* : authorization token for API calls (optional, it can increase API rate limit). * *descendingOrder* : allow to change ordering of pull requests (optional, default is `true`). + * *token* : authorization token for API calls (optional, it can allow access to more repos and increase API rate limit) NB: if a token is set, OAuth will be ignored for the team + * *oauthAppClientId* : clientId of the OAuth app the team depends on (optional) +* **githubOAuth** : OAuth config (optional) + * *gatekeeperBaseUrl* : url to [Gatekeeper](https://github.com/M6Web/gatekeeper) (see OAuth section) + * *apps* : list of the apps you use to auth + * *url* : base url of GitHub (should be https://github.com or the base url of your GitHub enterprise) + * *clientId* : clientId of the app ## Run the server @@ -54,6 +60,7 @@ To use GTR with GitHub OAuth you must : * Register a new application on GitHub (in Settings > Applications) * Install [Gatekeeper](https://github.com/M6Web/gatekeeper) and launch it * Set your "gatekeeperBaseUrl" and type in your app data (clientId and GitHub URL) in config/config.json (example in config.json.dist) +* Don't forget to link the OAuth app to the teams thanks to the oauthAppClientId property Then, you should see the Auth button in the upper-right corner of the app ! @@ -89,7 +96,7 @@ $ cp Vagrantfile.dist Vagrantfile ```shell $ vagrant up -$ vagrant provision # because of npm issue on the first vagrant up +$ vagrant provision # because of npm issue on the first vagrant up $ vagrant ssh $ cd /vagrant ``` diff --git a/config/config.json.dist b/config/config.json.dist index 2b89c32..843ea65 100644 --- a/config/config.json.dist +++ b/config/config.json.dist @@ -1,29 +1,33 @@ { "config": { + "refreshInterval": 90, + "teams": { + "teamWithStaticToken": { + "members": ["username1", "username2", "username3"], + "projects": ["repo1", "repo2"], + "orgs": ["orgname1", "orgname2"], + "token": "apiToken", + "descendingOrder": true + }, + "teamWithOAuth": { + "members": ["username4", "username2", "username5"], + "orgs": ["orgname2", "orgname4"], + "apiUrl": "https://github.enterprise.fr/api/v3", + "oauthAppClientId":"c13n71D" + } + }, "githubOAuth": { "gatekeeperBaseUrl": "http://localhost:9999", "apps": [ { "url": "https://github.enterprise.fr", - "clientId":"" + "clientId":"c13n71D" }, { "url": "https://github.com", - "clientId":"" + "clientId":"t0k3n5" } ] - }, - "refreshInterval": 90, - "teams": { - "myTeam": { - "members": ["username1", "username2", "username3"], - "projects": ["repo1", "repo2"], - "orgs": ["orgname1", "orgname2"], - "apiUrl": "https://api.github.com", - "token": "apiToken", - "descendingOrder": true, - "oauthAppClientId":"" - } } } }