diff --git a/.github/workflows/security-check.yaml b/.github/workflows/security-check.yaml
index aea700613..bf0498fff 100644
--- a/.github/workflows/security-check.yaml
+++ b/.github/workflows/security-check.yaml
@@ -2,17 +2,9 @@ name: Security checks
on:
push:
- paths-ignore:
- - 'docs/**'
- - '**.md'
- - '.gitignore'
branches:
- main
pull_request:
- paths-ignore:
- - 'docs/**'
- - '**.md'
- - '.gitignore'
branches:
- main
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 642e9dc30..6332956f8 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -3,10 +3,6 @@ name: Testing
on:
push:
- paths-ignore:
- - 'docs/**'
- - '**.md'
- - '.gitignore'
branches:
- main
pull_request:
diff --git a/docs/developer/cloning-databases.md b/docs/developer/cloning-databases.md
new file mode 100644
index 000000000..3c8a3c3fa
--- /dev/null
+++ b/docs/developer/cloning-databases.md
@@ -0,0 +1,17 @@
+# Cloning Databases
+The clone-db workflow clones a Source database to a Destination database using cloud.gov's cg-manage-rds tool. This document contains additional information needed to understand how the workflow functions.
+
+## Additional Roles Required
+The clone-db workflow functions by temporarily sharing the Destination database with the space of the Source database. This is because cloning databases across spaces is hard. Sharing is done via the `cf share-service` command, but requires that the authenticated user (in this case this will be a user from the Source space) have the `space-developer` role in *both* the Source and Destination spaces. This must be set by someone with permission to edit space roles *before* the workflow runs. The user in question can be found using the `cf space-users [ORG] [SPACE]` command where the SPACE is the Source space, and will appear as a UAA user with a UUID as the name. There is only one such user per space by default (this is a [service account](https://cloud.gov/docs/services/cloud-gov-service-account/) set up by cloud.gov for our Github workflows). This user needs to be provided with the `space-developer` role in the Destination space, which can be accomplished using `cf set-space-role [USER] [ORG] [DESTINATION SPACE] SpaceDeveloper`.
+
+## Turning Off DB Cloning Fast (For Emergencies or other Scenarios)
+Note: In less urgent situations it may be better to make a PR removing the scheduled workflow trigger.
+
+Step 1:
+Get the name of the correct service using `cf spaces-users cisa-dotgov stable`. There should only be one user with a name that is a UUID, that is the one you want.
+
+step 2:
+Remove the space developer role by doing the following command:
+`cf unset-space-role [USER] cisa-dotgov staging SpaceDeveloper`
+
+This will cause the job to fail without requiring pushing anything to main.
diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md
index 0863aa0b7..cdef3dba7 100644
--- a/docs/operations/data_migration.md
+++ b/docs/operations/data_migration.md
@@ -907,14 +907,87 @@ Example (only requests): `./manage.py create_federal_portfolio --branch "executi
```docker-compose exec app ./manage.py create_federal_portfolio --agency_name "{federal_agency_name}" --both```
##### Parameters
-| | Parameter | Description |
-|:-:|:-------------------------- |:-------------------------------------------------------------------------------------------|
-| 1 | **agency_name** | Name of the FederalAgency record surrounded by quotes. For instance,"AMTRAK". |
-| 2 | **branch** | Creates a portfolio for each federal agency in a branch: executive, legislative, judicial |
-| 3 | **both** | If True, runs parse_requests and parse_domains. |
-| 4 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. |
-| 5 | **parse_domains** | If True, then the created portfolio is added to all related Domains. |
+| | Parameter | Description |
+|:-:|:---------------------------- |:-------------------------------------------------------------------------------------------|
+| 1 | **agency_name** | Name of the FederalAgency record surrounded by quotes. For instance,"AMTRAK". |
+| 2 | **branch** | Creates a portfolio for each federal agency in a branch: executive, legislative, judicial |
+| 3 | **both** | If True, runs parse_requests and parse_domains. |
+| 4 | **parse_requests** | If True, then the created portfolio is added to all related DomainRequests. |
+| 5 | **parse_domains** | If True, then the created portfolio is added to all related Domains. |
+| 6 | **skip_existing_portfolios** | If True, then the script will only create suborganizations, modify DomainRequest, and modify DomainInformation records only when creating a new portfolio. Use this flag when you do not want to modify existing records. |
- Parameters #1-#2: Either `--agency_name` or `--branch` must be specified. Not both.
- Parameters #2-#3, you cannot use `--both` while using these. You must specify either `--parse_requests` or `--parse_domains` seperately. While all of these parameters are optional in that you do not need to specify all of them,
you must specify at least one to run this script.
+
+
+## Patch suborganizations
+This script deletes some duplicate suborganization data that exists in our database (one-time use).
+It works in two ways:
+1. If the only name difference between two suborg records is extra spaces or a capitalization difference,
+then we delete all duplicate records of this type.
+2. If the suborg name is one we manually specify to delete via the script.
+
+Before it deletes records, it goes through each DomainInformation and DomainRequest object and updates the reference to "sub_organization" to match the non-duplicative record.
+
+### Running on sandboxes
+
+#### Step 1: Login to CloudFoundry
+```cf login -a api.fr.cloud.gov --sso```
+
+#### Step 2: SSH into your environment
+```cf ssh getgov-{space}```
+
+Example: `cf ssh getgov-za`
+
+#### Step 3: Create a shell instance
+```/tmp/lifecycle/shell```
+
+#### Step 4: Upload your csv to the desired sandbox
+[Follow these steps](#use-scp-to-transfer-data-to-sandboxes) to upload the federal_cio csv to a sandbox of your choice.
+
+#### Step 5: Running the script
+To create a specific portfolio:
+```./manage.py patch_suborganizations```
+
+### Running locally
+
+#### Step 1: Running the script
+```docker-compose exec app ./manage.py patch_suborganizations```
+
+
+## Remove Non-whitelisted Portfolios
+This script removes Portfolio entries from the database that are not part of a predefined list of allowed portfolios (`ALLOWED_PORTFOLIOS`).
+It performs the following actions:
+1. Prompts the user for confirmation before proceeding with deletions.
+2. Updates related objects such as `DomainInformation`, `Domain`, and `DomainRequest` to set their `portfolio` field to `None` to prevent integrity errors.
+3. Deletes associated objects such as `PortfolioInvitation`, `UserPortfolioPermission`, and `Suborganization`.
+4. Logs a detailed summary of all cascading deletions and orphaned objects.
+
+### Running on sandboxes
+
+#### Step 1: Login to CloudFoundry
+```cf login -a api.fr.cloud.gov --sso```
+
+#### Step 2: SSH into your environment
+```cf ssh getgov-{space}```
+
+Example: `cf ssh getgov-nl`
+
+#### Step 3: Create a shell instance
+```/tmp/lifecycle/shell```
+
+#### Step 4: Running the script
+To remove portfolios:
+```./manage.py remove_unused_portfolios```
+
+If you wish to enable debug mode for additional logging:
+```./manage.py remove_unused_portfolios --debug```
+
+### Running locally
+
+#### Step 1: Running the script
+```docker-compose exec app ./manage.py remove_unused_portfolios```
+
+To enable debug mode locally:
+```docker-compose exec app ./manage.py remove_unused_portfolios --debug```
\ No newline at end of file
diff --git a/src/djangooidc/backends.py b/src/djangooidc/backends.py
index 41e442f2d..38fde0ced 100644
--- a/src/djangooidc/backends.py
+++ b/src/djangooidc/backends.py
@@ -21,49 +21,66 @@ class OpenIdConnectBackend(ModelBackend):
"""
def authenticate(self, request, **kwargs):
- logger.debug("kwargs %s" % kwargs)
- user = None
- if not kwargs or "sub" not in kwargs.keys():
- return user
+ logger.debug("kwargs %s", kwargs)
+
+ if not kwargs or "sub" not in kwargs:
+ return None
UserModel = get_user_model()
username = self.clean_username(kwargs["sub"])
+ openid_data = self.extract_openid_data(kwargs)
- # Some OP may actually choose to withhold some information, so we must
- # test if it is present
- openid_data = {"last_login": timezone.now()}
- openid_data["first_name"] = kwargs.get("given_name", "")
- openid_data["last_name"] = kwargs.get("family_name", "")
- openid_data["email"] = kwargs.get("email", "")
- openid_data["phone"] = kwargs.get("phone", "")
-
- # Note that this could be accomplished in one try-except clause, but
- # instead we use get_or_create when creating unknown users since it has
- # built-in safeguards for multiple threads.
if getattr(settings, "OIDC_CREATE_UNKNOWN_USER", True):
- args = {
- UserModel.USERNAME_FIELD: username,
- # defaults _will_ be updated, these are not fallbacks
- "defaults": openid_data,
- }
-
- user, created = UserModel.objects.get_or_create(**args)
-
- if not created:
- # If user exists, update existing user
- self.update_existing_user(user, args["defaults"])
- else:
- # If user is created, configure the user
- user = self.configure_user(user, **kwargs)
+ user = self.get_or_create_user(UserModel, username, openid_data, kwargs)
else:
- try:
- user = UserModel.objects.get_by_natural_key(username)
- except UserModel.DoesNotExist:
- return None
- # run this callback for a each login
- user.on_each_login()
+ user = self.get_user_by_username(UserModel, username)
+
+ if user:
+ user.on_each_login()
+
return user
+ def extract_openid_data(self, kwargs):
+ """Extract OpenID data from authentication kwargs."""
+ return {
+ "last_login": timezone.now(),
+ "first_name": kwargs.get("given_name", ""),
+ "last_name": kwargs.get("family_name", ""),
+ "email": kwargs.get("email", ""),
+ "phone": kwargs.get("phone", ""),
+ }
+
+ def get_or_create_user(self, UserModel, username, openid_data, kwargs):
+ """Retrieve user by username or email, or create a new user."""
+ user = self.get_user_by_username(UserModel, username)
+
+ if not user and openid_data["email"]:
+ user = self.get_user_by_email(UserModel, openid_data["email"])
+ if user:
+ # if found by email, update the username
+ setattr(user, UserModel.USERNAME_FIELD, username)
+
+ if not user:
+ user = UserModel.objects.create(**{UserModel.USERNAME_FIELD: username}, **openid_data)
+ return self.configure_user(user, **kwargs)
+
+ self.update_existing_user(user, openid_data)
+ return user
+
+ def get_user_by_username(self, UserModel, username):
+ """Retrieve user by username."""
+ try:
+ return UserModel.objects.get(**{UserModel.USERNAME_FIELD: username})
+ except UserModel.DoesNotExist:
+ return None
+
+ def get_user_by_email(self, UserModel, email):
+ """Retrieve user by email."""
+ try:
+ return UserModel.objects.get(email=email)
+ except UserModel.DoesNotExist:
+ return None
+
def update_existing_user(self, user, kwargs):
"""
Update user fields without overwriting certain fields.
diff --git a/src/djangooidc/tests/test_backends.py b/src/djangooidc/tests/test_backends.py
index c15106fa9..4e8f80a23 100644
--- a/src/djangooidc/tests/test_backends.py
+++ b/src/djangooidc/tests/test_backends.py
@@ -1,5 +1,6 @@
from django.test import TestCase
from registrar.models import User
+from api.tests.common import less_console_noise_decorator
from ..backends import OpenIdConnectBackend # Adjust the import path based on your project structure
@@ -17,6 +18,7 @@ class OpenIdConnectBackendTestCase(TestCase):
def tearDown(self) -> None:
User.objects.all().delete()
+ @less_console_noise_decorator
def test_authenticate_with_create_user(self):
"""Test that authenticate creates a new user if it does not find
existing user"""
@@ -32,6 +34,7 @@ class OpenIdConnectBackendTestCase(TestCase):
self.assertEqual(user.email, "john.doe@example.com")
self.assertEqual(user.phone, "123456789")
+ @less_console_noise_decorator
def test_authenticate_with_existing_user(self):
"""Test that authenticate updates an existing user if it finds one.
For this test, given_name and family_name are supplied"""
@@ -50,6 +53,30 @@ class OpenIdConnectBackendTestCase(TestCase):
self.assertEqual(user.email, "john.doe@example.com")
self.assertEqual(user.phone, "123456789")
+ @less_console_noise_decorator
+ def test_authenticate_with_existing_user_same_email_different_username(self):
+ """Test that authenticate updates an existing user if it finds one.
+ In this case, match is to an existing record with matching email but
+ a non-matching username. The existing record's username should be udpated.
+ For this test, given_name and family_name are supplied"""
+ # Create an existing user with the same username
+ User.objects.create_user(username="old_username", email="john.doe@example.com")
+
+ # Ensure that the authenticate method updates the existing user
+ user = self.backend.authenticate(request=None, **self.kwargs)
+ self.assertIsNotNone(user)
+ self.assertIsInstance(user, User)
+
+ # Verify that user fields are correctly updated
+ self.assertEqual(user.first_name, "John")
+ self.assertEqual(user.last_name, "Doe")
+ self.assertEqual(user.email, "john.doe@example.com")
+ self.assertEqual(user.phone, "123456789")
+ self.assertEqual(user.username, "test_user")
+ # Assert that a user no longer exists by the old username
+ self.assertFalse(User.objects.filter(username="old_username").exists())
+
+ @less_console_noise_decorator
def test_authenticate_with_existing_user_with_existing_first_last_phone(self):
"""Test that authenticate updates an existing user if it finds one.
For this test, given_name and family_name are not supplied.
@@ -79,6 +106,7 @@ class OpenIdConnectBackendTestCase(TestCase):
self.assertEqual(user.email, "john.doe@example.com")
self.assertEqual(user.phone, "9999999999")
+ @less_console_noise_decorator
def test_authenticate_with_existing_user_different_name_phone(self):
"""Test that authenticate updates an existing user if it finds one.
For this test, given_name and family_name are supplied and overwrite"""
@@ -100,6 +128,7 @@ class OpenIdConnectBackendTestCase(TestCase):
self.assertEqual(user.email, "john.doe@example.com")
self.assertEqual(user.phone, "123456789")
+ @less_console_noise_decorator
def test_authenticate_with_unknown_user(self):
"""Test that authenticate returns None when no kwargs are supplied"""
# Ensure that the authenticate method handles the case when the user is not found
diff --git a/src/package-lock.json b/src/package-lock.json
index a769abdf0..5caff976c 100644
--- a/src/package-lock.json
+++ b/src/package-lock.json
@@ -7074,9 +7074,9 @@
}
},
"node_modules/undici": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz",
- "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==",
+ "version": "6.21.1",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz",
+ "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==",
"license": "MIT",
"engines": {
"node": ">=18.17"
diff --git a/src/registrar/admin.py b/src/registrar/admin.py
index bb42b66c6..8ecf36f52 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -14,6 +14,7 @@ from django.db.models import (
from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect
from registrar.models.federal_agency import FederalAgency
+from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.utility.admin_helpers import (
AutocompleteSelectWithPlaceholder,
get_action_needed_reason_default_email,
@@ -27,8 +28,12 @@ from django.shortcuts import redirect
from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
-from registrar.utility.email import EmailSendingError
-from registrar.utility.email_invitations import send_portfolio_invitation_email
+from registrar.utility.email_invitations import send_domain_invitation_email, send_portfolio_invitation_email
+from registrar.views.utility.invitation_helper import (
+ get_org_membership,
+ get_requested_user,
+ handle_invitation_exceptions,
+)
from waffle.decorators import flag_is_active
from django.contrib import admin, messages
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
@@ -41,7 +46,7 @@ from waffle.admin import FlagAdmin
from waffle.models import Sample, Switch
from registrar.models import Contact, Domain, DomainRequest, DraftDomain, User, Website, SeniorOfficial
from registrar.utility.constants import BranchChoices
-from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes, MissingEmailError
+from registrar.utility.errors import FSMDomainRequestError, FSMErrorCodes
from registrar.utility.waffle import flag_is_active_for_user
from registrar.views.utility.mixins import OrderableFieldsMixin
from django.contrib.admin.views.main import ORDER_VAR
@@ -1217,9 +1222,9 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
class SeniorOfficialAdmin(ListHeaderAdmin):
"""Custom Senior Official Admin class."""
- search_fields = ["first_name", "last_name", "email"]
+ search_fields = ["first_name", "last_name", "email", "federal_agency__agency"]
search_help_text = "Search by first name, last name or email."
- list_display = ["first_name", "last_name", "email", "federal_agency"]
+ list_display = ["federal_agency", "first_name", "last_name", "email"]
# this ordering effects the ordering of results
# in autocomplete_fields for Senior Official
@@ -1362,6 +1367,8 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
autocomplete_fields = ["user", "domain"]
+ change_form_template = "django/admin/user_domain_role_change_form.html"
+
# Fixes a bug where non-superusers are redirected to the main page
def delete_view(self, request, object_id, extra_context=None):
"""Custom delete_view implementation that specifies redirect behaviour"""
@@ -1389,169 +1396,9 @@ class UserDomainRoleAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return super().changeform_view(request, object_id, form_url, extra_context=extra_context)
-class DomainInvitationAdmin(ListHeaderAdmin):
- """Custom domain invitation admin class."""
-
- class Meta:
- model = models.DomainInvitation
- fields = "__all__"
-
- _meta = Meta()
-
- # Columns
- list_display = [
- "email",
- "domain",
- "status",
- ]
-
- # Search
- search_fields = [
- "email",
- "domain__name",
- ]
-
- # Filters
- list_filter = ("status",)
-
- search_help_text = "Search by email or domain."
-
- # 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 = ["domain"]
-
- change_form_template = "django/admin/email_clipboard_change_form.html"
-
- # Select domain invitations to change -> Domain invitations
- def changelist_view(self, request, extra_context=None):
- if extra_context is None:
- extra_context = {}
- extra_context["tabtitle"] = "Domain invitations"
- # Get the filtered values
- return super().changelist_view(request, extra_context=extra_context)
-
- def save_model(self, request, obj, form, change):
- """
- Override the save_model method.
-
- On creation of a new domain invitation, attempt to retrieve the invitation,
- which will be successful if a single User exists for that email; otherwise, will
- just continue to create the invitation.
- """
- if not change and User.objects.filter(email=obj.email).count() == 1:
- # Domain Invitation creation for an existing User
- obj.retrieve()
- # Call the parent save method to save the object
- super().save_model(request, obj, form, change)
-
-
-class PortfolioInvitationAdmin(ListHeaderAdmin):
- """Custom portfolio invitation admin class."""
-
- form = PortfolioInvitationAdminForm
-
- class Meta:
- model = models.PortfolioInvitation
- fields = "__all__"
-
- _meta = Meta()
-
- # Columns
- list_display = [
- "email",
- "portfolio",
- "roles",
- "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/portfolio_invitation_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)
-
- def save_model(self, request, obj, form, change):
- """
- Override the save_model method.
-
- Only send email on creation of the PortfolioInvitation object. Not on updates.
- Emails sent to requested user / email.
- When exceptions are raised, return without saving model.
- """
- if not change: # Only send email if this is a new PortfolioInvitation (creation)
- portfolio = obj.portfolio
- requested_email = obj.email
- requestor = request.user
-
- permission_exists = UserPortfolioPermission.objects.filter(
- user__email=requested_email, portfolio=portfolio, user__email__isnull=False
- ).exists()
- try:
- if not permission_exists:
- # if permission does not exist for a user with requested_email, send email
- send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
- messages.success(request, f"{requested_email} has been invited.")
- else:
- messages.warning(request, "User is already a member of this portfolio.")
- except Exception as e:
- # when exception is raised, handle and do not save the model
- self._handle_exceptions(e, request, obj)
- return
- # Call the parent save method to save the object
- super().save_model(request, obj, form, change)
-
- def _handle_exceptions(self, exception, request, obj):
- """Handle exceptions raised during the process.
-
- Log warnings / errors, and message errors to the user.
- """
- if isinstance(exception, EmailSendingError):
- logger.warning(
- "Could not sent email invitation to %s for portfolio %s (EmailSendingError)",
- obj.email,
- obj.portfolio,
- exc_info=True,
- )
- messages.error(request, "Could not send email invitation. Portfolio invitation not saved.")
- elif isinstance(exception, MissingEmailError):
- messages.error(request, str(exception))
- logger.error(
- f"Can't send email to '{obj.email}' for portfolio '{obj.portfolio}'. "
- f"No email exists for the requestor.",
- exc_info=True,
- )
-
- else:
- logger.warning("Could not send email invitation (Other Exception)", exc_info=True)
- messages.error(request, "Could not send email invitation. Portfolio invitation not saved.")
+class BaseInvitationAdmin(ListHeaderAdmin):
+ """Base class for admin classes which will customize save_model and send email invitations
+ on model adds, and require custom handling of forms and form errors."""
def response_add(self, request, obj, post_url_continue=None):
"""
@@ -1560,8 +1407,9 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
Normal flow on successful save_model on add is to redirect to changelist_view.
If there are errors, flow is modified to instead render change form.
"""
- # Check if there are any error or warning messages in the `messages` framework
+ # store current messages from request so that they are preserved throughout the method
storage = get_messages(request)
+ # Check if there are any error or warning messages in the `messages` framework
has_errors = any(message.level_tag in ["error", "warning"] for message in storage)
if has_errors:
@@ -1608,7 +1456,206 @@ class PortfolioInvitationAdmin(ListHeaderAdmin):
change=False,
obj=obj,
)
- return super().response_add(request, obj, post_url_continue)
+
+ response = super().response_add(request, obj, post_url_continue)
+
+ # Re-add all messages from storage after `super().response_add`
+ # as super().response_add resets the success messages in request
+ for message in storage:
+ messages.add_message(request, message.level, message.message)
+
+ return response
+
+
+class DomainInvitationAdmin(BaseInvitationAdmin):
+ """Custom domain invitation admin class."""
+
+ class Meta:
+ model = models.DomainInvitation
+ fields = "__all__"
+
+ _meta = Meta()
+
+ # Columns
+ list_display = [
+ "email",
+ "domain",
+ "status",
+ ]
+
+ # Search
+ search_fields = [
+ "email",
+ "domain__name",
+ ]
+
+ # Filters
+ list_filter = ("status",)
+
+ search_help_text = "Search by email or domain."
+
+ # 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 = ["domain"]
+
+ change_form_template = "django/admin/domain_invitation_change_form.html"
+
+ # Select domain invitations to change -> Domain invitations
+ def changelist_view(self, request, extra_context=None):
+ if extra_context is None:
+ extra_context = {}
+ extra_context["tabtitle"] = "Domain invitations"
+ # Get the filtered values
+ return super().changelist_view(request, extra_context=extra_context)
+
+ def save_model(self, request, obj, form, change):
+ """
+ Override the save_model method.
+
+ On creation of a new domain invitation, attempt to retrieve the invitation,
+ which will be successful if a single User exists for that email; otherwise, will
+ just continue to create the invitation.
+ """
+ if not change:
+ domain = obj.domain
+ domain_org = getattr(domain.domain_info, "portfolio", None)
+ requested_email = obj.email
+ # Look up a user with that email
+ requested_user = get_requested_user(requested_email)
+ requestor = request.user
+
+ member_of_a_different_org, member_of_this_org = get_org_membership(
+ domain_org, requested_email, requested_user
+ )
+
+ try:
+ if (
+ flag_is_active(request, "organization_feature")
+ and not flag_is_active(request, "multiple_portfolios")
+ and domain_org is not None
+ and not member_of_this_org
+ and not member_of_a_different_org
+ ):
+ send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=domain_org)
+ portfolio_invitation, _ = PortfolioInvitation.objects.get_or_create(
+ email=requested_email,
+ portfolio=domain_org,
+ roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
+ )
+ # if user exists for email, immediately retrieve portfolio invitation upon creation
+ if requested_user is not None:
+ portfolio_invitation.retrieve()
+ portfolio_invitation.save()
+ messages.success(request, f"{requested_email} has been invited to the organization: {domain_org}")
+
+ send_domain_invitation_email(
+ email=requested_email,
+ requestor=requestor,
+ domains=domain,
+ is_member_of_different_org=member_of_a_different_org,
+ requested_user=requested_user,
+ )
+ if requested_user is not None:
+ # Domain Invitation creation for an existing User
+ obj.retrieve()
+ # Call the parent save method to save the object
+ super().save_model(request, obj, form, change)
+ messages.success(request, f"{requested_email} has been invited to the domain: {domain}")
+ except Exception as e:
+ handle_invitation_exceptions(request, e, requested_email)
+ return
+ else:
+ # Call the parent save method to save the object
+ super().save_model(request, obj, form, change)
+
+
+class PortfolioInvitationAdmin(BaseInvitationAdmin):
+ """Custom portfolio invitation admin class."""
+
+ form = PortfolioInvitationAdminForm
+
+ class Meta:
+ model = models.PortfolioInvitation
+ fields = "__all__"
+
+ _meta = Meta()
+
+ # Columns
+ list_display = [
+ "email",
+ "portfolio",
+ "roles",
+ "additional_permissions",
+ "status",
+ ]
+
+ # Search
+ search_fields = [
+ "email",
+ "portfolio__organization_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/portfolio_invitation_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)
+
+ def save_model(self, request, obj, form, change):
+ """
+ Override the save_model method.
+
+ Only send email on creation of the PortfolioInvitation object. Not on updates.
+ Emails sent to requested user / email.
+ When exceptions are raised, return without saving model.
+ """
+ if not change: # Only send email if this is a new PortfolioInvitation (creation)
+ portfolio = obj.portfolio
+ requested_email = obj.email
+ requestor = request.user
+ # Look up a user with that email
+ requested_user = get_requested_user(requested_email)
+
+ permission_exists = UserPortfolioPermission.objects.filter(
+ user__email=requested_email, portfolio=portfolio, user__email__isnull=False
+ ).exists()
+ try:
+ if not permission_exists:
+ # if permission does not exist for a user with requested_email, send email
+ send_portfolio_invitation_email(email=requested_email, requestor=requestor, portfolio=portfolio)
+ # if user exists for email, immediately retrieve portfolio invitation upon creation
+ if requested_user is not None:
+ obj.retrieve()
+ messages.success(request, f"{requested_email} has been invited.")
+ else:
+ messages.warning(request, "User is already a member of this portfolio.")
+ except Exception as e:
+ # when exception is raised, handle and do not save the model
+ handle_invitation_exceptions(request, e, requested_email)
+ return
+ # Call the parent save method to save the object
+ super().save_model(request, obj, form, change)
class DomainInformationResource(resources.ModelResource):
@@ -1631,22 +1678,25 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
parameter_name = "converted_generic_orgs"
def lookups(self, request, model_admin):
- converted_generic_orgs = set()
+ # Annotate the queryset to avoid Python-side iteration
+ queryset = (
+ DomainInformation.objects.annotate(
+ converted_generic_org=Case(
+ When(portfolio__organization_type__isnull=False, then="portfolio__organization_type"),
+ When(portfolio__isnull=True, generic_org_type__isnull=False, then="generic_org_type"),
+ default=Value(""),
+ output_field=CharField(),
+ )
+ )
+ .values_list("converted_generic_org", flat=True)
+ .distinct()
+ )
- # Populate the set with tuples of (value, display value)
- for domain_info in DomainInformation.objects.all():
- converted_generic_org = domain_info.converted_generic_org_type # Actual value
- converted_generic_org_display = domain_info.converted_generic_org_type_display # Display value
+ # Filter out empty results and return sorted list of unique values
+ return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org])
- if converted_generic_org:
- converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display
-
- # Sort the set by display value
- return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value
-
- # Filter queryset
def queryset(self, request, queryset):
- if self.value(): # Check if a generic org is selected in the filter
+ if self.value():
return queryset.filter(
Q(portfolio__organization_type=self.value())
| Q(portfolio__isnull=True, generic_org_type=self.value())
@@ -1984,22 +2034,25 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
parameter_name = "converted_generic_orgs"
def lookups(self, request, model_admin):
- converted_generic_orgs = set()
+ # Annotate the queryset to avoid Python-side iteration
+ queryset = (
+ DomainRequest.objects.annotate(
+ converted_generic_org=Case(
+ When(portfolio__organization_type__isnull=False, then="portfolio__organization_type"),
+ When(portfolio__isnull=True, generic_org_type__isnull=False, then="generic_org_type"),
+ default=Value(""),
+ output_field=CharField(),
+ )
+ )
+ .values_list("converted_generic_org", flat=True)
+ .distinct()
+ )
- # Populate the set with tuples of (value, display value)
- for domain_request in DomainRequest.objects.all():
- converted_generic_org = domain_request.converted_generic_org_type # Actual value
- converted_generic_org_display = domain_request.converted_generic_org_type_display # Display value
+ # Filter out empty results and return sorted list of unique values
+ return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org])
- if converted_generic_org:
- converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display
-
- # Sort the set by display value
- return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value
-
- # Filter queryset
def queryset(self, request, queryset):
- if self.value(): # Check if a generic org is selected in the filter
+ if self.value():
return queryset.filter(
Q(portfolio__organization_type=self.value())
| Q(portfolio__isnull=True, generic_org_type=self.value())
@@ -2015,24 +2068,39 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
parameter_name = "converted_federal_types"
def lookups(self, request, model_admin):
- converted_federal_types = set()
-
- # Populate the set with tuples of (value, display value)
- for domain_request in DomainRequest.objects.all():
- converted_federal_type = domain_request.converted_federal_type # Actual value
- converted_federal_type_display = domain_request.converted_federal_type_display # Display value
-
- if converted_federal_type:
- converted_federal_types.add(
- (converted_federal_type, converted_federal_type_display) # Value, Display
+ # Annotate the queryset for efficient filtering
+ queryset = (
+ DomainRequest.objects.annotate(
+ converted_federal_type=Case(
+ When(
+ portfolio__isnull=False,
+ portfolio__federal_agency__federal_type__isnull=False,
+ then="portfolio__federal_agency__federal_type",
+ ),
+ When(
+ portfolio__isnull=True,
+ federal_agency__federal_type__isnull=False,
+ then="federal_agency__federal_type",
+ ),
+ default=Value(""),
+ output_field=CharField(),
)
+ )
+ .values_list("converted_federal_type", flat=True)
+ .distinct()
+ )
- # Sort the set by display value
- return sorted(converted_federal_types, key=lambda x: x[1]) # x[1] is the display value
+ # Filter out empty values and return sorted unique entries
+ return sorted(
+ [
+ (federal_type, BranchChoices.get_branch_label(federal_type))
+ for federal_type in queryset
+ if federal_type
+ ]
+ )
- # Filter queryset
def queryset(self, request, queryset):
- if self.value(): # Check if a federal type is selected in the filter
+ if self.value():
return queryset.filter(
Q(portfolio__federal_agency__federal_type=self.value())
| Q(portfolio__isnull=True, federal_type=self.value())
@@ -3179,59 +3247,86 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
parameter_name = "converted_generic_orgs"
def lookups(self, request, model_admin):
- converted_generic_orgs = set()
+ # Annotate the queryset to avoid Python-side iteration
+ queryset = (
+ Domain.objects.annotate(
+ converted_generic_org=Case(
+ When(
+ domain_info__isnull=False,
+ domain_info__portfolio__organization_type__isnull=False,
+ then="domain_info__portfolio__organization_type",
+ ),
+ When(
+ domain_info__isnull=False,
+ domain_info__portfolio__isnull=True,
+ domain_info__generic_org_type__isnull=False,
+ then="domain_info__generic_org_type",
+ ),
+ default=Value(""),
+ output_field=CharField(),
+ )
+ )
+ .values_list("converted_generic_org", flat=True)
+ .distinct()
+ )
- # Populate the set with tuples of (value, display value)
- for domain_info in DomainInformation.objects.all():
- converted_generic_org = domain_info.converted_generic_org_type # Actual value
- converted_generic_org_display = domain_info.converted_generic_org_type_display # Display value
+ # Filter out empty results and return sorted list of unique values
+ return sorted([(org, DomainRequest.OrganizationChoices.get_org_label(org)) for org in queryset if org])
- if converted_generic_org:
- converted_generic_orgs.add((converted_generic_org, converted_generic_org_display)) # Value, Display
-
- # Sort the set by display value
- return sorted(converted_generic_orgs, key=lambda x: x[1]) # x[1] is the display value
-
- # Filter queryset
def queryset(self, request, queryset):
- if self.value(): # Check if a generic org is selected in the filter
+ if self.value():
return queryset.filter(
Q(domain_info__portfolio__organization_type=self.value())
| Q(domain_info__portfolio__isnull=True, domain_info__generic_org_type=self.value())
)
-
return queryset
class FederalTypeFilter(admin.SimpleListFilter):
"""Custom Federal Type filter that accomodates portfolio feature.
If we have a portfolio, use the portfolio's federal type. If not, use the
- federal type in the Domain Information object."""
+ organization in the Domain Request object."""
title = "federal type"
parameter_name = "converted_federal_types"
def lookups(self, request, model_admin):
- converted_federal_types = set()
-
- # Populate the set with tuples of (value, display value)
- for domain_info in DomainInformation.objects.all():
- converted_federal_type = domain_info.converted_federal_type # Actual value
- converted_federal_type_display = domain_info.converted_federal_type_display # Display value
-
- if converted_federal_type:
- converted_federal_types.add(
- (converted_federal_type, converted_federal_type_display) # Value, Display
+ # Annotate the queryset for efficient filtering
+ queryset = (
+ Domain.objects.annotate(
+ converted_federal_type=Case(
+ When(
+ domain_info__isnull=False,
+ domain_info__portfolio__isnull=False,
+ then=F("domain_info__portfolio__federal_agency__federal_type"),
+ ),
+ When(
+ domain_info__isnull=False,
+ domain_info__portfolio__isnull=True,
+ domain_info__federal_type__isnull=False,
+ then="domain_info__federal_agency__federal_type",
+ ),
+ default=Value(""),
+ output_field=CharField(),
)
+ )
+ .values_list("converted_federal_type", flat=True)
+ .distinct()
+ )
- # Sort the set by display value
- return sorted(converted_federal_types, key=lambda x: x[1]) # x[1] is the display value
+ # Filter out empty values and return sorted unique entries
+ return sorted(
+ [
+ (federal_type, BranchChoices.get_branch_label(federal_type))
+ for federal_type in queryset
+ if federal_type
+ ]
+ )
- # Filter queryset
def queryset(self, request, queryset):
- if self.value(): # Check if a federal type is selected in the filter
+ if self.value():
return queryset.filter(
- Q(domain_info__portfolio__federal_agency__federal_type=self.value())
- | Q(domain_info__portfolio__isnull=True, domain_info__federal_agency__federal_type=self.value())
+ Q(domain_info__portfolio__federal_type=self.value())
+ | Q(domain_info__portfolio__isnull=True, domain_info__federal_type=self.value())
)
return queryset
diff --git a/src/registrar/assets/js/uswds-edited.js b/src/registrar/assets/js/uswds-edited.js
index f59417b41..9d4dd2e51 100644
--- a/src/registrar/assets/js/uswds-edited.js
+++ b/src/registrar/assets/js/uswds-edited.js
@@ -29,6 +29,7 @@
* - tooltip dynamic content updated to include nested element (for better sizing control)
* - modal exposed to window to be accessible in other js files
* - fixed bug in createHeaderButton which added newlines to header button tooltips
+ * - modified combobox to handle error class
*/
if ("document" in window.self) {
@@ -1213,6 +1214,11 @@ const enhanceComboBox = _comboBoxEl => {
input.setAttribute("class", INPUT_CLASS);
input.setAttribute("type", "text");
input.setAttribute("role", "combobox");
+ // DOTGOV - handle error class for combobox
+ // Check if 'usa-input--error' exists in selectEl and add it to input if true
+ if (selectEl.classList.contains('usa-input--error')) {
+ input.classList.add('usa-input--error');
+ }
additionalAttributes.forEach(attr => Object.keys(attr).forEach(key => {
const value = Sanitizer.escapeHTML`${attr[key]}`;
input.setAttribute(key, value);
diff --git a/src/registrar/assets/src/js/getgov/combobox.js b/src/registrar/assets/src/js/getgov/combobox.js
deleted file mode 100644
index 36b7aa0ad..000000000
--- a/src/registrar/assets/src/js/getgov/combobox.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import { hideElement, showElement } from './helpers.js';
-
-export function loadInitialValuesForComboBoxes() {
- var overrideDefaultClearButton = true;
- var isTyping = false;
-
- document.addEventListener('DOMContentLoaded', (event) => {
- handleAllComboBoxElements();
- });
-
- function handleAllComboBoxElements() {
- const comboBoxElements = document.querySelectorAll(".usa-combo-box");
- comboBoxElements.forEach(comboBox => {
- const input = comboBox.querySelector("input");
- const select = comboBox.querySelector("select");
- if (!input || !select) {
- console.warn("No combobox element found");
- return;
- }
- // Set the initial value of the combobox
- let initialValue = select.getAttribute("data-default-value");
- let clearInputButton = comboBox.querySelector(".usa-combo-box__clear-input");
- if (!clearInputButton) {
- console.warn("No clear element found");
- return;
- }
-
- // Override the default clear button behavior such that it no longer clears the input,
- // it just resets to the data-initial-value.
- // Due to the nature of how uswds works, this is slightly hacky.
- // Use a MutationObserver to watch for changes in the dropdown list
- const dropdownList = comboBox.querySelector(`#${input.id}--list`);
- const observer = new MutationObserver(function(mutations) {
- mutations.forEach(function(mutation) {
- if (mutation.type === "childList") {
- addBlankOption(clearInputButton, dropdownList, initialValue);
- }
- });
- });
-
- // Configure the observer to watch for changes in the dropdown list
- const config = { childList: true, subtree: true };
- observer.observe(dropdownList, config);
-
- // Input event listener to detect typing
- input.addEventListener("input", () => {
- isTyping = true;
- });
-
- // Blur event listener to reset typing state
- input.addEventListener("blur", () => {
- isTyping = false;
- });
-
- // Hide the reset button when there is nothing to reset.
- // Do this once on init, then everytime a change occurs.
- updateClearButtonVisibility(select, initialValue, clearInputButton)
- select.addEventListener("change", () => {
- updateClearButtonVisibility(select, initialValue, clearInputButton)
- });
-
- // Change the default input behaviour - have it reset to the data default instead
- clearInputButton.addEventListener("click", (e) => {
- if (overrideDefaultClearButton && initialValue) {
- e.preventDefault();
- e.stopPropagation();
- input.click();
- // Find the dropdown option with the desired value
- const dropdownOptions = document.querySelectorAll(".usa-combo-box__list-option");
- if (dropdownOptions) {
- dropdownOptions.forEach(option => {
- if (option.getAttribute("data-value") === initialValue) {
- // Simulate a click event on the dropdown option
- option.click();
- }
- });
- }
- }
- });
- });
- }
-
- function updateClearButtonVisibility(select, initialValue, clearInputButton) {
- if (select.value === initialValue) {
- hideElement(clearInputButton);
- }else {
- showElement(clearInputButton)
- }
- }
-
- function addBlankOption(clearInputButton, dropdownList, initialValue) {
- if (dropdownList && !dropdownList.querySelector('[data-value=""]') && !isTyping) {
- const blankOption = document.createElement("li");
- blankOption.setAttribute("role", "option");
- blankOption.setAttribute("data-value", "");
- blankOption.classList.add("usa-combo-box__list-option");
- if (!initialValue){
- blankOption.classList.add("usa-combo-box__list-option--selected")
- }
- blankOption.textContent = "⎯";
-
- dropdownList.insertBefore(blankOption, dropdownList.firstChild);
- blankOption.addEventListener("click", (e) => {
- e.preventDefault();
- e.stopPropagation();
- overrideDefaultClearButton = false;
- // Trigger the default clear behavior
- clearInputButton.click();
- overrideDefaultClearButton = true;
- });
- }
- }
-}
diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js
index 6ff402aa4..a077da929 100644
--- a/src/registrar/assets/src/js/getgov/main.js
+++ b/src/registrar/assets/src/js/getgov/main.js
@@ -3,7 +3,6 @@ import { initDomainValidators } from './domain-validators.js';
import { initFormsetsForms, triggerModalOnDsDataForm, nameserversFormListener } from './formset-forms.js';
import { initializeUrbanizationToggle } from './urbanization.js';
import { userProfileListener, finishUserSetupListener } from './user-profile.js';
-import { loadInitialValuesForComboBoxes } from './combobox.js';
import { handleRequestingEntityFieldset } from './requesting-entity.js';
import { initDomainsTable } from './table-domains.js';
import { initDomainRequestsTable } from './table-domain-requests.js';
@@ -31,8 +30,6 @@ initializeUrbanizationToggle();
userProfileListener();
finishUserSetupListener();
-loadInitialValuesForComboBoxes();
-
handleRequestingEntityFieldset();
initDomainsTable();
diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js
index cfb83badc..c96677ebc 100644
--- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js
+++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js
@@ -18,11 +18,11 @@ export function initPortfolioNewMemberPageToggle() {
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}`);
+ wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `More Options 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);
+ MembersTable.addMemberDeleteModal(num_domains, member_email || member_name || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction);
uswdsInitializeModals();
@@ -87,14 +87,6 @@ export function initAddNewMemberPageListeners() {
});
});
- /*
- Helper function to capitalize the first letter in a string (for display purposes)
- */
- function capitalizeFirstLetter(text) {
- if (!text) return ''; // Return empty string if input is falsy
- return text.charAt(0).toUpperCase() + text.slice(1);
- }
-
/*
Populates contents of the "Add Member" confirmation modal
*/
@@ -102,10 +94,12 @@ export function initAddNewMemberPageListeners() {
const permissionDetailsContainer = document.getElementById("permission_details");
permissionDetailsContainer.innerHTML = ""; // Clear previous content
- // Get all permission sections (divs with h3 and radio inputs)
- const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`);
+ if (permission_details_div_id == 'member-basic-permissions') {
+ // for basic users, display values are based on selections in the form
+ // Get all permission sections (divs with h3 and radio inputs)
+ const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`);
- permissionSections.forEach(section => {
+ permissionSections.forEach(section => {
// Find the
element text
const sectionTitle = section.textContent;
@@ -113,31 +107,46 @@ export function initAddNewMemberPageListeners() {
const fieldset = section.nextElementSibling;
if (fieldset && fieldset.tagName.toLowerCase() === 'fieldset') {
- // Get the selected radio button within this fieldset
- const selectedRadio = fieldset.querySelector('input[type="radio"]:checked');
+ // Get the selected radio button within this fieldset
+ const selectedRadio = fieldset.querySelector('input[type="radio"]:checked');
- // If a radio button is selected, get its label text
- let selectedPermission = "No permission selected";
- if (selectedRadio) {
- const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`);
- selectedPermission = label ? label.textContent : "No permission selected";
+ // If a radio button is selected, get its label text
+ let selectedPermission = "No permission selected";
+ if (selectedRadio) {
+ const label = fieldset.querySelector(`label[for="${selectedRadio.id}"]`);
+ if (label) {
+ // Get only the text node content (excluding subtext in
)
+ const mainText = Array.from(label.childNodes)
+ .filter(node => node.nodeType === Node.TEXT_NODE)
+ .map(node => node.textContent.trim())
+ .join(""); // Combine and trim whitespace
+ selectedPermission = mainText || "No permission selected";
}
-
- // Create new elements for the modal content
- const titleElement = document.createElement("h4");
- titleElement.textContent = sectionTitle;
- titleElement.classList.add("text-primary");
- titleElement.classList.add("margin-bottom-0");
-
- const permissionElement = document.createElement("p");
- permissionElement.textContent = selectedPermission;
- permissionElement.classList.add("margin-top-0");
-
- // Append to the modal content container
- permissionDetailsContainer.appendChild(titleElement);
- permissionDetailsContainer.appendChild(permissionElement);
+ }
+ appendPermissionInContainer(sectionTitle, selectedPermission, permissionDetailsContainer);
}
- });
+ });
+ } else {
+ // for admin users, the permissions are always the same
+ appendPermissionInContainer('Domains', 'Viewer, all', permissionDetailsContainer);
+ appendPermissionInContainer('Domain requests', 'Creator', permissionDetailsContainer);
+ appendPermissionInContainer('Members', 'Manager', permissionDetailsContainer);
+ }
+ }
+
+ function appendPermissionInContainer(sectionTitle, permissionDisplay, permissionContainer) {
+ // Create new elements for the content
+ const titleElement = document.createElement("h4");
+ titleElement.textContent = sectionTitle;
+ titleElement.classList.add("text-primary", "margin-bottom-0");
+
+ const permissionElement = document.createElement("p");
+ permissionElement.textContent = permissionDisplay;
+ permissionElement.classList.add("margin-top-0");
+
+ // Append to the content container
+ permissionContainer.appendChild(titleElement);
+ permissionContainer.appendChild(permissionElement);
}
/*
@@ -149,18 +158,25 @@ export function initAddNewMemberPageListeners() {
let emailValue = document.getElementById('id_email').value;
document.getElementById('modalEmail').textContent = emailValue;
- // Get selected radio button for access level
+ // Get selected radio button for member access level
let selectedAccess = document.querySelector('input[name="role"]:checked');
- // Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button)
- // This value does not have the first letter capitalized so let's capitalize it
- let accessText = selectedAccess ? capitalizeFirstLetter(selectedAccess.value) : "No access level selected";
+ // Map the access level values to user-friendly labels
+ const accessLevelMapping = {
+ organization_admin: "Admin",
+ organization_member: "Basic",
+ };
+ // Determine the access text based on the selected value
+ let accessText = selectedAccess
+ ? accessLevelMapping[selectedAccess.value] || "Unknown access level"
+ : "No access level selected";
+ // Update the modal with the appropriate member access level text
document.getElementById('modalAccessLevel').textContent = accessText;
// Populate permission details based on access level
if (selectedAccess && selectedAccess.value === 'organization_admin') {
- populatePermissionDetails('new-member-admin-permissions');
+ populatePermissionDetails('admin');
} else {
- populatePermissionDetails('new-member-basic-permissions');
+ populatePermissionDetails('member-basic-permissions');
}
//------- Show the modal
@@ -177,22 +193,14 @@ export function initPortfolioMemberPageRadio() {
document.addEventListener("DOMContentLoaded", () => {
let memberForm = document.getElementById("member_form");
let newMemberForm = document.getElementById("add_member_form")
- if (memberForm) {
+ if (memberForm || newMemberForm) {
hookupRadioTogglerListener(
'role',
{
- 'organization_admin': 'member-admin-permissions',
+ 'organization_admin': '',
'organization_member': 'member-basic-permissions'
}
);
- }else if (newMemberForm){
- hookupRadioTogglerListener(
- 'role',
- {
- 'organization_admin': 'new-member-admin-permissions',
- 'organization_member': 'new-member-basic-permissions'
- }
- );
}
});
}
diff --git a/src/registrar/assets/src/js/getgov/requesting-entity.js b/src/registrar/assets/src/js/getgov/requesting-entity.js
index 3bcdcd35c..833eab2f8 100644
--- a/src/registrar/assets/src/js/getgov/requesting-entity.js
+++ b/src/registrar/assets/src/js/getgov/requesting-entity.js
@@ -9,15 +9,15 @@ export function handleRequestingEntityFieldset() {
const formPrefix = "portfolio_requesting_entity";
const radioFieldset = document.getElementById(`id_${formPrefix}-requesting_entity_is_suborganization__fieldset`);
const radios = radioFieldset?.querySelectorAll(`input[name="${formPrefix}-requesting_entity_is_suborganization"]`);
- const select = document.getElementById(`id_${formPrefix}-sub_organization`);
- const selectParent = select?.parentElement;
+ const input = document.getElementById(`id_${formPrefix}-sub_organization`);
+ const inputGrandParent = input?.parentElement?.parentElement;
+ const select = input?.previousElementSibling;
const suborgContainer = document.getElementById("suborganization-container");
const suborgDetailsContainer = document.getElementById("suborganization-container__details");
const suborgAddtlInstruction = document.getElementById("suborganization-addtl-instruction");
- const subOrgCreateNewOption = document.getElementById("option-to-add-suborg")?.value;
// Make sure all crucial page elements exist before proceeding.
// This more or less ensures that we are on the Requesting Entity page, and not elsewhere.
- if (!radios || !select || !selectParent || !suborgContainer || !suborgDetailsContainer) return;
+ if (!radios || !input || !select || !inputGrandParent || !suborgContainer || !suborgDetailsContainer) return;
// requestingSuborganization: This just broadly determines if they're requesting a suborg at all
// requestingNewSuborganization: This variable determines if the user is trying to *create* a new suborganization or not.
@@ -27,8 +27,8 @@ export function handleRequestingEntityFieldset() {
function toggleSuborganization(radio=null) {
if (radio != null) requestingSuborganization = radio?.checked && radio.value === "True";
requestingSuborganization ? showElement(suborgContainer) : hideElement(suborgContainer);
- if (select.options.length == 2) { // --Select-- and other are the only options
- hideElement(selectParent); // Hide the select drop down and indicate requesting new suborg
+ if (select.options.length == 1) { // other is the only option
+ hideElement(inputGrandParent); // Hide the combo box and indicate requesting new suborg
hideElement(suborgAddtlInstruction); // Hide additional instruction related to the list
requestingNewSuborganization.value = "True";
} else {
@@ -37,11 +37,6 @@ export function handleRequestingEntityFieldset() {
requestingNewSuborganization.value === "True" ? showElement(suborgDetailsContainer) : hideElement(suborgDetailsContainer);
}
- // Add fake "other" option to sub_organization select
- if (select && !Array.from(select.options).some(option => option.value === "other")) {
- select.add(new Option(subOrgCreateNewOption, "other"));
- }
-
if (requestingNewSuborganization.value === "True") {
select.value = "other";
}
diff --git a/src/registrar/assets/src/js/getgov/table-base.js b/src/registrar/assets/src/js/getgov/table-base.js
index 338d5d98c..0abfee9b6 100644
--- a/src/registrar/assets/src/js/getgov/table-base.js
+++ b/src/registrar/assets/src/js/getgov/table-base.js
@@ -93,7 +93,6 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
` : ''}
${modal_button_text}
- ${screen_reader_text}
`;
@@ -107,6 +106,7 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
class="usa-button usa-button--unstyled usa-button--with-icon usa-accordion__button usa-button--more-actions"
aria-expanded="false"
aria-controls="more-actions-${unique_id}"
+ aria-label="${screen_reader_text}"
>