Work in progress: Require invite acceptance to join an org
This commit is contained in:
parent
f6f857eec2
commit
56d7a3524d
9 changed files with 157 additions and 13 deletions
|
@ -108,6 +108,14 @@ class TeamMember(BaseModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TeamMemberInvite(BaseModel):
|
||||||
|
# Note: Either user OR email will be filled in, but not both.
|
||||||
|
user = ForeignKeyField(User, index=True, null=True)
|
||||||
|
email = CharField(null=True)
|
||||||
|
team = ForeignKeyField(Team, index=True)
|
||||||
|
invite_token = CharField(default=uuid_generator)
|
||||||
|
|
||||||
|
|
||||||
class LoginService(BaseModel):
|
class LoginService(BaseModel):
|
||||||
name = CharField(unique=True, index=True)
|
name = CharField(unique=True, index=True)
|
||||||
|
|
||||||
|
@ -405,4 +413,4 @@ all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission,
|
||||||
OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, NotificationKind,
|
OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, NotificationKind,
|
||||||
Notification, ImageStorageLocation, ImageStoragePlacement,
|
Notification, ImageStorageLocation, ImageStoragePlacement,
|
||||||
ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification,
|
ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification,
|
||||||
RepositoryAuthorizedEmail]
|
RepositoryAuthorizedEmail, TeamMemberInvite]
|
||||||
|
|
|
@ -43,6 +43,9 @@ class InvalidRobotException(DataModelException):
|
||||||
class InvalidTeamException(DataModelException):
|
class InvalidTeamException(DataModelException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
class InvalidTeamMemberException(DataModelException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class InvalidPasswordException(DataModelException):
|
class InvalidPasswordException(DataModelException):
|
||||||
pass
|
pass
|
||||||
|
@ -291,11 +294,46 @@ def remove_team(org_name, team_name, removed_by_username):
|
||||||
team.delete_instance(recursive=True, delete_nullable=True)
|
team.delete_instance(recursive=True, delete_nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
def add_or_invite_to_team(team, user=None, email=None, adder=None):
|
||||||
|
# If the user is a member of the organization, then we simply add the
|
||||||
|
# user directly to the team. Otherwise, an invite is created for the user/email.
|
||||||
|
# We return None if the user was directly added and the invite object if the user was invited.
|
||||||
|
if email:
|
||||||
|
try:
|
||||||
|
user = User.get(email=email)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
pass
|
||||||
|
|
||||||
|
requires_invite = True
|
||||||
|
if user:
|
||||||
|
orgname = team.organization.username
|
||||||
|
|
||||||
|
# If the user is part of the organization (or a robot), then no invite is required.
|
||||||
|
if user.robot:
|
||||||
|
requires_invite = False
|
||||||
|
if not user.username.startswith(orgname + '+'):
|
||||||
|
raise InvalidTeamMemberException('Cannot add the specified robot to this team, ' +
|
||||||
|
'as it is not a member of the organization')
|
||||||
|
else:
|
||||||
|
Org = User.alias()
|
||||||
|
found = User.select(User.username)
|
||||||
|
found = found.where(User.username == user.username).join(TeamMember).join(Team)
|
||||||
|
found = found.join(Org, on=(Org.username == orgname)).limit(1)
|
||||||
|
requires_invite = not any(found)
|
||||||
|
|
||||||
|
# If we have a valid user and no invite is required, simply add the user to the team.
|
||||||
|
if user and not requires_invite:
|
||||||
|
add_user_to_team(user, team)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return TeamMemberInvite.create(user=user, email=email if not user else None, team=team)
|
||||||
|
|
||||||
|
|
||||||
def add_user_to_team(user, team):
|
def add_user_to_team(user, team):
|
||||||
try:
|
try:
|
||||||
return TeamMember.create(user=user, team=team)
|
return TeamMember.create(user=user, team=team)
|
||||||
except Exception:
|
except Exception:
|
||||||
raise DataModelException('Unable to add user \'%s\' to team: \'%s\'' %
|
raise DataModelException('User \'%s\' is already a member of team \'%s\'' %
|
||||||
(user.username, team.name))
|
(user.username, team.name))
|
||||||
|
|
||||||
|
|
||||||
|
@ -570,6 +608,10 @@ def get_organization_team_members(teamid):
|
||||||
query = joined.where(Team.id == teamid)
|
query = joined.where(Team.id == teamid)
|
||||||
return query
|
return query
|
||||||
|
|
||||||
|
def get_organization_team_member_invites(teamid):
|
||||||
|
joined = TeamMemberInvite.select().join(Team).join(User)
|
||||||
|
query = joined.where(Team.id == teamid)
|
||||||
|
return query
|
||||||
|
|
||||||
def get_organization_member_set(orgname):
|
def get_organization_member_set(orgname):
|
||||||
Org = User.alias()
|
Org = User.alias()
|
||||||
|
|
|
@ -1,12 +1,32 @@
|
||||||
from flask import request
|
from flask import request
|
||||||
|
|
||||||
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
|
from endpoints.api import (resource, nickname, ApiResource, validate_json_request, request_error,
|
||||||
log_action, Unauthorized, NotFound, internal_only, require_scope)
|
log_action, Unauthorized, NotFound, internal_only, require_scope,
|
||||||
|
query_param, truthy_bool, parse_args)
|
||||||
from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission
|
from auth.permissions import AdministerOrganizationPermission, ViewTeamPermission
|
||||||
from auth.auth_context import get_authenticated_user
|
from auth.auth_context import get_authenticated_user
|
||||||
from auth import scopes
|
from auth import scopes
|
||||||
from data import model
|
from data import model
|
||||||
|
from util.useremails import send_org_invite_email
|
||||||
|
|
||||||
|
def add_or_invite_to_team(team, user=None, email=None, adder=None):
|
||||||
|
invite = model.add_or_invite_to_team(team, user, email, adder)
|
||||||
|
if not invite:
|
||||||
|
# User was added to the team directly.
|
||||||
|
return
|
||||||
|
|
||||||
|
orgname = team.organization.username
|
||||||
|
if user:
|
||||||
|
model.create_notification('org_team_invite', user, metadata = {
|
||||||
|
'code': invite.invite_token,
|
||||||
|
'adder': adder,
|
||||||
|
'org': orgname,
|
||||||
|
'team': team.name
|
||||||
|
})
|
||||||
|
|
||||||
|
send_org_invite_email(user.username if user else email, user.email if user else email,
|
||||||
|
orgname, team.name, adder, invite.invite_token)
|
||||||
|
return invite
|
||||||
|
|
||||||
def team_view(orgname, team):
|
def team_view(orgname, team):
|
||||||
view_permission = ViewTeamPermission(orgname, team.name)
|
view_permission = ViewTeamPermission(orgname, team.name)
|
||||||
|
@ -19,14 +39,26 @@ def team_view(orgname, team):
|
||||||
'role': role
|
'role': role
|
||||||
}
|
}
|
||||||
|
|
||||||
def member_view(member):
|
def member_view(member, invited=False):
|
||||||
return {
|
return {
|
||||||
'name': member.username,
|
'name': member.username,
|
||||||
'kind': 'user',
|
'kind': 'user',
|
||||||
'is_robot': member.robot,
|
'is_robot': member.robot,
|
||||||
|
'invited': invited,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def invite_view(invite):
|
||||||
|
if invite.user:
|
||||||
|
return member_view(invite.user, invited=True)
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
'email': invite.email,
|
||||||
|
'kind': 'invite',
|
||||||
|
'invited': True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@resource('/v1/organization/<orgname>/team/<teamname>')
|
@resource('/v1/organization/<orgname>/team/<teamname>')
|
||||||
@internal_only
|
@internal_only
|
||||||
class OrganizationTeam(ApiResource):
|
class OrganizationTeam(ApiResource):
|
||||||
|
@ -114,8 +146,10 @@ class OrganizationTeam(ApiResource):
|
||||||
@internal_only
|
@internal_only
|
||||||
class TeamMemberList(ApiResource):
|
class TeamMemberList(ApiResource):
|
||||||
""" Resource for managing the list of members for a team. """
|
""" Resource for managing the list of members for a team. """
|
||||||
|
@parse_args
|
||||||
|
@query_param('includePending', 'Whether to include pending members', type=truthy_bool, default=False)
|
||||||
@nickname('getOrganizationTeamMembers')
|
@nickname('getOrganizationTeamMembers')
|
||||||
def get(self, orgname, teamname):
|
def get(self, args, orgname, teamname):
|
||||||
""" Retrieve the list of members for the specified team. """
|
""" Retrieve the list of members for the specified team. """
|
||||||
view_permission = ViewTeamPermission(orgname, teamname)
|
view_permission = ViewTeamPermission(orgname, teamname)
|
||||||
edit_permission = AdministerOrganizationPermission(orgname)
|
edit_permission = AdministerOrganizationPermission(orgname)
|
||||||
|
@ -128,11 +162,17 @@ class TeamMemberList(ApiResource):
|
||||||
raise NotFound()
|
raise NotFound()
|
||||||
|
|
||||||
members = model.get_organization_team_members(team.id)
|
members = model.get_organization_team_members(team.id)
|
||||||
return {
|
data = {
|
||||||
'members': {m.username : member_view(m) for m in members},
|
'members': {m.username : member_view(m) for m in members},
|
||||||
'can_edit': edit_permission.can()
|
'can_edit': edit_permission.can()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if args['includePending'] and edit_permission.can():
|
||||||
|
invites = model.get_organization_team_member_invites(team.id)
|
||||||
|
data['pending'] = [invite_view(i) for i in invites]
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
raise Unauthorized()
|
raise Unauthorized()
|
||||||
|
|
||||||
|
|
||||||
|
@ -142,7 +182,7 @@ class TeamMember(ApiResource):
|
||||||
@require_scope(scopes.ORG_ADMIN)
|
@require_scope(scopes.ORG_ADMIN)
|
||||||
@nickname('updateOrganizationTeamMember')
|
@nickname('updateOrganizationTeamMember')
|
||||||
def put(self, orgname, teamname, membername):
|
def put(self, orgname, teamname, membername):
|
||||||
""" Add a member to an existing team. """
|
""" Adds or invites a member to an existing team. """
|
||||||
permission = AdministerOrganizationPermission(orgname)
|
permission = AdministerOrganizationPermission(orgname)
|
||||||
if permission.can():
|
if permission.can():
|
||||||
team = None
|
team = None
|
||||||
|
@ -159,10 +199,19 @@ class TeamMember(ApiResource):
|
||||||
if not user:
|
if not user:
|
||||||
raise request_error(message='Unknown user')
|
raise request_error(message='Unknown user')
|
||||||
|
|
||||||
# Add the user to the team.
|
# Add or invite the user to the team.
|
||||||
model.add_user_to_team(user, team)
|
adder = None
|
||||||
log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname})
|
if get_authenticated_user():
|
||||||
return member_view(user)
|
adder = get_authenticated_user().username
|
||||||
|
|
||||||
|
invite = add_or_invite_to_team(team, user=user, adder=adder)
|
||||||
|
if not invite:
|
||||||
|
log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname})
|
||||||
|
return member_view(user, invited=False)
|
||||||
|
|
||||||
|
# User was invited.
|
||||||
|
log_action('org_invite_team_member', orgname, {'member': membername, 'team': teamname})
|
||||||
|
return member_view(user, invited=True)
|
||||||
|
|
||||||
raise Unauthorized()
|
raise Unauthorized()
|
||||||
|
|
||||||
|
|
|
@ -212,6 +212,7 @@ def initialize_database():
|
||||||
|
|
||||||
LogEntryKind.create(name='org_create_team')
|
LogEntryKind.create(name='org_create_team')
|
||||||
LogEntryKind.create(name='org_delete_team')
|
LogEntryKind.create(name='org_delete_team')
|
||||||
|
LogEntryKind.create(name='org_invite_team_member')
|
||||||
LogEntryKind.create(name='org_add_team_member')
|
LogEntryKind.create(name='org_add_team_member')
|
||||||
LogEntryKind.create(name='org_remove_team_member')
|
LogEntryKind.create(name='org_remove_team_member')
|
||||||
LogEntryKind.create(name='org_set_team_description')
|
LogEntryKind.create(name='org_set_team_description')
|
||||||
|
@ -261,6 +262,7 @@ def initialize_database():
|
||||||
NotificationKind.create(name='over_private_usage')
|
NotificationKind.create(name='over_private_usage')
|
||||||
NotificationKind.create(name='expiring_license')
|
NotificationKind.create(name='expiring_license')
|
||||||
NotificationKind.create(name='maintenance')
|
NotificationKind.create(name='maintenance')
|
||||||
|
NotificationKind.create(name='org_team_invite')
|
||||||
|
|
||||||
NotificationKind.create(name='test_notification')
|
NotificationKind.create(name='test_notification')
|
||||||
|
|
||||||
|
@ -292,7 +294,7 @@ def populate_database():
|
||||||
new_user_2.verified = True
|
new_user_2.verified = True
|
||||||
new_user_2.save()
|
new_user_2.save()
|
||||||
|
|
||||||
new_user_3 = model.create_user('freshuser', 'password', 'no@thanks.com')
|
new_user_3 = model.create_user('freshuser', 'password', 'jschorr+test@devtable.com')
|
||||||
new_user_3.verified = True
|
new_user_3.verified = True
|
||||||
new_user_3.save()
|
new_user_3.save()
|
||||||
|
|
||||||
|
|
|
@ -736,6 +736,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
|
||||||
// We already have /api/v1/ on the URLs, so remove them from the paths.
|
// We already have /api/v1/ on the URLs, so remove them from the paths.
|
||||||
path = path.substr('/api/v1/'.length, path.length);
|
path = path.substr('/api/v1/'.length, path.length);
|
||||||
|
|
||||||
|
// Build the path, adjusted with the inline parameters.
|
||||||
|
var used = {};
|
||||||
var url = '';
|
var url = '';
|
||||||
for (var i = 0; i < path.length; ++i) {
|
for (var i = 0; i < path.length; ++i) {
|
||||||
var c = path[i];
|
var c = path[i];
|
||||||
|
@ -747,6 +749,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
|
||||||
throw new Error('Missing parameter: ' + varName);
|
throw new Error('Missing parameter: ' + varName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
used[varName] = true;
|
||||||
url += parameters[varName];
|
url += parameters[varName];
|
||||||
i = end;
|
i = end;
|
||||||
continue;
|
continue;
|
||||||
|
@ -755,6 +758,20 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
|
||||||
url += c;
|
url += c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Append any query parameters.
|
||||||
|
var isFirst = true;
|
||||||
|
for (var paramName in parameters) {
|
||||||
|
if (!parameters.hasOwnProperty(paramName)) { continue; }
|
||||||
|
if (used[paramName]) { continue; }
|
||||||
|
|
||||||
|
var value = parameters[paramName];
|
||||||
|
if (value) {
|
||||||
|
url += isFirst ? '?' : '&';
|
||||||
|
url += paramName + '=' + encodeURIComponent(value)
|
||||||
|
isFirst = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -2411,7 +2411,8 @@ function TeamViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams)
|
||||||
var loadMembers = function() {
|
var loadMembers = function() {
|
||||||
var params = {
|
var params = {
|
||||||
'orgname': orgname,
|
'orgname': orgname,
|
||||||
'teamname': teamname
|
'teamname': teamname,
|
||||||
|
'includePending': true
|
||||||
};
|
};
|
||||||
|
|
||||||
$scope.membersResource = ApiService.getOrganizationTeamMembersAsResource(params).get(function(resp) {
|
$scope.membersResource = ApiService.getOrganizationTeamMembersAsResource(params).get(function(resp) {
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
<tr ng-repeat="(name, member) in members">
|
<tr ng-repeat="(name, member) in members">
|
||||||
<td class="user entity">
|
<td class="user entity">
|
||||||
<span class="entity-reference" entity="member" namespace="organization.name"></span>
|
<span class="entity-reference" entity="member" namespace="organization.name"></span>
|
||||||
|
{{ member.invited }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="delete-ui" delete-title="'Remove User From Team'" button-title="'Remove'"
|
<span class="delete-ui" delete-title="'Remove User From Team'" button-title="'Remove'"
|
||||||
|
|
Binary file not shown.
|
@ -65,6 +65,19 @@ To confirm this email address, please click the following link:<br>
|
||||||
<a href="%s/authrepoemail?code=%s">%s/authrepoemail?code=%s</a>
|
<a href="%s/authrepoemail?code=%s">%s/authrepoemail?code=%s</a>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
INVITE_TO_ORG_TEAM_MESSAGE = """
|
||||||
|
Hi {0},<br>
|
||||||
|
{1} has invited you to join the team {2} under organization {3} on <a href="{4}">{5}</a>.
|
||||||
|
<br><br>
|
||||||
|
To join the team, please click the following link:<br>
|
||||||
|
<a href="{4}/confirminvite?code={6}">{4}/confirminvite?code={6}</a>
|
||||||
|
<br><br>
|
||||||
|
If you were not expecting this invitation, you can ignore this email.
|
||||||
|
<br><br>
|
||||||
|
Thanks,<br>
|
||||||
|
- {5} Support
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
SUBSCRIPTION_CHANGE_TITLE = 'Subscription Change - {0} {1}'
|
SUBSCRIPTION_CHANGE_TITLE = 'Subscription Change - {0} {1}'
|
||||||
|
|
||||||
|
@ -123,3 +136,14 @@ def send_payment_failed(customer_email, quay_username):
|
||||||
recipients=[customer_email])
|
recipients=[customer_email])
|
||||||
msg.html = PAYMENT_FAILED.format(quay_username)
|
msg.html = PAYMENT_FAILED.format(quay_username)
|
||||||
mail.send(msg)
|
mail.send(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def send_org_invite_email(member_name, member_email, orgname, team, adder, code):
|
||||||
|
app_title = app.config['REGISTRY_TITLE_SHORT']
|
||||||
|
app_url = get_app_url()
|
||||||
|
|
||||||
|
title = '%s has invited you to join a team in %s' % (adder, app_title)
|
||||||
|
msg = Message(title, sender='support@quay.io', recipients=[member_email])
|
||||||
|
msg.html = INVITE_TO_ORG_TEAM_MESSAGE.format(member_name, adder, team, orgname,
|
||||||
|
app_url, app_title, code)
|
||||||
|
mail.send(msg)
|
||||||
|
|
Reference in a new issue