diff --git a/docs/operations/runbooks/downtime_incident_management.md b/docs/operations/runbooks/downtime_incident_management.md index 4aa884e9d..8ace9fa11 100644 --- a/docs/operations/runbooks/downtime_incident_management.md +++ b/docs/operations/runbooks/downtime_incident_management.md @@ -16,6 +16,8 @@ The following set of rules should be followed while an incident is in progress. - If downtime occurs outside of working hours, team members who are off for the day may still be pinged and called but are not required to join if unavailable to do so. - Uncomment the [banner on get.gov](https://github.com/cisagov/get.gov/blob/0365d3d34b041cc9353497b2b5f81b6ab7fe75a9/_includes/header.html#L9), so it is transparent to users that we know about the issue on manage.get.gov. - Designers or Developers should be able to make this change; if designers are online and can help with this task, that will allow developers to focus on fixing the bug. +- Uncomment the [banner on manage.get.gov's base template](https://github.com/cisagov/manage.get.gov/blob/main/src/registrar/templates/base.html#L78). + - Designers or Developers should be able to make this change; if designers are online and can help with this task, that will allow developers to focus on fixing the bug. - If the issue persists for three hours or more, follow the [instructions for enabling/disabling a redirect to get.gov](https://docs.google.com/document/d/1PiWXpjBzbiKsSYqEo9Rkl72HMytMp7zTte9CI-vvwYw/edit). ## Post Incident diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js index adcc21d2a..805d29cbd 100644 --- a/src/registrar/assets/js/get-gov.js +++ b/src/registrar/assets/js/get-gov.js @@ -86,6 +86,132 @@ function makeVisible(el) { el.style.visibility = "visible"; } +/** + * Creates and adds a modal dialog to the DOM with customizable attributes and content. + * + * @param {string} id - A unique identifier for the modal, appended to the action for uniqueness. + * @param {string} ariaLabelledby - The ID of the element that labels the modal, for accessibility. + * @param {string} ariaDescribedby - The ID of the element that describes the modal, for accessibility. + * @param {string} modalHeading - The heading text displayed at the top of the modal. + * @param {string} modalDescription - The main descriptive text displayed within the modal. + * @param {string} modalSubmit - The HTML content for the submit button, allowing customization. + * @param {HTMLElement} wrapper_element - Optional. The element to which the modal is appended. If not provided, defaults to `document.body`. + * @param {boolean} forceAction - Optional. If true, adds a `data-force-action` attribute to the modal for additional control. + * + * The modal includes a heading, description, submit button, and a cancel button, along with a close button. + * The `data-close-modal` attribute is added to cancel and close buttons to enable closing functionality. + */ +function addModal(id, ariaLabelledby, ariaDescribedby, modalHeading, modalDescription, modalSubmit, wrapper_element, forceAction) { + + const modal = document.createElement('div'); + modal.setAttribute('class', 'usa-modal'); + modal.setAttribute('id', id); + modal.setAttribute('aria-labelledby', ariaLabelledby); + modal.setAttribute('aria-describedby', ariaDescribedby); + if (forceAction) + modal.setAttribute('data-force-action', ''); + + modal.innerHTML = ` +
+
+

+ ${modalHeading} +

+
+

+ ${modalDescription} +

+
+ +
+ +
+ ` + if (wrapper_element) { + wrapper_element.appendChild(modal); + } else { + document.body.appendChild(modal); + } +} + +/** + * Helper function that creates a dynamic accordion navigation + * @param {string} action - The action type or identifier used to create a unique DOM IDs. + * @param {string} unique_id - An ID that when combined with action makes a unique identifier + * @param {string} modal_button_text - The action button's text + * @param {string} screen_reader_text - A screen reader helper + */ +function generateKebabHTML(action, unique_id, modal_button_text, screen_reader_text) { + + const generateModalButton = (mobileOnly = false) => ` + + ${mobileOnly ? `` : ''} + ${modal_button_text} + ${screen_reader_text} + + `; + + // Main kebab structure + const kebab = ` + ${generateModalButton(true)} + +
+
+ +
+ +
+ `; + + return kebab; +} + + /** * Toggles expand_more / expand_more svgs in buttons or anchors * @param {Element} element - DOM element @@ -104,9 +230,9 @@ function toggleCaret(element) { } /** - * Helper function that scrolls to an element + * Helper function that scrolls to an element, identified by a class or an id. * @param {string} attributeName - The string "class" or "id" - * @param {string} attributeValue - The class or id name + * @param {string} attributeValue - The class or id used name to identify the element */ function ScrollToElement(attributeName, attributeValue) { let targetEl = null; @@ -1022,46 +1148,54 @@ function initializeTooltips() { * Initialize USWDS modals by calling on method. Requires that uswds-edited.js be loaded * before get-gov.js. uswds-edited.js adds the modal module to the window to be accessible * directly in get-gov.js. - * initializeModals adds modal-related DOM elements, based on other DOM elements existing in + * load Modals adds modal-related DOM elements, based on other DOM elements existing in * the page. It needs to be called only once for any particular DOM element; otherwise, it * will initialize improperly. Therefore, if DOM elements change dynamically and include - * DOM elements with modal classes, unloadModals needs to be called before initializeModals. + * DOM elements with modal classes, uswdsUnloadModals needs to be called before loadModals. * */ -function initializeModals() { - window.modal.on(); +function uswdsInitializeModals() { + window.modal.on(); + } /** * Unload existing USWDS modals by calling off method. Requires that uswds-edited.js be * loaded before get-gov.js. uswds-edited.js adds the modal module to the window to be * accessible directly in get-gov.js. - * See note above with regards to calling this method relative to initializeModals. + * See note above with regards to calling this method relative to loadModals. * */ -function unloadModals() { +function uswdsUnloadModals() { window.modal.off(); } -class LoadTableBase { - constructor(sectionSelector) { - this.tableWrapper = document.getElementById(`${sectionSelector}__table-wrapper`); - this.tableHeaders = document.querySelectorAll(`#${sectionSelector} th[data-sortable]`); +/** + * Base table class which handles search, retrieval, rendering and interaction with results. + * Classes can extend the basic behavior of this class to customize display and interaction. + * NOTE: PLEASE notice that whatever itemName is coming in will have an "s" added to it (ie domain -> domains) + */ +class BaseTable { + constructor(itemName) { + this.itemName = itemName; + this.sectionSelector = itemName + 's'; + this.tableWrapper = document.getElementById(`${this.sectionSelector}__table-wrapper`); + this.tableHeaders = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable]`); this.currentSortBy = 'id'; this.currentOrder = 'asc'; this.currentStatus = []; this.currentSearchTerm = ''; this.scrollToTable = false; - this.searchInput = document.getElementById(`${sectionSelector}__search-field`); - this.searchSubmit = document.getElementById(`${sectionSelector}__search-field-submit`); - this.tableAnnouncementRegion = document.getElementById(`${sectionSelector}__usa-table__announcement-region`); - this.resetSearchButton = document.getElementById(`${sectionSelector}__reset-search`); - this.resetFiltersButton = document.getElementById(`${sectionSelector}__reset-filters`); - this.statusCheckboxes = document.querySelectorAll(`.${sectionSelector} input[name="filter-status"]`); - this.statusIndicator = document.getElementById(`${sectionSelector}__filter-indicator`); - this.statusToggle = document.getElementById(`${sectionSelector}__usa-button--filter`); - this.noTableWrapper = document.getElementById(`${sectionSelector}__no-data`); - this.noSearchResultsWrapper = document.getElementById(`${sectionSelector}__no-search-results`); + this.searchInput = document.getElementById(`${this.sectionSelector}__search-field`); + this.searchSubmit = document.getElementById(`${this.sectionSelector}__search-field-submit`); + this.tableAnnouncementRegion = document.getElementById(`${this.sectionSelector}__usa-table__announcement-region`); + this.resetSearchButton = document.getElementById(`${this.sectionSelector}__reset-search`); + this.resetFiltersButton = document.getElementById(`${this.sectionSelector}__reset-filters`); + this.statusCheckboxes = document.querySelectorAll(`.${this.sectionSelector} input[name="filter-status"]`); + this.statusIndicator = document.getElementById(`${this.sectionSelector}__filter-indicator`); + this.statusToggle = document.getElementById(`${this.sectionSelector}__usa-button--filter`); + this.noTableWrapper = document.getElementById(`${this.sectionSelector}__no-data`); + this.noSearchResultsWrapper = document.getElementById(`${this.sectionSelector}__no-search-results`); this.portfolioElement = document.getElementById('portfolio-js-value'); this.portfolioValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-portfolio') : null; this.initializeTableHeaders(); @@ -1074,31 +1208,24 @@ class LoadTableBase { } /** - * Generalized function to update pagination for a list. - * @param {string} itemName - The name displayed in the counter - * @param {string} paginationSelector - CSS selector for the pagination container. - * @param {string} counterSelector - CSS selector for the pagination counter. - * @param {string} tableSelector - CSS selector for the header element to anchor the links to. - * @param {number} currentPage - The current page number (starting with 1). - * @param {number} numPages - The total number of pages. - * @param {boolean} hasPrevious - Whether there is a page before the current page. - * @param {boolean} hasNext - Whether there is a page after the current page. - * @param {number} total - The total number of items. - */ + * Generalized function to update pagination for a list. + * @param {number} currentPage - The current page number (starting with 1). + * @param {number} numPages - The total number of pages. + * @param {boolean} hasPrevious - Whether there is a page before the current page. + * @param {boolean} hasNext - Whether there is a page after the current page. + * @param {number} total - The total number of items. + */ updatePagination( - itemName, - paginationSelector, - counterSelector, - parentTableSelector, currentPage, numPages, hasPrevious, hasNext, - totalItems, + totalItems ) { - const paginationButtons = document.querySelector(`${paginationSelector} .usa-pagination__list`); - const counterSelectorEl = document.querySelector(counterSelector); - const paginationSelectorEl = document.querySelector(paginationSelector); + const paginationButtons = document.querySelector(`#${this.sectionSelector}-pagination .usa-pagination__list`); + const counterSelectorEl = document.querySelector(`#${this.sectionSelector}-pagination .usa-pagination__counter`); + const paginationSelectorEl = document.querySelector(`#${this.sectionSelector}-pagination`); + const parentTableSelector = `#${this.sectionSelector}`; counterSelectorEl.innerHTML = ''; paginationButtons.innerHTML = ''; @@ -1108,12 +1235,30 @@ class LoadTableBase { // Counter should only be displayed if there is more than 1 item paginationSelectorEl.classList.toggle('display-none', totalItems < 1); - counterSelectorEl.innerHTML = `${totalItems} ${itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`; + counterSelectorEl.innerHTML = `${totalItems} ${this.itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`; + + // Helper function to create a pagination item, such as a + const createPaginationItem = (page) => { + const paginationItem = document.createElement('li'); + paginationItem.classList.add('usa-pagination__item', 'usa-pagination__page-no'); + paginationItem.innerHTML = ` + ${page} + `; + if (page === currentPage) { + paginationItem.querySelector('a').classList.add('usa-current'); + paginationItem.querySelector('a').setAttribute('aria-current', 'page'); + } + paginationItem.querySelector('a').addEventListener('click', (event) => { + event.preventDefault(); + this.loadTable(page); + }); + return paginationItem; + }; if (hasPrevious) { - const prevPageItem = document.createElement('li'); - prevPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - prevPageItem.innerHTML = ` + const prevPaginationItem = document.createElement('li'); + prevPaginationItem.className = 'usa-pagination__item usa-pagination__arrow'; + prevPaginationItem.innerHTML = ` Previous `; - prevPageItem.querySelector('a').addEventListener('click', (event) => { + prevPaginationItem.querySelector('a').addEventListener('click', (event) => { event.preventDefault(); this.loadTable(currentPage - 1); }); - paginationButtons.appendChild(prevPageItem); + paginationButtons.appendChild(prevPaginationItem); } // Add first page and ellipsis if necessary if (currentPage > 2) { - paginationButtons.appendChild(this.createPageItem(1, parentTableSelector, currentPage)); + paginationButtons.appendChild(createPaginationItem(1)); if (currentPage > 3) { const ellipsis = document.createElement('li'); ellipsis.className = 'usa-pagination__item usa-pagination__overflow'; @@ -1142,7 +1287,7 @@ class LoadTableBase { // Add pages around the current page for (let i = Math.max(1, currentPage - 1); i <= Math.min(numPages, currentPage + 1); i++) { - paginationButtons.appendChild(this.createPageItem(i, parentTableSelector, currentPage)); + paginationButtons.appendChild(createPaginationItem(i)); } // Add last page and ellipsis if necessary @@ -1154,13 +1299,13 @@ class LoadTableBase { ellipsis.innerHTML = ''; paginationButtons.appendChild(ellipsis); } - paginationButtons.appendChild(this.createPageItem(numPages, parentTableSelector, currentPage)); + paginationButtons.appendChild(createPaginationItem(numPages)); } if (hasNext) { - const nextPageItem = document.createElement('li'); - nextPageItem.className = 'usa-pagination__item usa-pagination__arrow'; - nextPageItem.innerHTML = ` + const nextPaginationItem = document.createElement('li'); + nextPaginationItem.className = 'usa-pagination__item usa-pagination__arrow'; + nextPaginationItem.innerHTML = ` Next `; - nextPageItem.querySelector('a').addEventListener('click', (event) => { + nextPaginationItem.querySelector('a').addEventListener('click', (event) => { event.preventDefault(); this.loadTable(currentPage + 1); }); - paginationButtons.appendChild(nextPageItem); + paginationButtons.appendChild(nextPaginationItem); } } /** - * A helper that toggles content/ no content/ no search results - * - */ + * A helper that toggles content/ no content/ no search results based on results in data. + * @param {Object} data - Data representing current page of results data. + * @param {HTMLElement} dataWrapper - The DOM element to show if there are results on the current page. + * @param {HTMLElement} noDataWrapper - The DOM element to show if there are no results period. + * @param {HTMLElement} noSearchResultsWrapper - The DOM element to show if there are no results in the current filtered search. + */ updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => { const { unfiltered_total, total } = data; if (unfiltered_total) { @@ -1199,24 +1347,6 @@ class LoadTableBase { } }; - // Helper function to create a page item - createPageItem(page, parentTableSelector, currentPage) { - const pageItem = document.createElement('li'); - pageItem.className = 'usa-pagination__item usa-pagination__page-no'; - pageItem.innerHTML = ` - ${page} - `; - if (page === currentPage) { - pageItem.querySelector('a').classList.add('usa-current'); - pageItem.querySelector('a').setAttribute('aria-current', 'page'); - } - pageItem.querySelector('a').addEventListener('click', (event) => { - event.preventDefault(); - this.loadTable(page); - }); - return pageItem; - } - /** * A helper that resets sortable table headers * @@ -1230,11 +1360,186 @@ class LoadTableBase { header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel); }; - // Abstract method (to be implemented in the child class) - loadTable(page, sortBy, order) { - throw new Error('loadData() must be implemented in a subclass'); + + /** + * Generates search params for filtering and sorting + * @param {number} page - The current page number for pagination (starting with 1) + * @param {*} sortBy - The sort column option + * @param {*} order - The order of sorting {asc, desc} + * @param {string} searchTerm - The search term used to filter results for a specific keyword + * @param {*} status - The status filter applied {ready, dns_needed, etc} + * @param {string} portfolio - The portfolio id + */ + getSearchParams(page, sortBy, order, searchTerm, status, portfolio) { + 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); + if (status) + searchParams.append("status", status); + return searchParams; } + /** + * Gets the base URL of API requests + * Placeholder function in a parent class - method should be implemented by child class for specifics + * Throws an error if called directly from the parent class + */ + getBaseUrl() { + throw new Error('getBaseUrl must be defined'); + } + + /** + * Calls "uswdsUnloadModals" to remove any existing modal element to make sure theres no unintended consequences + * from leftover event listeners + can be properly re-initialized + */ + unloadModals(){} + + /** + * Loads modals + sets up event listeners for the modal submit actions + * "Activates" the modals after the DOM updates + * Utilizes "uswdsInitializeModals" + * Adds click event listeners to each modal's submit button so we can handle a user's actions + * + * When the submit button is clicked: + * - Triggers the close button to reset modal classes + * - Determines if the page needs refreshing if the last item is deleted + * @param {number} page - The current page number for pagination + * @param {number} total - The total # of items on the current page + * @param {number} unfiltered_total - The total # of items across all pages + */ + loadModals(page, total, unfiltered_total) {} + + /** + * Allows us to customize the table display based on specific conditions and a user's permissions + * Dynamically manages the visibility set up of columns, adding/removing headers + * (ie if a domain request is deleteable, we include the kebab column or if a user has edit permissions + * for a member, they will also see the kebab column) + * @param {Object} dataObjects - Data which contains info on domain requests or a user's permission + * Currently returns a dictionary of either: + * - "needsAdditionalColumn": If a new column should be displayed + * - "UserPortfolioPermissionChoices": A user's portfolio permission choices + */ + customizeTable(dataObjects){ return {}; } + + /** + * Retrieves specific data objects + * Placeholder function in a parent class - method should be implemented by child class for specifics + * Throws an error if called directly from the parent class + * Returns either: data.members, data.domains or data.domain_requests + * @param {Object} data - The full data set from which a subset of objects is extracted. + */ + getDataObjects(data) { + throw new Error('getDataObjects must be defined'); + } + + /** + * Creates + appends a row to a tbody element + * Tailored structure set up for each data object (domain, domain_request, member, etc) + * Placeholder function in a parent class - method should be implemented by child class for specifics + * Throws an error if called directly from the parent class + * Returns either: data.members, data.domains or data.domain_requests + * @param {Object} dataObject - The data used to populate the row content + * @param {HTMLElement} tbody - The table body to which the new row is appended to + * @param {Object} customTableOptions - Additional options for customizing row appearance (ie needsAdditionalColumn) + */ + addRow(dataObject, tbody, customTableOptions) { + throw new Error('addRow must be defined'); + } + + /** + * See function for more details + */ + initShowMoreButtons(){} + + /** + * 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 - The 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, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { + // --------- SEARCH + let searchParams = this.getSearchParams(page, sortBy, order, searchTerm, status, portfolio); + + // --------- FETCH DATA + // fetch json of page of domains, given params + const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null; + if (!baseUrlValue) return; + + let url = `${baseUrlValue}?${searchParams.toString()}` + 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 list of results will be inserted into the DOM + const tbody = this.tableWrapper.querySelector('tbody'); + tbody.innerHTML = ''; + + // remove any existing modal elements from the DOM so they can be properly re-initialized + // after the DOM content changes and there are new delete modal buttons added + this.unloadModals(); + + let dataObjects = this.getDataObjects(data); + let customTableOptions = this.customizeTable(data); + + dataObjects.forEach(dataObject => { + this.addRow(dataObject, tbody, customTableOptions); + }); + + this.initShowMoreButtons(); + + this.loadModals(data.page, data.total, data.unfiltered_total); + + // Do not scroll on first page load + if (scroll) + ScrollToElement('class', this.sectionSelector); + this.scrollToTable = true; + + // update pagination + this.updatePagination( + 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 objects:', error)); + } + + // Add event listeners to table headers for sorting initializeTableHeaders() { this.tableHeaders.forEach(header => { @@ -1400,150 +1705,82 @@ class LoadTableBase { } } -class DomainsTable extends LoadTableBase { +class DomainsTable extends BaseTable { constructor() { - super('domains'); + super('domain'); } - /** - * Loads rows in the domains list, as well as updates pagination around the domains 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 {*} status - control for the status filter - * @param {*} searchTerm - the search term - * @param {*} portfolio - the portfolio id - */ - loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { + getBaseUrl() { + return document.getElementById("get_domains_json_url"); + } + getDataObjects(data) { + return data.domains; + } + addRow(dataObject, tbody, customTableOptions) { + const domain = dataObject; + const options = { year: 'numeric', month: 'short', day: 'numeric' }; + const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null; + const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : ''; + const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; + const actionUrl = domain.action_url; + const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯'; - // fetch json of page of domais, given params - let baseUrl = document.getElementById("get_domains_json_url"); - if (!baseUrl) { - return; - } + const row = document.createElement('tr'); - let baseUrlValue = baseUrl.innerHTML; - if (!baseUrlValue) { - return; - } + let markupForSuborganizationRow = ''; - // fetch json of page of domains, given params - let searchParams = new URLSearchParams( - { - "page": page, - "sort_by": sortBy, - "order": order, - "status": status, - "search_term": searchTerm - } - ); - if (portfolio) - searchParams.append("portfolio", portfolio) + if (this.portfolioValue) { + markupForSuborganizationRow = ` + + ${suborganization} + + ` + } - let url = `${baseUrlValue}?${searchParams.toString()}` - 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 domains 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 domainList = document.querySelector('#domains tbody'); - domainList.innerHTML = ''; - - data.domains.forEach(domain => { - const options = { year: 'numeric', month: 'short', day: 'numeric' }; - const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null; - const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : ''; - const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; - const actionUrl = domain.action_url; - const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯'; - - const row = document.createElement('tr'); - - let markupForSuborganizationRow = ''; - - if (this.portfolioValue) { - markupForSuborganizationRow = ` - - ${suborganization} - - ` - } - - row.innerHTML = ` - - ${domain.name} - - - ${expirationDateFormatted} - - - ${domain.state_display} - - - - - ${markupForSuborganizationRow} - - - - ${domain.action_label} ${domain.name} - - - `; - domainList.appendChild(row); - }); - // initialize tool tips immediately after the associated DOM elements are added - initializeTooltips(); - - // Do not scroll on first page load - if (scroll) - ScrollToElement('class', 'domains'); - this.scrollToTable = true; - - // update pagination - this.updatePagination( - 'domain', - '#domains-pagination', - '#domains-pagination .usa-pagination__counter', - '#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)); + row.innerHTML = ` + + ${domain.name} + + + ${expirationDateFormatted} + + + ${domain.state_display} + + + + + ${markupForSuborganizationRow} + + + + ${domain.action_label} ${domain.name} + + + `; + tbody.appendChild(row); } } -class DomainRequestsTable extends LoadTableBase { +class DomainRequestsTable extends BaseTable { constructor() { - super('domain-requests'); + super('domain-request'); } + getBaseUrl() { + return document.getElementById("get_domain_requests_json_url"); + } + toggleExportButton(requests) { const exportButton = document.getElementById('export-csv'); if (exportButton) { @@ -1553,327 +1790,136 @@ class DomainRequestsTable extends LoadTableBase { hideElement(exportButton); } } -} + } - /** - * Loads rows in the domains list, as well as updates pagination around the domains 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 {*} status - control for the status filter - * @param {*} searchTerm - the search term - * @param {*} portfolio - the portfolio id - */ - loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm = this.currentSearchTerm, portfolio = this.portfolioValue) { - let baseUrl = document.getElementById("get_domain_requests_json_url"); - - if (!baseUrl) { - return; - } + getDataObjects(data) { + return data.domain_requests; + } + unloadModals() { + uswdsUnloadModals(); + } + customizeTable(data) { - let baseUrlValue = baseUrl.innerHTML; - if (!baseUrlValue) { - return; - } + // Manage "export as CSV" visibility for domain requests + this.toggleExportButton(data.domain_requests); - // add searchParams - let searchParams = new URLSearchParams( - { - "page": page, - "sort_by": sortBy, - "order": order, - "status": status, - "search_term": searchTerm + let needsDeleteColumn = data.domain_requests.some(request => request.is_deletable); + + // Remove existing delete th and td if they exist + let existingDeleteTh = document.querySelector('.delete-header'); + if (!needsDeleteColumn) { + if (existingDeleteTh) + existingDeleteTh.remove(); + } else { + if (!existingDeleteTh) { + const delheader = document.createElement('th'); + delheader.setAttribute('scope', 'col'); + delheader.setAttribute('role', 'columnheader'); + delheader.setAttribute('class', 'delete-header width-5'); + delheader.innerHTML = ` + Delete Action`; + let tableHeaderRow = this.tableWrapper.querySelector('thead tr'); + tableHeaderRow.appendChild(delheader); } - ); - if (portfolio) - searchParams.append("portfolio", portfolio) + } + return { 'needsAdditionalColumn': needsDeleteColumn }; + } - let url = `${baseUrlValue}?${searchParams.toString()}` - fetch(url) - .then(response => response.json()) - .then(data => { - if (data.error) { - console.error('Error in AJAX call: ' + data.error); - return; + addRow(dataObject, tbody, customTableOptions) { + const request = dataObject; + const options = { year: 'numeric', month: 'short', day: 'numeric' }; + const domainName = request.requested_domain ? request.requested_domain : `New domain request
(${utcDateString(request.created_at)})`; + const actionUrl = request.action_url; + const actionLabel = request.action_label; + const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `Not submitted`; + + // The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page) + // If the request is not deletable, use the following (hidden) span for ANDI screenreaders to indicate this state to the end user + let modalTrigger = ` + Domain request cannot be deleted now. Edit the request for more information.`; + + let markupCreatorRow = ''; + + if (this.portfolioValue) { + markupCreatorRow = ` + + ${request.creator ? request.creator : ''} + + ` + } + + if (request.is_deletable) { + // 1st path: Just a modal trigger in any screen size for non-org users + modalTrigger = ` + + Delete ${domainName} + ` + + // Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly + if (this.portfolioValue) { + + // 2nd path: Just a modal trigger on mobile for org users or kebab + accordion with nested modal trigger on desktop for org users + modalTrigger = generateKebabHTML('delete-domain', request.id, 'Delete', domainName); + } + } + + const row = document.createElement('tr'); + row.innerHTML = ` + + ${domainName} + + + ${submissionDate} + + ${markupCreatorRow} + + ${request.status} + + + + + ${actionLabel} ${request.requested_domain ? request.requested_domain : 'New domain request'} + + + ${customTableOptions.needsAdditionalColumn ? ''+modalTrigger+'' : ''} + `; + tbody.appendChild(row); + if (request.is_deletable) DomainRequestsTable.addDomainRequestsModal(request.requested_domain, request.id, request.created_at, tbody); + } + + loadModals(page, total, unfiltered_total) { + // initialize modals immediately after the DOM content is updated + uswdsInitializeModals(); + + // Now the DOM and modals are ready, add listeners to the submit buttons + const modals = document.querySelectorAll('.usa-modal__content'); + + modals.forEach(modal => { + const submitButton = modal.querySelector('.usa-modal__submit'); + const closeButton = modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + let pk = submitButton.getAttribute('data-pk'); + // Workaround: Close the modal to remove the USWDS UI local classes + closeButton.click(); + // If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page + let pageToDisplay = page; + if (total == 1 && unfiltered_total > 1) { + pageToDisplay--; } - - // Manage "export as CSV" visibility for domain requests - this.toggleExportButton(data.domain_requests); - - // handle the display of proper messaging in the event that no requests 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 request list will be inserted into the DOM - const tbody = document.querySelector('#domain-requests tbody'); - tbody.innerHTML = ''; - - // Unload modals will re-inject the DOM with the initial placeholders to allow for .on() in regular use cases - // We do NOT want that as it will cause multiple placeholders and therefore multiple inits on delete, - // which will cause bad delete requests to be sent. - const preExistingModalPlaceholders = document.querySelectorAll('[data-placeholder-for^="toggle-delete-domain-alert"]'); - preExistingModalPlaceholders.forEach(element => { - element.remove(); - }); - - // remove any existing modal elements from the DOM so they can be properly re-initialized - // after the DOM content changes and there are new delete modal buttons added - unloadModals(); - - let needsDeleteColumn = false; - - needsDeleteColumn = data.domain_requests.some(request => request.is_deletable); - - // Remove existing delete th and td if they exist - let existingDeleteTh = document.querySelector('.delete-header'); - if (!needsDeleteColumn) { - if (existingDeleteTh) - existingDeleteTh.remove(); - } else { - if (!existingDeleteTh) { - const delheader = document.createElement('th'); - delheader.setAttribute('scope', 'col'); - delheader.setAttribute('role', 'columnheader'); - delheader.setAttribute('class', 'delete-header'); - delheader.innerHTML = ` - Delete Action`; - let tableHeaderRow = document.querySelector('#domain-requests thead tr'); - tableHeaderRow.appendChild(delheader); - } - } - - data.domain_requests.forEach(request => { - const options = { year: 'numeric', month: 'short', day: 'numeric' }; - const domainName = request.requested_domain ? request.requested_domain : `New domain request
(${utcDateString(request.created_at)})`; - const actionUrl = request.action_url; - const actionLabel = request.action_label; - const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `Not submitted`; - - // The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page) - // If the request is not deletable, use the following (hidden) span for ANDI screenreaders to indicate this state to the end user - let modalTrigger = ` - Domain request cannot be deleted now. Edit the request for more information.`; - - let markupCreatorRow = ''; - - if (this.portfolioValue) { - markupCreatorRow = ` - - ${request.creator ? request.creator : ''} - - ` - } - - if (request.is_deletable) { - // If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages - let modalHeading = ''; - let modalDescription = ''; - - if (request.requested_domain) { - modalHeading = `Are you sure you want to delete ${request.requested_domain}?`; - modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; - } else { - if (request.created_at) { - modalHeading = 'Are you sure you want to delete this domain request?'; - modalDescription = `This will remove the domain request (created ${utcDateString(request.created_at)}) from the .gov registrar. This action cannot be undone`; - } else { - modalHeading = 'Are you sure you want to delete New domain request?'; - modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; - } - } - - modalTrigger = ` - - Delete ${domainName} - ` - - const modalSubmit = ` - - ` - - const modal = document.createElement('div'); - modal.setAttribute('class', 'usa-modal'); - modal.setAttribute('id', `toggle-delete-domain-alert-${request.id}`); - modal.setAttribute('aria-labelledby', 'Are you sure you want to continue?'); - modal.setAttribute('aria-describedby', 'Domain will be removed'); - modal.setAttribute('data-force-action', ''); - - modal.innerHTML = ` -
-
- -
- -
- -
- -
- ` - - this.tableWrapper.appendChild(modal); - - // Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly - if (this.portfolioValue) { - modalTrigger = ` - - Delete ${domainName} - - -
-
- -
- -
- ` - } - } - - - const row = document.createElement('tr'); - row.innerHTML = ` - - ${domainName} - - - ${submissionDate} - - ${markupCreatorRow} - - ${request.status} - - - - - ${actionLabel} ${request.requested_domain ? request.requested_domain : 'New domain request'} - - - ${needsDeleteColumn ? ''+modalTrigger+'' : ''} - `; - tbody.appendChild(row); - }); - - // initialize modals immediately after the DOM content is updated - initializeModals(); - - // Now the DOM and modals are ready, add listeners to the submit buttons - const modals = document.querySelectorAll('.usa-modal__content'); - - modals.forEach(modal => { - const submitButton = modal.querySelector('.usa-modal__submit'); - const closeButton = modal.querySelector('.usa-modal__close'); - submitButton.addEventListener('click', () => { - let pk = submitButton.getAttribute('data-pk'); - // Close the modal to remove the USWDS UI local classes - closeButton.click(); - // If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page - let pageToDisplay = data.page; - if (data.total == 1 && data.unfiltered_total > 1) { - pageToDisplay--; - } - this.deleteDomainRequest(pk, pageToDisplay); - }); - }); - - // Do not scroll on first page load - if (scroll) - ScrollToElement('class', 'domain-requests'); - this.scrollToTable = true; - - // update the pagination after the domain requests list is updated - this.updatePagination( - 'domain request', - '#domain-requests-pagination', - '#domain-requests-pagination .usa-pagination__counter', - '#domain-requests', - 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 domain requests:', error)); + this.deleteDomainRequest(pk, pageToDisplay); + }); + }); } /** @@ -1903,18 +1949,186 @@ class DomainRequestsTable extends LoadTableBase { throw new Error(`HTTP error! status: ${response.status}`); } // Update data and UI - this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentSearchTerm); + this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentStatus, this.currentSearchTerm); }) .catch(error => console.error('Error fetching domain requests:', error)); } + + /** + * Modal that displays when deleting a domain request + * @param {string} requested_domain - The requested domain URL + * @param {string} id - The request's ID + * @param {string}} created_at - When the request was created at + * @param {HTMLElement} wrapper_element - The element to which the modal is appended + */ + static addDomainRequestsModal(requested_domain, id, created_at, wrapper_element) { + // If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages + let modalHeading = ''; + let modalDescription = ''; + + if (requested_domain) { + modalHeading = `Are you sure you want to delete ${requested_domain}?`; + modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; + } else { + if (created_at) { + modalHeading = 'Are you sure you want to delete this domain request?'; + modalDescription = `This will remove the domain request (created ${utcDateString(created_at)}) from the .gov registrar. This action cannot be undone`; + } else { + modalHeading = 'Are you sure you want to delete New domain request?'; + modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; + } + } + + const modalSubmit = ` + + ` + + addModal(`toggle-delete-domain-${id}`, 'Are you sure you want to continue?', 'Domain will be removed', modalHeading, modalDescription, modalSubmit, wrapper_element, true); + + } } -class MembersTable extends LoadTableBase { +class MembersTable extends BaseTable { constructor() { - super('members'); + super('member'); } + getBaseUrl() { + return document.getElementById("get_members_json_url"); + } + + // Abstract method (to be implemented in the child class) + getDataObjects(data) { + return data.members; + } + unloadModals() { + uswdsUnloadModals(); + } + loadModals(page, total, unfiltered_total) { + // initialize modals immediately after the DOM content is updated + uswdsInitializeModals(); + + // Now the DOM and modals are ready, add listeners to the submit buttons + const modals = document.querySelectorAll('.usa-modal__content'); + + modals.forEach(modal => { + const submitButton = modal.querySelector('.usa-modal__submit'); + const closeButton = modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + let pk = submitButton.getAttribute('data-pk'); + // Close the modal to remove the USWDS UI local classes + closeButton.click(); + // If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page + let pageToDisplay = page; + if (total == 1 && unfiltered_total > 1) { + pageToDisplay--; + } + + this.deleteMember(pk, pageToDisplay); + }); + }); + } + + customizeTable(data) { + // Get whether the logged in user has edit members permission + const hasEditPermission = this.portfolioElement ? this.portfolioElement.getAttribute('data-has-edit-permission')==='True' : null; + + let existingExtraActionsHeader = document.querySelector('.extra-actions-header'); + + if (hasEditPermission && !existingExtraActionsHeader) { + const extraActionsHeader = document.createElement('th'); + extraActionsHeader.setAttribute('id', 'extra-actions'); + extraActionsHeader.setAttribute('role', 'columnheader'); + extraActionsHeader.setAttribute('class', 'extra-actions-header width-5'); + extraActionsHeader.innerHTML = ` + Extra Actions`; + let tableHeaderRow = this.tableWrapper.querySelector('thead tr'); + tableHeaderRow.appendChild(extraActionsHeader); + } + return { + 'needsAdditionalColumn': hasEditPermission, + 'UserPortfolioPermissionChoices' : data.UserPortfolioPermissionChoices + }; + } + + addRow(dataObject, tbody, customTableOptions) { + const member = dataObject; + // member is based on either a UserPortfolioPermission or a PortfolioInvitation + // and also includes information from related domains; the 'id' of the org_member + // is the id of the UserPorfolioPermission or PortfolioInvitation, it is not a user id + // member.type is either invitedmember or member + const unique_id = member.type + member.id; // unique string for use in dom, this is + // not the id of the associated user + const member_delete_url = member.action_url + "/delete"; + const num_domains = member.domain_urls.length; + const last_active = this.handleLastActive(member.last_active); + let cancelInvitationButton = member.type === "invitedmember" ? "Cancel invitation" : "Remove member"; + const kebabHTML = customTableOptions.needsAdditionalColumn ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member.name}`): ''; + + const row = document.createElement('tr'); + + let admin_tagHTML = ``; + if (member.is_admin) + admin_tagHTML = `Admin` + + // generate html blocks for domains and permissions for the member + let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url); + let permissionsHTML = this.generatePermissionsHTML(member.permissions, customTableOptions.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 = `
${domainsHTML} ${permissionsHTML}
`; + showMoreRow.classList.add('show-more-content'); + showMoreRow.classList.add('display-none'); + showMoreRow.id = unique_id; + } + + row.innerHTML = ` + + ${member.member_display} ${admin_tagHTML} ${showMoreButton} + + + ${last_active.display_value} + + + + + ${member.action_label} ${member.name} + + + ${customTableOptions.needsAdditionalColumn ? ''+kebabHTML+'' : ''} + `; + tbody.appendChild(row); + if (domainsHTML || permissionsHTML) { + tbody.appendChild(showMoreRow); + } + // 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 + if (customTableOptions.needsAdditionalColumn) MembersTable.addMemberModal(num_domains, member.email || "Samwise Gamgee", member_delete_url, unique_id, row); + } + /** * Initializes "Show More" buttons on the page, enabling toggle functionality to show or hide content. * @@ -2042,6 +2256,86 @@ class MembersTable extends LoadTableBase { return domainsHTML; } + /** + * The POST call for deleting a Member and which error or success message it should return + * and redirection if necessary + * + * @param {string} member_delete_url - The URL for deletion ie `${member_type}-${member_id}/delete`` + * @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page + * Note: X-Request-With is used for security reasons to present CSRF attacks, the server checks that this header is present + * (consent via CORS) so it knows it's not from a random request attempt + */ + deleteMember(member_delete_url, pageToDisplay) { + // Get CSRF token + const csrfToken = getCsrfToken(); + // Create FormData object and append the CSRF token + const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}`; + + fetch(`${member_delete_url}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': csrfToken, + }, + body: formData + }) + .then(response => { + if (response.status === 200) { + response.json().then(data => { + if (data.success) { + this.addAlert("success", data.success); + } + this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentStatus, this.currentSearchTerm); + }); + } else { + response.json().then(data => { + if (data.error) { + // This should display the error given from backend for + // either only admin OR in progress requests + this.addAlert("error", data.error); + } else { + throw new Error(`Unexpected status: ${response.status}`); + } + }); + } + }) + .catch(error => { + console.error('Error deleting member:', error); + }); + } + + + /** + * Adds an alert message to the page with an alert class. + * + * @param {string} alertClass - {error, warning, info, success} + * @param {string} alertMessage - The text that will be displayed + * + */ + addAlert(alertClass, alertMessage) { + let toggleableAlertDiv = document.getElementById("toggleable-alert"); + this.resetAlerts(); + toggleableAlertDiv.classList.add(`usa-alert--${alertClass}`); + let alertParagraph = toggleableAlertDiv.querySelector(".usa-alert__text"); + alertParagraph.innerHTML = alertMessage + showElement(toggleableAlertDiv); + } + + /** + * Resets the reusable alert message + */ + resetAlerts() { + // Create a list of any alert that's leftover and remove + document.querySelectorAll(".usa-alert:not(#toggleable-alert)").forEach(alert => { + alert.remove(); + }); + let toggleableAlertDiv = document.getElementById("toggleable-alert"); + toggleableAlertDiv.classList.remove('usa-alert--error'); + toggleableAlertDiv.classList.remove('usa-alert--success'); + hideElement(toggleableAlertDiv); + } + /** * Generates an HTML string summarizing a user's additional permissions within a portfolio, * based on the user's permissions and predefined permission choices. @@ -2105,266 +2399,68 @@ class MembersTable extends LoadTableBase { return permissionsHTML; } - /** - * 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) { + /** + * Modal that displays when deleting a domain request + * @param {string} num_domains - Number of domain a user has within the org + * @param {string} member_email - The member's email + * @param {string} submit_delete_url - `${member_type}-${member_id}/delete` + * @param {HTMLElement} wrapper_element - The element to which the modal is appended + */ + static addMemberModal(num_domains, member_email, submit_delete_url, id, wrapper_element) { + let modalHeading = ''; + let modalDescription = ''; + + if (num_domains == 0){ + modalHeading = `Are you sure you want to delete ${member_email}?`; + modalDescription = `They will no longer be able to access this organization. + This action cannot be undone.`; + } else if (num_domains == 1) { + modalHeading = `Are you sure you want to delete ${member_email}?`; + modalDescription = `${member_email} currently manages ${num_domains} domain in the organization. + Removing them from the organization will remove all of their domains. They will no longer be able to + access this organization. This action cannot be undone.`; + } else if (num_domains > 1) { + modalHeading = `Are you sure you want to delete ${member_email}?`; + modalDescription = `${member_email} currently manages ${num_domains} domains in the organization. + Removing them from the organization will remove all of their domains. They will no longer be able to + access this organization. This action cannot be undone.`; + } - // --------- SEARCH - let searchParams = new URLSearchParams( - { - "page": page, - "sort_by": sortBy, - "order": order, - "search_term": searchTerm - } - ); - if (portfolio) - searchParams.append("portfolio", portfolio) + const modalSubmit = ` + + ` - - // --------- FETCH DATA - // fetch json of page of domains, given params - let baseUrl = document.getElementById("get_members_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 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 member_permissions = member.permissions; - const domain_urls = member.domain_urls; - const domain_names = member.domain_names; - const num_domains = domain_urls.length; - - const last_active = this.handleLastActive(member.last_active); - - const action_url = member.action_url; - const action_label = member.action_label; - const svg_icon = member.svg_icon; - - const row = document.createElement('tr'); - - let admin_tagHTML = ``; - 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 = `
${domainsHTML} ${permissionsHTML}
`; - showMoreRow.classList.add('show-more-content'); - showMoreRow.classList.add('display-none'); - showMoreRow.id = member_id; - } - - row.innerHTML = ` - - ${member_display} ${admin_tagHTML} ${showMoreButton} - - - ${last_active.display_value} - - - - - ${action_label} ${member_name} - - - `; - memberList.appendChild(row); - if (domainsHTML || permissionsHTML) { - memberList.appendChild(showMoreRow); - } - }); - - this.initShowMoreButtons(); - - // Do not scroll on first page load - if (scroll) - ScrollToElement('class', 'members'); - this.scrollToTable = true; - - // update pagination - this.updatePagination( - 'member', - '#members-pagination', - '#members-pagination .usa-pagination__counter', - '#members', - 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 members:', error)); + addModal(`toggle-remove-member-${id}`, 'Are you sure you want to continue?', 'Member will be removed', modalHeading, modalDescription, modalSubmit, wrapper_element, true); } } -class MemberDomainsTable extends LoadTableBase { +class MemberDomainsTable extends BaseTable { constructor() { - super('member-domains'); + super('member-domain'); 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)); + getBaseUrl() { + return document.getElementById("get_member_domains_json_url"); } + getDataObjects(data) { + return data.domains; + } + addRow(dataObject, tbody, customTableOptions) { + const domain = dataObject; + const row = document.createElement('tr'); + + row.innerHTML = ` + + ${domain.name} + + `; + tbody.appendChild(row); + } + } @@ -2776,6 +2872,46 @@ document.addEventListener('DOMContentLoaded', function() { } })(); +// This is specifically for the Member Profile (Manage Member) Page member/invitation removal +document.addEventListener("DOMContentLoaded", () => { + (function portfolioMemberPageToggle() { + const wrapperDeleteAction = document.getElementById("wrapper-delete-action") + if (wrapperDeleteAction) { + const member_type = wrapperDeleteAction.getAttribute("data-member-type"); + const member_id = wrapperDeleteAction.getAttribute("data-member-id"); + const num_domains = wrapperDeleteAction.getAttribute("data-num-domains"); + const member_name = wrapperDeleteAction.getAttribute("data-member-name"); + const member_email = wrapperDeleteAction.getAttribute("data-member-email"); + const member_delete_url = `${member_type}-${member_id}/delete`; + const unique_id = `${member_type}-${member_id}`; + + let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; + wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`); + + // 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 + MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); + + uswdsInitializeModals(); + + // Now the DOM and modals are ready, add listeners to the submit buttons + const modals = document.querySelectorAll('.usa-modal__content'); + + modals.forEach(modal => { + const submitButton = modal.querySelector('.usa-modal__submit'); + const closeButton = modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + let delete_member_form = document.getElementById("member-delete-form"); + if (delete_member_form) { + delete_member_form.submit(); + } + }); + }); + } + })(); +}); + /** An IIFE that intializes the requesting entity page. * This page has a radio button that dynamically toggles some fields * Within that, the dropdown also toggles some additional form elements. @@ -2819,4 +2955,4 @@ document.addEventListener('DOMContentLoaded', function() { // Add event listener to the suborg dropdown to show/hide the suborg details section select.addEventListener("change", () => toggleSuborganization()); -})(); +})(); \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/_alerts.scss b/src/registrar/assets/sass/_theme/_alerts.scss index 08404232e..3cfa768fe 100644 --- a/src/registrar/assets/sass/_theme/_alerts.scss +++ b/src/registrar/assets/sass/_theme/_alerts.scss @@ -1,3 +1,4 @@ +@use "uswds-core" as *; @use "base" as *; // Fixes some font size disparities with the Figma @@ -29,3 +30,24 @@ .usa-alert__body--widescreen { max-width: $widescreen-max-width !important; } + +.usa-site-alert--hot-pink { + .usa-alert { + background-color: $hot-pink; + border-left-color: $hot-pink; + .usa-alert__body { + color: color('base-darkest'); + background-color: $hot-pink; + } + } +} + +@supports ((-webkit-mask:url()) or (mask:url())) { + .usa-site-alert--hot-pink .usa-alert .usa-alert__body::before { + background-color: color('base-darkest'); + } +} + +.usa-site-alert--hot-pink .usa-alert .usa-alert__body::before { + background-image: url('../img/usa-icons-bg/error.svg'); +} diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/sass/_theme/_base.scss index 85f453dac..db1599621 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/sass/_theme/_base.scss @@ -2,6 +2,7 @@ @use "cisa_colors" as *; $widescreen-max-width: 1920px; +$hot-pink: #FFC3F9; /* Styles for making visible to screen reader / AT users only. */ .sr-only { diff --git a/src/registrar/assets/sass/_theme/_uswds-theme.scss b/src/registrar/assets/sass/_theme/_uswds-theme.scss index 6ef679734..1661a6388 100644 --- a/src/registrar/assets/sass/_theme/_uswds-theme.scss +++ b/src/registrar/assets/sass/_theme/_uswds-theme.scss @@ -119,7 +119,7 @@ in the form $setting: value, /*--------------------------- ## Emergency state ----------------------------*/ - $theme-color-emergency: #FFC3F9, + $theme-color-emergency: "red-warm-60v", /*--------------------------- # Input settings diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index aa84a3405..458aa5ce0 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -93,6 +93,11 @@ urlpatterns = [ views.PortfolioMemberView.as_view(), name="member", ), + path( + "member//delete", + views.PortfolioMemberDeleteView.as_view(), + name="member-delete", + ), path( "member//permissions", views.PortfolioMemberEditView.as_view(), @@ -108,6 +113,11 @@ urlpatterns = [ views.PortfolioInvitedMemberView.as_view(), name="invitedmember", ), + path( + "invitedmember//delete", + views.PortfolioInvitedMemberDeleteView.as_view(), + name="invitedmember-delete", + ), path( "invitedmember//permissions", views.PortfolioInvitedMemberEditView.as_view(), diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 80c972d38..2d65aa02e 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -1,11 +1,12 @@ import logging +from django.apps import apps from django.contrib.auth.models import AbstractUser from django.db import models from django.db.models import Q from registrar.models import DomainInformation, UserDomainRole -from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .domain_invitation import DomainInvitation from .portfolio_invitation import PortfolioInvitation @@ -471,3 +472,42 @@ class User(AbstractUser): return DomainRequest.objects.filter(portfolio=portfolio).values_list("id", flat=True) else: return UserDomainRole.objects.filter(user=self).values_list("id", flat=True) + + def get_active_requests_count_in_portfolio(self, request): + """Return count of active requests for the portfolio associated with the request.""" + # Get the portfolio from the session using the existing method + + portfolio = request.session.get("portfolio") + + if not portfolio: + return 0 # No portfolio found + + allowed_states = [ + DomainRequest.DomainRequestStatus.SUBMITTED, + DomainRequest.DomainRequestStatus.IN_REVIEW, + DomainRequest.DomainRequestStatus.ACTION_NEEDED, + ] + + # Now filter based on the portfolio retrieved + active_requests_count = self.domain_requests_created.filter( + status__in=allowed_states, portfolio=portfolio + ).count() + + return active_requests_count + + def is_only_admin_of_portfolio(self, portfolio): + """Check if the user is the only admin of the given portfolio.""" + + UserPortfolioPermission = apps.get_model("registrar", "UserPortfolioPermission") + + admin_permission = UserPortfolioRoleChoices.ORGANIZATION_ADMIN + + admins = UserPortfolioPermission.objects.filter(portfolio=portfolio, roles__contains=[admin_permission]) + admin_count = admins.count() + + # Check if the current user is in the list of admins + if admin_count == 1 and admins.first().user == self: + return True # The user is the only admin + + # If there are other admins or the user is not the only one + return False diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html index db34fd893..5ca5edffc 100644 --- a/src/registrar/templates/admin/base_site.html +++ b/src/registrar/templates/admin/base_site.html @@ -45,7 +45,7 @@ {% block header %} {% if not IS_PRODUCTION %} {% with add_body_class="margin-left-1" %} - {% include "includes/non-production-alert.html" %} + {% include "includes/banner-non-production-alert.html" %} {% endwith %} {% endif %} diff --git a/src/registrar/templates/base.html b/src/registrar/templates/base.html index b14dab2fa..b123a0eac 100644 --- a/src/registrar/templates/base.html +++ b/src/registrar/templates/base.html @@ -72,9 +72,28 @@ Skip to main content {% if not IS_PRODUCTION %} - {% include "includes/non-production-alert.html" %} + {% include "includes/banner-non-production-alert.html" %} {% endif %} + {% comment %} + + + + {% include "includes/banner-error.html" %} + + + {% include "includes/banner-info.html" %} + + + {% include "includes/banner-service-disruption.html" %} + {% include "includes/banner-site-alert.html" %} + {% include "includes/banner-system-outage.html" %} + + + {% include "includes/banner-warning.html" %} + {% endcomment %} +
diff --git a/src/registrar/templates/django/admin/domain_change_form.html b/src/registrar/templates/django/admin/domain_change_form.html index f020fabf0..662328660 100644 --- a/src/registrar/templates/django/admin/domain_change_form.html +++ b/src/registrar/templates/django/admin/domain_change_form.html @@ -64,7 +64,7 @@ >
-

Are you sure you want to extend the expiration date?

@@ -128,7 +128,7 @@ >
-

Are you sure you want to place this domain on hold?

@@ -195,7 +195,7 @@ >
-

Are you sure you want to remove this domain from the registry?

diff --git a/src/registrar/templates/django/admin/domain_request_change_form.html b/src/registrar/templates/django/admin/domain_request_change_form.html index ef76ac0b1..46965f236 100644 --- a/src/registrar/templates/django/admin/domain_request_change_form.html +++ b/src/registrar/templates/django/admin/domain_request_change_form.html @@ -57,7 +57,7 @@ >
-

Are you sure you want to select ineligible status?

diff --git a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html index 1cb835988..d5b1130ab 100644 --- a/src/registrar/templates/django/admin/includes/detail_table_fieldset.html +++ b/src/registrar/templates/django/admin/includes/detail_table_fieldset.html @@ -359,7 +359,8 @@ This is using a custom implementation fieldset.html (see admin/fieldset.html) - + + diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_admins_table.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_admins_table.html index 4ea9225da..7add74323 100644 --- a/src/registrar/templates/django/admin/includes/portfolio/portfolio_admins_table.html +++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_admins_table.html @@ -9,6 +9,7 @@ + diff --git a/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html b/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html index 136fe3a5a..fe62f268b 100644 --- a/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html +++ b/src/registrar/templates/django/admin/includes/portfolio/portfolio_members_table.html @@ -11,6 +11,7 @@ + diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index 0fb29d2eb..96ec4c5b6 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -41,14 +41,16 @@ {% include "includes/domain_dates.html" %} - {% if is_portfolio_user and not is_domain_manager %} -
-
-

- You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers. -

+ {% if analyst_action != 'edit' or analyst_action_location != domain.pk %} + {% if is_portfolio_user and not is_domain_manager %} +
+
+

+ You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers. +

+
-
+ {% endif %} {% endif %} diff --git a/src/registrar/templates/includes/banner-error.html b/src/registrar/templates/includes/banner-error.html new file mode 100644 index 000000000..7b5c32ed1 --- /dev/null +++ b/src/registrar/templates/includes/banner-error.html @@ -0,0 +1,12 @@ +
+
+
+

+ Header +

+

+ Text here +

+
+
+
diff --git a/src/registrar/templates/includes/banner-info.html b/src/registrar/templates/includes/banner-info.html new file mode 100644 index 000000000..e5d54e483 --- /dev/null +++ b/src/registrar/templates/includes/banner-info.html @@ -0,0 +1,12 @@ +
+
+
+

+ Header +

+

+ Text here +

+
+
+
diff --git a/src/registrar/templates/includes/banner-non-production-alert.html b/src/registrar/templates/includes/banner-non-production-alert.html new file mode 100644 index 000000000..61d4eed51 --- /dev/null +++ b/src/registrar/templates/includes/banner-non-production-alert.html @@ -0,0 +1,9 @@ +
+
+
+

+ Attention: You are on a test site. +

+
+
+
diff --git a/src/registrar/templates/includes/banner-service-disruption.html b/src/registrar/templates/includes/banner-service-disruption.html new file mode 100644 index 000000000..fc89ee65d --- /dev/null +++ b/src/registrar/templates/includes/banner-service-disruption.html @@ -0,0 +1,12 @@ +
+
+
+

+ Service disruption +

+

+ Month day, time-in-24-hour-notation UTC: We're investigating a service disruption on the .gov registrar. The .gov zone and individual domains remain online. However, the registrar is running slower than usual. +

+
+
+
diff --git a/src/registrar/templates/includes/banner-site-alert.html b/src/registrar/templates/includes/banner-site-alert.html new file mode 100644 index 000000000..52256f46b --- /dev/null +++ b/src/registrar/templates/includes/banner-site-alert.html @@ -0,0 +1,12 @@ +
+
+
+

+ Header here +

+

+ Text here +

+
+
+
diff --git a/src/registrar/templates/includes/banner-system-outage.html b/src/registrar/templates/includes/banner-system-outage.html new file mode 100644 index 000000000..911fa4487 --- /dev/null +++ b/src/registrar/templates/includes/banner-system-outage.html @@ -0,0 +1,12 @@ +
+
+
+

+ System outage +

+

+ Oct 16, 24:00 UTC: We're investigating an outage on the .gov registrar. The .gov zone and individual domains remain online. However, you can't request a new domain or manage an existing one at this time. +

+
+
+
diff --git a/src/registrar/templates/includes/banner-warning.html b/src/registrar/templates/includes/banner-warning.html new file mode 100644 index 000000000..6838aef7b --- /dev/null +++ b/src/registrar/templates/includes/banner-warning.html @@ -0,0 +1,12 @@ +
+
+
+

+ Header +

+

+ Text here +

+
+
+
diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html index 5e0dc6116..066a058fc 100644 --- a/src/registrar/templates/includes/members_table.html +++ b/src/registrar/templates/includes/members_table.html @@ -1,7 +1,7 @@ {% load static %} - + {% comment %} Stores the json endpoint in a url for easier access {% endcomment %} {% url 'get_portfolio_members_json' as url %} diff --git a/src/registrar/templates/includes/modal.html b/src/registrar/templates/includes/modal.html index d918b335d..00c51cee0 100644 --- a/src/registrar/templates/includes/modal.html +++ b/src/registrar/templates/includes/modal.html @@ -2,7 +2,7 @@
-

{{ modal_heading }} {%if domain_name_modal is not None %} @@ -16,7 +16,7 @@ {% endif %}

-

{{ modal_description }}

diff --git a/src/registrar/templates/includes/non-production-alert.html b/src/registrar/templates/includes/non-production-alert.html deleted file mode 100644 index 7d8215a76..000000000 --- a/src/registrar/templates/includes/non-production-alert.html +++ /dev/null @@ -1,7 +0,0 @@ -
-
-
- Attention: You are on a test site. -
-
-
diff --git a/src/registrar/templates/portfolio_member.html b/src/registrar/templates/portfolio_member.html index c0611f854..f492dbd2f 100644 --- a/src/registrar/templates/portfolio_member.html +++ b/src/registrar/templates/portfolio_member.html @@ -1,7 +1,9 @@ {% extends 'portfolio_base.html' %} {% load static field_helpers%} -{% block title %}Organization member {% endblock %} +{% block title %} +Organization member +{% endblock %} {% load static %} @@ -33,60 +35,30 @@ {% if has_edit_members_portfolio_permission %} {% if member %} - - Remove member - - {% else %} - - Cancel invitation - - {% endif %} - -
-
- -
- +
{% csrf_token %}
Last active: {% if member and member.last_login %} diff --git a/src/registrar/templates/portfolio_members.html b/src/registrar/templates/portfolio_members.html index 3cd3aec44..332d1c16c 100644 --- a/src/registrar/templates/portfolio_members.html +++ b/src/registrar/templates/portfolio_members.html @@ -9,11 +9,15 @@ {% endblock %} {% block portfolio_content %} -{% block messages %} - {% include "includes/form_messages.html" %} -{% endblock %}
+

Members

diff --git a/src/registrar/templates/profile.html b/src/registrar/templates/profile.html index 6e1e7781f..7d365d9c1 100644 --- a/src/registrar/templates/profile.html +++ b/src/registrar/templates/profile.html @@ -51,11 +51,11 @@ Edit your User Profile | >
-

Add contact information

-

.Gov domain registrants must maintain accurate contact information in the .gov registrar. Before you can manage your domain, we need you to add your contact information.

diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 53206359b..0c1bdec2a 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -824,6 +824,92 @@ class TestUser(TestCase): cm.exception.message, "When portfolio roles or additional permissions are assigned, portfolio is required." ) + @less_console_noise_decorator + def test_get_active_requests_count_in_portfolio_returns_zero_if_no_portfolio(self): + # There is no portfolio referenced in session so should return 0 + request = self.factory.get("/") + request.session = {} + + count = self.user.get_active_requests_count_in_portfolio(request) + self.assertEqual(count, 0) + + @less_console_noise_decorator + def test_get_active_requests_count_in_portfolio_returns_count_if_portfolio(self): + request = self.factory.get("/") + request.session = {"portfolio": self.portfolio} + + # Create active requests + domain_1, _ = DraftDomain.objects.get_or_create(name="meoward1.gov") + domain_2, _ = DraftDomain.objects.get_or_create(name="meoward2.gov") + domain_3, _ = DraftDomain.objects.get_or_create(name="meoward3.gov") + domain_4, _ = DraftDomain.objects.get_or_create(name="meoward4.gov") + + # Create 3 active requests + 1 that isn't + DomainRequest.objects.create( + creator=self.user, + requested_domain=domain_1, + status=DomainRequest.DomainRequestStatus.SUBMITTED, + portfolio=self.portfolio, + ) + DomainRequest.objects.create( + creator=self.user, + requested_domain=domain_2, + status=DomainRequest.DomainRequestStatus.IN_REVIEW, + portfolio=self.portfolio, + ) + DomainRequest.objects.create( + creator=self.user, + requested_domain=domain_3, + status=DomainRequest.DomainRequestStatus.ACTION_NEEDED, + portfolio=self.portfolio, + ) + DomainRequest.objects.create( # This one should not be counted + creator=self.user, + requested_domain=domain_4, + status=DomainRequest.DomainRequestStatus.REJECTED, + portfolio=self.portfolio, + ) + + count = self.user.get_active_requests_count_in_portfolio(request) + self.assertEqual(count, 3) + + @less_console_noise_decorator + def test_is_only_admin_of_portfolio_returns_true(self): + # Create user as the only admin of the portfolio + UserPortfolioPermission.objects.create( + user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + self.assertTrue(self.user.is_only_admin_of_portfolio(self.portfolio)) + + @less_console_noise_decorator + def test_is_only_admin_of_portfolio_returns_false_if_no_admins(self): + # No admin for the portfolio + self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio)) + + @less_console_noise_decorator + def test_is_only_admin_of_portfolio_returns_false_if_multiple_admins(self): + # Create multiple admins for the same portfolio + UserPortfolioPermission.objects.create( + user=self.user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + # Create another user within this test + other_user = User.objects.create(email="second_admin@igorville.gov", username="second_admin") + UserPortfolioPermission.objects.create( + user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio)) + + @less_console_noise_decorator + def test_is_only_admin_of_portfolio_returns_false_if_user_not_admin(self): + # Create other_user for same portfolio and is given admin access + other_user = User.objects.create(email="second_admin@igorville.gov", username="second_admin") + + UserPortfolioPermission.objects.create( + user=other_user, portfolio=self.portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + # User doesn't have admin access so should return false + self.assertFalse(self.user.is_only_admin_of_portfolio(self.portfolio)) + class TestContact(TestCase): @less_console_noise_decorator diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 15a786c8b..678d5be82 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -323,6 +323,27 @@ class TestDomainDetail(TestDomainOverview): self.assertContains(detail_page, "noinformation.gov") self.assertContains(detail_page, "Domain missing domain information") + def test_domain_detail_with_analyst_managing_domain(self): + """Test that domain management page returns 200 and does not display + blue error message when an analyst is managing the domain""" + with less_console_noise(): + staff_user = create_user() + self.client.force_login(staff_user) + + # need to set the analyst_action and analyst_action_location + # in the session to emulate user clicking Manage Domain + # in the admin interface + session = self.client.session + session["analyst_action"] = "edit" + session["analyst_action_location"] = self.domain.id + session.save() + + detail_page = self.client.get(reverse("domain", kwargs={"pk": self.domain.id})) + + self.assertNotContains( + detail_page, "To manage information for this domain, you must add yourself as a domain manager." + ) + @less_console_noise_decorator @override_flag("organization_feature", active=True) def test_domain_readonly_on_detail_page(self): diff --git a/src/registrar/tests/test_views_portfolio.py b/src/registrar/tests/test_views_portfolio.py index a50a78b23..b7f455936 100644 --- a/src/registrar/tests/test_views_portfolio.py +++ b/src/registrar/tests/test_views_portfolio.py @@ -2,8 +2,9 @@ from django.urls import reverse from api.tests.common import less_console_noise_decorator from registrar.config import settings from registrar.models import Portfolio, SeniorOfficial -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from django_webtest import WebTest # type: ignore +from django.core.handlers.wsgi import WSGIRequest from registrar.models import ( DomainRequest, Domain, @@ -959,7 +960,7 @@ class TestPortfolio(WebTest): ) # Assert buttons and links within the page are correct - self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present + self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present self.assertContains(response, "sprite.svg#edit") # test that Edit link is present self.assertContains(response, "sprite.svg#settings") # test that Manage link is present self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present @@ -1077,9 +1078,8 @@ class TestPortfolio(WebTest): self.assertContains( response, 'This member does not manage any domains. To assign this member a domain, click "Manage"' ) - # Assert buttons and links within the page are correct - self.assertContains(response, "usa-button--more-actions") # test that 3 dot is present + self.assertContains(response, "wrapper-delete-action") # test that 3 dot is present self.assertContains(response, "sprite.svg#edit") # test that Edit link is present self.assertContains(response, "sprite.svg#settings") # test that Manage link is present self.assertNotContains(response, "sprite.svg#visibility") # test that View link is not present @@ -1392,6 +1392,510 @@ class TestPortfolio(WebTest): self.assertTrue(DomainRequest.objects.filter(pk=domain_request.pk).exists()) domain_request.delete() + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_members_table_contains_hidden_permissions_js_hook(self): + # In the members_table.html we use data-has-edit-permission as a boolean + # to indicate if a user has permission to edit members in the specific portfolio + + # 1. User w/ edit permission + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Create a member under same portfolio + member_email = "a_member@example.com" + member, _ = User.objects.get_or_create(username="a_member", email=member_email) + + UserPortfolioPermission.objects.get_or_create( + user=member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + # I log in as the User so I can see the Members Table + self.client.force_login(self.user) + + # Specifically go to the Member Table page + response = self.client.get(reverse("members")) + + self.assertContains(response, 'data-has-edit-permission="True"') + + # 2. User w/o edit permission (additional permission of EDIT_MEMBERS removed) + permission = UserPortfolioPermission.objects.get(user=self.user, portfolio=self.portfolio) + + # Remove the EDIT_MEMBERS additional permission + permission.additional_permissions = [ + perm for perm in permission.additional_permissions if perm != UserPortfolioPermissionChoices.EDIT_MEMBERS + ] + + # Save the updated permissions list + permission.save() + + # Re-fetch the page to check for updated permissions + response = self.client.get(reverse("members")) + + self.assertContains(response, 'data-has-edit-permission="False"') + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_member_page_has_kebab_wrapper_for_member_if_user_has_edit_permission(self): + """Test that the kebab wrapper displays for a member with edit permissions""" + + # I'm a user + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Create a member under same portfolio + member_email = "a_member@example.com" + member, _ = User.objects.get_or_create(username="a_member", email=member_email) + + upp, _ = UserPortfolioPermission.objects.get_or_create( + user=member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + # I log in as the User so I can see the Manage Member page + self.client.force_login(self.user) + + # Specifically go to the Manage Member page + response = self.client.get(reverse("member", args=[upp.id]), follow=True) + + self.assertEqual(response.status_code, 200) + + # Check for email AND member type (which here is just member) + self.assertContains(response, f'data-member-name="{member_email}"') + self.assertContains(response, 'data-member-type="member"') + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_member_page_has_kebab_wrapper_for_invited_member_if_user_has_edit_permission(self): + """Test that the kebab wrapper displays for an invitedmember with edit permissions""" + + # I'm a user + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Invite a member under same portfolio + invited_member_email = "invited_member@example.com" + invitation = PortfolioInvitation.objects.create( + email=invited_member_email, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + # I log in as the User so I can see the Manage Member page + self.client.force_login(self.user) + response = self.client.get(reverse("invitedmember", args=[invitation.id]), follow=True) + + self.assertEqual(response.status_code, 200) + + # Assert the invited members email + invitedmember type + self.assertContains(response, f'data-member-name="{invited_member_email}"') + self.assertContains(response, 'data-member-type="invitedmember"') + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_member_page_does_not_have_kebab_wrapper(self): + """Test that the kebab does not display.""" + + # I'm a user + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # That creates a member with only view access + member_email = "member_with_view_access@example.com" + member, _ = User.objects.get_or_create(username="test_member_with_view_access", email=member_email) + + upp, _ = UserPortfolioPermission.objects.get_or_create( + user=member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + ], + ) + + # I log in as the Member with only view permissions to evaluate the pages behaviour + # when viewed by someone who doesn't have edit perms + self.client.force_login(member) + + # Go to the Manage Member page + response = self.client.get(reverse("member", args=[upp.id]), follow=True) + + self.assertEqual(response.status_code, 200) + + # Assert that the kebab edit options are unavailable + self.assertNotContains(response, 'data-member-type="member"') + self.assertNotContains(response, 'data-member-type="invitedmember"') + self.assertNotContains(response, f'data-member-name="{member_email}"') + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_member_page_has_correct_form_wrapper(self): + """Test that the manage members page the right form wrapper""" + + # I'm a user + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # That creates a member + member_email = "a_member@example.com" + member, _ = User.objects.get_or_create(email=member_email) + + upp, _ = UserPortfolioPermission.objects.get_or_create( + user=member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + # Login as the User to see the Manage Member page + self.client.force_login(self.user) + + # Specifically go to the Manage Member page + response = self.client.get(reverse("member", args=[upp.id]), follow=True) + + # Check for a 200 response + self.assertEqual(response.status_code, 200) + + # Check for form method + that its "post" and id "member-delete-form" + self.assertContains(response, "Contact the .gov team to remove them." + ) + + self.assertContains(response, expected_error_message, status_code=400) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_portfolio_member_delete_view_members_table_only_admin(self): + """Error state w/ deleting a member that's the only admin on Members Table""" + + # I'm a user with admin permission + admin_perm_user, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + with patch.object(User, "is_only_admin_of_portfolio", return_value=True): + self.client.force_login(self.user) + # We check X_REQUESTED_WITH bc those return JSON responses + response = self.client.post( + reverse("member-delete", kwargs={"pk": admin_perm_user.pk}), HTTP_X_REQUESTED_WITH="XMLHttpRequest" + ) + + self.assertEqual(response.status_code, 400) + expected_error_message = ( + "There must be at least one admin in your organization. Give another member admin " + "permissions, make sure they log into the registrar, and then remove this member." + ) + self.assertContains(response, expected_error_message, status_code=400) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_portfolio_member_table_delete_view_success(self): + """Success state with deleting on Members Table page bc no active request AND not only admin""" + + # I'm a user + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Creating a member that can be deleted (see patch) + member_email = "deleteable_member@example.com" + member, _ = User.objects.get_or_create(email=member_email) + + # Set up the member in the portfolio + upp, _ = UserPortfolioPermission.objects.get_or_create( + user=member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + + # And set that the member has no active requests AND it's not the only admin + with patch.object(User, "get_active_requests_count_in_portfolio", return_value=0), patch.object( + User, "is_only_admin_of_portfolio", return_value=False + ): + + # Attempt to delete + self.client.force_login(self.user) + response = self.client.post( + # We check X_REQUESTED_WITH bc those return JSON responses + reverse("member-delete", kwargs={"pk": upp.pk}), + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + + # Check for a successful deletion + self.assertEqual(response.status_code, 200) + + expected_success_message = f"You've removed {member.email} from the organization." + self.assertContains(response, expected_success_message, status_code=200) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_portfolio_member_delete_view_manage_members_page_active_requests(self): + """Error state when deleting a member with active requests on the Manage Members page""" + + # I'm an admin user + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Create a member with active requests + member_email = "member_with_active_request@example.com" + member, _ = User.objects.get_or_create(email=member_email) + + upp, _ = UserPortfolioPermission.objects.get_or_create( + user=member, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + with patch.object(User, "get_active_requests_count_in_portfolio", return_value=1): + with patch("django.contrib.messages.error") as mock_error: + self.client.force_login(self.user) + response = self.client.post( + reverse("member-delete", kwargs={"pk": upp.pk}), + ) + # We don't want to do follow=True in response bc that does automatic redirection + + # We want 302 bc indicates redirect + self.assertEqual(response.status_code, 302) + + support_url = "https://get.gov/contact/" + expected_error_message = ( + f"This member has an active domain request and can't be removed from the organization. " + f"Contact the .gov team to remove them." + ) + + args, kwargs = mock_error.call_args + # Check if first arg is a WSGIRequest, confirms request object passed correctly + # WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete') + self.assertIsInstance(args[0], WSGIRequest) + # Check that the error message matches the expected error message + self.assertEqual(args[1], expected_error_message) + + # Location is used for a 3xx HTTP status code to indicate that the URL was redirected + # and then confirm that we're still on the Manage Members page + self.assertEqual(response.headers["Location"], reverse("member", kwargs={"pk": upp.pk})) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_portfolio_member_delete_view_manage_members_page_only_admin(self): + """Error state when trying to delete the only admin on the Manage Members page""" + + # Create an admin with admin user perms + admin_perm_user, _ = UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Set them to be the only admin and attempt to delete + with patch.object(User, "is_only_admin_of_portfolio", return_value=True): + with patch("django.contrib.messages.error") as mock_error: + self.client.force_login(self.user) + response = self.client.post( + reverse("member-delete", kwargs={"pk": admin_perm_user.pk}), + ) + + self.assertEqual(response.status_code, 302) + + expected_error_message = ( + "There must be at least one admin in your organization. Give another member admin " + "permissions, make sure they log into the registrar, and then remove this member." + ) + + args, kwargs = mock_error.call_args + # Check if first arg is a WSGIRequest, confirms request object passed correctly + # WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete') + self.assertIsInstance(args[0], WSGIRequest) + # Check that the error message matches the expected error message + self.assertEqual(args[1], expected_error_message) + + # Location is used for a 3xx HTTP status code to indicate that the URL was redirected + # and then confirm that we're still on the Manage Members page + self.assertEqual(response.headers["Location"], reverse("member", kwargs={"pk": admin_perm_user.pk})) + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + @override_flag("organization_members", active=True) + def test_portfolio_member_delete_view_manage_members_page_invitedmember(self): + """Success state w/ deleting invited member on Manage Members page should redirect back to Members Table""" + + # I'm a user + UserPortfolioPermission.objects.get_or_create( + user=self.user, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, + ], + ) + + # Invite a member under same portfolio + invited_member_email = "invited_member@example.com" + invitation = PortfolioInvitation.objects.create( + email=invited_member_email, + portfolio=self.portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + ) + with patch("django.contrib.messages.success") as mock_success: + self.client.force_login(self.user) + response = self.client.post( + reverse("invitedmember-delete", kwargs={"pk": invitation.pk}), + ) + + self.assertEqual(response.status_code, 302) + + expected_success_message = f"You've removed {invitation.email} from the organization." + args, kwargs = mock_success.call_args + # Check if first arg is a WSGIRequest, confirms request object passed correctly + # WSGIRequest protocol is basically the HTTPRequest but in Django form (ie POST '/member/1/delete') + self.assertIsInstance(args[0], WSGIRequest) + # Check that the error message matches the expected error message + self.assertEqual(args[1], expected_success_message) + + # Location is used for a 3xx HTTP status code to indicate that the URL was redirected + # and then confirm that we're now on Members Table page + self.assertEqual(response.headers["Location"], reverse("members")) + class TestPortfolioMemberDomainsView(TestWithUser, WebTest): @classmethod diff --git a/src/registrar/views/portfolio_members_json.py b/src/registrar/views/portfolio_members_json.py index 17209f0d9..512124377 100644 --- a/src/registrar/views/portfolio_members_json.py +++ b/src/registrar/views/portfolio_members_json.py @@ -100,7 +100,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): user__permissions__domain__domain_info__portfolio=portfolio ), # only include domains in portfolio ), - source=Value("permission", output_field=CharField()), + type=Value("member", output_field=CharField()), ) .values( "id", @@ -112,7 +112,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): "additional_permissions_display", "member_display", "domain_info", - "source", + "type", ) ) return permissions @@ -140,7 +140,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): distinct=True, ) ), - source=Value("invitation", output_field=CharField()), + type=Value("invitedmember", output_field=CharField()), ).values( "id", "first_name", @@ -151,7 +151,7 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): "additional_permissions_display", "member_display", "domain_info", - "source", + "type", ) return invitations @@ -188,12 +188,12 @@ class PortfolioMembersJson(PortfolioMembersPermission, View): view_only = not user.has_edit_members_portfolio_permission(portfolio) or not user_can_edit_other_users is_admin = UserPortfolioRoleChoices.ORGANIZATION_ADMIN in (item.get("roles") or []) - action_url = reverse("member" if item["source"] == "permission" else "invitedmember", kwargs={"pk": item["id"]}) + action_url = reverse(item["type"], kwargs={"pk": item["id"]}) # Serialize member data member_json = { - "id": item.get("id", ""), - "source": item.get("source", ""), + "id": item.get("id", ""), # id is id of UserPortfolioPermission or PortfolioInvitation + "type": item.get("type", ""), # source is member or invitedmember "name": " ".join(filter(None, [item.get("first_name", ""), item.get("last_name", "")])), "email": item.get("email_display", ""), "member_display": item.get("member_display", ""), diff --git a/src/registrar/views/portfolios.py b/src/registrar/views/portfolios.py index 7839d209e..373d1619d 100644 --- a/src/registrar/views/portfolios.py +++ b/src/registrar/views/portfolios.py @@ -1,13 +1,17 @@ import logging -from django.http import Http404 + +from django.http import Http404, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.utils.safestring import mark_safe from django.contrib import messages + from registrar.forms import portfolio as portfolioForms from registrar.models import Portfolio, User from registrar.models.portfolio_invitation import PortfolioInvitation from registrar.models.user_portfolio_permission import UserPortfolioPermission from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices +from registrar.views.utility.mixins import PortfolioMemberPermission from registrar.views.utility.permission_views import ( PortfolioDomainRequestsPermissionView, PortfolioDomainsPermissionView, @@ -81,6 +85,58 @@ class PortfolioMemberView(PortfolioMemberPermissionView, View): ) +class PortfolioMemberDeleteView(PortfolioMemberPermission, View): + + def post(self, request, pk): + """ + Find and delete the portfolio member using the provided primary key (pk). + Redirect to a success page after deletion (or any other appropriate page). + """ + portfolio_member_permission = get_object_or_404(UserPortfolioPermission, pk=pk) + member = portfolio_member_permission.user + + active_requests_count = member.get_active_requests_count_in_portfolio(request) + + support_url = "https://get.gov/contact/" + + error_message = "" + + if active_requests_count > 0: + # If they have any in progress requests + error_message = mark_safe( # nosec + f"This member has an active domain request and can't be removed from the organization. " + f"Contact the .gov team to remove them." + ) + elif member.is_only_admin_of_portfolio(portfolio_member_permission.portfolio): + # If they are the last manager of a domain + error_message = ( + "There must be at least one admin in your organization. Give another member admin " + "permissions, make sure they log into the registrar, and then remove this member." + ) + + # From the Members Table page Else the Member Page + if error_message: + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse( + {"error": error_message}, + status=400, + ) + else: + messages.error(request, error_message) + return redirect(reverse("member", kwargs={"pk": pk})) + + # passed all error conditions + portfolio_member_permission.delete() + + # From the Members Table page Else the Member Page + success_message = f"You've removed {member.email} from the organization." + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": success_message}, status=200) + else: + messages.success(request, success_message) + return redirect(reverse("members")) + + class PortfolioMemberEditView(PortfolioMemberEditPermissionView, View): template_name = "portfolio_member_permissions.html" @@ -177,6 +233,26 @@ class PortfolioInvitedMemberView(PortfolioMemberPermissionView, View): ) +class PortfolioInvitedMemberDeleteView(PortfolioMemberPermission, View): + + def post(self, request, pk): + """ + Find and delete the portfolio invited member using the provided primary key (pk). + Redirect to a success page after deletion (or any other appropriate page). + """ + portfolio_invitation = get_object_or_404(PortfolioInvitation, pk=pk) + + portfolio_invitation.delete() + + success_message = f"You've removed {portfolio_invitation.email} from the organization." + # From the Members Table page Else the Member Page + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return JsonResponse({"success": success_message}, status=200) + else: + messages.success(request, success_message) + return redirect(reverse("members")) + + class PortfolioInvitedMemberEditView(PortfolioMemberEditPermissionView, View): template_name = "portfolio_member_permissions.html"
Other contact informationOther contact informationAction
Title Email PhoneAction
Email Phone RolesAction