mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-07-12 05:58:18 +02:00
Merge remote-tracking branch 'origin/main' into el/2762-add-liz-to-fixture
This commit is contained in:
commit
4205c33e3f
12 changed files with 270 additions and 102 deletions
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 can’t remove yourself as a domain manager if you’re 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 can’t remove yourself as a domain manager if you’re the only one assigned to this domain.</li>
|
||||
</ul>
|
||||
|
||||
{% if domain.permissions %}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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"""
|
||||
|
|
|
@ -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())
|
||||
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue