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 @@ import logging
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 @@ class NewMemberForm(forms.ModelForm):
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 @@ class UserPortfolioPermission(TimeStampedModel):
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 @@ class UserPortfolioPermission(TimeStampedModel):
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.apps import apps
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 %}
+
{% for group, options, index in widget.optgroups %}
{% if group %}
{% endif %}
@@ -13,7 +15,17 @@
+ >
+ {{ option.label }}
+ {% comment %} Add a description on each, if available {% endcomment %}
+ {% if field and field.field and field.field.descriptions %}
+ {% with description=field.field.descriptions|get_dict_value:option.value %}
+ {% if description %}
+
{{ description }}
+ {% endif %}
+ {% endwith %}
+ {% endif %}
+
{% endfor %}
{% if group %}