diff --git a/hc/accounts/forms.py b/hc/accounts/forms.py index 544cc3b3..54a3d85c 100644 --- a/hc/accounts/forms.py +++ b/hc/accounts/forms.py @@ -1,4 +1,5 @@ from django import forms +from django.contrib.auth.models import User class LowercaseEmailField(forms.EmailField): @@ -21,6 +22,18 @@ class SetPasswordForm(forms.Form): password = forms.CharField() +class ChangeEmailForm(forms.Form): + error_css_class = "has-error" + email = LowercaseEmailField() + + def clean_email(self): + v = self.cleaned_data["email"] + if User.objects.filter(email=v).exists(): + raise forms.ValidationError("%s is not available" % v) + + return v + + class InviteTeamMemberForm(forms.Form): email = LowercaseEmailField() diff --git a/hc/accounts/models.py b/hc/accounts/models.py index 4602608e..2702db78 100644 --- a/hc/accounts/models.py +++ b/hc/accounts/models.py @@ -79,6 +79,18 @@ class Profile(models.Model): } emails.set_password(self.user.email, ctx) + def send_change_email_link(self): + token = str(uuid.uuid4()) + self.token = make_password(token) + self.save() + + path = reverse("hc-change-email", args=[token]) + ctx = { + "button_text": "Change Email", + "button_url": settings.SITE_ROOT + path + } + emails.change_email(self.user.email, ctx) + def set_api_key(self): self.api_key = base64.urlsafe_b64encode(os.urandom(24)) self.save() diff --git a/hc/accounts/tests/test_change_email.py b/hc/accounts/tests/test_change_email.py new file mode 100644 index 00000000..cdd0a186 --- /dev/null +++ b/hc/accounts/tests/test_change_email.py @@ -0,0 +1,41 @@ +from django.contrib.auth.hashers import make_password + +from hc.test import BaseTestCase + + +class ChangeEmailTestCase(BaseTestCase): + + def test_it_shows_form(self): + self.profile.token = make_password("foo") + self.profile.save() + + self.client.login(username="alice@example.org", password="password") + + r = self.client.get("/accounts/change_email/foo/") + self.assertContains(r, "Change Account's Email Address") + + def test_it_changes_password(self): + self.profile.token = make_password("foo") + self.profile.save() + + self.client.login(username="alice@example.org", password="password") + + payload = {"email": "alice2@example.org"} + self.client.post("/accounts/change_email/foo/", payload) + + self.alice.refresh_from_db() + self.assertEqual(self.alice.email, "alice2@example.org") + self.assertFalse(self.alice.has_usable_password()) + + def test_it_requires_unique_email(self): + self.profile.token = make_password("foo") + self.profile.save() + + self.client.login(username="alice@example.org", password="password") + + payload = {"email": "bob@example.org"} + r = self.client.post("/accounts/change_email/foo/", payload) + self.assertContains(r, "bob@example.org is not available") + + self.alice.refresh_from_db() + self.assertEqual(self.alice.email, "alice@example.org") diff --git a/hc/accounts/tests/test_profile.py b/hc/accounts/tests/test_profile.py index 7630ee57..ecd3463c 100644 --- a/hc/accounts/tests/test_profile.py +++ b/hc/accounts/tests/test_profile.py @@ -22,7 +22,7 @@ class ProfileTestCase(BaseTestCase): # And an email should have been sent self.assertEqual(len(mail.outbox), 1) - expected_subject = 'Set password on {0}'.format(getattr(settings, "SITE_NAME")) + expected_subject = "Set password on %s" % settings.SITE_NAME self.assertEqual(mail.outbox[0].subject, expected_subject) def test_it_creates_api_key(self): @@ -30,7 +30,7 @@ class ProfileTestCase(BaseTestCase): form = {"create_api_key": "1"} r = self.client.post("/accounts/profile/", form) - assert r.status_code == 200 + self.assertEqual(r.status_code, 200) self.alice.profile.refresh_from_db() api_key = self.alice.profile.api_key @@ -64,7 +64,7 @@ class ProfileTestCase(BaseTestCase): form = {"invite_team_member": "1", "email": "frank@example.org"} r = self.client.post("/accounts/profile/", form) - assert r.status_code == 200 + self.assertEqual(r.status_code, 200) member_emails = set() for member in self.alice.profile.member_set.all(): @@ -90,7 +90,7 @@ class ProfileTestCase(BaseTestCase): form = {"remove_team_member": "1", "email": "bob@example.org"} r = self.client.post("/accounts/profile/", form) - assert r.status_code == 200 + self.assertEqual(r.status_code, 200) self.assertEqual(Member.objects.count(), 0) @@ -102,7 +102,7 @@ class ProfileTestCase(BaseTestCase): form = {"set_team_name": "1", "team_name": "Alpha Team"} r = self.client.post("/accounts/profile/", form) - assert r.status_code == 200 + self.assertEqual(r.status_code, 200) self.alice.profile.refresh_from_db() self.assertEqual(self.alice.profile.team_name, "Alpha Team") @@ -123,3 +123,20 @@ class ProfileTestCase(BaseTestCase): # to user's default team. self.bobs_profile.refresh_from_db() self.assertEqual(self.bobs_profile.current_team, self.bobs_profile) + + def test_it_sends_change_email_link(self): + self.client.login(username="alice@example.org", password="password") + + form = {"change_email": "1"} + r = self.client.post("/accounts/profile/", form) + assert r.status_code == 302 + + # profile.token should be set now + self.alice.profile.refresh_from_db() + token = self.alice.profile.token + self.assertTrue(len(token) > 10) + + # And an email should have been sent + self.assertEqual(len(mail.outbox), 1) + expected_subject = "Change email address on %s" % settings.SITE_NAME + self.assertEqual(mail.outbox[0].subject, expected_subject) diff --git a/hc/accounts/urls.py b/hc/accounts/urls.py index 23e47e97..ca22c54f 100644 --- a/hc/accounts/urls.py +++ b/hc/accounts/urls.py @@ -7,8 +7,8 @@ urlpatterns = [ url(r'^login_link_sent/$', views.login_link_sent, name="hc-login-link-sent"), - url(r'^set_password_link_sent/$', - views.set_password_link_sent, name="hc-set-password-link-sent"), + url(r'^link_sent/$', + views.link_sent, name="hc-link-sent"), url(r'^check_token/([\w-]+)/([\w-]+)/$', views.check_token, name="hc-check-token"), @@ -24,8 +24,13 @@ urlpatterns = [ url(r'^set_password/([\w-]+)/$', views.set_password, name="hc-set-password"), + url(r'^change_email/done/$', + views.change_email_done, name="hc-change-email-done"), + + url(r'^change_email/([\w-]+)/$', + views.change_email, name="hc-change-email"), + url(r'^switch_team/([\w-]+)/$', views.switch_team, name="hc-switch-team"), - ] diff --git a/hc/accounts/views.py b/hc/accounts/views.py index ca55cc3c..620c40a4 100644 --- a/hc/accounts/views.py +++ b/hc/accounts/views.py @@ -13,9 +13,10 @@ from django.core import signing from django.http import HttpResponseForbidden, HttpResponseBadRequest from django.shortcuts import redirect, render from django.views.decorators.http import require_POST -from hc.accounts.forms import (EmailPasswordForm, InviteTeamMemberForm, - RemoveTeamMemberForm, ReportSettingsForm, - SetPasswordForm, TeamNameForm) +from hc.accounts.forms import (ChangeEmailForm, EmailPasswordForm, + InviteTeamMemberForm, RemoveTeamMemberForm, + ReportSettingsForm, SetPasswordForm, + TeamNameForm) from hc.accounts.models import Profile, Member from hc.api.models import Channel, Check from hc.lib.badges import get_badge_url @@ -114,8 +115,8 @@ def login_link_sent(request): return render(request, "accounts/login_link_sent.html") -def set_password_link_sent(request): - return render(request, "accounts/set_password_link_sent.html") +def link_sent(request): + return render(request, "accounts/link_sent.html") def check_token(request, username, token): @@ -156,21 +157,33 @@ def profile(request): profile.current_team = profile profile.save() - show_api_key = False + ctx = { + "page": "profile", + "profile": profile, + "show_api_key": False, + "api_status": "default", + "team_status": "default" + } + if request.method == "POST": - if "set_password" in request.POST: + if "change_email" in request.POST: + profile.send_change_email_link() + return redirect("hc-link-sent") + elif "set_password" in request.POST: profile.send_set_password_link() - return redirect("hc-set-password-link-sent") + return redirect("hc-link-sent") elif "create_api_key" in request.POST: profile.set_api_key() - show_api_key = True - messages.success(request, "The API key has been created!") + ctx["show_api_key"] = True + ctx["api_key_created"] = True + ctx["api_status"] = "success" elif "revoke_api_key" in request.POST: profile.api_key = "" profile.save() - messages.info(request, "The API key has been revoked!") + ctx["api_key_revoked"] = True + ctx["api_status"] = "info" elif "show_api_key" in request.POST: - show_api_key = True + ctx["show_api_key"] = True elif "invite_team_member" in request.POST: if not profile.team_access_allowed: return HttpResponseForbidden() @@ -185,7 +198,9 @@ def profile(request): user = _make_user(email) profile.invite(user) - messages.success(request, "Invitation to %s sent!" % email) + ctx["team_member_invited"] = email + ctx["team_status"] = "success" + elif "remove_team_member" in request.POST: form = RemoveTeamMemberForm(request.POST) if form.is_valid(): @@ -198,7 +213,8 @@ def profile(request): Member.objects.filter(team=profile, user=farewell_user).delete() - messages.info(request, "%s removed from team!" % email) + ctx["team_member_removed"] = email + ctx["team_status"] = "info" elif "set_team_name" in request.POST: if not profile.team_access_allowed: return HttpResponseForbidden() @@ -207,13 +223,8 @@ def profile(request): if form.is_valid(): profile.team_name = form.cleaned_data["team_name"] profile.save() - messages.success(request, "Team Name updated!") - - ctx = { - "page": "profile", - "profile": profile, - "show_api_key": show_api_key - } + ctx["team_name_updated"] = True + ctx["team_status"] = "success" return render(request, "accounts/profile.html", ctx) @@ -301,6 +312,33 @@ def set_password(request, token): return render(request, "accounts/set_password.html", {}) +@login_required +def change_email(request, token): + profile = request.user.profile + if not check_password(token, profile.token): + return HttpResponseBadRequest() + + if request.method == "POST": + form = ChangeEmailForm(request.POST) + if form.is_valid(): + request.user.email = form.cleaned_data["email"] + request.user.set_unusable_password() + request.user.save() + + profile.token = "" + profile.save() + + return redirect("hc-change-email-done") + else: + form = ChangeEmailForm() + + return render(request, "accounts/change_email.html", {"form": form}) + + +def change_email_done(request): + return render(request, "accounts/change_email_done.html") + + def unsubscribe_reports(request, username): try: signing.Signer().unsign(request.GET.get("token")) diff --git a/hc/lib/emails.py b/hc/lib/emails.py index b2d453d0..d33cfac3 100644 --- a/hc/lib/emails.py +++ b/hc/lib/emails.py @@ -44,6 +44,10 @@ def set_password(to, ctx): send("set-password", to, ctx) +def change_email(to, ctx): + send("change-email", to, ctx) + + def alert(to, ctx, headers={}): send("alert", to, ctx, headers) diff --git a/static/css/profile.css b/static/css/profile.css new file mode 100644 index 00000000..98dd4a07 --- /dev/null +++ b/static/css/profile.css @@ -0,0 +1,18 @@ +.panel-success .panel-footer { + background: #dff0d8; + color: #3c763d; + font-size: small; + text-align: center; + border-top: 0; + padding: 6px 15px; +} + +.panel-info .panel-footer { + background: #d9edf7; + color: #31708f; + font-size: small; + text-align: center; + border-top: 0; + padding: 8px 15px; +} + diff --git a/templates/accounts/change_email.html b/templates/accounts/change_email.html new file mode 100644 index 00000000..5ac0c4f1 --- /dev/null +++ b/templates/accounts/change_email.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% load hc_extras %} + +{% block content %} +
+ Your account's email address is used for sending + the sign-in links and monthly reports. + + + Make sure you can receive emails at the new address. + + + Otherwise, you may get locked out of + your {% site_name %} account. +
+ + {% if request.user.has_usable_password %} ++ Note: Changing the email address will also + reset your current password + and log you out. +
+ {% endif %} ++ Your account's email address has been updated. + You can now sign in + with the new email address. +
+- We've sent you an email with instructions to set - a password for your account. Please check your inbox! + We've sent you an email with further instructions. + Please check your inbox!
diff --git a/templates/accounts/profile.html b/templates/accounts/profile.html index e64e7d6b..7078d4e8 100644 --- a/templates/accounts/profile.html +++ b/templates/accounts/profile.html @@ -19,7 +19,6 @@