diff --git a/src/registrar/assets/sass/_theme/_buttons.scss b/src/registrar/assets/sass/_theme/_buttons.scss index 02089ec6d..2f4121399 100644 --- a/src/registrar/assets/sass/_theme/_buttons.scss +++ b/src/registrar/assets/sass/_theme/_buttons.scss @@ -44,6 +44,22 @@ a.usa-button.disabled-link:focus { color: #454545 !important } +a.usa-button--unstyled.disabled-link, +a.usa-button--unstyled.disabled-link:hover, +a.usa-button--unstyled.disabled-link:focus { + cursor: not-allowed !important; + outline: none !important; + text-decoration: none !important; +} + +.usa-button--unstyled.disabled-button, +.usa-button--unstyled.disabled-link:hover, +.usa-button--unstyled.disabled-link:focus { + cursor: not-allowed !important; + outline: none !important; + text-decoration: none !important; +} + a.usa-button:not(.usa-button--unstyled, .usa-button--outline) { color: color('white'); } diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index ba5ae22cc..f6378b555 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -142,6 +142,11 @@ views.DomainApplicationDeleteView.as_view(http_method_names=["post"]), name="application-delete", ), + path( + "domain//users//delete", + views.DomainDeleteUserView.as_view(http_method_names=["post"]), + name="domain-user-delete", + ), ] # we normally would guard these with `if settings.DEBUG` but tests run with diff --git a/src/registrar/templates/dashboard_base.html b/src/registrar/templates/dashboard_base.html index 27b5ea717..6dd2ce8fd 100644 --- a/src/registrar/templates/dashboard_base.html +++ b/src/registrar/templates/dashboard_base.html @@ -3,22 +3,22 @@ {% block wrapper %}
- {% block messages %} - {% if messages %} -
    - {% for message in messages %} - - {{ message }} - - {% endfor %} -
- {% endif %} - {% endblock %} - {% block section_nav %}{% endblock %} {% block hero %}{% endblock %} - {% block content %}{% endblock %} + {% block content %} + {% block messages %} + {% if messages %} +
    + {% for message in messages %} +
  • + {{ message }} +
  • + {% endfor %} +
+ {% endif %} + {% endblock %} + {% endblock %}
{% block complementary %}{% endblock %}
diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index 0eecd35b3..e295d2f7e 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -16,10 +16,8 @@

Domain managers

  • There is no limit to the number of domain managers you can add.
  • After adding a domain manager, an email invitation will be sent to that user with instructions on how to set up an account.
  • -
  • To remove a domain manager, contact us for - assistance.
  • All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.
  • +
  • Domains must have at least one domain manager. You can’t remove yourself as a domain manager if you’re the only one assigned to this domain. Add another domain manager before you remove yourself from this domain.
  • {% if domain.permissions %} @@ -30,7 +28,8 @@

    Domain managers

    Email - Role + Role + Action @@ -40,6 +39,61 @@

    Domain managers

    {{ permission.user.email }} {{ permission.role|title }} + + {% if can_delete_users %} + + Remove + + {# Display a custom message if the user is trying to delete themselves #} + {% if permission.user.email == current_user_email %} +
    +
    + {% with domain_name=domain.name|force_escape %} + {% include 'includes/modal.html' with modal_heading="Are you sure you want to remove yourself as a domain manager?" modal_description="You will no longer be able to manage the domain "|add:domain_name|add:"."|safe modal_button=modal_button_self|safe %} + {% endwith %} +
    +
    + {% else %} +
    +
    + {% with email=permission.user.email|default:permission.user|force_escape domain_name=domain.name|force_escape %} + {% include 'includes/modal.html' with modal_heading="Are you sure you want to remove " heading_value=email|add:"?" modal_description=""|add:email|add:" will no longer be able to manage the domain "|add:domain_name|add:"."|safe modal_button=modal_button|safe %} + {% endwith %} +
    +
    + {% endif %} + {% else %} + + {% endif %} + {% endfor %} @@ -66,8 +120,8 @@

    Invitations

    Email Date created - Status - Action + Status + Action @@ -78,8 +132,9 @@

    Invitations

    {{ invitation.created_at|date }} {{ invitation.status|title }} -
    - {% csrf_token %} + + + {% csrf_token %}
    diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index c7a005f97..2400e38f3 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -10,6 +10,9 @@ {# the entire logged in page goes here #}
    + {% block messages %} + {% include "includes/form_messages.html" %} + {% endblock %}

    Manage your domains

    diff --git a/src/registrar/templates/includes/form_messages.html b/src/registrar/templates/includes/form_messages.html index c7b704f67..59ecb4eaa 100644 --- a/src/registrar/templates/includes/form_messages.html +++ b/src/registrar/templates/includes/form_messages.html @@ -2,7 +2,7 @@ {% for message in messages %}

    - {{ message }} + {{ message }}
    diff --git a/src/registrar/tests/test_url_auth.py b/src/registrar/tests/test_url_auth.py index 34f80ac44..3e0514a85 100644 --- a/src/registrar/tests/test_url_auth.py +++ b/src/registrar/tests/test_url_auth.py @@ -23,6 +23,7 @@ "content_type_id": "2", "object_id": "3", "domain": "whitehouse.gov", + "user_pk": "1", } # Our test suite will ignore some namespaces. diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index f8373710c..dcb6ba9e0 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -2662,6 +2662,7 @@ def tearDown(self): super().tearDown() self.user.is_staff = False self.user.save() + User.objects.all().delete() def test_domain_managers(self): response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id})) @@ -2677,6 +2678,183 @@ def test_domain_user_add(self): response = self.client.get(reverse("domain-users-add", kwargs={"pk": self.domain.id})) self.assertContains(response, "Add a domain manager") + def test_domain_user_delete(self): + """Tests if deleting a domain manager works""" + + # Add additional users + dummy_user_1 = User.objects.create( + username="macncheese", + email="cheese@igorville.com", + ) + dummy_user_2 = User.objects.create( + username="pastapizza", + email="pasta@igorville.com", + ) + + role_1 = UserDomainRole.objects.create(user=dummy_user_1, domain=self.domain, role=UserDomainRole.Roles.MANAGER) + role_2 = UserDomainRole.objects.create(user=dummy_user_2, domain=self.domain, role=UserDomainRole.Roles.MANAGER) + + response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id})) + + # Make sure we're on the right page + self.assertContains(response, "Domain managers") + + # Make sure the desired user exists + self.assertContains(response, "cheese@igorville.com") + + # Delete dummy_user_1 + response = self.client.post( + reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": dummy_user_1.id}), follow=True + ) + + # Grab the displayed messages + messages = list(response.context["messages"]) + self.assertEqual(len(messages), 1) + + # Ensure the error we recieve is in line with what we expect + message = messages[0] + self.assertEqual(message.message, "Removed cheese@igorville.com as a manager for this domain.") + self.assertEqual(message.tags, "success") + + # Check that role_1 deleted in the DB after the post + deleted_user_exists = UserDomainRole.objects.filter(id=role_1.id).exists() + self.assertFalse(deleted_user_exists) + + # Ensure that the current user wasn't deleted + current_user_exists = UserDomainRole.objects.filter(user=self.user.id, domain=self.domain).exists() + self.assertTrue(current_user_exists) + + # Ensure that the other userdomainrole was not deleted + role_2_exists = UserDomainRole.objects.filter(id=role_2.id).exists() + self.assertTrue(role_2_exists) + + def test_domain_user_delete_denied_if_no_permission(self): + """Deleting a domain manager is denied if the user has no permission to do so""" + + # Create a domain object + vip_domain = Domain.objects.create(name="freeman.gov") + + # Add users + dummy_user_1 = User.objects.create( + username="bagel", + email="bagel@igorville.com", + ) + dummy_user_2 = User.objects.create( + username="pastapizza", + email="pasta@igorville.com", + ) + + role_1 = UserDomainRole.objects.create(user=dummy_user_1, domain=vip_domain, role=UserDomainRole.Roles.MANAGER) + role_2 = UserDomainRole.objects.create(user=dummy_user_2, domain=vip_domain, role=UserDomainRole.Roles.MANAGER) + + response = self.client.get(reverse("domain-users", kwargs={"pk": vip_domain.id})) + + # Make sure that we can't access the domain manager page normally + self.assertEqual(response.status_code, 403) + + # Try to delete dummy_user_1 + response = self.client.post( + reverse("domain-user-delete", kwargs={"pk": vip_domain.id, "user_pk": dummy_user_1.id}), follow=True + ) + + # Ensure that we are denied access + self.assertEqual(response.status_code, 403) + + # Ensure that the user wasn't deleted + role_1_exists = UserDomainRole.objects.filter(id=role_1.id).exists() + self.assertTrue(role_1_exists) + + # Ensure that the other userdomainrole was not deleted + role_2_exists = UserDomainRole.objects.filter(id=role_2.id).exists() + self.assertTrue(role_2_exists) + + # Make sure that the current user wasn't deleted for some reason + current_user_exists = UserDomainRole.objects.filter(user=dummy_user_1.id, domain=vip_domain.id).exists() + self.assertTrue(current_user_exists) + + def test_domain_user_delete_denied_if_last_man_standing(self): + """Deleting a domain manager is denied if the user is the only manager""" + + # Create a domain object + vip_domain = Domain.objects.create(name="olive-oil.gov") + + # Add the requesting user as the only manager on the domain + UserDomainRole.objects.create(user=self.user, domain=vip_domain, role=UserDomainRole.Roles.MANAGER) + + response = self.client.get(reverse("domain-users", kwargs={"pk": vip_domain.id})) + + # Make sure that we can still access the domain manager page normally + self.assertContains(response, "Domain managers") + + # Make sure that the logged in user exists + self.assertContains(response, "info@example.com") + + # Try to delete the current user + response = self.client.post( + reverse("domain-user-delete", kwargs={"pk": vip_domain.id, "user_pk": self.user.id}), follow=True + ) + + # Ensure that we are denied access + self.assertEqual(response.status_code, 403) + + # Make sure that the current user wasn't deleted + current_user_exists = UserDomainRole.objects.filter(user=self.user.id, domain=vip_domain.id).exists() + self.assertTrue(current_user_exists) + + def test_domain_user_delete_self_redirects_home(self): + """Tests if deleting yourself redirects to home""" + # Add additional users + dummy_user_1 = User.objects.create( + username="macncheese", + email="cheese@igorville.com", + ) + dummy_user_2 = User.objects.create( + username="pastapizza", + email="pasta@igorville.com", + ) + + role_1 = UserDomainRole.objects.create(user=dummy_user_1, domain=self.domain, role=UserDomainRole.Roles.MANAGER) + role_2 = UserDomainRole.objects.create(user=dummy_user_2, domain=self.domain, role=UserDomainRole.Roles.MANAGER) + + response = self.client.get(reverse("domain-users", kwargs={"pk": self.domain.id})) + + # Make sure we're on the right page + self.assertContains(response, "Domain managers") + + # Make sure the desired user exists + self.assertContains(response, "info@example.com") + + # Make sure more than one UserDomainRole exists on this object + self.assertContains(response, "cheese@igorville.com") + + # Delete the current user + response = self.client.post( + reverse("domain-user-delete", kwargs={"pk": self.domain.id, "user_pk": self.user.id}), follow=True + ) + + # Check if we've been redirected to the home page + self.assertContains(response, "Manage your domains") + + # Grab the displayed messages + messages = list(response.context["messages"]) + self.assertEqual(len(messages), 1) + + # Ensure the error we recieve is in line with what we expect + message = messages[0] + self.assertEqual(message.message, "You are no longer managing the domain igorville.gov.") + self.assertEqual(message.tags, "success") + + # Ensure that the current user was deleted + current_user_exists = UserDomainRole.objects.filter(user=self.user.id, domain=self.domain).exists() + self.assertFalse(current_user_exists) + + # Ensure that the other userdomainroles are not deleted + role_1_exists = UserDomainRole.objects.filter(id=role_1.id).exists() + self.assertTrue(role_1_exists) + + role_2_exists = UserDomainRole.objects.filter(id=role_2.id).exists() + self.assertTrue(role_2_exists) + @boto3_mocking.patching def test_domain_user_add_form(self): """Adding an existing user works.""" diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index c1400d7c0..8785c9076 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -12,6 +12,7 @@ DomainUsersView, DomainAddUserView, DomainInvitationDeleteView, + DomainDeleteUserView, ) from .health import * from .index import * diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index b81d268b4..313762ef1 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -33,6 +33,7 @@ SecurityEmailErrorCodes, ) from registrar.models.utility.contact_error import ContactError +from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView from ..forms import ( ContactForm, @@ -630,6 +631,55 @@ class DomainUsersView(DomainBaseView): template_name = "domain_users.html" + def get_context_data(self, **kwargs): + """The initial value for the form (which is a formset here).""" + context = super().get_context_data(**kwargs) + + # Add conditionals to the context (such as "can_delete_users") + context = self._add_booleans_to_context(context) + + # Add modal buttons to the context (such as for delete) + context = self._add_modal_buttons_to_context(context) + + # Get the email of the current user + context["current_user_email"] = self.request.user.email + + return context + + def _add_booleans_to_context(self, context): + # Determine if the current user can delete managers + domain_pk = None + can_delete_users = False + + if self.kwargs is not None and "pk" in self.kwargs: + domain_pk = self.kwargs["pk"] + # Prevent the end user from deleting themselves as a manager if they are the + # only manager that exists on a domain. + can_delete_users = UserDomainRole.objects.filter(domain__id=domain_pk).count() > 1 + + context["can_delete_users"] = can_delete_users + return context + + def _add_modal_buttons_to_context(self, context): + """Adds modal buttons (and their HTML) to the context""" + # Create HTML for the modal button + modal_button = ( + '' + ) + context["modal_button"] = modal_button + + # Create HTML for the modal button when deleting yourself + modal_button_self = ( + '' + ) + context["modal_button_self"] = modal_button_self + + return context + class DomainAddUserView(DomainFormBaseView): """Inside of a domain's user management, a form for adding users. @@ -743,3 +793,60 @@ def get_success_url(self): def get_success_message(self, cleaned_data): return f"Successfully canceled invitation for {self.object.email}." + + +class DomainDeleteUserView(UserDomainRolePermissionDeleteView): + """Inside of a domain's user management, a form for deleting users.""" + + object: UserDomainRole # workaround for type mismatch in DeleteView + + def get_object(self, queryset=None): + """Custom get_object definition to grab a UserDomainRole object from a domain_id and user_id""" + domain_id = self.kwargs.get("pk") + user_id = self.kwargs.get("user_pk") + return UserDomainRole.objects.get(domain=domain_id, user=user_id) + + def get_success_url(self): + """Refreshes the page after a delete is successful""" + return reverse("domain-users", kwargs={"pk": self.object.domain.id}) + + def get_success_message(self, delete_self=False): + """Returns confirmation content for the deletion event""" + + # Grab the text representation of the user we want to delete + email_or_name = self.object.user.email + if email_or_name is None or email_or_name.strip() == "": + email_or_name = self.object.user + + # If the user is deleting themselves, return a specific message. + # If not, return something more generic. + if delete_self: + message = f"You are no longer managing the domain {self.object.domain}." + else: + message = f"Removed {email_or_name} as a manager for this domain." + + return message + + def form_valid(self, form): + """Delete the specified user on this domain.""" + + # Delete the object + super().form_valid(form) + + # Is the user deleting themselves? If so, display a different message + delete_self = self.request.user == self.object.user + + # Add a success message + messages.success(self.request, self.get_success_message(delete_self)) + return redirect(self.get_success_url()) + + def post(self, request, *args, **kwargs): + """Custom post implementation to redirect to home in the event that the user deletes themselves""" + response = super().post(request, *args, **kwargs) + + # If the user is deleting themselves, redirect to home + delete_self = self.request.user == self.object.user + if delete_self: + return redirect(reverse("home")) + + return response diff --git a/src/registrar/views/utility/mixins.py b/src/registrar/views/utility/mixins.py index 0cf5970df..b2c4cb364 100644 --- a/src/registrar/views/utility/mixins.py +++ b/src/registrar/views/utility/mixins.py @@ -286,6 +286,43 @@ def has_permission(self): return True +class UserDeleteDomainRolePermission(PermissionsLoginMixin): + + """Permission mixin for UserDomainRole if user + has access, otherwise 403""" + + def has_permission(self): + """Check if this user has access to this domain application. + + The user is in self.request.user and the domain needs to be looked + up from the domain's primary key in self.kwargs["pk"] + """ + domain_pk = self.kwargs["pk"] + user_pk = self.kwargs["user_pk"] + + # Check if the user is authenticated + if not self.request.user.is_authenticated: + return False + + # Check if the UserDomainRole object exists, then check + # if the user requesting the delete has permissions to do so + has_delete_permission = UserDomainRole.objects.filter( + user=user_pk, + domain=domain_pk, + domain__permissions__user=self.request.user, + ).exists() + if not has_delete_permission: + return False + + # Check if more than one manager exists on the domain. + # If only one exists, prevent this from happening + has_multiple_managers = len(UserDomainRole.objects.filter(domain=domain_pk)) > 1 + if not has_multiple_managers: + return False + + return True + + class DomainApplicationPermissionWithdraw(PermissionsLoginMixin): """Permission mixin that redirects to withdraw action on domain application diff --git a/src/registrar/views/utility/permission_views.py b/src/registrar/views/utility/permission_views.py index 587eb0b5c..54c96d602 100644 --- a/src/registrar/views/utility/permission_views.py +++ b/src/registrar/views/utility/permission_views.py @@ -4,6 +4,7 @@ from django.views.generic import DetailView, DeleteView, TemplateView from registrar.models import Domain, DomainApplication, DomainInvitation +from registrar.models.user_domain_role import UserDomainRole from .mixins import ( DomainPermission, @@ -11,6 +12,7 @@ DomainApplicationPermissionWithdraw, DomainInvitationPermission, ApplicationWizardPermission, + UserDeleteDomainRolePermission, ) import logging @@ -130,3 +132,20 @@ class DomainApplicationPermissionDeleteView(DomainApplicationPermission, DeleteV model = DomainApplication object: DomainApplication + + +class UserDomainRolePermissionDeleteView(UserDeleteDomainRolePermission, DeleteView, abc.ABC): + + """Abstract base view for deleting a UserDomainRole. + + This abstract view cannot be instantiated. Actual views must specify + `template_name`. + """ + + # DetailView property for what model this is viewing + model = UserDomainRole + # workaround for type mismatch in DeleteView + object: UserDomainRole + + # variable name in template context for the model object + context_object_name = "userdomainrole"