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):
|
||||
name = CharField(unique=True, index=True)
|
||||
|
||||
|
@ -405,4 +413,4 @@ all_models = [User, Repository, Image, AccessToken, Role, RepositoryPermission,
|
|||
OAuthApplication, OAuthAuthorizationCode, OAuthAccessToken, NotificationKind,
|
||||
Notification, ImageStorageLocation, ImageStoragePlacement,
|
||||
ExternalNotificationEvent, ExternalNotificationMethod, RepositoryNotification,
|
||||
RepositoryAuthorizedEmail]
|
||||
RepositoryAuthorizedEmail, TeamMemberInvite]
|
||||
|
|
|
@ -43,6 +43,9 @@ class InvalidRobotException(DataModelException):
|
|||
class InvalidTeamException(DataModelException):
|
||||
pass
|
||||
|
||||
class InvalidTeamMemberException(DataModelException):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidPasswordException(DataModelException):
|
||||
pass
|
||||
|
@ -291,11 +294,46 @@ def remove_team(org_name, team_name, removed_by_username):
|
|||
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):
|
||||
try:
|
||||
return TeamMember.create(user=user, team=team)
|
||||
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))
|
||||
|
||||
|
||||
|
@ -570,6 +608,10 @@ def get_organization_team_members(teamid):
|
|||
query = joined.where(Team.id == teamid)
|
||||
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):
|
||||
Org = User.alias()
|
||||
|
|
|
@ -1,12 +1,32 @@
|
|||
from flask import request
|
||||
|
||||
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.auth_context import get_authenticated_user
|
||||
from auth import scopes
|
||||
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):
|
||||
view_permission = ViewTeamPermission(orgname, team.name)
|
||||
|
@ -19,11 +39,23 @@ def team_view(orgname, team):
|
|||
'role': role
|
||||
}
|
||||
|
||||
def member_view(member):
|
||||
def member_view(member, invited=False):
|
||||
return {
|
||||
'name': member.username,
|
||||
'kind': 'user',
|
||||
'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
|
||||
}
|
||||
|
||||
|
||||
|
@ -114,8 +146,10 @@ class OrganizationTeam(ApiResource):
|
|||
@internal_only
|
||||
class TeamMemberList(ApiResource):
|
||||
""" 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')
|
||||
def get(self, orgname, teamname):
|
||||
def get(self, args, orgname, teamname):
|
||||
""" Retrieve the list of members for the specified team. """
|
||||
view_permission = ViewTeamPermission(orgname, teamname)
|
||||
edit_permission = AdministerOrganizationPermission(orgname)
|
||||
|
@ -128,11 +162,17 @@ class TeamMemberList(ApiResource):
|
|||
raise NotFound()
|
||||
|
||||
members = model.get_organization_team_members(team.id)
|
||||
return {
|
||||
data = {
|
||||
'members': {m.username : member_view(m) for m in members},
|
||||
'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()
|
||||
|
||||
|
||||
|
@ -142,7 +182,7 @@ class TeamMember(ApiResource):
|
|||
@require_scope(scopes.ORG_ADMIN)
|
||||
@nickname('updateOrganizationTeamMember')
|
||||
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)
|
||||
if permission.can():
|
||||
team = None
|
||||
|
@ -159,10 +199,19 @@ class TeamMember(ApiResource):
|
|||
if not user:
|
||||
raise request_error(message='Unknown user')
|
||||
|
||||
# Add the user to the team.
|
||||
model.add_user_to_team(user, team)
|
||||
# Add or invite the user to the team.
|
||||
adder = None
|
||||
if get_authenticated_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)
|
||||
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()
|
||||
|
||||
|
|
|
@ -212,6 +212,7 @@ def initialize_database():
|
|||
|
||||
LogEntryKind.create(name='org_create_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_remove_team_member')
|
||||
LogEntryKind.create(name='org_set_team_description')
|
||||
|
@ -261,6 +262,7 @@ def initialize_database():
|
|||
NotificationKind.create(name='over_private_usage')
|
||||
NotificationKind.create(name='expiring_license')
|
||||
NotificationKind.create(name='maintenance')
|
||||
NotificationKind.create(name='org_team_invite')
|
||||
|
||||
NotificationKind.create(name='test_notification')
|
||||
|
||||
|
@ -292,7 +294,7 @@ def populate_database():
|
|||
new_user_2.verified = True
|
||||
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.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.
|
||||
path = path.substr('/api/v1/'.length, path.length);
|
||||
|
||||
// Build the path, adjusted with the inline parameters.
|
||||
var used = {};
|
||||
var url = '';
|
||||
for (var i = 0; i < path.length; ++i) {
|
||||
var c = path[i];
|
||||
|
@ -747,6 +749,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
|
|||
throw new Error('Missing parameter: ' + varName);
|
||||
}
|
||||
|
||||
used[varName] = true;
|
||||
url += parameters[varName];
|
||||
i = end;
|
||||
continue;
|
||||
|
@ -755,6 +758,20 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
|
|||
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;
|
||||
};
|
||||
|
||||
|
|
|
@ -2411,7 +2411,8 @@ function TeamViewCtrl($rootScope, $scope, Restangular, ApiService, $routeParams)
|
|||
var loadMembers = function() {
|
||||
var params = {
|
||||
'orgname': orgname,
|
||||
'teamname': teamname
|
||||
'teamname': teamname,
|
||||
'includePending': true
|
||||
};
|
||||
|
||||
$scope.membersResource = ApiService.getOrganizationTeamMembersAsResource(params).get(function(resp) {
|
||||
|
|
|
@ -15,6 +15,7 @@
|
|||
<tr ng-repeat="(name, member) in members">
|
||||
<td class="user entity">
|
||||
<span class="entity-reference" entity="member" namespace="organization.name"></span>
|
||||
{{ member.invited }}
|
||||
</td>
|
||||
<td>
|
||||
<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>
|
||||
"""
|
||||
|
||||
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}'
|
||||
|
||||
|
@ -123,3 +136,14 @@ def send_payment_failed(customer_email, quay_username):
|
|||
recipients=[customer_email])
|
||||
msg.html = PAYMENT_FAILED.format(quay_username)
|
||||
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