From d00b889402d7920f6b40fad3f85fa42ed586fdaf Mon Sep 17 00:00:00 2001 From: Raymond Akornor Date: Mon, 12 Nov 2018 13:15:49 +0000 Subject: [PATCH] auth: Add an organization reactivation flow with admin confirmation. This adds a web flow and management command for reactivating a Zulip organization, with confirmation from one of the organization administrators. Further work is needed to make the emails nicer (ideally, we'd send one email with all the admins on the `To` line, but the `send_email` library doesn't support that). Fixes #10783. With significant tweaks to the email text by tabbott. --- confirmation/models.py | 7 +++- .../emails/realm_reactivation.source.html | 38 +++++++++++++++++++ .../zerver/emails/realm_reactivation.subject | 1 + .../zerver/emails/realm_reactivation.txt | 21 ++++++++++ templates/zerver/realm_reactivation.html | 14 +++++++ .../zerver/realm_reactivation_link_error.html | 14 +++++++ version.py | 2 +- zerver/lib/actions.py | 12 ++++++ .../commands/send_realm_reactivation_email.py | 23 +++++++++++ zerver/tests/test_management_commands.py | 10 ++++- zerver/tests/test_realm.py | 36 ++++++++++++++++++ zerver/views/realm.py | 12 ++++++ zproject/urls.py | 4 ++ 13 files changed, 191 insertions(+), 3 deletions(-) create mode 100644 templates/zerver/emails/realm_reactivation.source.html create mode 100644 templates/zerver/emails/realm_reactivation.subject create mode 100644 templates/zerver/emails/realm_reactivation.txt create mode 100644 templates/zerver/realm_reactivation.html create mode 100644 templates/zerver/realm_reactivation_link_error.html create mode 100644 zerver/management/commands/send_realm_reactivation_email.py diff --git a/confirmation/models.py b/confirmation/models.py index 7b99e4a5c3..e00755f01b 100644 --- a/confirmation/models.py +++ b/confirmation/models.py @@ -70,8 +70,11 @@ def create_confirmation_link(obj: ContentType, host: str, confirmation_type: int, url_args: Optional[Dict[str, str]]=None) -> str: key = generate_key() + realm = None + if hasattr(obj, 'realm'): + realm = obj.realm Confirmation.objects.create(content_object=obj, date_sent=timezone_now(), confirmation_key=key, - realm=obj.realm, type=confirmation_type) + realm=realm, type=confirmation_type) return confirmation_url(key, host, confirmation_type, url_args) def confirmation_url(confirmation_key: str, host: str, @@ -99,6 +102,7 @@ class Confirmation(models.Model): SERVER_REGISTRATION = 5 MULTIUSE_INVITE = 6 REALM_CREATION = 7 + REALM_REACTIVATION = 8 type = models.PositiveSmallIntegerField() # type: int def __str__(self) -> str: @@ -121,6 +125,7 @@ _properties = { 'zerver.views.registration.accounts_home_from_multiuse_invite', validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS), Confirmation.REALM_CREATION: ConfirmationType('check_prereg_key_and_redirect'), + Confirmation.REALM_REACTIVATION: ConfirmationType('zerver.views.realm.realm_reactivation'), } def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> str: diff --git a/templates/zerver/emails/realm_reactivation.source.html b/templates/zerver/emails/realm_reactivation.source.html new file mode 100644 index 0000000000..de3d770e97 --- /dev/null +++ b/templates/zerver/emails/realm_reactivation.source.html @@ -0,0 +1,38 @@ +{% extends "zerver/emails/compiled/email_base_default.html" %} + +{% block illustration %} + +{% endblock %} + +{% block content %} +

+ {% trans %} + Dear former administrators of {{ realm_name }}, + {% endtrans %} +

+

+ {% trans %} + One of your administrators requested reactivation of the + previously deactivated Zulip organization hosted at {{ realm_uri }}. If you'd + like to do confirm that request and reactivate the organization, please click here: + {% endtrans %} +

+{{ _('Reactivate organization') }} +

+ {% trans %} + If the request was in error, you can take no action and this link + will expire in 24 hours. + {% endtrans %} +

+

+ {% trans %} + Feel free to give us a shout at + {{ support_email }}, + if you have any questions. + {% endtrans %} +

+

+ {{ _("Cheers,") }}
+ {{ _("Team Zulip") }} +

+{% endblock %} diff --git a/templates/zerver/emails/realm_reactivation.subject b/templates/zerver/emails/realm_reactivation.subject new file mode 100644 index 0000000000..f9d0d76725 --- /dev/null +++ b/templates/zerver/emails/realm_reactivation.subject @@ -0,0 +1 @@ +Reactivate your Zulip organization diff --git a/templates/zerver/emails/realm_reactivation.txt b/templates/zerver/emails/realm_reactivation.txt new file mode 100644 index 0000000000..9396212598 --- /dev/null +++ b/templates/zerver/emails/realm_reactivation.txt @@ -0,0 +1,21 @@ +{% trans %}Dear former administrators of {{ realm_name }},{% endtrans %} + +{% trans %} +One of your administrators requested reactivation of the previously +deactivated Zulip organization hosted at + {{ realm_uri }}. +If you'd like to do confirm that request and reactivate the +organization, please click here: +{% endtrans %} +{{ _('To reactivate organization, please click here:') }} + <{{ confirmation_url }}> + +{% trans %} +If the request was in error, you can take no action and this link +will expire in 24 hours. +{% endtrans %} + +{% trans %}Feel free to give us a shout at {{ support_email }},if you have any questions.{% endtrans %} + +{{ _("Cheers,") }}
+{{ _("Team Zulip") }} diff --git a/templates/zerver/realm_reactivation.html b/templates/zerver/realm_reactivation.html new file mode 100644 index 0000000000..ab31060390 --- /dev/null +++ b/templates/zerver/realm_reactivation.html @@ -0,0 +1,14 @@ +{% extends "zerver/portico_signup.html" %} + +{% block portico_content %} +
+ +
+{% endblock %} diff --git a/templates/zerver/realm_reactivation_link_error.html b/templates/zerver/realm_reactivation_link_error.html new file mode 100644 index 0000000000..ee498f21f2 --- /dev/null +++ b/templates/zerver/realm_reactivation_link_error.html @@ -0,0 +1,14 @@ +{% extends "zerver/portico_signup.html" %} + +{% block portico_content %} +
+ +
+{% endblock %} diff --git a/version.py b/version.py index 5859cfabe2..a636cfce52 100644 --- a/version.py +++ b/version.py @@ -11,4 +11,4 @@ LATEST_RELEASE_ANNOUNCEMENT = "https://blog.zulip.org/2018/11/07/zulip-1-9-relea # Typically, adding a dependency only requires a minor version bump, and # removing a dependency requires a major version bump. -PROVISION_VERSION = '26.14' +PROVISION_VERSION = '26.15' diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index 4fba754285..aaa2d6dce0 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -5140,3 +5140,15 @@ def missing_any_realm_internal_bots() -> bool: .annotate(Count('id'))) realm_count = Realm.objects.count() return any(bot_counts.get(email, 0) < realm_count for email in bot_emails) + +def do_send_realm_reactivation_email(realm: Realm) -> None: + confirmation_url = create_confirmation_link(realm, realm.host, Confirmation.REALM_REACTIVATION) + admins = realm.get_admin_users() + context = {'confirmation_url': confirmation_url, + 'realm_uri': realm.uri, + 'realm_name': realm.name} + for admin in admins: + send_email( + 'zerver/emails/realm_reactivation', to_email=admin.email, + from_address=FromAddress.tokenized_no_reply_address(), + from_name="Zulip Account Security", context=context) diff --git a/zerver/management/commands/send_realm_reactivation_email.py b/zerver/management/commands/send_realm_reactivation_email.py new file mode 100644 index 0000000000..39b1dd91a6 --- /dev/null +++ b/zerver/management/commands/send_realm_reactivation_email.py @@ -0,0 +1,23 @@ + +from argparse import ArgumentParser + +from zerver.lib.management import ZulipBaseCommand, CommandError +from zerver.lib.send_email import send_email, FromAddress +from zerver.lib.actions import do_send_realm_reactivation_email + +from typing import Any + +class Command(ZulipBaseCommand): + help = """Sends realm reactivation email to admins""" + + def add_arguments(self, parser: ArgumentParser) -> None: + self.add_realm_args(parser, True) + + def handle(self, *args: Any, **options: str) -> None: + realm = self.get_realm(options) + assert realm is not None + if not realm.deactivated: + raise CommandError("The realm %s is already active." % (realm.name,)) + print('Sending email to admins') + do_send_realm_reactivation_email(realm) + print('Done!') diff --git a/zerver/tests/test_management_commands.py b/zerver/tests/test_management_commands.py index 5286b6c4b2..94a76d3b6a 100644 --- a/zerver/tests/test_management_commands.py +++ b/zerver/tests/test_management_commands.py @@ -11,7 +11,7 @@ from typing import List, Dict, Any, Optional from django.conf import settings from django.core.management import call_command from django.test import TestCase, override_settings -from zerver.lib.actions import do_create_user +from zerver.lib.actions import do_create_user, do_deactivate_realm, do_reactivate_realm from zerver.lib.management import ZulipBaseCommand, CommandError, check_config from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_helpers import stdout_suppressed @@ -287,3 +287,11 @@ class TestPasswordRestEmail(ZulipTestCase): tokenized_no_reply_email = parseaddr(from_email)[1] self.assertTrue(re.search(self.TOKENIZED_NOREPLY_REGEX, tokenized_no_reply_email)) self.assertIn("Psst. Word on the street is that you", outbox[0].body) + +class TestRealmReactivationEmail(ZulipTestCase): + COMMAND_NAME = "send_realm_reactivation_email" + + def test_if_realm_not_deactivated(self) -> None: + realm = get_realm('zulip') + with self.assertRaisesRegex(CommandError, "The realm %s is already active." % (realm.name,)): + call_command(self.COMMAND_NAME, "--realm=zulip") diff --git a/zerver/tests/test_realm.py b/zerver/tests/test_realm.py index b46925408a..dc30235ae6 100644 --- a/zerver/tests/test_realm.py +++ b/zerver/tests/test_realm.py @@ -3,6 +3,7 @@ import datetime import ujson import re import mock +from email.utils import parseaddr from django.conf import settings from django.http import HttpResponse @@ -20,8 +21,10 @@ from zerver.lib.actions import ( do_scrub_realm, create_stream_if_needed, do_change_plan_type, + do_send_realm_reactivation_email ) +from confirmation.models import create_confirmation_link, Confirmation from zerver.lib.send_email import send_future_email from zerver.lib.test_classes import ZulipTestCase from zerver.lib.test_helpers import tornado_redirected_to_list @@ -195,6 +198,39 @@ class RealmTest(ZulipTestCase): do_deactivate_realm(realm) self.assertTrue(realm.deactivated) + def test_realm_reactivation_link(self) -> None: + realm = get_realm('zulip') + do_deactivate_realm(realm) + self.assertTrue(realm.deactivated) + confirmation_url = create_confirmation_link(realm, realm.host, Confirmation.REALM_REACTIVATION) + response = self.client_get(confirmation_url) + self.assert_in_success_response(['Your organization has been successfully reactivated'], response) + realm = get_realm('zulip') + self.assertFalse(realm.deactivated) + + def test_do_send_realm_reactivation_email(self) -> None: + realm = get_realm('zulip') + do_send_realm_reactivation_email(realm) + from django.core.mail import outbox + self.assertEqual(len(outbox), 1) + from_email = outbox[0].from_email + tokenized_no_reply_email = parseaddr(from_email)[1] + self.assertIn("Zulip Account Security", from_email) + self.assertTrue(re.search(self.TOKENIZED_NOREPLY_REGEX, tokenized_no_reply_email)) + self.assertIn('Reactivate your Zulip organization', outbox[0].subject) + self.assertIn('To reactivate organization, please click here:', outbox[0].body) + admins = realm.get_admin_users() + confirmation_url = self.get_confirmation_url_from_outbox(admins[0].email) + response = self.client_get(confirmation_url) + self.assert_in_success_response(['Your organization has been successfully reactivated'], response) + realm = get_realm('zulip') + self.assertFalse(realm.deactivated) + + def test_realm_reactivation_with_random_link(self) -> None: + random_link = "/reactivate/5e89081eb13984e0f3b130bf7a4121d153f1614b" + response = self.client_get(random_link) + self.assert_in_success_response(['The organization reactivation link has expired or is not valid.'], response) + def test_change_notifications_stream(self) -> None: # We need an admin user. email = 'iago@zulip.com' diff --git a/zerver/views/realm.py b/zerver/views/realm.py index 18bdb25f67..255f2c78db 100644 --- a/zerver/views/realm.py +++ b/zerver/views/realm.py @@ -1,6 +1,7 @@ from typing import Any, Dict, Optional, List from django.http import HttpRequest, HttpResponse +from django.shortcuts import render from django.utils.translation import ugettext as _ from django.conf import settings from django.core.exceptions import ValidationError @@ -15,6 +16,7 @@ from zerver.lib.actions import ( do_set_realm_signup_notifications_stream, do_set_realm_property, do_deactivate_realm, + do_reactivate_realm, ) from zerver.lib.i18n import get_available_language_codes from zerver.lib.request import has_request_variables, REQ, JsonableError @@ -24,6 +26,7 @@ from zerver.lib.streams import access_stream_by_id from zerver.lib.domains import validate_domain from zerver.models import Realm, UserProfile from zerver.forms import check_subdomain_available as check_subdomain +from confirmation.models import get_object_from_key, Confirmation, ConfirmationKeyException @require_realm_admin @has_request_variables @@ -169,3 +172,12 @@ def check_subdomain_available(request: HttpRequest, subdomain: str) -> HttpRespo return json_success({"msg": "available"}) except ValidationError as e: return json_success({"msg": e.message}) + +def realm_reactivation(request: HttpRequest, confirmation_key: str) -> HttpResponse: + try: + realm = get_object_from_key(confirmation_key, Confirmation.REALM_REACTIVATION) + except ConfirmationKeyException: + return render(request, 'zerver/realm_reactivation_link_error.html') + do_reactivate_realm(realm) + context = {"realm": realm} + return render(request, 'zerver/realm_reactivation.html', context) diff --git a/zproject/urls.py b/zproject/urls.py index d296410608..5191209788 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -458,6 +458,10 @@ i18n_urls = [ url(r'^new/(?P[\w]+)$', zerver.views.registration.create_realm, name='zerver.views.create_realm'), + # Realm Reactivation + url(r'^reactivate/(?P[\w]+)', zerver.views.realm.realm_reactivation, + name='zerver.views.realm.realm_reactivation'), + # Global public streams (Zulip's way of doing archives) url(r'^archive/streams/(?P\d+)/topics/(?P[^/]+)$', zerver.views.archive.archive,