From 48dcbb380f41736f8c3ec258c30e975ef52644e9 Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Mon, 15 Sep 2025 16:38:58 +0300 Subject: [PATCH 1/3] Add "add email" button to admin user profile --- admin/templates/users/add_email.html | 27 +++++++++++++++++++++++ admin/templates/users/user.html | 1 + admin/users/forms.py | 4 ++++ admin/users/urls.py | 1 + admin/users/views.py | 32 +++++++++++++++++++++++++++- 5 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 admin/templates/users/add_email.html diff --git a/admin/templates/users/add_email.html b/admin/templates/users/add_email.html new file mode 100644 index 00000000000..79ee05bb2fb --- /dev/null +++ b/admin/templates/users/add_email.html @@ -0,0 +1,27 @@ +{% if perms.osf.change_osfuser %} + Add email + +{% endif %} + + diff --git a/admin/templates/users/user.html b/admin/templates/users/user.html index f8afa6bdf59..a0e556777e5 100644 --- a/admin/templates/users/user.html +++ b/admin/templates/users/user.html @@ -20,6 +20,7 @@
{% include "users/reset_password.html" with user=user %} + {% include "users/add_email.html" with user=user %} {% if perms.osf.change_osfuser %} Get password reset link {% if user.confirmed %} diff --git a/admin/users/forms.py b/admin/users/forms.py index e64a648ff77..7dccb2c57a9 100644 --- a/admin/users/forms.py +++ b/admin/users/forms.py @@ -23,3 +23,7 @@ class MergeUserForm(forms.Form): class AddSystemTagForm(forms.Form): system_tag_to_add = forms.CharField(label='system_tag_to_add', min_length=1, max_length=1024, required=True) + + +class AddEmailForm(forms.Form): + new_email = forms.EmailField(label='new_email', required=True) diff --git a/admin/users/urls.py b/admin/users/urls.py index 7f5d55ddb9b..31516a736d2 100644 --- a/admin/users/urls.py +++ b/admin/users/urls.py @@ -25,6 +25,7 @@ re_path(r'^(?P[a-z0-9]+)/get_reset_password/$', views.GetPasswordResetLink.as_view(), name='get-reset-password'), re_path(r'^(?P[a-z0-9]+)/reindex_elastic_user/$', views.UserReindexElastic.as_view(), name='reindex-elastic-user'), + re_path(r'^(?P[a-z0-9]+)/add_email/$', views.UserAddEmail.as_view(), name='add-email'), re_path(r'^(?P[a-z0-9]+)/merge_accounts/$', views.UserMergeAccounts.as_view(), name='merge-accounts'), re_path(r'^(?P[a-z0-9]+)/draft_registrations/$', views.UserDraftRegistrationsList.as_view(), name='draft-registrations'), ] diff --git a/admin/users/views.py b/admin/users/views.py index 9072c66a989..d77e49b23d8 100644 --- a/admin/users/views.py +++ b/admin/users/views.py @@ -43,7 +43,8 @@ EmailResetForm, UserSearchForm, MergeUserForm, - AddSystemTagForm + AddSystemTagForm, + AddEmailForm ) from admin.base.views import GuidView from api.users.services import send_password_reset_email @@ -399,6 +400,35 @@ def form_valid(self, form): return super().form_valid(form) +class UserAddEmail(UserMixin, FormView): + """Allows authorized users to add an email to a user's account and trigger confirmation.""" + permission_required = 'osf.change_osfuser' + raise_exception = True + form_class = AddEmailForm + + def form_valid(self, form): + from osf.exceptions import BlockedEmailError + from django.core.exceptions import ValidationError as DjangoValidationError + from framework.auth.views import send_confirm_email_async + from django.utils import timezone + + user = self.get_object() + address = form.cleaned_data['new_email'].strip().lower() + try: + user.add_unconfirmed_email(address) + + send_confirm_email_async(user, email=address) + user.email_last_sent = timezone.now() + user.save() + messages.success(self.request, f'Added unconfirmed email {address} and sent confirmation email.') + except (DjangoValidationError, ValueError) as e: + messages.error(self.request, f'Invalid email: {getattr(e, "message", str(e))}') + except BlockedEmailError: + messages.error(self.request, 'This email address domain is blocked.') + + return super().form_valid(form) + + class UserMergeAccounts(UserMixin, FormView): """ Allows authorized users to merge a user's accounts using their guid. """ From a058959f9cbbd38739f19fad31f3b828cb4d5fea Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Thu, 30 Oct 2025 15:51:26 +0200 Subject: [PATCH 2/3] add email check --- admin/users/views.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/admin/users/views.py b/admin/users/views.py index d77e49b23d8..55d1383c627 100644 --- a/admin/users/views.py +++ b/admin/users/views.py @@ -18,7 +18,7 @@ from osf.exceptions import UserStateError from osf.models.base import Guid -from osf.models.user import OSFUser +from osf.models.user import OSFUser, Email from osf.models.spam import SpamStatus from framework.auth import get_user from framework.auth.core import generate_verification_key @@ -414,6 +414,19 @@ def form_valid(self, form): user = self.get_object() address = form.cleaned_data['new_email'].strip().lower() + + existing_email = Email.objects.filter(address=address).first() + if existing_email: + if existing_email.user == user: + messages.error(self.request, f'Email {address} is already confirmed for this user.') + else: + messages.error(self.request, f'Email {address} already exists in the system and is associated with another user.') + return super().form_valid(form) + + if address in user.unconfirmed_emails: + messages.error(self.request, f'Email {address} is already pending confirmation for this user.') + return super().form_valid(form) + try: user.add_unconfirmed_email(address) From 1b8c36d4b6b34e64d32566c0b22f9a286b163870 Mon Sep 17 00:00:00 2001 From: Vlad0n20 Date: Thu, 30 Oct 2025 15:56:05 +0200 Subject: [PATCH 3/3] Update imports --- admin/users/views.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/admin/users/views.py b/admin/users/views.py index 55d1383c627..6d90fb9b4c7 100644 --- a/admin/users/views.py +++ b/admin/users/views.py @@ -11,17 +11,19 @@ from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.urls import reverse +from django.utils import timezone from django.core.exceptions import PermissionDenied from django.shortcuts import redirect from django.core.paginator import Paginator from django.core.exceptions import ValidationError -from osf.exceptions import UserStateError +from osf.exceptions import UserStateError, BlockedEmailError from osf.models.base import Guid from osf.models.user import OSFUser, Email from osf.models.spam import SpamStatus from framework.auth import get_user from framework.auth.core import generate_verification_key +from framework.auth.views import send_confirm_email_async from website import search from website.settings import EXTERNAL_IDENTITY_PROFILE @@ -407,10 +409,6 @@ class UserAddEmail(UserMixin, FormView): form_class = AddEmailForm def form_valid(self, form): - from osf.exceptions import BlockedEmailError - from django.core.exceptions import ValidationError as DjangoValidationError - from framework.auth.views import send_confirm_email_async - from django.utils import timezone user = self.get_object() address = form.cleaned_data['new_email'].strip().lower() @@ -434,7 +432,7 @@ def form_valid(self, form): user.email_last_sent = timezone.now() user.save() messages.success(self.request, f'Added unconfirmed email {address} and sent confirmation email.') - except (DjangoValidationError, ValueError) as e: + except (ValidationError, ValueError) as e: messages.error(self.request, f'Invalid email: {getattr(e, "message", str(e))}') except BlockedEmailError: messages.error(self.request, 'This email address domain is blocked.')