diff --git a/.github/workflows/deploy-development.yaml b/.github/workflows/deploy-development.yaml
index 686635c20..e62607d95 100644
--- a/.github/workflows/deploy-development.yaml
+++ b/.github/workflows/deploy-development.yaml
@@ -22,9 +22,16 @@ jobs:
- name: Compile USWDS assets
working-directory: ./src
run: |
- docker compose run node npm install &&
- docker compose run node npx gulp copyAssets &&
- docker compose run node npx gulp compile
+ docker compose run node bash -c "\
+ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \
+ export NVM_DIR=\"\$HOME/.nvm\" && \
+ [ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \
+ [ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \
+ nvm install 21.7.3 && \
+ nvm use 21.7.3 && \
+ npm install && \
+ npx gulp copyAssets && \
+ npx gulp compile"
- name: Collect static assets
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input
@@ -45,4 +52,4 @@ jobs:
cf_password: ${{ secrets.CF_DEVELOPMENT_PASSWORD }}
cf_org: cisa-dotgov
cf_space: development
- cf_command: "run-task getgov-development --command 'python manage.py migrate' --name migrate"
+ cf_command: "run-task getgov-development --command 'python manage.py migrate' --name migrate"
\ No newline at end of file
diff --git a/.github/workflows/deploy-sandbox.yaml b/.github/workflows/deploy-sandbox.yaml
index f7f4a0d65..d9d7cbe14 100644
--- a/.github/workflows/deploy-sandbox.yaml
+++ b/.github/workflows/deploy-sandbox.yaml
@@ -42,9 +42,16 @@ jobs:
- name: Compile USWDS assets
working-directory: ./src
run: |
- docker compose run node npm install &&
- docker compose run node npx gulp copyAssets &&
- docker compose run node npx gulp compile
+ docker compose run node bash -c "\
+ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \
+ export NVM_DIR=\"\$HOME/.nvm\" && \
+ [ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \
+ [ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \
+ nvm install 21.7.3 && \
+ nvm use 21.7.3 && \
+ npm install && \
+ npx gulp copyAssets && \
+ npx gulp compile"
- name: Collect static assets
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input
@@ -75,4 +82,4 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
body: '🥳 Successfully deployed to developer sandbox **[${{ env.ENVIRONMENT }}](https://getgov-${{ env.ENVIRONMENT }}.app.cloud.gov/)**.'
- })
+ })
\ No newline at end of file
diff --git a/.github/workflows/deploy-stable.yaml b/.github/workflows/deploy-stable.yaml
index 0ded4a3a6..9d0573e01 100644
--- a/.github/workflows/deploy-stable.yaml
+++ b/.github/workflows/deploy-stable.yaml
@@ -23,9 +23,16 @@ jobs:
- name: Compile USWDS assets
working-directory: ./src
run: |
- docker compose run node npm install &&
- docker compose run node npx gulp copyAssets &&
- docker compose run node npx gulp compile
+ docker compose run node bash -c "\
+ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \
+ export NVM_DIR=\"\$HOME/.nvm\" && \
+ [ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \
+ [ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \
+ nvm install 21.7.3 && \
+ nvm use 21.7.3 && \
+ npm install && \
+ npx gulp copyAssets && \
+ npx gulp compile"
- name: Collect static assets
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input
diff --git a/.github/workflows/deploy-staging.yaml b/.github/workflows/deploy-staging.yaml
index 1df08f412..9584985f0 100644
--- a/.github/workflows/deploy-staging.yaml
+++ b/.github/workflows/deploy-staging.yaml
@@ -23,9 +23,16 @@ jobs:
- name: Compile USWDS assets
working-directory: ./src
run: |
- docker compose run node npm install &&
- docker compose run node npx gulp copyAssets &&
- docker compose run node npx gulp compile
+ docker compose run node bash -c "\
+ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash && \
+ export NVM_DIR=\"\$HOME/.nvm\" && \
+ [ -s \"\$NVM_DIR/nvm.sh\" ] && \. \"\$NVM_DIR/nvm.sh\" && \
+ [ -s \"\$NVM_DIR/bash_completion\" ] && \. \"\$NVM_DIR/bash_completion\" && \
+ nvm install 21.7.3 && \
+ nvm use 21.7.3 && \
+ npm install && \
+ npx gulp copyAssets && \
+ npx gulp compile"
- name: Collect static assets
working-directory: ./src
run: docker compose run app python manage.py collectstatic --no-input
@@ -44,4 +51,4 @@ jobs:
cf_password: ${{ secrets.CF_STAGING_PASSWORD }}
cf_org: cisa-dotgov
cf_space: staging
- cf_command: "run-task getgov-staging --command 'python manage.py migrate' --name migrate"
+ cf_command: "run-task getgov-staging --command 'python manage.py migrate' --name migrate"
\ No newline at end of file
diff --git a/docs/operations/data_migration.md b/docs/operations/data_migration.md
index 0846208de..e4543a28c 100644
--- a/docs/operations/data_migration.md
+++ b/docs/operations/data_migration.md
@@ -602,18 +602,18 @@ That data are synthesized from the generic_org_type field and the is_election_bo
The latest domain_election_board csv can be found [here](https://drive.google.com/file/d/1aDeCqwHmBnXBl2arvoFCN0INoZmsEGsQ/view).
After downloading this file, place it in `src/migrationdata`
-#### Step 2: Upload the domain_election_board file to your sandbox
+#### Step 3: Upload the domain_election_board file to your sandbox
Follow [Step 1: Transfer data to sandboxes](#step-1-transfer-data-to-sandboxes) and [Step 2: Transfer uploaded files to the getgov directory](#step-2-transfer-uploaded-files-to-the-getgov-directory) from the [Set Up Migrations on Sandbox](#set-up-migrations-on-sandbox) portion of this doc.
-#### Step 2: SSH into your environment
+#### Step 4: SSH into your environment
```cf ssh getgov-{space}```
Example: `cf ssh getgov-za`
-#### Step 3: Create a shell instance
+#### Step 5: Create a shell instance
```/tmp/lifecycle/shell```
-#### Step 4: Running the script
+#### Step 6: Running the script
```./manage.py populate_organization_type {domain_election_board_filename}```
- The domain_election_board_filename file must adhere to this format:
@@ -642,3 +642,29 @@ Example (assuming that this is being ran from src/):
| | Parameter | Description |
|:-:|:------------------------------------|:-------------------------------------------------------------------|
| 1 | **domain_election_board_filename** | A file containing every domain that is an election office.
+
+
+## Populate Verification Type
+This section outlines how to run the `populate_verification_type` script.
+The script is used to update the verification_type field on User when it is None.
+
+### 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: Running the script
+```./manage.py populate_verification_type```
+
+### Running locally
+
+#### Step 1: Running the script
+```docker-compose exec app ./manage.py populate_verification_type```
diff --git a/src/djangooidc/tests/test_views.py b/src/djangooidc/tests/test_views.py
index f10afcbaf..bdd61b346 100644
--- a/src/djangooidc/tests/test_views.py
+++ b/src/djangooidc/tests/test_views.py
@@ -4,8 +4,10 @@ from django.http import HttpResponse
from django.test import Client, TestCase, RequestFactory
from django.urls import reverse
+from api.tests.common import less_console_noise_decorator
from djangooidc.exceptions import StateMismatch, InternalError
from ..views import login_callback
+from registrar.models import User, Contact, VerifiedByStaff, DomainInvitation, TransitionDomain, Domain
from .common import less_console_noise
@@ -16,6 +18,14 @@ class ViewsTest(TestCase):
self.client = Client()
self.factory = RequestFactory()
+ def tearDown(self):
+ User.objects.all().delete()
+ Contact.objects.all().delete()
+ DomainInvitation.objects.all().delete()
+ VerifiedByStaff.objects.all().delete()
+ TransitionDomain.objects.all().delete()
+ Domain.objects.all().delete()
+
def say_hi(*args):
return HttpResponse("Hi")
@@ -229,6 +239,140 @@ class ViewsTest(TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/")
+ @less_console_noise_decorator
+ def test_login_callback_sets_verification_type_regular(self, mock_client):
+ """
+ Test that openid sets the verification type to regular on the returned user.
+ Regular, in this context, means that this user was "Verifed by Login.gov"
+ """
+ # SETUP
+ session = self.client.session
+ session.save()
+ # MOCK
+ # mock that callback returns user_info; this is the expected behavior
+ mock_client.callback.side_effect = self.user_info
+ # patch that the request does not require step up auth
+ with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(
+ "djangooidc.views._initialize_client"
+ ) as mock_init_client:
+ with patch("djangooidc.views._client_is_none", return_value=True):
+ # TEST
+ # test the login callback url
+ response = self.client.get(reverse("openid_login_callback"))
+
+ # assert that _initialize_client was called
+ mock_init_client.assert_called_once()
+
+ # Assert that we get a redirect
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "/")
+
+ # Test the created user object
+ created_user = User.objects.get(email="test@example.com")
+ self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.REGULAR)
+
+ @less_console_noise_decorator
+ def test_login_callback_sets_verification_type_invited(self, mock_client):
+ """Test that openid sets the verification type to invited on the returned user
+ when they exist in the DomainInvitation table"""
+ # SETUP
+ session = self.client.session
+ session.save()
+
+ domain, _ = Domain.objects.get_or_create(name="test123.gov")
+ invitation, _ = DomainInvitation.objects.get_or_create(email="test@example.com", domain=domain)
+ # MOCK
+ # mock that callback returns user_info; this is the expected behavior
+ mock_client.callback.side_effect = self.user_info
+ # patch that the request does not require step up auth
+ with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(
+ "djangooidc.views._initialize_client"
+ ) as mock_init_client:
+ with patch("djangooidc.views._client_is_none", return_value=True):
+ # TEST
+ # test the login callback url
+ response = self.client.get(reverse("openid_login_callback"))
+
+ # assert that _initialize_client was called
+ mock_init_client.assert_called_once()
+
+ # Assert that we get a redirect
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "/")
+
+ # Test the created user object
+ created_user = User.objects.get(email="test@example.com")
+ self.assertEqual(created_user.email, invitation.email)
+ self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.INVITED)
+
+ @less_console_noise_decorator
+ def test_login_callback_sets_verification_type_grandfathered(self, mock_client):
+ """Test that openid sets the verification type to grandfathered
+ on a user which exists in our TransitionDomain table"""
+ # SETUP
+ session = self.client.session
+ session.save()
+ # MOCK
+ # mock that callback returns user_info; this is the expected behavior
+ mock_client.callback.side_effect = self.user_info
+
+ td, _ = TransitionDomain.objects.get_or_create(username="test@example.com", domain_name="test123.gov")
+
+ # patch that the request does not require step up auth
+ with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(
+ "djangooidc.views._initialize_client"
+ ) as mock_init_client:
+ with patch("djangooidc.views._client_is_none", return_value=True):
+ # TEST
+ # test the login callback url
+ response = self.client.get(reverse("openid_login_callback"))
+
+ # assert that _initialize_client was called
+ mock_init_client.assert_called_once()
+
+ # Assert that we get a redirect
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "/")
+
+ # Test the created user object
+ created_user = User.objects.get(email="test@example.com")
+ self.assertEqual(created_user.email, td.username)
+ self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.GRANDFATHERED)
+
+ @less_console_noise_decorator
+ def test_login_callback_sets_verification_type_verified_by_staff(self, mock_client):
+ """Test that openid sets the verification type to verified_by_staff
+ on a user which exists in our VerifiedByStaff table"""
+ # SETUP
+ session = self.client.session
+ session.save()
+ # MOCK
+ # mock that callback returns user_info; this is the expected behavior
+ mock_client.callback.side_effect = self.user_info
+
+ vip, _ = VerifiedByStaff.objects.get_or_create(email="test@example.com")
+
+ # patch that the request does not require step up auth
+ with patch("djangooidc.views._requires_step_up_auth", return_value=False), patch(
+ "djangooidc.views._initialize_client"
+ ) as mock_init_client:
+ with patch("djangooidc.views._client_is_none", return_value=True):
+ # TEST
+ # test the login callback url
+ response = self.client.get(reverse("openid_login_callback"))
+
+ # assert that _initialize_client was called
+ mock_init_client.assert_called_once()
+
+ # Assert that we get a redirect
+ self.assertEqual(response.status_code, 302)
+ self.assertEqual(response.url, "/")
+
+ # Test the created user object
+ created_user = User.objects.get(email="test@example.com")
+ self.assertEqual(created_user.email, vip.email)
+ self.assertEqual(created_user.verification_type, User.VerificationTypeChoices.VERIFIED_BY_STAFF)
+
def test_login_callback_no_step_up_auth(self, mock_client):
"""Walk through login_callback when _requires_step_up_auth returns False
and assert that we have a redirect to /"""
diff --git a/src/djangooidc/views.py b/src/djangooidc/views.py
index ab81ccff1..815df4ecf 100644
--- a/src/djangooidc/views.py
+++ b/src/djangooidc/views.py
@@ -99,8 +99,22 @@ def login_callback(request):
return CLIENT.create_authn_request(request.session)
user = authenticate(request=request, **userinfo)
if user:
+
+ # Fixture users kind of exist in a superposition of verification types,
+ # because while the system "verified" them, if they login,
+ # we don't know how the user themselves was verified through login.gov until
+ # they actually try logging in. This edge-case only matters in non-production environments.
+ fixture_user = User.VerificationTypeChoices.FIXTURE_USER
+ is_fixture_user = user.verification_type and user.verification_type == fixture_user
+
+ # Set the verification type if it doesn't already exist or if its a fixture user
+ if not user.verification_type or is_fixture_user:
+ user.set_user_verification_type()
+ user.save()
+
login(request, user)
logger.info("Successfully logged in user %s" % user)
+
# Clear the flag if the exception is not caught
request.session.pop("redirect_attempted", None)
return redirect(request.session.get("next", "/"))
diff --git a/src/node.Dockerfile b/src/node.Dockerfile
index b478a8a26..cf0b6acc6 100644
--- a/src/node.Dockerfile
+++ b/src/node.Dockerfile
@@ -1,5 +1,5 @@
FROM docker.io/cimg/node:current-browsers
-
+FROM node:21.7.3
WORKDIR /app
# Install app dependencies
@@ -7,4 +7,6 @@ WORKDIR /app
# where available (npm@5+)
COPY --chown=circleci:circleci package*.json ./
-RUN npm install
+
+RUN npm install -g npm@10.5.0
+RUN npm install
\ No newline at end of file
diff --git a/src/package-lock.json b/src/package-lock.json
index dc1464ee8..9df99a739 100644
--- a/src/package-lock.json
+++ b/src/package-lock.json
@@ -15,6 +15,10 @@
},
"devDependencies": {
"@uswds/compile": "^1.0.0-beta.3"
+ },
+ "engines": {
+ "node": "21.7.3",
+ "npm": "10.5.0"
}
},
"node_modules/@gulp-sourcemaps/identity-map": {
diff --git a/src/package.json b/src/package.json
index 274e0e282..3afce297f 100644
--- a/src/package.json
+++ b/src/package.json
@@ -3,6 +3,11 @@
"version": "1.0.0",
"description": "========================",
"main": "index.js",
+ "engines": {
+ "node": "21.7.3",
+ "npm": "10.5.0"
+ },
+ "engineStrict": true,
"scripts": {
"pa11y-ci": "pa11y-ci",
"test": "echo \"Error: no test specified\" && exit 1"
@@ -17,4 +22,4 @@
"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 5433169a6..485751b3c 100644
--- a/src/registrar/admin.py
+++ b/src/registrar/admin.py
@@ -537,7 +537,7 @@ class MyUserAdmin(BaseUserAdmin):
fieldsets = (
(
None,
- {"fields": ("username", "password", "status")},
+ {"fields": ("username", "password", "status", "verification_type")},
),
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
(
@@ -555,13 +555,20 @@ class MyUserAdmin(BaseUserAdmin):
("Important dates", {"fields": ("last_login", "date_joined")}),
)
+ readonly_fields = ("verification_type",)
+
# Hide Username (uuid), Groups and Permissions
# Q: Now that we're using Groups and Permissions,
# do we expose those to analysts to view?
analyst_fieldsets = (
(
None,
- {"fields": ("status",)},
+ {
+ "fields": (
+ "status",
+ "verification_type",
+ )
+ },
),
("Personal Info", {"fields": ("first_name", "last_name", "email")}),
(
@@ -681,11 +688,14 @@ class MyUserAdmin(BaseUserAdmin):
return []
def get_readonly_fields(self, request, obj=None):
+ readonly_fields = list(self.readonly_fields)
+
if request.user.has_perm("registrar.full_access_permission"):
- return () # No read-only fields for all access users
- # Return restrictive Read-only fields for analysts and
- # users who might not belong to groups
- return self.analyst_readonly_fields
+ return readonly_fields
+ else:
+ # Return restrictive Read-only fields for analysts and
+ # users who might not belong to groups
+ return self.analyst_readonly_fields
class HostIPInline(admin.StackedInline):
@@ -1001,9 +1011,10 @@ class DomainInformationAdmin(ListHeaderAdmin):
},
),
(
- "More details",
+ "Show details",
{
- "classes": ["collapse"],
+ "classes": ["collapse--dotgov"],
+ "description": "Extends type of organization",
"fields": [
"federal_type",
# "updated_federal_agency",
@@ -1026,9 +1037,10 @@ class DomainInformationAdmin(ListHeaderAdmin):
},
),
(
- "More details",
+ "Show details",
{
- "classes": ["collapse"],
+ "classes": ["collapse--dotgov"],
+ "description": "Extends organization name and mailing address",
"fields": [
"address_line1",
"address_line2",
@@ -1252,9 +1264,10 @@ class DomainRequestAdmin(ListHeaderAdmin):
},
),
(
- "More details",
+ "Show details",
{
- "classes": ["collapse"],
+ "classes": ["collapse--dotgov"],
+ "description": "Extends type of organization",
"fields": [
"federal_type",
# "updated_federal_agency",
@@ -1277,9 +1290,10 @@ class DomainRequestAdmin(ListHeaderAdmin):
},
),
(
- "More details",
+ "Show details",
{
- "classes": ["collapse"],
+ "classes": ["collapse--dotgov"],
+ "description": "Extends organization name and mailing address",
"fields": [
"address_line1",
"address_line2",
diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js
index 126ab0a2a..f38afd252 100644
--- a/src/registrar/assets/js/get-gov-admin.js
+++ b/src/registrar/assets/js/get-gov-admin.js
@@ -233,10 +233,8 @@ function openInNewTab(el, removeAttribute = false){
// Initialize custom filter_horizontal widgets; each widget has a "from" select list
// and a "to" select list; initialization is based off of the presence of the
// "to" select list
- checkToListThenInitWidget('id_other_contacts_to', 0);
- checkToListThenInitWidget('id_domain_info-0-other_contacts_to', 0);
- checkToListThenInitWidget('id_current_websites_to', 0);
- checkToListThenInitWidget('id_alternative_domains_to', 0);
+ checkToListThenInitWidget('id_groups_to', 0);
+ checkToListThenInitWidget('id_user_permissions_to', 0);
})();
// Function to check for the existence of the "to" select list element in the DOM, and if and when found,
@@ -245,215 +243,56 @@ function checkToListThenInitWidget(toListId, attempts) {
let toList = document.getElementById(toListId);
attempts++;
- if (attempts < 6) {
- if ((toList !== null)) {
+ if (attempts < 12) {
+ if (toList) {
// toList found, handle it
- // Add an event listener on the element
- // Add disabled buttons on the element's great-grandparent
- initializeWidgetOnToList(toList, toListId);
+ // Then get fromList and handle it
+ initializeWidgetOnList(toList, ".selector-chosen");
+ let fromList = toList.closest('.selector').querySelector(".selector-available select");
+ initializeWidgetOnList(fromList, ".selector-available");
} else {
// Element not found, check again after a delay
- setTimeout(() => checkToListThenInitWidget(toListId, attempts), 1000); // Check every 1000 milliseconds (1 second)
+ setTimeout(() => checkToListThenInitWidget(toListId, attempts), 300); // Check every 300 milliseconds
}
}
}
// Initialize the widget:
-// add related buttons to the widget for edit, delete and view
-// add event listeners on the from list, the to list, and selector buttons which either enable or disable the related buttons
-function initializeWidgetOnToList(toList, toListId) {
- // create the change button
- let changeLink = createAndCustomizeLink(
- toList,
- toListId,
- 'related-widget-wrapper-link change-related',
- 'Change',
- '/public/admin/img/icon-changelink.svg',
- {
- 'contacts': '/admin/registrar/contact/__fk__/change/?_to_field=id&_popup=1',
- 'websites': '/admin/registrar/website/__fk__/change/?_to_field=id&_popup=1',
- 'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id&_popup=1',
- },
- true,
- true
- );
+// Replace h2 with more semantic h3
+function initializeWidgetOnList(list, parentId) {
+ if (list) {
+ // Get h2 and its container
+ const parentElement = list.closest(parentId);
+ const h2Element = parentElement.querySelector('h2');
- let hasDeletePermission = hasDeletePermissionOnPage();
+ // One last check
+ if (parentElement && h2Element) {
+ // Create a new
element
+ const h3Element = document.createElement('h3');
- let deleteLink = null;
- if (hasDeletePermission) {
- // create the delete button if user has permission to delete
- deleteLink = createAndCustomizeLink(
- toList,
- toListId,
- 'related-widget-wrapper-link delete-related',
- 'Delete',
- '/public/admin/img/icon-deletelink.svg',
- {
- 'contacts': '/admin/registrar/contact/__fk__/delete/?_to_field=id&_popup=1',
- 'websites': '/admin/registrar/website/__fk__/delete/?_to_field=id&_popup=1',
- 'alternative_domains': '/admin/registrar/website/__fk__/delete/?_to_field=id&_popup=1',
- },
- true,
- false
- );
- }
+ // Copy the text content from the
element to the
element
+ h3Element.textContent = h2Element.textContent;
- // create the view button
- let viewLink = createAndCustomizeLink(
- toList,
- toListId,
- 'related-widget-wrapper-link view-related',
- 'View',
- '/public/admin/img/icon-viewlink.svg',
- {
- 'contacts': '/admin/registrar/contact/__fk__/change/?_to_field=id',
- 'websites': '/admin/registrar/website/__fk__/change/?_to_field=id',
- 'alternative_domains': '/admin/registrar/website/__fk__/change/?_to_field=id',
- },
- // NOTE: If we open view in the same window then use the back button
- // to go back, the 'chosen' list will fail to initialize correctly in
- // sandbozes (but will work fine on local). This is related to how the
- // Django JS runs (SelectBox.js) and is probably due to a race condition.
- true,
- false
- );
+ // Find the nested element inside the
+ const nestedSpan = h2Element.querySelector('span[class][title]');
- // identify the fromList element in the DOM
- let fromList = toList.closest('.selector').querySelector(".selector-available select");
+ // If the nested element exists
+ if (nestedSpan) {
+ // Create a new element
+ const newSpan = document.createElement('span');
- fromList.addEventListener('click', function(event) {
- handleSelectClick(fromList, changeLink, deleteLink, viewLink);
- });
-
- toList.addEventListener('click', function(event) {
- handleSelectClick(toList, changeLink, deleteLink, viewLink);
- });
-
- // Disable buttons when the selectors are interacted with (items are moved from one column to the other)
- let selectorButtons = [];
- selectorButtons.push(toList.closest(".selector").querySelector(".selector-chooseall"));
- selectorButtons.push(toList.closest(".selector").querySelector(".selector-add"));
- selectorButtons.push(toList.closest(".selector").querySelector(".selector-remove"));
+ // Copy the class and title attributes from the nested element
+ newSpan.className = nestedSpan.className;
+ newSpan.title = nestedSpan.title;
- selectorButtons.forEach((selector) => {
- selector.addEventListener("click", ()=>{disableRelatedWidgetButtons(changeLink, deleteLink, viewLink)});
- });
-}
-
-// create and customize the button, then add to the DOM, relative to the toList
-// toList - the element in the DOM for the toList
-// toListId - the ID of the element in the DOM
-// className - className to add to the created link
-// action - the action to perform on the item {change, delete, view}
-// imgSrc - the img.src for the created link
-// dataMappings - dictionary which relates toListId to href for the created link
-// dataPopup - boolean for whether the link should produce a popup window
-// firstPosition - boolean indicating if link should be first position in list of links, otherwise, should be last link
-function createAndCustomizeLink(toList, toListId, className, action, imgSrc, dataMappings, dataPopup, firstPosition) {
- // Create a link element
- var link = document.createElement('a');
-
- // Set class attribute for the link
- link.className = className;
-
- // Set id
- // Determine function {change, link, view} from the className
- // Add {function}_ to the beginning of the string
- let modifiedLinkString = className.split('-')[0] + '_' + toListId;
- // Remove '_to' from the end of the string
- modifiedLinkString = modifiedLinkString.replace('_to', '');
- link.id = modifiedLinkString;
-
- // Set data-href-template
- for (const [idPattern, template] of Object.entries(dataMappings)) {
- if (toListId.includes(idPattern)) {
- link.setAttribute('data-href-template', template);
- break; // Stop checking once a match is found
- }
- }
-
- if (dataPopup)
- link.setAttribute('data-popup', 'yes');
-
- link.setAttribute('title-template', action + " selected item")
- link.title = link.getAttribute('title-template');
-
- // Create an 'img' element
- var img = document.createElement('img');
-
- // Set attributes for the new image
- img.src = imgSrc;
- img.alt = action;
-
- // Append the image to the link
- link.appendChild(img);
-
- let relatedWidgetWrapper = toList.closest('.related-widget-wrapper');
- // If firstPosition is true, insert link as the first child element
- if (firstPosition) {
- relatedWidgetWrapper.insertBefore(link, relatedWidgetWrapper.children[0]);
- } else {
- // otherwise, insert the link prior to the last child (which is a div)
- // and also prior to any text elements immediately preceding the last
- // child node
- var lastChild = relatedWidgetWrapper.lastChild;
-
- // Check if lastChild is an element node (not a text node, comment, etc.)
- if (lastChild.nodeType === 1) {
- var previousSibling = lastChild.previousSibling;
- // need to work around some white space which has been inserted into the dom
- while (previousSibling.nodeType !== 1) {
- previousSibling = previousSibling.previousSibling;
+ // Append the new element to the
element
+ h3Element.appendChild(newSpan);
}
- relatedWidgetWrapper.insertBefore(link, previousSibling.nextSibling);
+
+ // Replace the
element with the new
element
+ parentElement.replaceChild(h3Element, h2Element);
}
}
-
- // Return the link, which we'll use in the disable and enable functions
- return link;
-}
-
-// Either enable or disable widget buttons when select is clicked. Action (enable or disable) taken depends on the count
-// of selected items in selectElement. If exactly one item is selected, buttons are enabled, and urls for the buttons are
-// associated with the selected item
-function handleSelectClick(selectElement, changeLink, deleteLink, viewLink) {
-
- // If one item is selected (across selectElement and relatedSelectElement), enable buttons; otherwise, disable them
- if (selectElement.selectedOptions.length === 1) {
- // enable buttons for selected item in selectElement
- enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, selectElement.selectedOptions[0].value, selectElement.selectedOptions[0].text);
- } else {
- disableRelatedWidgetButtons(changeLink, deleteLink, viewLink);
- }
-}
-
-// return true if there exist elements on the page with classname of delete-related.
-// presence of one or more of these elements indicates user has permission to delete
-function hasDeletePermissionOnPage() {
- return document.querySelector('.delete-related') != null
-}
-
-function disableRelatedWidgetButtons(changeLink, deleteLink, viewLink) {
- changeLink.removeAttribute('href');
- changeLink.setAttribute('title', changeLink.getAttribute('title-template'));
- if (deleteLink) {
- deleteLink.removeAttribute('href');
- deleteLink.setAttribute('title', deleteLink.getAttribute('title-template'));
- }
- viewLink.removeAttribute('href');
- viewLink.setAttribute('title', viewLink.getAttribute('title-template'));
-}
-
-function enableRelatedWidgetButtons(changeLink, deleteLink, viewLink, elementPk, elementText) {
- changeLink.setAttribute('href', changeLink.getAttribute('data-href-template').replace('__fk__', elementPk));
- changeLink.setAttribute('title', changeLink.getAttribute('title-template').replace('selected item', elementText));
- if (deleteLink) {
- deleteLink.setAttribute('href', deleteLink.getAttribute('data-href-template').replace('__fk__', elementPk));
- deleteLink.setAttribute('title', deleteLink.getAttribute('title-template').replace('selected item', elementText));
- }
- viewLink.setAttribute('href', viewLink.getAttribute('data-href-template').replace('__fk__', elementPk));
- viewLink.setAttribute('title', viewLink.getAttribute('title-template').replace('selected item', elementText));
}
/** An IIFE for admin in DjangoAdmin to listen to changes on the domain request
diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/sass/_theme/_admin.scss
index e0db9ac57..b4b590acb 100644
--- a/src/registrar/assets/sass/_theme/_admin.scss
+++ b/src/registrar/assets/sass/_theme/_admin.scss
@@ -542,17 +542,30 @@ address.dja-address-contact-list {
}
// Collapse button styles for fieldsets
-.module.collapse {
+.module.collapse--dotgov {
margin-top: -35px;
padding-top: 0;
border: none;
- h2 {
+ button {
background: none;
- color: var(--body-fg)!important;
text-transform: none;
- }
- a {
color: var(--link-fg);
+ margin-top: 8px;
+ margin-left: 10px;
+ span {
+ text-decoration: underline;
+ font-size: 13px;
+ font-feature-settings: "kern";
+ font-kerning: normal;
+ line-height: 13px;
+ font-family: -apple-system, "system-ui", "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
+ }
+ }
+}
+.collapse--dotgov.collapsed .collapse-toggle--dotgov {
+ display: inline-block!important;
+ * {
+ display: inline-block;
}
}
@@ -634,3 +647,32 @@ address.dja-address-contact-list {
.usa-button__small-text {
font-size: small;
}
+
+// Get rid of padding on all help texts
+form .aligned p.help, form .aligned div.help {
+ padding-left: 0px !important;
+}
+
+// We override the DJA header on multi list selects from h2 to h3
+// The following block of code styles our generated h3s to match the old h2s
+.selector .selector-available h3 {
+ background: var(--darkened-bg);
+ color: var(--body-quiet-color);
+}
+
+.selector-available h3, .selector-chosen h3 {
+ border: 1px solid var(--border-color);
+ border-radius: 4px 4px 0 0;
+ margin: 0;
+ padding: 8px;
+ font-size: 0.8125rem;
+ text-align: left;
+ margin: 0;
+ padding: 8px;
+ line-height: 1.3;
+}
+
+.selector .selector-chosen h3 {
+ background: var(--primary);
+ color: var(--header-link-color);
+}
diff --git a/src/registrar/assets/sass/_theme/_links.scss b/src/registrar/assets/sass/_theme/_links.scss
new file mode 100644
index 000000000..6d2e75a68
--- /dev/null
+++ b/src/registrar/assets/sass/_theme/_links.scss
@@ -0,0 +1,21 @@
+@use "uswds-core" as *;
+
+.dotgov-table {
+ a {
+ display: flex;
+ align-items: flex-start;
+ color: color('primary');
+
+ &:visited {
+ color: color('primary');
+ }
+ }
+}
+
+a {
+ .usa-icon {
+ // align icon with x height
+ margin-top: units(0.5);
+ margin-right: units(0.5);
+ }
+}
diff --git a/src/registrar/assets/sass/_theme/_tables.scss b/src/registrar/assets/sass/_theme/_tables.scss
index 0d58b5878..26d90d291 100644
--- a/src/registrar/assets/sass/_theme/_tables.scss
+++ b/src/registrar/assets/sass/_theme/_tables.scss
@@ -56,22 +56,6 @@
.dotgov-table {
width: 100%;
- a {
- display: flex;
- align-items: flex-start;
- color: color('primary');
-
- &:visited {
- color: color('primary');
- }
-
- .usa-icon {
- // align icon with x height
- margin-top: units(0.5);
- margin-right: units(0.5);
- }
- }
-
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
right: auto;
}
@@ -108,12 +92,51 @@
padding: units(2) units(2) units(2) 0;
}
- th:first-of-type {
- padding-left: 0;
- }
-
thead tr:first-child th:first-child {
border-top: none;
}
}
}
+@media (min-width: 1040px){
+ .dotgov-table__domain-requests {
+ th:nth-of-type(1) {
+ width: 200px;
+ }
+
+ th:nth-of-type(2) {
+ width: 158px;
+ }
+
+ th:nth-of-type(3) {
+ width: 120px;
+ }
+
+ th:nth-of-type(4) {
+ width: 95px;
+ }
+
+ th:nth-of-type(5) {
+ width: 85px;
+ }
+ }
+}
+
+@media (min-width: 1040px){
+ .dotgov-table__registered-domains {
+ th:nth-of-type(1) {
+ width: 200px;
+ }
+
+ th:nth-of-type(2) {
+ width: 158px;
+ }
+
+ th:nth-of-type(3) {
+ width: 215px;
+ }
+
+ th:nth-of-type(4) {
+ width: 95px;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/sass/_theme/styles.scss
index 942501110..64b113a29 100644
--- a/src/registrar/assets/sass/_theme/styles.scss
+++ b/src/registrar/assets/sass/_theme/styles.scss
@@ -10,6 +10,7 @@
--- Custom Styles ---------------------------------*/
@forward "base";
@forward "typography";
+@forward "links";
@forward "lists";
@forward "buttons";
@forward "forms";
diff --git a/src/registrar/fixtures_users.py b/src/registrar/fixtures_users.py
index d01cb48e5..7f991fa0e 100644
--- a/src/registrar/fixtures_users.py
+++ b/src/registrar/fixtures_users.py
@@ -7,6 +7,7 @@ from registrar.models import (
UserGroup,
)
+
fake = Faker()
logger = logging.getLogger(__name__)
@@ -207,6 +208,10 @@ class UserFixture:
user.email = user_data["email"]
user.is_staff = True
user.is_active = True
+ # This verification type will get reverted to "regular" (or whichever is applicables)
+ # once the user logs in for the first time (as they then got verified through different means).
+ # In the meantime, we can still describe how the user got here in the first place.
+ user.verification_type = User.VerificationTypeChoices.FIXTURE_USER
group = UserGroup.objects.get(name=group_name)
user.groups.add(group)
user.save()
diff --git a/src/registrar/management/commands/populate_verification_type.py b/src/registrar/management/commands/populate_verification_type.py
new file mode 100644
index 000000000..b61521977
--- /dev/null
+++ b/src/registrar/management/commands/populate_verification_type.py
@@ -0,0 +1,23 @@
+import logging
+from django.core.management import BaseCommand
+from registrar.management.commands.utility.terminal_helper import PopulateScriptTemplate, TerminalColors
+from registrar.models import User
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand, PopulateScriptTemplate):
+ help = "Loops through each valid User object and updates its verification_type value"
+
+ def handle(self, **kwargs):
+ """Loops through each valid User object and updates its verification_type value"""
+ filter_condition = {"verification_type__isnull": True}
+ self.mass_populate_field(User, filter_condition, ["verification_type"])
+
+ def populate_field(self, field_to_update):
+ """Defines how we update the verification_type field"""
+ field_to_update.set_user_verification_type()
+ logger.info(
+ f"{TerminalColors.OKCYAN}Updating {field_to_update} => "
+ f"{field_to_update.verification_type}{TerminalColors.OKCYAN}"
+ )
diff --git a/src/registrar/management/commands/utility/terminal_helper.py b/src/registrar/management/commands/utility/terminal_helper.py
index b54209750..db3e4a9d3 100644
--- a/src/registrar/management/commands/utility/terminal_helper.py
+++ b/src/registrar/management/commands/utility/terminal_helper.py
@@ -1,5 +1,6 @@
import logging
import sys
+from abc import ABC, abstractmethod
from django.core.paginator import Paginator
from typing import List
from registrar.utility.enums import LogCode
@@ -58,6 +59,55 @@ class ScriptDataHelper:
model_class.objects.bulk_update(page.object_list, fields_to_update)
+class PopulateScriptTemplate(ABC):
+ """
+ Contains an ABC for generic populate scripts
+ """
+
+ def mass_populate_field(self, sender, filter_conditions, fields_to_update):
+ """Loops through each valid "sender" object - specified by filter_conditions - and
+ updates fields defined by fields_to_update using populate_function.
+
+ You must define populate_field before you can use this function.
+ """
+
+ objects = sender.objects.filter(**filter_conditions)
+
+ # Code execution will stop here if the user prompts "N"
+ TerminalHelper.prompt_for_execution(
+ system_exit_on_terminate=True,
+ info_to_inspect=f"""
+ ==Proposed Changes==
+ Number of {sender} objects to change: {len(objects)}
+ These fields will be updated on each record: {fields_to_update}
+ """,
+ prompt_title="Do you wish to patch this data?",
+ )
+ logger.info("Updating...")
+
+ to_update: List[sender] = []
+ failed_to_update: List[sender] = []
+ for updated_object in objects:
+ try:
+ self.populate_field(updated_object)
+ to_update.append(updated_object)
+ except Exception as err:
+ failed_to_update.append(updated_object)
+ logger.error(err)
+ logger.error(f"{TerminalColors.FAIL}" f"Failed to update {updated_object}" f"{TerminalColors.ENDC}")
+
+ # Do a bulk update on the first_ready field
+ ScriptDataHelper.bulk_update_fields(sender, to_update, fields_to_update)
+
+ # Log what happened
+ TerminalHelper.log_script_run_summary(to_update, failed_to_update, skipped=[], debug=True)
+
+ @abstractmethod
+ def populate_field(self, field_to_update):
+ """Defines how we update each field. Must be defined before using mass_populate_field."""
+ raise NotImplementedError
+
+
class TerminalHelper:
@staticmethod
def log_script_run_summary(to_update, failed_to_update, skipped, debug: bool, log_header=None):
diff --git a/src/registrar/migrations/0089_user_verification_type.py b/src/registrar/migrations/0089_user_verification_type.py
new file mode 100644
index 000000000..e021e89e1
--- /dev/null
+++ b/src/registrar/migrations/0089_user_verification_type.py
@@ -0,0 +1,29 @@
+# Generated by Django 4.2.10 on 2024-04-26 14:03
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("registrar", "0088_domaininformation_cisa_representative_email_and_more"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="user",
+ name="verification_type",
+ field=models.CharField(
+ blank=True,
+ choices=[
+ ("grandfathered", "Legacy user"),
+ ("verified_by_staff", "Verified by staff"),
+ ("regular", "Verified by Login.gov"),
+ ("invited", "Invited by a domain manager"),
+ ("fixture_user", "Created by fixtures"),
+ ],
+ help_text="The means through which this user was verified",
+ null=True,
+ ),
+ ),
+ ]
diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py
index 165cad4c5..5e4c88f63 100644
--- a/src/registrar/models/user.py
+++ b/src/registrar/models/user.py
@@ -2,6 +2,7 @@ import logging
from django.contrib.auth.models import AbstractUser
from django.db import models
+from django.db.models import Q
from registrar.models.user_domain_role import UserDomainRole
@@ -23,6 +24,28 @@ class User(AbstractUser):
but can be customized later.
"""
+ class VerificationTypeChoices(models.TextChoices):
+ """
+ Users achieve access to our system in a few different ways.
+ These choices reflect those pathways.
+
+ Overview of verification types:
+ - GRANDFATHERED: User exists in the `TransitionDomain` table
+ - VERIFIED_BY_STAFF: User exists in the `VerifiedByStaff` table
+ - INVITED: User exists in the `DomainInvitation` table
+ - REGULAR: User was verified through IAL2
+ - FIXTURE_USER: User was created by fixtures
+ """
+
+ GRANDFATHERED = "grandfathered", "Legacy user"
+ VERIFIED_BY_STAFF = "verified_by_staff", "Verified by staff"
+ REGULAR = "regular", "Verified by Login.gov"
+ INVITED = "invited", "Invited by a domain manager"
+ # We need a type for fixture users (rather than using verified by staff)
+ # because those users still do get "verified" through normal means
+ # after they login.
+ FIXTURE_USER = "fixture_user", "Created by fixtures"
+
# #### Constants for choice fields ####
RESTRICTED = "restricted"
STATUS_CHOICES = ((RESTRICTED, RESTRICTED),)
@@ -50,6 +73,13 @@ class User(AbstractUser):
db_index=True,
)
+ verification_type = models.CharField(
+ choices=VerificationTypeChoices.choices,
+ null=True,
+ blank=True,
+ help_text="The means through which this user was verified",
+ )
+
def __str__(self):
# this info is pulled from Login.gov
if self.first_name or self.last_name:
@@ -114,23 +144,61 @@ class User(AbstractUser):
except Exception as err:
raise err
- # A new incoming user who is a domain manager for one of the domains
- # that we inputted from Verisign (that is, their email address appears
- # in the username field of a TransitionDomain)
- if TransitionDomain.objects.filter(username=email).exists():
- return False
+ # We can't set the verification type here because the user may not
+ # always exist at this point. We do it down the line.
+ verification_type = cls.get_verification_type_from_email(email)
- # New users flagged by Staff to bypass ial2
- if VerifiedByStaff.objects.filter(email=email).exists():
- return False
+ # Checks if the user needs verification.
+ # The user needs identity verification if they don't meet
+ # any special criteria, i.e. we are validating them "regularly"
+ return verification_type == cls.VerificationTypeChoices.REGULAR
- # A new incoming user who is being invited to be a domain manager (that is,
- # their email address is in DomainInvitation for an invitation that is not yet "retrieved").
- invited = DomainInvitation.DomainInvitationStatus.INVITED
- if DomainInvitation.objects.filter(email=email, status=invited).exists():
- return False
+ def set_user_verification_type(self):
+ """
+ Given pre-existing data from TransitionDomain, VerifiedByStaff, and DomainInvitation,
+ set the verification "type" defined in VerificationTypeChoices.
+ """
+ email_or_username = self.email if self.email else self.username
+ retrieved = DomainInvitation.DomainInvitationStatus.RETRIEVED
+ verification_type = self.get_verification_type_from_email(email_or_username, invitation_status=retrieved)
- return True
+ # An existing user may have been invited to a domain after they got verified.
+ # We need to check for this condition.
+ if verification_type == User.VerificationTypeChoices.INVITED:
+ invitation = (
+ DomainInvitation.objects.filter(email=email_or_username, status=retrieved)
+ .order_by("created_at")
+ .first()
+ )
+
+ # If you joined BEFORE the oldest invitation was created, then you were verified normally.
+ # (See logic in get_verification_type_from_email)
+ if not invitation and self.date_joined < invitation.created_at:
+ verification_type = User.VerificationTypeChoices.REGULAR
+
+ self.verification_type = verification_type
+
+ @classmethod
+ def get_verification_type_from_email(cls, email, invitation_status=DomainInvitation.DomainInvitationStatus.INVITED):
+ """Retrieves the verification type based off of a provided email address"""
+
+ verification_type = None
+ if TransitionDomain.objects.filter(Q(username=email) | Q(email=email)).exists():
+ # A new incoming user who is a domain manager for one of the domains
+ # that we inputted from Verisign (that is, their email address appears
+ # in the username field of a TransitionDomain)
+ verification_type = cls.VerificationTypeChoices.GRANDFATHERED
+ elif VerifiedByStaff.objects.filter(email=email).exists():
+ # New users flagged by Staff to bypass ial2
+ verification_type = cls.VerificationTypeChoices.VERIFIED_BY_STAFF
+ elif DomainInvitation.objects.filter(email=email, status=invitation_status).exists():
+ # A new incoming user who is being invited to be a domain manager (that is,
+ # their email address is in DomainInvitation for an invitation that is not yet "retrieved").
+ verification_type = cls.VerificationTypeChoices.INVITED
+ else:
+ verification_type = cls.VerificationTypeChoices.REGULAR
+
+ return verification_type
def check_domain_invitations_on_login(self):
"""When a user first arrives on the site, we need to retrieve any domain
diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html
index 58843421a..dd680cec5 100644
--- a/src/registrar/templates/admin/base_site.html
+++ b/src/registrar/templates/admin/base_site.html
@@ -23,6 +23,7 @@
+
{% endblock %}
{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %}
diff --git a/src/registrar/templates/admin/fieldset.html b/src/registrar/templates/admin/fieldset.html
index 8b8972e08..19c5db294 100644
--- a/src/registrar/templates/admin/fieldset.html
+++ b/src/registrar/templates/admin/fieldset.html
@@ -6,9 +6,23 @@ It is not inherently customizable on its own, so we can modify this instead.
https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/includes/fieldset.html
{% endcomment %}