mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-03-16 21:23:36 +00:00
Users can update their email addresses. Fixes #105
This commit is contained in:
parent
8d58a3a361
commit
2393dad09e
16 changed files with 346 additions and 40 deletions
|
@ -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()
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
41
hc/accounts/tests/test_change_email.py
Normal file
41
hc/accounts/tests/test_change_email.py
Normal 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")
|
|
@ -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)
|
||||
|
|
|
@ -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"),
|
||||
|
||||
|
||||
]
|
||||
|
|
|
@ -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"))
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
18
static/css/profile.css
Normal file
18
static/css/profile.css
Normal 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;
|
||||
}
|
||||
|
72
templates/accounts/change_email.html
Normal file
72
templates/accounts/change_email.html
Normal 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 %}
|
18
templates/accounts/change_email_done.html
Normal file
18
templates/accounts/change_email_done.html
Normal 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 %}
|
|
@ -7,8 +7,8 @@
|
|||
<h1>Email with Instructions Sent!</h1>
|
||||
<br />
|
||||
<p>
|
||||
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!
|
||||
</p>
|
||||
|
||||
</div>
|
|
@ -19,7 +19,6 @@
|
|||
</div>
|
||||
|
||||
<div class="row">
|
||||
|
||||
<div class="col-sm-3">
|
||||
<ul class="nav nav-pills nav-stacked">
|
||||
<li class="active"><a href="{% url 'hc-profile' %}">Account</a></li>
|
||||
|
@ -33,17 +32,29 @@
|
|||
<div class="panel-body settings-block">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<h2>Set Password</h2>
|
||||
Attach a password to your {% site_name %} account
|
||||
<button
|
||||
type="submit"
|
||||
name="set_password"
|
||||
class="btn btn-default pull-right">Set Password</button>
|
||||
<h2>Email and Password</h2>
|
||||
<p>
|
||||
Your account's email address is
|
||||
<code>{{ request.user.email }}</code>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel panel-{{ api_status }}">
|
||||
<div class="panel-body settings-block">
|
||||
<h2>API Access</h2>
|
||||
{% if profile.api_key %}
|
||||
|
@ -78,9 +89,21 @@
|
|||
</form>
|
||||
{% endif %}
|
||||
</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 class="panel panel-default">
|
||||
<div class="panel panel-{{ team_status }}">
|
||||
<div class="panel-body settings-block">
|
||||
<h2>Team Access</h2>
|
||||
{% if profile.member_set.count %}
|
||||
|
@ -135,6 +158,24 @@
|
|||
data-target="#invite-team-member-modal">Invite a Team Member</a>
|
||||
{% endif %}
|
||||
</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 class="panel panel-default">
|
||||
|
|
|
@ -33,6 +33,7 @@
|
|||
<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/last_ping.css' %}" type="text/css">
|
||||
<link rel="stylesheet" href="{% static 'css/profile.css' %}" type="text/css">
|
||||
{% endcompress %}
|
||||
</head>
|
||||
<body class="page-{{ page }}">
|
||||
|
|
13
templates/emails/change-email-body-html.html
Normal file
13
templates/emails/change-email-body-html.html
Normal 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 %}
|
11
templates/emails/change-email-body-text.html
Normal file
11
templates/emails/change-email-body-text.html
Normal 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 %}
|
2
templates/emails/change-email-subject.html
Normal file
2
templates/emails/change-email-subject.html
Normal file
|
@ -0,0 +1,2 @@
|
|||
{% load hc_extras %}
|
||||
Change email address on {% site_name %}
|
Loading…
Reference in a new issue