From 09cd47c6ad03ed4386cbe5ed143c38a95d913e19 Mon Sep 17 00:00:00 2001 From: Henrik Pettersson Date: Sat, 21 Oct 2017 03:15:12 +0200 Subject: [PATCH] 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. --- .eslintrc.json | 1 + frontend_tests/node_tests/templates.js | 36 ++++- static/js/admin_sections.js | 7 + static/js/settings.js | 1 + static/js/settings_invites.js | 153 ++++++++++++++++++ static/styles/settings.css | 5 + .../templates/admin_invites_list.handlebars | 21 +++ static/templates/admin_tab.handlebars | 4 + .../settings/invites-list-admin.handlebars | 18 +++ .../settings/resend-invite-modal.handlebars | 13 ++ .../settings/revoke-invite-modal.handlebars | 13 ++ templates/zerver/settings_overlay.html | 6 + zerver/lib/actions.py | 78 +++++++++ zerver/tests/test_signup.py | 90 ++++++++++- zerver/views/invite.py | 28 +++- zproject/settings.py | 1 + zproject/urls.py | 7 +- 17 files changed, 475 insertions(+), 7 deletions(-) create mode 100644 static/js/settings_invites.js create mode 100644 static/templates/admin_invites_list.handlebars create mode 100644 static/templates/settings/invites-list-admin.handlebars create mode 100644 static/templates/settings/resend-invite-modal.handlebars create mode 100644 static/templates/settings/revoke-invite-modal.handlebars 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}} +

+ + + + + +{{/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" }} + +
+ +
+
+ + + {{ref}} + + {{invited}} + + + +
+ + + + + + + +
{{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 @@ + 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 @@ + 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,