Merge pull request #213 from coreos-inc/orgmember

Add a secondary tab to Teams for managing org members
This commit is contained in:
Jimmy Zelinskie 2015-07-06 11:48:40 -04:00
commit cf4800c06c
8 changed files with 373 additions and 105 deletions

View file

@ -414,6 +414,28 @@ def convert_user_to_organization(user, admin_user):
return user
def remove_organization_member(org, user):
org_admins = [u.username for u in __get_org_admin_users(org)]
if len(org_admins) == 1 and user.username in org_admins:
raise DataModelException('Cannot remove user as they are the only organization admin')
with config.app_config['DB_TRANSACTION_FACTORY'](db):
# Find and remove the user from any repositorys under the org.
permissions = (RepositoryPermission.select(RepositoryPermission.id)
.join(Repository)
.where(Repository.namespace_user == org,
RepositoryPermission.user == user))
RepositoryPermission.delete().where(RepositoryPermission.id << permissions).execute()
# Find and remove the user from any teams under the org.
members = (TeamMember.select(TeamMember.id)
.join(Team)
.where(Team.organization == org, TeamMember.user == user))
TeamMember.delete().where(TeamMember.id << members).execute()
def create_team(name, org, team_role_name, description=''):
(username_valid, username_issue) = validate_username(name)
if not username_valid:
@ -428,6 +450,15 @@ def create_team(name, org, team_role_name, description=''):
description=description)
def __get_org_admin_users(org):
return (User.select()
.join(TeamMember)
.join(Team)
.join(TeamRole)
.where(Team.organization == org, TeamRole.name == 'admin', User.robot == False)
.distinct())
def __get_user_admin_teams(org_name, teamname, username):
Org = User.alias()
user_teams = Team.select().join(TeamMember).join(User)
@ -877,6 +908,23 @@ def verify_user(username_or_email, password):
# We weren't able to authorize the user
return None
def list_organization_member_permissions(organization):
query = (RepositoryPermission.select(RepositoryPermission, Repository, User)
.join(Repository)
.switch(RepositoryPermission)
.join(User)
.where(Repository.namespace_user == organization)
.where(User.robot == False))
return query
def list_organization_members_by_teams(organization):
query = (TeamMember.select(Team, User)
.annotate(Team)
.annotate(User)
.where(Team.organization == organization))
return query
def get_user_organizations(username):
UserAlias = User.alias()
@ -905,14 +953,6 @@ def get_organization_team(orgname, teamname):
return result[0]
def get_organization_members_with_teams(organization, membername = None):
joined = TeamMember.select().annotate(Team).annotate(User)
query = joined.where(Team.organization == organization)
if membername:
query = query.where(User.username == membername)
return query
def get_organization_team_members(teamid):
joined = User.select().join(TeamMember).join(Team)
query = joined.where(Team.id == teamid)

View file

@ -230,7 +230,7 @@ class OrganizationMemberList(ApiResource):
@require_scope(scopes.ORG_ADMIN)
@nickname('getOrganizationMembers')
def get(self, orgname):
""" List the members of the specified organization. """
""" List the human members of the specified organization. """
permission = AdministerOrganizationPermission(orgname)
if permission.can():
try:
@ -242,21 +242,41 @@ class OrganizationMemberList(ApiResource):
# will return an entry for *every team* a member is on, so we will have
# duplicate keys (which is why we pre-build the dictionary).
members_dict = {}
members = model.get_organization_members_with_teams(org)
members = model.list_organization_members_by_teams(org)
for member in members:
if member.user.robot:
continue
if not member.user.username in members_dict:
members_dict[member.user.username] = {'name': member.user.username,
'kind': 'user',
'is_robot': member.user.robot,
'teams': []}
member_data = {
'name': member.user.username,
'kind': 'user',
'avatar': avatar.get_data_for_user(member.user),
'teams': [],
'repositories': []
}
members_dict[member.user.username]['teams'].append(member.team.name)
members_dict[member.user.username] = member_data
return {'members': members_dict}
members_dict[member.user.username]['teams'].append({
'name': member.team.name,
'avatar': avatar.get_data_for_team(member.team),
})
# Loop to add direct repository permissions.
for permission in model.list_organization_member_permissions(org):
username = permission.user.username
if not username in members_dict:
continue
members_dict[username]['repositories'].append(permission.repository.name)
return {'members': members_dict.values()}
raise Unauthorized()
@resource('/v1/organization/<orgname>/members/<membername>')
@path_param('orgname', 'The name of the organization')
@path_param('membername', 'The username of the organization member')
@ -264,31 +284,26 @@ class OrganizationMember(ApiResource):
""" Resource for managing individual organization members. """
@require_scope(scopes.ORG_ADMIN)
@nickname('getOrganizationMember')
def get(self, orgname, membername):
""" Get information on the specific organization member. """
@nickname('removeOrganizationMember')
def delete(self, orgname, membername):
""" Removes a member from an organization, revoking all its repository
priviledges and removing it from all teams in the organization.
"""
permission = AdministerOrganizationPermission(orgname)
if permission.can():
# Lookup the user.
user = model.get_nonrobot_user(membername)
if not user:
raise NotFound()
try:
org = model.get_organization(orgname)
except model.InvalidOrganizationException:
raise NotFound()
member_dict = None
member_teams = model.get_organization_members_with_teams(org, membername=membername)
for member in member_teams:
if not member_dict:
member_dict = {'name': member.user.username,
'kind': 'user',
'is_robot': member.user.robot,
'teams': []}
member_dict['teams'].append(member.team.name)
if not member_dict:
raise NotFound()
return {'member': member_dict}
# Remove the user from the organization.
model.remove_organization_member(org, user)
return 'Deleted', 204
raise Unauthorized()

View file

@ -4,7 +4,11 @@
.teams-manager .manager-header {
border-bottom: 1px solid #eee;
margin-bottom: 10px;
margin-bottom: 16px;
}
.teams-manager .manager-header i.fa {
margin-right: 6px;
}
.teams-manager .cor-options-menu {
@ -12,6 +16,25 @@
margin-left: 10px;
}
.teams-manager td .empty {
color: #ccc;
}
.teams-manager .cor-confirm-dialog .entity-reference .avatar {
margin-left: 4px;
margin-right: 0px;
}
.teams-manager .cor-confirm-dialog .entity-reference .entity-name {
margin-left: 2px;
}
@media (min-width: 768px) {
.teams-manager .manager-header {
padding-bottom: 0px;
}
}
@media (max-width: 767px) {
.teams-manager .control-col {
padding-left: 55px;

View file

@ -1,65 +1,162 @@
<div class="teams-manager-element">
<div class="manager-header" header-title="Teams">
<span class="popup-input-button" pattern="TEAM_PATTERN" placeholder="'Team Name'"
<div class="manager-header" header-title="Teams and Membership">
<div class="tab-header-controls hidden-xs">
<div class="btn-group btn-group-sm" ng-show="organization.is_admin">
<button class="btn"
ng-class="!showingMembers ? 'btn-primary active' : 'btn-default'" ng-click="showMembers(false)">
<i class="fa fa-group"></i>Teams View
</button>
<button class="btn"
ng-class="showingMembers ? 'btn-info active' : 'btn-default'" ng-click="showMembers(true)">
<i class="fa fa-user"></i>Members View
</button>
</div>
</div>
<span class="popup-input-button visible-xs" ng-if="!showingMembers"
pattern="TEAM_PATTERN" placeholder="'Team Name'"
submitted="createTeam(value)" ng-show="organization.is_admin">
<i class="fa fa-plus" style="margin-right: 6px;"></i> Create New Team
</span>
</div>
<div class="row hidden-xs">
<div class="col-md-4 col-md-offset-8 col-sm-5 col-sm-offset-7 header-col" ng-show="organization.is_admin">
<span class="header-text">Team Permissions</span>
<i class="info-icon fa fa-info-circle" data-placement="bottom" data-original-title="" data-title=""
data-content="Global permissions for the team and its members<br><br><dl><dt>Member</dt><dd>Permissions are assigned on a per repository basis</dd><dt>Creator</dt><dd>A team can create its own repositories</dd><dt>Admin</dt><dd>A team has full control of the organization</dd></dl>"
data-html="true"
data-trigger="hover"
bs-popover></i>
</div>
</div>
<!-- Teams List -->
<div ng-show="!showingMembers">
<span class="popup-input-button hidden-xs"
pattern="TEAM_PATTERN" placeholder="'Team Name'"
submitted="createTeam(value)" ng-show="organization.is_admin"
style="margin-bottom: 10px;">
<i class="fa fa-plus" style="margin-right: 6px;"></i> Create New Team
</span>
<div class="team-listing" ng-repeat="team in orderedTeams">
<div id="team-{{team.name}}" class="row">
<div class="col-sm-7 col-md-8">
<div class="team-title">
<span class="avatar" data="team.avatar" size="30"></span>
<span ng-show="team.can_view">
<a href="/organization/{{ organization.name }}/teams/{{ team.name }}">{{ team.name }}</a>
</span>
<span ng-show="!team.can_view">
{{ team.name }}
</span>
<div class="row hidden-xs">
<div class="col-md-4 col-md-offset-8 col-sm-5 col-sm-offset-7 header-col" ng-show="organization.is_admin">
<span class="header-text">Team Permissions</span>
<i class="info-icon fa fa-info-circle" data-placement="bottom" data-original-title="" data-title=""
data-content="Global permissions for the team and its members<br><br><dl><dt>Member</dt><dd>Permissions are assigned on a per repository basis</dd><dt>Creator</dt><dd>A team can create its own repositories</dd><dt>Admin</dt><dd>A team has full control of the organization</dd></dl>"
data-html="true"
data-trigger="hover"
bs-popover></i>
</div>
</div>
<div class="team-listing" ng-repeat="team in orderedTeams">
<div id="team-{{team.name}}" class="row">
<div class="col-sm-7 col-md-8">
<div class="team-title">
<span class="avatar" data="team.avatar" size="30"></span>
<span ng-show="team.can_view">
<a href="/organization/{{ organization.name }}/teams/{{ team.name }}">{{ team.name }}</a>
</span>
<span ng-show="!team.can_view">
{{ team.name }}
</span>
</div>
<div class="team-description markdown-view" content="team.description" first-line-only="true"></div>
<div class="team-member-list hidden-xs" ng-if="members[team.name]">
<div class="cor-loader" ng-if="!members[team.name].members"></div>
<span class="team-member"
ng-repeat="member in members[team.name].members | orderBy:'is_robot' | limitTo: 20">
<span data-title="{{ member.name }}" bs-tooltip>
<a href="/user/{{ member.name }}" ng-if="!member.is_robot">
<span class="avatar" data="member.avatar" size="26"></span>
</a>
<i class="fa ci-robot fa-lg" ng-if="member.is_robot"></i>
</span>
</span>
<span class="team-member-more"
ng-if="members[team.name].members.length > 20">+ {{ members[team.name].members.length - 20 }} more team members.</span>
<span class="team-member-more"
ng-if="members[team.name].members && !members[team.name].members.length">(Empty Team)</span>
</div>
</div>
<div class="team-description markdown-view" content="team.description" first-line-only="true"></div>
<div class="col-sm-5 col-md-4 control-col" ng-show="organization.is_admin">
<span class="role-group" current-role="team.role" pull-left="true"
role-changed="setRole(role, team.name)" roles="teamRoles"></span>
<div class="team-member-list hidden-xs" ng-if="members[team.name]">
<div class="cor-loader" ng-if="!members[team.name].members"></div>
<span class="team-member"
ng-repeat="member in members[team.name].members | orderBy:'is_robot' | limitTo: 20">
<span data-title="{{ member.name }}" bs-tooltip>
<a href="/user/{{ member.name }}" ng-if="!member.is_robot">
<span class="avatar" data="member.avatar" size="26"></span>
</a>
<i class="fa ci-robot fa-lg" ng-if="member.is_robot"></i>
<span class="cor-options-menu">
<span class="cor-option" option-click="askDeleteTeam(team.name)">
<i class="fa fa-times"></i> Delete Team {{ team.name }}
</span>
</span>
<span class="team-member-more"
ng-if="members[team.name].members.length > 20">+ {{ members[team.name].members.length - 20 }} more team members.</span>
<span class="team-member-more"
ng-if="members[team.name].members && !members[team.name].members.length">(Empty Team)</span>
</div>
</div>
<div class="col-sm-5 col-md-4 control-col" ng-show="organization.is_admin">
<span class="role-group" current-role="team.role" pull-left="true"
role-changed="setRole(role, team.name)" roles="teamRoles"></span>
<span class="cor-options-menu">
<span class="cor-option" option-click="askDeleteTeam(team.name)">
<i class="fa fa-times"></i> Delete Team {{ team.name }}
</span>
</span>
</div>
</div>
</div>
<!-- Members List -->
<div ng-show="showingMembers">
<div class="cor-loader" ng-if="!fullMemberList"></div>
<div class="filter-box" collection="fullMemberList" filter-model="memberFilter"
filter-name="Organization Members"></div>
<div class="empty" ng-if="fullMemberList.length && !(fullMemberList | filter:memberFilter).length">
<div class="empty-primary-msg">No organization members found matching filter.</div>
<div class="empty-secondary-msg">
Please change your filter to display members.
</div>
</div>
<table class="cor-table" ng-if="(fullMemberList | filter:memberFilter).length">
<thead>
<td>Member Name</td>
<td>Teams</td>
<td>Direct Repository Permissions</td>
<td class="options-col"></td>
</thead>
<tbody ng-repeat="memberInfo in fullMemberList | filter:memberFilter | orderBy:'name'" bindonce>
<tr>
<td>
<div class="entity-reference" entity="memberInfo"></div>
</td>
<td>
<span ng-repeat="team in memberInfo.teams"
data-title="Team {{ team.name }}" bs-tooltip>
<span class="anchor" href="/organization/{{ organization.name }}/teams/{{ team.name }}">
<span class="avatar" size="24" data="team.avatar"></span>
</span>
</span>
</td>
<td>
<span class="empty" bo-if="memberInfo.repositories.length == 0">
(No direct permissions on any repositories)
</span>
<span class="member-perm-summary" bo-if="memberInfo.repositories.length > 0">
Direct permissions on {{ memberInfo.repositories.length }}
<span bo-if="memberInfo.repositories.length == 1">repository</span>
<span bo-if="memberInfo.repositories.length > 1">repositories</span>
under this organization
</span>
</td>
<td class="options-col">
<span class="cor-options-menu" ng-if="memberInfo.name != user.username">
<span class="cor-option" option-click="askRemoveMember(memberInfo)">
<i class="fa fa-times"></i> Remove From Organization
</span>
</span>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Remove member confirm -->
<div class="cor-confirm-dialog"
dialog-context="removeMemberInfo"
dialog-action="removeMember(info, callback)"
dialog-title="Remove Organization Member"
dialog-action-title="Remove Member">
<div class="co-alert co-alert-info" style="margin-bottom: 10px;">
<span class="entity-reference" entity="removeMemberInfo"></span> will be removed from all teams and repositories under this organization in which they are a
member or have permissions.
</div>
Continue with removal of <span class="entity-reference" entity="removeMemberInfo"></span>?
</div>
</div>

View file

@ -12,7 +12,7 @@ angular.module('quay').directive('teamsManager', function () {
'organization': '=organization',
'isEnabled': '=isEnabled'
},
controller: function($scope, $element, ApiService, CreateService, $timeout) {
controller: function($scope, $element, ApiService, CreateService, $timeout, UserService) {
$scope.TEAM_PATTERN = TEAM_PATTERN;
$scope.teamRoles = [
{ 'id': 'member', 'title': 'Member', 'kind': 'default' },
@ -20,8 +20,12 @@ angular.module('quay').directive('teamsManager', function () {
{ 'id': 'admin', 'title': 'Admin', 'kind': 'primary' }
];
UserService.updateUserIn($scope);
$scope.members = {};
$scope.orderedTeams = [];
$scope.showingMembers = false;
$scope.fullMemberList = null;
var loadTeamMembers = function() {
if (!$scope.organization || !$scope.isEnabled) { return; }
@ -141,6 +145,47 @@ angular.module('quay').directive('teamsManager', function () {
delete $scope.organization.teams[teamname];
}, ApiService.errorDisplay('Cannot delete team'));
};
$scope.showMembers = function(value) {
$scope.showingMembers = value;
if (value && !$scope.fullMemberList) {
var params = {
'orgname': $scope.organization.name
};
ApiService.getOrganizationMembers(null, params).then(function(resp) {
$scope.fullMemberList = resp['members'];
}, ApiService.errorDisplay('Could not load full membership list'));
}
};
$scope.removeMember = function(memberInfo, callback) {
var params = {
'orgname': $scope.organization.name,
'membername': memberInfo.name
};
var errorHandler = ApiService.errorDisplay('Could not remove member', function() {
callback(false);
});
ApiService.removeOrganizationMember(null, params).then(function(resp) {
// Reset the state of the directive.
$scope.members = {};
$scope.orderedTeams = [];
$scope.fullMemberList = null;
loadOrderedTeams();
loadTeamMembers();
$scope.showMembers(true);
callback(true);
}, errorHandler)
};
$scope.askRemoveMember = function(memberInfo) {
$scope.removeMemberInfo = $.extend({}, memberInfo);
};
}
};

View file

@ -30,7 +30,7 @@
<span class="cor-tab" tab-active="true" tab-title="Repositories" tab-target="#repos">
<i class="fa fa-hdd-o"></i>
</span>
<span class="cor-tab" tab-title="Teams" tab-target="#teams" tab-init="showTeams()">
<span class="cor-tab" tab-title="Teams and Membership" tab-target="#teams" tab-init="showTeams()">
<i class="fa fa-users"></i>
</span>
<span class="cor-tab" tab-title="Robot Accounts" tab-target="#robots" tab-init="showRobots()"

View file

@ -1966,19 +1966,19 @@ class TestPermissionPrototypeBuynlargeL24b(ApiTestCase):
class TestOrganizationMemberBuynlargeDevtable(ApiTestCase):
def setUp(self):
ApiTestCase.setUp(self)
self._set_url(OrganizationMember, orgname="buynlarge", membername="devtable")
self._set_url(OrganizationMember, orgname="buynlarge", membername="someuser")
def test_get_anonymous(self):
self._run_test('GET', 401, None, None)
def test_delete_anonymous(self):
self._run_test('DELETE', 401, None, None)
def test_get_freshuser(self):
self._run_test('GET', 403, 'freshuser', None)
def test_delete_freshuser(self):
self._run_test('DELETE', 403, 'freshuser', None)
def test_get_reader(self):
self._run_test('GET', 403, 'reader', None)
def test_delete_reader(self):
self._run_test('DELETE', 403, 'reader', None)
def test_get_devtable(self):
self._run_test('GET', 200, 'devtable', None)
def test_delete_devtable(self):
self._run_test('DELETE', 404, 'devtable', None)
class TestOrgRobotBuynlargeZ7pd(ApiTestCase):

View file

@ -802,28 +802,76 @@ class TestUpdateOrganizationPrototypes(ApiTestCase):
self.assertEquals('admin', json['role'])
class TestGetOrganiaztionMembers(ApiTestCase):
class TestGetOrganizationMembers(ApiTestCase):
def test_getmembers(self):
self.login(ADMIN_ACCESS_USER)
json = self.getJsonResponse(OrganizationMemberList,
params=dict(orgname=ORGANIZATION))
assert ADMIN_ACCESS_USER in json['members']
assert READ_ACCESS_USER in json['members']
assert not NO_ACCESS_USER in json['members']
membernames = [member['name'] for member in json['members']]
assert ADMIN_ACCESS_USER in membernames
assert READ_ACCESS_USER in membernames
assert not NO_ACCESS_USER in membernames
def test_getspecificmember(self):
class TestRemoveOrganizationMember(ApiTestCase):
def test_try_remove_only_admin(self):
self.login(ADMIN_ACCESS_USER)
json = self.getJsonResponse(OrganizationMember,
params=dict(orgname=ORGANIZATION,
membername=ADMIN_ACCESS_USER))
self.deleteResponse(OrganizationMember,
params=dict(orgname=ORGANIZATION, membername=ADMIN_ACCESS_USER),
expected_code=400)
self.assertEquals(ADMIN_ACCESS_USER, json['member']['name'])
self.assertEquals('user', json['member']['kind'])
def test_remove_member(self):
self.login(ADMIN_ACCESS_USER)
assert 'owners' in json['member']['teams']
json = self.getJsonResponse(OrganizationMemberList,
params=dict(orgname=ORGANIZATION))
membernames = [member['name'] for member in json['members']]
assert ADMIN_ACCESS_USER in membernames
assert READ_ACCESS_USER in membernames
self.deleteResponse(OrganizationMember,
params=dict(orgname=ORGANIZATION, membername=READ_ACCESS_USER))
json = self.getJsonResponse(OrganizationMemberList,
params=dict(orgname=ORGANIZATION))
membernames = [member['name'] for member in json['members']]
assert ADMIN_ACCESS_USER in membernames
assert not READ_ACCESS_USER in membernames
def test_remove_member_repo_permission(self):
self.login(ADMIN_ACCESS_USER)
# Add read user as a direct permission on the admin user's repo.
model.set_user_repo_permission(READ_ACCESS_USER, ADMIN_ACCESS_USER, 'simple', 'read')
# Verify the user has a permission on the admin user's repo.
admin_perms = [p.user.username for p in model.get_all_repo_users(ADMIN_ACCESS_USER, 'simple')]
assert READ_ACCESS_USER in admin_perms
# Add read user as a direct permission on the org repo.
model.set_user_repo_permission(READ_ACCESS_USER, ORGANIZATION, ORG_REPO, 'read')
# Verify the user has a permission on the org repo.
org_perms = [p.user.username for p in model.get_all_repo_users(ORGANIZATION, ORG_REPO)]
assert READ_ACCESS_USER in org_perms
# Remove the user from the org.
self.deleteResponse(OrganizationMember,
params=dict(orgname=ORGANIZATION, membername=READ_ACCESS_USER))
# Verify that the user's permission on the org repo is gone, but it is still
# present on the other repo.
org_perms = [p.user.username for p in model.get_all_repo_users(ORGANIZATION, ORG_REPO)]
assert not READ_ACCESS_USER in org_perms
admin_perms = [p.user.username for p in model.get_all_repo_users(ADMIN_ACCESS_USER, 'simple')]
assert READ_ACCESS_USER in admin_perms
class TestGetOrganizationPrivateAllowed(ApiTestCase):