Repository: PostHog/posthog
Pull request: #167 Add change user password form
Fixes #167 

## Description
Included a change password form in the setup scene. 

- When password submission is successful the user is redirected to the login page after 2 seconds. A successful toast is displayed.
- When the password submission fails an error toast  is displayed.

## Screenshot 
![image](https://user-images.githubusercontent.com/1487006/78408827-697f7c80-7608-11ea-8c6d-93f43a543d24.png)



Problem statement:
Need ability to change password
Installed on heroku this morning. Set a haphazard password since I was just checking it out.

After setting it up a bit, I realized there doesn't seem to be a way to reset or change the password.

Hints from issue discussion:
Thanks for filing this bug, you're absolutely right and it's now on the list :)

If need to do this now, you can
- Go to your app on Heroku
- Click "More" in the top right
- Click "run console"
- Enter `python manage.py changepassword [your email]`
- Change your password

Let me know if that worked
 
Thanks for the workaround. It worked great! 

I was trying to fumble my way through using the django-admin shell to find and update the user record.

Commit message: Fix lint errors

Patch to review:
diff --git a/frontend/src/scenes/setup/ChangePassword.js b/frontend/src/scenes/setup/ChangePassword.js
new file mode 100644
index 0000000000000..dd472b52ee007
--- /dev/null
+++ b/frontend/src/scenes/setup/ChangePassword.js
@@ -0,0 +1,72 @@
+import React, { Component } from 'react'
+import { toast } from 'react-toastify'
+import api from '../../lib/api'
+
+export class ChangePassword extends Component {
+    constructor(props) {
+        super(props)
+        this.state = {
+            oldPassword: '',
+            newPassword: '',
+        }
+    }
+
+    handleChange = event => {
+        this.setState({
+            [event.target.name]: event.target.value,
+        })
+    }
+
+    save = async event => {
+        event.preventDefault()
+        try {
+            await api.update('api/user/change_password', {
+                oldPassword: this.state.oldPassword,
+                newPassword: this.state.newPassword,
+            })
+            toast.success('Password changed')
+            // Give the user enough time to see the "Password change" toast
+            setTimeout(() => {
+                window.location.reload()
+            }, 2000)
+        } catch (response) {
+            toast.error(response.error)
+        } finally {
+            this.setState({
+                oldPassword: '',
+                newPassword: '',
+            })
+        }
+    }
+
+    render() {
+        return (
+            <form id="change-password" onSubmit={this.save}>
+                <label>Old Password</label>
+                <input
+                    name="oldPassword"
+                    required
+                    type="password"
+                    className="form-control"
+                    onChange={this.handleChange}
+                    value={this.state.oldPassword}
+                    style={{ maxWidth: 400 }}
+                />
+                <label>New Password</label>
+                <input
+                    name="newPassword"
+                    required
+                    type="password"
+                    className="form-control"
+                    onChange={this.handleChange}
+                    value={this.state.newPassword}
+                    style={{ maxWidth: 400 }}
+                />
+                <br />
+                <button type="submit" className="btn btn-outline-primary">
+                    Change Password
+                </button>
+            </form>
+        )
+    }
+}
diff --git a/frontend/src/scenes/setup/Setup.js b/frontend/src/scenes/setup/Setup.js
index 5b1d6afc128dd..7ea01e1cac2b5 100644
--- a/frontend/src/scenes/setup/Setup.js
+++ b/frontend/src/scenes/setup/Setup.js
@@ -6,7 +6,8 @@ import { OptOutCapture } from './OptOutCapture'
 import { SetupAppUrls } from './SetupAppUrls'
 
 import { userLogic } from '../userLogic'
-import { DeleteDemoData } from './DeleteDemoData';
+import { DeleteDemoData } from './DeleteDemoData'
+import { ChangePassword } from './ChangePassword'
 
 export function Setup() {
     const { user } = useValues(userLogic)
@@ -18,25 +19,23 @@ export function Setup() {
             <br />
             <br />
             <h2>Integrate PostHog</h2>
-            To integrate PostHog, copy + paste the following snippet to your
-            website. Ideally, put it just above the{' '}
+            To integrate PostHog, copy + paste the following snippet to your website. Ideally, put it just above the{' '}
             <pre style={{ display: 'inline' }}>&lt;/head&gt;</pre> tag.
             <a href="https://github.com/PostHog/posthog/wiki/JS-integration">
                 See docs for instructions on how to identify users.
             </a>
             <br />
             <JSSnippet user={user} />
-            <a href="https://github.com/PostHog/posthog/wiki/Integrations">
-                Using Python/Ruby/Node/Go/PHP instead?
-            </a>
+            <a href="https://github.com/PostHog/posthog/wiki/Integrations">Using Python/Ruby/Node/Go/PHP instead?</a>
             <br />
             <br />
             <br />
             <h2>API key</h2>
-            You can use this api key in any of our   
+            You can use this api key in any of our
             <a href="https://github.com/PostHog/posthog/wiki/Integrations"> libraries</a>.
-            <pre className='code'>{user.team.api_token}</pre>
-            This key is write-only, in that it can only create new events. It can't read any events or any of your other data stored on PostHog.
+            <pre className="code">{user.team.api_token}</pre>
+            This key is write-only, in that it can only create new events. It can't read any events or any of your other
+            data stored on PostHog.
             <br />
             <br />
             <br />
@@ -48,13 +47,16 @@ export function Setup() {
             </div>
             <br />
             <br />
-
             <h2>Delete HogFlix demo data</h2>
             <DeleteDemoData />
             <br />
             <br />
             <br />
-
+            <h2>Change Password</h2>
+            <ChangePassword />
+            <br />
+            <br />
+            <br />
             <h2>Opt out of capturing</h2>
             <OptOutCapture />
         </div>
diff --git a/posthog/api/test/base.py b/posthog/api/test/base.py
index 3c61ed0f2757e..198179c6e365d 100644
--- a/posthog/api/test/base.py
+++ b/posthog/api/test/base.py
@@ -5,6 +5,8 @@
 
 class BaseTest(TestCase):
     TESTS_API: bool = False
+    TESTS_PASSWORD: str = 'testpassword12345'
+
     def _create_user(self, email, **kwargs) -> User:
         user = User.objects.create_user(email, **kwargs)
         if not hasattr(self, 'team'):
@@ -18,6 +20,5 @@ def setUp(self):
         self.team: Team = Team.objects.create(api_token='token123')
         if self.TESTS_API:
             self.client = Client()
-            self.user = self._create_user('user1')
+            self.user = self._create_user('user1', password=self.TESTS_PASSWORD)
             self.client.force_login(self.user)
-    
\ No newline at end of file
diff --git a/posthog/api/test/test_user.py b/posthog/api/test/test_user.py
index d54555a7c3338..37ba85d3bb7b0 100644
--- a/posthog/api/test/test_user.py
+++ b/posthog/api/test/test_user.py
@@ -23,6 +23,41 @@ def test_create_user_with_distinct_id(self):
         self.assertNotEqual(user.distinct_id, '')
         self.assertNotEqual(user.distinct_id, None)
 
+
+class TestUserChangePassword(BaseTest):
+    TESTS_API = True
+    ENDPOINT:str = '/api/user/change_password/'
+
+    def send_request(self, payload):
+        return self.client.patch(self.ENDPOINT, payload, content_type='application/json')
+
+    def test_change_password_no_data(self):
+        response = self.send_request({})
+        self.assertEqual(response.status_code, 400)
+
+    def test_change_password_invalid_old_password(self):
+        response = self.send_request({
+            'oldPassword': '12345',
+            'newPassword': '12345'
+        })
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json()['error'], 'Incorrect old password')
+
+    def test_change_password_invalid_new_password(self):
+        response = self.send_request({
+            'oldPassword': self.TESTS_PASSWORD,
+            'newPassword': '123451230'
+        })
+        self.assertEqual(response.status_code, 400)
+        self.assertEqual(response.json()['error'], 'This password is entirely numeric.')
+
+    def test_change_password_success(self):
+        response = self.send_request({
+            'oldPassword': self.TESTS_PASSWORD,
+            'newPassword': 'prettyhardpassword123456'
+        })
+        self.assertEqual(response.status_code, 200)
+
 class TestLoginViews(BaseTest):
     def test_redirect_to_setup_admin_when_no_users(self):
         User.objects.all().delete()
diff --git a/posthog/api/user.py b/posthog/api/user.py
index 69d262c57a5f0..0c32acd5567ea 100644
--- a/posthog/api/user.py
+++ b/posthog/api/user.py
@@ -1,4 +1,7 @@
 from django.http import HttpResponse, JsonResponse
+from django.views.decorators.http import require_http_methods
+from django.contrib.auth.password_validation import validate_password
+from django.core.exceptions import ValidationError
 from django.shortcuts import redirect
 from django.conf import settings
 from posthog.models import Event
@@ -55,3 +58,33 @@ def redirect_to_site(request):
     }))
 
     return redirect("{}#state={}".format(app_url, state))
+
+
+@require_http_methods(['PATCH'])
+def change_password(request):
+    """Change the password of a regular User."""
+    if not request.user.is_authenticated:
+        return JsonResponse({}, status=401)
+
+    try:
+        body = json.loads(request.body)
+    except (TypeError, json.decoder.JSONDecodeError):
+        return JsonResponse({'error': 'Cannot parse request body'}, status=400)
+
+    old_password = body.get('oldPassword')
+    new_password = body.get('newPassword')
+
+    if not old_password or not new_password:
+        return JsonResponse({'error': 'Missing payload'}, status=400)
+
+    if not request.user.check_password(old_password):
+        return JsonResponse({'error': 'Incorrect old password'}, status=400)
+
+    try:
+        validate_password(new_password, user)
+    except ValidationError as err:
+        return JsonResponse({'error': err.messages[0]}, status=400)
+
+    request.user.set_password(new_password)
+    request.user.save()
+    return JsonResponse({})
diff --git a/posthog/urls.py b/posthog/urls.py
index a5ccb79fe341f..1ac92907157e8 100644
--- a/posthog/urls.py
+++ b/posthog/urls.py
@@ -104,6 +104,7 @@ def logout(request):
 
     path('api/user/', user.user),
     path('api/user/redirect_to_site/', user.redirect_to_site),
+    path('api/user/change_password/', user.change_password),
     path('decide/', capture.get_decide),
     path('engage/', capture.get_event),
     path('engage', capture.get_event),


Code review comments:
diff_hunk: @@ -0,0 +1,72 @@
+import React, { Component } from 'react'
+import { toast } from 'react-toastify'
+import api from '../../lib/api'
+
+export class ChangePassword extends Component {
+    constructor(props) {
+        super(props)
+        this.state = {
+            oldPassword: '',
+            newPassword: '',
+        }
+    }
+
+    handleChange = event => {
+        this.setState({
+            [event.target.name]: event.target.value,
+        })
+    }
+
+    save = async event => {
+        event.preventDefault()
+        try {
+            await api.update('api/user/change_password', {
+                oldPassword: this.state.oldPassword,
+                newPassword: this.state.newPassword,
+            })
+            toast.success('Password changed')
+            // Give the user enough time to see the "Password change" toast
+            setTimeout(() => {
+                window.location.reload()
path: frontend/src/scenes/setup/ChangePassword.js
text: @user1:
What's the reason for this reload?

@author:
Django automatically invalidates the user's session on a [password change](https://docs.djangoproject.com/en/3.0/topics/auth/default/#changing-passwords). This means that if you click on some other element on the page you will be redirected to the login page. 

So in order for the users to not be surprised because of the above, they instead get redirected after 2 seconds to the login page automatically.

**1. Password change success**
<img width="1149" alt="Screenshot 2020-04-04 at 11 04 16 AM" src="https://user-images.githubusercontent.com/1487006/78423277-8eeea380-7665-11ea-985b-d701f1f3e79e.png">

**2. Redirected to login**
![image](https://user-images.githubusercontent.com/1487006/78423297-b04f8f80-7665-11ea-87ac-55830758a45f.png)


@user1:
That makes sense, but then I think it is worth just using 

```python
update_session_auth_hash(request, form.user)
```

To make sure the user stays logged in, for a better experience!

@author:
Nice! didn't know that existed. 

@author:
@user1 Done. The user does not get logged out after the password change.