Repository: getsentry/sentry

Issue:
Double verify new password when changing password
Right now, there's only one field for "New Password". If you quickly typed a password wrong, there's no way to really know since we don't require a double verification.

Maybe we should tuck this away under a "Change Password" CTA to avoid the clutter?

/cc @kjlundsgaard @ckj 


Discussion:
+1 on this - what do we think this should look like? A "change password" link that generates the fields New Password, Confirm New Password, Confirm Old Password?

Literally no idea. @ckj has ideas about how things look.

@kjlundsgaard Another option could be to add a change password section under the security tab and have that link point there. That's how Dropbox does it and kind of how GitHub does it:

![screen shot 2016-11-15 at 3 04 44 pm](https://cloud.githubusercontent.com/assets/30713/20328080/357e6400-ab45-11e6-8705-d753a47ef591.png)

Should roughly look like this (mocked in web inspector):

<img width="773" alt="image" src="https://user-images.githubusercontent.com/2153/33857241-b5dfe880-de7f-11e7-8513-35317946e0d5.png">


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
@@ -316,6 +316,11 @@ class AccountSettingsForm(forms.Form):
         required=False,
         # help_text=password_validation.password_validators_help_text_html(),
     )
+    verify_new_password = forms.CharField(
+        label=_('Verify new password'),
+        widget=forms.PasswordInput(),
+        required=False,
+    )
     password = forms.CharField(
         label=_('Current password'),
         widget=forms.PasswordInput(),
@@ -340,6 +345,7 @@ def __init__(self, user, request, *args, **kwargs):
                     needs_password = False
 
             del self.fields['new_password']
+            del self.fields['verify_new_password']
 
         # don't show username field if its the same as their email address
         if self.user.email == self.user.username:
@@ -393,6 +399,19 @@ def clean_password(self):
             raise forms.ValidationError('You must confirm your current password to make changes.')
         return value
 
+    def clean_verify_new_password(self):
+        new_password = self.cleaned_data.get('new_password')
+
+        if new_password:
+            verify_new_password = self.cleaned_data.get('verify_new_password')
+            if verify_new_password is None:
+                raise forms.ValidationError('You must verify your new password.')
+
+            if new_password != verify_new_password:
+                raise forms.ValidationError('Your new password and verify new password must match.')
+
+            return verify_new_password
+
     def clean_new_password(self):
         new_password = self.cleaned_data.get('new_password')
         if new_password:


Test patch:
diff --git a/tests/sentry/web/frontend/accounts/tests.py b/tests/sentry/web/frontend/accounts/tests.py
--- a/tests/sentry/web/frontend/accounts/tests.py
+++ b/tests/sentry/web/frontend/accounts/tests.py
@@ -139,6 +139,7 @@ def test_can_change_password_with_password(self):
         params = self.params()
         params['password'] = 'admin'
         params['new_password'] = 'foobar'
+        params['verify_new_password'] = 'foobar'
 
         resp = self.client.post(self.path, params)
         assert resp.status_code == 302
@@ -151,6 +152,7 @@ def test_cannot_change_password_with_invalid_password(self):
 
         params = self.params()
         params['new_password'] = 'foobar'
+        params['verify_new_password'] = 'foobar'
 
         resp = self.client.post(self.path, params)
         assert resp.status_code == 200
@@ -168,6 +170,7 @@ def test_cannot_change_password_with_managed_user(self):
         params['email'] = user.email
         params['password'] = 'admin'
         params['new_password'] = 'foobar'
+        params['verify_new_password'] = 'foobar'
 
         resp = self.client.post(self.path, params)
         assert resp.status_code == 302
@@ -212,6 +215,62 @@ def test_cannot_change_email_with_invalid_password(self):
         user = User.objects.get(id=self.user.id)
         assert user.email == 'admin@localhost'
 
+    def test_settings_renders_with_verify_new_password(self):
+        user = self.create_user('foo@example.com')
+        self.login_as(user)
+        path = reverse('sentry-account-settings')
+        resp = self.client.get(path)
+        assert resp.status_code == 200
+        self.assertTemplateUsed('sentry/account/settings.html')
+        form = resp.context['form']
+        assert form.errors == {}
+        for field in ('name', 'email', 'new_password', 'verify_new_password', 'password'):
+            assert field in form.fields
+
+        self.assertContains(resp, 'New password')
+        self.assertContains(resp, 'Verify new password')
+
+    def test_settings_renders_without_new_password(self):
+        user = self.create_user('foo@example.com')
+        user.update(is_managed=True)
+        self.login_as(user)
+        path = reverse('sentry-account-settings')
+        resp = self.client.get(path)
+        assert resp.status_code == 200
+        self.assertTemplateUsed('sentry/account/settings.html')
+        form = resp.context['form']
+        assert form.errors == {}
+        for field in ('new_password', 'verify_new_password'):
+            assert field not in form.fields
+
+    def test_cannot_change_password_without_verify_password(self):
+        self.login_as(self.user)
+
+        params = self.params()
+        params['new_password'] = 'foobar'
+
+        resp = self.client.post(self.path, params)
+        assert resp.status_code == 200
+        self.assertTemplateUsed('sentry/account/settings.html')
+        assert resp.context['form'].errors
+        user = User.objects.get(id=self.user.id)
+        assert not user.check_password('foobar')
+
+    def test_cannot_change_password_with_unequal_verify_password(self):
+        self.login_as(self.user)
+
+        params = self.params()
+        params['new_password'] = 'foobar'
+        params['verify_new_password'] = 'foobars'
+
+        resp = self.client.post(self.path, params)
+        assert resp.status_code == 200
+        self.assertTemplateUsed('sentry/account/settings.html')
+        assert resp.context['form'].errors
+        user = User.objects.get(id=self.user.id)
+        assert not user.check_password('foobar')
+        assert not user.check_password('foobars')
+
 
 class ListIdentitiesTest(TestCase):
     @fixture