Work in progress: Require invite acceptance to join an org

This commit is contained in:
Joseph Schorr 2014-08-15 17:47:43 -04:00
parent f6f857eec2
commit 56d7a3524d
9 changed files with 157 additions and 13 deletions

View file

@ -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]

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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;
};

View file

@ -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) {

View file

@ -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.

View file

@ -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)