diff --git a/src/common/helper.js b/src/common/helper.js index 5d7f21d..017a00c 100644 --- a/src/common/helper.js +++ b/src/common/helper.js @@ -166,6 +166,26 @@ function convertGitLabError(err, message) { return apiError; } +/** + * Convert azure api error. + * @param {Error} err the azure api error + * @param {String} message the error message + * @returns {Error} converted error + */ +function convertAzureError(err, message) { + let resMsg = `${message}. ${err.message}.\n`; + const detail = _.get(err, 'response.body.message'); + if (detail) { + resMsg += ` Detail: ${detail}`; + } + const apiError = new errors.ApiError( + err.status || _.get(err, 'response.status', constants.SERVICE_ERROR_STATUS), + _.get(err, 'response.body.message', constants.SERVICE_ERROR), + resMsg + ); + return apiError; +} + /** * Ensure entity exists for given criteria. Return error if no result. * @param {Object} Model the mongoose model to query @@ -270,6 +290,7 @@ module.exports = { buildController, convertGitHubError, convertGitLabError, + convertAzureError, ensureExists, generateIdentifier, getProviderType, diff --git a/src/config.js b/src/config.js index fe3e983..a6377d4 100644 --- a/src/config.js +++ b/src/config.js @@ -93,7 +93,8 @@ const frontendConfigs = { "OWNER_LOGIN_AZURE_URL":"/api/v1/azure/owneruser/login", "TOPCODER_URL": "https://topcoder-dev.com", "GITHUB_TEAM_URL": "https://github.com/orgs/", - "GITLAB_GROUP_URL": "https://gitlab.com/groups/" + "GITLAB_GROUP_URL": "https://gitlab.com/groups/", + "AZURE_TEAM_URL": "https://dev.azure.com/" }, "heroku":{ @@ -111,7 +112,8 @@ const frontendConfigs = { "OWNER_LOGIN_AZURE_URL":"/api/v1/azure/owneruser/login", "TOPCODER_URL": "https://topcoder-dev.com", "GITHUB_TEAM_URL": "https://github.com/orgs/", - "GITLAB_GROUP_URL": "https://gitlab.com/groups/" + "GITLAB_GROUP_URL": "https://gitlab.com/groups/", + "AZURE_TEAM_URL": "https://dev.azure.com/" }, "dev":{ "JWT_V3_NAME":"v3jwt", @@ -128,7 +130,8 @@ const frontendConfigs = { "OWNER_LOGIN_AZURE_URL":"/api/v1/azure/owneruser/login", "TOPCODER_URL": "https://topcoder-dev.com", "GITHUB_TEAM_URL": "https://github.com/orgs/", - "GITLAB_GROUP_URL": "https://gitlab.com/groups/" + "GITLAB_GROUP_URL": "https://gitlab.com/groups/", + "AZURE_TEAM_URL": "https://dev.azure.com/" }, "qa":{ "JWT_V3_NAME":"v3jwt", @@ -145,7 +148,8 @@ const frontendConfigs = { "OWNER_LOGIN_AZURE_URL":"/api/v1/azure/owneruser/login", "TOPCODER_URL": "https://topcoder-dev.com", "GITHUB_TEAM_URL": "https://github.com/orgs/", - "GITLAB_GROUP_URL": "https://gitlab.com/groups/" + "GITLAB_GROUP_URL": "https://gitlab.com/groups/", + "AZURE_TEAM_URL": "https://dev.azure.com/" }, "prod":{ "JWT_V3_NAME":"v3jwt", @@ -162,7 +166,8 @@ const frontendConfigs = { "OWNER_LOGIN_AZURE_URL":"/api/v1/azure/owneruser/login", "TOPCODER_URL": "https://topcoder-dev.com", "GITHUB_TEAM_URL": "https://github.com/orgs/", - "GITLAB_GROUP_URL": "https://gitlab.com/groups/" + "GITLAB_GROUP_URL": "https://gitlab.com/groups/", + "AZURE_TEAM_URL": "https://dev.azure.com/" } }; @@ -185,5 +190,6 @@ module.exports.frontendConfigs = { OWNER_LOGIN_AZURE_URL: process.env.OWNER_LOGIN_AZURE_URL || frontendConfigs[activeEnv].OWNER_LOGIN_AZURE_URL, TOPCODER_URL: process.env.TOPCODER_URL || frontendConfigs[activeEnv].TOPCODER_URL, GITHUB_TEAM_URL: process.env.GITHUB_TEAM_URL || frontendConfigs[activeEnv].GITHUB_TEAM_URL, - GITLAB_GROUP_URL: process.env.GITLAB_GROUP_URL || frontendConfigs[activeEnv].GITLAB_GROUP_URL + GITLAB_GROUP_URL: process.env.GITLAB_GROUP_URL || frontendConfigs[activeEnv].GITLAB_GROUP_URL, + AZURE_TEAM_URL: process.env.AZURE_TEAM_URL || frontendConfigs[activeEnv].AZURE_TEAM_URL }; \ No newline at end of file diff --git a/src/controllers/AzureController.js b/src/controllers/AzureController.js index b82100c..a257e0a 100644 --- a/src/controllers/AzureController.js +++ b/src/controllers/AzureController.js @@ -3,7 +3,7 @@ */ /** - * This controller exposes Gitlab REST endpoints. + * This controller exposes Azure REST endpoints. * * @author TCSCODER * @version 1.0 @@ -17,12 +17,11 @@ const errors = require('../common/errors'); const constants = require('../common/constants'); const config = require('../config'); const AzureService = require('../services/AzureService'); -const GitlabService = require('../services/GitlabService'); const UserService = require('../services/UserService'); const User = require('../models').User; const OwnerUserTeam = require('../models').OwnerUserTeam; -// const UserMapping = require('../models').UserMapping; -const UserGroupMapping = require('../models').UserGroupMapping; +const UserMapping = require('../models').UserMapping; +const UserTeamMapping = require('../models').UserTeamMapping; const request = superagentPromise(superagent, Promise); @@ -40,7 +39,7 @@ async function ownerUserLogin(req, res) { if (!req.session.state) { req.session.state = helper.generateIdentifier(); } - // redirect to GitLab OAuth + // redirect to Azure OAuth const callbackUri = `${config.WEBSITE_SECURE}${constants.AZURE_OWNER_CALLBACK_URL}`; res.redirect(`https://app.vssps.visualstudio.com/oauth2/authorize?client_id=${ config.AZURE_APP_ID @@ -50,7 +49,7 @@ async function ownerUserLogin(req, res) { } /** - * Owner user login callback, redirected by GitLab. + * Owner user login callback, redirected by Azure. * @param {Object} req the request * @param {Object} res the response */ @@ -104,7 +103,7 @@ async function ownerUserLoginCallback(req, res) { async function listOwnerUserTeams(req) { const user = await UserService.getAccessTokenByHandle(req.currentUser.handle, constants.USER_TYPES.AZURE); if (!user || !user.accessToken) { - throw new errors.UnauthorizedError('You have not setup for Gitlab.'); + throw new errors.UnauthorizedError('You have not setup for Azure.'); } return await AzureService.listOwnerUserTeams(user, req.query.page, req.query.perPage); } @@ -136,7 +135,7 @@ async function addUserToTeam(req, res) { // store identifier to session, to be compared in callback req.session.identifier = identifier; - // redirect to GitLab OAuth + // redirect to Azure OAuth const callbackUri = `${config.WEBSITE_SECURE}/api/${config.API_VERSION}/azure/normaluser/callback`; res.redirect(`https://app.vssps.visualstudio.com/oauth2/authorize?client_id=${ config.AZURE_USER_APP_ID @@ -146,7 +145,7 @@ async function addUserToTeam(req, res) { } /** - * Normal user callback, to be added to group. Redirected by GitLab. + * Normal user callback, to be added to group. Redirected by Azure. * @param {Object} req the request * @param {Object} res the response */ @@ -197,29 +196,15 @@ async function addUserToTeamCallback(req, res) { .end() .then((resp) => resp.body); - // PATCH https://vsaex.dev.azure.com/{organization}/_apis/userentitlements/{userId}?api-version=5.1-preview.2 try { - await request - .patch(`https://vsaex.dev.azure.com/telagaid/_apis/userentitlements/${userProfile.id}?api-version=5.1-preview.2`) + await request + .patch(`https://vsaex.dev.azure.com/${team.organizationName}/_apis/UserEntitlements?doNotSendInviteForNewUsers=true&api-version=5.1-preview.3`) .send([{ - from: "", + from: '', op: 0, - path: "", + path: `/${userProfile.id}/projectEntitlements/${team.githubOrgId}/teamRefs`, value: { - projectEntitlements: { - projectRef: { - id: team.githubOrgId - }, - teamRefs: [{ - id:team.teamId - }] - }, - user: { - subjectKind: 'user', - displayName: userProfile.emailAddress, - principalName: userProfile.emailAddress, - id: userProfile.id - } + id:team.teamId } }]) .set('Content-Type', 'application/json-patch+json') @@ -229,37 +214,70 @@ async function addUserToTeamCallback(req, res) { catch(err) { console.log(err); // eslint-disable-line no-console } + + // associate azure username with TC username + const mapping = await dbHelper.scanOne(UserMapping, { + topcoderUsername: {eq: req.session.tcUsername}, + }); + if (mapping) { + await dbHelper.update(UserMapping, mapping.id, { + azureEmail: userProfile.emailAddress, + azureUserId: userProfile.id + }); + } else { + await dbHelper.create(UserMapping, { + id: helper.generateIdentifier(), + topcoderUsername: req.session.tcUsername, + azureEmail: userProfile.emailAddress, + azureUserId: userProfile.id + }); + } + + const azureUserToTeamMapping = await dbHelper.scanOne(UserTeamMapping, { + teamId: {eq: team.teamId}, + azureUserId: {eq: userProfile.id}, + }); + + if (!azureUserToTeamMapping) { + await dbHelper.create(UserTeamMapping, { + id: helper.generateIdentifier(), + teamId: team.teamId, + azureUserId: userProfile.id, + azureProjectId: team.githubOrgId + }); + } + // redirect to success page - res.redirect(`${constants.USER_ADDED_TO_TEAM_SUCCESS_URL}/azure/path`); + res.redirect(`${constants.USER_ADDED_TO_TEAM_SUCCESS_URL}/azure/${team.organizationName}_${team.githubOrgId}`); } /** - * Delete users from a group. + * Delete users from a team. * @param {Object} req the request * @param {Object} res the response */ async function deleteUsersFromTeam(req, res) { - const groupId = req.params.id; - let groupInDB; + const teamId = req.params.id; + let teamInDB; try { - groupInDB = await helper.ensureExists(OwnerUserTeam, {groupId}, 'OwnerUserTeam'); + teamInDB = await helper.ensureExists(OwnerUserTeam, {teamId}, 'OwnerUserTeam'); } catch (err) { if (!(err instanceof errors.NotFoundError)) { throw err; } } - // If groupInDB not exists, then just return - if (groupInDB) { + // If teamInDB not exists, then just return + if (teamInDB) { try { const ownerUser = await helper.ensureExists(User, - {username: groupInDB.ownerUsername, type: constants.USER_TYPES.GITLAB, role: constants.USER_ROLES.OWNER}, 'User'); - await GitlabService.refreshGitlabUserAccessToken(ownerUser); - const userGroupMappings = await dbHelper.scan(UserGroupMapping, {groupId}); + {username: teamInDB.ownerUsername, type: constants.USER_TYPES.AZURE, role: constants.USER_ROLES.OWNER}, 'User'); + await AzureService.refreshAzureUserAccessToken(ownerUser); + const userTeamMappings = await dbHelper.scan(UserTeamMapping, {teamId}); // eslint-disable-next-line no-restricted-syntax - for (const userGroupMapItem of userGroupMappings) { - await GitlabService.deleteUserFromGitlabGroup(ownerUser.accessToken, groupId, userGroupMapItem.gitlabUserId); - await dbHelper.remove(UserGroupMapping, {id: userGroupMapItem.id}); + for (const userTeamMapItem of userTeamMappings) { + await AzureService.deleteUserFromAzureTeam(ownerUser.accessToken, teamInDB, userTeamMapItem.azureUserId); + await dbHelper.remove(UserTeamMapping, {id: userTeamMapItem.id}); } } catch (err) { throw err; diff --git a/src/front/src/app/git-access-control/access-control.service.js b/src/front/src/app/git-access-control/access-control.service.js index 928472a..edbad10 100644 --- a/src/front/src/app/git-access-control/access-control.service.js +++ b/src/front/src/app/git-access-control/access-control.service.js @@ -95,5 +95,15 @@ angular.module('topcoderX') }); }; + /** + * remove all users from a azure team + * + */ + service.removeAllAzureUsers = function (teamId) { + return $http.delete(baseUrl + '/api/v1/azure/teams/' + teamId + '/users').then(function (response) { + return response; + }); + }; + return service; }]); diff --git a/src/front/src/app/members/member.controller.js b/src/front/src/app/members/member.controller.js index b4f1461..7c9634f 100644 --- a/src/front/src/app/members/member.controller.js +++ b/src/front/src/app/members/member.controller.js @@ -11,8 +11,11 @@ angular.module('topcoderX') const org = params[0]; const team = url.replace(org, '').substring(1); $scope.link = $rootScope.appConfig.GITHUB_TEAM_URL + org + '/teams/' + team; - } else { + } else if (provider === 'github') { $scope.link = $rootScope.appConfig.GITLAB_GROUP_URL + url; + } else if (provider === 'azure') { + const params = url.split('_'); + $scope.link = $rootScope.appConfig.AZURE_TEAM_URL + params[0] + '/' + params[1]; } }; _getUrl($scope.provider, $stateParams.url); diff --git a/src/front/src/app/members/member.html b/src/front/src/app/members/member.html index 5d20ef3..bf3f4ff 100644 --- a/src/front/src/app/members/member.html +++ b/src/front/src/app/members/member.html @@ -19,6 +19,10 @@

{{title}}

You were successfully added to the group!

{{link}} +
+

You were successfully added to the Azure DevOps project team!

+ {{link}} +
diff --git a/src/front/src/app/projects/projects.controller.js b/src/front/src/app/projects/projects.controller.js index a79cf0b..9857646 100644 --- a/src/front/src/app/projects/projects.controller.js +++ b/src/front/src/app/projects/projects.controller.js @@ -67,7 +67,18 @@ angular.module('topcoderX') }; $scope.repoType = function (repo) { - return (repo.toLocaleLowerCase().indexOf("gitlab") >= 0 ? "Gitlab" : "Github"); + if (repo.toLocaleLowerCase().indexOf("github") >= 0) { + return "Github"; + } + else if (repo.toLocaleLowerCase().indexOf("gitlab") >= 0) { + return "Gitlab"; + } + else if (repo.toLocaleLowerCase().indexOf("azure") >= 0) { + return "Azure"; + } + else { + return "Other"; + } }; $scope.init = function () { diff --git a/src/front/src/app/projects/projects.html b/src/front/src/app/projects/projects.html index f88867a..a2b2c40 100644 --- a/src/front/src/app/projects/projects.html +++ b/src/front/src/app/projects/projects.html @@ -58,7 +58,7 @@

You don't have active projects right now. Please Project Name Topcoder Direct ID - Gitlab/Github Repo Url + Service Provider Owner @@ -117,7 +117,7 @@

You don't have active projects right now. Please Project Name Topcoder Direct ID - Gitlab/Github Repo Url + Service Provider Owner diff --git a/src/models/UserTeamMapping.js b/src/models/UserTeamMapping.js index 2e57d63..06bda59 100644 --- a/src/models/UserTeamMapping.js +++ b/src/models/UserTeamMapping.js @@ -23,7 +23,7 @@ const schema = new Schema({ }, githubOrgId: { type: String, - required: true, + required: false, index: { global: true, project: true, @@ -33,7 +33,7 @@ const schema = new Schema({ }, githubUserName: { type: String, - required: true, + required: false, index: { global: true, project: true, @@ -41,6 +41,8 @@ const schema = new Schema({ name: 'GithubUserNameIndex', }, }, + azureProjectId: { type: String, required: false }, + azureUserId: { type: String, required: false } }); module.exports = schema; diff --git a/src/services/AzureService.js b/src/services/AzureService.js index c893f7c..0b31da9 100644 --- a/src/services/AzureService.js +++ b/src/services/AzureService.js @@ -3,7 +3,7 @@ */ /** - * This service will provide GitLab operations. + * This service will provide Azure operations. * * @author TCSCODER * @version 1.0 @@ -12,7 +12,6 @@ const Joi = require('joi'); const superagent = require('superagent'); const superagentPromise = require('superagent-promise'); -const _ = require('lodash'); const config = require('../config'); const constants = require('../common/constants'); const helper = require('../common/helper'); @@ -42,7 +41,7 @@ async function ensureOwnerUser(token, topcoderUsername) { .end() .then((res) => res.body); } catch (err) { - throw helper.convertGitLabError(err, 'Failed to ensure valid owner user.'); + throw helper.convertAzureError(err, 'Failed to ensure valid owner user.'); } if (!userProfile) { throw new errors.UnauthorizedError('Can not get user from the access token.'); @@ -139,7 +138,7 @@ async function listOwnerUserTeams(user, page = 1, perPage = constants.GITLAB_DEF }) }; } catch (err) { - throw helper.convertGitLabError(err, 'Failed to list user groups'); + throw helper.convertAzureError(err, 'Failed to list user groups'); } } @@ -189,89 +188,6 @@ getTeamRegistrationUrl.schema = Joi.object().keys({ projectId: Joi.string() }); -/** - * Add group member. - * @param {String} groupId the group id - * @param {String} ownerUserToken the owner user token - * @param {String} normalUserToken the normal user token - * @param {String} accessLevel the access level - * @param {String} expiredAt the expired at params to define how long user joined teams. can be null - * @returns {Promise} the promise result - */ -async function addGroupMember(groupId, ownerUserToken, normalUserToken, accessLevel, expiredAt) { - let username; - let userId; - try { - // get normal user id - const res = await request - .get(`${config.GITLAB_API_BASE_URL}/api/v4/user`) - .set('Authorization', `Bearer ${normalUserToken}`) - .end(); - userId = res.body.id; - username = res.body.username; - if (!userId) { - throw new errors.UnauthorizedError('Can not get user id from the normal user access token.'); - } - - let body = `user_id=${userId}&access_level=${accessLevel}`; - if (expiredAt) { - body = body + `&expires_at=${expiredAt} `; - } - // add user to group - await request - .post(`${config.GITLAB_API_BASE_URL}/api/v4/groups/${groupId}/members`) - .set('Authorization', `Bearer ${ownerUserToken}`) - .send(body) - .end(); - // return gitlab username - return { - username: res.body.username, - id: res.body.id, - }; - } catch (err) { - if (_.get(JSON.parse(err.response.text), 'message') !== 'Member already exists') { - if (err instanceof errors.ApiError) { - throw err; - } - throw helper.convertGitLabError(err, 'Failed to add group member'); - } - return {username, id: userId}; - } -} - -addGroupMember.schema = Joi.object().keys({ - groupId: Joi.string().required(), - ownerUserToken: Joi.string().required(), - normalUserToken: Joi.string().required(), - accessLevel: Joi.string().required(), - expiredAt: Joi.string() -}); - -/** - * Gets the user id by username - * @param {string} username the username - * @returns {number} the user id - */ -async function getUserIdByUsername(username) { - try { - // get current user - const users = await request - .get(`${config.GITLAB_API_BASE_URL}/api/v4/users?username=${username}`) - .end() - .then((res) => res.body); - if (!users || !users.length) { - throw new errors.NotFoundError(`The user with username ${username} is not found on gitlab`); - } - return users[0].id; - } catch (err) { - throw helper.convertGitLabError(err, 'Failed to get detail about user from gitlab.'); - } -} - -getUserIdByUsername.schema = Joi.object().keys({ - username: Joi.string().required(), -}); - /** * Refresh the owner user access token if needed * @param {Object} azureOwner the azure owner @@ -318,29 +234,37 @@ refreshAzureUserAccessToken.schema = Joi.object().keys({ /** * delete user fromgroup - * @param {String} ownerUserToken the gitlab owner token - * @param {String} groupId the gitlab group Id + * @param {String} ownerUserToken the azure owner token + * @param {Object} team the azure team * @param {String} userId the normal user id */ -async function deleteUserFromGitlabGroup(ownerUserToken, groupId, userId) { +async function deleteUserFromAzureTeam(ownerUserToken, team, userId) { try { await request - .del(`${config.GITLAB_API_BASE_URL}/api/v4/groups/${groupId}/members/${userId}`) - .set('Authorization', `Bearer ${ownerUserToken}`) - .send() - .end(); + .patch(`https://vsaex.dev.azure.com/${team.organizationName}/_apis/UserEntitlements?doNotSendInviteForNewUsers=true&api-version=5.1-preview.3`) + .send([{ + from: '', + op: 'remove', + path: `/${userId}/projectEntitlements/${team.githubOrgId}`, + value: { + id:team.teamId + } + }]) + .set('Content-Type', 'application/json-patch+json') + .set('Authorization', `Bearer ${ownerUserToken}`) + .end(); } catch (err) { - // If a user is not found from gitlab, then ignore the error + // If a user is not found from azure, then ignore the error // eslint-disable-next-line no-magic-numbers if (err.status !== 404) { - throw helper.convertGitLabError(err, `Failed to delete user from group, userId is ${userId}, groupId is ${groupId}.`); + throw helper.convertAzureError(err, `Failed to delete user from group, userId is ${userId}, teamId is ${team.teamId}.`); } } } -deleteUserFromGitlabGroup.schema = Joi.object().keys({ +deleteUserFromAzureTeam.schema = Joi.object().keys({ ownerUserToken: Joi.string().required(), - groupId: Joi.string().required(), + team: Joi.object().required(), userId: Joi.string().required(), }); @@ -348,10 +272,8 @@ module.exports = { ensureOwnerUser, listOwnerUserTeams, getTeamRegistrationUrl, - addGroupMember, - getUserIdByUsername, refreshAzureUserAccessToken, - deleteUserFromGitlabGroup, + deleteUserFromAzureTeam }; helper.buildService(module.exports); \ No newline at end of file diff --git a/src/services/ProjectService.js b/src/services/ProjectService.js index 3e5b681..86757d9 100644 --- a/src/services/ProjectService.js +++ b/src/services/ProjectService.js @@ -131,13 +131,20 @@ async function create(project, currentUser) { const provider = await helper.getProviderType(project.repoUrl); if (provider === 'azure') { - project.repoUrl = decodeURIComponent(project.repoUrl); + const repoUrlObj = new URL(project.repoUrl); + const pathCount = 3; + const paths = repoUrlObj.pathname.split('/'); + if (paths.length > pathCount) { + project.repoUrl = decodeURIComponent(`${repoUrlObj.origin}/${paths[1]}/${paths[2]}`); // eslint-disable-line no-magic-numbers + } + else { + project.repoUrl = decodeURIComponent(`${repoUrlObj.origin}${repoUrlObj.pathname}`); + } const userRole = await helper.getProjectCopilotOrOwner(models, project, provider, false); const results = project.repoUrl.split('/'); const index = 1; const repoName = results[results.length - index]; - const excludePart = 3; - const repoOwner = _(results).slice(excludePart, results.length - 1).join('/'); + const repoOwner = _(results).slice(pathCount, results.length - 1).join('/'); let result = await request .get(`${config.AZURE_DEVOPS_API_BASE_URL}/${repoOwner}/_apis/projects/${repoName}?api-version=5.1`)