mirror of
https://github.com/zulip/zulip.git
synced 2025-11-04 14:03:30 +00:00
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:
committed by
Tim Abbott
parent
10e8e2acac
commit
d00b889402
@@ -70,8 +70,11 @@ def create_confirmation_link(obj: ContentType, host: str,
|
|||||||
confirmation_type: int,
|
confirmation_type: int,
|
||||||
url_args: Optional[Dict[str, str]]=None) -> str:
|
url_args: Optional[Dict[str, str]]=None) -> str:
|
||||||
key = generate_key()
|
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,
|
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)
|
return confirmation_url(key, host, confirmation_type, url_args)
|
||||||
|
|
||||||
def confirmation_url(confirmation_key: str, host: str,
|
def confirmation_url(confirmation_key: str, host: str,
|
||||||
@@ -99,6 +102,7 @@ class Confirmation(models.Model):
|
|||||||
SERVER_REGISTRATION = 5
|
SERVER_REGISTRATION = 5
|
||||||
MULTIUSE_INVITE = 6
|
MULTIUSE_INVITE = 6
|
||||||
REALM_CREATION = 7
|
REALM_CREATION = 7
|
||||||
|
REALM_REACTIVATION = 8
|
||||||
type = models.PositiveSmallIntegerField() # type: int
|
type = models.PositiveSmallIntegerField() # type: int
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
@@ -121,6 +125,7 @@ _properties = {
|
|||||||
'zerver.views.registration.accounts_home_from_multiuse_invite',
|
'zerver.views.registration.accounts_home_from_multiuse_invite',
|
||||||
validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS),
|
validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS),
|
||||||
Confirmation.REALM_CREATION: ConfirmationType('check_prereg_key_and_redirect'),
|
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:
|
def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> str:
|
||||||
|
|||||||
38
templates/zerver/emails/realm_reactivation.source.html
Normal file
38
templates/zerver/emails/realm_reactivation.source.html
Normal 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 %}
|
||||||
1
templates/zerver/emails/realm_reactivation.subject
Normal file
1
templates/zerver/emails/realm_reactivation.subject
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Reactivate your Zulip organization
|
||||||
21
templates/zerver/emails/realm_reactivation.txt
Normal file
21
templates/zerver/emails/realm_reactivation.txt
Normal 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") }}
|
||||||
14
templates/zerver/realm_reactivation.html
Normal file
14
templates/zerver/realm_reactivation.html
Normal 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 %}
|
||||||
14
templates/zerver/realm_reactivation_link_error.html
Normal file
14
templates/zerver/realm_reactivation_link_error.html
Normal 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 %}
|
||||||
@@ -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
|
# Typically, adding a dependency only requires a minor version bump, and
|
||||||
# removing a dependency requires a major version bump.
|
# removing a dependency requires a major version bump.
|
||||||
|
|
||||||
PROVISION_VERSION = '26.14'
|
PROVISION_VERSION = '26.15'
|
||||||
|
|||||||
@@ -5140,3 +5140,15 @@ def missing_any_realm_internal_bots() -> bool:
|
|||||||
.annotate(Count('id')))
|
.annotate(Count('id')))
|
||||||
realm_count = Realm.objects.count()
|
realm_count = Realm.objects.count()
|
||||||
return any(bot_counts.get(email, 0) < realm_count for email in bot_emails)
|
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)
|
||||||
|
|||||||
23
zerver/management/commands/send_realm_reactivation_email.py
Normal file
23
zerver/management/commands/send_realm_reactivation_email.py
Normal 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!')
|
||||||
@@ -11,7 +11,7 @@ from typing import List, Dict, Any, Optional
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.test import TestCase, override_settings
|
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.management import ZulipBaseCommand, CommandError, check_config
|
||||||
from zerver.lib.test_classes import ZulipTestCase
|
from zerver.lib.test_classes import ZulipTestCase
|
||||||
from zerver.lib.test_helpers import stdout_suppressed
|
from zerver.lib.test_helpers import stdout_suppressed
|
||||||
@@ -287,3 +287,11 @@ class TestPasswordRestEmail(ZulipTestCase):
|
|||||||
tokenized_no_reply_email = parseaddr(from_email)[1]
|
tokenized_no_reply_email = parseaddr(from_email)[1]
|
||||||
self.assertTrue(re.search(self.TOKENIZED_NOREPLY_REGEX, tokenized_no_reply_email))
|
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)
|
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")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import datetime
|
|||||||
import ujson
|
import ujson
|
||||||
import re
|
import re
|
||||||
import mock
|
import mock
|
||||||
|
from email.utils import parseaddr
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
@@ -20,8 +21,10 @@ from zerver.lib.actions import (
|
|||||||
do_scrub_realm,
|
do_scrub_realm,
|
||||||
create_stream_if_needed,
|
create_stream_if_needed,
|
||||||
do_change_plan_type,
|
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.send_email import send_future_email
|
||||||
from zerver.lib.test_classes import ZulipTestCase
|
from zerver.lib.test_classes import ZulipTestCase
|
||||||
from zerver.lib.test_helpers import tornado_redirected_to_list
|
from zerver.lib.test_helpers import tornado_redirected_to_list
|
||||||
@@ -195,6 +198,39 @@ class RealmTest(ZulipTestCase):
|
|||||||
do_deactivate_realm(realm)
|
do_deactivate_realm(realm)
|
||||||
self.assertTrue(realm.deactivated)
|
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:
|
def test_change_notifications_stream(self) -> None:
|
||||||
# We need an admin user.
|
# We need an admin user.
|
||||||
email = 'iago@zulip.com'
|
email = 'iago@zulip.com'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
|
||||||
from typing import Any, Dict, Optional, List
|
from typing import Any, Dict, Optional, List
|
||||||
from django.http import HttpRequest, HttpResponse
|
from django.http import HttpRequest, HttpResponse
|
||||||
|
from django.shortcuts import render
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
@@ -15,6 +16,7 @@ from zerver.lib.actions import (
|
|||||||
do_set_realm_signup_notifications_stream,
|
do_set_realm_signup_notifications_stream,
|
||||||
do_set_realm_property,
|
do_set_realm_property,
|
||||||
do_deactivate_realm,
|
do_deactivate_realm,
|
||||||
|
do_reactivate_realm,
|
||||||
)
|
)
|
||||||
from zerver.lib.i18n import get_available_language_codes
|
from zerver.lib.i18n import get_available_language_codes
|
||||||
from zerver.lib.request import has_request_variables, REQ, JsonableError
|
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.lib.domains import validate_domain
|
||||||
from zerver.models import Realm, UserProfile
|
from zerver.models import Realm, UserProfile
|
||||||
from zerver.forms import check_subdomain_available as check_subdomain
|
from zerver.forms import check_subdomain_available as check_subdomain
|
||||||
|
from confirmation.models import get_object_from_key, Confirmation, ConfirmationKeyException
|
||||||
|
|
||||||
@require_realm_admin
|
@require_realm_admin
|
||||||
@has_request_variables
|
@has_request_variables
|
||||||
@@ -169,3 +172,12 @@ def check_subdomain_available(request: HttpRequest, subdomain: str) -> HttpRespo
|
|||||||
return json_success({"msg": "available"})
|
return json_success({"msg": "available"})
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
return json_success({"msg": e.message})
|
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)
|
||||||
|
|||||||
@@ -458,6 +458,10 @@ i18n_urls = [
|
|||||||
url(r'^new/(?P<creation_key>[\w]+)$',
|
url(r'^new/(?P<creation_key>[\w]+)$',
|
||||||
zerver.views.registration.create_realm, name='zerver.views.create_realm'),
|
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)
|
# Global public streams (Zulip's way of doing archives)
|
||||||
url(r'^archive/streams/(?P<stream_id>\d+)/topics/(?P<topic_name>[^/]+)$',
|
url(r'^archive/streams/(?P<stream_id>\d+)/topics/(?P<topic_name>[^/]+)$',
|
||||||
zerver.views.archive.archive,
|
zerver.views.archive.archive,
|
||||||
|
|||||||
Reference in New Issue
Block a user