Allow user to add a contact email address (#2606)

- Adds a contact email field to user settings.
- Implements verification process: sends unique verification URL with a token to that email address.

Co-authored-by: Eemeli Aro <eemeli@gmail.com>
This commit is contained in:
Matjaž Horvat 2022-08-17 14:43:18 +02:00 коммит произвёл GitHub
Родитель aad2f844f4
Коммит 49f2e2f17a
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
16 изменённых файлов: 292 добавлений и 20 удалений

Просмотреть файл

@ -81,6 +81,10 @@ you create:
Maximum number of tasks a Celery worker process can execute before its
replaced with a new one. Defaults to 20 tasks.
``DEFAULT_FROM_EMAIL``
Optional. Default email address to send emails from. Default value:
``Pontoon <noreply@hostname>``.
``DISABLE_COLLECTSTATIC``
Disables running ``./manage.py collectstatic`` during the build. Should be
set to ``1``.

Просмотреть файл

@ -216,6 +216,7 @@ class UserProfileForm(forms.ModelForm):
model = UserProfile
fields = (
"username",
"contact_email",
"bio",
"bugzilla",
"matrix",

Просмотреть файл

@ -0,0 +1,28 @@
# Generated by Django 3.2.14 on 2022-08-10 12:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("base", "0032_user_profile_visibility"),
]
operations = [
migrations.AddField(
model_name="userprofile",
name="contact_email",
field=models.EmailField(
blank=True,
max_length=254,
null=True,
verbose_name="Contact email address",
),
),
migrations.AddField(
model_name="userprofile",
name="contact_email_verified",
field=models.BooleanField(default=False),
),
]

Просмотреть файл

@ -1584,6 +1584,8 @@ class UserProfile(models.Model):
# Personal information
username = models.SlugField(unique=True, blank=True, null=True)
contact_email = models.EmailField("Contact email address", blank=True, null=True)
contact_email_verified = models.BooleanField(default=False)
bio = models.TextField(max_length=160, blank=True, null=True)
# External accounts

Просмотреть файл

@ -66,6 +66,11 @@
text-align: left;
}
#main .field a {
float: none;
text-transform: none;
}
#main .field label {
display: inline-block;
margin-right: 25px;
@ -91,11 +96,21 @@
height: 142px;
}
#main .field .help,
#main .field .verify,
.errorlist {
font-size: 14px;
margin: 5px 0 0 325px;
}
#main .field .help {
color: #888888;
font-size: 14px;
font-style: italic;
margin: 5px 0 0 325px;
}
#main .field .verify {
color: #7bc876;
font-style: italic;
}
#main .info li {
@ -111,10 +126,8 @@
.errorlist {
color: #f36;
font-size: 14px;
font-style: italic;
list-style: none;
margin: 5px 0 0 325px;
}
.check-list {

Просмотреть файл

@ -46,6 +46,16 @@
{{ user_profile_form.username }}
{{ user_profile_form.username.errors }}
</div>
<div class="field">
{{ user_profile_form.contact_email.label_tag(label_suffix='') }}
{{ user_profile_form.contact_email }}
{{ user_profile_form.contact_email.errors }}
{% if user.profile.contact_email and not user.profile.contact_email_verified %}
<p class="verify">Check your inbox to verify your email address</a></p>
{% else %}
<p class="help">If provided, used instead of login email address on the <a href="{{ url('pontoon.contributors.profile') }}">Profile page</a></p>
{% endif %}
</div>
<div class="field">
{{ user_profile_form.bio.label_tag(label_suffix='') }}
{{ user_profile_form.bio }}

Просмотреть файл

@ -0,0 +1,8 @@
Hey {{ display_name }},
Please click on the link below within the next hour to verify your contact email address:
{{ link }}
Youre receiving this email because you recently changed a contact email address on Pontoon. If this wasnt you, please ignore this email.
Sent by Pontoon.

Просмотреть файл

@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}Email Verification{% endblock %}
{% block middle %}
<section id="error">
<div class="inner">
<h1 id="title"><a href="/">{{ title }}</a></h1>
<h2 id="subtitle">{% block description %}{{ message }}{% endblock %}</h2>
</div>
</section>
{% endblock %}

Просмотреть файл

@ -0,0 +1,62 @@
import pytest
from unittest.mock import patch
from django.test.client import RequestFactory
from pontoon.base.models import User
from pontoon.contributors.utils import (
generate_verification_token,
send_verification_email,
check_verification_token,
)
@pytest.mark.django_db
def test_generate_verification_token(member):
with patch("jwt.encode") as mock_encode:
generate_verification_token(member.user)
assert mock_encode.called
args = mock_encode.call_args.args
assert list(args[0].values())[0] == member.user.pk
assert list(args[0].values())[1] == member.user.profile.contact_email
@pytest.mark.django_db
def test_send_verification_email(member):
with patch("pontoon.contributors.utils.EmailMessage") as mock_email_message:
rf = RequestFactory()
request = rf.get("/settings/")
request.user = member.user
token = "EMAIL-VERIFICATION-TOKEN"
send_verification_email(request, token)
assert mock_email_message.called
kwargs = mock_email_message.call_args.kwargs
assert token in kwargs["body"]
assert kwargs["to"] == [None]
@pytest.mark.django_db
def test_check_verification_token(member, user_b):
# Invalid token
token = "INVALID-VERIFICATION-TOKEN"
title, message = check_verification_token(member.user, token)
assert title == "Oops!"
assert message == "Invalid verification token"
assert User.objects.get(pk=member.user.pk).profile.contact_email_verified is False
# Valid token
token = generate_verification_token(member.user)
title, message = check_verification_token(member.user, token)
assert title == "Success!"
assert message == "Your email address has been verified"
assert User.objects.get(pk=member.user.pk).profile.contact_email_verified is True
# Invalid user
token = generate_verification_token(user_b)
title, message = check_verification_token(member.user, token)
assert title == "Oops!"
assert message == "Invalid verification token"

Просмотреть файл

@ -109,6 +109,26 @@ def test_profileform_user_locales_order(member, settings_url):
]
@pytest.mark.django_db
def test_profileform_contact_email_verified(member):
"""When contact_email changes, contact_email_verified gets set to False."""
profile = User.objects.get(pk=member.user.pk).profile
profile.contact_email_verified = True
profile.save()
assert User.objects.get(pk=member.user.pk).profile.contact_email_verified is True
response = member.client.post(
"/settings/",
{
"first_name": "contributor",
"email": member.user.email,
"contact_email": "contact@example.com",
},
)
assert response.status_code == 200
assert User.objects.get(pk=member.user.pk).profile.contact_email_verified is False
@pytest.fixture
def mock_profile_render():
with patch(

Просмотреть файл

@ -46,6 +46,12 @@ urlpatterns = [
views.contributor_username,
name="pontoon.contributors.contributor.username",
),
# Verify email address
path(
"verify-email-address/<str:token>/",
views.verify_email_address,
name="pontoon.contributors.verify.email",
),
# Current user's profile
path("profile/", views.profile, name="pontoon.contributors.profile"),
# Current user's settings

Просмотреть файл

@ -1,9 +1,17 @@
from collections import defaultdict
import jwt
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from django.conf import settings
from django.core.mail import EmailMessage
from django.db.models import (
Count,
Prefetch,
)
from django.template.loader import get_template
from django.urls import reverse
from django.utils import timezone
from pontoon.base.models import (
Locale,
@ -141,3 +149,60 @@ def users_with_translations_counts(
contributors_list = contributors_list[:limit]
return contributors_list
def generate_verification_token(user):
payload = {
"user": user.pk,
"email": user.profile.contact_email,
"exp": timezone.now() + relativedelta(hours=1),
}
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
def send_verification_email(request, token):
template = get_template("contributors/verification_email.jinja")
mail_subject = "Verify Email Address for Pontoon"
link = request.build_absolute_uri(
reverse("pontoon.contributors.verify.email", args=(token,))
)
mail_body = template.render(
{
"display_name": request.user.display_name,
"link": link,
}
)
EmailMessage(
subject=mail_subject,
body=mail_body,
to=[request.user.profile.contact_email],
).send()
def check_verification_token(user, token):
profile = user.profile
title = "Oops!"
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms="HS256")
if payload["user"] == user.pk and payload["email"] == profile.contact_email:
profile.contact_email_verified = True
profile.save(update_fields=["contact_email_verified"])
title = "Success!"
message = "Your email address has been verified"
else:
raise jwt.exceptions.InvalidTokenError
except jwt.exceptions.ExpiredSignatureError:
message = "Verification token has expired"
except jwt.exceptions.InvalidTokenError:
message = "Invalid verification token"
return title, message

Просмотреть файл

@ -24,7 +24,10 @@ from pontoon.base import forms
from pontoon.base.models import Locale, Project, UserProfile
from pontoon.base.utils import require_AJAX
from pontoon.contributors.utils import (
check_verification_token,
generate_verification_token,
map_translations_to_events,
send_verification_email,
users_with_translations_counts,
)
from pontoon.uxactionlog.utils import log_ux_action
@ -210,10 +213,11 @@ def dismiss_addon_promotion(request):
@login_required(redirect_field_name="", login_url="/403")
def settings(request):
"""View and edit user settings."""
profile = request.user.profile
if request.method == "POST":
locales_form = forms.UserLocalesOrderForm(
request.POST,
instance=request.user.profile,
instance=profile,
)
user_form = forms.UserForm(
request.POST,
@ -221,7 +225,7 @@ def settings(request):
)
user_profile_form = forms.UserProfileForm(
request.POST,
instance=request.user.profile,
instance=profile,
)
if (
@ -233,12 +237,19 @@ def settings(request):
user_form.save()
user_profile_form.save()
if "contact_email" in user_profile_form.changed_data:
profile.contact_email_verified = False
profile.save(update_fields=["contact_email_verified"])
token = generate_verification_token(request.user)
send_verification_email(request, token)
messages.success(request, "Settings saved.")
else:
user_form = forms.UserForm(instance=request.user)
user_profile_form = forms.UserProfileForm(instance=request.user.profile)
user_profile_form = forms.UserProfileForm(instance=profile)
selected_locales = list(request.user.profile.sorted_locales)
selected_locales = list(profile.sorted_locales)
available_locales = Locale.objects.exclude(pk__in=[l.pk for l in selected_locales])
default_homepage_locale = Locale(name="Default homepage", code="")
@ -246,7 +257,7 @@ def settings(request):
all_locales.insert(0, default_homepage_locale)
# Set custom homepage selector value
custom_homepage_locale = request.user.profile.custom_homepage
custom_homepage_locale = profile.custom_homepage
if custom_homepage_locale:
custom_homepage_locale = Locale.objects.filter(
code=custom_homepage_locale
@ -259,7 +270,7 @@ def settings(request):
preferred_locales.insert(0, default_preferred_source_locale)
# Set preferred source locale
preferred_source_locale = request.user.profile.preferred_source_locale
preferred_source_locale = profile.preferred_source_locale
if preferred_source_locale:
preferred_source_locale = Locale.objects.filter(
code=preferred_source_locale
@ -280,12 +291,26 @@ def settings(request):
"user_form": user_form,
"user_profile_form": user_profile_form,
"user_profile_visibility_form": forms.UserProfileVisibilityForm(
instance=request.user.profile
instance=profile
),
},
)
@login_required(redirect_field_name="", login_url="/403")
def verify_email_address(request, token):
title, message = check_verification_token(request.user, token)
return render(
request,
"contributors/verify_email.html",
{
"title": title,
"message": message,
},
)
@login_required(redirect_field_name="", login_url="/403")
def notifications(request):
"""View and edit user notifications."""

Просмотреть файл

@ -39,6 +39,22 @@ ADMINS = MANAGERS = (
# A list of project manager email addresses to send project requests to
PROJECT_MANAGERS = os.environ.get("PROJECT_MANAGERS", "").split(",")
def _get_site_url_netloc():
from urllib.parse import urlparse
from django.conf import settings
return urlparse(settings.SITE_URL).netloc
def _default_from_email():
return os.environ.get(
"DEFAULT_FROM_EMAIL", f"Pontoon <noreply@{_get_site_url_netloc()}>"
)
DEFAULT_FROM_EMAIL = lazy(_default_from_email, str)()
# Email from which new locale requests are sent.
LOCALE_REQUEST_FROM_EMAIL = os.environ.get(
"LOCALE_REQUEST_FROM_EMAIL", "pontoon@example.com"
@ -582,10 +598,7 @@ STATICFILES_DIRS = [
# Set ALLOWED_HOSTS based on SITE_URL setting.
def _allowed_hosts():
from urllib.parse import urlparse
from django.conf import settings
host = urlparse(settings.SITE_URL).netloc # Remove protocol and path
host = _get_site_url_netloc() # Remove protocol and path
result = [host]
# In order to be able to use ALLOWED_HOSTS to validate URLs, we need to
# have a version of the host that contains the port. This only applies

Просмотреть файл

@ -40,6 +40,7 @@ newrelic==6.2.0.156
parsimonious==0.8.1
polib==1.0.6
psycopg2==2.8.5
PyJWT==2.4.0
python-dateutil==2.8.1
python-dotenv==0.17.0
python-levenshtein==0.12.2

Просмотреть файл

@ -470,10 +470,12 @@ pycparser==2.21 \
--hash=sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9 \
--hash=sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206
# via cffi
pyjwt[crypto]==2.3.0 \
--hash=sha256:b888b4d56f06f6dcd777210c334e69c737be74755d3e5e9ee3fe67dc18a0ee41 \
--hash=sha256:e0c4bb8d9f0af0c7f5b1ec4c5036309617d03d56932877f2f7a0beeb5318322f
# via django-allauth
pyjwt[crypto]==2.4.0 \
--hash=sha256:72d1d253f32dbd4f5c88eaf1fdc62f3a19f676ccbadb9dbc5d07e951b2b26daf \
--hash=sha256:d42908208c699b3b973cbeb01a969ba6a96c821eefb1c5bfe4c390c01d67abba
# via
# -r requirements/default.in
# django-allauth
pyparsing==3.0.6 \
--hash=sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4 \
--hash=sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81