Add UI for viewing and cancelling open Zulip invitations.

Lets administrators view a list of open(unconfirmed) invitations and
resend or revoke a chosen invitation.

There are a few changes that we can expect for the future:

  * It is currently possible to invite an email that you have already
    invited, it might make sense to change this behavior.

  * Resend currently sends an invite reminder instead of resending the
    original invite, this is because 'custom_body' was not stored when
    the first invite was sent.

Tweaked in various minor ways, primarily in the backend, by tabbott,
mostly for style consistency with the rest of the codebase.

Fixes: #1180.
This commit is contained in:
Henrik Pettersson 2017-10-21 03:15:12 +02:00 committed by Tim Abbott
parent b831df8f7f
commit 09cd47c6ad
17 changed files with 475 additions and 7 deletions

View File

@ -50,6 +50,7 @@
"settings_users": false,
"settings_streams": false,
"settings_filters": false,
"settings_invites": false,
"settings": false,
"resize": false,
"loading": false,

View File

@ -238,6 +238,39 @@ function render(template_name, args) {
assert.equal(filter_format.text(), 'https://trac.example.com/ticket/%(id)s');
}());
(function admin_invites_list() {
var html = '<table>';
var invites = ['alice', 'bob', 'carl'];
var invite_id = 0;
_.each(invites, function (invite) {
var args = {
invite: {
email: invite + '@zulip.com',
ref: 'iago@zulip.com',
invited: "2017-01-01 01:01:01",
id: invite_id,
},
};
html += render('admin_invites_list', args);
invite_id += 1;
});
html += "</table>";
var buttons = $(html).find('.button');
assert.equal($(buttons[0]).text().trim(), "Revoke");
assert($(buttons[0]).hasClass("revoke"));
assert.equal($(buttons[0]).attr("data-invite-id"), 0);
assert.equal($(buttons[3]).text().trim(), "Resend");
assert($(buttons[3]).hasClass("resend"));
assert.equal($(buttons[3]).attr("data-invite-id"), 1);
var span = $(html).find(".email:first");
assert.equal(span.text(), "alice@zulip.com");
global.write_handlebars_output("admin_invites_list", html);
}());
(function admin_streams_list() {
var html = '<table>';
var streams = ['devel', 'trac', 'zulip'];
@ -257,7 +290,8 @@ function render(template_name, args) {
};
var html = render('admin_tab', args);
var admin_features = ["admin_users_table", "admin_bots_table",
"admin_streams_table", "admin_deactivated_users_table"];
"admin_streams_table", "admin_deactivated_users_table",
"admin_invites_table"];
_.each(admin_features, function (admin_feature) {
assert.notEqual($(html).find("#" + admin_feature).length, 0);
});

View File

@ -29,6 +29,9 @@ exports.load_admin_section = function (name) {
case 'filter-settings':
section = 'filters';
break;
case 'invites-list-admin':
section = 'invites';
break;
default:
blueslip.error('Unknown admin id ' + name);
return;
@ -56,6 +59,9 @@ exports.load_admin_section = function (name) {
case 'filters':
settings_filters.set_up();
break;
case 'invites':
settings_invites.set_up();
break;
default:
blueslip.error('programming error for section ' + section);
return;
@ -71,6 +77,7 @@ exports.reset_sections = function () {
settings_users.reset();
settings_streams.reset();
settings_filters.reset();
settings_invites.reset();
};
return exports;

View File

@ -83,6 +83,7 @@ function _setup_page() {
"streams-list-admin": i18n.t("Streams"),
"default-streams-list": i18n.t("Default streams"),
"filter-settings": i18n.t("Filter settings"),
"invites-list-admin": i18n.t("Invitations"),
};
}

View File

@ -0,0 +1,153 @@
var settings_invites = (function () {
var exports = {};
var meta = {
loaded: false,
};
exports.reset = function () {
meta.loaded = false;
};
function failed_listing_invites(xhr) {
loading.destroy_indicator($('#admin_page_invites_loading_indicator'));
ui_report.error(i18n.t("Error listing invites"), xhr, $("#organization-status"));
}
function populate_invites(invites_data) {
if (!meta.loaded) {
return;
}
var invites_table = $("#admin_invites_table").expectOne();
list_render(invites_table, invites_data.invites, {
name: "admin_invites_list",
modifier: function (item) {
return templates.render("admin_invites_list", { invite: item });
},
filter: {
element: invites_table.closest(".settings-section").find(".search"),
callback: function (item, value) {
return item.email.toLowerCase().indexOf(value) >= 0;
},
},
}).init();
loading.destroy_indicator($('#admin_page_invites_loading_indicator'));
}
exports.set_up = function () {
meta.loaded = true;
// create loading indicators
loading.make_indicator($('#admin_page_invites_loading_indicator'));
// Populate invites table
channel.get({
url: '/json/invites',
idempotent: true,
timeout: 10*1000,
success: exports.on_load_success,
error: failed_listing_invites,
});
};
exports.on_load_success = function (invites_data) {
meta.loaded = true;
populate_invites(invites_data);
$(".admin_invites_table").on("click", ".revoke", function (e) {
e.preventDefault();
e.stopPropagation();
var row = $(e.target).closest(".invite_row");
var email = row.find('.email').text();
meta.current_revoke_invite_user_modal_row = row;
meta.invite_id = $(e.currentTarget).attr("data-invite-id");
$("#revoke_invite_modal .email").text(email);
$("#revoke_invite_modal #do_revoke_invite_button").attr("data-invite-id", meta.invite_id);
$("#revoke_invite_modal").modal("show");
});
$(".admin_invites_table").on("click", ".resend", function (e) {
e.preventDefault();
e.stopPropagation();
var row = $(e.target).closest(".invite_row");
var email = row.find('.email').text();
meta.current_resend_invite_user_modal_row = row;
meta.invite_id = $(e.currentTarget).attr("data-invite-id");
$("#resend_invite_modal .email").text(email);
$("#resend_invite_modal #do_resend_invite_button").attr("data-invite-id", meta.invite_id);
$("#resend_invite_modal").modal("show");
});
$("#do_revoke_invite_button").click(function () {
var modal_invite_id = $("#revoke_invite_modal #do_revoke_invite_button").attr("data-invite-id");
var revoke_button = meta.current_revoke_invite_user_modal_row.find("button.revoke");
if (modal_invite_id !== meta.invite_id) {
blueslip.error("Invite revoking canceled due to non-matching fields.");
ui_report.message("Resending encountered an error. Please reload and try again.",
$("#home-error"), 'alert-error');
}
$("#revoke_invite_modal").modal("hide");
revoke_button.prop("disabled", true).text(i18n.t("Working…"));
channel.del({
url: '/json/invites/' + meta.invite_id,
error: function (xhr) {
if (xhr.status.toString().charAt(0) === "4") {
revoke_button.closest("td").html(
$("<p>").addClass("text-error").text(JSON.parse(xhr.responseText).msg)
);
} else {
revoke_button.text(i18n.t("Failed!"));
}
},
success: function () {
meta.current_revoke_invite_user_modal_row.remove();
},
});
});
$("#do_resend_invite_button").click(function () {
var modal_invite_id = $("#resend_invite_modal #do_resend_invite_button").attr("data-invite-id");
var resend_button = meta.current_resend_invite_user_modal_row.find("button.resend");
if (modal_invite_id !== meta.invite_id) {
blueslip.error("Invite resending canceled due to non-matching fields.");
ui_report.message("Resending encountered an error. Please reload and try again.",
$("#home-error"), 'alert-error');
}
$("#resend_invite_modal").modal("hide");
resend_button.prop("disabled", true).text(i18n.t("Working…"));
channel.post({
url: '/json/invites/' + meta.invite_id + "/resend",
error: function (xhr) {
if (xhr.status.toString().charAt(0) === "4") {
resend_button.closest("td").html(
$("<p>").addClass("text-error").text(JSON.parse(xhr.responseText).msg)
);
} else {
resend_button.text(i18n.t("Failed!"));
}
},
success: function (data) {
resend_button.text(i18n.t("Resent!"));
resend_button.removeClass('resend btn-warning').addClass('sea-green');
meta.current_resend_invite_user_modal_row.find(".invited_at").text(data.timestamp);
},
});
});
};
return exports;
}());
if (typeof module !== 'undefined') {
module.exports = settings_invites;
}

View File

@ -858,6 +858,11 @@ input[type=checkbox].inline-block {
width: 100px;
height: 100px;
}
.invite-user-link i {
text-decoration: none;
margin-right: 5px;
}
/* -- new settings overlay -- */
#settings_page {
min-height: 550px;

View File

@ -0,0 +1,21 @@
{{#with invite}}
<tr class="invite_row">
<td>
<span class="email">{{email}}</span>
</td>
<td>
<span class="referred_by">{{ref}}</span>
</td>
<td>
<span class="invited_at">{{invited}}</span>
</td>
<td>
<button class="button rounded small revoke btn-danger" data-invite-id="{{id}}">
{{t "Revoke" }}
</button>
<button class="button rounded small resend btn-warning" data-invite-id="{{id}}">
{{t "Resend" }}
</button>
</td>
</tr>
{{/with}}

View File

@ -3,6 +3,8 @@
{{ partial "deactivation-user-modal" }}
{{ partial "deactivation-stream-modal" }}
{{ partial "realm-domains-modal" }}
{{ partial "revoke-invite-modal" }}
{{ partial "resend-invite-modal" }}
{{ partial "organization-profile-admin" }}
{{ partial "organization-settings-admin" }}
@ -23,3 +25,5 @@
{{ partial "auth-methods-settings-admin" }}
{{ partial "realm-filter-settings-admin" }}
{{ partial "invites-list-admin" }}

View File

@ -0,0 +1,18 @@
<div id="admin-invites-list" class="settings-section" data-name="invites-list-admin">
<a class="invite-user-link" href="#invite"><i class="icon-vector-plus-sign"></i>{{t "Invite more users" }}</a>
<input type="text" class="search" placeholder="{{t 'Filter invites' }}" aria-label="{{t 'Filter invites' }}"/>
<div class="clear-float"></div>
<div class="progressive-table-wrapper">
<table class="table table-condensed table-striped">
<thead>
<th>{{t "Email" }}</th>
<th>{{t "Invited by" }}</th>
<th>{{t "Invited at" }}</th>
<th class="actions">{{t "Actions" }}</th>
</thead>
<tbody id="admin_invites_table" class="required-text thick admin_invites_table" data-empty="{{t 'No invites match your current filter.' }}"></tbody>
</table>
</div>
<div id="admin_page_invites_loading_indicator"></div>
</div>

View File

@ -0,0 +1,13 @@
<div id="resend_invite_modal" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="resend_invite_modal_label" aria-hidden="true">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="{{t 'Close' }}"><span aria-hidden="true">&times;</span></button>
<h3 id="resend_invite_modal_label">{{#tr this}}Resend invitation to <span class="email"></span>{{/tr}}</h3>
</div>
<div class="modal-body">
<p>{{#tr this}}Are you sure you want to resend the invitation to <strong><span class="email"></span></strong>?{{/tr}}</p>
</div>
<div class="modal-footer">
<button class="button rounded" data-dismiss="modal">{{t "Cancel" }}</button>
<button class="button rounded btn-danger" id="do_resend_invite_button" data-invite-id="{{invite_id}}">{{t "Resend now" }}</button>
</div>
</div>

View File

@ -0,0 +1,13 @@
<div id="revoke_invite_modal" class="modal hide fade" tabindex="-1" role="dialog" aria-labelledby="revoke_invite_modal_label" aria-hidden="true">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="{{t 'Close' }}"><span aria-hidden="true">&times;</span></button>
<h3 id="revoke_invite_modal_label">{{#tr this}}Revoke invitation to <span class="email"></span>{{/tr}}</h3>
</div>
<div class="modal-body">
<p>{{#tr this}}Are you sure you want to revoke the invitation to <strong><span class="email"></span></strong>?{{/tr}}</p>
</div>
<div class="modal-footer">
<button class="button rounded" data-dismiss="modal">{{t "Cancel" }}</button>
<button class="button rounded btn-danger" id="do_revoke_invite_button">{{t "Revoke now" }}</button>
</div>
</div>

View File

@ -92,6 +92,12 @@
<i class="icon icon-vector-font"></i>
<div class="text">{{ _('Filter settings') }}</div>
</li>
{% if is_admin %}
<li class="admin" tabindex="0" data-section="invites-list-admin">
<i class="icon icon-vector-user"></i>
<div class="text">{{ _('Invitations') }}</div>
</li>
{% endif %}
</ul>
</div>

View File

@ -4,6 +4,7 @@ from typing import (
)
from mypy_extensions import TypedDict
from django.contrib.contenttypes.models import ContentType
from django.utils.html import escape
from django.utils.translation import ugettext as _
from django.conf import settings
@ -71,6 +72,7 @@ from django.core.mail import EmailMessage
from django.utils.timezone import now as timezone_now
from confirmation.models import Confirmation, create_confirmation_link
from confirmation import settings as confirmation_settings
from six.moves import filter
from six.moves import map
from six import unichr
@ -3805,6 +3807,82 @@ def do_invite_users(user_profile, invitee_emails, streams, invite_as_admin=False
"invitations to everyone else!"),
skipped, sent_invitations=True)
def do_get_user_invites(user_profile):
# type: (UserProfile) -> List[Dict[str, Any]]
days_to_activate = getattr(settings, 'ACCOUNT_ACTIVATION_DAYS', 7)
active_value = getattr(confirmation_settings, 'STATUS_ACTIVE', 1)
lowest_datetime = timezone_now() - datetime.timedelta(days=days_to_activate)
prereg_users = PreregistrationUser.objects.exclude(status=active_value).filter(
invited_at__gte=lowest_datetime,
referred_by__realm=user_profile.realm)
invites = []
for invitee in prereg_users:
invites.append(dict(email=invitee.email,
ref=invitee.referred_by.email,
invited=invitee.invited_at.strftime("%Y-%m-%d %H:%M:%S"),
id=invitee.id))
return invites
def do_revoke_user_invite(invite_id, realm_id):
# type: (int, int) -> None
try:
prereg_user = PreregistrationUser.objects.get(id=invite_id)
except PreregistrationUser.DoesNotExist:
raise JsonableError(_("Invalid invitation ID."))
if prereg_user.referred_by.realm_id != realm_id:
raise JsonableError(_("Invalid invitation ID."))
email = prereg_user.email
# Delete both the confirmation objects and the prereg_user object.
# TODO: Probably we actaully want to set the confirmation objects
# to a "revoked" status so that we can give the user a better
# error message.
content_type = ContentType.objects.get_for_model(PreregistrationUser)
Confirmation.objects.filter(content_type=content_type,
object_id=prereg_user.id).delete()
prereg_user.delete()
clear_scheduled_invitation_emails(email)
def do_resend_user_invite_email(invite_id, realm_id):
# type: (int, int) -> str
try:
prereg_user = PreregistrationUser.objects.get(id=invite_id)
except PreregistrationUser.DoesNotExist:
raise JsonableError(_("Invalid invitation ID."))
if (prereg_user.referred_by.realm_id != realm_id):
raise JsonableError(_("Invalid invitation ID."))
prereg_user.invited_at = timezone_now()
prereg_user.save()
# sends a invitation reminder since 'custom_body' can not be resent
# imported here to avoid import cycle error
from zerver.context_processors import common_context
clear_scheduled_invitation_emails(prereg_user.email)
link = create_confirmation_link(prereg_user, prereg_user.referred_by.realm.host, Confirmation.INVITATION)
context = common_context(prereg_user.referred_by)
context.update({
'activate_url': link,
'referrer_name': prereg_user.referred_by.full_name,
'referrer_email': prereg_user.referred_by.email,
'referrer_realm_name': prereg_user.referred_by.realm.name,
})
send_email(
"zerver/emails/invitation_reminder",
to_email=prereg_user.email,
from_address=FromAddress.NOREPLY,
context=context)
return prereg_user.invited_at.strftime("%Y-%m-%d %H:%M:%S")
def notify_realm_emoji(realm):
# type: (Realm) -> None
event = dict(type="realm_emoji", op="update",

View File

@ -12,6 +12,7 @@ from zerver.lib.test_helpers import MockLDAP
from confirmation.models import Confirmation, create_confirmation_link, MultiuseInvite, \
generate_key, confirmation_url
from confirmation import settings as confirmation_settings
from zerver.forms import HomepageForm, WRONG_SUBDOMAIN_ERROR
from zerver.lib.actions import do_change_password, gather_subscriptions
@ -442,7 +443,6 @@ class InviteUserBase(ZulipTestCase):
self.assertIn(FromAddress.NOREPLY, outbox[0].from_email)
class InviteUserTest(InviteUserBase):
def invite(self, users, streams, body='', invite_as_admin="false"):
# type: (Text, List[Text], str, str) -> HttpResponse
"""
@ -460,6 +460,7 @@ class InviteUserTest(InviteUserBase):
"invite_as_admin": invite_as_admin,
"custom_body": body})
class InviteUserTest(InviteUserBase):
def test_successful_invite_user(self):
# type: () -> None
"""
@ -845,6 +846,93 @@ so we didn't send them an invitation. We did send invitations to everyone else!"
scheduled_timestamp__lte=timezone_now(), type=ScheduledEmail.INVITATION_REMINDER)
self.assertEqual(len(email_jobs_to_deliver), 0)
class InvitationsTestCase(InviteUserBase):
def test_successful_get_open_invitations(self):
# type: () -> None
"""
A GET call to /json/invites returns all unexpired invitations.
"""
days_to_activate = getattr(settings, 'ACCOUNT_ACTIVATION_DAYS', "Wrong")
active_value = getattr(confirmation_settings, 'STATUS_ACTIVE', "Wrong")
self.assertNotEqual(days_to_activate, "Wrong")
self.assertNotEqual(active_value, "Wrong")
self.login(self.example_email("iago"))
user_profile = self.example_user("iago")
prereg_user_one = PreregistrationUser(email="TestOne@zulip.com", referred_by=user_profile)
prereg_user_one.save()
expired_datetime = timezone_now() - datetime.timedelta(days=(days_to_activate+1))
prereg_user_two = PreregistrationUser(email="TestTwo@zulip.com", referred_by=user_profile)
prereg_user_two.save()
PreregistrationUser.objects.filter(id=prereg_user_two.id).update(invited_at=expired_datetime)
prereg_user_three = PreregistrationUser(email="TestThree@zulip.com",
referred_by=user_profile, status=active_value)
prereg_user_three.save()
result = self.client_get("/json/invites")
self.assertEqual(result.status_code, 200)
self.assert_in_success_response(["TestOne@zulip.com"], result)
self.assert_not_in_success_response(["TestTwo@zulip.com", "TestThree@zulip.com"], result)
def test_successful_delete_invitation(self):
# type: () -> None
"""
A DELETE call to /json/invites/<ID> should delete the invite and
any scheduled invitation reminder emails.
"""
self.login(self.example_email("iago"))
invitee = "DeleteMe@zulip.com"
self.assert_json_success(self.invite(invitee, ['Denmark']))
prereg_user = PreregistrationUser.objects.get(email=invitee)
# Verify that the scheduled email exists.
ScheduledEmail.objects.get(address__iexact=invitee,
type=ScheduledEmail.INVITATION_REMINDER)
result = self.client_delete('/json/invites/' + str(prereg_user.id))
self.assertEqual(result.status_code, 200)
error_result = self.client_delete('/json/invites/' + str(prereg_user.id))
self.assert_json_error(error_result, "Invalid invitation ID.")
self.assertRaises(ScheduledEmail.DoesNotExist,
lambda: ScheduledEmail.objects.get(address__iexact=invitee,
type=ScheduledEmail.INVITATION_REMINDER))
def test_successful_resend_invitation(self):
# type: () -> None
"""
A POST call to /json/invites/<ID>/resend should send an invitation reminder email
and delete any scheduled invitation reminder email.
"""
self.login(self.example_email("iago"))
invitee = "ResendMe@zulip.com"
self.assert_json_success(self.invite(invitee, ['Denmark']))
prereg_user = PreregistrationUser.objects.get(email=invitee)
# Verify and then clear from the outbox the original invite email
self.check_sent_emails([invitee], custom_from_name="Zulip")
from django.core.mail import outbox
outbox.pop()
# Verify that the scheduled email exists.
ScheduledEmail.objects.get(address__iexact=invitee,
type=ScheduledEmail.INVITATION_REMINDER)
result = self.client_post('/json/invites/' + str(prereg_user.id) + '/resend')
self.assertEqual(result.status_code, 200)
error_result = self.client_post('/json/invites/' + str(9999) + '/resend')
self.assert_json_error(error_result, "Invalid invitation ID.")
self.check_sent_emails([invitee], custom_from_name="Zulip")
self.assertRaises(ScheduledEmail.DoesNotExist,
lambda: ScheduledEmail.objects.get(address__iexact=invitee,
type=ScheduledEmail.INVITATION_REMINDER))
class InviteeEmailsParserTests(TestCase):
def setUp(self):
# type: () -> None

View File

@ -5,11 +5,11 @@ from django.http import HttpRequest, HttpResponse
from django.utils.translation import ugettext as _
from typing import List, Optional, Set, Text
from zerver.decorator import authenticated_json_post_view
from zerver.lib.actions import do_invite_users, \
get_default_subs
from zerver.decorator import authenticated_json_post_view, require_realm_admin, to_non_negative_int
from zerver.lib.actions import do_invite_users, do_revoke_user_invite, do_resend_user_invite_email, \
get_default_subs, do_get_user_invites
from zerver.lib.request import REQ, has_request_variables, JsonableError
from zerver.lib.response import json_success, json_error
from zerver.lib.response import json_success, json_error, json_response
from zerver.lib.streams import access_stream_by_name
from zerver.lib.validator import check_string, check_list, check_bool
from zerver.models import PreregistrationUser, Stream, UserProfile
@ -65,3 +65,23 @@ def get_invitee_emails_set(invitee_emails_raw):
email = is_email_with_name.group('email')
invitee_emails.add(email.strip())
return invitee_emails
@require_realm_admin
def get_user_invites(request, user_profile):
# type: (HttpRequest, UserProfile) -> HttpResponse
all_users = do_get_user_invites(user_profile)
return json_success({'invites': all_users})
@require_realm_admin
@has_request_variables
def revoke_user_invite(request, user_profile, prereg_id):
# type: (HttpRequest, UserProfile, int) -> HttpResponse
do_revoke_user_invite(prereg_id, user_profile.realm_id)
return json_success()
@require_realm_admin
@has_request_variables
def resend_user_invite_email(request, user_profile, prereg_id):
# type: (HttpRequest, UserProfile, int) -> HttpResponse
timestamp = do_resend_user_invite_email(prereg_id, user_profile.realm_id)
return json_success({'timestamp': timestamp})

View File

@ -1018,6 +1018,7 @@ JS_SPECS = {
'js/settings_users.js',
'js/settings_streams.js',
'js/settings_filters.js',
'js/settings_invites.js',
'js/settings.js',
'js/admin_sections.js',
'js/admin.js',

View File

@ -130,7 +130,12 @@ v1_api_and_json_patterns = [
# invites -> zerver.views.invite
url(r'^invites$', rest_dispatch,
{'POST': 'zerver.views.invite.invite_users_backend'}),
{'GET': 'zerver.views.invite.get_user_invites',
'POST': 'zerver.views.invite.invite_users_backend'}),
url(r'^invites/(?P<prereg_id>[0-9]+)$', rest_dispatch,
{'DELETE': 'zerver.views.invite.revoke_user_invite'}),
url(r'^invites/(?P<prereg_id>[0-9]+)/resend$', rest_dispatch,
{'POST': 'zerver.views.invite.resend_user_invite_email'}),
# mark messages as read (in bulk)
url(r'^mark_all_as_read$', rest_dispatch,