diff --git a/data/database.py b/data/database.py index 76a0af9df..3e98b83be 100644 --- a/data/database.py +++ b/data/database.py @@ -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] diff --git a/data/model/legacy.py b/data/model/legacy.py index b5afdfeb8..64bf8d4f8 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -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() diff --git a/endpoints/api/team.py b/endpoints/api/team.py index 0631cc028..47eeed1f4 100644 --- a/endpoints/api/team.py +++ b/endpoints/api/team.py @@ -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,14 +39,26 @@ 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 + } + + @resource('/v1/organization//team/') @internal_only class OrganizationTeam(ApiResource): @@ -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) - log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname}) - return member_view(user) + # 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, invited=False) + + # User was invited. + log_action('org_invite_team_member', orgname, {'member': membername, 'team': teamname}) + return member_view(user, invited=True) raise Unauthorized() diff --git a/initdb.py b/initdb.py index 7e48ae3af..21485d5a4 100644 --- a/initdb.py +++ b/initdb.py @@ -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() diff --git a/static/js/app.js b/static/js/app.js index 9c4095db6..3dba1519e 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -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; }; diff --git a/static/js/controllers.js b/static/js/controllers.js index e04dd0a3c..e05fdc0c3 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -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) { diff --git a/static/partials/team-view.html b/static/partials/team-view.html index b55721455..c63bcea1d 100644 --- a/static/partials/team-view.html +++ b/static/partials/team-view.html @@ -15,6 +15,7 @@ + {{ member.invited }} %s/authrepoemail?code=%s """ +INVITE_TO_ORG_TEAM_MESSAGE = """ +Hi {0},
+{1} has invited you to join the team {2} under organization {3} on {5}. +

+To join the team, please click the following link:
+{4}/confirminvite?code={6} +

+If you were not expecting this invitation, you can ignore this email. +

+Thanks,
+- {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)