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,