diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 93d0673f0..c662f91d7 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1447,6 +1447,7 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin): search_help_text = "Search by first name, last name, email, or portfolio." change_form_template = "django/admin/user_portfolio_permission_change_form.html" + delete_confirmation_template = "django/admin/user_portfolio_permission_delete_confirmation.html" def get_roles(self, obj): readable_roles = obj.get_readable_roles() @@ -1790,6 +1791,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin): autocomplete_fields = ["portfolio"] change_form_template = "django/admin/portfolio_invitation_change_form.html" + delete_confirmation_template = "django/admin/portfolio_invitation_delete_confirmation.html" # Select portfolio invitations to change -> Portfolio invitations def changelist_view(self, request, extra_context=None): @@ -3858,11 +3860,13 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin): # Using variables to get past the linter message1 = f"Cannot delete Domain when in state {obj.state}" message2 = f"This subdomain is being used as a hostname on another domain: {err.note}" + message3 = f"Command failed with note: {err.note}" # Human-readable mappings of ErrorCodes. Can be expanded. error_messages = { # noqa on these items as black wants to reformat to an invalid length ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION: message1, ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION: message2, + ErrorCode.COMMAND_FAILED: message3, } message = "Cannot connect to the registry" diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js index 9d4dd2e51..ae246b05c 100644 --- a/src/registrar/assets/js/uswds-edited.js +++ b/src/registrar/assets/js/uswds-edited.js @@ -5695,19 +5695,35 @@ const createHeaderButton = (header, headerName) => { buttonEl.setAttribute("tabindex", "0"); buttonEl.classList.add(SORT_BUTTON_CLASS); // ICON_SOURCE + // ---- END DOTGOV EDIT + // Change icons on sort, use source from arro_upward and arrow_downward + // buttonEl.innerHTML = Sanitizer.escapeHTML` + // + // `; buttonEl.innerHTML = Sanitizer.escapeHTML` `; + // ---- END DOTGOV EDIT header.appendChild(buttonEl); updateSortLabel(header, headerName); }; diff --git a/src/registrar/assets/src/js/getgov/domain-request-form.js b/src/registrar/assets/src/js/getgov/domain-request-form.js index d9b660a50..b49912fa4 100644 --- a/src/registrar/assets/src/js/getgov/domain-request-form.js +++ b/src/registrar/assets/src/js/getgov/domain-request-form.js @@ -2,11 +2,41 @@ import { submitForm } from './helpers.js'; export function initDomainRequestForm() { document.addEventListener('DOMContentLoaded', function() { - const button = document.getElementById("domain-request-form-submit-button"); - if (button) { - button.addEventListener("click", function () { - submitForm("submit-domain-request-form"); - }); - } + // These are the request steps in DomainRequestWizard, such as current_websites or review + initRequestStepCurrentWebsitesListener(); + initRequestStepReviewListener(); }); +} + +function initRequestStepReviewListener() { + const button = document.getElementById("domain-request-form-submit-button"); + if (button) { + button.addEventListener("click", function () { + submitForm("submit-domain-request-form"); + }); + } +} + +function initRequestStepCurrentWebsitesListener() { + //register-form-step + const addAnotherSiteButton = document.getElementById("submit-domain-request--site-button"); + if (addAnotherSiteButton) { + // Check for focus state in sessionStorage + const focusTarget = sessionStorage.getItem("lastFocusedElement"); + if (focusTarget) { + document.querySelector(focusTarget)?.focus(); + } + // Add form submit handler to store focus state + const form = document.querySelector("form"); + if (form) { + form.addEventListener("submit", () => { + const activeElement = document.activeElement; + if (activeElement) { + sessionStorage.setItem("lastFocusedElement", "#" + activeElement.id); + } + }); + } + // We only want to do this action once, so we clear out the session + sessionStorage.removeItem("lastFocusedElement"); + } } \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov/helpers.js b/src/registrar/assets/src/js/getgov/helpers.js index 7d1449bac..08be011c2 100644 --- a/src/registrar/assets/src/js/getgov/helpers.js +++ b/src/registrar/assets/src/js/getgov/helpers.js @@ -96,3 +96,14 @@ export function submitForm(form_id) { console.error("Form '" + form_id + "' not found."); } } + +/** + * Helper function to strip HTML tags + * THIS IS NOT SUITABLE FOR SANITIZING DANGEROUS STRINGS + */ +export function unsafeStripHtmlTags(input) { + const tempDiv = document.createElement("div"); + // NOTE: THIS IS NOT SUITABLE FOR SANITIZING DANGEROUS STRINGS + tempDiv.innerHTML = input; + return tempDiv.textContent || tempDiv.innerText || ""; +} diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index 95723fc7e..96961e5dc 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -18,7 +18,7 @@ export function initPortfolioNewMemberPageToggle() { const unique_id = `${member_type}-${member_id}`; let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; - wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `More Options for ${member_name}`); + wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `More Options for ${member_name}`, "usa-icon--large"); // This easter egg is only for fixtures that dont have names as we are displaying their emails // All prod users will have emails linked to their account @@ -100,8 +100,8 @@ export function initAddNewMemberPageListeners() { const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); permissionSections.forEach(section => { - // Find the
This member is assigned to ${num_domains} domain${num_domains > 1 ? 's' : ''}:
`; + domainsHTML += `This member is assigned to ${num_domains} domain${num_domains > 1 ? 's' : ''}:
`; domainsHTML += "This member is assigned to 0 domains.
`; } + // If there are more than 6 domains, display a "View assigned domains" link + domainsHTML += ``; + + domainsHTML += "Member access: Admin
`; + } else { + permissionsHTML += `Member access: Basic
`; + } // Check domain-related permissions if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)) { - permissionsHTML += `Domains: Can view all organization domains. Can manage domains they are assigned to and edit information about the domain (including DNS settings).
`; + permissionsHTML += `Domains: Viewer
`; } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)) { - permissionsHTML += `Domains: Can manage domains they are assigned to and edit information about the domain (including DNS settings).
`; + permissionsHTML += `Domains: Viewer, limited
`; } // Check request-related permissions if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_REQUESTS)) { - permissionsHTML += `Domain requests: Can view all organization domain requests. Can create domain requests and modify their own requests.
`; + permissionsHTML += `Domain requests: Creator
`; } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)) { - permissionsHTML += `Domain requests (view-only): Can view all organization domain requests. Can't create or modify any domain requests.
`; + permissionsHTML += `Domain requests: Viewer
`; + } else { + permissionsHTML += `Domain requests: No access
`; } // Check member-related permissions if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) { - permissionsHTML += `Members: Can manage members including inviting new members, removing current members, and assigning domains to members.
`; + permissionsHTML += `Members: Manager
`; } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) { - permissionsHTML += `Members (view-only): Can view all organizational members. Can't manage any members.
`; + permissionsHTML += `Members: Viewer
`; + } else { + permissionsHTML += `Members: No access
`; } // If no specific permissions are assigned, display a message indicating no additional permissions if (!permissionsHTML) { - permissionsHTML += `No additional permissions: There are no additional permissions for this member.
`; + permissionsHTML += `No additional permissions: There are no additional permissions for this member.
`; } // Add a permissions header and wrap the entire output in a container - permissionsHTML = `styling in django admin @@ -839,6 +830,17 @@ div.dja__model-description{ text-transform: capitalize; } +.module caption { + // Match the old
description beneath each option self.fields["domain_permissions"].descriptions = { - UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: "Can view only the domains they manage", - UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: "Can view all domains for the organization", + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: get_domains_description_display( + UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None + ), + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: get_domains_description_display( + UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS] + ), } self.fields["domain_request_permissions"].descriptions = { UserPortfolioPermissionChoices.EDIT_REQUESTS.value: ( - "Can view all domain requests for the organization and create requests" + get_domain_requests_description_display( + UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.EDIT_REQUESTS] + ) ), - UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: "Can view all domain requests for the organization", - "no_access": "Cannot view or create domain requests", + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: ( + get_domain_requests_description_display( + UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS] + ) + ), + "no_access": get_domain_requests_description_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None), } self.fields["member_permissions"].descriptions = { - UserPortfolioPermissionChoices.VIEW_MEMBERS.value: "Can view all members permissions", - "no_access": "Cannot view member permissions", + UserPortfolioPermissionChoices.VIEW_MEMBERS.value: get_members_description_display( + UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_MEMBERS] + ), + "no_access": get_members_description_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None), } # Map model instance values to custom form fields @@ -218,6 +262,9 @@ class BasePortfolioMemberForm(forms.ModelForm): cleaned_data = super().clean() role = cleaned_data.get("role") + # handle role + cleaned_data["roles"] = [role] if role else [] + # Get required fields for the selected role. Then validate all required fields for the role. required_fields = self.ROLE_REQUIRED_FIELDS.get(role, []) for field_name in required_fields: @@ -236,9 +283,6 @@ class BasePortfolioMemberForm(forms.ModelForm): if cleaned_data.get("member_permissions") == "no_access": cleaned_data["member_permissions"] = None - # Handle roles - cleaned_data["roles"] = [role] - # Handle additional_permissions valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, []) additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)} diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 42310c3bb..d3c0ed347 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -2,6 +2,7 @@ from itertools import zip_longest import logging import ipaddress import re +import time from datetime import date, timedelta from typing import Optional from django.db import transaction @@ -750,11 +751,7 @@ class Domain(TimeStampedModel, DomainHelper): successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount - try: - self._delete_hosts_if_not_used(hostsToDelete=deleted_values) - except Exception as e: - # we don't need this part to succeed in order to continue. - logger.error("Failed to delete nameserver hosts: %s", e) + self._delete_hosts_if_not_used(hostsToDelete=deleted_values) if successTotalNameservers < 2: try: @@ -1038,13 +1035,13 @@ class Domain(TimeStampedModel, DomainHelper): logger.error(f"registry error removing client hold: {err}") raise (err) - def _delete_domain(self): + def _delete_domain(self): # noqa """This domain should be deleted from the registry may raises RegistryError, should be caught or handled correctly by caller""" logger.info("Deleting subdomains for %s", self.name) # check if any subdomains are in use by another domain - hosts = Host.objects.filter(name__regex=r".+{}".format(self.name)) + hosts = Host.objects.filter(name__regex=r".+\.{}".format(self.name)) for host in hosts: if host.domain != self: logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain) @@ -1052,38 +1049,119 @@ class Domain(TimeStampedModel, DomainHelper): code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, note=f"Host {host.name} is in use by {host.domain}", ) + try: + # set hosts to empty list so nameservers are deleted + ( + deleted_values, + updated_values, + new_values, + oldNameservers, + ) = self.getNameserverChanges(hosts=[]) - ( - deleted_values, - updated_values, - new_values, - oldNameservers, - ) = self.getNameserverChanges(hosts=[]) - - _ = self._update_host_values(updated_values, oldNameservers) # returns nothing, just need to be run and errors - addToDomainList, _ = self.createNewHostList(new_values) - deleteHostList, _ = self.createDeleteHostList(deleted_values) - responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList) - + # update the hosts + _ = self._update_host_values( + updated_values, oldNameservers + ) # returns nothing, just need to be run and errors + addToDomainList, _ = self.createNewHostList(new_values) + deleteHostList, _ = self.createDeleteHostList(deleted_values) + responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList) + except RegistryError as e: + logger.error(f"Error trying to delete hosts from domain {self}: {e}") + raise e # if unable to update domain raise error and stop if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY: raise NameserverError(code=nsErrorCodes.BAD_DATA) + logger.info("Finished removing nameservers from domain") + # addAndRemoveHostsFromDomain removes the hosts from the domain object, # but we still need to delete the object themselves self._delete_hosts_if_not_used(hostsToDelete=deleted_values) + logger.info("Finished _delete_hosts_if_not_used inside _delete_domain()") + # delete the non-registrant contacts logger.debug("Deleting non-registrant contacts for %s", self.name) contacts = PublicContact.objects.filter(domain=self) - for contact in contacts: - if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT: - self._update_domain_with_contact(contact, rem=True) - request = commands.DeleteContact(contact.registry_id) - registry.send(request, cleaned=True) + logger.info(f"retrieved contacts for domain: {contacts}") - logger.info("Deleting domain %s", self.name) + for contact in contacts: + try: + if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT: + logger.info(f"Deleting contact: {contact}") + try: + self._update_domain_with_contact(contact, rem=True) + except Exception as e: + logger.error(f"Error while updating domain with contact: {contact}, e: {e}", exc_info=True) + request = commands.DeleteContact(contact.registry_id) + registry.send(request, cleaned=True) + logger.info(f"sent DeleteContact for {contact}") + except RegistryError as e: + logger.error(f"Error deleting contact: {contact}, {e}", exc_info=True) + + logger.info(f"Finished deleting contacts for {self.name}") + + # delete ds data if it exists + if self.dnssecdata: + logger.debug("Deleting ds data for %s", self.name) + try: + # set and unset client hold to be able to change ds data + logger.info("removing client hold") + self._remove_client_hold() + self.dnssecdata = None + logger.info("placing client hold") + self._place_client_hold() + except RegistryError as e: + logger.error("Error deleting ds data for %s: %s", self.name, e) + e.note = "Error deleting ds data for %s" % self.name + raise e + + # check if the domain can be deleted + if not self._domain_can_be_deleted(): + note = "Domain has associated objects that prevent deletion." + raise RegistryError(code=ErrorCode.COMMAND_FAILED, note=note) + + # delete the domain request = commands.DeleteDomain(name=self.name) - registry.send(request, cleaned=True) + try: + registry.send(request, cleaned=True) + logger.info("Domain %s deleted successfully.", self.name) + except RegistryError as e: + logger.error("Error deleting domain %s: %s", self.name, e) + raise e + + def _domain_can_be_deleted(self, max_attempts=5, wait_interval=2) -> bool: + """ + Polls the registry using InfoDomain calls to confirm that the domain can be deleted. + Returns True if the domain can be deleted, False otherwise. Includes a retry mechanism + using wait_interval and max_attempts, which may be necessary if subdomains and other + associated objects were only recently deleted as the registry may not be immediately updated. + """ + logger.info("Polling registry to confirm deletion pre-conditions for %s", self.name) + last_info_error = None + for attempt in range(max_attempts): + try: + info_response = registry.send(commands.InfoDomain(name=self.name), cleaned=True) + domain_info = info_response.res_data[0] + hosts_associated = getattr(domain_info, "hosts", None) + if hosts_associated is None or len(hosts_associated) == 0: + logger.info("InfoDomain reports no associated hosts for %s. Proceeding with deletion.", self.name) + return True + else: + logger.info("Attempt %d: Domain %s still has hosts: %s", attempt + 1, self.name, hosts_associated) + except RegistryError as info_e: + # If the domain is already gone, we can assume deletion already occurred. + if info_e.code == ErrorCode.OBJECT_DOES_NOT_EXIST: + logger.info("InfoDomain check indicates domain %s no longer exists.", self.name) + raise info_e + logger.warning("Attempt %d: Error during InfoDomain check: %s", attempt + 1, info_e) + time.sleep(wait_interval) + else: + logger.error( + "Exceeded max attempts waiting for domain %s to clear associated objects; last error: %s", + self.name, + last_info_error, + ) + return False def __str__(self) -> str: return self.name @@ -1840,8 +1918,6 @@ class Domain(TimeStampedModel, DomainHelper): else: logger.error("Error _delete_hosts_if_not_used, code was %s error was %s" % (e.code, e)) - raise e - def _fix_unknown_state(self, cleaned): """ _fix_unknown_state: Calls _add_missing_contacts_if_unknown diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py index 99febc92e..fafa99856 100644 --- a/src/registrar/models/portfolio_invitation.py +++ b/src/registrar/models/portfolio_invitation.py @@ -9,8 +9,11 @@ from .utility.portfolio_helper import ( UserPortfolioPermissionChoices, UserPortfolioRoleChoices, cleanup_after_portfolio_member_deletion, + get_domain_requests_description_display, get_domain_requests_display, + get_domains_description_display, get_domains_display, + get_members_description_display, get_members_display, get_role_display, validate_portfolio_invitation, @@ -115,6 +118,16 @@ class PortfolioInvitation(TimeStampedModel): """ return get_domains_display(self.roles, self.additional_permissions) + @property + def domains_description_display(self): + """ + Returns a string description of the user's domain access level. + + Returns: + str: The display name of the user's domain permissions description. + """ + return get_domains_description_display(self.roles, self.additional_permissions) + @property def domain_requests_display(self): """ @@ -129,6 +142,16 @@ class PortfolioInvitation(TimeStampedModel): """ return get_domain_requests_display(self.roles, self.additional_permissions) + @property + def domain_requests_description_display(self): + """ + Returns a string description of the user's access to domain requests. + + Returns: + str: The display name of the user's domain request permissions description. + """ + return get_domain_requests_description_display(self.roles, self.additional_permissions) + @property def members_display(self): """ @@ -143,6 +166,16 @@ class PortfolioInvitation(TimeStampedModel): """ return get_members_display(self.roles, self.additional_permissions) + @property + def members_description_display(self): + """ + Returns a string description of the user's access to managing members. + + Returns: + str: The display name of the user's member management permissions description. + """ + return get_members_description_display(self.roles, self.additional_permissions) + @transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED) def retrieve(self): """When an invitation is retrieved, create the corresponding permission. diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index e077daa57..0a758ff6a 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -7,8 +7,11 @@ from registrar.models.utility.portfolio_helper import ( MemberPermissionDisplay, cleanup_after_portfolio_member_deletion, get_domain_requests_display, + get_domain_requests_description_display, get_domains_display, + get_domains_description_display, get_members_display, + get_members_description_display, get_role_display, validate_user_portfolio_permission, ) @@ -211,6 +214,16 @@ class UserPortfolioPermission(TimeStampedModel): """ return get_domains_display(self.roles, self.additional_permissions) + @property + def domains_description_display(self): + """ + Returns a string description of the user's domain access level. + + Returns: + str: The display name of the user's domain permissions description. + """ + return get_domains_description_display(self.roles, self.additional_permissions) + @property def domain_requests_display(self): """ @@ -225,6 +238,16 @@ class UserPortfolioPermission(TimeStampedModel): """ return get_domain_requests_display(self.roles, self.additional_permissions) + @property + def domain_requests_description_display(self): + """ + Returns a string description of the user's access to domain requests. + + Returns: + str: The display name of the user's domain request permissions description. + """ + return get_domain_requests_description_display(self.roles, self.additional_permissions) + @property def members_display(self): """ @@ -239,6 +262,16 @@ class UserPortfolioPermission(TimeStampedModel): """ return get_members_display(self.roles, self.additional_permissions) + @property + def members_description_display(self): + """ + Returns a string description of the user's access to managing members. + + Returns: + str: The display name of the user's member management permissions description. + """ + return get_members_description_display(self.roles, self.additional_permissions) + def clean(self): """Extends clean method to perform additional validation, which can raise errors in django admin.""" super().clean() diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 041f3b2f3..6863af34e 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -123,6 +123,25 @@ def get_domains_display(roles, permissions): return "Viewer, limited" +def get_domains_description_display(roles, permissions): + """ + Determines the display description for a user's domain viewing permissions. + + Args: + roles (list): A list of role strings assigned to the user. + permissions (list): A list of additional permissions assigned to the user. + + Returns: + str: A string representing the user's domain viewing access description. + """ + UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") + all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions) + if UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS in all_permissions: + return "Can view all domains for the organization" + else: + return "Can view only the domains they manage" + + def get_domain_requests_display(roles, permissions): """ Determines the display name for a user's domain request permissions. @@ -148,6 +167,27 @@ def get_domain_requests_display(roles, permissions): return "No access" +def get_domain_requests_description_display(roles, permissions): + """ + Determines the display description for a user's domain request permissions. + + Args: + roles (list): A list of role strings assigned to the user. + permissions (list): A list of additional permissions assigned to the user. + + Returns: + str: A string representing the user's domain request access level description. + """ + UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") + all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions) + if UserPortfolioPermissionChoices.EDIT_REQUESTS in all_permissions: + return "Can view all domain requests for the organization and create requests" + elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions: + return "Can view all domain requests for the organization" + else: + return "Cannot view or create domain requests" + + def get_members_display(roles, permissions): """ Determines the display name for a user's member management permissions. @@ -173,6 +213,27 @@ def get_members_display(roles, permissions): return "No access" +def get_members_description_display(roles, permissions): + """ + Determines the display description for a user's member management permissions. + + Args: + roles (list): A list of role strings assigned to the user. + permissions (list): A list of additional permissions assigned to the user. + + Returns: + str: A string representing the user's member management access level description. + """ + UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission") + all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions) + if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions: + return "Can view and manage all member permissions" + elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions: + return "Can view all member permissions" + else: + return "Cannot view member permissions" + + def validate_user_portfolio_permission(user_portfolio_permission): """ Validates a UserPortfolioPermission instance. Located in portfolio_helper to avoid circular imports diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index ed7a4dffc..8c9c79876 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -3,9 +3,13 @@ Contains middleware used in settings.py """ import logging +import re from urllib.parse import parse_qs +from django.conf import settings +from django.core.exceptions import PermissionDenied from django.urls import reverse from django.http import HttpResponseRedirect +from django.urls import resolve from registrar.models import User from waffle.decorators import flag_is_active @@ -170,3 +174,51 @@ class CheckPortfolioMiddleware: request.session["portfolio"] = request.user.get_first_portfolio() else: request.session["portfolio"] = request.user.get_first_portfolio() + + +class RestrictAccessMiddleware: + """ + Middleware that blocks access to all views unless explicitly permitted. + + This middleware enforces authentication by default. Views must explicitly allow access + using access control mechanisms such as the `@grant_access` decorator. Exceptions are made + for Django admin views, explicitly ignored paths, and views that opt out of login requirements. + """ + + def __init__(self, get_response): + self.get_response = get_response + # Compile regex patterns from settings to identify paths that bypass login requirements + self.ignored_paths = [re.compile(pattern) for pattern in getattr(settings, "LOGIN_REQUIRED_IGNORE_PATHS", [])] + + def __call__(self, request): + + # Allow requests to Django Debug Toolbar + if request.path.startswith("/__debug__/"): + return self.get_response(request) + + # Allow requests matching configured ignored paths + if any(pattern.match(request.path) for pattern in self.ignored_paths): + return self.get_response(request) + + # Attempt to resolve the request path to a view function + try: + resolver_match = resolve(request.path_info) + view_func = resolver_match.func + app_name = resolver_match.app_name # Get the app name of the resolved view + except Exception: + # If resolution fails, allow the request to proceed (avoid blocking non-view routes) + return self.get_response(request) + + # Automatically allow access to Django's built-in admin views (excluding custom /admin/* views) + if app_name == "admin": + return self.get_response(request) + + # Allow access if the view explicitly opts out of login requirements + if getattr(view_func, "login_required", True) is False: + return self.get_response(request) + + # Restrict access to views that do not explicitly declare access rules + if not getattr(view_func, "has_explicit_access", False): + raise PermissionDenied # Deny access if the view lacks explicit permission handling + + return self.get_response(request) diff --git a/src/registrar/templates/admin/app_list.html b/src/registrar/templates/admin/app_list.html index aaf3dc423..ecce12a3e 100644 --- a/src/registrar/templates/admin/app_list.html +++ b/src/registrar/templates/admin/app_list.html @@ -4,24 +4,22 @@ {% for app in app_list %}
- {{ app.name }} - | - {% else %} -- {{ app.name }} - | - {% endif %} + {# .gov override: hide headers #} + {% comment %} + {% if show_changelinks %} ++ {{ app.name }} + | + {% else %} ++ {{ app.name }} + | + {% endif %} + {% endcomment %}||||||
---|---|---|---|---|---|---|---|---|---|
Model | @@ -45,16 +43,17 @@ {% endif %} {% if model.add_url %} -{% translate 'Add' %} | + {% comment %} Remove the 's' from the end of the string to avoid text like "Add domain requests" {% endcomment %} +{% translate 'Add' %} | {% else %}{% endif %} {% if model.admin_url and show_changelinks %} {% if model.view_only %} - | {% translate 'View' %} | +{% translate 'View' %} | {% else %} -{% translate 'Change' %} | +{% translate 'Change' %} | {% endif %} {% elif show_changelinks %}@@ -64,9 +63,20 @@ |
Reports | +
---|
Dashboard | +
{% translate 'You don’t have permission to view or edit anything.' %}
diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html index e5b3604b4..a8ef438f9 100644 --- a/src/registrar/templates/admin/base_site.html +++ b/src/registrar/templates/admin/base_site.html @@ -48,6 +48,10 @@ {% endwith %} {% endif %} + {% if opts.model_name %} + Skip to filters + {% endif %} + {# Djando update: this div will change to header #}+ If you cancel the portfolio invitation here, it won't trigger any emails. It also won't remove the user's + portfolio access if they already logged in. Go to the + + User Portfolio Permissions + + table if you want to remove the user from a portfolio. +
++ If you remove someone from a portfolio here, it will not send any emails when you click "Save". +
+{{ description }}
+{{ description }}
{% endif %} {% endwith %} {% endif %} diff --git a/src/registrar/templates/domain_add_user.html b/src/registrar/templates/domain_add_user.html index 04565f61e..8ef167f98 100644 --- a/src/registrar/templates/domain_add_user.html +++ b/src/registrar/templates/domain_add_user.html @@ -16,10 +16,10 @@ Domains- Domain managers can update all information related to a domain within the - .gov registrar, including security email and DNS name servers. + Domain managers can update information related to this domain, including security email and DNS name servers.
- {% else %} -- Domain managers can update all information related to a domain within the - .gov registrar, including contact details, senior official, security email, and DNS name servers. -
- {% endif %}