Merge branch 'main' into ag/3110-create-all-federal-executive-portfolios

This commit is contained in:
zandercymatics 2024-12-05 10:39:47 -07:00
commit bf255c4f10
No known key found for this signature in database
GPG key ID: FF4636ABEC9682B7
10 changed files with 1491 additions and 281 deletions

View file

@ -85,6 +85,7 @@ services:
volumes:
- .:/app
working_dir: /app
entrypoint: /app/node_entrypoint.sh
stdin_open: true
tty: true
command: ./run_node_watch.sh

View file

@ -1,9 +1,9 @@
FROM docker.io/cimg/node:current-browsers
WORKDIR /app
USER root
# Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
# where available (npm@5+)
COPY --chown=circleci:circleci package*.json ./
RUN npm install

24
src/node_entrypoint.sh Executable file
View file

@ -0,0 +1,24 @@
#!/bin/bash
# Get UID and GID of the /app directory owner
HOST_UID=$(stat -c '%u' /app)
HOST_GID=$(stat -c '%g' /app)
# Check if the circleci user exists
if id "circleci" &>/dev/null; then
echo "circleci user exists. Updating UID and GID to match host UID:GID ($HOST_UID:$HOST_GID)"
# Update circleci user's UID and GID
groupmod -g "$HOST_GID" circleci
usermod -u "$HOST_UID" circleci
echo "Updating ownership of /app recursively to circleci:circleci"
chown -R circleci:circleci /app
# Switch to circleci user and execute the command
echo "Switching to circleci user and running command: $@"
su -s /bin/bash -c "$*" circleci
else
echo "circleci user does not exist. Running command as the current user."
exec "$@"
fi

1130
src/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -10,6 +10,7 @@ import { initDomainRequestsTable } from './table-domain-requests.js';
import { initMembersTable } from './table-members.js';
import { initMemberDomainsTable } from './table-member-domains.js';
import { initPortfolioMemberPageToggle } from './portfolio-member-page.js';
import { initAddNewMemberPageListeners } from './portfolio-member-page.js';
initDomainValidators();
@ -42,3 +43,4 @@ initMembersTable();
initMemberDomainsTable();
initPortfolioMemberPageToggle();
initAddNewMemberPageListeners();

View file

@ -1,4 +1,5 @@
import { uswdsInitializeModals } from './helpers-uswds.js';
import { getCsrfToken } from './helpers.js';
import { generateKebabHTML } from './table-base.js';
import { MembersTable } from './table-members.js';
@ -41,3 +42,131 @@ export function initPortfolioMemberPageToggle() {
}
});
}
/**
* Hooks up specialized listeners for handling form validation and modals
* on the Add New Member page.
*/
export function initAddNewMemberPageListeners() {
add_member_form = document.getElementById("add_member_form")
if (!add_member_form){
return;
}
document.getElementById("confirm_new_member_submit").addEventListener("click", function() {
// Upon confirmation, submit the form
document.getElementById("add_member_form").submit();
});
document.getElementById("add_member_form").addEventListener("submit", function(event) {
event.preventDefault(); // Prevents the form from submitting
const form = document.getElementById("add_member_form")
const formData = new FormData(form);
// Check if the form is valid
// If the form is valid, open the confirmation modal
// If the form is invalid, submit it to trigger error
fetch(form.action, {
method: "POST",
body: formData,
headers: {
"X-Requested-With": "XMLHttpRequest",
"X-CSRFToken": getCsrfToken()
}
})
.then(response => response.json())
.then(data => {
if (data.is_valid) {
// If the form is valid, show the confirmation modal before submitting
openAddMemberConfirmationModal();
} else {
// If the form is not valid, trigger error messages by firing a submit event
form.submit();
}
});
});
/*
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
*/
function populatePermissionDetails(permission_details_div_id) {
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`);
permissionSections.forEach(section => {
// Find the <h3> element text
const sectionTitle = section.textContent;
// Find the associated radio buttons container (next fieldset)
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');
// 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";
}
// 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);
}
});
}
/*
Updates and opens the "Add Member" confirmation modal.
*/
function openAddMemberConfirmationModal() {
//------- Populate modal details
// Get email value
let emailValue = document.getElementById('id_email').value;
document.getElementById('modalEmail').textContent = emailValue;
// Get selected radio button for access level
let selectedAccess = document.querySelector('input[name="member_access_level"]: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";
document.getElementById('modalAccessLevel').textContent = accessText;
// Populate permission details based on access level
if (selectedAccess && selectedAccess.value === 'admin') {
populatePermissionDetails('new-member-admin-permissions')
} else {
populatePermissionDetails('new-member-basic-permissions')
}
//------- Show the modal
let modalTrigger = document.querySelector("#invite_member_trigger");
if (modalTrigger) {
modalTrigger.click()
}
}
}

View file

@ -35,7 +35,8 @@
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate>
<form class="usa-form usa-form--large" method="post" id="add_member_form" novalidate>
<fieldset class="usa-fieldset margin-top-2">
<legend>
<h2>Email</h2>
@ -80,12 +81,17 @@
<h2>Admin access permissions</h2>
<p>Member permissions available for admin-level acccess.</p>
<h3 class="margin-bottom-0">Organization domain requests</h3>
<h3 class="summary-item__title
text-primary-dark
margin-bottom-0">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.admin_org_domain_request_permissions %}
{% endwith %}
<h3 class="margin-bottom-0 margin-top-3">Organization members</h3>
<h3 class="summary-item__title
text-primary-dark
margin-bottom-0
margin-top-3">Organization members</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.admin_org_members_permissions %}
{% endwith %}
@ -94,8 +100,12 @@
<!-- Basic access form -->
<div id="new-member-basic-permissions" class="margin-top-2">
<h2>Basic member permissions</h2>
<p>Member permissions available for basic-level access</p>
<p>Member permissions available for basic-level acccess.</p>
<h3 class="margin-bottom-0">Organization domain requests</h3>
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-0" %}
{% input_with_errors form.basic_org_domain_request_permissions %}
{% endwith %}
</div>
<!-- Submit/cancel buttons -->
@ -108,10 +118,76 @@
aria-label="Cancel adding new member"
>Cancel
</a>
<button type="submit" class="usa-button">Invite Member</button>
<a
id="invite_member_trigger"
href="#invite-member-modal"
class="usa-button usa-button--outline margin-top-1 display-none"
aria-controls="invite-member-modal"
data-open-modal
>Trigger invite member modal</a>
<button id="invite_new_member_submit" type="submit" class="usa-button">Invite Member</button>
</div>
</form>
<div
class="usa-modal"
id="invite-member-modal"
aria-labelledby="invite-member-heading"
aria-describedby="confirm-invite-description"
style="display: none;"
>
<div class="usa-modal__content">
<div class="usa-modal__main">
<h2 class="usa-modal__heading" id="invite-member-heading">
Invite this member to the organization?
</h2>
<h3 class="summary-item__title
text-primary-dark">Member information and permissions</h3>
<div class="usa-prose">
<!-- Display email as a header and access level -->
<h4 class="text-primary">Email</h4>
<p class="margin-top-0" id="modalEmail"></p>
<h4 class="text-primary">Member Access</h4>
<p class="margin-top-0" id="modalAccessLevel"></p>
<!-- Dynamic Permissions Details -->
<div id="permission_details"></div>
</div>
<div class="usa-modal__footer">
<ul class="usa-button-group">
<li class="usa-button-group__item">
<button id="confirm_new_member_submit" type="submit" class="usa-button">Yes, invite member</button>
</li>
<li class="usa-button-group__item">
<button
type="button"
class="usa-button usa-button--unstyled"
data-close-modal
onclick="closeModal()"
>
Cancel
</button>
</li>
</ul>
</div>
</div>
<button
type="button"
class="usa-button usa-modal__close"
aria-label="Close this window"
data-close-modal
onclick="closeModal()"
>
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#close"></use>
</svg>
</button>
</div>
</div>
{% endblock portfolio_content%}

View file

@ -2384,3 +2384,136 @@ class TestRequestingEntity(WebTest):
self.assertContains(response, "Requesting entity")
self.assertContains(response, "moon")
self.assertContains(response, "kepler, AL")
class TestPortfolioInviteNewMemberView(TestWithUser, WebTest):
@classmethod
def setUpClass(cls):
super().setUpClass()
# Create Portfolio
cls.portfolio = Portfolio.objects.create(creator=cls.user, organization_name="Test Portfolio")
# Add an invited member who has been invited to manage domains
cls.invited_member_email = "invited@example.com"
cls.invitation = PortfolioInvitation.objects.create(
email=cls.invited_member_email,
portfolio=cls.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
],
)
cls.new_member_email = "new_user@example.com"
# Assign permissions to the user making requests
UserPortfolioPermission.objects.create(
user=cls.user,
portfolio=cls.portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[
UserPortfolioPermissionChoices.VIEW_MEMBERS,
UserPortfolioPermissionChoices.EDIT_MEMBERS,
],
)
@classmethod
def tearDownClass(cls):
PortfolioInvitation.objects.all().delete()
UserPortfolioPermission.objects.all().delete()
Portfolio.objects.all().delete()
User.objects.all().delete()
super().tearDownClass()
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_invite_for_new_users(self):
"""Tests the member invitation flow for new users."""
self.client.force_login(self.user)
# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# Simulate submission of member invite for new user
final_response = self.client.post(
reverse("new-member"),
{
"member_access_level": "basic",
"basic_org_domain_request_permissions": "view_only",
"email": self.new_member_email,
},
)
# Ensure the final submission is successful
self.assertEqual(final_response.status_code, 302) # redirects after success
# Validate Database Changes
portfolio_invite = PortfolioInvitation.objects.filter(
email=self.new_member_email, portfolio=self.portfolio
).first()
self.assertIsNotNone(portfolio_invite)
self.assertEqual(portfolio_invite.email, self.new_member_email)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_invite_for_previously_invited_member(self):
"""Tests the member invitation flow for existing portfolio member."""
self.client.force_login(self.user)
# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
invite_count_before = PortfolioInvitation.objects.count()
# Simulate submission of member invite for user who has already been invited
response = self.client.post(
reverse("new-member"),
{
"member_access_level": "basic",
"basic_org_domain_request_permissions": "view_only",
"email": self.invited_member_email,
},
)
self.assertEqual(response.status_code, 302) # Redirects
# TODO: verify messages
# Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count()
self.assertEqual(invite_count_after, invite_count_before)
@less_console_noise_decorator
@override_flag("organization_feature", active=True)
@override_flag("organization_members", active=True)
def test_member_invite_for_existing_member(self):
"""Tests the member invitation flow for existing portfolio member."""
self.client.force_login(self.user)
# Simulate a session to ensure continuity
session_id = self.client.session.session_key
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
invite_count_before = PortfolioInvitation.objects.count()
# Simulate submission of member invite for user who has already been invited
response = self.client.post(
reverse("new-member"),
{
"member_access_level": "basic",
"basic_org_domain_request_permissions": "view_only",
"email": self.user.email,
},
)
self.assertEqual(response.status_code, 302) # Redirects
# TODO: verify messages
# Validate Database has not changed
invite_count_after = PortfolioInvitation.objects.count()
self.assertEqual(invite_count_after, invite_count_before)

View file

@ -1,4 +1,5 @@
import logging
from django.conf import settings
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
@ -11,6 +12,7 @@ from registrar.models import Portfolio, User
from registrar.models.portfolio_invitation import PortfolioInvitation
from registrar.models.user_portfolio_permission import UserPortfolioPermission
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
from registrar.utility.email import EmailSendingError
from registrar.views.utility.mixins import PortfolioMemberPermission
from registrar.views.utility.permission_views import (
PortfolioDomainRequestsPermissionView,
@ -25,6 +27,7 @@ from registrar.views.utility.permission_views import (
from django.views.generic import View
from django.views.generic.edit import FormMixin
logger = logging.getLogger(__name__)
@ -492,138 +495,165 @@ class NewMemberView(PortfolioMembersPermissionView, FormMixin):
"""Handle POST requests to process form submission."""
self.object = self.get_object()
form = self.get_form()
if form.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
def is_ajax(self):
return self.request.headers.get("X-Requested-With") == "XMLHttpRequest"
def form_invalid(self, form):
"""Handle the case when the form is invalid."""
return self.render_to_response(self.get_context_data(form=form))
if self.is_ajax():
return JsonResponse({"is_valid": False}) # Return a JSON response
else:
return super().form_invalid(form) # Handle non-AJAX requests normally
def form_valid(self, form):
if self.is_ajax():
return JsonResponse({"is_valid": True}) # Return a JSON response
else:
return self.submit_new_member(form)
def get_success_url(self):
"""Redirect to members table."""
return reverse("members")
##########################################
# TODO: future ticket #2854
# (save/invite new member)
##########################################
def _send_portfolio_invitation_email(self, email: str, requestor: User, add_success=True):
"""Performs the sending of the member invitation email
email: string- email to send to
add_success: bool- default True indicates:
adding a success message to the view if the email sending succeeds
# def _send_domain_invitation_email(self, email: str, requestor: User, add_success=True):
# """Performs the sending of the member invitation email
# email: string- email to send to
# add_success: bool- default True indicates:
# adding a success message to the view if the email sending succeeds
raises EmailSendingError
"""
# raises EmailSendingError
# """
# Set a default email address to send to for staff
requestor_email = settings.DEFAULT_FROM_EMAIL
# # Set a default email address to send to for staff
# requestor_email = settings.DEFAULT_FROM_EMAIL
# Check if the email requestor has a valid email address
if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "":
requestor_email = requestor.email
elif not requestor.is_staff:
messages.error(self.request, "Can't send invitation email. No email is associated with your account.")
logger.error(
f"Can't send email to '{email}' on domain '{self.object}'."
f"No email exists for the requestor '{requestor.username}'.",
exc_info=True,
)
return None
# # Check if the email requestor has a valid email address
# if not requestor.is_staff and requestor.email is not None and requestor.email.strip() != "":
# requestor_email = requestor.email
# elif not requestor.is_staff:
# messages.error(self.request, "Can't send invitation email. No email is associated with your account.")
# logger.error(
# f"Can't send email to '{email}' on domain '{self.object}'."
# f"No email exists for the requestor '{requestor.username}'.",
# exc_info=True,
# )
# return None
# Check to see if an invite has already been sent
try:
invite = PortfolioInvitation.objects.get(email=email, portfolio=self.object)
if invite: # We have an existin invite
# check if the invite has already been accepted
if invite.status == PortfolioInvitation.PortfolioInvitationStatus.RETRIEVED:
add_success = False
messages.warning(
self.request,
f"{email} is already a manager for this portfolio.",
)
else:
add_success = False
# it has been sent but not accepted
messages.warning(self.request, f"{email} has already been invited to this portfolio")
return
except Exception as err:
logger.error(f"_send_portfolio_invitation_email() => An error occured: {err}")
# # Check to see if an invite has already been sent
# try:
# invite = MemberInvitation.objects.get(email=email, domain=self.object)
# # check if the invite has already been accepted
# if invite.status == MemberInvitation.MemberInvitationStatus.RETRIEVED:
# add_success = False
# messages.warning(
# self.request,
# f"{email} is already a manager for this domain.",
# )
# else:
# add_success = False
# # else if it has been sent but not accepted
# messages.warning(self.request, f"{email} has already been invited to this domain")
# except Exception:
# logger.error("An error occured")
try:
logger.debug("requestor email: " + requestor_email)
# try:
# send_templated_email(
# "emails/member_invitation.txt",
# "emails/member_invitation_subject.txt",
# "emails/portfolio_invitation.txt",
# "emails/portfolio_invitation_subject.txt",
# to_address=email,
# context={
# "portfolio": self.object,
# "requestor_email": requestor_email,
# },
# )
# except EmailSendingError as exc:
# logger.warn(
# "Could not sent email invitation to %s for domain %s",
# email,
# self.object,
# exc_info=True,
# )
# raise EmailSendingError("Could not send email invitation.") from exc
# else:
# if add_success:
# messages.success(self.request, f"{email} has been invited to this domain.")
except EmailSendingError as exc:
logger.warn(
"Could not sent email invitation to %s for domain %s",
email,
self.object,
exc_info=True,
)
raise EmailSendingError("Could not send email invitation.") from exc
else:
if add_success:
messages.success(self.request, f"{email} has been invited.")
# def _make_invitation(self, email_address: str, requestor: User):
# """Make a Member invitation for this email and redirect with a message."""
# try:
# self._send_member_invitation_email(email=email_address, requestor=requestor)
# except EmailSendingError:
# messages.warning(self.request, "Could not send email invitation.")
# else:
# # (NOTE: only create a MemberInvitation if the e-mail sends correctly)
# MemberInvitation.objects.get_or_create(email=email_address, domain=self.object)
# return redirect(self.get_success_url())
def _make_invitation(self, email_address: str, requestor: User, add_success=True):
"""Make a Member invitation for this email and redirect with a message."""
try:
self._send_portfolio_invitation_email(email=email_address, requestor=requestor, add_success=add_success)
except EmailSendingError:
logger.warn(
"Could not send email invitation (EmailSendingError)",
self.object,
exc_info=True,
)
messages.warning(self.request, "Could not send email invitation.")
except Exception:
logger.warn(
"Could not send email invitation (Other Exception)",
self.object,
exc_info=True,
)
messages.warning(self.request, "Could not send email invitation.")
else:
# (NOTE: only create a MemberInvitation if the e-mail sends correctly)
PortfolioInvitation.objects.get_or_create(email=email_address, portfolio=self.object)
return redirect(self.get_success_url())
# def form_valid(self, form):
def submit_new_member(self, form):
"""Add the specified user as a member
for this portfolio.
Throws EmailSendingError."""
requested_email = form.cleaned_data["email"]
requestor = self.request.user
# """Add the specified user as a member
# for this portfolio.
# Throws EmailSendingError."""
# requested_email = form.cleaned_data["email"]
# requestor = self.request.user
# # look up a user with that email
# try:
# requested_user = User.objects.get(email=requested_email)
# except User.DoesNotExist:
# # no matching user, go make an invitation
# return self._make_invitation(requested_email, requestor)
# else:
# # if user already exists then just send an email
# try:
# self._send_member_invitation_email(requested_email, requestor, add_success=False)
# except EmailSendingError:
# logger.warn(
# "Could not send email invitation (EmailSendingError)",
# self.object,
# exc_info=True,
# )
# messages.warning(self.request, "Could not send email invitation.")
# except Exception:
# logger.warn(
# "Could not send email invitation (Other Exception)",
# self.object,
# exc_info=True,
# )
# messages.warning(self.request, "Could not send email invitation.")
requested_user = User.objects.filter(email=requested_email).first()
permission_exists = UserPortfolioPermission.objects.filter(user=requested_user, portfolio=self.object).exists()
if not requested_user or not permission_exists:
return self._make_invitation(requested_email, requestor)
else:
if permission_exists:
messages.warning(self.request, "User is already a member of this portfolio.")
return redirect(self.get_success_url())
# try:
# UserPortfolioPermission.objects.create(
# user=requested_user,
# portfolio=self.object,
# role=UserDomainRole.Roles.MANAGER,
# )
# except IntegrityError:
# messages.warning(self.request, f"{requested_email} is already a member of this portfolio")
# else:
# messages.success(self.request, f"Added user {requested_email}.")
# return redirect(self.get_success_url())
# look up a user with that email
try:
requested_user = User.objects.get(email=requested_email)
except User.DoesNotExist:
# no matching user, go make an invitation
return self._make_invitation(requested_email, requestor)
else:
# If user already exists, check to see if they are part of the portfolio already
# If they are already part of the portfolio, raise an error. Otherwise, send an invite.
existing_user = UserPortfolioPermission.objects.get(user=requested_user, portfolio=self.object)
if existing_user:
messages.warning(self.request, "User is already a member of this portfolio.")
else:
try:
self._send_portfolio_invitation_email(requested_email, requestor, add_success=False)
except EmailSendingError:
logger.warn(
"Could not send email invitation (EmailSendingError)",
self.object,
exc_info=True,
)
messages.warning(self.request, "Could not send email invitation.")
except Exception:
logger.warn(
"Could not send email invitation (Other Exception)",
self.object,
exc_info=True,
)
messages.warning(self.request, "Could not send email invitation.")
return redirect(self.get_success_url())

View file

@ -1,4 +1,5 @@
#!/bin/bash
npm install
npm rebuild
dir=./registrar/assets