Manage member
- -- {% if member %} - {{ member.email }} - {% elif invitation %} - {{ invitation.email }} - {% endif %} -
- -- - - - - -
diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js index bd4bed01b..f5ebc83a3 100644 --- a/src/registrar/assets/src/js/getgov/main.js +++ b/src/registrar/assets/src/js/getgov/main.js @@ -10,8 +10,7 @@ import { initDomainRequestsTable } from './table-domain-requests.js'; import { initMembersTable } from './table-members.js'; import { initMemberDomainsTable } from './table-member-domains.js'; import { initEditMemberDomainsTable } from './table-edit-member-domains.js'; -import { initPortfolioMemberPageToggle } from './portfolio-member-page.js'; -import { initAddNewMemberPageListeners } from './portfolio-member-page.js'; +import { initPortfolioNewMemberPageToggle, initAddNewMemberPageListeners, initPortfolioMemberPageRadio } from './portfolio-member-page.js'; initDomainValidators(); @@ -21,13 +20,6 @@ nameserversFormListener(); hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees'); hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null); -hookupRadioTogglerListener( - 'member_access_level', - { - 'admin': 'new-member-admin-permissions', - 'basic': 'new-member-basic-permissions' - } -); hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null); initializeUrbanizationToggle(); @@ -44,5 +36,7 @@ initMembersTable(); initMemberDomainsTable(); initEditMemberDomainsTable(); -initPortfolioMemberPageToggle(); +// Init the portfolio new member page +initPortfolioMemberPageRadio(); +initPortfolioNewMemberPageToggle(); initAddNewMemberPageListeners(); 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 ba874cfb1..02d927438 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -2,9 +2,10 @@ import { uswdsInitializeModals } from './helpers-uswds.js'; import { getCsrfToken } from './helpers.js'; import { generateKebabHTML } from './table-base.js'; import { MembersTable } from './table-members.js'; +import { hookupRadioTogglerListener } from './radios.js'; // This is specifically for the Member Profile (Manage Member) Page member/invitation removal -export function initPortfolioMemberPageToggle() { +export function initPortfolioNewMemberPageToggle() { document.addEventListener("DOMContentLoaded", () => { const wrapperDeleteAction = document.getElementById("wrapper-delete-action") if (wrapperDeleteAction) { @@ -169,4 +170,29 @@ export function initAddNewMemberPageListeners() { } } -} \ No newline at end of file +} + +// Initalize the radio for the member pages +export function initPortfolioMemberPageRadio() { + document.addEventListener("DOMContentLoaded", () => { + let memberForm = document.getElementById("member_form"); + let newMemberForm = document.getElementById("add_member_form") + if (memberForm) { + hookupRadioTogglerListener( + 'role', + { + 'organization_admin': 'member-admin-permissions', + 'organization_member': 'member-basic-permissions' + } + ); + }else if (newMemberForm){ + hookupRadioTogglerListener( + 'member_access_level', + { + 'admin': 'new-member-admin-permissions', + 'basic': 'new-member-basic-permissions' + } + ); + } + }); +} diff --git a/src/registrar/assets/src/js/getgov/radios.js b/src/registrar/assets/src/js/getgov/radios.js index 248865e8b..055bdf621 100644 --- a/src/registrar/assets/src/js/getgov/radios.js +++ b/src/registrar/assets/src/js/getgov/radios.js @@ -38,21 +38,21 @@ export function hookupYesNoListener(radioButtonName, elementIdToShowIfYes, eleme **/ export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) { // Get the radio buttons - let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]'); + let radioButtons = document.querySelectorAll(`input[name="${radioButtonName}"]`); // Extract the list of all element IDs from the valueToElementMap let allElementIds = Object.values(valueToElementMap); - + function handleRadioButtonChange() { // Find the checked radio button - let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked'); + let radioButtonChecked = document.querySelector(`input[name="${radioButtonName}"]:checked`); let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; // Hide all elements by default allElementIds.forEach(function (elementId) { let element = document.getElementById(elementId); if (element) { - hideElement(element); + hideElement(element); } }); @@ -64,8 +64,8 @@ export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) { } } } - - if (radioButtons.length) { + + if (radioButtons && radioButtons.length) { // Add event listener to each radio button radioButtons.forEach(function (radioButton) { radioButton.addEventListener('change', handleRadioButtonChange); diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index 5309f7263..eaa885a85 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -4,6 +4,7 @@ from django import forms from django.core.validators import RegexValidator from django.core.validators import MaxLengthValidator +from django.utils.safestring import mark_safe from registrar.models import ( PortfolioInvitation, @@ -271,3 +272,210 @@ def clean(self): if admin_member_error in self.errors: del self.errors[admin_member_error] return cleaned_data + + +class BasePortfolioMemberForm(forms.Form): + """Base form for the PortfolioMemberForm and PortfolioInvitedMemberForm""" + + # The label for each of these has a red "required" star. We can just embed that here for simplicity. + required_star = '*' + role = forms.ChoiceField( + choices=[ + # Uses .value because the choice has a different label (on /admin) + (UserPortfolioRoleChoices.ORGANIZATION_ADMIN.value, "Admin access"), + (UserPortfolioRoleChoices.ORGANIZATION_MEMBER.value, "Basic access"), + ], + widget=forms.RadioSelect, + required=True, + error_messages={ + "required": "Member access level is required", + }, + ) + + domain_request_permission_admin = forms.ChoiceField( + label=mark_safe(f"Select permission {required_star}"), # nosec + choices=[ + (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), + (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"), + ], + widget=forms.RadioSelect, + required=False, + error_messages={ + "required": "Admin domain request permission is required", + }, + ) + + member_permission_admin = forms.ChoiceField( + label=mark_safe(f"Select permission {required_star}"), # nosec + choices=[ + (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"), + (UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "View all members plus manage members"), + ], + widget=forms.RadioSelect, + required=False, + error_messages={ + "required": "Admin member permission is required", + }, + ) + + domain_request_permission_member = forms.ChoiceField( + label=mark_safe(f"Select permission {required_star}"), # nosec + choices=[ + (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), + (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"), + ("no_access", "No access"), + ], + widget=forms.RadioSelect, + required=False, + error_messages={ + "required": "Basic member permission is required", + }, + ) + + # Tracks what form elements are required for a given role choice. + # All of the fields included here have "required=False" by default as they are conditionally required. + # see def clean() for more details. + ROLE_REQUIRED_FIELDS = { + UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ + "domain_request_permission_admin", + "member_permission_admin", + ], + UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ + "domain_request_permission_member", + ], + } + + def __init__(self, *args, instance=None, **kwargs): + """Initialize self.instance, self.initial, and descriptions under each radio button. + Uses map_instance_to_initial to set the initial dictionary.""" + super().__init__(*args, **kwargs) + if instance: + self.instance = instance + self.initial = self.map_instance_to_initial(self.instance) + # Adds a
description beneath each role option + self.fields["role"].descriptions = { + "organization_admin": UserPortfolioRoleChoices.get_role_description( + UserPortfolioRoleChoices.ORGANIZATION_ADMIN + ), + "organization_member": UserPortfolioRoleChoices.get_role_description( + UserPortfolioRoleChoices.ORGANIZATION_MEMBER + ), + } + + def save(self): + """Saves self.instance by grabbing data from self.cleaned_data. + Uses map_cleaned_data_to_instance. + """ + self.instance = self.map_cleaned_data_to_instance(self.cleaned_data, self.instance) + self.instance.save() + return self.instance + + def clean(self): + """Validates form data based on selected role and its required fields.""" + cleaned_data = super().clean() + role = cleaned_data.get("role") + + # 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: + # Helpful error for if this breaks + if field_name not in self.fields: + raise ValueError(f"ROLE_REQUIRED_FIELDS referenced a non-existent field: {field_name}.") + + if not cleaned_data.get(field_name): + self.add_error(field_name, self.fields.get(field_name).error_messages.get("required")) + + # Edgecase: Member uses a special form value for None called "no_access". + if cleaned_data.get("domain_request_permission_member") == "no_access": + cleaned_data["domain_request_permission_member"] = None + + return cleaned_data + + # Explanation of how map_instance_to_initial / map_cleaned_data_to_instance work: + # map_instance_to_initial => called on init to set self.initial. + # Converts the incoming object (usually PortfolioInvitation or UserPortfolioPermission) + # into a dictionary representation for the form to use automatically. + + # map_cleaned_data_to_instance => called on save() to save the instance to the db. + # Takes the self.cleaned_data dict, and converts this dict back to the object. + + def map_instance_to_initial(self, instance): + """ + Maps self.instance to self.initial, handling roles and permissions. + Returns form data dictionary with appropriate permission levels based on user role: + { + "role": "organization_admin" or "organization_member", + "member_permission_admin": permission level if admin, + "domain_request_permission_admin": permission level if admin, + "domain_request_permission_member": permission level if member + } + """ + # Function variables + form_data = {} + perms = UserPortfolioPermission.get_portfolio_permissions( + instance.roles, instance.additional_permissions, get_list=False + ) + + # Get the available options for roles, domains, and member. + roles = [ + UserPortfolioRoleChoices.ORGANIZATION_ADMIN, + UserPortfolioRoleChoices.ORGANIZATION_MEMBER, + ] + domain_perms = [ + UserPortfolioPermissionChoices.EDIT_REQUESTS, + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + ] + member_perms = [ + UserPortfolioPermissionChoices.EDIT_MEMBERS, + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ] + + # Build form data based on role (which options are available). + # Get which one should be "selected" by assuming that EDIT takes precedence over view, + # and ADMIN takes precedence over MEMBER. + roles = instance.roles or [] + selected_role = next((role for role in roles if role in roles), None) + form_data = {"role": selected_role} + is_admin = selected_role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN + if is_admin: + selected_domain_permission = next((perm for perm in domain_perms if perm in perms), None) + selected_member_permission = next((perm for perm in member_perms if perm in perms), None) + form_data["domain_request_permission_admin"] = selected_domain_permission + form_data["member_permission_admin"] = selected_member_permission + else: + # Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection. + selected_domain_permission = next((perm for perm in domain_perms if perm in perms), "no_access") + form_data["domain_request_permission_member"] = selected_domain_permission + + return form_data + + def map_cleaned_data_to_instance(self, cleaned_data, instance): + """ + Maps self.cleaned_data to self.instance, setting roles and permissions. + Args: + cleaned_data (dict): Cleaned data containing role and permission choices + instance: Instance to update + + Returns: + instance: Updated instance + """ + role = cleaned_data.get("role") + + # Handle roles + instance.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)} + + # Handle EDIT permissions (should be accompanied with a view permission) + if UserPortfolioPermissionChoices.EDIT_MEMBERS in additional_permissions: + additional_permissions.add(UserPortfolioPermissionChoices.VIEW_MEMBERS) + + if UserPortfolioPermissionChoices.EDIT_REQUESTS in additional_permissions: + additional_permissions.add(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS) + + # Only set unique permissions not already defined in the base role + role_permissions = UserPortfolioPermission.get_portfolio_permissions(instance.roles, [], get_list=False) + instance.additional_permissions = list(additional_permissions - role_permissions) + return instance diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index a149a9476..25abb6748 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -110,8 +110,13 @@ def _get_portfolio_permissions(self): return self.get_portfolio_permissions(self.roles, self.additional_permissions) @classmethod - def get_portfolio_permissions(cls, roles, additional_permissions): - """Class method to return a list of permissions based on roles and addtl permissions""" + def get_portfolio_permissions(cls, roles, additional_permissions, get_list=True): + """Class method to return a list of permissions based on roles and addtl permissions. + Params: + roles => An array of roles + additional_permissions => An array of additional_permissions + get_list => If true, returns a list of perms. If false, returns a set of perms. + """ # Use a set to avoid duplicate permissions portfolio_permissions = set() if roles: @@ -119,7 +124,7 @@ def get_portfolio_permissions(cls, roles, additional_permissions): portfolio_permissions.update(cls.PORTFOLIO_ROLE_PERMISSIONS.get(role, [])) if additional_permissions: portfolio_permissions.update(additional_permissions) - return list(portfolio_permissions) + return list(portfolio_permissions) if get_list else portfolio_permissions @classmethod def get_domain_request_permission_display(cls, roles, additional_permissions): diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 3768aa77a..cde28e4bd 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -4,6 +4,9 @@ from django.forms import ValidationError from registrar.utility.waffle import flag_is_active_for_user from django.contrib.auth import get_user_model +import logging + +logger = logging.getLogger(__name__) class UserPortfolioRoleChoices(models.TextChoices): @@ -16,7 +19,28 @@ class UserPortfolioRoleChoices(models.TextChoices): @classmethod def get_user_portfolio_role_label(cls, user_portfolio_role): - return cls(user_portfolio_role).label if user_portfolio_role else None + try: + return cls(user_portfolio_role).label if user_portfolio_role else None + except ValueError: + logger.warning(f"Invalid portfolio role: {user_portfolio_role}") + return f"Unknown ({user_portfolio_role})" + + @classmethod + def get_role_description(cls, user_portfolio_role): + """Returns a detailed description for a given role.""" + descriptions = { + cls.ORGANIZATION_ADMIN: ( + "Grants this member access to the organization-wide information " + "on domains, domain requests, and members. Domain management can be assigned separately." + ), + cls.ORGANIZATION_MEMBER: ( + "Grants this member access to the organization. They can be given extra permissions to view all " + "organization domain requests and submit domain requests on behalf of the organization. Basic access " + "members can’t view all members of an organization or manage them. " + "Domain management can be assigned separately." + ), + } + return descriptions.get(user_portfolio_role) class UserPortfolioPermissionChoices(models.TextChoices): diff --git a/src/registrar/templates/django/forms/widgets/multiple_input.html b/src/registrar/templates/django/forms/widgets/multiple_input.html index 90c241366..cc0e11989 100644 --- a/src/registrar/templates/django/forms/widgets/multiple_input.html +++ b/src/registrar/templates/django/forms/widgets/multiple_input.html @@ -1,3 +1,5 @@ +{% load static custom_filters %} +
{{ description }}
+ {% endif %} + {% endwith %} + {% endif %} + {% endfor %} {% if group %}- {% if member %} - {{ member.email }} - {% elif invitation %} - {{ invitation.email }} - {% endif %} -
- -