Skip to content

Commit

Permalink
change-email: Implement confirmation flow.
Browse files Browse the repository at this point in the history
Partially fixes zulip#734.
  • Loading branch information
umairwaheed committed Jan 18, 2017
1 parent 287b84d commit a4a71dd
Show file tree
Hide file tree
Showing 16 changed files with 279 additions and 12 deletions.
24 changes: 24 additions & 0 deletions confirmation/migrations/0003_emailchangeconfirmation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-17 09:16
from __future__ import unicode_literals

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
('confirmation', '0002_realmcreationkey'),
]

operations = [
migrations.CreateModel(
name='EmailChangeConfirmation',
fields=[
],
options={
'proxy': True,
},
bases=('confirmation.confirmation',),
),
]
19 changes: 17 additions & 2 deletions confirmation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

from confirmation.util import get_status_field
from zerver.lib.utils import generate_random_token
from zerver.models import PreregistrationUser
from zerver.models import PreregistrationUser, EmailChangeStatus
from typing import Optional, Union, Any, Text

try:
Expand Down Expand Up @@ -67,7 +67,7 @@ def generate_realm_creation_url():
class ConfirmationManager(models.Manager):

def confirm(self, confirmation_key):
# type: (str) -> Union[bool, PreregistrationUser]
# type: (str) -> Union[bool, PreregistrationUser, EmailChangeStatus]
if B16_RE.search(confirmation_key):
try:
confirmation = self.get(confirmation_key=confirmation_key)
Expand Down Expand Up @@ -131,6 +131,15 @@ def send_confirmation(self, obj, email_address, additional_context=None,
send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [email_address])
return self.create(content_object=obj, date_sent=now(), confirmation_key=confirmation_key)

class EmailChangeConfirmationManager(ConfirmationManager):
def get_activation_url(self, key, host=None):
# type: (Text, Optional[str]) -> Text
if host is None:
host = settings.EXTERNAL_HOST
return u'%s%s%s' % (settings.EXTERNAL_URI_SCHEME,
host,
reverse('zerver.views.user_settings.confirm_email_change',
kwargs={'confirmation_key': key}))

class Confirmation(models.Model):
content_type = models.ForeignKey(ContentType)
Expand All @@ -149,6 +158,12 @@ def __unicode__(self):
# type: () -> Text
return _('confirmation email for %s') % (self.content_object,)

class EmailChangeConfirmation(Confirmation):
class Meta(object):
proxy = True

objects = EmailChangeConfirmationManager()

class RealmCreationKey(models.Model):
creation_key = models.CharField(_('activation key'), max_length=40)
date_created = models.DateTimeField(_('created'), default=now)
3 changes: 3 additions & 0 deletions static/js/server_events.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,9 @@ function dispatch_normal_event(event) {
break;
}
break;
case 'update_email':
settings.update_email(event.payload.new_email);
break;
}
}

Expand Down
13 changes: 11 additions & 2 deletions static/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,8 +260,12 @@ function _setup_page() {
}
return true;
},
success: function () {
settings_change_success("Updated settings!");
success: function (data) {
var msg = "Updated settings!";
if ('account.email' in data) {
msg += " " + data['account.email'];
}
settings_change_success(msg);
},
error: function (xhr) {
settings_change_error("Error changing settings", xhr);
Expand Down Expand Up @@ -821,6 +825,11 @@ exports.update_page = function () {
i18n.ensure_i18n(_update_page);
};

exports.update_email = function (new_email) {
// Update the email value in all browser windows.
$('.account-settings-form').find('input[name=email]').val(new_email);
};

return exports;
}());

Expand Down
5 changes: 5 additions & 0 deletions static/templates/settings/account-settings.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
<input type="text" name="full_name" value="{{ page_params.fullname }}" />
</div>

<div class="input-group email_change_container">
<label for="email">{{t "Email" }}</label>
<input type="text" name="email" value="{{ page_params.email }}" />
</div>

<!-- password start -->
{{#if page_params.password_auth_enabled}}
<div class="input-group" id="pw_change_link">
Expand Down
16 changes: 16 additions & 0 deletions templates/confirmation/confirm_new_email_address_body.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
Hi there,

Can you please confirm the ownership of this email address by clicking the
link below?

{{ activate_url }}

{% if verbose_support_offers %}
Feel free to give us a shout at <{{ support_email }}> if you have any questions.
{% else %}
If you are having issues, please contact your Zulip administrator at <{{ support_email }}>.
{% endif %}

Cheers,

The Zulip Team
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Please confirm your new email address
14 changes: 14 additions & 0 deletions templates/confirmation/notify_change_in_email_body.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Hi there,

The email address associated with your Zulip account was changed recently. If
you did this then ignore this email otherwise contact us immediately.

{% if verbose_support_offers %}
Feel free to give us a shout at <{{ support_email }}> if you have any questions.
{% else %}
Please contact your Zulip administrator at <{{ support_email }}>.
{% endif %}

Cheers,

The Zulip Team
1 change: 1 addition & 0 deletions templates/confirmation/notify_change_in_email_subject.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Email change attempt
2 changes: 2 additions & 0 deletions tools/lint-all
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,8 @@ def build_custom_checkers(by_lang):
('zerver/views/invite.py',
'return json_error(data=error_data, msg=ret_error)'),
('zerver/views/streams.py', 'return json_error(property_conversion)'),
# error and skipped are already internationalized
('zerver/views/user_settings.py', 'return json_error(error or skipped)'),
# We can't do anything about this.
('zerver/views/realm_emoji.py', 'return json_error(e.messages[0])'),
('zerver/views/realm_filters.py', 'return json_error(e.messages[0], data={"errors": dict(e)})'),
Expand Down
38 changes: 35 additions & 3 deletions zerver/lib/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from django.utils.translation import ugettext as _
from django.conf import settings
from django.core import validators
from django.core.mail import send_mail
from django.contrib.sessions.models import Session
from zerver.lib.bugdown import (
BugdownRenderingException,
Expand Down Expand Up @@ -37,7 +38,7 @@
realm_filters_for_realm, RealmFilter, receives_offline_notifications, \
ScheduledJob, get_owned_bot_dicts, \
get_old_unclaimed_attachments, get_cross_realm_emails, receives_online_notifications, \
Reaction
Reaction, EmailChangeStatus

from zerver.lib.alert_words import alert_words_in_realm
from zerver.lib.avatar import get_avatar_url, avatar_url
Expand All @@ -49,8 +50,9 @@
from importlib import import_module
from django.core.mail import EmailMessage
from django.utils.timezone import now
from django.template.loader import render_to_string

from confirmation.models import Confirmation
from confirmation.models import Confirmation, EmailChangeConfirmation
import six
from six.moves import filter
from six.moves import map
Expand Down Expand Up @@ -690,14 +692,44 @@ def do_deactivate_stream(stream, log=True):

def do_change_user_email(user_profile, new_email):
# type: (UserProfile, Text) -> None
old_email = user_profile.email
user_profile.email = new_email
user_profile.save(update_fields=["email"])

payload = dict(new_email=new_email)
send_event(dict(type='update_email', op='complete', payload=payload),
[user_profile.id])

def do_start_email_change_process(user_profile, new_email):
# type: (UserProfile, Text) -> None
old_email = user_profile.email
user_profile.email = new_email

log_event({'type': 'user_email_changed',
'old_email': old_email,
'new_email': new_email})

context = {'support_email': settings.ZULIP_ADMINISTRATOR,
'verbose_support_offers': settings.VERBOSE_SUPPORT_OFFERS,
}

with transaction.atomic():
obj = EmailChangeStatus.objects.create(new_email=new_email,
old_email=old_email,
user_profile=user_profile)

EmailChangeConfirmation.objects.send_confirmation(
obj, new_email,
additional_context=context,
host=user_profile.realm.host,
subject_template_path='confirmation/confirm_new_email_address_subject.txt',
body_template_path='confirmation/confirm_new_email_address_body.txt')

subject = render_to_string(
'confirmation/notify_change_in_email_subject.txt')
body = render_to_string(
'confirmation/notify_change_in_email_body.txt', context)
send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [old_email])

def compute_irc_user_fullname(email):
# type: (NonBinaryStr) -> NonBinaryStr
return email.split("@")[0] + " (IRC)"
Expand Down
29 changes: 29 additions & 0 deletions zerver/migrations/0050_emailchangestatus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.4 on 2017-01-17 09:18
from __future__ import unicode_literals

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('zerver', '0049_userprofile_pm_content_in_desktop_notifications'),
]

operations = [
migrations.CreateModel(
name='EmailChangeStatus',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('new_email', models.EmailField(max_length=254)),
('old_email', models.EmailField(max_length=254)),
('updated_at', models.DateTimeField(auto_now=True)),
('status', models.IntegerField(default=0)),
('realm', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='zerver.Realm')),
('user_profile', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
]
12 changes: 12 additions & 0 deletions zerver/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,18 @@ class PreregistrationUser(models.Model):

realm = models.ForeignKey(Realm, null=True) # type: Optional[Realm]

class EmailChangeStatus(models.Model):
new_email = models.EmailField() # type: Text
old_email = models.EmailField() # type: Text
updated_at = models.DateTimeField(auto_now=True) # type: datetime.datetime
user_profile = models.ForeignKey(UserProfile) # type: UserProfile

# status: whether an object has been confirmed.
# if confirmed, set to confirmation.settings.STATUS_ACTIVE
status = models.IntegerField(default=0) # type: int

realm = models.ForeignKey(Realm, null=True) # type: Optional[Realm]

class PushDeviceToken(models.Model):
APNS = 1
GCM = 2
Expand Down
66 changes: 66 additions & 0 deletions zerver/tests/test_email_change.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
from __future__ import absolute_import

import datetime
from typing import Any

import django
import mock
from django.conf import settings
from django.http import HttpResponse
from django.urls import reverse

from confirmation.models import EmailChangeConfirmation, generate_key
from zerver.lib.actions import do_start_email_change_process
from zerver.lib.test_classes import (
ZulipTestCase,
)
from zerver.models import get_user_profile_by_email, EmailChangeStatus


class EmailChangeTestCase(ZulipTestCase):
def test_confirm_email_change_with_non_existent_key(self):
# type: () -> None
self.login('hamlet@zulip.com')
key = generate_key()
url = EmailChangeConfirmation.objects.get_activation_url(key)
response = self.client_get(url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/?ec=0#settings")

def test_confirm_email_change_with_invalid_key(self):
# type: () -> None
self.login('hamlet@zulip.com')
key = 'invalid key'
url = EmailChangeConfirmation.objects.get_activation_url(key)
response = self.client_get(url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/?ec=0#settings")

def test_confirm_email_change(self):
# type: () -> None
old_email = 'hamlet@zulip.com'
new_email = 'hamlet-new@zulip.com'
self.login(old_email)
user_profile = get_user_profile_by_email(old_email)
obj = EmailChangeStatus.objects.create(new_email=new_email,
old_email=old_email,
user_profile=user_profile)
key = generate_key()
EmailChangeConfirmation.objects.create(content_object=obj,
date_sent=datetime.datetime.now(),
confirmation_key=key)
url = EmailChangeConfirmation.objects.get_activation_url(key)
response = self.client_get(url)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/?ec=1#settings")
user_profile = get_user_profile_by_email(new_email)
self.assertTrue(bool(user_profile))
obj.refresh_from_db()
self.assertEqual(obj.status, 1)

def test_start_email_change_process(self):
# type: () -> None
user_profile = get_user_profile_by_email('hamlet@zulip.com')
do_start_email_change_process(user_profile, 'hamlet-new@zulip.com')
self.assertEqual(EmailChangeStatus.objects.count(), 1)
Loading

0 comments on commit a4a71dd

Please sign in to comment.