Merge remote-tracking branch 'origin/main' into el/2762-add-liz-to-fixture

This commit is contained in:
lizpearl 2024-10-18 12:22:22 -05:00
commit 4205c33e3f
No known key found for this signature in database
GPG key ID: 17B8E9D4576B2708
12 changed files with 270 additions and 102 deletions

View file

@ -25,6 +25,8 @@
/**
* Edits made for dotgov project:
* - tooltip exposed to window to be accessible in other js files
* - tooltip positioning logic updated to allow position:fixed
* - 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
*/
@ -5938,6 +5940,22 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
return offset;
};
// ---- DOTGOV EDIT (Added section)
// DOTGOV: Tooltip positioning logic updated to allow position:fixed
const tooltipStyle = window.getComputedStyle(tooltipBody);
const tooltipIsFixedPositioned = tooltipStyle.position === 'fixed';
const triggerRect = tooltipTrigger.getBoundingClientRect(); //detect if tooltip is set to "fixed" position
const targetLeft = tooltipIsFixedPositioned ? triggerRect.left + triggerRect.width/2 + 'px': `50%`
const targetTop = tooltipIsFixedPositioned ? triggerRect.top + triggerRect.height/2 + 'px': `50%`
if (tooltipIsFixedPositioned) {
/* DOTGOV: Add listener to handle scrolling if tooltip position = 'fixed'
(so that the tooltip doesn't appear to stick to the screen) */
window.addEventListener('scroll', function() {
findBestPosition(tooltipBody)
});
}
// ---- END DOTGOV EDIT
/**
* Positions tooltip at the top
* @param {HTMLElement} e - this is the tooltip body
@ -5949,8 +5967,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger);
const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger);
setPositionClass("top");
e.style.left = `50%`; // center the element
e.style.top = `-${TRIANGLE_SIZE}px`; // consider the pseudo element
// ---- DOTGOV EDIT
// e.style.left = `50%`; // center the element
// e.style.top = `-${TRIANGLE_SIZE}px`; // consider the pseudo element
// DOTGOV: updated logic for position:fixed
e.style.left = targetLeft; // center the element
e.style.top = tooltipIsFixedPositioned ?`${triggerRect.top-TRIANGLE_SIZE}px`:`-${TRIANGLE_SIZE}px`; // consider the pseudo element
// ---- END DOTGOV EDIT
// apply our margins based on the offset
e.style.margin = `-${topMargin}px 0 0 -${leftMargin / 2}px`;
};
@ -5963,7 +5989,17 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
resetPositionStyles(e);
const leftMargin = calculateMarginOffset("left", e.offsetWidth, tooltipTrigger);
setPositionClass("bottom");
e.style.left = `50%`;
// ---- DOTGOV EDIT
// e.style.left = `50%`;
// DOTGOV: updated logic for position:fixed
if (tooltipIsFixedPositioned){
e.style.top = triggerRect.bottom+'px';
}
// ---- END DOTGOV EDIT
e.style.left = targetLeft;
e.style.margin = `${TRIANGLE_SIZE}px 0 0 -${leftMargin / 2}px`;
};
@ -5975,8 +6011,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
resetPositionStyles(e);
const topMargin = calculateMarginOffset("top", e.offsetHeight, tooltipTrigger);
setPositionClass("right");
e.style.top = `50%`;
e.style.left = `${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`;
// ---- DOTGOV EDIT
// e.style.top = `50%`;
// e.style.left = `${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`;
// DOTGOV: updated logic for position:fixed
e.style.top = targetTop;
e.style.left = tooltipIsFixedPositioned ? `${triggerRect.right + TRIANGLE_SIZE}px`:`${tooltipTrigger.offsetLeft + tooltipTrigger.offsetWidth + TRIANGLE_SIZE}px`;
// ---- END DOTGOV EDIT
e.style.margin = `-${topMargin / 2}px 0 0 0`;
};
@ -5991,8 +6035,16 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
// we have to check for some utility margins
const leftMargin = calculateMarginOffset("left", tooltipTrigger.offsetLeft > e.offsetWidth ? tooltipTrigger.offsetLeft - e.offsetWidth : e.offsetWidth, tooltipTrigger);
setPositionClass("left");
e.style.top = `50%`;
e.style.left = `-${TRIANGLE_SIZE}px`;
// ---- DOTGOV EDIT
// e.style.top = `50%`;
// e.style.left = `-${TRIANGLE_SIZE}px`;
// DOTGOV: updated logic for position:fixed
e.style.top = targetTop;
e.style.left = tooltipIsFixedPositioned ? `${triggerRect.left-TRIANGLE_SIZE}px` : `-${TRIANGLE_SIZE}px`;
// ---- END DOTGOV EDIT
e.style.margin = `-${topMargin / 2}px 0 0 ${tooltipTrigger.offsetLeft > e.offsetWidth ? leftMargin : -leftMargin}px`; // adjust the margin
};
@ -6017,6 +6069,7 @@ const showToolTip = (tooltipBody, tooltipTrigger, position) => {
if (i < positions.length) {
const pos = positions[i];
pos(element);
if (!isElementInViewport(element)) {
// eslint-disable-next-line no-param-reassign
tryPositions(i += 1);
@ -6128,7 +6181,17 @@ const setUpAttributes = tooltipTrigger => {
tooltipBody.setAttribute("aria-hidden", "true");
// place the text in the tooltip
tooltipBody.textContent = tooltipContent;
// -- DOTGOV EDIT
// tooltipBody.textContent = tooltipContent;
// DOTGOV: nest the text element to allow us greater control over width and wrapping behavior
tooltipBody.innerHTML = `
<span class="usa-tooltip__content">
${tooltipContent}
</span>`
// -- END DOTGOV EDIT
return {
tooltipBody,
position,

View file

@ -28,3 +28,47 @@
#extended-logo .usa-tooltip__body {
font-weight: 400 !important;
}
.domains__table {
/*
Trick tooltips in the domains table to do 2 things...
1 - Shrink itself to a padded viewport window
(override width and wrapping properties in key areas to constrain tooltip size)
2 - NOT be clipped by the table's scrollable view
(Set tooltip position to "fixed", which prevents tooltip from being clipped by parent
containers. Fixed-position detection was added to uswds positioning logic to update positioning
calculations accordingly.)
*/
.usa-tooltip__body {
white-space: inherit;
max-width: fit-content; // prevent adjusted widths from being larger than content
position: fixed; // prevents clipping by parent containers
}
/*
Override width adustments in this dynamically added class
(this is original to the javascript handler as a way to shrink tooltip contents within the viewport,
but is insufficient for our needs. We cannot simply override its properties
because the logic surrounding its dynamic appearance in the DOM does not account
for parent containers (basically, this class isn't in the DOM when we need it).
Intercept .usa-tooltip__content instead and nullify the effects of
.usa-tooltip__body--wrap to prevent conflicts)
*/
.usa-tooltip__body--wrap {
min-width: inherit;
width: inherit;
}
/*
Add width and wrapping to tooltip content in order to confine it to a smaller viewport window.
*/
.usa-tooltip__content {
width: 50vw;
text-wrap: wrap;
text-align: center;
font-size: inherit; //inherit tooltip fontsize of .93rem
max-width: fit-content;
@include at-media('desktop') {
width: 70vw;
}
display: block;
}
}

View file

@ -1,4 +1,5 @@
import logging
import random
from faker import Faker
from django.db import transaction
@ -51,23 +52,24 @@ class UserPortfolioPermissionFixture:
user_portfolio_permissions_to_create = []
for user in users:
for portfolio in portfolios:
try:
if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists():
user_portfolio_permission = UserPortfolioPermission(
user=user,
portfolio=portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
)
user_portfolio_permissions_to_create.append(user_portfolio_permission)
else:
logger.info(
f"Permission exists for user '{user.username}' "
f"on portfolio '{portfolio.organization_name}'."
)
except Exception as e:
logger.warning(e)
# Assign a random portfolio to a user
portfolio = random.choice(portfolios) # nosec
try:
if not UserPortfolioPermission.objects.filter(user=user, portfolio=portfolio).exists():
user_portfolio_permission = UserPortfolioPermission(
user=user,
portfolio=portfolio,
roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN],
additional_permissions=[UserPortfolioPermissionChoices.EDIT_MEMBERS],
)
user_portfolio_permissions_to_create.append(user_portfolio_permission)
else:
logger.info(
f"Permission exists for user '{user.username}' "
f"on portfolio '{portfolio.organization_name}'."
)
except Exception as e:
logger.warning(e)
# Bulk create permissions
cls._bulk_create_permissions(user_portfolio_permissions_to_create)

View file

@ -4,11 +4,31 @@
{% block title %}Add a domain manager | {% endblock %}
{% block domain_content %}
{% block breadcrumb %}
{% url 'domain-users' pk=domain.id as url %}
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain manager breadcrumb">
<ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item">
<a href="{{ url }}" class="usa-breadcrumb__link"><span>Domain managers</span></a>
</li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Add a domain manager</span>
</li>
</ol>
</nav>
{% endblock breadcrumb %}
<h1>Add a domain manager</h1>
<p>You can add another user to help manage your domain. They will need to sign
in to the .gov registrar with their Login.gov account.
{% if has_organization_feature_flag %}
<p>
You can add another user to help manage your domain. Users can only be a member of one .gov organization,
and they'll need to sign in with their Login.gov account.
</p>
{% else %}
<p>
You can add another user to help manage your domain. They will need to sign in to the .gov registrar with
their Login.gov account.
</p>
{% endif %}
<form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %}

View file

@ -45,7 +45,7 @@
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body">
<p class="usa-alert__text ">
To manage information for this domain, you must add yourself as a domain manager.
You don't have access to manage {{domain.name}}. If you need to make updates, contact one of the listed domain managers.
</p>
</div>
</div>

View file

@ -8,8 +8,7 @@
<p>
Domain managers can update all information related to a domain within the
.gov registrar, including contact details, senior official, security
email, and DNS name servers.
.gov registrar, including including security email and DNS name servers.
</p>
<ul class="usa-list">
@ -17,7 +16,8 @@
<li>After adding a domain manager, an email invitation will be sent to that user with
instructions on how to set up an account.</li>
<li>All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.</li>
<li>Domains must have at least one domain manager. You cant remove yourself as a domain manager if youre the only one assigned to this domain. Add another domain manager before you remove yourself from this domain.</li>
<li>All domain managers will be notified when updates are made to this domain.</li>
<li>Domains must have at least one domain manager. You cant remove yourself as a domain manager if youre the only one assigned to this domain.</li>
</ul>
{% if domain.permissions %}

View file

@ -340,7 +340,10 @@ class TestDomainDetail(TestDomainOverview):
detail_page = self.client.get(f"/domain/{domain.id}")
# Check that alert message displays properly
self.assertContains(
detail_page, "To manage information for this domain, you must add yourself as a domain manager."
detail_page,
"You don't have access to manage "
+ domain.name
+ ". If you need to make updates, contact one of the listed domain managers.",
)
# Check that user does not have option to Edit domain
self.assertNotContains(detail_page, "Edit")

View file

@ -23,6 +23,15 @@ class InvalidDomainError(ValueError):
pass
class OutsideOrgMemberError(ValueError):
"""
Error raised when an org member tries adding a user from a different .gov org.
To be deleted when users can be members of multiple orgs.
"""
pass
class ActionNotAllowed(Exception):
"""User accessed an action that is not
allowed by the current state"""

View file

@ -21,8 +21,10 @@ from registrar.models import (
DomainRequest,
DomainInformation,
DomainInvitation,
PortfolioInvitation,
User,
UserDomainRole,
UserPortfolioPermission,
PublicContact,
)
from registrar.utility.enums import DefaultEmail
@ -35,9 +37,11 @@ from registrar.utility.errors import (
DsDataErrorCodes,
SecurityEmailError,
SecurityEmailErrorCodes,
OutsideOrgMemberError,
)
from registrar.models.utility.contact_error import ContactError
from registrar.views.utility.permission_views import UserDomainRolePermissionDeleteView
from registrar.utility.waffle import flag_is_active_for_user
from ..forms import (
SeniorOfficialContactForm,
@ -778,7 +782,18 @@ class DomainAddUserView(DomainFormBaseView):
"""Get an absolute URL for this domain."""
return self.request.build_absolute_uri(reverse("domain", kwargs={"pk": self.object.id}))
def _send_domain_invitation_email(self, email: str, requestor: User, add_success=True):
def _is_member_of_different_org(self, email, requestor, requested_user):
"""Verifies if an email belongs to a different organization as a member or invited member."""
# Check if user is a already member of a different organization than the requestor's org
requestor_org = UserPortfolioPermission.objects.filter(user=requestor).first().portfolio
existing_org_permission = UserPortfolioPermission.objects.filter(user=requested_user).first()
existing_org_invitation = PortfolioInvitation.objects.filter(email=email).first()
return (existing_org_permission and existing_org_permission.portfolio != requestor_org) or (
existing_org_invitation and existing_org_invitation.portfolio != requestor_org
)
def _send_domain_invitation_email(self, email: str, requestor: User, requested_user=None, add_success=True):
"""Performs the sending of the domain invitation email,
does not make a domain information object
email: string- email to send to
@ -803,6 +818,13 @@ class DomainAddUserView(DomainFormBaseView):
)
return None
# Check is user is a member or invited member of a different org from this domain's org
if flag_is_active_for_user(requestor, "organization_feature") and self._is_member_of_different_org(
email, requestor, requested_user
):
add_success = False
raise OutsideOrgMemberError
# Check to see if an invite has already been sent
try:
invite = DomainInvitation.objects.get(email=email, domain=self.object)
@ -859,16 +881,21 @@ class DomainAddUserView(DomainFormBaseView):
Throws EmailSendingError."""
requested_email = form.cleaned_data["email"]
requestor = self.request.user
email_success = False
# 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
email_success = True
return self._make_invitation(requested_email, requestor)
else:
# if user already exists then just send an email
try:
self._send_domain_invitation_email(requested_email, requestor, add_success=False)
self._send_domain_invitation_email(
requested_email, requestor, requested_user=requested_user, add_success=False
)
email_success = True
except EmailSendingError:
logger.warn(
"Could not send email invitation (EmailSendingError)",
@ -876,6 +903,17 @@ class DomainAddUserView(DomainFormBaseView):
exc_info=True,
)
messages.warning(self.request, "Could not send email invitation.")
email_success = True
except OutsideOrgMemberError:
logger.warn(
"Could not send email. Can not invite member of a .gov organization to a different organization.",
self.object,
exc_info=True,
)
messages.error(
self.request,
f"{requested_email} is already a member of another .gov organization.",
)
except Exception:
logger.warn(
"Could not send email invitation (Other Exception)",
@ -883,17 +921,17 @@ class DomainAddUserView(DomainFormBaseView):
exc_info=True,
)
messages.warning(self.request, "Could not send email invitation.")
if email_success:
try:
UserDomainRole.objects.create(
user=requested_user,
domain=self.object,
role=UserDomainRole.Roles.MANAGER,
)
messages.success(self.request, f"Added user {requested_email}.")
except IntegrityError:
messages.warning(self.request, f"{requested_email} is already a manager for this domain")
try:
UserDomainRole.objects.create(
user=requested_user,
domain=self.object,
role=UserDomainRole.Roles.MANAGER,
)
except IntegrityError:
messages.warning(self.request, f"{requested_email} is already a manager for this domain")
else:
messages.success(self.request, f"Added user {requested_email}.")
return redirect(self.get_success_url())