diff --git a/.eslintrc.json b/.eslintrc.json
index 904799347f..d238f72b1b 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -50,6 +50,7 @@
"settings_users": false,
"settings_streams": false,
"settings_filters": false,
+ "settings_invites": false,
"settings": false,
"resize": false,
"loading": false,
diff --git a/frontend_tests/node_tests/templates.js b/frontend_tests/node_tests/templates.js
index 8eb86422e3..d1f1c4b11a 100644
--- a/frontend_tests/node_tests/templates.js
+++ b/frontend_tests/node_tests/templates.js
@@ -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 = '
';
+ 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 += "
";
+ 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 = '';
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);
});
diff --git a/static/js/admin_sections.js b/static/js/admin_sections.js
index f63942df2b..856e627a58 100644
--- a/static/js/admin_sections.js
+++ b/static/js/admin_sections.js
@@ -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;
diff --git a/static/js/settings.js b/static/js/settings.js
index e383aa90bf..13a9a1f480 100644
--- a/static/js/settings.js
+++ b/static/js/settings.js
@@ -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"),
};
}
diff --git a/static/js/settings_invites.js b/static/js/settings_invites.js
new file mode 100644
index 0000000000..8dad42a7cf
--- /dev/null
+++ b/static/js/settings_invites.js
@@ -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(
+ $("").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(
+ $("
").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;
+}
diff --git a/static/styles/settings.css b/static/styles/settings.css
index 9f13f0a8ea..e915b3508c 100644
--- a/static/styles/settings.css
+++ b/static/styles/settings.css
@@ -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;
diff --git a/static/templates/admin_invites_list.handlebars b/static/templates/admin_invites_list.handlebars
new file mode 100644
index 0000000000..dd5cfe1d4c
--- /dev/null
+++ b/static/templates/admin_invites_list.handlebars
@@ -0,0 +1,21 @@
+{{#with invite}}
+
+
+ {{email}}
+ |
+
+ {{ref}}
+ |
+
+ {{invited}}
+ |
+
+
+
+ |
+
+{{/with}}
diff --git a/static/templates/admin_tab.handlebars b/static/templates/admin_tab.handlebars
index 4f5e588f48..ae2ce2a4b9 100644
--- a/static/templates/admin_tab.handlebars
+++ b/static/templates/admin_tab.handlebars
@@ -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" }}
diff --git a/static/templates/settings/invites-list-admin.handlebars b/static/templates/settings/invites-list-admin.handlebars
new file mode 100644
index 0000000000..7efd2c1a61
--- /dev/null
+++ b/static/templates/settings/invites-list-admin.handlebars
@@ -0,0 +1,18 @@
+
+
{{t "Invite more users" }}
+
+
+
+
+
+
+ {{t "Email" }} |
+ {{t "Invited by" }} |
+ {{t "Invited at" }} |
+ {{t "Actions" }} |
+
+
+
+
+
+
diff --git a/static/templates/settings/resend-invite-modal.handlebars b/static/templates/settings/resend-invite-modal.handlebars
new file mode 100644
index 0000000000..df316b33dd
--- /dev/null
+++ b/static/templates/settings/resend-invite-modal.handlebars
@@ -0,0 +1,13 @@
+
+
+
+
{{#tr this}}Are you sure you want to resend the invitation to ?{{/tr}}
+
+
+
diff --git a/static/templates/settings/revoke-invite-modal.handlebars b/static/templates/settings/revoke-invite-modal.handlebars
new file mode 100644
index 0000000000..fd0fe42090
--- /dev/null
+++ b/static/templates/settings/revoke-invite-modal.handlebars
@@ -0,0 +1,13 @@
+
+
+
+
{{#tr this}}Are you sure you want to revoke the invitation to ?{{/tr}}
+
+
+
diff --git a/templates/zerver/settings_overlay.html b/templates/zerver/settings_overlay.html
index d731a93291..c75f050214 100644
--- a/templates/zerver/settings_overlay.html
+++ b/templates/zerver/settings_overlay.html
@@ -92,6 +92,12 @@
{{ _('Filter settings') }}
+ {% if is_admin %}
+
+
+ {{ _('Invitations') }}
+
+ {% endif %}
diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py
index 6ba2853638..243aafc6c5 100644
--- a/zerver/lib/actions.py
+++ b/zerver/lib/actions.py
@@ -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",
diff --git a/zerver/tests/test_signup.py b/zerver/tests/test_signup.py
index 2dc8c61e38..740e7aefbb 100644
--- a/zerver/tests/test_signup.py
+++ b/zerver/tests/test_signup.py
@@ -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/ 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//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
diff --git a/zerver/views/invite.py b/zerver/views/invite.py
index 53f43ad90f..3430898b91 100644
--- a/zerver/views/invite.py
+++ b/zerver/views/invite.py
@@ -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})
diff --git a/zproject/settings.py b/zproject/settings.py
index 57ac5b0dc8..5aa354c303 100644
--- a/zproject/settings.py
+++ b/zproject/settings.py
@@ -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',
diff --git a/zproject/urls.py b/zproject/urls.py
index eb050f2186..f52ca928ce 100644
--- a/zproject/urls.py
+++ b/zproject/urls.py
@@ -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[0-9]+)$', rest_dispatch,
+ {'DELETE': 'zerver.views.invite.revoke_user_invite'}),
+ url(r'^invites/(?P[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,