";
+ }
+
+ return domainsHTML;
+ }
+
+ /**
+ * Generates an HTML string summarizing a user's additional permissions within a portfolio,
+ * based on the user's permissions and predefined permission choices.
+ *
+ * @param {Array} member_permissions - An array of permission strings that the member has.
+ * @param {Object} UserPortfolioPermissionChoices - An object containing predefined permission choice constants.
+ * Expected keys include:
+ * - VIEW_ALL_DOMAINS
+ * - VIEW_MANAGED_DOMAINS
+ * - EDIT_REQUESTS
+ * - VIEW_ALL_REQUESTS
+ * - EDIT_MEMBERS
+ * - VIEW_MEMBERS
+ *
+ * @returns {string} - A string of HTML representing the user's additional permissions.
+ * If the user has no specific permissions, it returns a default message
+ * indicating no additional permissions.
+ *
+ * Behavior:
+ * - The function checks the user's permissions (`member_permissions`) and generates
+ * corresponding HTML sections based on the permission choices defined in `UserPortfolioPermissionChoices`.
+ * - Permissions are categorized into domains, requests, and members:
+ * - Domains: Determines whether the user can view or manage all or assigned domains.
+ * - Requests: Differentiates between users who can edit requests, view all requests, or have no request privileges.
+ * - Members: Distinguishes between members who can manage or only view other members.
+ * - If no relevant permissions are found, the function returns a message stating that the user has no additional permissions.
+ * - The resulting HTML always includes a header "Additional permissions for this member" and appends the relevant permission descriptions.
+ */
+ generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices) {
+ let permissionsHTML = '';
+
+ // 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).
Members (view-only): Can view all organizational members. Can't manage any members.
";
+ }
+
+ // 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.
";
+ }
+
+ // Add a permissions header and wrap the entire output in a container
+ permissionsHTML = "
Additional permissions for this member
" + permissionsHTML + "
";
+
+ return permissionsHTML;
+ }
+
/**
* Loads rows in the members list, as well as updates pagination around the members list
* based on the supplied attributes.
@@ -1902,7 +2092,7 @@ class MembersTable extends LoadTableBase {
// --------- FETCH DATA
- // fetch json of page of domais, given params
+ // fetch json of page of domains, given params
let baseUrl = document.getElementById("get_members_json_url");
if (!baseUrl) {
return;
@@ -1926,42 +2116,23 @@ class MembersTable extends LoadTableBase {
this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
// identify the DOM element where the domain list will be inserted into the DOM
- const memberList = document.querySelector('.members__table tbody');
+ const memberList = document.querySelector('#members tbody');
memberList.innerHTML = '';
+ const UserPortfolioPermissionChoices = data.UserPortfolioPermissionChoices;
const invited = 'Invited';
+ const invalid_date = 'Invalid date';
data.members.forEach(member => {
+ const member_id = member.source + member.id;
const member_name = member.name;
const member_display = member.member_display;
- const options = { year: 'numeric', month: 'short', day: 'numeric' };
+ const member_permissions = member.permissions;
+ const domain_urls = member.domain_urls;
+ const domain_names = member.domain_names;
+ const num_domains = domain_urls.length;
- // Handle last_active values
- let last_active = member.last_active;
- let last_active_formatted = '';
- let last_active_sort_value = '';
-
- // Handle 'Invited' or null/empty values differently from valid dates
- if (last_active && last_active !== invited) {
- try {
- // Try to parse the last_active as a valid date
- last_active = new Date(last_active);
- if (!isNaN(last_active)) {
- last_active_formatted = last_active.toLocaleDateString('en-US', options);
- last_active_sort_value = last_active.getTime(); // For sorting purposes
- } else {
- last_active_formatted='Invalid date'
- }
- } catch (e) {
- console.error(`Error parsing date: ${last_active}. Error: ${e}`);
- last_active_formatted='Invalid date'
- }
- } else {
- // Handle 'Invited' or null
- last_active = invited;
- last_active_formatted = invited;
- last_active_sort_value = invited; // Keep 'Invited' as a sortable string
- }
+ const last_active = this.handleLastActive(member.last_active);
const action_url = member.action_url;
const action_label = member.action_label;
@@ -1973,14 +2144,42 @@ class MembersTable extends LoadTableBase {
if (member.is_admin)
admin_tagHTML = `Admin`
+ // generate html blocks for domains and permissions for the member
+ let domainsHTML = this.generateDomainsHTML(num_domains, domain_names, domain_urls, action_url);
+ let permissionsHTML = this.generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices);
+
+ // domainsHTML block and permissionsHTML block need to be wrapped with hide/show toggle, Expand
+ let showMoreButton = '';
+ const showMoreRow = document.createElement('tr');
+ if (domainsHTML || permissionsHTML) {
+ showMoreButton = `
+
+ `;
+
+ showMoreRow.innerHTML = `
`;
memberList.appendChild(row);
+ if (domainsHTML || permissionsHTML) {
+ memberList.appendChild(showMoreRow);
+ }
});
+ this.initShowMoreButtons();
+
// Do not scroll on first page load
if (scroll)
ScrollToElement('class', 'members');
@@ -2017,14 +2221,121 @@ class MembersTable extends LoadTableBase {
}
}
+class MemberDomainsTable extends LoadTableBase {
+
+ constructor() {
+ super('member-domains');
+ this.currentSortBy = 'name';
+ }
+ /**
+ * Loads rows in the members list, as well as updates pagination around the members list
+ * based on the supplied attributes.
+ * @param {*} page - the page number of the results (starts with 1)
+ * @param {*} sortBy - the sort column option
+ * @param {*} order - the sort order {asc, desc}
+ * @param {*} scroll - control for the scrollToElement functionality
+ * @param {*} searchTerm - the search term
+ * @param {*} portfolio - the portfolio id
+ */
+ loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) {
+
+ // --------- SEARCH
+ let searchParams = new URLSearchParams(
+ {
+ "page": page,
+ "sort_by": sortBy,
+ "order": order,
+ "search_term": searchTerm,
+ }
+ );
+
+ let emailValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-email') : null;
+ let memberIdValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-id') : null;
+ let memberOnly = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-only') : null;
+
+ if (portfolio)
+ searchParams.append("portfolio", portfolio)
+ if (emailValue)
+ searchParams.append("email", emailValue)
+ if (memberIdValue)
+ searchParams.append("member_id", memberIdValue)
+ if (memberOnly)
+ searchParams.append("member_only", memberOnly)
+
+
+ // --------- FETCH DATA
+ // fetch json of page of domais, given params
+ let baseUrl = document.getElementById("get_member_domains_json_url");
+ if (!baseUrl) {
+ return;
+ }
+
+ let baseUrlValue = baseUrl.innerHTML;
+ if (!baseUrlValue) {
+ return;
+ }
+
+ let url = `${baseUrlValue}?${searchParams.toString()}` //TODO: uncomment for search function
+ fetch(url)
+ .then(response => response.json())
+ .then(data => {
+ if (data.error) {
+ console.error('Error in AJAX call: ' + data.error);
+ return;
+ }
+
+ // handle the display of proper messaging in the event that no members exist in the list or search returns no results
+ this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm);
+
+ // identify the DOM element where the domain list will be inserted into the DOM
+ const memberDomainsList = document.querySelector('#member-domains tbody');
+ memberDomainsList.innerHTML = '';
+
+
+ data.domains.forEach(domain => {
+ const row = document.createElement('tr');
+
+ row.innerHTML = `
+
+ ${domain.name}
+
+ `;
+ memberDomainsList.appendChild(row);
+ });
+
+ // Do not scroll on first page load
+ if (scroll)
+ ScrollToElement('class', 'member-domains');
+ this.scrollToTable = true;
+
+ // update pagination
+ this.updatePagination(
+ 'member domain',
+ '#member-domains-pagination',
+ '#member-domains-pagination .usa-pagination__counter',
+ '#member-domains',
+ data.page,
+ data.num_pages,
+ data.has_previous,
+ data.has_next,
+ data.total,
+ );
+ this.currentSortBy = sortBy;
+ this.currentOrder = order;
+ this.currentSearchTerm = searchTerm;
+ })
+ .catch(error => console.error('Error fetching domains:', error));
+ }
+}
+
/**
* An IIFE that listens for DOM Content to be loaded, then executes. This function
- * initializes the domains list and associated functionality on the home page of the app.
+ * initializes the domains list and associated functionality.
*
*/
document.addEventListener('DOMContentLoaded', function() {
- const isDomainsPage = document.querySelector("#domains")
+ const isDomainsPage = document.getElementById("domains")
if (isDomainsPage){
const domainsTable = new DomainsTable();
if (domainsTable.tableWrapper) {
@@ -2036,11 +2347,11 @@ document.addEventListener('DOMContentLoaded', function() {
/**
* An IIFE that listens for DOM Content to be loaded, then executes. This function
- * initializes the domain requests list and associated functionality on the home page of the app.
+ * initializes the domain requests list and associated functionality.
*
*/
document.addEventListener('DOMContentLoaded', function() {
- const domainRequestsSectionWrapper = document.querySelector('.domain-requests');
+ const domainRequestsSectionWrapper = document.getElementById('domain-requests');
if (domainRequestsSectionWrapper) {
const domainRequestsTable = new DomainRequestsTable();
if (domainRequestsTable.tableWrapper) {
@@ -2093,11 +2404,11 @@ const utcDateString = (dateString) => {
/**
* An IIFE that listens for DOM Content to be loaded, then executes. This function
- * initializes the domains list and associated functionality on the home page of the app.
+ * initializes the members list and associated functionality.
*
*/
document.addEventListener('DOMContentLoaded', function() {
- const isMembersPage = document.querySelector("#members")
+ const isMembersPage = document.getElementById("members")
if (isMembersPage){
const membersTable = new MembersTable();
if (membersTable.tableWrapper) {
@@ -2107,6 +2418,22 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
+/**
+ * An IIFE that listens for DOM Content to be loaded, then executes. This function
+ * initializes the member domains list and associated functionality.
+ *
+ */
+document.addEventListener('DOMContentLoaded', function() {
+ const isMemberDomainsPage = document.getElementById("member-domains")
+ if (isMemberDomainsPage){
+ const memberDomainsTable = new MemberDomainsTable();
+ if (memberDomainsTable.tableWrapper) {
+ // Initial load
+ memberDomainsTable.loadTable(1);
+ }
+ }
+});
+
/**
* An IIFE that displays confirmation modal on the user profile page
*/
diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js
index 52dc441fc..f59417b41 100644
--- a/src/registrar/assets/js/uswds-edited.js
+++ b/src/registrar/assets/js/uswds-edited.js
@@ -5508,6 +5508,8 @@ const SORT_BUTTON = `.${SORT_BUTTON_CLASS}`;
const SORTABLE_HEADER = `th[data-sortable]`;
const ANNOUNCEMENT_REGION = `.${PREFIX}-table__announcement-region[aria-live="polite"]`;
+// ---- DOTGOV EDIT
+
/** Gets the data-sort-value attribute value, if provided — otherwise, gets
* the innerText or textContent — of the child element (HTMLTableCellElement)
* at the specified index of the given table row
@@ -5516,7 +5518,19 @@ const ANNOUNCEMENT_REGION = `.${PREFIX}-table__announcement-region[aria-live="po
* @param {array} tr
* @return {boolean}
*/
-const getCellValue = (tr, index) => tr.children[index].getAttribute(SORT_OVERRIDE) || tr.children[index].innerText || tr.children[index].textContent;
+const getCellValue = (tr, index) => {
+ if (tr.children[index])
+ return tr.children[index].getAttribute(SORT_OVERRIDE) || tr.children[index].innerText || tr.children[index].textContent;
+ return "";
+}
+
+// const getCellValue = (tr, index) => tr.children[index].getAttribute(SORT_OVERRIDE) || tr.children[index].innerText || tr.children[index].textContent;
+// DOTGOV: added check for tr.children[index] to protect from absent cells
+
+// ---- END DOTGOV EDIT
+
+
+
/**
* Compares the values of two row array items at the given index, then sorts by the given direction
@@ -5528,7 +5542,6 @@ const compareFunction = (index, isAscending) => (thisRow, nextRow) => {
// get values to compare from data attribute or cell content
const value1 = getCellValue(isAscending ? thisRow : nextRow, index);
const value2 = getCellValue(isAscending ? nextRow : thisRow, index);
-
// if neither value is empty, and if both values are already numbers, compare numerically
if (value1 && value2 && !Number.isNaN(Number(value1)) && !Number.isNaN(Number(value2))) {
return value1 - value2;
@@ -5603,7 +5616,16 @@ const sortRows = (header, isAscending) => {
const thisHeaderIndex = allHeaders.indexOf(header);
allRows.sort(compareFunction(thisHeaderIndex, !isAscending)).forEach(tr => {
[].slice.call(tr.children).forEach(td => td.removeAttribute("data-sort-active"));
- tr.children[thisHeaderIndex].setAttribute("data-sort-active", true);
+
+ // ---- DOTGOV EDIT
+
+ // tr.children[thisHeaderIndex].setAttribute("data-sort-active", true);
+ if (tr.children[thisHeaderIndex])
+ tr.children[thisHeaderIndex].setAttribute("data-sort-active", true);
+ // DOTGOV added conditional to protect from tr.children[thisHeaderIndex] being absent
+
+ // ---- END DOTGOV EDIT
+
tbody.appendChild(tr);
});
return true;
diff --git a/src/registrar/assets/sass/_theme/_buttons.scss b/src/registrar/assets/sass/_theme/_buttons.scss
index ff5ffb386..c58242fe7 100644
--- a/src/registrar/assets/sass/_theme/_buttons.scss
+++ b/src/registrar/assets/sass/_theme/_buttons.scss
@@ -258,3 +258,11 @@ a.text-secondary,
a.text-secondary:hover {
color: $theme-color-error;
}
+
+.usa-button--show-more-button {
+ font-size: size('ui', 'xs');
+ text-decoration: none;
+ .usa-icon {
+ top: 6px;
+ }
+}
diff --git a/src/registrar/assets/sass/_theme/_search.scss b/src/registrar/assets/sass/_theme/_search.scss
new file mode 100644
index 000000000..2bd7832e2
--- /dev/null
+++ b/src/registrar/assets/sass/_theme/_search.scss
@@ -0,0 +1,11 @@
+@use "base" as *;
+
+.usa-search--show-label {
+ flex-wrap: wrap;
+ label {
+ width: 100%;
+ }
+ .usa-search--show-label__input-wrapper {
+ flex: 1;
+ }
+}
\ No newline at end of file
diff --git a/src/registrar/assets/sass/_theme/_tables.scss b/src/registrar/assets/sass/_theme/_tables.scss
index d57b51117..ed2d5685b 100644
--- a/src/registrar/assets/sass/_theme/_tables.scss
+++ b/src/registrar/assets/sass/_theme/_tables.scss
@@ -56,8 +56,10 @@ th {
border: none;
}
- td, th {
- border-bottom: 1px solid color('base-lighter');
+ tr:not(.hide-td-borders) {
+ td, th {
+ border-bottom: 1px solid color('base-lighter');
+ }
}
thead th {
diff --git a/src/registrar/assets/sass/_theme/_typography.scss b/src/registrar/assets/sass/_theme/_typography.scss
index cc0d39a5b..d815ef6dd 100644
--- a/src/registrar/assets/sass/_theme/_typography.scss
+++ b/src/registrar/assets/sass/_theme/_typography.scss
@@ -28,3 +28,8 @@ h2 {
.usa-form fieldset {
font-size: 1rem;
}
+
+.p--blockquote {
+ padding-left: units(1);
+ border-left: 2px solid color('base-lighter');
+}
diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss
index 8c38ae0b4..78d27b2e0 100644
--- a/src/registrar/assets/sass/_theme/styles.scss
+++ b/src/registrar/assets/sass/_theme/styles.scss
@@ -15,6 +15,7 @@
@forward "buttons";
@forward "pagination";
@forward "forms";
+@forward "search";
@forward "tooltips";
@forward "fieldsets";
@forward "alerts";
diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py
index ee923aac6..f61e31e54 100644
--- a/src/registrar/config/urls.py
+++ b/src/registrar/config/urls.py
@@ -26,7 +26,6 @@ from registrar.views.report_views import (
# --jsons
from registrar.views.domain_requests_json import get_domain_requests_json
from registrar.views.domains_json import get_domains_json
-from registrar.views.portfolio_members_json import get_portfolio_members_json
from registrar.views.utility.api_views import (
get_senior_official_from_federal_agency_json,
get_federal_and_portfolio_types_from_federal_agency_json,
@@ -96,6 +95,11 @@ urlpatterns = [
views.PortfolioMemberEditView.as_view(),
name="member-permissions",
),
+ path(
+ "member//domains",
+ views.PortfolioMemberDomainsView.as_view(),
+ name="member-domains",
+ ),
path(
"invitedmember/",
views.PortfolioInvitedMemberView.as_view(),
@@ -106,6 +110,11 @@ urlpatterns = [
views.PortfolioInvitedMemberEditView.as_view(),
name="invitedmember-permissions",
),
+ path(
+ "invitedmember//domains",
+ views.PortfolioInvitedMemberDomainsView.as_view(),
+ name="invitedmember-domains",
+ ),
# path(
# "no-organization-members/",
# views.PortfolioNoMembersView.as_view(),
@@ -328,7 +337,8 @@ urlpatterns = [
),
path("get-domains-json/", get_domains_json, name="get_domains_json"),
path("get-domain-requests-json/", get_domain_requests_json, name="get_domain_requests_json"),
- path("get-portfolio-members-json/", get_portfolio_members_json, name="get_portfolio_members_json"),
+ path("get-portfolio-members-json/", views.PortfolioMembersJson.as_view(), name="get_portfolio_members_json"),
+ path("get-member-domains-json/", views.PortfolioMemberDomainsJson.as_view(), name="get_member_domains_json"),
]
# Djangooidc strips out context data from that context, so we define a custom error
diff --git a/src/registrar/fixtures/fixtures_domains.py b/src/registrar/fixtures/fixtures_domains.py
index 98f13cd43..4606024d0 100644
--- a/src/registrar/fixtures/fixtures_domains.py
+++ b/src/registrar/fixtures/fixtures_domains.py
@@ -89,12 +89,18 @@ class DomainFixture(DomainRequestFixture):
# Approve the current domain request
if domain_request:
- cls._approve_request(domain_request, users)
+ try:
+ cls._approve_request(domain_request, users)
+ except Exception as err:
+ logger.warning(f"Cannot approve domain request in fixtures: {err}")
domain_requests_to_update.append(domain_request)
# Approve the expired domain request
if domain_request_expired:
- cls._approve_request(domain_request_expired, users)
+ try:
+ cls._approve_request(domain_request_expired, users)
+ except Exception as err:
+ logger.warning(f"Cannot approve domain request (expired) in fixtures: {err}")
domain_requests_to_update.append(domain_request_expired)
expired_requests.append(domain_request_expired)
diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py
index b1f22ae83..61a6b7397 100644
--- a/src/registrar/models/portfolio_invitation.py
+++ b/src/registrar/models/portfolio_invitation.py
@@ -79,19 +79,8 @@ class PortfolioInvitation(TimeStampedModel):
def get_portfolio_permissions(self):
"""
Retrieve the permissions for the user's portfolio roles from the invite.
- This is similar logic to _get_portfolio_permissions in user_portfolio_permission
"""
- # Use a set to avoid duplicate permissions
- portfolio_permissions = set()
-
- if self.roles:
- for role in self.roles:
- portfolio_permissions.update(UserPortfolioPermission.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
-
- if self.additional_permissions:
- portfolio_permissions.update(self.additional_permissions)
-
- return list(portfolio_permissions)
+ return UserPortfolioPermission.get_portfolio_permissions(self.roles, self.additional_permissions)
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
def retrieve(self):
diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py
index f16f6d7e6..8d09562c2 100644
--- a/src/registrar/models/user_portfolio_permission.py
+++ b/src/registrar/models/user_portfolio_permission.py
@@ -92,16 +92,18 @@ class UserPortfolioPermission(TimeStampedModel):
"""
Retrieve the permissions for the user's portfolio roles.
"""
+ 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"""
# Use a set to avoid duplicate permissions
portfolio_permissions = set()
-
- if self.roles:
- for role in self.roles:
- portfolio_permissions.update(self.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
-
- if self.additional_permissions:
- portfolio_permissions.update(self.additional_permissions)
-
+ if roles:
+ for role in roles:
+ portfolio_permissions.update(cls.PORTFOLIO_ROLE_PERMISSIONS.get(role, []))
+ if additional_permissions:
+ portfolio_permissions.update(additional_permissions)
return list(portfolio_permissions)
def clean(self):
diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py
index ddb487f71..d998d7ffa 100644
--- a/src/registrar/models/utility/portfolio_helper.py
+++ b/src/registrar/models/utility/portfolio_helper.py
@@ -36,3 +36,7 @@ class UserPortfolioPermissionChoices(models.TextChoices):
@classmethod
def get_user_portfolio_permission_label(cls, user_portfolio_permission):
return cls(user_portfolio_permission).label if user_portfolio_permission else None
+
+ @classmethod
+ def to_dict(cls):
+ return {key: value.value for key, value in cls.__members__.items()}
diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html
index 4bef23870..5b7604222 100644
--- a/src/registrar/templates/includes/domain_requests_table.html
+++ b/src/registrar/templates/includes/domain_requests_table.html
@@ -17,22 +17,24 @@