diff --git a/confirmation/migrations/0003_emailchangeconfirmation.py b/confirmation/migrations/0003_emailchangeconfirmation.py new file mode 100644 index 00000000000000..cd7c126ab9d97f --- /dev/null +++ b/confirmation/migrations/0003_emailchangeconfirmation.py @@ -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',), + ), + ] diff --git a/confirmation/models.py b/confirmation/models.py index 1a304cae377cf7..781e8ab7220a79 100644 --- a/confirmation/models.py +++ b/confirmation/models.py @@ -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 B16_RE = re.compile('^[a-f0-9]{40}$') @@ -59,7 +59,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) @@ -139,6 +139,19 @@ def send_confirmation(self, obj, email_address, additional_context=None, send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [email_address], html_message=html_content) 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})) + + def get_link_validity_in_days(self): + # type: () -> int + return getattr(settings, 'EMAIL_CHANGE_CONFIRMATION_DAYS', 1) class Confirmation(models.Model): content_type = models.ForeignKey(ContentType) @@ -157,6 +170,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) diff --git a/static/js/settings.js b/static/js/settings.js index e91a073696b8ba..740c26d3587965 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -517,12 +517,44 @@ function _setup_page() { }); }); + $('#change_email_button').on('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + $('#change_email_modal').modal('hide'); + + var data = {}; + data.email = $('.email_change_container').find("input[name='email']").val(); + + channel.patch({ + url: '/json/settings/change', + data: data, + success: function (data) { + if ('account.email' in data) { + settings_change_success(data['account.email']); + } else { + settings_change_error(i18n.t("Error changing settings: No new data supplied.")); + } + }, + error: function (xhr) { + settings_change_error("Error changing settings", xhr); + }, + }); + }); + $('#default_language').on('click', function (e) { e.preventDefault(); e.stopPropagation(); $('#default_language_modal').show().attr('aria-hidden', false); }); + $('#change_email').on('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + $('#change_email_modal').modal('show'); + var email = $('#email_value').text(); + $('.email_change_container').find("input[name='email']").val(email); + }); + $("#user_deactivate_account_button").on('click', function (e) { e.preventDefault(); e.stopPropagation(); diff --git a/static/templates/settings/account-settings.handlebars b/static/templates/settings/account-settings.handlebars index da6abc9a4d07a2..493ba73a356cda 100644 --- a/static/templates/settings/account-settings.handlebars +++ b/static/templates/settings/account-settings.handlebars @@ -4,6 +4,31 @@ {{t "Your account" }}
+
+

+ {{t "Email" }}: {{page_params.email}} + [Change] +

+ + +
+
diff --git a/templates/confirmation/confirm_email_change.html b/templates/confirmation/confirm_email_change.html new file mode 100644 index 00000000000000..db0287057a467c --- /dev/null +++ b/templates/confirmation/confirm_email_change.html @@ -0,0 +1,32 @@ +{% extends "zerver/portico.html" %} + +{% block portico_content %} + +
+
+{% if confirmed %} +

Your new email address is confirmed.

+{% else %} + +

Whoops, something's not right. We couldn't find your confirmation ID!

+ + {% if verbose_support_offers %} +

Make sure you copied the link correctly in to your browser. If you're + still encountering this page, its probably our fault. We're sorry.

+ +

Anyway, shoot us a line at + {{ support_email }} + and we'll get this resolved shortly. +

+ {% else %} +

Make sure you copied the link correctly in to your browser.

+ +

If you're still having problems, please contact your Zulip administrator at + {{ support_email }}. +

+ {% endif %} + +{% endif %} +
+ +{% endblock %} diff --git a/templates/confirmation/emailchangestatus_confirmation_email.html b/templates/confirmation/emailchangestatus_confirmation_email.html new file mode 100644 index 00000000000000..48befe904e3d9e --- /dev/null +++ b/templates/confirmation/emailchangestatus_confirmation_email.html @@ -0,0 +1,38 @@ + + + + + Zulip + + + + + + +
+

Hi! +

+ +

Our records show that you requested a change to the email address + you use to sign in to Zulip. Please click the link below to activate + your new address. +
+ {{ activate_url }} +

+ +

+ {% if verbose_support_offers %} + Feel free to give us a shout at {{ support_email }}, if you have any questions. + {% else %} + If you did not request this change, please contact your Zulip + administrator at {{ support_email }}. + {% endif %} +

+ +

+ Cheers,
+ The Zulip Team +

+
+ + diff --git a/templates/confirmation/emailchangestatus_confirmation_email.subject b/templates/confirmation/emailchangestatus_confirmation_email.subject new file mode 100644 index 00000000000000..b2744d3bd788e1 --- /dev/null +++ b/templates/confirmation/emailchangestatus_confirmation_email.subject @@ -0,0 +1 @@ +[Zulip] Confirm your new email address for {{ realm.name }} diff --git a/templates/confirmation/emailchangestatus_confirmation_email.txt b/templates/confirmation/emailchangestatus_confirmation_email.txt new file mode 100644 index 00000000000000..0e8cb2fcd5eeea --- /dev/null +++ b/templates/confirmation/emailchangestatus_confirmation_email.txt @@ -0,0 +1,17 @@ +Hi! + +Our records show that you requested a change to the email address you use to sign in to Zulip. +Please click the link below to activate your new address. + +{{ activate_url }} + + +{% if verbose_support_offers %} +Feel free to give us a shout at <{{ support_email }}> if you have any questions. +{% else %} +If you did not request this change, please contact your Zulip administrator at <{{ support_email }}>. +{% endif %} + +Cheers, + +The Zulip Team diff --git a/templates/confirmation/notify_change_in_email_body.txt b/templates/confirmation/notify_change_in_email_body.txt new file mode 100644 index 00000000000000..0b97bf37222c92 --- /dev/null +++ b/templates/confirmation/notify_change_in_email_body.txt @@ -0,0 +1,9 @@ +Hi, + +We just wanted to let you know that the email associated with your Zulip account +was recently changed to {{ new_email }}. If you did not request this change, +please contact us immediately at <{{ support_email }}>. + +Best, + +The Zulip Team diff --git a/templates/confirmation/notify_change_in_email_subject.txt b/templates/confirmation/notify_change_in_email_subject.txt new file mode 100644 index 00000000000000..61599fd659dc48 --- /dev/null +++ b/templates/confirmation/notify_change_in_email_subject.txt @@ -0,0 +1 @@ +[Zulip] Email address changed for {{ realm.name }} diff --git a/tools/lint-all b/tools/lint-all index 012b03f4da6813..c6d349b36b7c21 100755 --- a/tools/lint-all +++ b/tools/lint-all @@ -375,6 +375,8 @@ def build_custom_checkers(by_lang): 'return json_error(data=error_data, msg=ret_error)'), ('zerver/views/streams.py', 'return json_error(property_conversion)'), ('zerver/views/streams.py', 'return json_error(e.error, data=result, status=404)'), + # 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_filters.py', 'return json_error(e.messages[0], data={"errors": dict(e)})'), ]), diff --git a/zerver/lib/actions.py b/zerver/lib/actions.py index 0f774629b35c7c..75705623fc1508 100644 --- a/zerver/lib/actions.py +++ b/zerver/lib/actions.py @@ -37,7 +37,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 avatar_url @@ -50,7 +50,7 @@ from django.core.mail import EmailMessage from django.utils.timezone import now -from confirmation.models import Confirmation +from confirmation.models import Confirmation, EmailChangeConfirmation import six from six.moves import filter from six.moves import map @@ -694,14 +694,39 @@ 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='update', 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, + 'realm': user_profile.realm, + } + + 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, + ) + + def compute_irc_user_fullname(email): # type: (NonBinaryStr) -> NonBinaryStr return email.split("@")[0] + " (IRC)" diff --git a/zerver/migrations/0053_emailchangestatus.py b/zerver/migrations/0053_emailchangestatus.py new file mode 100644 index 00000000000000..88810305e094d5 --- /dev/null +++ b/zerver/migrations/0053_emailchangestatus.py @@ -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', '0052_auto_fix_realmalias_realm_nullable'), + ] + + 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)), + ], + ), + ] diff --git a/zerver/models.py b/zerver/models.py index 122a229026a237..fc4851b62b20f5 100644 --- a/zerver/models.py +++ b/zerver/models.py @@ -678,6 +678,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 diff --git a/zerver/tests/test_email_change.py b/zerver/tests/test_email_change.py new file mode 100644 index 00000000000000..c1c0d0af8869e9 --- /dev/null +++ b/zerver/tests/test_email_change.py @@ -0,0 +1,130 @@ +# -*- 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.core import mail +from django.http import HttpResponse +from django.urls import reverse +from django.utils.timezone import now + +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, 200) + self.assertIn("Whoops", response.content.decode('utf8')) + + 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, 200) + self.assertIn("Whoops", response.content.decode('utf8')) + + def test_confirm_email_change_when_time_exceeded(self): + # type: () -> None + old_email = 'hamlet@zulip.com' + new_email = 'hamlet-new@zulip.com' + 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() + date_sent = now() - datetime.timedelta(days=2) + EmailChangeConfirmation.objects.create(content_object=obj, + date_sent=date_sent, + confirmation_key=key) + url = EmailChangeConfirmation.objects.get_activation_url(key) + response = self.client_get(url) + self.assertEqual(response.status_code, 200) + self.assertIn("Whoops", response.content.decode('utf8')) + + def test_confirm_email_change(self): + # type: () -> None + old_email = 'hamlet@zulip.com' + new_email = 'hamlet-new@zulip.com' + 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=now(), + confirmation_key=key) + url = EmailChangeConfirmation.objects.get_activation_url(key) + response = self.client_get(url) + self.assertEqual(response.status_code, 200) + self.assertIn("Your new email address is confirmed", + response.content.decode('utf8')) + 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) + + def test_end_to_end_flow(self): + # type: () -> None + data = {'email': 'hamlet-new@zulip.com'} + email = 'hamlet@zulip.com' + self.login(email) + url = '/json/settings/change' + self.assertEqual(len(mail.outbox), 0) + result = self.client_post(url, data) + self.assertEqual(len(mail.outbox), 1) + self.assertIn('We have sent you an email', result.content.decode('utf8')) + email_message = mail.outbox[0] + self.assertEqual( + email_message.subject, + '[Zulip] Confirm your new email address for Zulip Dev' + ) + body = email_message.body + self.assertIn('Our records show that you requested a change', body) + + activation_url = [s for s in body.split('\n') if s][3] + response = self.client_get(activation_url) + + self.assertEqual(response.status_code, 200) + self.assertIn("Your new email address is confirmed", + response.content.decode('utf8')) + + def test_post_invalid_email(self): + # type: () -> None + data = {'email': 'hamlet-new'} + email = 'hamlet@zulip.com' + self.login(email) + url = '/json/settings/change' + result = self.client_post(url, data) + self.assertIn('Invalid address', result.content.decode('utf8')) + + def test_post_same_email(self): + # type: () -> None + data = {'email': 'hamlet@zulip.com'} + email = 'hamlet@zulip.com' + self.login(email) + url = '/json/settings/change' + result = self.client_post(url, data) + self.assertEqual('success', result.json()['result']) + self.assertEqual('', result.json()['msg']) diff --git a/zerver/tests/test_templates.py b/zerver/tests/test_templates.py index af40d12c5a0d4e..feb9bc808a5b23 100644 --- a/zerver/tests/test_templates.py +++ b/zerver/tests/test_templates.py @@ -83,6 +83,8 @@ def test_templates(self): 'confirmation/mituser_confirmation_email_subject.txt', 'confirmation/mituser_invite_email_body.txt', 'confirmation/mituser_invite_email_subject.txt', + 'confirmation/emailchangestatus_confirmation_email.subject', + 'confirmation/notify_change_in_email_subject.txt', 'corporate/mit.html', 'corporate/privacy.html', 'corporate/zephyr.html', diff --git a/zerver/views/user_settings.py b/zerver/views/user_settings.py index f94cc68a628fb8..3d4994278e4c03 100644 --- a/zerver/views/user_settings.py +++ b/zerver/views/user_settings.py @@ -5,7 +5,11 @@ from django.utils.translation import ugettext as _ from django.conf import settings from django.contrib.auth import authenticate, update_session_auth_hash +from django.core.mail import send_mail from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect, render_to_response +from django.template.loader import render_to_string +from django.urls import reverse from zerver.decorator import authenticated_json_post_view, has_request_variables, REQ from zerver.lib.actions import do_change_password, \ @@ -17,7 +21,8 @@ do_change_enable_stream_desktop_notifications, do_change_enable_stream_sounds, \ do_regenerate_api_key, do_change_avatar_fields, do_change_twenty_four_hour_time, \ do_change_left_side_userlist, do_change_default_language, \ - do_change_pm_content_in_desktop_notifications + do_change_pm_content_in_desktop_notifications, validate_email, \ + do_change_user_email, do_start_email_change_process from zerver.lib.avatar import avatar_url from zerver.lib.i18n import get_available_language_codes from zerver.lib.response import json_success, json_error @@ -25,7 +30,37 @@ from zerver.lib.validator import check_bool, check_string from zerver.lib.request import JsonableError from zerver.lib.users import check_change_full_name -from zerver.models import UserProfile, Realm, name_changes_disabled +from zerver.models import UserProfile, Realm, name_changes_disabled, \ + EmailChangeStatus +from confirmation.models import EmailChangeConfirmation + +def confirm_email_change(request, confirmation_key): + # type: (HttpRequest, str) -> HttpResponse + confirmation_key = confirmation_key.lower() + obj = EmailChangeConfirmation.objects.confirm(confirmation_key) + confirmed = False + if obj: + confirmed = True + assert isinstance(obj, EmailChangeStatus) + do_change_user_email(obj.user_profile, obj.new_email) + + context = {'support_email': settings.ZULIP_ADMINISTRATOR, + 'verbose_support_offers': settings.VERBOSE_SUPPORT_OFFERS, + 'realm': obj.user_profile.realm, + 'new_email': obj.new_email, + } + subject = render_to_string( + 'confirmation/notify_change_in_email_subject.txt', context) + body = render_to_string( + 'confirmation/notify_change_in_email_body.txt', context) + send_mail(subject, body, settings.DEFAULT_FROM_EMAIL, [obj.old_email]) + + ctx = { + 'confirmed': confirmed, + 'support_email': settings.ZULIP_ADMINISTRATOR, + 'verbose_support_offers': settings.VERBOSE_SUPPORT_OFFERS, + } + return render_to_response('confirmation/confirm_email_change.html', ctx) @has_request_variables def json_change_ui_settings(request, user_profile, @@ -53,11 +88,12 @@ def json_change_ui_settings(request, user_profile, @has_request_variables def json_change_settings(request, user_profile, full_name=REQ(default=""), + email=REQ(default=""), old_password=REQ(default=""), new_password=REQ(default=""), confirm_password=REQ(default="")): - # type: (HttpRequest, UserProfile, Text, Text, Text, Text) -> HttpResponse - if not (full_name or new_password): + # type: (HttpRequest, UserProfile, Text, Text, Text, Text, Text) -> HttpResponse + if not (full_name or new_password or email): return json_error(_("No new data supplied")) if new_password != "" or confirm_password != "": @@ -82,6 +118,16 @@ def json_change_settings(request, user_profile, request.session.save() result = {} + new_email = email.strip() + if user_profile.email != email and new_email != '': + error, skipped = validate_email(user_profile, new_email) + if error or skipped: + return json_error(error or skipped) + + do_start_email_change_process(user_profile, new_email) + result['account.email'] = _('We have sent you an email on your ' + 'new email address for confirmation.') + if user_profile.full_name != full_name and full_name.strip() != "": if name_changes_disabled(user_profile.realm): # Failingly silently is fine -- they can't do it through the UI, so diff --git a/zproject/urls.py b/zproject/urls.py index 3383a18176cc55..4d4a22ba6141a8 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -25,6 +25,7 @@ import zerver.views.users import zerver.views.unsubscribe import zerver.views.integrations +import zerver.views.user_settings import confirmation.views from zerver.lib.rest import rest_dispatch @@ -103,6 +104,10 @@ name='zerver.views.registration.accounts_register'), url(r'^accounts/do_confirm/(?P[\w]+)', confirmation.views.confirm, name='confirmation.views.confirm'), + url(r'^accounts/confirm_new_email/(?P[\w]+)', + zerver.views.user_settings.confirm_email_change, + name='zerver.views.user_settings.confirm_email_change'), + # Email unsubscription endpoint. Allows for unsubscribing from various types of emails, # including the welcome emails (day 1 & 2), missed PMs, etc. url(r'^accounts/unsubscribe/(?P[\w]+)/(?P[\w]+)',