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.
This commit is contained in:
Raymond Akornor
2018-11-12 13:15:49 +00:00
committed by Tim Abbott
parent 10e8e2acac
commit d00b889402
13 changed files with 191 additions and 3 deletions

View File

@@ -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:

View File

@@ -0,0 +1,38 @@
{% extends "zerver/emails/compiled/email_base_default.html" %}
{% block illustration %}
<img src="{{ email_images_base_uri }}/registration_confirmation.png"/>
{% endblock %}
{% block content %}
<p>
{% trans %}
Dear former administrators of {{ realm_name }},
{% endtrans %}
</p>
<p>
{% 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 %}
</p>
<a class="button" href="{{ confirmation_url }}">{{ _('Reactivate organization') }}</a>
<p>
{% trans %}
If the request was in error, you can take no action and this link
will expire in 24 hours.
{% endtrans %}
</p>
<p>
{% trans %}
Feel free to give us a shout at
<a href="mailto:{{ support_email }}">{{ support_email }}</a>,
if you have any questions.
{% endtrans %}
</p>
<p>
{{ _("Cheers,") }}<br />
{{ _("Team Zulip") }}
</p>
{% endblock %}

View File

@@ -0,0 +1 @@
Reactivate your Zulip organization

View File

@@ -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,") }}<br />
{{ _("Team Zulip") }}

View File

@@ -0,0 +1,14 @@
{% extends "zerver/portico_signup.html" %}
{% block portico_content %}
<div class="app portico-page">
<div class="app-main portico-page-container center-block flex full-page account-creation new-style">
<div class="inline-block">
<div class="get-started">
<h1>{{ _("Your organization has been successfully reactivated.") }}</h1>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends "zerver/portico_signup.html" %}
{% block portico_content %}
<div class="app portico-page">
<div class="app-main portico-page-container center-block flex full-page account-creation new-style">
<div class="inline-block">
<div class="get-started">
<h1>{{ _("The organization reactivation link has expired or is not valid.") }}</h1>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -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'

View File

@@ -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)

View File

@@ -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!')

View File

@@ -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")

View File

@@ -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'

View File

@@ -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)

View File

@@ -458,6 +458,10 @@ i18n_urls = [
url(r'^new/(?P<creation_key>[\w]+)$',
zerver.views.registration.create_realm, name='zerver.views.create_realm'),
# Realm Reactivation
url(r'^reactivate/(?P<confirmation_key>[\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<stream_id>\d+)/topics/(?P<topic_name>[^/]+)$',
zerver.views.archive.archive,