Get team invite confirmation working and fully tested
This commit is contained in:
parent
eefb7e1ec9
commit
43b6695f9c
12 changed files with 458 additions and 43 deletions
|
@ -113,6 +113,7 @@ class TeamMemberInvite(BaseModel):
|
||||||
user = ForeignKeyField(User, index=True, null=True)
|
user = ForeignKeyField(User, index=True, null=True)
|
||||||
email = CharField(null=True)
|
email = CharField(null=True)
|
||||||
team = ForeignKeyField(Team, index=True)
|
team = ForeignKeyField(Team, index=True)
|
||||||
|
inviter = ForeignKeyField(User, related_name='inviter')
|
||||||
invite_token = CharField(default=uuid_generator)
|
invite_token = CharField(default=uuid_generator)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -71,6 +71,10 @@ class TooManyUsersException(DataModelException):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UserAlreadyInTeam(DataModelException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def is_create_user_allowed():
|
def is_create_user_allowed():
|
||||||
return get_active_user_count() < config.app_config['LICENSE_USER_LIMIT']
|
return get_active_user_count() < config.app_config['LICENSE_USER_LIMIT']
|
||||||
|
|
||||||
|
@ -294,7 +298,7 @@ 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):
|
def add_or_invite_to_team(inviter, team, user=None, email=None):
|
||||||
# If the user is a member of the organization, then we simply add the
|
# 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.
|
# 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.
|
# We return None if the user was directly added and the invite object if the user was invited.
|
||||||
|
@ -326,15 +330,16 @@ def add_or_invite_to_team(team, user=None, email=None, adder=None):
|
||||||
add_user_to_team(user, team)
|
add_user_to_team(user, team)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return TeamMemberInvite.create(user=user, email=email if not user else None, team=team)
|
return TeamMemberInvite.create(user=user, email=email if not user else None, team=team,
|
||||||
|
inviter=inviter)
|
||||||
|
|
||||||
|
|
||||||
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('User \'%s\' is already a member of team \'%s\'' %
|
raise UserAlreadyInTeam('User \'%s\' is already a member of team \'%s\'' %
|
||||||
(user.username, team.name))
|
(user.username, team.name))
|
||||||
|
|
||||||
|
|
||||||
def remove_user_from_team(org_name, team_name, username, removed_by_username):
|
def remove_user_from_team(org_name, team_name, username, removed_by_username):
|
||||||
|
@ -1766,6 +1771,32 @@ def delete_notifications_by_kind(target, kind_name):
|
||||||
Notification.delete().where(Notification.target == target,
|
Notification.delete().where(Notification.target == target,
|
||||||
Notification.kind == kind_ref).execute()
|
Notification.kind == kind_ref).execute()
|
||||||
|
|
||||||
|
def delete_matching_notifications(target, kind_name, **kwargs):
|
||||||
|
kind_ref = NotificationKind.get(name=kind_name)
|
||||||
|
|
||||||
|
# Load all notifications for the user with the given kind.
|
||||||
|
notifications = list(Notification.select().where(
|
||||||
|
Notification.target == target,
|
||||||
|
Notification.kind == kind_ref))
|
||||||
|
|
||||||
|
# For each, match the metadata to the specified values.
|
||||||
|
for notification in notifications:
|
||||||
|
matches = True
|
||||||
|
try:
|
||||||
|
metadata = json.loads(notification.metadata_json)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for (key, value) in kwargs.iteritems():
|
||||||
|
if not key in metadata or metadata[key] != value:
|
||||||
|
matches = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
continue
|
||||||
|
|
||||||
|
notification.delete_instance()
|
||||||
|
|
||||||
|
|
||||||
def get_active_users():
|
def get_active_users():
|
||||||
return User.select().where(User.organization == False, User.robot == False)
|
return User.select().where(User.organization == False, User.robot == False)
|
||||||
|
@ -1821,3 +1852,51 @@ def confirm_email_authorization_for_repo(code):
|
||||||
found.save()
|
found.save()
|
||||||
|
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_team_invites(user):
|
||||||
|
return TeamMemberInvite.select().where(TeamMemberInvite.user == user)
|
||||||
|
|
||||||
|
def lookup_team_invite(code, user):
|
||||||
|
# Lookup the invite code.
|
||||||
|
try:
|
||||||
|
found = TeamMemberInvite.get(TeamMemberInvite.invite_token == code)
|
||||||
|
except TeamMemberInvite.DoesNotExist:
|
||||||
|
raise DataModelException('Invalid confirmation code.')
|
||||||
|
|
||||||
|
# Verify the code applies to the current user.
|
||||||
|
if found.user:
|
||||||
|
if found.user != user:
|
||||||
|
raise DataModelException('Invalid confirmation code.')
|
||||||
|
else:
|
||||||
|
if found.email != user.email:
|
||||||
|
raise DataModelException('Invalid confirmation code.')
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def delete_team_invite(code, user):
|
||||||
|
found = lookup_team_invite(code, user)
|
||||||
|
|
||||||
|
team = found.team
|
||||||
|
inviter = found.inviter
|
||||||
|
|
||||||
|
found.delete_instance()
|
||||||
|
|
||||||
|
return (team, inviter)
|
||||||
|
|
||||||
|
def confirm_team_invite(code, user):
|
||||||
|
found = lookup_team_invite(code, user)
|
||||||
|
|
||||||
|
# Add the user to the team.
|
||||||
|
try:
|
||||||
|
add_user_to_team(user, found.team)
|
||||||
|
except UserAlreadyInTeam:
|
||||||
|
# Ignore.
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Delete the invite and return the team.
|
||||||
|
team = found.team
|
||||||
|
inviter = found.inviter
|
||||||
|
found.delete_instance()
|
||||||
|
return (team, inviter)
|
||||||
|
|
|
@ -2,7 +2,7 @@ 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)
|
query_param, truthy_bool, parse_args, require_user_admin)
|
||||||
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
|
||||||
|
@ -10,8 +10,8 @@ from data import model
|
||||||
from util.useremails import send_org_invite_email
|
from util.useremails import send_org_invite_email
|
||||||
from util.gravatar import compute_hash
|
from util.gravatar import compute_hash
|
||||||
|
|
||||||
def add_or_invite_to_team(team, user=None, email=None, adder=None):
|
def add_or_invite_to_team(inviter, team, user=None, email=None):
|
||||||
invite = model.add_or_invite_to_team(team, user, email, adder)
|
invite = model.add_or_invite_to_team(inviter, team, user, email)
|
||||||
if not invite:
|
if not invite:
|
||||||
# User was added to the team directly.
|
# User was added to the team directly.
|
||||||
return
|
return
|
||||||
|
@ -20,13 +20,13 @@ def add_or_invite_to_team(team, user=None, email=None, adder=None):
|
||||||
if user:
|
if user:
|
||||||
model.create_notification('org_team_invite', user, metadata = {
|
model.create_notification('org_team_invite', user, metadata = {
|
||||||
'code': invite.invite_token,
|
'code': invite.invite_token,
|
||||||
'adder': adder,
|
'inviter': inviter.username,
|
||||||
'org': orgname,
|
'org': orgname,
|
||||||
'team': team.name
|
'team': team.name
|
||||||
})
|
})
|
||||||
|
|
||||||
send_org_invite_email(user.username if user else email, user.email if user else email,
|
send_org_invite_email(user.username if user else email, user.email if user else email,
|
||||||
orgname, team.name, adder, invite.invite_token)
|
orgname, team.name, inviter.username, invite.invite_token)
|
||||||
return invite
|
return invite
|
||||||
|
|
||||||
def team_view(orgname, team):
|
def team_view(orgname, team):
|
||||||
|
@ -204,11 +204,8 @@ class TeamMember(ApiResource):
|
||||||
raise request_error(message='Unknown user')
|
raise request_error(message='Unknown user')
|
||||||
|
|
||||||
# Add or invite the user to the team.
|
# Add or invite the user to the team.
|
||||||
adder = None
|
inviter = get_authenticated_user()
|
||||||
if get_authenticated_user():
|
invite = add_or_invite_to_team(inviter, team, user=user)
|
||||||
adder = get_authenticated_user().username
|
|
||||||
|
|
||||||
invite = add_or_invite_to_team(team, user=user, adder=adder)
|
|
||||||
if not invite:
|
if not invite:
|
||||||
log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname})
|
log_action('org_add_team_member', orgname, {'member': membername, 'team': teamname})
|
||||||
return member_view(user, invited=False)
|
return member_view(user, invited=False)
|
||||||
|
@ -232,3 +229,52 @@ class TeamMember(ApiResource):
|
||||||
return 'Deleted', 204
|
return 'Deleted', 204
|
||||||
|
|
||||||
raise Unauthorized()
|
raise Unauthorized()
|
||||||
|
|
||||||
|
|
||||||
|
@resource('/v1/teaminvite/<code>')
|
||||||
|
@internal_only
|
||||||
|
class TeamMemberInvite(ApiResource):
|
||||||
|
""" Resource for managing invites to jon a team. """
|
||||||
|
@require_user_admin
|
||||||
|
@nickname('acceptOrganizationTeamInvite')
|
||||||
|
def put(self, code):
|
||||||
|
""" Accepts an invite to join a team in an organization. """
|
||||||
|
# Accept the invite for the current user.
|
||||||
|
try:
|
||||||
|
(team, inviter) = model.confirm_team_invite(code, get_authenticated_user())
|
||||||
|
except model.DataModelException:
|
||||||
|
raise NotFound()
|
||||||
|
|
||||||
|
model.delete_matching_notifications(get_authenticated_user(), 'org_team_invite', code=code)
|
||||||
|
|
||||||
|
orgname = team.organization.username
|
||||||
|
log_action('org_team_member_invite_accepted', orgname, {
|
||||||
|
'member': get_authenticated_user().username,
|
||||||
|
'team': team.name,
|
||||||
|
'inviter': inviter.username
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'org': orgname,
|
||||||
|
'team': team.name
|
||||||
|
}
|
||||||
|
|
||||||
|
@nickname('declineOrganizationTeamInvite')
|
||||||
|
@require_user_admin
|
||||||
|
def delete(self, code):
|
||||||
|
""" Delete an existing member of a team. """
|
||||||
|
try:
|
||||||
|
(team, inviter) = model.delete_team_invite(code, get_authenticated_user())
|
||||||
|
except model.DataModelException:
|
||||||
|
raise NotFound()
|
||||||
|
|
||||||
|
model.delete_matching_notifications(get_authenticated_user(), 'org_team_invite', code=code)
|
||||||
|
|
||||||
|
orgname = team.organization.username
|
||||||
|
log_action('org_team_member_invite_declined', orgname, {
|
||||||
|
'member': get_authenticated_user().username,
|
||||||
|
'team': team.name,
|
||||||
|
'inviter': inviter.username
|
||||||
|
})
|
||||||
|
|
||||||
|
return 'Deleted', 204
|
||||||
|
|
|
@ -32,8 +32,8 @@ STATUS_TAGS = app.config['STATUS_TAGS']
|
||||||
@web.route('/', methods=['GET'], defaults={'path': ''})
|
@web.route('/', methods=['GET'], defaults={'path': ''})
|
||||||
@web.route('/organization/<path:path>', methods=['GET'])
|
@web.route('/organization/<path:path>', methods=['GET'])
|
||||||
@no_cache
|
@no_cache
|
||||||
def index(path):
|
def index(path, **kwargs):
|
||||||
return render_page_template('index.html')
|
return render_page_template('index.html', **kwargs)
|
||||||
|
|
||||||
|
|
||||||
@web.route('/500', methods=['GET'])
|
@web.route('/500', methods=['GET'])
|
||||||
|
@ -101,7 +101,7 @@ def superuser():
|
||||||
|
|
||||||
@web.route('/signin/')
|
@web.route('/signin/')
|
||||||
@no_cache
|
@no_cache
|
||||||
def signin():
|
def signin(redirect=None):
|
||||||
return index('')
|
return index('')
|
||||||
|
|
||||||
|
|
||||||
|
@ -123,6 +123,13 @@ def new():
|
||||||
return index('')
|
return index('')
|
||||||
|
|
||||||
|
|
||||||
|
@web.route('/confirminvite')
|
||||||
|
@no_cache
|
||||||
|
def confirm_invite():
|
||||||
|
code = request.values['code']
|
||||||
|
return index('', code=code)
|
||||||
|
|
||||||
|
|
||||||
@web.route('/repository/', defaults={'path': ''})
|
@web.route('/repository/', defaults={'path': ''})
|
||||||
@web.route('/repository/<path:path>', methods=['GET'])
|
@web.route('/repository/<path:path>', methods=['GET'])
|
||||||
@no_cache
|
@no_cache
|
||||||
|
|
|
@ -214,6 +214,8 @@ def initialize_database():
|
||||||
LogEntryKind.create(name='org_delete_team')
|
LogEntryKind.create(name='org_delete_team')
|
||||||
LogEntryKind.create(name='org_invite_team_member')
|
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_team_member_invite_accepted')
|
||||||
|
LogEntryKind.create(name='org_team_member_invite_declined')
|
||||||
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')
|
||||||
LogEntryKind.create(name='org_set_team_role')
|
LogEntryKind.create(name='org_set_team_role')
|
||||||
|
|
|
@ -535,7 +535,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
|
||||||
|
|
||||||
stringBuilderService.buildString = function(value_or_func, metadata) {
|
stringBuilderService.buildString = function(value_or_func, metadata) {
|
||||||
var fieldIcons = {
|
var fieldIcons = {
|
||||||
'adder': 'user',
|
'inviter': 'user',
|
||||||
'username': 'user',
|
'username': 'user',
|
||||||
'activating_username': 'user',
|
'activating_username': 'user',
|
||||||
'delegate_user': 'user',
|
'delegate_user': 'user',
|
||||||
|
@ -1115,8 +1115,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
|
||||||
return externalNotificationData;
|
return externalNotificationData;
|
||||||
}]);
|
}]);
|
||||||
|
|
||||||
$provide.factory('NotificationService', ['$rootScope', '$interval', 'UserService', 'ApiService', 'StringBuilderService', 'PlanService', 'UserService', 'Config',
|
$provide.factory('NotificationService', ['$rootScope', '$interval', 'UserService', 'ApiService', 'StringBuilderService', 'PlanService', 'UserService', 'Config', '$location',
|
||||||
function($rootScope, $interval, UserService, ApiService, StringBuilderService, PlanService, UserService, Config) {
|
function($rootScope, $interval, UserService, ApiService, StringBuilderService, PlanService, UserService, Config, $location) {
|
||||||
var notificationService = {
|
var notificationService = {
|
||||||
'user': null,
|
'user': null,
|
||||||
'notifications': [],
|
'notifications': [],
|
||||||
|
@ -1135,15 +1135,24 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
|
||||||
},
|
},
|
||||||
'org_team_invite': {
|
'org_team_invite': {
|
||||||
'level': 'primary',
|
'level': 'primary',
|
||||||
'message': '{adder} is inviting you to join team {team} under organization {org}',
|
'message': '{inviter} is inviting you to join team {team} under organization {org}',
|
||||||
'actions': [
|
'actions': [
|
||||||
{
|
{
|
||||||
'title': 'Join team',
|
'title': 'Join team',
|
||||||
'kind': 'primary',
|
'kind': 'primary',
|
||||||
'handler': function(notification) {
|
'handler': function(notification) {
|
||||||
|
window.location = '/confirminvite?code=' + notification.metadata['code'];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{'title': 'Decline', 'kind': 'default'}
|
{
|
||||||
|
'title': 'Decline',
|
||||||
|
'kind': 'default',
|
||||||
|
'handler': function(notification) {
|
||||||
|
ApiService.declineOrganizationTeamInvite(null, {'code': notification.metadata['code']}).then(function() {
|
||||||
|
notificationService.update();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
'password_required': {
|
'password_required': {
|
||||||
|
@ -1725,7 +1734,7 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
|
||||||
templateUrl: '/static/partials/plans.html', controller: PlansCtrl}).
|
templateUrl: '/static/partials/plans.html', controller: PlansCtrl}).
|
||||||
when('/security/', {title: 'Security', description: 'Security features used when transmitting and storing data',
|
when('/security/', {title: 'Security', description: 'Security features used when transmitting and storing data',
|
||||||
templateUrl: '/static/partials/security.html'}).
|
templateUrl: '/static/partials/security.html'}).
|
||||||
when('/signin/', {title: 'Sign In', description: 'Sign into ' + title, templateUrl: '/static/partials/signin.html'}).
|
when('/signin/', {title: 'Sign In', description: 'Sign into ' + title, templateUrl: '/static/partials/signin.html', controller: SignInCtrl, reloadOnSearch: false}).
|
||||||
when('/new/', {title: 'Create new repository', description: 'Create a new public or private docker repository, optionally constructing from a dockerfile',
|
when('/new/', {title: 'Create new repository', description: 'Create a new public or private docker repository, optionally constructing from a dockerfile',
|
||||||
templateUrl: '/static/partials/new-repo.html', controller: NewRepoCtrl}).
|
templateUrl: '/static/partials/new-repo.html', controller: NewRepoCtrl}).
|
||||||
when('/organizations/', {title: 'Organizations', description: 'Private docker repository hosting for businesses and organizations',
|
when('/organizations/', {title: 'Organizations', description: 'Private docker repository hosting for businesses and organizations',
|
||||||
|
@ -1746,6 +1755,8 @@ quayApp = angular.module('quay', quayDependencies, function($provide, cfpLoading
|
||||||
when('/tour/features', {title: title + ' Features', templateUrl: '/static/partials/tour.html', controller: TourCtrl}).
|
when('/tour/features', {title: title + ' Features', templateUrl: '/static/partials/tour.html', controller: TourCtrl}).
|
||||||
when('/tour/enterprise', {title: 'Enterprise Edition', templateUrl: '/static/partials/tour.html', controller: TourCtrl}).
|
when('/tour/enterprise', {title: 'Enterprise Edition', templateUrl: '/static/partials/tour.html', controller: TourCtrl}).
|
||||||
|
|
||||||
|
when('/confirminvite', {title: 'Confirm Team Invite', templateUrl: '/static/partials/confirm-team-invite.html', controller: ConfirmInviteCtrl, reloadOnSearch: false}).
|
||||||
|
|
||||||
when('/', {title: 'Hosted Private Docker Registry', templateUrl: '/static/partials/landing.html', controller: LandingCtrl,
|
when('/', {title: 'Hosted Private Docker Registry', templateUrl: '/static/partials/landing.html', controller: LandingCtrl,
|
||||||
pageClass: 'landing-page'}).
|
pageClass: 'landing-page'}).
|
||||||
otherwise({redirectTo: '/'});
|
otherwise({redirectTo: '/'});
|
||||||
|
@ -2244,6 +2255,10 @@ quayApp.directive('signinForm', function () {
|
||||||
'signedIn': '&signedIn'
|
'signedIn': '&signedIn'
|
||||||
},
|
},
|
||||||
controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, CookieService, Features, Config) {
|
controller: function($scope, $location, $timeout, ApiService, KeyService, UserService, CookieService, Features, Config) {
|
||||||
|
var getRedirectUrl = function() {
|
||||||
|
return $scope.redirectUrl;
|
||||||
|
};
|
||||||
|
|
||||||
$scope.showGithub = function() {
|
$scope.showGithub = function() {
|
||||||
if (!Features.GITHUB_LOGIN) { return; }
|
if (!Features.GITHUB_LOGIN) { return; }
|
||||||
|
|
||||||
|
@ -2255,7 +2270,7 @@ quayApp.directive('signinForm', function () {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the redirect URL in a cookie so that we can redirect back after GitHub returns to us.
|
// Save the redirect URL in a cookie so that we can redirect back after GitHub returns to us.
|
||||||
var redirectURL = $scope.redirectUrl || window.location.toString();
|
var redirectURL = getRedirectUrl() || window.location.toString();
|
||||||
CookieService.putPermanent('quay.redirectAfterLoad', redirectURL);
|
CookieService.putPermanent('quay.redirectAfterLoad', redirectURL);
|
||||||
|
|
||||||
// Needed to ensure that UI work done by the started callback is finished before the location
|
// Needed to ensure that UI work done by the started callback is finished before the location
|
||||||
|
@ -2283,17 +2298,19 @@ quayApp.directive('signinForm', function () {
|
||||||
if ($scope.signedIn != null) {
|
if ($scope.signedIn != null) {
|
||||||
$scope.signedIn();
|
$scope.signedIn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the newly created user.
|
||||||
UserService.load();
|
UserService.load();
|
||||||
|
|
||||||
// Redirect to the specified page or the landing page
|
// Redirect to the specified page or the landing page
|
||||||
// Note: The timeout of 500ms is needed to ensure dialogs containing sign in
|
// Note: The timeout of 500ms is needed to ensure dialogs containing sign in
|
||||||
// forms get removed before the location changes.
|
// forms get removed before the location changes.
|
||||||
$timeout(function() {
|
$timeout(function() {
|
||||||
if ($scope.redirectUrl == $location.path()) {
|
var redirectUrl = getRedirectUrl();
|
||||||
return;
|
if (redirectUrl == $location.path()) {
|
||||||
}
|
return;
|
||||||
$location.path($scope.redirectUrl ? $scope.redirectUrl : '/');
|
}
|
||||||
|
window.location = (redirectUrl ? redirectUrl : '/');
|
||||||
}, 500);
|
}, 500);
|
||||||
}, function(result) {
|
}, function(result) {
|
||||||
$scope.needsEmailVerification = result.data.needsEmailVerification;
|
$scope.needsEmailVerification = result.data.needsEmailVerification;
|
||||||
|
@ -2629,8 +2646,12 @@ quayApp.directive('logsView', function () {
|
||||||
'org_create_team': 'Create team: {team}',
|
'org_create_team': 'Create team: {team}',
|
||||||
'org_delete_team': 'Delete team: {team}',
|
'org_delete_team': 'Delete team: {team}',
|
||||||
'org_add_team_member': 'Add member {member} to team {team}',
|
'org_add_team_member': 'Add member {member} to team {team}',
|
||||||
'org_invite_team_member': 'Invite user {member} to team {team}',
|
|
||||||
'org_remove_team_member': 'Remove member {member} from team {team}',
|
'org_remove_team_member': 'Remove member {member} from team {team}',
|
||||||
|
'org_invite_team_member': 'Invite user {member} to team {team}',
|
||||||
|
|
||||||
|
'org_team_member_invite_accepted': 'User {member}, invited by {inviter}, accepted to join team {team}',
|
||||||
|
'org_team_member_invite_declined': 'User {member}, invited by {inviter}, declined to join team {team}',
|
||||||
|
|
||||||
'org_set_team_description': 'Change description of team {team}: {description}',
|
'org_set_team_description': 'Change description of team {team}: {description}',
|
||||||
'org_set_team_role': 'Change permission of team {team} to {role}',
|
'org_set_team_role': 'Change permission of team {team} to {role}',
|
||||||
'create_prototype_permission': function(metadata) {
|
'create_prototype_permission': function(metadata) {
|
||||||
|
@ -2711,6 +2732,8 @@ quayApp.directive('logsView', function () {
|
||||||
'org_add_team_member': 'Add team member',
|
'org_add_team_member': 'Add team member',
|
||||||
'org_invite_team_member': 'Invite team member',
|
'org_invite_team_member': 'Invite team member',
|
||||||
'org_remove_team_member': 'Remove team member',
|
'org_remove_team_member': 'Remove team member',
|
||||||
|
'org_team_member_invite_accepted': 'Team invite accepted',
|
||||||
|
'org_team_member_invite_declined': 'Team invite declined',
|
||||||
'org_set_team_description': 'Change team description',
|
'org_set_team_description': 'Change team description',
|
||||||
'org_set_team_role': 'Change team permission',
|
'org_set_team_role': 'Change team permission',
|
||||||
'create_prototype_permission': 'Create default permission',
|
'create_prototype_permission': 'Create default permission',
|
||||||
|
@ -5346,7 +5369,7 @@ quayApp.directive('dockerfileBuildForm', function () {
|
||||||
var data = {
|
var data = {
|
||||||
'mimeType': mimeType
|
'mimeType': mimeType
|
||||||
};
|
};
|
||||||
|
|
||||||
var getUploadUrl = ApiService.getFiledropUrl(data).then(function(resp) {
|
var getUploadUrl = ApiService.getFiledropUrl(data).then(function(resp) {
|
||||||
conductUpload(file, resp.url, resp.file_id, mimeType);
|
conductUpload(file, resp.url, resp.file_id, mimeType);
|
||||||
}, function() {
|
}, function() {
|
||||||
|
|
|
@ -20,6 +20,17 @@ $.fn.clipboardCopy = function() {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function SignInCtrl($scope, $location) {
|
||||||
|
var redirect = $location.search()['redirect'];
|
||||||
|
if (redirect && redirect.indexOf('/') < 0) {
|
||||||
|
delete $location.search()['redirect'];
|
||||||
|
$scope.redirectUrl = '/' + redirect;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$scope.redirectUrl = '/';
|
||||||
|
}
|
||||||
|
|
||||||
function GuideCtrl() {
|
function GuideCtrl() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2855,3 +2866,28 @@ function SuperUserAdminCtrl($scope, ApiService, Features, UserService) {
|
||||||
function TourCtrl($scope, $location) {
|
function TourCtrl($scope, $location) {
|
||||||
$scope.kind = $location.path().substring('/tour/'.length);
|
$scope.kind = $location.path().substring('/tour/'.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ConfirmInviteCtrl($scope, $location, UserService, ApiService, NotificationService) {
|
||||||
|
// Monitor any user changes and place the current user into the scope.
|
||||||
|
$scope.loading = false;
|
||||||
|
|
||||||
|
UserService.updateUserIn($scope, function(user) {
|
||||||
|
if (!user.anonymous && !$scope.loading) {
|
||||||
|
$scope.loading = true;
|
||||||
|
|
||||||
|
var params = {
|
||||||
|
'code': $location.search()['code']
|
||||||
|
};
|
||||||
|
|
||||||
|
ApiService.acceptOrganizationTeamInvite(null, params).then(function(resp) {
|
||||||
|
NotificationService.update();
|
||||||
|
$location.path('/organization/' + resp.org + '/teams/' + resp.team);
|
||||||
|
}, function() {
|
||||||
|
$scope.loading = false;
|
||||||
|
$scope.invalid = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$scope.redirectUrl = 'confirminvite?code=' + $location.search()['code'];
|
||||||
|
}
|
13
static/partials/confirm-team-invite.html
Normal file
13
static/partials/confirm-team-invite.html
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<div class="confirm-team-invite">
|
||||||
|
<div class="container signin-container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-6 col-sm-offset-3">
|
||||||
|
<div class="user-setup" ng-show="user.anonymous" redirect-url="redirectUrl"></div>
|
||||||
|
<div class="quay-spinner" ng-show="!user.anonymous && loading"></div>
|
||||||
|
<div class="alert alert-danger" ng-show="!user.anonymous && invalid">
|
||||||
|
Invalid confirmation code
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -1,7 +1,7 @@
|
||||||
<div class="container signin-container">
|
<div class="container signin-container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-6 col-sm-offset-3">
|
<div class="col-sm-6 col-sm-offset-3">
|
||||||
<div class="user-setup" redirect-url="'/'"></div>
|
<div class="user-setup" redirect-url="redirectUrl"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -8,7 +8,7 @@ from app import app
|
||||||
from initdb import setup_database_for_testing, finished_database_for_testing
|
from initdb import setup_database_for_testing, finished_database_for_testing
|
||||||
from endpoints.api import api_bp, api
|
from endpoints.api import api_bp, api
|
||||||
|
|
||||||
from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam
|
from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam, TeamMemberInvite
|
||||||
from endpoints.api.tag import RepositoryTagImages, RepositoryTag
|
from endpoints.api.tag import RepositoryTagImages, RepositoryTag
|
||||||
from endpoints.api.search import FindRepositories, EntitySearch
|
from endpoints.api.search import FindRepositories, EntitySearch
|
||||||
from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList
|
from endpoints.api.image import RepositoryImageChanges, RepositoryImage, RepositoryImageList
|
||||||
|
@ -3424,6 +3424,36 @@ class TestSuperUserLogs(ApiTestCase):
|
||||||
self._run_test('GET', 200, 'devtable', None)
|
self._run_test('GET', 200, 'devtable', None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTeamMemberInvite(ApiTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
ApiTestCase.setUp(self)
|
||||||
|
self._set_url(TeamMemberInvite, code='foobarbaz')
|
||||||
|
|
||||||
|
def test_put_anonymous(self):
|
||||||
|
self._run_test('PUT', 401, None, None)
|
||||||
|
|
||||||
|
def test_put_freshuser(self):
|
||||||
|
self._run_test('PUT', 404, 'freshuser', None)
|
||||||
|
|
||||||
|
def test_put_reader(self):
|
||||||
|
self._run_test('PUT', 404, 'reader', None)
|
||||||
|
|
||||||
|
def test_put_devtable(self):
|
||||||
|
self._run_test('PUT', 404, 'devtable', None)
|
||||||
|
|
||||||
|
def test_delete_anonymous(self):
|
||||||
|
self._run_test('DELETE', 401, None, None)
|
||||||
|
|
||||||
|
def test_delete_freshuser(self):
|
||||||
|
self._run_test('DELETE', 404, 'freshuser', None)
|
||||||
|
|
||||||
|
def test_delete_reader(self):
|
||||||
|
self._run_test('DELETE', 404, 'reader', None)
|
||||||
|
|
||||||
|
def test_delete_devtable(self):
|
||||||
|
self._run_test('DELETE', 404, 'devtable', None)
|
||||||
|
|
||||||
|
|
||||||
class TestSuperUserList(ApiTestCase):
|
class TestSuperUserList(ApiTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
ApiTestCase.setUp(self)
|
ApiTestCase.setUp(self)
|
||||||
|
@ -3442,7 +3472,6 @@ class TestSuperUserList(ApiTestCase):
|
||||||
self._run_test('GET', 200, 'devtable', None)
|
self._run_test('GET', 200, 'devtable', None)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TestSuperUserManagement(ApiTestCase):
|
class TestSuperUserManagement(ApiTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
ApiTestCase.setUp(self)
|
ApiTestCase.setUp(self)
|
||||||
|
|
|
@ -11,7 +11,7 @@ from app import app
|
||||||
from initdb import setup_database_for_testing, finished_database_for_testing
|
from initdb import setup_database_for_testing, finished_database_for_testing
|
||||||
from data import model, database
|
from data import model, database
|
||||||
|
|
||||||
from endpoints.api.team import TeamMember, TeamMemberList, OrganizationTeam
|
from endpoints.api.team import TeamMember, TeamMemberList, TeamMemberInvite, OrganizationTeam
|
||||||
from endpoints.api.tag import RepositoryTagImages, RepositoryTag
|
from endpoints.api.tag import RepositoryTagImages, RepositoryTag
|
||||||
from endpoints.api.search import FindRepositories, EntitySearch
|
from endpoints.api.search import FindRepositories, EntitySearch
|
||||||
from endpoints.api.image import RepositoryImage, RepositoryImageList
|
from endpoints.api.image import RepositoryImage, RepositoryImageList
|
||||||
|
@ -734,16 +734,50 @@ class TestGetOrganizationTeamMembers(ApiTestCase):
|
||||||
params=dict(orgname=ORGANIZATION,
|
params=dict(orgname=ORGANIZATION,
|
||||||
teamname='readers'))
|
teamname='readers'))
|
||||||
|
|
||||||
assert READ_ACCESS_USER in json['members']
|
self.assertEquals(READ_ACCESS_USER, json['members'][1]['name'])
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateOrganizationTeamMember(ApiTestCase):
|
class TestUpdateOrganizationTeamMember(ApiTestCase):
|
||||||
def test_addmember(self):
|
def assertInTeam(self, data, membername):
|
||||||
|
for memberData in data['members']:
|
||||||
|
if memberData['name'] == membername:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.fail(membername + ' not found in team: ' + json.dumps(data))
|
||||||
|
|
||||||
|
def test_addmember_alreadyteammember(self):
|
||||||
self.login(ADMIN_ACCESS_USER)
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
membername = READ_ACCESS_USER
|
||||||
|
self.putResponse(TeamMember,
|
||||||
|
params=dict(orgname=ORGANIZATION, teamname='readers',
|
||||||
|
membername=membername),
|
||||||
|
expected_code=400)
|
||||||
|
|
||||||
|
|
||||||
|
def test_addmember_orgmember(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
membername = READ_ACCESS_USER
|
||||||
|
self.putJsonResponse(TeamMember,
|
||||||
|
params=dict(orgname=ORGANIZATION, teamname='owners',
|
||||||
|
membername=membername))
|
||||||
|
|
||||||
|
# Verify the user was added to the team.
|
||||||
|
json = self.getJsonResponse(TeamMemberList,
|
||||||
|
params=dict(orgname=ORGANIZATION,
|
||||||
|
teamname='owners'))
|
||||||
|
|
||||||
|
self.assertInTeam(json, membername)
|
||||||
|
|
||||||
|
|
||||||
|
def test_addmember_robot(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
membername = ORGANIZATION + '+coolrobot'
|
||||||
self.putJsonResponse(TeamMember,
|
self.putJsonResponse(TeamMember,
|
||||||
params=dict(orgname=ORGANIZATION, teamname='readers',
|
params=dict(orgname=ORGANIZATION, teamname='readers',
|
||||||
membername=NO_ACCESS_USER))
|
membername=membername))
|
||||||
|
|
||||||
|
|
||||||
# Verify the user was added to the team.
|
# Verify the user was added to the team.
|
||||||
|
@ -751,7 +785,152 @@ class TestUpdateOrganizationTeamMember(ApiTestCase):
|
||||||
params=dict(orgname=ORGANIZATION,
|
params=dict(orgname=ORGANIZATION,
|
||||||
teamname='readers'))
|
teamname='readers'))
|
||||||
|
|
||||||
assert NO_ACCESS_USER in json['members']
|
self.assertInTeam(json, membername)
|
||||||
|
|
||||||
|
|
||||||
|
def test_addmember_invalidrobot(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
membername = 'freshuser+anotherrobot'
|
||||||
|
self.putResponse(TeamMember,
|
||||||
|
params=dict(orgname=ORGANIZATION, teamname='readers',
|
||||||
|
membername=membername),
|
||||||
|
expected_code=400)
|
||||||
|
|
||||||
|
|
||||||
|
def test_addmember_nonorgmember(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
membername = NO_ACCESS_USER
|
||||||
|
response = self.putJsonResponse(TeamMember,
|
||||||
|
params=dict(orgname=ORGANIZATION, teamname='owners',
|
||||||
|
membername=membername))
|
||||||
|
|
||||||
|
|
||||||
|
self.assertEquals(True, response['invited'])
|
||||||
|
|
||||||
|
# Make sure the user is not (yet) part of the team.
|
||||||
|
json = self.getJsonResponse(TeamMemberList,
|
||||||
|
params=dict(orgname=ORGANIZATION,
|
||||||
|
teamname='readers'))
|
||||||
|
|
||||||
|
for member in json['members']:
|
||||||
|
self.assertNotEqual(membername, member['name'])
|
||||||
|
|
||||||
|
|
||||||
|
class TestAcceptTeamMemberInvite(ApiTestCase):
|
||||||
|
def assertInTeam(self, data, membername):
|
||||||
|
for memberData in data['members']:
|
||||||
|
if memberData['name'] == membername:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.fail(membername + ' not found in team: ' + json.dumps(data))
|
||||||
|
|
||||||
|
def test_accept_wronguser(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
# Create the invite.
|
||||||
|
membername = NO_ACCESS_USER
|
||||||
|
response = self.putJsonResponse(TeamMember,
|
||||||
|
params=dict(orgname=ORGANIZATION, teamname='owners',
|
||||||
|
membername=membername))
|
||||||
|
|
||||||
|
self.assertEquals(True, response['invited'])
|
||||||
|
|
||||||
|
# Try to accept the invite.
|
||||||
|
user = model.get_user(membername)
|
||||||
|
invites = list(model.lookup_team_invites(user))
|
||||||
|
self.assertEquals(1, len(invites))
|
||||||
|
|
||||||
|
self.putResponse(TeamMemberInvite,
|
||||||
|
params=dict(code=invites[0].invite_token),
|
||||||
|
expected_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
def test_accept(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
# Create the invite.
|
||||||
|
membername = NO_ACCESS_USER
|
||||||
|
response = self.putJsonResponse(TeamMember,
|
||||||
|
params=dict(orgname=ORGANIZATION, teamname='owners',
|
||||||
|
membername=membername))
|
||||||
|
|
||||||
|
self.assertEquals(True, response['invited'])
|
||||||
|
|
||||||
|
# Login as the user.
|
||||||
|
self.login(membername)
|
||||||
|
|
||||||
|
# Accept the invite.
|
||||||
|
user = model.get_user(membername)
|
||||||
|
invites = list(model.lookup_team_invites(user))
|
||||||
|
self.assertEquals(1, len(invites))
|
||||||
|
|
||||||
|
self.putJsonResponse(TeamMemberInvite,
|
||||||
|
params=dict(code=invites[0].invite_token))
|
||||||
|
|
||||||
|
# Verify the user is now on the team.
|
||||||
|
json = self.getJsonResponse(TeamMemberList,
|
||||||
|
params=dict(orgname=ORGANIZATION,
|
||||||
|
teamname='owners'))
|
||||||
|
|
||||||
|
self.assertInTeam(json, membername)
|
||||||
|
|
||||||
|
# Verify the accept now fails.
|
||||||
|
self.putResponse(TeamMemberInvite,
|
||||||
|
params=dict(code=invites[0].invite_token),
|
||||||
|
expected_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeclineTeamMemberInvite(ApiTestCase):
|
||||||
|
def test_decline_wronguser(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
# Create the invite.
|
||||||
|
membername = NO_ACCESS_USER
|
||||||
|
response = self.putJsonResponse(TeamMember,
|
||||||
|
params=dict(orgname=ORGANIZATION, teamname='owners',
|
||||||
|
membername=membername))
|
||||||
|
|
||||||
|
self.assertEquals(True, response['invited'])
|
||||||
|
|
||||||
|
# Try to decline the invite.
|
||||||
|
user = model.get_user(membername)
|
||||||
|
invites = list(model.lookup_team_invites(user))
|
||||||
|
self.assertEquals(1, len(invites))
|
||||||
|
|
||||||
|
self.deleteResponse(TeamMemberInvite,
|
||||||
|
params=dict(code=invites[0].invite_token),
|
||||||
|
expected_code=404)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decline(self):
|
||||||
|
self.login(ADMIN_ACCESS_USER)
|
||||||
|
|
||||||
|
# Create the invite.
|
||||||
|
membername = NO_ACCESS_USER
|
||||||
|
response = self.putJsonResponse(TeamMember,
|
||||||
|
params=dict(orgname=ORGANIZATION, teamname='owners',
|
||||||
|
membername=membername))
|
||||||
|
|
||||||
|
self.assertEquals(True, response['invited'])
|
||||||
|
|
||||||
|
# Login as the user.
|
||||||
|
self.login(membername)
|
||||||
|
|
||||||
|
# Decline the invite.
|
||||||
|
user = model.get_user(membername)
|
||||||
|
invites = list(model.lookup_team_invites(user))
|
||||||
|
self.assertEquals(1, len(invites))
|
||||||
|
|
||||||
|
self.deleteResponse(TeamMemberInvite,
|
||||||
|
params=dict(code=invites[0].invite_token))
|
||||||
|
|
||||||
|
# Make sure the invite was deleted.
|
||||||
|
self.deleteResponse(TeamMemberInvite,
|
||||||
|
params=dict(code=invites[0].invite_token),
|
||||||
|
expected_code=404)
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteOrganizationTeamMember(ApiTestCase):
|
class TestDeleteOrganizationTeamMember(ApiTestCase):
|
||||||
|
@ -768,7 +947,7 @@ class TestDeleteOrganizationTeamMember(ApiTestCase):
|
||||||
params=dict(orgname=ORGANIZATION,
|
params=dict(orgname=ORGANIZATION,
|
||||||
teamname='readers'))
|
teamname='readers'))
|
||||||
|
|
||||||
assert not READ_ACCESS_USER in json['members']
|
assert len(json['members']) == 1
|
||||||
|
|
||||||
|
|
||||||
class TestCreateRepo(ApiTestCase):
|
class TestCreateRepo(ApiTestCase):
|
||||||
|
@ -2064,7 +2243,7 @@ class TestSuperUserManagement(ApiTestCase):
|
||||||
|
|
||||||
json = self.getJsonResponse(SuperUserManagement, params=dict(username = 'freshuser'))
|
json = self.getJsonResponse(SuperUserManagement, params=dict(username = 'freshuser'))
|
||||||
self.assertEquals('freshuser', json['username'])
|
self.assertEquals('freshuser', json['username'])
|
||||||
self.assertEquals('no@thanks.com', json['email'])
|
self.assertEquals('jschorr+test@devtable.com', json['email'])
|
||||||
self.assertEquals(False, json['super_user'])
|
self.assertEquals(False, json['super_user'])
|
||||||
|
|
||||||
def test_delete_user(self):
|
def test_delete_user(self):
|
||||||
|
@ -2087,7 +2266,7 @@ class TestSuperUserManagement(ApiTestCase):
|
||||||
# Verify the user exists.
|
# Verify the user exists.
|
||||||
json = self.getJsonResponse(SuperUserManagement, params=dict(username = 'freshuser'))
|
json = self.getJsonResponse(SuperUserManagement, params=dict(username = 'freshuser'))
|
||||||
self.assertEquals('freshuser', json['username'])
|
self.assertEquals('freshuser', json['username'])
|
||||||
self.assertEquals('no@thanks.com', json['email'])
|
self.assertEquals('jschorr+test@devtable.com', json['email'])
|
||||||
|
|
||||||
# Update the user.
|
# Update the user.
|
||||||
self.putJsonResponse(SuperUserManagement, params=dict(username='freshuser'), data=dict(email='foo@bar.com'))
|
self.putJsonResponse(SuperUserManagement, params=dict(username='freshuser'), data=dict(email='foo@bar.com'))
|
||||||
|
|
|
@ -67,7 +67,7 @@ To confirm this email address, please click the following link:<br>
|
||||||
|
|
||||||
INVITE_TO_ORG_TEAM_MESSAGE = """
|
INVITE_TO_ORG_TEAM_MESSAGE = """
|
||||||
Hi {0},<br>
|
Hi {0},<br>
|
||||||
{1} has invited you to join the team {2} under organization {3} on <a href="{4}">{5}</a>.
|
{1} has invited you to join the team <b>{2}</b> under organization <b>{3}</b> on <a href="{4}">{5}</a>.
|
||||||
<br><br>
|
<br><br>
|
||||||
To join the team, please click the following link:<br>
|
To join the team, please click the following link:<br>
|
||||||
<a href="{4}/confirminvite?code={6}">{4}/confirminvite?code={6}</a>
|
<a href="{4}/confirminvite?code={6}">{4}/confirminvite?code={6}</a>
|
||||||
|
|
Reference in a new issue