Repository: getsentry/sentry

Issue:
Email Improvements
- Require password confirmation (when available) to add an email -- this is a security concern as allowing someone to change an email without this means they can reset the account password. (https://hackerone.com/reports/176470)
- Tweak the UI so its just a list of emails with one active as primary. Secondary addresses can become primary. Alternatively, make the primary email a selector instead of a freeform text box. Alternative alternatively, keep it the same, but fix some behavior when trying to make an existing secondary a primary email address (you lose the old address)



Discussion:
Do we want users to be able to make non-verified emails primary, or only emails that have been verified?

So far I'm making the behavior look like this:

![screen shot 2016-10-19 at 1 32 11 pm](https://cloud.githubusercontent.com/assets/9269824/19536373/951cdee6-9600-11e6-9245-349c525ae66a.png)

> Do we want users to be able to make non-verified emails primary, or only emails that have been verified?

I'd say it should be verified. The only issue here is that we don't really _require_ email verification, so there will have to be the default behavior still of users only having one single email address. But I'd say to add an addiitonal and to make it default, it must be verified.

IMO require verification to make it a new primary email

@ckj can you help on UI thoughts here

I would also just put the verification part directly under new email (rather than having a header to separate it)

Another question:
Would this form be read-only for any users?

@kjlundsgaard so we do need to check "if user.has_usable_password(): # they can verify password" -- if thats not true we need to del the password field. Theres some others that do this for example.

@dcramer Yeah, I have that part covered. But the account settings form can be read only (if user.is_managed...) while the notification settings form currently is not.

@kjlundsgaard ah -- so i guess we shouldnt allow them to change primary email address if its managed, but they should be able to still add secondary addresses

General opinion: There's a lot going on, but I think there are some simple things we could do to make it better. Some thoughts in order of importance to me:
- Ideally, the primary email would be at the top of the list.
- "Set as primary email" should have the `btn-default` class.
- I think we could get rid of the 'Verified' badge... it's only worth highlighting the ones that require action
- 👍 on removing the verification header
- I'd consider making the 'Primary' badge text only. Something like "katie@sentry.io (Primary)" so again, there's less competing with the more important 'Unverified' badge
- We could probably do without `.table-bordered`. I'd wrap a standard `table.table` in a `.panel` so that you get the outer border without the other lines. (This one is the least important to me)


Resolving patch:
diff --git a/src/sentry/web/forms/accounts.py b/src/sentry/web/forms/accounts.py
--- a/src/sentry/web/forms/accounts.py
+++ b/src/sentry/web/forms/accounts.py
@@ -13,6 +13,7 @@
 from django import forms
 from django.conf import settings
 from django.contrib.auth import authenticate, get_user_model
+from django.db.models import Q
 from django.utils.text import capfirst
 from django.utils.translation import ugettext_lazy as _
 
@@ -233,7 +234,6 @@ def clean_password(self):
 
 
 class EmailForm(forms.Form):
-    primary_email = forms.EmailField(label=_('Primary Email'))
 
     alt_email = forms.EmailField(
         label=_('New Email'),
@@ -257,23 +257,6 @@ def __init__(self, user, *args, **kwargs):
         if not needs_password:
             del self.fields['password']
 
-    def save(self, commit=True):
-
-        if self.cleaned_data['primary_email'] != self.user.email:
-            new_username = self.user.email == self.user.username
-        else:
-            new_username = False
-
-        self.user.email = self.cleaned_data['primary_email']
-
-        if new_username and not User.objects.filter(username__iexact=self.user.email).exists():
-            self.user.username = self.user.email
-
-        if commit:
-            self.user.save()
-
-        return self.user
-
     def clean_password(self):
         value = self.cleaned_data.get('password')
         if value and not self.user.check_password(value):
@@ -337,7 +320,13 @@ def _clean_managed_field(self, field):
         return self.cleaned_data[field]
 
     def clean_email(self):
-        return self._clean_managed_field('email')
+        value = self._clean_managed_field('email').lower()
+        if User.objects.filter(Q(email__iexact=value) | Q(username__iexact=value)).exclude(id=self.user.id).exists():
+            raise forms.ValidationError(
+                _("There was an error adding %s: that email is already in use")
+                % self.cleaned_data['email']
+            )
+        return value
 
     def clean_name(self):
         return self._clean_managed_field('name')
@@ -642,7 +631,8 @@ def __init__(self, project, user, *args, **kwargs):
         specified_email = UserOption.objects.get_value(user, project, 'mail:email', None)
         emails.extend([user.email, alert_email, specified_email])
 
-        choices = [(email, email) for email in set(emails) if email is not None]
+        choices = [(email, email) for email in sorted(set(emails)) if email]
+
         self.fields['email'].choices = choices
 
         self.fields['alert'].initial = has_alerts
diff --git a/src/sentry/web/frontend/accounts.py b/src/sentry/web/frontend/accounts.py
--- a/src/sentry/web/frontend/accounts.py
+++ b/src/sentry/web/frontend/accounts.py
@@ -17,6 +17,7 @@
 from django.core.context_processors import csrf
 from django.core.urlresolvers import reverse
 from django.db import IntegrityError, transaction
+from django.db.models import Q
 from django.http import HttpResponseRedirect, Http404, HttpResponse
 from django.views.decorators.http import require_http_methods
 from django.views.decorators.cache import never_cache
@@ -29,7 +30,7 @@
 
 from sentry import newsletter
 from sentry.models import (
-    UserEmail, LostPasswordHash, Project, UserOption, Authenticator
+    User, UserEmail, LostPasswordHash, Project, UserOption, Authenticator
 )
 from sentry.security import capture_security_activity
 from sentry.signals import email_verified
@@ -240,11 +241,21 @@ def account_settings(request):
 
         form.save()
 
-        # remove previously valid email address
+        # update notification settings for those set to primary email with new primary email
+        alert_email = UserOption.objects.get_value(user=user, project=None, key='alert_email')
+
+        if alert_email == old_email:
+            UserOption.objects.set_value(user=user, project=None, key='alert_email', value=user.email)
+        options = UserOption.objects.filter(user=user, key='mail:email')
+        for option in options:
+            if option.value != old_email:
+                continue
+            option.value = user.email
+            option.save()
+
         # TODO(dcramer): we should maintain validation here when we support
         # multiple email addresses
         if request.user.email != old_email:
-            UserEmail.objects.filter(user=user, email=old_email).delete()
             try:
                 with transaction.atomic():
                     user_email = UserEmail.objects.create(
@@ -442,14 +453,10 @@ def disconnect_identity(request, identity_id):
 @login_required
 def show_emails(request):
     user = request.user
+    emails = user.emails.all()
+    email_form = EmailForm(user, request.POST or None)
     primary_email = UserEmail.get_primary_email(user)
-    alt_emails = user.emails.all().exclude(email=primary_email.email)
-
-    email_form = EmailForm(user, request.POST or None,
-        initial={
-            'primary_email': primary_email.email,
-        },
-    )
+    alt_emails = emails.exclude(email=primary_email.email)
 
     if 'remove' in request.POST:
         email = request.POST.get('email')
@@ -463,75 +470,79 @@ def show_emails(request):
 
         return HttpResponseRedirect(request.path)
 
+    if 'primary' in request.POST:
+        new_primary = request.POST['new_primary_email'].lower()
+
+        if User.objects.filter(Q(email__iexact=new_primary) | Q(username__iexact=new_primary)).exclude(id=user.id).exists():
+            messages.add_message(request,
+                messages.ERROR,
+                _("That email is already in use for another user")
+            )
+
+        elif new_primary != user.email:
+
+            # update notification settings for those set to primary email with new primary email
+            alert_email = UserOption.objects.get_value(user=user, project=None, key='alert_email')
+
+            if alert_email == user.email:
+                UserOption.objects.set_value(user=user, project=None, key='alert_email', value=new_primary)
+            options = UserOption.objects.filter(user=user, key='mail:email')
+            for option in options:
+                if option.value != user.email:
+                    continue
+                option.value = new_primary
+                option.save()
+
+            has_new_username = user.email == user.username
+
+            user.email = new_primary
+
+            msg = _('Your settings were saved')
+            messages.add_message(
+                request,
+                messages.SUCCESS,
+                msg)
+
+            if has_new_username and not User.objects.filter(username__iexact=new_primary).exists():
+                user.username = user.email
+            user.save()
+        return HttpResponseRedirect(request.path)
+
     if email_form.is_valid():
-        old_email = user.email
 
-        email_form.save()
+        alternative_email = email_form.cleaned_data['alt_email'].lower()
 
-        if user.email != old_email:
-            queryset = UserOption.objects.filter(
-                user=user,
-                key='mail:email',
-            )
-            for option in queryset:
-                if option.value == old_email:
-                    option.value = user.email
-                    option.save()
-            UserEmail.objects.filter(user=user, email=old_email).delete()
+        # check if this alternative email already exists for user
+        if alternative_email and not UserEmail.objects.filter(user=user, email__iexact=alternative_email):
+            # create alternative email for user
             try:
                 with transaction.atomic():
-                    user_email = UserEmail.objects.create(
+                    new_email = UserEmail.objects.create(
                         user=user,
-                        email=user.email,
+                        email=alternative_email
                     )
             except IntegrityError:
                 pass
             else:
-                user_email.set_hash()
-                user_email.save()
-                user.send_confirm_email_singular(user_email)
+                new_email.set_hash()
+                new_email.save()
+                user.send_confirm_email_singular(new_email)
                 # Update newsletter subscription and mark as unverified
                 newsletter.update_subscription(
                     user=user,
                     verified=False,
                 )
+
                 logger.info('user.email.add', extra={
                     'user_id': user.id,
                     'ip_address': request.META['REMOTE_ADDR'],
-                    'email': user_email.email,
+                    'email': new_email.email,
                 })
-                msg = _('A confirmation email has been sent to %s.') % user_email.email
+                msg = _('A confirmation email has been sent to %s.') % new_email.email
                 messages.add_message(
                     request,
                     messages.SUCCESS,
                     msg)
-        alternative_email = email_form.cleaned_data['alt_email']
-        # check if this alternative email already exists for user
-        if alternative_email and not UserEmail.objects.filter(user=user, email=alternative_email):
-            # create alternative email for user
-            try:
-                with transaction.atomic():
-                    new_email = UserEmail.objects.create(
-                        user=user,
-                        email=alternative_email
-                    )
-            except IntegrityError:
-                pass
-            else:
-                new_email.set_hash()
-                new_email.save()
-            # send confirmation emails to any non verified emails
-            user.send_confirm_email_singular(new_email)
-            logger.info('user.email.add', extra={
-                'user_id': user.id,
-                'ip_address': request.META['REMOTE_ADDR'],
-                'email': new_email.email,
-            })
-            msg = _('A confirmation email has been sent to %s.') % new_email.email
-            messages.add_message(
-                request,
-                messages.SUCCESS,
-                msg)
 
         messages.add_message(
             request, messages.SUCCESS, _('Your settings were saved.'))


Test patch:
diff --git a/tests/sentry/web/frontend/test_emails.py b/tests/sentry/web/frontend/test_emails.py
--- a/tests/sentry/web/frontend/test_emails.py
+++ b/tests/sentry/web/frontend/test_emails.py
@@ -18,7 +18,6 @@ def test_render_emails(self):
         resp = self.client.get(self.path)
         assert resp.status_code == 200
         self.assertTemplateUsed('sentry/account/settings/emails.html')
-        assert 'alt_emails' in resp.context
         assert 'primary_email' in resp.context
         self.assertIn('foo@example.com', resp.content)
 
@@ -37,7 +36,6 @@ def test_create_alt_email_with_password(self):
         user.set_password('something')
         user.save()
         resp = self.client.post(self.path, data={
-            'primary_email': user.email,
             'alt_email': 'hello@gmail.com',
             'password': 'something'},
             follow=True
@@ -53,8 +51,8 @@ def test_fail_to_create_email_without_pw(self):
         user.set_password('something')
         user.save()
         resp = self.client.post(self.path, data={
-            'primary_email': user.email,
-            'alt_email': 'hello@gmail.com'},
+            'alt_email': 'hello@gmail.com',
+        },
             follow=True
         )
         assert resp.status_code == 200
@@ -68,8 +66,7 @@ def test_create_alt_email_without_usable_pw(self):
         user.set_unusable_password()
         user.save()
         resp = self.client.post(self.path, data={
-            'primary_email': user.email,
-            'alt_email': 'hello@gmail.com'
+            'alt_email': 'hello@gmail.com',
         },
             follow=True
         )
@@ -92,16 +89,24 @@ def test_remove_alt_email(self):
     def test_change_primary_email(self):
         user = self.create_user('foo@example.com')
         self.login_as(user)
-        user.set_password('something')
-        user.save()
         resp = self.client.get(self.path)
         self.assertIn('foo@example.com', resp.content)
         resp = self.client.post(self.path,
-            {'primary_email': 'bar@example.com',
-             'password': 'something'},
+            {'primary': '',
+             'new_primary_email': 'bar@example.com'},
             follow=True
         )
         self.assertIn('bar@example.com', resp.content)
         user = User.objects.get(id=user.id)
         assert user.email != 'foo@example.com'
         assert user.email == 'bar@example.com'
+
+    def test_username_updates(self):
+        user = self.create_user('foo@example.com')
+        self.login_as(user)
+        email = UserEmail(user=user, email='bar@example.com')
+        email.save()
+        self.client.post(self.path, data={'primary': '', 'new_primary_email': 'bar@example.com'}, follow=True)
+        user = User.objects.get(id=user.id)
+        assert user.username != 'foo@example.com'
+        assert user.username == 'bar@example.com'