From 56d7a3524de95c54bfe01c4306e195bbf33958fd Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 15 Aug 2014 17:47:43 -0400 Subject: [PATCH 01/90] Work in progress: Require invite acceptance to join an org --- data/database.py | 10 ++++- data/model/legacy.py | 44 +++++++++++++++++++++- endpoints/api/team.py | 67 ++++++++++++++++++++++++++++----- initdb.py | 4 +- static/js/app.js | 17 +++++++++ static/js/controllers.js | 3 +- static/partials/team-view.html | 1 + test/data/test.db | Bin 614400 -> 626688 bytes util/useremails.py | 24 ++++++++++++ 9 files changed, 157 insertions(+), 13 deletions(-) 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 }} ;tmS7<^;9kb(V!qbS+rK69(!uhRH2DGQr~*k>a3UF3)R4Fe5?=1=Wa1-e!}LYq?m{*TRrXJ}z& zNqgYK!I$Ys+1C?zX7#y9{>49u{$7(_)#}d_XZh#D*6pkm`2ElX&9ccofxwz^IwSvp z5-42z2+fZC=y2e7YtPWL)!G*VuMEFT&nw8`0-Hz9MNTVIHu-zmFJb(<5$2`QJ=8~! z|2(jI-8dcd#*V~5%KArW-O_!Iz`^xr=$WVTF9gOnysRYwd$Yfff~;F~`GIF{-=j@* ze5cjltA?Z~By7K9LYp@8j|=_t2pFg9@&cR3n&V**2m6Di*gRB&&G!{-zL$Z`clTc& zW4fqJLq$OQJ3cMK_!r}G<8#Ix#=DIkW2w<>j5d63_=Dkf!-N40V+OCGV*j+1^Lk%G zCZ)>vcze7<-lc=yf&Qg^{X^ZpZntx&yT5P7TuNngdOSCBV&+gP@mjTEuWNaK|H`Ng zN@e~+;|ei#cHot>X=CY>O1Prp92n@i(noX}lGhA)oM^zcP=!ibQwc z+U_Cm{oV{O{sryyL7JY*LK7+}y!DuwzZ`#Af za(8nk?xDc6l)2z~jQl0|`c}f5pyuaf!{lB zu%STzuKso$*AhM4_ZdkoY_DplEH4sk+dGu1hH}}qu(Z6gqNTp6rphK)TPuspON#31 zD{6|1+o~#+%FeRll2)OqzEKv6i!4n-M+5xnGm=tRp)9mpEPRntSzl9C+_X?G?I@~m zuvT>=`#?u|wc9SXm9AacQw_&1lDV>A z_pVvtvGM)G#VzYrW%qWJDxJ19tsX~>tEQ!`vt-2OX_wbEtt(%x5~GQ z?}mp$s-4Y)9gY0R@`2il>YA#aoW}Y=OGWlz&*(zFv}~kjw7t8rrMAyf@2j+y6%RMH zuE}vPY==)S5n-%sVJmN~Y%VUT>}Z#S;)=F*-mbJ;JFOMX%|b^@U2CIYZ*QsQ9b&ms zTGLi-7poRZovls0y}7g0QZ07VdR4NYDbT4{mh}yKS1(_)+B=wTai$BFbeC+it#&zz zN2HGW>T*w0ZPQ47cKvWm#d6Pzwbh+9eWP=o{LpG;VQE9-P_}(UYOKgEb*@gVVXG>y>TE48FDq%SXzFaSJ1p(3vZcPY+}c^v z(&nfwt?KNw)ECv37FSu?n~KX9w${~EmN+WKhE}n<2JX8|66MM&sc$gbyQak_HZNb7 zGgvgx*HrB7b2YmM2CJpD-Oj$@fmTZ$KiuA()8MwaEt0sbJoxryl9duKs<>_3EYqk- z82a`k< zkei2dV~%>xw~k5h4>{`3vcI*@DkzPL5Wi8c{vY{m?3xLrLLG);)Zf;36(o$LR)16G zrhHTCPIjqvDcWao_0KfDhq-pdG*-^wc!~PkW*dD4=l|AkW7ki-)D@s_>ov6_G%(#a z`E1rVmiy!b>d)%t+H0-#QdLD$yDT+SwN|w^mCL+RR@Z86Di)fBCb3m$;LC-D#qi?0 z>e*27uDX7%Qd`$i-rii)R?^teB9=)NN}Hpk$l|C_>=t{OSmvly>Z_zWTM$(~&Y9(N zdz~IZGW%S1!7N!Uvf0T?KC{p6m8>q&$@}04ML!sf2i89^AQKrW^ zBe#aj8IAIeT;Ide&?>aF+jBj=2qyBW8Tv5rln^-csi?{@ zAvI-g>J`P*%sUo*Z3iLbn{)tA=2LN34PT6X#e4~UMI|-Uzpen1^QaV30)=_hTa$$p zy*Ee~l8ZT89w3@snvbYL&0ZBZYTAp{y2Avg7tn@uK@lalO|Y6Jo5f|8c;07LTvn&q z&Pz_OZ1Ff1K>%X`&5t<-yR%XRIi+aLvJ?w1NGTQ}SK@PJCB@9+pVW|#`7W zsW(bnz9M~l2*S)`2FfV{re6psTd;|eBKm2fQ?(MEpDAMPGH;cf7OUAkCfhw`$>p`0 z?JmV_cG;YQVsQx;yTztopW5$QfxWIzbz&=^B`*Bmayvda2M6(S5z;JKDdG~OtnTlf zvT&`lXN@=Y=5|&tNrizTRn$r)qOege?IWZjr0gNCfO9X)vYLI#pbn}B{zD3BI!fTamvf}cXd5Z@FFZx_&r_1X!W_`DJ?74Wj;6`c}A8PQ?UsvZ^o{VmBO|3x)ZFVPA;&O~Z! zwV$xtb=A6~5ib2){Z50ySZ+LIs#NV%ZNtgHs`eE^LOwN@l!huP4wmFovq=%$6(W^| zNCl)A&gN4*aloowBpRN}r*47L1(XGoeRu(tPRfIiETB9@-B${iAEUEiIZs8w-mSC_ z)^DTdAV~jKI-68N*ETu<&TXUTBN7V6!RK4)C{h)sLj8ITY|zCaDh7f4!Yf zB^B`TcG^N}g6TVGof@ICchV)`-A$)K-A+0ZcI>1D1Uj~pE`yIk)CIffGDJYJOxUxF zj)(7$hh=r4S1nxFg$gy0wVPH*BYeD@PKA4S(`Hf&le_6$(i9>!vUS?nWg_v9!s(r8 zt$Uo#BF$l8e2#;LU33zBFit0vdSIVG%UdAx33@SUfK5-(v*E}D9Rr7+z~MH+xe&Vz zwjO0>?8z?BxZ&Wg_6f1kG*PLuVrLygjr8 zXWw90*nO0l2Ie;z6YM@r(t^A9&{neZ0XlMWFI~GK-R<&WCV2(3+a)Sy$u3J~7q3{& z7SSUh&n3HMFRaR9O4F>o&*BkXPBZUyV|j}*Z+1zdV76GiKAYtADoz`m$YQMNPLE=> zDpsFab_zBWbV_EY=;O_T$6^tEcHZiC%3v`w3(_RXEBJgOrnD6W1)FSkStZ#lyFE^? z;0(z-Az)^T(|sPdAS+lh9-rNfQw#4h+ZCt9EcvWnpHsk@2Rr|VnVFyFcA+XxLuR+) z#7WX?L3EqwGfPg7#U*0@Za0)?BP5#ak$pC=S(arl0{L*ZvI|bL%-duf63@GAUf7e( zB&N%<$L{mmB%IB7T%uiW86_p&Y;js$f})_6k`vBlGZpDJUKU(-E0(v#i39Rr89I5J z%WT7;$cl%TMBWJIo%?=-MrN$ znHAnGVSt=Cp~@D;>=k6}#D!jQ$vb(biw~7_q?%W$dG#TxiTZ@z#8hh{wU^lM>g>AX z5v%o=49g5>jE$yUToc($zK6wJN!^p>f$>uePx({Y;fXhxxs>133hzBd=fQn%GU>4Q zG0gsor!l44LeyChc$!Wii^6GWJj&?dji>4CtJ>1H(k_N`zr}nVc?KRk{*f&8OP44vsO?tqVvFtb9|MnmLDO$5xuav)2;C=Czk3`tax zt}jT@fRI{9KFUY+z3?M!4++BcOQ?oM4KHCrtnQ3C4;hA{ms7IDD~CmEhBhd-ZWYOsgAQ_MWl9ePWEJ5Mng2#(>hlND2Q z1APBY4Fi!!a2)*G8g0;cnyFB4!OcXFet@}HFo_q^g+V$+^BC9JA=C*Sme!&UvB*-@ zT3K3ZDN=a8O0I3P@pYx;MJ0{a#+HS$wL|W-isfx$ky6+0C>7e0<+v~5hM$)cUB&2G5A8i z#?hb{>3$X6&%`so)U4J*`P50CVzLncNlfdORe(sWE{gpN)hclx8|?Jg$j@n&X-n(jOK?LLCP7H^(*eVDCxIm6;nu{62Q=4RxIc>)&P4 z;MrrEjL_3m+C{?HsjEqVz0(klgHg}-LDEr8Ik=9aXyH*D`FXF%*SIy$N|yt{kJtyP;yF>42Ry<%qBgcKcz{81t-y=Si>CrCd9&58{4am ztwz0D!+O67F)L1MvH_oNMK`0KovaBtaVE?;6^_R|^y&c|e65y-Gd0@THQ7uD)yJjah0wAdUnLu zvvpr#kA0DPSJBz8@NZyizrw!3?V7K!Z*V(vJ$np~de=mazLtNZSE;XOf91e+qju=$ zRyFrGY*kyh&$z#tZa3uwr|;FC;WQ5}MB)R%%P(pTJb5^1_(;2yBLV2StgR-Sg9k2a zDm7BSWto9Fy2&Lc!l8wwBx<-hm*!QSDd5Q!-mh zzH_~Ps+G+#`H42Rpg?_hQuy}zF)JGf!&Ww)+!tQDr=Xv;vcFgRRq&{d%_a9KQD z-NBFZS&r)ur=9QZFQw};VXsXD(*OU7Bl8Bml^tP^Lj{i6lK<3uR1xvE}r%O=cfR$`N!-E zIx%_i9a|n_Ytk)ln}UZTui5K!;~C3}XF0nQZ>W5D`+$cg*=6x4Fz^_Ame#LnGD6o@ zc3eAa$+6}Bq;xpK>hhpw8@oh1{nO2N`F&}yN~g=m13&w?Hez9eWg zA>?bjLXh_D8~u(%m{^Q5|FIh}&HuPJ2nFMa8F66R1HeClm@&&gh=-IX5i{5G-lRWi z29W)19vCJNGjm(nr+!~7Y~9c1!=4Gmw3LWBen$+{Sg@BZd)RyEc`uB1LGn|G$@QIo z79yW!&q_JZ6+S;TL#g^z9A|PEml%F#h}5S<PVd~&F2w3KC9L25xsay6ZA{A`jL8Q_zxVzg3{=tP&A2ti5tAksUZYmRu+Z@Pw4xy$u@RN}8Q-tE%-+l)opGOyqwz~EH zqH$@P4V-{F|DVv<1! z)xrG}P^dwOh_l-t0zr!qF}HMHf>|s=-1@)o`~-f@B1BrmuNOm~4k2oE{7IeR+WOmc z-4ZRebk#k6Uq`sMAR4if3)YuGVGLr;itpV7y4Vob8;$qFg;>N|koE9S;Eyv9EAsu~ zGybHOa7`VHLo8NQV@)-OYic+iv2tEe6+lV?V)08ZJK@6w#EO3Dk8|PeM8x9TSKR`? zO42={iJ$Y3KdBDh3u1Xr&eScT<@3esAvRfehSn-0yC+k0i>J{G)Z$CfB_J)gj(R{3+(o&MfFIGu=;5=l3qHS50P_Rs5u*OD+ZEwXL} zP44~l2s|cZJfeReH3JH*h&iJ=#s---G*!58=m7l1raKoo-#urezjyhUpJj!p%TTt>4q}+;Nw!2z_pvyzB`EM*I@J1ej@&C2y7ye$? zmtdi9yntGOVCs!=)v$j7f+g?FycN0%LY957dEsQCZY)kK@3Vg5?_D?bITyUtfg{TB z&Y9_-H#+q_7d+F6BZ}<%$9s^n2=x+|toOmeMQB7pUi@4bUyNSLI5FDo?^^?g&~)<5 t67X22&h)AHUMWmi_kozW2;M|4!GxGU@Xr4<*sZ23)y$lNi3q6cOr_Lw=U>q5<5EZ z$Lsz0QC5Q=VgY_6XX8g=+>^Av}BGy+nfQplWT`31Y&3SKbx= zplcs7^7M>b!%ub(#uzf}_bKhhXjPq(C5%@l!vinTV)!3Dn~9OvPfZAidp8mB)oJc< z!J>N!Ls#3>@Y{<{5Z1u1nc)LB>?0EHTR$qicJW|LhAT%>+Uf6Mlw%lX&yp>gV7wCU zUb=~heZc!%*mUE)L|#Sio8g@|o*?wPSAyY9%l2v0vw!i8(xHLu>AGp*Czo&0PDYQs zqqM6bxd;m$ylJy`!f%{}G6{jfq00-e?W;-nO&1>Q+7|q{s~kUWv*X9D6Y=AgZ5R5; z*_up!Agq1cJl7UggS|bV@jP zFfMAGMpZPd4R;PEQ0<+;w$PHWcQ6hmg;x!x#3yQ0xt(3DE!}cYOONbpZ|UlsFDE91 z|1y{~ajZsVkLbGO_RdA}u)1At_sd;zV{oSt6&}X6u^)egYBTjg74s4^ziCc&_R$ub&^Q;ZW0pBr8^ z>}2YgfPTL*U~m~Um{h$!>^hs2m^oHsLzgJ~A*C7N6=&lUCdO*m@AcnNIw4I{hQeD< zrl9oj3uoiTq{V4CbR~K&U9akIs$(c0O~uBQ18a7oG!(8y%OH0*O1}8vo&nz;^l)~Fz4V|@1YlXI2t3xbt(VNZF*(1y|%idt+>qU zle?ypch)TK z4dhnmE@`TPUq?*sbBdM}_wqTFU7@Z;mHxW&C6#`gcYakccj+Q7WOLLzJl&4@t+iG2 z>#Uw~ZmFkZvAGvMIET1C-et2{>p8cx+1Y3@yRBwdQLWQaU)kg>t`|KO?%I+Dw^Zz| z59tLjA$i^$bCO52*O{lg#WrD~hp$<}iGE*4XF<;# zU&p*|*mWL_v$XRC@q873&>I+*@WqwtIAc)J3Z4Kbv0h0##~cT?k#GxHJBaE7O4V{!Q#dvH;kHqvKyS`l^*|ss(FF>UYBEOt9Rh_3n<%^kgsA`V?MLdxRP1P{L}b_@$XCmqh-2{ zhj8qt82gF|#el9g_Vq(3|CiX;Ul#mN=cwm+t~DUY5taqRcBoDJPf>_t3H#{ zcda~?YA6*#iXmG4f8omB_k%)Fsvd?S)z`{bDH=vntA8q5O+OW`{j=3NlXfAFvHxVU zaD2UB>?SyMYB1JnH!XXAe|#rxN&&v-H`By%Ze zvo*Thcrj_R2*pJ%(JU584VAp7%35D*tE#Ez>aA6<<1O_BD1A%q&8(`avpZ|cYWb=L zXLE(C%Gu;8X%JmzXRQMlGQPN?th~0e##udpO+L*XZN2~Oo{#zKC+W|ulN4LWubnWzvh>?Fp* z6FZ3s=(>m~9tL+3Bhd7SJRjvl{w~6dilA@{8VxIV5e`%U2X_(a$T9HAE+UB59VLlJ zi2}_N1B8iC7f^4im`99n4h1nh(Lw0b19s6C;4K1cwORwLXtVlRzv#EKL7!+3`Z=Bt z+Jo?R2Vw53==Pm5NioZmXJk| zLq5(Dbz^FGXZuj6i+uBY<fY z6+eeFb%v%+_e1~xmHYiq5Q~{UyXfb6Kj&jPi{NKPyWPh6tY!-<@#X+;GjlR0@)~un znpUAY9j>-fe$Fi6&6|%6aU7nvU`S;BA+yNx*uXCGm>0pX#@p;61EfMxJ}D^6l4xUX zGLL=CnoPZ=in=-KL;VE(?+qEo<4mXVFw?1$BEhaquLsXQay*>hOOA$^GgK7Zv5$;ew}+&m zbPF=VTwh|no_~HhlquT_7El< z{QLG0ImiiL>>->u__0kkDuFqBi4^rULrh2CCu|a&+DoMBi?#8BfPS6Wt^wyhLZ`-lR6a`-AvavNpGbuT&k`~4%(DcCJ^JQZq8OD&y|OdH=SAY}<$ zcs4>+q~WaxiHYz*q{YRrqhRnLk%#-9bO;kx1!aebnYi1>4&gddJzOpRafq0VJir{r z4QfDnju->Cm(W`99wxp;UT_`36xRaULymzpM+gB`!rmi9E~*>0#xofOuOA^8NPUjT z!abBfM~J9?0ORx_jbf~ZdAo)KK?^f>ld-5F0+kk-d@ZcsO=jUfaf>|E7?D@?zfDvu zOAm(Z7QbDxut7P1%Xu&$v3?sbv0Nx%37Ku2owG`?a3<+Uv&m*(fWvx=WR+P_uyL#u z^y4ZPwBqV6@q$f+V>3x>x-AeCE&h(d@IavOMIa)x!GBK8_XaSisw{BNCKck0I>>9*k@gSs#y6 zQWRw~>k}e%Pq6biX)U5)7h%iwWKudWih|(BkUpQ;&WgNUU~v;a%h@EeWVPEwF<^ti z>&en|D<_69P1rd&(k;$kZRjD7 zp;1y~`1Dh%2)2JgO@z0;qDE+bc3JV_D8u!~jb>g7(t3?zs)hg9NoK)$$0$$S%@nLIT6ks zz@^xA5Tne3MF+`L6d1VYASoksJzO|MW}x6Oae0Svixy}*Oq!7l>kpIVxV!fclao*= z@+}eABjiNf-Ljpe87_Q5(WrHJ92K8YdN7}+v;*6ZkfmyklIf3l8wXFErAS!uHATbm zFDL`-*o(`@maiy1ngd(UP|256c_?ZC6`%|G{gu>1OmkyZVUfpMS_s(_SW{V9V{Nn-mloF7SgTznwWSr+%@(g~dX>fEv=&!-3v1~s*K#pO5X_uq z;Cu_Un<==~4*C~R>vPjBa?om%Ei%qZAI=~%k3R)up2elYFXCmE3xwo>r+-g5EB{Pr zX=7wRdTHB({gEQ6m}1l(i~W(pCR%w8q8aULl|Ry0MZ7ce_qNRsvK_v5IkIBw$pjUd zK<=lywWGC%qZUTJ8{Ms^^D*ak8gS_ z=mfMJvMT5axa_EVHs7hH^&(RidpLym_Y6W!MCYnA(k z)_CYUMI`~{p~q=frnbWKUsID%`*$nS=hQo}We?J?`ukpzKg@^s$QeB9iqm8#rMhX+b7!7xxrbeO8h_mBh*J)}3 zc0Bql9_w{yuoHNH#GnPkN-VI^bG7kh*mml$?ekb2^F8xJbG$G@}IU8e540v%`VzcbffroMu9>N52e zw39zp_YI#FFAj~kOn(Kdnt!jp{Iqw4b>x>oHS-mIs=~|}<}2fJW9~rg+u9Qhbw?u> z-ac^Q6RqBiemkHy(Jc&G1M~6r9NjsvJ(n(xd8CpyXVR$EFaMBDMb9_D=u)c(zf>!>(a&;*^0?1NYP`7r%6c?`fi(sppPJm@DdLpi4590Lz z-4GGQ!*&;5cTTzJ@n|88bkmd2;$eA)8+-ao7d;Y2{N|&eOtr_RH$`;n_Ge<5D1$@}U`z_Yz57}n{cI#dxc^qt=h9R?G z!;s92izOwL3{}@*$Rn>|$b{c+->Nv1VDoLb&7Y59%#rM#&5-}s2jG5WA ziUHFb^d^E65|6`?H|Tqb_}CXdgZ97CC$vegO#PMOjDvDNMqT$Ny^2SKAt%23?hYt;4^t8K#fJT0ejii8u#bHMZ@y38J7UCvr`?L2 zd-1i2WKFnlsQ^#?lYW@G@yz>*GbenqD-Rz207Ire7yA|DeuyEdLvxOU=_3p&p4Rk3 z%t<_BaWgVDDan)IQvthh_9ULM#K8}@D4{I4OT^sXH;88}=64^56=!Dn@C|ve@?#8{ zHTCWWU_QZ+vD=TG>_0{K6imd|2(sCaPt*9$0N=ez__{;1+F5f*wwPrpWVH)+#aTY& z)~jaSDk5`t(RrB7>9AY(_f3E*UU!lhTl**hg@SGqAv}I}9egb4?j>^68$X6SMBNE( z!p)1@6sL2@jeZM;u;=eO3I$dS!Av|c4mcZz81+_{6~;>#B1SXqb$Cv~5IkGJK!+Ve z5C>P@p*RbM!g<{k43WHQ?5kj!iXk#RZ`Z+yJPeWi$g-J$reTOtYf8G|@o5+$>c0X2 zp6f8gsMmfuQE^Tg4&O!j7-QN-_m}GmbTt_keEIC-@KuJ*hi@cteYFLkTo4UGAaGPS3yD%c0~B-_yLG==r(B+ z54FFjBsUM|)mIK3PSx!zdz4Vqa9%+%#v;xqQc&Q;SO)KnJm^X=7BTMT95`Kqu~G`Y zu7(dwF%}uT-K!+m!qGSAJb2ZGvFyvQfU=cMrV%K8EV|V5pQ+f3qr~3Rv+^4AtPpP%*dM zKcl}=H**9bzDhO0Y@hA~G3lPwCUE(6`?Q4V;T=jxC!8$9!B!p6ZPAWUl8-6v9q?v3 zmJ|hbo3)ns4X2bz?Qo<5hbS-W<`RPbR3>bY@w`md?K}+yA&e{~j#zrV?m6Fj$sabpW&txr`ZEg5<%37)(Kk0>Yk<*i`472D|-zWx~OycIhU z_41m#VAE}wrD?Y14=NqKpfACkJb622Y1}8$Ql-5I&P5~--l5w %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) From 7d7cca39ccdb2cf90d80cc3d46f271418b44c614 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Fri, 15 Aug 2014 20:51:31 -0400 Subject: [PATCH 02/90] New team view interface --- endpoints/api/team.py | 14 ++-- static/css/quay.css | 46 +++++++++++- static/directives/entity-reference.html | 10 ++- static/directives/entity-search.html | 2 +- static/directives/team-view-add.html | 14 ++++ static/js/app.js | 6 +- static/js/controllers.js | 33 +++++++-- static/partials/team-view.html | 93 +++++++++++++++++-------- 8 files changed, 173 insertions(+), 45 deletions(-) create mode 100644 static/directives/team-view-add.html diff --git a/endpoints/api/team.py b/endpoints/api/team.py index 47eeed1f4..3c0751a56 100644 --- a/endpoints/api/team.py +++ b/endpoints/api/team.py @@ -8,6 +8,7 @@ from auth.auth_context import get_authenticated_user from auth import scopes from data import model from util.useremails import send_org_invite_email +from util.gravatar import compute_hash def add_or_invite_to_team(team, user=None, email=None, adder=None): invite = model.add_or_invite_to_team(team, user, email, adder) @@ -44,6 +45,7 @@ def member_view(member, invited=False): 'name': member.username, 'kind': 'user', 'is_robot': member.robot, + 'gravatar': compute_hash(member.email) if not member.robot else None, 'invited': invited, } @@ -55,6 +57,7 @@ def invite_view(invite): return { 'email': invite.email, 'kind': 'invite', + 'gravatar': compute_hash(invite.email), 'invited': True } @@ -162,14 +165,15 @@ class TeamMemberList(ApiResource): raise NotFound() members = model.get_organization_team_members(team.id) - data = { - 'members': {m.username : member_view(m) for m in members}, - 'can_edit': edit_permission.can() - } + invites = [] 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] + + data = { + 'members': [member_view(m) for m in members] + [invite_view(i) for i in invites], + 'can_edit': edit_permission.can() + } return data diff --git a/static/css/quay.css b/static/css/quay.css index 20a81200f..4743b6e50 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -4593,4 +4593,48 @@ i.quay-icon { .external-notification-view-element:hover .side-controls button { border: 1px solid #eee; -} \ No newline at end of file +} + +.member-listing { + width: 100%; +} + +.member-listing .section-header { + color: #ccc; + margin-top: 20px; + margin-bottom: 10px; +} + +.member-listing .gravatar { + vertical-align: middle; + margin-right: 10px; +} + +.member-listing .entity-reference { + margin-bottom: 10px; + display: inline-block; +} + +.organization-header .popover { + max-width: none !important; +} + +.organization-header .popover.bottom-right .arrow:after { + border-bottom-color: #f7f7f7; + top: 2px; +} + +.organization-header .popover-content { + font-size: 14px; + padding-top: 6px; +} + +.organization-header .popover-content input { + background: white; +} + +.organization-header .popover-content .help-text { + font-size: 13px; + color: #ccc; + margin-top: 10px; +} diff --git a/static/directives/entity-reference.html b/static/directives/entity-reference.html index d01b100ee..ea65db875 100644 --- a/static/directives/entity-reference.html +++ b/static/directives/entity-reference.html @@ -7,15 +7,19 @@
- + {{entity.name}} {{entity.name}} - - + + + + + + {{ getPrefix(entity.name) }}+{{ getShortenedName(entity.name) }} diff --git a/static/directives/entity-search.html b/static/directives/entity-search.html index fec00b393..63abb1528 100644 --- a/static/directives/entity-search.html +++ b/static/directives/entity-search.html @@ -5,7 +5,7 @@ ng-click="lazyLoad()"> -