зеркало из https://github.com/mozilla/pontoon.git
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:
Родитель
aad2f844f4
Коммит
49f2e2f17a
|
@ -81,6 +81,10 @@ you create:
|
|||
Maximum number of tasks a Celery worker process can execute before it’s
|
||||
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 }}
|
||||
|
||||
You’re receiving this email because you recently changed a contact email address on Pontoon. If this wasn’t 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
|
||||
|
|
Загрузка…
Ссылка в новой задаче