From dd95549bf370ec4b6804a889fa725e1d32d0c041 Mon Sep 17 00:00:00 2001
From: George Katsikas <giorgakis.katsikas@gmail.com>
Date: Fri, 16 Aug 2024 10:48:34 +0200
Subject: [PATCH] add profile email verification procedure

fixes #186
---
 ..._profileemail_token_expiration_and_more.py | 17 -------
 scipost_django/profiles/models.py             | 26 +++++++++-
 .../_hx_profile_emails_table_row.html         | 13 +++--
 .../profiles/verify_profile_email.html        | 45 ++++++++++++++++++
 scipost_django/profiles/urls.py               | 17 ++++---
 scipost_django/profiles/views.py              | 47 +++++++++++++++++--
 scipost_django/templates/bi/clock-fill.html   |  8 ++++
 .../email/profiles/verify_profile_email.html  | 26 ++++++++++
 .../email/profiles/verify_profile_email.json  | 11 +++++
 9 files changed, 177 insertions(+), 33 deletions(-)
 create mode 100644 scipost_django/profiles/templates/profiles/verify_profile_email.html
 create mode 100644 scipost_django/templates/bi/clock-fill.html
 create mode 100644 scipost_django/templates/email/profiles/verify_profile_email.html
 create mode 100644 scipost_django/templates/email/profiles/verify_profile_email.json

diff --git a/scipost_django/profiles/migrations/0042_profileemail_token_expiration_and_more.py b/scipost_django/profiles/migrations/0042_profileemail_token_expiration_and_more.py
index 9f57cba80..498000498 100644
--- a/scipost_django/profiles/migrations/0042_profileemail_token_expiration_and_more.py
+++ b/scipost_django/profiles/migrations/0042_profileemail_token_expiration_and_more.py
@@ -5,14 +5,6 @@ from django.db import migrations, models
 from django.utils import timezone
 
 
-def reset_profile_email_verification_tokens(apps, schema_editor):
-    ProfileEmail = apps.get_model("profiles", "ProfileEmail")
-    for email in ProfileEmail.objects.all():
-        email.verification_token = secrets.token_urlsafe(40)
-        email.verified = email.primary and email.still_valid
-        email.save()
-
-
 class Migration(migrations.Migration):
     dependencies = [
         ("profiles", "0041_profile_orcid_authenticated"),
@@ -29,13 +21,4 @@ class Migration(migrations.Migration):
             name="verification_token",
             field=models.CharField(max_length=128, null=True, unique=False),
         ),
-        migrations.RunPython(
-            reset_profile_email_verification_tokens,
-            reverse_code=migrations.RunPython.noop,
-        ),
-        migrations.AlterField(
-            model_name="profileemail",
-            name="verification_token",
-            field=models.CharField(max_length=128, unique=True, null=False),
-        ),
     ]
diff --git a/scipost_django/profiles/models.py b/scipost_django/profiles/models.py
index 9c0ae4758..6301a0a29 100644
--- a/scipost_django/profiles/models.py
+++ b/scipost_django/profiles/models.py
@@ -3,6 +3,7 @@ __license__ = "AGPL v3"
 
 
 import datetime
+import secrets
 from django.contrib.contenttypes.fields import GenericRelation
 from django.db.models import Q
 
@@ -12,6 +13,7 @@ from django.db.models.functions import Concat
 from django.shortcuts import get_object_or_404
 from django.utils import timezone
 
+from mails.utils import DirectMailUtil
 from scipost.behaviors import orcid_validator
 from scipost.constants import TITLE_CHOICES, TITLE_DR
 from scipost.models import Contributor
@@ -213,7 +215,7 @@ class ProfileEmail(models.Model):
     email = models.EmailField()
     still_valid = models.BooleanField(default=True)
     verified = models.BooleanField(default=False)
-    verification_token = models.CharField(max_length=128, unique=True)
+    verification_token = models.CharField(max_length=128, null=True)
     token_expiration = models.DateTimeField(default=timezone.now)
     added_by = models.ForeignKey(
         "scipost.Contributor",
@@ -232,6 +234,28 @@ class ProfileEmail(models.Model):
     def __str__(self):
         return self.email
 
+    def reset_verification_token(self):
+        self.verification_token = secrets.token_urlsafe(40)
+        self.token_expiration = timezone.now() + datetime.timedelta(hours=48)
+        self.save()
+
+    @property
+    def has_token_expired(self):
+        return timezone.now() > self.token_expiration
+
+    def send_verification_email(self):
+        if self.has_token_expired:
+            self.reset_verification_token()
+
+        mail_sender = DirectMailUtil("profiles/verify_profile_email", object=self)
+        mail_sender.send_mail()
+
+    def get_verification_url(self):
+        return reverse(
+            "profiles:verify_profile_email",
+            kwargs={"email_id": self.id, "token": self.verification_token},
+        )
+
 
 def get_profiles(slug):
     """
diff --git a/scipost_django/profiles/templates/profiles/_hx_profile_emails_table_row.html b/scipost_django/profiles/templates/profiles/_hx_profile_emails_table_row.html
index 831d19dd5..d2fc5016c 100644
--- a/scipost_django/profiles/templates/profiles/_hx_profile_emails_table_row.html
+++ b/scipost_django/profiles/templates/profiles/_hx_profile_emails_table_row.html
@@ -20,6 +20,8 @@
 
     {% if profile_mail.verified %}
       <span class="text-success">{% include "bi/check-circle-fill.html" %}</span>
+    {% elif not profile_mail.has_token_expired %}
+      <span class="text-warning">{% include "bi/clock-fill.html" %}</span>
     {% else %}
       <span class="text-danger">{% include "bi/x-circle-fill.html" %}</span>
     {% endif %}
@@ -46,16 +48,17 @@
         </button>
       {% endif %}
 
-      {% comment %} {% if is_mail_owner or perms.scipost.can_verify_profile_emails %}
-        <!-- TODO: Implement proper verification of emails -->
+      {% if is_mail_owner or perms.scipost.can_verify_profile_emails %}
         <button type="button"
                 class="btn btn-sm btn-light py-0"
                 hx-target="closest tr"
                 hx-swap="outerHTML"
+                {% if not profile_mail.has_token_expired %}hx-confirm="Your previous verification code has not expired yet. Are you sure you want to resend the verification email?"{% endif %}
                 hx-confirm="This will send a verification email to the address. Are you sure?"
-                hx-patch="{% url 'profiles:_hx_profile_email_toggle_verified' profile_mail.id %}">Verify</button>
-        <!-- TODO END-->
-      {% endif %} {% endcomment %}
+                hx-patch="{% url 'profiles:_hx_profile_email_request_verification' profile_mail.id %}"
+                {% if profile_mail.verified %}disabled{% endif %}
+                >Verify</button>
+      {% endif %}
 
       {% if is_mail_owner or perms.scipost.can_mark_profile_emails_primary %}
         <button type="button"
diff --git a/scipost_django/profiles/templates/profiles/verify_profile_email.html b/scipost_django/profiles/templates/profiles/verify_profile_email.html
new file mode 100644
index 000000000..12f06961f
--- /dev/null
+++ b/scipost_django/profiles/templates/profiles/verify_profile_email.html
@@ -0,0 +1,45 @@
+{% extends "profiles/base.html" %}
+
+{% block breadcrumb_items %}
+  {{ block.super }}
+  <span class="breadcrumb-item">Email Verification</span>
+{% endblock %}
+
+{% block content %}
+  <h1>Email Verification</h1>
+  <div class="w-100 text-center">
+
+    <div class="fs-2">
+
+      {% if was_previously_verified %}
+        <p>
+          <span class="text-success me-3">{% include "bi/check-circle-fill.html" %}</span>
+          Your email address has already been verified.
+        </p>
+      {% elif profile_email.verified %}
+        <p>
+          <span class="text-success me-3">{% include "bi/check-circle-fill.html" %}</span>
+          Your email address has been successfully verified.
+        </p>
+      {% else %}
+        <p>
+          <span class="text-danger me-3">{% include "bi/x-circle-fill.html" %}</span>
+          Your email address could not be verified.
+        </p>
+
+        {% if profile_email.has_token_expired %}
+          <p class="fs-5">
+            Your token has expired, please request a new one from the <a href="{% url "scipost:personal_page" %}">personal page</a>.
+          </p>
+        {% elif not is_token_correct %}
+          <p class="fs-5">The token you provided is incorrect, please verify that you copied it correctly.</p>
+        {% endif %}
+
+      {% endif %}
+
+    </div>
+
+    <p class="fs-5 text-muted">You may now close this window.</p>
+  </div>
+
+{% endblock %}
diff --git a/scipost_django/profiles/urls.py b/scipost_django/profiles/urls.py
index a328161ec..69fa6aa98 100644
--- a/scipost_django/profiles/urls.py
+++ b/scipost_django/profiles/urls.py
@@ -93,6 +93,11 @@ urlpatterns = [
         "emails/<int:email_id>/",
         include(
             [
+                path(
+                    "delete",
+                    views._hx_profile_email_delete,
+                    name="_hx_profile_email_delete",
+                ),
                 path(
                     "make_primary",
                     views._hx_profile_email_mark_primary,
@@ -104,14 +109,14 @@ urlpatterns = [
                     name="_hx_profile_email_toggle_valid",
                 ),
                 path(
-                    "toggle_verified",
-                    views._hx_profile_email_toggle_verified,
-                    name="_hx_profile_email_toggle_verified",
+                    "request_verification",
+                    views._hx_profile_email_request_verification,
+                    name="_hx_profile_email_request_verification",
                 ),
                 path(
-                    "delete",
-                    views._hx_profile_email_delete,
-                    name="_hx_profile_email_delete",
+                    "verify/<str:token>",
+                    views.verify_profile_email,
+                    name="verify_profile_email",
                 ),
             ]
         ),
diff --git a/scipost_django/profiles/views.py b/scipost_django/profiles/views.py
index 901af4b62..0acf5f389 100644
--- a/scipost_django/profiles/views.py
+++ b/scipost_django/profiles/views.py
@@ -5,6 +5,7 @@ __license__ = "AGPL v3"
 from django.contrib import messages
 from django.contrib.auth.decorators import login_required
 from django.contrib.auth.mixins import UserPassesTestMixin
+from django.core.exceptions import BadRequest
 from django.template.response import TemplateResponse
 from django.urls import reverse, reverse_lazy
 from django.db import transaction
@@ -529,12 +530,25 @@ def _hx_profile_email_toggle_valid(request, email_id):
 
 
 @permission_required_htmx("scipost.can_verify_profile_emails")
-def _hx_profile_email_toggle_verified(request, email_id):
+def _hx_profile_email_request_verification(request, email_id):
     """Toggle verified/unverified status of ProfileEmail."""
     profile_email = get_object_or_404(ProfileEmail, pk=email_id)
-    if request.method == "PATCH":
-        profile_email.verified = not profile_email.verified
-        profile_email.save()
+
+    if not request.method == "PATCH":
+        raise BadRequest("Invalid request method")
+
+    if not profile_email.verified:
+        profile_email.send_verification_email()
+        messages.success(
+            request,
+            f"Verification email sent to {profile_email.email}.",
+        )
+    else:
+        messages.warning(
+            request,
+            f"{profile_email.email} is already verified.",
+        )
+
     return TemplateResponse(
         request,
         "profiles/_hx_profile_emails_table_row.html",
@@ -542,6 +556,31 @@ def _hx_profile_email_toggle_verified(request, email_id):
     )
 
 
+def verify_profile_email(request, email_id, token: str):
+    """Verify a ProfileEmail."""
+    profile_email = get_object_or_404(ProfileEmail, pk=email_id)
+
+    is_token_correct = profile_email.verification_token == token
+    was_previously_verified = profile_email.verified
+    if (
+        not profile_email.has_token_expired
+        and is_token_correct
+        and not was_previously_verified
+    ):
+        profile_email.verified = True
+        profile_email.save()
+
+    return TemplateResponse(
+        request,
+        "profiles/verify_profile_email.html",
+        {
+            "profile_email": profile_email,
+            "is_token_correct": is_token_correct,
+            "was_previously_verified": was_previously_verified,
+        },
+    )
+
+
 @permission_required_htmx("scipost.can_delete_profile_emails")
 def _hx_profile_email_delete(request, email_id):
     """Delete ProfileEmail."""
diff --git a/scipost_django/templates/bi/clock-fill.html b/scipost_django/templates/bi/clock-fill.html
new file mode 100644
index 000000000..d9bdcd85b
--- /dev/null
+++ b/scipost_django/templates/bi/clock-fill.html
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg"
+     width="16"
+     height="16"
+     fill="currentColor"
+     class="bi bi-clock-fill"
+     viewBox="0 0 16 16">
+  <path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71z" />
+</svg>
diff --git a/scipost_django/templates/email/profiles/verify_profile_email.html b/scipost_django/templates/email/profiles/verify_profile_email.html
new file mode 100644
index 000000000..338c8e16b
--- /dev/null
+++ b/scipost_django/templates/email/profiles/verify_profile_email.html
@@ -0,0 +1,26 @@
+<p>Dear {{ object.profile.get_title_display }} {{ object.profile.last_name }},</p>
+
+<p>To verify your email address, please click the following link:</p>
+
+<p>
+  <a style="font-weight: bold;
+            font-size: 2em"
+     href="https://{{ domain }}{{ object.get_verification_url }}">Verify Email</a>
+</p>
+
+<p>
+  If the direct link is not working, you can also copy and paste the following link in your browser: https://{{ domain }}{{ object.get_verification_url }}
+</p>
+
+<p>
+  If you did not request this verification, it is possible that a SciPost Administrator has requested it on your behalf.
+  Rest assured that your email address and SciPost account are safe.
+</p>
+
+<p>
+  Best regards,
+  <br />
+  The SciPost Team.
+</p>
+
+{% include 'email/_footer.html' %}
diff --git a/scipost_django/templates/email/profiles/verify_profile_email.json b/scipost_django/templates/email/profiles/verify_profile_email.json
new file mode 100644
index 000000000..234e0443d
--- /dev/null
+++ b/scipost_django/templates/email/profiles/verify_profile_email.json
@@ -0,0 +1,11 @@
+{
+    "subject": "SciPost: Email address verification",
+    "recipient_list": [
+        "email"
+    ],
+    "bcc": [
+        "registration@"
+    ],
+    "from_name": "SciPost Registration",
+    "from_email": "registration@"
+}
-- 
GitLab