diff --git a/.github/workflows/deploy-branch-to-sandbox.yaml b/.github/workflows/deploy-manual.yaml similarity index 79% rename from .github/workflows/deploy-branch-to-sandbox.yaml rename to .github/workflows/deploy-manual.yaml index f57961fa8..e0bbee436 100644 --- a/.github/workflows/deploy-branch-to-sandbox.yaml +++ b/.github/workflows/deploy-manual.yaml @@ -29,6 +29,7 @@ on: - hotgov - litterbox - ms + - ad # GitHub Actions has no "good" way yet to dynamically input branches branch: description: 'Branch to deploy' @@ -73,20 +74,4 @@ jobs: cf_org: cisa-dotgov cf_space: ${{ env.ENVIRONMENT }} cf_manifest: ops/manifests/manifest-${{ env.ENVIRONMENT }}.yaml - comment: - runs-on: ubuntu-latest - needs: [deploy] - steps: - - uses: actions/github-script@v6 - env: - ENVIRONMENT: ${{ github.event.inputs.environment }} - with: - github-token: ${{secrets.GITHUB_TOKEN}} - script: | - github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.' - }) - diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml index 57561919c..fe0a19089 100644 --- a/.github/workflows/deploy-sandbox.yaml +++ b/.github/workflows/deploy-sandbox.yaml @@ -29,6 +29,7 @@ jobs: || startsWith(github.head_ref, 'litterbox/') || startsWith(github.head_ref, 'ag/') || startsWith(github.head_ref, 'ms/') + || startsWith(github.head_ref, 'ad/') outputs: environment: ${{ steps.var.outputs.environment}} runs-on: "ubuntu-latest" diff --git a/.github/workflows/migrate.yaml b/.github/workflows/migrate.yaml index 3ebee59f9..70ff8ee95 100644 --- a/.github/workflows/migrate.yaml +++ b/.github/workflows/migrate.yaml @@ -16,6 +16,7 @@ on: - stable - staging - development + - ad - ms - ag - litterbox diff --git a/.github/workflows/reset-db.yaml b/.github/workflows/reset-db.yaml index 49e4b5e5f..b6fa0fec5 100644 --- a/.github/workflows/reset-db.yaml +++ b/.github/workflows/reset-db.yaml @@ -16,6 +16,7 @@ on: options: - staging - development + - ad - ms - ag - litterbox diff --git a/docs/operations/README.md b/docs/operations/README.md index 9aaee4c86..cc73d82cb 100644 --- a/docs/operations/README.md +++ b/docs/operations/README.md @@ -45,6 +45,8 @@ When deploying to your personal sandbox, you should make sure all of the USWDS a For ease of use, you can run the `deploy.sh ` script in the `/src` directory to build the assets and deploy to your sandbox. Similarly, you could run `build.sh ` script to just compile and collect the assets without deploying. +You may also manually deploy to a sandbox using our [manual deploy workflow](https://github.com/cisagov/manage.get.gov/actions/workflows/deploy-manual.yaml) on GitHub Actions. Select Run workflow and enter the branch you want to deploy to your sandbox of choice. + Your sandbox space should've been setup as part of the onboarding process. If this was not the case, please have an admin follow the instructions below. ## Creating a sandbox or new environment diff --git a/ops/manifests/manifest-ad.yaml b/ops/manifests/manifest-ad.yaml new file mode 100644 index 000000000..73d6f96ff --- /dev/null +++ b/ops/manifests/manifest-ad.yaml @@ -0,0 +1,32 @@ +--- +applications: +- name: getgov-ad + buildpacks: + - python_buildpack + path: ../../src + instances: 1 + memory: 512M + stack: cflinuxfs4 + timeout: 180 + command: ./run.sh + health-check-type: http + health-check-http-endpoint: /health + health-check-invocation-timeout: 40 + env: + # Send stdout and stderr straight to the terminal without buffering + PYTHONUNBUFFERED: yup + # Tell Django where to find its configuration + DJANGO_SETTINGS_MODULE: registrar.config.settings + # Tell Django where it is being hosted + DJANGO_BASE_URL: https://getgov-ad.app.cloud.gov + # Tell Django how much stuff to log + DJANGO_LOG_LEVEL: INFO + # default public site location + GETGOV_PUBLIC_SITE_URL: https://get.gov + # Flag to disable/enable features in prod environments + IS_PRODUCTION: False + routes: + - route: getgov-ad.app.cloud.gov + services: + - getgov-credentials + - getgov-ad-database diff --git a/ops/scripts/create_dev_sandbox.sh b/ops/scripts/create_dev_sandbox.sh index 676fcf7ae..6cbad9c4f 100755 --- a/ops/scripts/create_dev_sandbox.sh +++ b/ops/scripts/create_dev_sandbox.sh @@ -116,6 +116,10 @@ sed -i '' '/ - development/ {a\ - '"$1"' }' .github/workflows/migrate.yaml +sed -i '' '/ - backup/ {a\ + - '"$1"' +}' .github/workflows/deploy-manual.yaml + sed -i '' '/${{startsWith(github.head_ref, / {a\ || startsWith(github.head_ref, '"'$1'"') }' .github/workflows/deploy-sandbox.yaml diff --git a/ops/scripts/destroy_dev_sandbox.sh b/ops/scripts/destroy_dev_sandbox.sh index 9e233b2f1..c8a00937f 100755 --- a/ops/scripts/destroy_dev_sandbox.sh +++ b/ops/scripts/destroy_dev_sandbox.sh @@ -49,6 +49,7 @@ rm ops/manifests/manifest-$1.yaml sed -i '' "/getgov-$1.app.cloud.gov/d" src/registrar/config/settings.py sed -i '' "/- $1/d" .github/workflows/reset-db.yaml sed -i '' "/- $1/d" .github/workflows/migrate.yaml +sed -i '' "/- $1/d" .github/workflows/deploy-manual.yaml echo "Cleaning up services, applications, and the Cloud.gov space for $1..." cf delete getgov-$1 diff --git a/src/package-lock.json b/src/package-lock.json index 2ff464d5e..08e70dd51 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@uswds/uswds": "^3.8.0", + "@uswds/uswds": "^3.8.1", "pa11y-ci": "^3.0.1", "sass": "^1.54.8" }, @@ -187,9 +187,10 @@ } }, "node_modules/@uswds/uswds": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/@uswds/uswds/-/uswds-3.8.0.tgz", - "integrity": "sha512-rMwCXe/u4HGkfskvS1Iuabapi/EXku3ChaIFW7y/dUhc7R1TXQhbbfp8YXEjmXPF0yqJnv9T08WPgS0fQqWZ8w==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/@uswds/uswds/-/uswds-3.8.1.tgz", + "integrity": "sha512-bKG/B9mJF1v0yoqth48wQDzST5Xyu3OxxpePIPDyhKWS84oDrCehnu3Z88JhSjdIAJMl8dtjtH8YvdO9kZUpAg==", + "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "classlist-polyfill": "1.2.0", "object-assign": "4.1.1", diff --git a/src/package.json b/src/package.json index 58ae3a4ed..e16bc8198 100644 --- a/src/package.json +++ b/src/package.json @@ -10,11 +10,11 @@ "author": "", "license": "ISC", "dependencies": { - "@uswds/uswds": "^3.8.0", + "@uswds/uswds": "^3.8.1", "pa11y-ci": "^3.0.1", "sass": "^1.54.8" }, "devDependencies": { "@uswds/compile": "^1.0.0-beta.3" } -} \ No newline at end of file +} diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 46f6cc68c..5dd0b1852 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -11,6 +11,7 @@ from django.shortcuts import redirect from django_fsm import get_available_FIELD_transitions, FSMField from registrar.models.domain_group import DomainGroup from registrar.models.suborganization import Suborganization +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from waffle.decorators import flag_is_active from django.contrib import admin, messages from django.contrib.auth.admin import UserAdmin as BaseUserAdmin @@ -131,12 +132,12 @@ class MyUserAdminForm(UserChangeForm): "groups": NoAutocompleteFilteredSelectMultiple("groups", False), "user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False), "portfolio_roles": FilteredSelectMultipleArrayWidget( - "portfolio_roles", is_stacked=False, choices=User.UserPortfolioRoleChoices.choices + "portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices ), "portfolio_additional_permissions": FilteredSelectMultipleArrayWidget( "portfolio_additional_permissions", is_stacked=False, - choices=User.UserPortfolioPermissionChoices.choices, + choices=UserPortfolioPermissionChoices.choices, ), } @@ -169,6 +170,24 @@ class MyUserAdminForm(UserChangeForm): ) +class PortfolioInvitationAdminForm(UserChangeForm): + """This form utilizes the custom widget for its class's ManyToMany UIs.""" + + class Meta: + model = models.PortfolioInvitation + fields = "__all__" + widgets = { + "portfolio_roles": FilteredSelectMultipleArrayWidget( + "portfolio_roles", is_stacked=False, choices=UserPortfolioRoleChoices.choices + ), + "portfolio_additional_permissions": FilteredSelectMultipleArrayWidget( + "portfolio_additional_permissions", + is_stacked=False, + choices=UserPortfolioPermissionChoices.choices, + ), + } + + class DomainInformationAdminForm(forms.ModelForm): """This form utilizes the custom widget for its class's ManyToMany UIs.""" @@ -1299,6 +1318,56 @@ class DomainInvitationAdmin(ListHeaderAdmin): return super().changelist_view(request, extra_context=extra_context) +class PortfolioInvitationAdmin(ListHeaderAdmin): + """Custom portfolio invitation admin class.""" + + form = PortfolioInvitationAdminForm + + class Meta: + model = models.PortfolioInvitation + fields = "__all__" + + _meta = Meta() + + # Columns + list_display = [ + "email", + "portfolio", + "portfolio_roles", + "portfolio_additional_permissions", + "status", + ] + + # Search + search_fields = [ + "email", + "portfolio__name", + ] + + # Filters + list_filter = ("status",) + + search_help_text = "Search by email or portfolio." + + # Mark the FSM field 'status' as readonly + # to allow admin users to create Domain Invitations + # without triggering the FSM Transition Not Allowed + # error. + readonly_fields = ["status"] + + autocomplete_fields = ["portfolio"] + + change_form_template = "django/admin/email_clipboard_change_form.html" + + # Select portfolio invitations to change -> Portfolio invitations + def changelist_view(self, request, extra_context=None): + if extra_context is None: + extra_context = {} + extra_context["tabtitle"] = "Portfolio invitations" + # Get the filtered values + return super().changelist_view(request, extra_context=extra_context) + + class DomainInformationResource(resources.ModelResource): """defines how each field in the referenced model should be mapped to the corresponding fields in the import/export file""" @@ -2900,6 +2969,7 @@ admin.site.register(models.PublicContact, PublicContactAdmin) admin.site.register(models.DomainRequest, DomainRequestAdmin) admin.site.register(models.TransitionDomain, TransitionDomainAdmin) admin.site.register(models.VerifiedByStaff, VerifiedByStaffAdmin) +admin.site.register(models.PortfolioInvitation, PortfolioInvitationAdmin) admin.site.register(models.Portfolio, PortfolioAdmin) admin.site.register(models.DomainGroup, DomainGroupAdmin) admin.site.register(models.Suborganization, SuborganizationAdmin) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js index d8bc21899..04f5417b0 100644 --- a/src/registrar/assets/js/get-gov-admin.js +++ b/src/registrar/assets/js/get-gov-admin.js @@ -207,15 +207,11 @@ function addOrRemoveSessionBoolean(name, add){ })(); + /** An IIFE for pages in DjangoAdmin that use a clipboard button */ (function (){ - function copyInnerTextToClipboard(elem) { - let text = elem.innerText - navigator.clipboard.writeText(text) - } - function copyToClipboardAndChangeIcon(button) { // Assuming the input is the previous sibling of the button let input = button.previousElementSibling; @@ -224,7 +220,7 @@ function addOrRemoveSessionBoolean(name, add){ if (input) { navigator.clipboard.writeText(input.value).then(function() { // Change the icon to a checkmark on successful copy - let buttonIcon = button.querySelector('.usa-button__clipboard use'); + let buttonIcon = button.querySelector('.copy-to-clipboard use'); if (buttonIcon) { let currentHref = buttonIcon.getAttribute('xlink:href'); let baseHref = currentHref.split('#')[0]; @@ -233,21 +229,17 @@ function addOrRemoveSessionBoolean(name, add){ buttonIcon.setAttribute('xlink:href', baseHref + '#check'); // Change the button text - nearestSpan = button.querySelector("span") + let nearestSpan = button.querySelector("span") + let original_text = nearestSpan.innerText nearestSpan.innerText = "Copied to clipboard" setTimeout(function() { // Change back to the copy icon buttonIcon.setAttribute('xlink:href', currentHref); - if (button.classList.contains('usa-button__small-text')) { - nearestSpan.innerText = "Copy email"; - } else { - nearestSpan.innerText = "Copy"; - } + nearestSpan.innerText = original_text; }, 2000); } - }).catch(function(error) { console.error('Clipboard copy failed', error); }); @@ -255,7 +247,7 @@ function addOrRemoveSessionBoolean(name, add){ } function handleClipboardButtons() { - clipboardButtons = document.querySelectorAll(".usa-button__clipboard") + clipboardButtons = document.querySelectorAll(".copy-to-clipboard") clipboardButtons.forEach((button) => { // Handle copying the text to your clipboard, @@ -278,20 +270,7 @@ function addOrRemoveSessionBoolean(name, add){ }); } - function handleClipboardLinks() { - let emailButtons = document.querySelectorAll(".usa-button__clipboard-link"); - if (emailButtons){ - emailButtons.forEach((button) => { - button.addEventListener("click", ()=>{ - copyInnerTextToClipboard(button); - }) - }); - } - } - handleClipboardButtons(); - handleClipboardLinks(); - })(); @@ -605,3 +584,169 @@ function initializeWidgetOnList(list, parentId) { } } })(); + + +/** An IIFE for copy summary button (appears in DomainRegistry models) +*/ +(function (){ + const copyButton = document.getElementById('id-copy-to-clipboard-summary'); + + if (copyButton) { + copyButton.addEventListener('click', function() { + /// Generate a rich HTML summary text and copy to clipboard + + //------ Organization Type + const organizationTypeElement = document.getElementById('id_organization_type'); + const organizationType = organizationTypeElement.options[organizationTypeElement.selectedIndex].text; + + //------ Alternative Domains + const alternativeDomainsDiv = document.querySelector('.form-row.field-alternative_domains .readonly'); + const alternativeDomainslinks = alternativeDomainsDiv.querySelectorAll('a'); + const alternativeDomains = Array.from(alternativeDomainslinks).map(link => link.textContent); + + //------ Existing Websites + const existingWebsitesDiv = document.querySelector('.form-row.field-current_websites .readonly'); + const existingWebsiteslinks = existingWebsitesDiv.querySelectorAll('a'); + const existingWebsites = Array.from(existingWebsiteslinks).map(link => link.textContent); + + //------ Additional Contacts + // 1 - Create a hyperlinks map so we can display contact details and also link to the contact + const otherContactsDiv = document.querySelector('.form-row.field-other_contacts .readonly'); + let otherContactLinks = []; + const nameToUrlMap = {}; + if (otherContactsDiv) { + otherContactLinks = otherContactsDiv.querySelectorAll('a'); + otherContactLinks.forEach(link => { + const name = link.textContent.trim(); + const url = link.href; + nameToUrlMap[name] = url; + }); + } + + // 2 - Iterate through contact details and assemble html for summary + let otherContactsSummary = "" + const bulletList = document.createElement('ul'); + + // CASE 1 - Contacts are not in a table (this happens if there is only one or two other contacts) + const contacts = document.querySelectorAll('.field-other_contacts .dja-detail-list dd'); + if (contacts) { + contacts.forEach(contact => { + // Check if the
element is not empty + const name = contact.querySelector('a#contact_info_name')?.innerText; + const title = contact.querySelector('span#contact_info_title')?.innerText; + const email = contact.querySelector('span#contact_info_email')?.innerText; + const phone = contact.querySelector('span#contact_info_phone')?.innerText; + const url = nameToUrlMap[name] || '#'; + // Format the contact information + const listItem = document.createElement('li'); + listItem.innerHTML = `${name}, ${title}, ${email}, ${phone}`; + bulletList.appendChild(listItem); + }); + + } + + // CASE 2 - Contacts are in a table (this happens if there is more than 2 contacts) + const otherContactsTable = document.querySelector('.form-row.field-other_contacts table tbody'); + if (otherContactsTable) { + const otherContactsRows = otherContactsTable.querySelectorAll('tr'); + otherContactsRows.forEach(contactRow => { + // Extract the contact details + const name = contactRow.querySelector('th').textContent.trim(); + const title = contactRow.querySelectorAll('td')[0].textContent.trim(); + const email = contactRow.querySelectorAll('td')[1].textContent.trim(); + const phone = contactRow.querySelectorAll('td')[2].textContent.trim(); + const url = nameToUrlMap[name] || '#'; + // Format the contact information + const listItem = document.createElement('li'); + listItem.innerHTML = `${name}, ${title}, ${email}, ${phone}`; + bulletList.appendChild(listItem); + }); + } + otherContactsSummary += bulletList.outerHTML + + + //------ Requested Domains + const requestedDomainElement = document.getElementById('id_requested_domain'); + const requestedDomain = requestedDomainElement.options[requestedDomainElement.selectedIndex].text; + + //------ Submitter + // Function to extract text by ID and handle missing elements + function extractTextById(id, divElement) { + if (divElement) { + const element = divElement.querySelector(`#${id}`); + return element ? ", " + element.textContent.trim() : ''; + } + return ''; + } + // Extract the submitter name, title, email, and phone number + const submitterDiv = document.querySelector('.form-row.field-submitter'); + const submitterNameElement = document.getElementById('id_submitter'); + const submitterName = submitterNameElement.options[submitterNameElement.selectedIndex].text; + const submitterTitle = extractTextById('contact_info_title', submitterDiv); + const submitterEmail = extractTextById('contact_info_email', submitterDiv); + const submitterPhone = extractTextById('contact_info_phone', submitterDiv); + let submitterInfo = `${submitterName}${submitterTitle}${submitterEmail}${submitterPhone}`; + + + //------ Senior Official + const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official'); + const seniorOfficialElement = document.getElementById('id_senior_official'); + const seniorOfficialName = seniorOfficialElement.options[seniorOfficialElement.selectedIndex].text; + const seniorOfficialTitle = extractTextById('contact_info_title', seniorOfficialDiv); + const seniorOfficialEmail = extractTextById('contact_info_email', seniorOfficialDiv); + const seniorOfficialPhone = extractTextById('contact_info_phone', seniorOfficialDiv); + let seniorOfficialInfo = `${seniorOfficialName}${seniorOfficialTitle}${seniorOfficialEmail}${seniorOfficialPhone}`; + + const html_summary = `Recommendation:
` + + `Organization Type: ${organizationType}
` + + `Requested Domain: ${requestedDomain}
` + + `Current Websites: ${existingWebsites.join(', ')}
` + + `Rationale:
` + + `Alternative Domains: ${alternativeDomains.join(', ')}
` + + `Submitter: ${submitterInfo}
` + + `Senior Official: ${seniorOfficialInfo}
` + + `Other Employees: ${otherContactsSummary}
`; + + //Replace
with \n, then strip out all remaining html tags (replace <...> with '') + const plain_summary = html_summary.replace(/<\/br>|
/g, '\n').replace(/<\/?[^>]+(>|$)/g, ''); + + // Create Blobs with the summary content + const html_blob = new Blob([html_summary], { type: 'text/html' }); + const plain_blob = new Blob([plain_summary], { type: 'text/plain' }); + + // Create a ClipboardItem with the Blobs + const clipboardItem = new ClipboardItem({ + 'text/html': html_blob, + 'text/plain': plain_blob + }); + + // Write the ClipboardItem to the clipboard + navigator.clipboard.write([clipboardItem]).then(() => { + // Change the icon to a checkmark on successful copy + let buttonIcon = copyButton.querySelector('use'); + if (buttonIcon) { + let currentHref = buttonIcon.getAttribute('xlink:href'); + let baseHref = currentHref.split('#')[0]; + + // Append the new icon reference + buttonIcon.setAttribute('xlink:href', baseHref + '#check'); + + // Change the button text + nearestSpan = copyButton.querySelector("span") + original_text = nearestSpan.innerText + nearestSpan.innerText = "Copied to clipboard" + + setTimeout(function() { + // Change back to the copy icon + buttonIcon.setAttribute('xlink:href', currentHref); + nearestSpan.innerText = original_text + }, 2000); + + } + console.log('Summary copied to clipboard successfully!'); + }).catch(err => { + console.error('Failed to copy text: ', err); + }); + }); + } +})(); diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss index d7d116046..711bfe960 100644 --- a/src/registrar/assets/sass/_theme/_admin.scss +++ b/src/registrar/assets/sass/_theme/_admin.scss @@ -369,9 +369,6 @@ input.admin-confirm-button { padding: 10px 8px; line-height: normal; } - .usa-icon { - top: 2px; - } a.button:active, a.button:focus { text-decoration: none; } @@ -447,15 +444,12 @@ address.margin-top-neg-1__detail-list { } } -td button.usa-button__clipboard-link, address.dja-address-contact-list { +address.dja-address-contact-list { font-size: unset; } address.dja-address-contact-list { color: var(--body-quiet-color); - button.usa-button__clipboard-link { - font-size: unset; - } } // Mimic the normal label size @@ -464,11 +458,18 @@ address.dja-address-contact-list { font-size: 0.875rem; color: var(--body-quiet-color); } +} - address button.usa-button__clipboard-link, td button.usa-button__clipboard-link { - font-size: 0.875rem !important; - } +// Targets the unstyled buttons in the form +.button--clipboard { + color: var(--link-fg); +} +// Targets the DJA buttom with a nested icon +button .usa-icon, +.button .usa-icon, +.button--clipboard .usa-icon { + vertical-align: middle; } .errors span.select2-selection { @@ -663,7 +664,7 @@ address.dja-address-contact-list { align-items: center; - .usa-button__icon { + .usa-button--icon { position: absolute; right: auto; left: 4px; @@ -681,10 +682,6 @@ address.dja-address-contact-list { } } -button.usa-button__clipboard { - color: var(--link-fg); -} - .no-outline-on-click:focus { outline: none !important; } diff --git a/src/registrar/assets/sass/_theme/_buttons.scss b/src/registrar/assets/sass/_theme/_buttons.scss index 7fa379c0b..d246366d8 100644 --- a/src/registrar/assets/sass/_theme/_buttons.scss +++ b/src/registrar/assets/sass/_theme/_buttons.scss @@ -213,4 +213,4 @@ a.usa-button--unstyled:visited { .margin-right-neg-4px { margin-right: -4px; -} +} \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/sass/_theme/_forms.scss index c025bdb29..0aedfcdba 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/sass/_theme/_forms.scss @@ -82,3 +82,13 @@ legend.float-left-tablet + button.float-right-tablet { color: var(--close-button-hover-bg); } } + +.read-only-label { + font-size: size('body', 'sm'); + color: color('primary'); + margin-bottom: units(0.5); +} + +.read-only-value { + margin-top: units(0); +} diff --git a/src/registrar/assets/sass/_theme/_links.scss b/src/registrar/assets/sass/_theme/_links.scss index e9b71733a..fd1c3dee9 100644 --- a/src/registrar/assets/sass/_theme/_links.scss +++ b/src/registrar/assets/sass/_theme/_links.scss @@ -15,3 +15,4 @@ margin-right: units(0.5); } } + diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss index 4775b60c9..f9df015b4 100644 --- a/src/registrar/assets/sass/_theme/styles.scss +++ b/src/registrar/assets/sass/_theme/styles.scss @@ -27,4 +27,4 @@ /*-------------------------------------------------- --- Admin ---------------------------------*/ -@forward "admin"; +@forward "admin"; \ No newline at end of file diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 3da0a104a..9d707a533 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -241,7 +241,6 @@ TEMPLATES = [ "registrar.context_processors.is_demo_site", "registrar.context_processors.is_production", "registrar.context_processors.org_user_status", - "registrar.context_processors.add_portfolio_to_context", "registrar.context_processors.add_path_to_context", "registrar.context_processors.add_has_profile_feature_flag_to_context", "registrar.context_processors.portfolio_permissions", @@ -665,6 +664,7 @@ ALLOWED_HOSTS = [ "getgov-stable.app.cloud.gov", "getgov-staging.app.cloud.gov", "getgov-development.app.cloud.gov", + "getgov-ad.app.cloud.gov", "getgov-ms.app.cloud.gov", "getgov-ag.app.cloud.gov", "getgov-litterbox.app.cloud.gov", diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 7f9db0e41..90137c4af 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -26,7 +26,6 @@ from registrar.views.domain_request import Step from registrar.views.domain_requests_json import get_domain_requests_json from registrar.views.domains_json import get_domains_json from registrar.views.utility import always_404 -from registrar.views.portfolios import PortfolioDomainsView, PortfolioDomainRequestsView, PortfolioOrganizationView from api.views import available, get_current_federal, get_current_full @@ -61,19 +60,19 @@ for step, view in [ urlpatterns = [ path("", views.index, name="home"), path( - "portfolio//domains/", - PortfolioDomainsView.as_view(), - name="portfolio-domains", + "domains/", + views.PortfolioDomainsView.as_view(), + name="domains", ), path( - "portfolio//domain_requests/", - PortfolioDomainRequestsView.as_view(), - name="portfolio-domain-requests", + "requests/", + views.PortfolioDomainRequestsView.as_view(), + name="domain-requests", ), path( - "portfolio//organization/", - PortfolioOrganizationView.as_view(), - name="portfolio-organization", + "organization/", + views.PortfolioOrganizationView.as_view(), + name="organization", ), path( "admin/logout/", diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index 06ef07050..ee5f8aee1 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -50,10 +50,6 @@ def org_user_status(request): } -def add_portfolio_to_context(request): - return {"portfolio": getattr(request, "portfolio", None)} - - def add_path_to_context(request): return {"path": getattr(request, "path", None)} @@ -65,16 +61,20 @@ def add_has_profile_feature_flag_to_context(request): def portfolio_permissions(request): """Make portfolio permissions for the request user available in global context""" try: - if not request.user or not request.user.is_authenticated: + if not request.user or not request.user.is_authenticated or not flag_is_active(request, "organization_feature"): return { "has_base_portfolio_permission": False, "has_domains_portfolio_permission": False, "has_domain_requests_portfolio_permission": False, + "portfolio": None, + "has_organization_feature_flag": False, } return { "has_base_portfolio_permission": request.user.has_base_portfolio_permission(), "has_domains_portfolio_permission": request.user.has_domains_portfolio_permission(), "has_domain_requests_portfolio_permission": request.user.has_domain_requests_portfolio_permission(), + "portfolio": request.user.portfolio, + "has_organization_feature_flag": True, } except AttributeError: # Handles cases where request.user might not exist @@ -82,4 +82,6 @@ def portfolio_permissions(request): "has_base_portfolio_permission": False, "has_domains_portfolio_permission": False, "has_domain_requests_portfolio_permission": False, + "portfolio": None, + "has_organization_feature_flag": False, } diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py index 74fd4d15d..7ce63d364 100644 --- a/src/registrar/fixtures_users.py +++ b/src/registrar/fixtures_users.py @@ -22,6 +22,11 @@ class UserFixture: """ ADMINS = [ + { + "username": "aad084c3-66cc-4632-80eb-41cdf5c5bcbf", + "first_name": "Aditi", + "last_name": "Green", + }, { "username": "be17c826-e200-4999-9389-2ded48c43691", "first_name": "Matthew", @@ -120,6 +125,11 @@ class UserFixture: ] STAFF = [ + { + "username": "ffec5987-aa84-411b-a05a-a7ee5cbcde54", + "first_name": "Aditi-Analyst", + "last_name": "Green-Analyst", + }, { "username": "d6bf296b-fac5-47ff-9c12-f88ccc5c1b99", "first_name": "Matthew-Analyst", diff --git a/src/registrar/migrations/0115_portfolioinvitation.py b/src/registrar/migrations/0115_portfolioinvitation.py new file mode 100644 index 000000000..82a171f10 --- /dev/null +++ b/src/registrar/migrations/0115_portfolioinvitation.py @@ -0,0 +1,83 @@ +# Generated by Django 4.2.10 on 2024-08-01 12:28 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import django_fsm + + +class Migration(migrations.Migration): + + dependencies = [ + ("registrar", "0114_alter_user_portfolio_additional_permissions"), + ] + + operations = [ + migrations.CreateModel( + name="PortfolioInvitation", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("email", models.EmailField(max_length=254)), + ( + "portfolio_roles", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("organization_admin", "Admin"), + ("organization_admin_read_only", "Admin read only"), + ("organization_member", "Member"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more roles.", + null=True, + size=None, + ), + ), + ( + "portfolio_additional_permissions", + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ("view_all_domains", "View all domains and domain reports"), + ("view_managed_domains", "View managed domains"), + ("view_member", "View members"), + ("edit_member", "Create and edit members"), + ("view_all_requests", "View all requests"), + ("view_created_requests", "View created requests"), + ("edit_requests", "Create and edit requests"), + ("view_portfolio", "View organization"), + ("edit_portfolio", "Edit organization"), + ], + max_length=50, + ), + blank=True, + help_text="Select one or more additional permissions.", + null=True, + size=None, + ), + ), + ( + "status", + django_fsm.FSMField( + choices=[("invited", "Invited"), ("retrieved", "Retrieved")], + default="invited", + max_length=50, + protected=True, + ), + ), + ( + "portfolio", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="portfolios", to="registrar.portfolio" + ), + ), + ], + options={ + "indexes": [models.Index(fields=["status"], name="registrar_p_status_aa4218_idx")], + }, + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index a68633aff..1e0aad0b1 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -1,4 +1,4 @@ -from auditlog.registry import auditlog # type: ignore +from auditlog.registry import auditlog from .contact import Contact from .domain_request import DomainRequest from .domain_information import DomainInformation @@ -16,6 +16,7 @@ from .website import Website from .transition_domain import TransitionDomain from .verified_by_staff import VerifiedByStaff from .waffle_flag import WaffleFlag +from .portfolio_invitation import PortfolioInvitation from .portfolio import Portfolio from .domain_group import DomainGroup from .suborganization import Suborganization @@ -40,6 +41,7 @@ __all__ = [ "TransitionDomain", "VerifiedByStaff", "WaffleFlag", + "PortfolioInvitation", "Portfolio", "DomainGroup", "Suborganization", @@ -63,6 +65,7 @@ auditlog.register(Website) auditlog.register(TransitionDomain) auditlog.register(VerifiedByStaff) auditlog.register(WaffleFlag) +auditlog.register(PortfolioInvitation) auditlog.register(Portfolio) auditlog.register(DomainGroup) auditlog.register(Suborganization) diff --git a/src/registrar/models/domain_request.py b/src/registrar/models/domain_request.py index a7252e16b..363de213b 100644 --- a/src/registrar/models/domain_request.py +++ b/src/registrar/models/domain_request.py @@ -215,6 +215,11 @@ class DomainRequest(TimeStampedModel): } return org_election_map + @classmethod + def get_org_label(cls, org_name: str): + # Translating the key that is given to the direct readable value + return cls(org_name).label if org_name else None + class OrganizationChoicesVerbose(models.TextChoices): """ Tertiary organization choices diff --git a/src/registrar/models/portfolio_invitation.py b/src/registrar/models/portfolio_invitation.py new file mode 100644 index 000000000..2ad780429 --- /dev/null +++ b/src/registrar/models/portfolio_invitation.py @@ -0,0 +1,95 @@ +"""People are invited by email to administer domains.""" + +import logging + +from django.contrib.auth import get_user_model +from django.db import models + +from django_fsm import FSMField, transition +from .utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices # type: ignore + +from .utility.time_stamped_model import TimeStampedModel +from django.contrib.postgres.fields import ArrayField + + +logger = logging.getLogger(__name__) + + +class PortfolioInvitation(TimeStampedModel): + class Meta: + """Contains meta information about this class""" + + indexes = [ + models.Index(fields=["status"]), + ] + + # Constants for status field + class PortfolioInvitationStatus(models.TextChoices): + INVITED = "invited", "Invited" + RETRIEVED = "retrieved", "Retrieved" + + email = models.EmailField( + null=False, + blank=False, + ) + + portfolio = models.ForeignKey( + "registrar.Portfolio", + on_delete=models.CASCADE, # delete portfolio, then get rid of invitations + null=False, + related_name="portfolios", + ) + + portfolio_roles = ArrayField( + models.CharField( + max_length=50, + choices=UserPortfolioRoleChoices.choices, + ), + null=True, + blank=True, + help_text="Select one or more roles.", + ) + + portfolio_additional_permissions = ArrayField( + models.CharField( + max_length=50, + choices=UserPortfolioPermissionChoices.choices, + ), + null=True, + blank=True, + help_text="Select one or more additional permissions.", + ) + + status = FSMField( + choices=PortfolioInvitationStatus.choices, + default=PortfolioInvitationStatus.INVITED, + protected=True, # can't alter state except through transition methods! + ) + + def __str__(self): + return f"Invitation for {self.email} on {self.portfolio} is {self.status}" + + @transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED) + def retrieve(self): + """When an invitation is retrieved, create the corresponding permission. + + Raises: + RuntimeError if no matching user can be found. + """ + + # get a user with this email address + User = get_user_model() + try: + user = User.objects.get(email=self.email) + except User.DoesNotExist: + # should not happen because a matching user should exist before + # we retrieve this invitation + raise RuntimeError("Cannot find the user to retrieve this portfolio invitation.") + + # and create a role for that user on this portfolio + user.portfolio = self.portfolio + if self.portfolio_roles and len(self.portfolio_roles) > 0: + user.portfolio_roles = self.portfolio_roles + if self.portfolio_additional_permissions and len(self.portfolio_additional_permissions) > 0: + user.portfolio_additional_permissions = self.portfolio_additional_permissions + user.save() diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index b1c9473db..bd2af40b7 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -5,8 +5,10 @@ from django.db import models from django.db.models import Q from registrar.models.user_domain_role import UserDomainRole +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .domain_invitation import DomainInvitation +from .portfolio_invitation import PortfolioInvitation from .transition_domain import TransitionDomain from .verified_by_staff import VerifiedByStaff from .domain import Domain @@ -62,31 +64,6 @@ class User(AbstractUser): # after they login. FIXTURE_USER = "fixture_user", "Created by fixtures" - class UserPortfolioRoleChoices(models.TextChoices): - """ - Roles make it easier for admins to look at - """ - - ORGANIZATION_ADMIN = "organization_admin", "Admin" - ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only" - ORGANIZATION_MEMBER = "organization_member", "Member" - - class UserPortfolioPermissionChoices(models.TextChoices): - """ """ - - VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports" - VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains" - - VIEW_MEMBER = "view_member", "View members" - EDIT_MEMBER = "edit_member", "Create and edit members" - - VIEW_ALL_REQUESTS = "view_all_requests", "View all requests" - VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests" - EDIT_REQUESTS = "edit_requests", "Create and edit requests" - - VIEW_PORTFOLIO = "view_portfolio", "View organization" - EDIT_PORTFOLIO = "edit_portfolio", "Edit organization" - PORTFOLIO_ROLE_PERMISSIONS = { UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, @@ -270,20 +247,23 @@ class User(AbstractUser): return portfolio_permission in portfolio_permissions - # the methods below are checks for individual portfolio permissions. they are defined here + # the methods below are checks for individual portfolio permissions. They are defined here # to make them easier to call elsewhere throughout the application def has_base_portfolio_permission(self): - return self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_PORTFOLIO) + return self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_PORTFOLIO) + + def has_edit_org_portfolio_permission(self): + return self._has_portfolio_permission(UserPortfolioPermissionChoices.EDIT_PORTFOLIO) def has_domains_portfolio_permission(self): return self._has_portfolio_permission( - User.UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS - ) or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS + ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS) def has_domain_requests_portfolio_permission(self): return self._has_portfolio_permission( - User.UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS - ) or self._has_portfolio_permission(User.UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS + ) or self._has_portfolio_permission(UserPortfolioPermissionChoices.VIEW_CREATED_REQUESTS) @classmethod def needs_identity_verification(cls, email, uuid): @@ -392,6 +372,24 @@ class User(AbstractUser): new_domain_invitation = DomainInvitation(email=transition_domain_email.lower(), domain=new_domain) new_domain_invitation.save() + def check_portfolio_invitations_on_login(self): + """When a user first arrives on the site, we need to retrieve any portfolio + invitations that match their email address.""" + for invitation in PortfolioInvitation.objects.filter( + email__iexact=self.email, status=PortfolioInvitation.PortfolioInvitationStatus.INVITED + ): + if self.portfolio is None: + try: + invitation.retrieve() + invitation.save() + except RuntimeError: + # retrieving should not fail because of a missing user, but + # if it does fail, log the error so a new user can continue + # logging in + logger.warn("Failed to retrieve invitation %s", invitation, exc_info=True) + else: + logger.warn("User already has a portfolio, did not retrieve invitation %s", invitation, exc_info=True) + def on_each_login(self): """Callback each time the user is authenticated. @@ -403,6 +401,7 @@ class User(AbstractUser): """ self.check_domain_invitations_on_login() + self.check_portfolio_invitations_on_login() def is_org_user(self, request): has_organization_feature_flag = flag_is_active(request, "organization_feature") diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py new file mode 100644 index 000000000..70977f312 --- /dev/null +++ b/src/registrar/models/utility/portfolio_helper.py @@ -0,0 +1,28 @@ +from django.db import models + + +class UserPortfolioRoleChoices(models.TextChoices): + """ + Roles make it easier for admins to look at + """ + + ORGANIZATION_ADMIN = "organization_admin", "Admin" + ORGANIZATION_ADMIN_READ_ONLY = "organization_admin_read_only", "Admin read only" + ORGANIZATION_MEMBER = "organization_member", "Member" + + +class UserPortfolioPermissionChoices(models.TextChoices): + """ """ + + VIEW_ALL_DOMAINS = "view_all_domains", "View all domains and domain reports" + VIEW_MANAGED_DOMAINS = "view_managed_domains", "View managed domains" + + VIEW_MEMBER = "view_member", "View members" + EDIT_MEMBER = "edit_member", "Create and edit members" + + VIEW_ALL_REQUESTS = "view_all_requests", "View all requests" + VIEW_CREATED_REQUESTS = "view_created_requests", "View created requests" + EDIT_REQUESTS = "edit_requests", "Create and edit requests" + + VIEW_PORTFOLIO = "view_portfolio", "View organization" + EDIT_PORTFOLIO = "edit_portfolio", "Edit organization" diff --git a/src/registrar/registrar_middleware.py b/src/registrar/registrar_middleware.py index dd9b5541a..2af331bc9 100644 --- a/src/registrar/registrar_middleware.py +++ b/src/registrar/registrar_middleware.py @@ -149,10 +149,10 @@ class CheckPortfolioMiddleware: request.portfolio = portfolio if request.user.has_domains_portfolio_permission(): - portfolio_redirect = reverse("portfolio-domains", kwargs={"portfolio_id": portfolio.id}) + portfolio_redirect = reverse("domains") else: # View organization is the lowest access - portfolio_redirect = reverse("portfolio-organization", kwargs={"portfolio_id": portfolio.id}) + portfolio_redirect = reverse("organization") return HttpResponseRedirect(portfolio_redirect) diff --git a/src/registrar/templates/admin/change_form.html b/src/registrar/templates/admin/change_form.html index 78dac9ac0..f2ac7f2df 100644 --- a/src/registrar/templates/admin/change_form.html +++ b/src/registrar/templates/admin/change_form.html @@ -1,4 +1,5 @@ {% extends "admin/change_form.html" %} +{% load static i18n %} {% comment %} Replace the Django ul markup with a div. We'll edit the child markup accordingly in change_form_object_tools {% endcomment %} {% block object-tools %} @@ -9,4 +10,4 @@ {% endblock %} {% endif %} -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/src/registrar/templates/admin/change_form_object_tools.html b/src/registrar/templates/admin/change_form_object_tools.html index 28c655bbc..198140c19 100644 --- a/src/registrar/templates/admin/change_form_object_tools.html +++ b/src/registrar/templates/admin/change_form_object_tools.html @@ -1,4 +1,5 @@ {% load i18n admin_urls %} +{% load i18n static %} {% comment %} Replace li with p for more semantic HTML if we have a single child {% endcomment %} {% block object-tools-items %} @@ -13,8 +14,21 @@ {% else %} -

- {% translate "History" %} -

+ {% endif %} -{% endblock %} \ No newline at end of file +{% endblock %} + diff --git a/src/registrar/templates/admin/input_with_clipboard.html b/src/registrar/templates/admin/input_with_clipboard.html index 20a029bed..ea2fbce33 100644 --- a/src/registrar/templates/admin/input_with_clipboard.html +++ b/src/registrar/templates/admin/input_with_clipboard.html @@ -8,7 +8,7 @@ Template for an input field with a clipboard
{{ field }}
+ {% endif %} + + {% url 'domain-dns-nameservers' pk=domain.id as url %} {% if domain.nameservers|length > 0 %} - {% include "includes/summary_item.html" with title='DNS name servers' domains='true' value=domain.nameservers list='true' edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='DNS name servers' domains='true' value=domain.nameservers list='true' edit_link=url editable=is_editable %} {% else %} - {% if domain.is_editable %} + {% if is_editable %}

DNS name servers

No DNS name servers have been added yet. Before your domain can be used we’ll need information about your domain name servers.

Add DNS name servers {% else %} - {% include "includes/summary_item.html" with title='DNS name servers' domains='true' value='' edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='DNS name servers' domains='true' value='' edit_link=url editable=is_editable %} {% endif %} {% endif %} {% url 'domain-org-name-address' pk=domain.id as url %} - {% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='Organization name and mailing address' value=domain.domain_info address='true' edit_link=url editable=is_editable %} {% url 'domain-senior-official' pk=domain.id as url %} - {% include "includes/summary_item.html" with title='Senior official' value=domain.domain_info.senior_official contact='true' edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='Senior official' value=domain.domain_info.senior_official contact='true' edit_link=url editable=is_editable %} {# Conditionally display profile #} {% if not has_profile_feature_flag %} {% url 'domain-your-contact-information' pk=domain.id as url %} - {% include "includes/summary_item.html" with title='Your contact information' value=request.user contact='true' edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='Your contact information' value=request.user contact='true' edit_link=url editable=is_editable %} {% endif %} {% url 'domain-security-email' pk=domain.id as url %} {% if security_email is not None and security_email not in hidden_security_emails%} - {% include "includes/summary_item.html" with title='Security email' value=security_email edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='Security email' value=security_email edit_link=url editable=is_editable %} {% else %} - {% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url editable=is_editable %} {% endif %} {% url 'domain-users' pk=domain.id as url %} - {% include "includes/summary_item.html" with title='Domain managers' users='true' list=True value=domain.permissions.all edit_link=url editable=domain.is_editable %} + {% include "includes/summary_item.html" with title='Domain managers' users='true' list=True value=domain.permissions.all edit_link=url editable=is_editable %} {% endblock %} {# domain_content #} diff --git a/src/registrar/templates/domain_sidebar.html b/src/registrar/templates/domain_sidebar.html index d61e5f45c..603822d0d 100644 --- a/src/registrar/templates/domain_sidebar.html +++ b/src/registrar/templates/domain_sidebar.html @@ -12,7 +12,7 @@ - {% if domain.is_editable %} + {% if is_editable %}
  • {% url 'domain-dns' pk=domain.id as url %} diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index ad91699ef..30c206741 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -2,7 +2,7 @@
    - {% if portfolio is None %} + {% if not has_domain_requests_portfolio_permission %}

    Domain requests

    diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html index 528f56151..64eddec41 100644 --- a/src/registrar/templates/includes/domains_table.html +++ b/src/registrar/templates/includes/domains_table.html @@ -1,17 +1,15 @@ {% load static %} -
    -
    - {% if portfolio is None %} -
    -

    Domains

    -
    +
    +
    + {% if not has_domains_portfolio_permission %} +

    Domains

    {% else %} {% endif %} -