diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f65007b2b..dec0b9fac 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -48,7 +48,7 @@ All other changes require just a single approving review.--> - [ ] Added at least 2 developers as PR reviewers (only 1 will need to approve) - [ ] Messaged on Slack or in standup to notify the team that a PR is ready for review - [ ] Changes to “how we do things” are documented in READMEs and or onboarding guide -- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the assoicated migrations file has been commited. +- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the associated migrations file has been commited. #### Ensured code standards are met (Original Developer) @@ -72,7 +72,7 @@ All other changes require just a single approving review.--> - [ ] Reviewed this code and left comments - [ ] Checked that all code is adequately covered by tests - [ ] Made it clear which comments need to be addressed before this work is merged -- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the assoicated migrations file has been commited. +- [ ] If any model was updated to modify/add/delete columns, makemigrations was ran and the associated migrations file has been commited. #### Ensured code standards are met (Code reviewer) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index cdbbc83ee..866c7bd7d 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -283,19 +283,20 @@ function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk, (function (){ // Get the current date in the format YYYY-MM-DD - var currentDate = new Date().toISOString().split('T')[0]; + let currentDate = new Date().toISOString().split('T')[0]; // Default the value of the start date input field to the current date let startDateInput =document.getElementById('start'); - startDateInput.value = currentDate; - + // Default the value of the end date input field to the current date let endDateInput =document.getElementById('end'); - endDateInput.value = currentDate; let exportGrowthReportButton = document.getElementById('exportLink'); if (exportGrowthReportButton) { + startDateInput.value = currentDate; + endDateInput.value = currentDate; + exportGrowthReportButton.addEventListener('click', function() { // Get the selected start and end dates let startDate = startDateInput.value; diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index 52f88bb1d..587b95305 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -219,8 +219,8 @@ function validateFormsetInputs(e, availabilityButton) { // Run validators for each input inputs.forEach(input => { - runValidators(input); removeFormErrors(input, true); + runValidators(input); }); // Set the validate-for attribute on the button with the collected input IDs 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 @@ urlpatterns = [ 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/application_dotgov_domain.html b/src/registrar/templates/application_dotgov_domain.html index f5b31fb15..39f9935c2 100644 --- a/src/registrar/templates/application_dotgov_domain.html +++ b/src/registrar/templates/application_dotgov_domain.html @@ -48,7 +48,6 @@ {% endwith %} {% endwith %} ' + ) + 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 @@ class DomainInvitationDeleteView(DomainInvitationPermissionDeleteView, SuccessMe 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 @@ class DomainApplicationPermission(PermissionsLoginMixin): 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 @@ import abc # abstract base class 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 @@ from .mixins import ( 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"