Users can update their email addresses. Fixes #105

This commit is contained in:
Pēteris Caune 2017-08-23 15:47:20 +03:00
parent 8d58a3a361
commit 2393dad09e
16 changed files with 346 additions and 40 deletions

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.contrib.auth.models import User
class LowercaseEmailField(forms.EmailField): class LowercaseEmailField(forms.EmailField):
@ -21,6 +22,18 @@ class SetPasswordForm(forms.Form):
password = forms.CharField() 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): class InviteTeamMemberForm(forms.Form):
email = LowercaseEmailField() email = LowercaseEmailField()

View File

@ -79,6 +79,18 @@ class Profile(models.Model):
} }
emails.set_password(self.user.email, ctx) 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): def set_api_key(self):
self.api_key = base64.urlsafe_b64encode(os.urandom(24)) self.api_key = base64.urlsafe_b64encode(os.urandom(24))
self.save() self.save()

View File

@ -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")

View File

@ -22,7 +22,7 @@ class ProfileTestCase(BaseTestCase):
# And an email should have been sent # And an email should have been sent
self.assertEqual(len(mail.outbox), 1) 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) self.assertEqual(mail.outbox[0].subject, expected_subject)
def test_it_creates_api_key(self): def test_it_creates_api_key(self):
@ -30,7 +30,7 @@ class ProfileTestCase(BaseTestCase):
form = {"create_api_key": "1"} form = {"create_api_key": "1"}
r = self.client.post("/accounts/profile/", form) 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.alice.profile.refresh_from_db()
api_key = self.alice.profile.api_key api_key = self.alice.profile.api_key
@ -64,7 +64,7 @@ class ProfileTestCase(BaseTestCase):
form = {"invite_team_member": "1", "email": "frank@example.org"} form = {"invite_team_member": "1", "email": "frank@example.org"}
r = self.client.post("/accounts/profile/", form) r = self.client.post("/accounts/profile/", form)
assert r.status_code == 200 self.assertEqual(r.status_code, 200)
member_emails = set() member_emails = set()
for member in self.alice.profile.member_set.all(): 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"} form = {"remove_team_member": "1", "email": "bob@example.org"}
r = self.client.post("/accounts/profile/", form) r = self.client.post("/accounts/profile/", form)
assert r.status_code == 200 self.assertEqual(r.status_code, 200)
self.assertEqual(Member.objects.count(), 0) self.assertEqual(Member.objects.count(), 0)
@ -102,7 +102,7 @@ class ProfileTestCase(BaseTestCase):
form = {"set_team_name": "1", "team_name": "Alpha Team"} form = {"set_team_name": "1", "team_name": "Alpha Team"}
r = self.client.post("/accounts/profile/", form) 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.alice.profile.refresh_from_db()
self.assertEqual(self.alice.profile.team_name, "Alpha Team") self.assertEqual(self.alice.profile.team_name, "Alpha Team")
@ -123,3 +123,20 @@ class ProfileTestCase(BaseTestCase):
# to user's default team. # to user's default team.
self.bobs_profile.refresh_from_db() self.bobs_profile.refresh_from_db()
self.assertEqual(self.bobs_profile.current_team, self.bobs_profile) 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)

View File

@ -7,8 +7,8 @@ urlpatterns = [
url(r'^login_link_sent/$', url(r'^login_link_sent/$',
views.login_link_sent, name="hc-login-link-sent"), views.login_link_sent, name="hc-login-link-sent"),
url(r'^set_password_link_sent/$', url(r'^link_sent/$',
views.set_password_link_sent, name="hc-set-password-link-sent"), views.link_sent, name="hc-link-sent"),
url(r'^check_token/([\w-]+)/([\w-]+)/$', url(r'^check_token/([\w-]+)/([\w-]+)/$',
views.check_token, name="hc-check-token"), views.check_token, name="hc-check-token"),
@ -24,8 +24,13 @@ urlpatterns = [
url(r'^set_password/([\w-]+)/$', url(r'^set_password/([\w-]+)/$',
views.set_password, name="hc-set-password"), 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-]+)/$', url(r'^switch_team/([\w-]+)/$',
views.switch_team, name="hc-switch-team"), views.switch_team, name="hc-switch-team"),
] ]

View File

@ -13,9 +13,10 @@ from django.core import signing
from django.http import HttpResponseForbidden, HttpResponseBadRequest from django.http import HttpResponseForbidden, HttpResponseBadRequest
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
from hc.accounts.forms import (EmailPasswordForm, InviteTeamMemberForm, from hc.accounts.forms import (ChangeEmailForm, EmailPasswordForm,
RemoveTeamMemberForm, ReportSettingsForm, InviteTeamMemberForm, RemoveTeamMemberForm,
SetPasswordForm, TeamNameForm) ReportSettingsForm, SetPasswordForm,
TeamNameForm)
from hc.accounts.models import Profile, Member from hc.accounts.models import Profile, Member
from hc.api.models import Channel, Check from hc.api.models import Channel, Check
from hc.lib.badges import get_badge_url 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") return render(request, "accounts/login_link_sent.html")
def set_password_link_sent(request): def link_sent(request):
return render(request, "accounts/set_password_link_sent.html") return render(request, "accounts/link_sent.html")
def check_token(request, username, token): def check_token(request, username, token):
@ -156,21 +157,33 @@ def profile(request):
profile.current_team = profile profile.current_team = profile
profile.save() 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 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() profile.send_set_password_link()
return redirect("hc-set-password-link-sent") return redirect("hc-link-sent")
elif "create_api_key" in request.POST: elif "create_api_key" in request.POST:
profile.set_api_key() profile.set_api_key()
show_api_key = True ctx["show_api_key"] = True
messages.success(request, "The API key has been created!") ctx["api_key_created"] = True
ctx["api_status"] = "success"
elif "revoke_api_key" in request.POST: elif "revoke_api_key" in request.POST:
profile.api_key = "" profile.api_key = ""
profile.save() 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: elif "show_api_key" in request.POST:
show_api_key = True ctx["show_api_key"] = True
elif "invite_team_member" in request.POST: elif "invite_team_member" in request.POST:
if not profile.team_access_allowed: if not profile.team_access_allowed:
return HttpResponseForbidden() return HttpResponseForbidden()
@ -185,7 +198,9 @@ def profile(request):
user = _make_user(email) user = _make_user(email)
profile.invite(user) 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: elif "remove_team_member" in request.POST:
form = RemoveTeamMemberForm(request.POST) form = RemoveTeamMemberForm(request.POST)
if form.is_valid(): if form.is_valid():
@ -198,7 +213,8 @@ def profile(request):
Member.objects.filter(team=profile, Member.objects.filter(team=profile,
user=farewell_user).delete() 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: elif "set_team_name" in request.POST:
if not profile.team_access_allowed: if not profile.team_access_allowed:
return HttpResponseForbidden() return HttpResponseForbidden()
@ -207,13 +223,8 @@ def profile(request):
if form.is_valid(): if form.is_valid():
profile.team_name = form.cleaned_data["team_name"] profile.team_name = form.cleaned_data["team_name"]
profile.save() profile.save()
messages.success(request, "Team Name updated!") ctx["team_name_updated"] = True
ctx["team_status"] = "success"
ctx = {
"page": "profile",
"profile": profile,
"show_api_key": show_api_key
}
return render(request, "accounts/profile.html", ctx) return render(request, "accounts/profile.html", ctx)
@ -301,6 +312,33 @@ def set_password(request, token):
return render(request, "accounts/set_password.html", {}) 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): def unsubscribe_reports(request, username):
try: try:
signing.Signer().unsign(request.GET.get("token")) signing.Signer().unsign(request.GET.get("token"))

View File

@ -44,6 +44,10 @@ def set_password(to, ctx):
send("set-password", to, ctx) send("set-password", to, ctx)
def change_email(to, ctx):
send("change-email", to, ctx)
def alert(to, ctx, headers={}): def alert(to, ctx, headers={}):
send("alert", to, ctx, headers) send("alert", to, ctx, headers)

18
static/css/profile.css Normal file
View File

@ -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;
}

View File

@ -0,0 +1,72 @@
{% extends "base.html" %}
{% load hc_extras %}
{% block content %}
<div class="row">
<div class="col-sm-6 col-sm-offset-3">
<div class="hc-dialog">
<h1>Change Account's Email Address</h1>
<div class="dialog-body">
<p>
Your account's email address is used for sending
the sign-in links and monthly reports.
<strong>
Make sure you can receive emails at the new address.
</strong>
Otherwise, you may get locked out of
your {% site_name %} account.
</p>
{% if request.user.has_usable_password %}
<p>
Note: Changing the email address will also
<strong>reset your current password</strong>
and log you out.
</p>
{% endif %}
</div>
<form class="form-horizontal" method="post">
{% csrf_token %}
<div class="form-group">
<label class="col-sm-3 control-label">Current Email</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
disabled
value="{{ request.user.email }}">
</div>
</div>
<div class="form-group {{ form.email.css_classes }}">
<label for="ce-email" class="col-sm-3 control-label">New Email</label>
<div class="col-sm-9">
<input
type="email"
class="form-control"
id="ce-email"
name="email"
placeholder="you@example.org">
{% if form.email.errors %}
<div class="help-block">
{{ form.email.errors|join:"" }}
</div>
{% endif %}
</div>
</div>
<div class="clearfix">
<button type="submit" class="btn btn-lg btn-primary pull-right">
Change Email
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block content %}
<div class="row">
<div class="col-sm-6 col-sm-offset-3">
<div class="hc-dialog">
<h1>Email Address Updated</h1>
<br />
<p>
Your account's email address has been updated.
You can now <a href="{% url 'hc-login' %}">sign in</a>
with the new email address.
</p>
</div>
</div>
</div>
{% endblock %}

View File

@ -7,8 +7,8 @@
<h1>Email with Instructions Sent!</h1> <h1>Email with Instructions Sent!</h1>
<br /> <br />
<p> <p>
We've sent you an email with instructions to set We've sent you an email with further instructions.
a password for your account. Please check your inbox! Please check your inbox!
</p> </p>
</div> </div>

View File

@ -19,7 +19,6 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm-3"> <div class="col-sm-3">
<ul class="nav nav-pills nav-stacked"> <ul class="nav nav-pills nav-stacked">
<li class="active"><a href="{% url 'hc-profile' %}">Account</a></li> <li class="active"><a href="{% url 'hc-profile' %}">Account</a></li>
@ -33,17 +32,29 @@
<div class="panel-body settings-block"> <div class="panel-body settings-block">
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<h2>Set Password</h2> <h2>Email and Password</h2>
Attach a password to your {% site_name %} account <p>
<button Your account's email address is
type="submit" <code>{{ request.user.email }}</code>
name="set_password"
class="btn btn-default pull-right">Set Password</button> <button
type="submit"
name="change_email"
class="btn btn-default pull-right">Change Email</button>
</p>
<p class="clearfix"></p>
<p>
Attach a password to your {% site_name %} account
<button
type="submit"
name="set_password"
class="btn btn-default pull-right">Set Password</button>
</p>
</form> </form>
</div> </div>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-{{ api_status }}">
<div class="panel-body settings-block"> <div class="panel-body settings-block">
<h2>API Access</h2> <h2>API Access</h2>
{% if profile.api_key %} {% if profile.api_key %}
@ -78,9 +89,21 @@
</form> </form>
{% endif %} {% endif %}
</div> </div>
{% if api_key_created %}
<div class="panel-footer">
API key created
</div>
{% endif %}
{% if api_key_revoked %}
<div class="panel-footer">
API key revoked
</div>
{% endif %}
</div> </div>
<div class="panel panel-default"> <div class="panel panel-{{ team_status }}">
<div class="panel-body settings-block"> <div class="panel-body settings-block">
<h2>Team Access</h2> <h2>Team Access</h2>
{% if profile.member_set.count %} {% if profile.member_set.count %}
@ -135,6 +158,24 @@
data-target="#invite-team-member-modal">Invite a Team Member</a> data-target="#invite-team-member-modal">Invite a Team Member</a>
{% endif %} {% endif %}
</div> </div>
{% if team_member_invited %}
<div class="panel-footer">
{{ team_member_invited }} invited to team
</div>
{% endif %}
{% if team_member_removed %}
<div class="panel-footer">
{{ team_member_removed }} removed from team
</div>
{% endif %}
{% if team_name_updated %}
<div class="panel-footer">
Team name updated
</div>
{% endif %}
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -33,6 +33,7 @@
<link rel="stylesheet" href="{% static 'css/add_pushover.css' %}" type="text/css"> <link rel="stylesheet" href="{% static 'css/add_pushover.css' %}" type="text/css">
<link rel="stylesheet" href="{% static 'css/settings.css' %}" type="text/css"> <link rel="stylesheet" href="{% static 'css/settings.css' %}" type="text/css">
<link rel="stylesheet" href="{% static 'css/last_ping.css' %}" type="text/css"> <link rel="stylesheet" href="{% static 'css/last_ping.css' %}" type="text/css">
<link rel="stylesheet" href="{% static 'css/profile.css' %}" type="text/css">
{% endcompress %} {% endcompress %}
</head> </head>
<body class="page-{{ page }}"> <body class="page-{{ page }}">

View File

@ -0,0 +1,13 @@
{% extends "emails/base.html" %}
{% load hc_extras %}
{% block content %}
Hello,<br />
To change the email address for your account on {% site_name %}, please press
the button below:</p>
{% endblock %}
{% block content_more %}
Regards,<br />
The {% escaped_site_name %} Team
{% endblock %}

View File

@ -0,0 +1,11 @@
{% load hc_extras %}
Hello,
Here's a link to change the email address for your account on {% site_name %}:
{{ button_url }}
--
Regards,
{% site_name %}

View File

@ -0,0 +1,2 @@
{% load hc_extras %}
Change email address on {% site_name %}