Merge remote-tracking branch 'origin/main' into ms/3212-FEB-purpose-questions

This commit is contained in:
matthewswspence 2025-03-03 16:25:57 -06:00
commit f218ca31ba
No known key found for this signature in database
GPG key ID: FB458202A7852BA4
145 changed files with 5025 additions and 3402 deletions

View file

@ -1,6 +1,6 @@
name: Bug name: Bug
description: Report a bug or problem with the application description: Report a bug or problem with the application
labels: ["bug"] labels: ["bug","dev"]
body: body:
- type: markdown - type: markdown

View file

@ -1,8 +1,8 @@
# This workflow can be run from the CLI # This workflow can be run from the CLI
# gh workflow run reset-db.yaml -f environment=ENVIRONMENT # gh workflow run reset-db.yaml -f environment=ENVIRONMENT
name: Reset database name: Delete and Recreate database
run-name: Reset database for ${{ github.event.inputs.environment }} run-name: Delete and Recreate for ${{ github.event.inputs.environment }}
on: on:
workflow_dispatch: workflow_dispatch:
@ -53,7 +53,7 @@ jobs:
sudo apt-get update sudo apt-get update
sudo apt-get install cf8-cli sudo apt-get install cf8-cli
cf api api.fr.cloud.gov cf api api.fr.cloud.gov
cf auth "$CF_USERNAME" "$CF_PASSWORD" cf auth "$cf_username" "$cf_password"
cf target -o cisa-dotgov -s $DESTINATION_ENVIRONMENT cf target -o cisa-dotgov -s $DESTINATION_ENVIRONMENT

View file

@ -0,0 +1,102 @@
# 26. Django Waffle library for Feature Flags
Date: 2024-05-22 (back dated)
## Status
Approved
## Context
When we decided to implement server-side rendering ([ADR#8 - Server-Side rendering](./0008-server-side-rendering.md)), we identified a potential risk: users and stakeholders might expect increasingly interactive experiences similar to those found in single-page applications (SPAs). Modern JavaScript frameworks such as React, Angular, and Vue enable rich interactivity by allowing applications to update portions of the page dynamically—often without requiring a full-page reload. These frameworks abstract AJAX and DOM manipulation, creating a high-level interface between JavaScript, HTML, and the browser.
Our decision to use Django for server-rendered pages allowed us to deliver an MVP quickly and facilitated easy onboarding for new developers. However, the anticipated risk materialized, and stakeholders now expect a more seamless, SPA-like experience.
We already leverage vanilla JavaScript for interactive components throughout the application. These implementations are neatly contained within Immediately Invoked Function Expressions (IIFEs) and are designed to extend specific components without interfering with Djangos server-rendered structure.
However, new components that require features like pagination, search, and filtering demand a more responsive, real-time user experience. This prompted an exploration of possible solutions.
## Considered Options
**Option 1:** Migrate to a Full SPA with Django as a Backend API
This approach involves refactoring Django into a backend-only service and adopting a modern JavaScript framework for the frontend.
✅ Pros:
- Future-proof solution that aligns with modern web development practices.
- Enables highly interactive and dynamic UI.
- Clean separation of concerns between frontend and backend.
❌ Cons:
- Requires significant investment in development and infrastructure changes.
- Major refactoring effort, delaying feature delivery.
- Increased complexity for testing and deployment.
This approach was deemed too costly in terms of both time and resources.
---
**Option 2:** Adopt a Modern JS Framework for Select Parts of the Application
Instead of a full migration, this approach involves integrating a modern JavaScript framework (e.g., React or Vue) only in areas that require high interactivity.
✅ Pros:
- Avoids a complete rewrite, allowing incremental improvements.
- More flexibility in choosing the level of interactivity per feature.
❌ Cons:
- Introduces multiple frontend paradigms, increasing developer onboarding complexity.
- Requires new deployment and build infrastructure.
- Creates long-term technical debt if legacy Django templates and new JS-driven components coexist indefinitely.
This approach would still introduce diverging implementation stacks, leading to long-term maintenance challenges.
---
**Option 3:** Use a Lightweight JavaScript Framework (e.g., HTMX, HTMZ)
Instead of React or Vue, this approach involves using a minimal JavaScript framework like HTMX or HTMZ to enhance interactivity while preserving Djangos server-rendered structure.
✅ Pros:
- Reduces the need for a full rewrite.
- Keeps Django templates largely intact.
- Minimizes complexity compared to React or Vue.
❌ Cons:
- Limited community support and long-term viability concerns.
- Still introduces new technology and learning curves.
- Unclear whether it fully meets our interactivity needs.
Ultimately, we determined that the benefits did not outweigh the potential downsides.
---
**Option 4:** Extend Vanilla JavaScript with AJAX (Selected Option)
This approach involves incrementally enhancing Djangos server-rendered pages with AJAX while maintaining our existing architecture.
✅ Pros:
Avoids expensive refactors and new dependencies.
- Fully customized to our existing codebase.
- Keeps Django templates intact while allowing dynamic updates.
- No need for additional build tools or frontend frameworks.
❌ Cons:
- Requires designing our own structured approach to AJAX.
- Testing and maintainability must be carefully considered.
This approach aligns with our existing architecture and skill set while meeting stakeholder demands for interactivity.
## Decision
We chose Option 4: Extending our use of vanilla JavaScript with AJAX.
## Consequences
1. Ownership of Solution
- We fully control the implementation without external dependencies.
2. Maintainability
- Our AJAX implementation will follow an object-oriented approach, with a base class for components requiring pagination, search, and filtering.
3. Backend Considerations
- Views handling AJAX responses will be explicitly designated as JSON views.
4. Scalability
- While this approach works now, we may need to reassess in the future if interactivity demands continue to grow.
This decision allows us to enhance the application's responsiveness without disrupting existing architecture or delaying feature development.

1030
src/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@uswds/uswds": "3.8.1", "@uswds/uswds": "3.8.1",
"pa11y-ci": "^3.0.1", "pa11y-ci": "^3.1.0",
"sass": "^1.54.8" "sass": "^1.54.8"
}, },
"devDependencies": { "devDependencies": {

View file

@ -11,6 +11,7 @@ from django.db.models import (
Value, Value,
When, When,
) )
from django.db.models.functions import Concat, Coalesce from django.db.models.functions import Concat, Coalesce
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from registrar.models.federal_agency import FederalAgency from registrar.models.federal_agency import FederalAgency
@ -24,7 +25,7 @@ from registrar.utility.admin_helpers import (
from django.conf import settings from django.conf import settings
from django.contrib.messages import get_messages from django.contrib.messages import get_messages
from django.contrib.admin.helpers import AdminForm from django.contrib.admin.helpers import AdminForm
from django.shortcuts import redirect from django.shortcuts import redirect, get_object_or_404
from django_fsm import get_available_FIELD_transitions, FSMField from django_fsm import get_available_FIELD_transitions, FSMField
from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation from registrar.models import DomainInformation, Portfolio, UserPortfolioPermission, DomainInvitation
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices
@ -162,6 +163,18 @@ class MyUserAdminForm(UserChangeForm):
"user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False), "user_permissions": NoAutocompleteFilteredSelectMultiple("user_permissions", False),
} }
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select contact to change", display "Contacts")
# see "base_site.html" for the <title> code.
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
"""Custom init to modify the user form""" """Custom init to modify the user form"""
super(MyUserAdminForm, self).__init__(*args, **kwargs) super(MyUserAdminForm, self).__init__(*args, **kwargs)
@ -522,6 +535,18 @@ class CustomLogEntryAdmin(LogEntryAdmin):
"user_url", "user_url",
] ]
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select contact to change", display "Contacts")
# see "base_site.html" for the <title> code.
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
# We name the custom prop 'resource' because linter # We name the custom prop 'resource' because linter
# is not allowing a short_description attr on it # is not allowing a short_description attr on it
# This gets around the linter limitation, for now. # This gets around the linter limitation, for now.
@ -541,13 +566,6 @@ class CustomLogEntryAdmin(LogEntryAdmin):
change_form_template = "admin/change_form_no_submit.html" change_form_template = "admin/change_form_no_submit.html"
add_form_template = "admin/change_form_no_submit.html" add_form_template = "admin/change_form_no_submit.html"
# Select log entry to change -> Log entries
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Log entries"
return super().changelist_view(request, extra_context=extra_context)
# #786: Skipping on updating audit log tab titles for now # #786: Skipping on updating audit log tab titles for now
# def change_view(self, request, object_id, form_url="", extra_context=None): # def change_view(self, request, object_id, form_url="", extra_context=None):
# if extra_context is None: # if extra_context is None:
@ -628,6 +646,18 @@ class AdminSortFields:
class AuditedAdmin(admin.ModelAdmin): class AuditedAdmin(admin.ModelAdmin):
"""Custom admin to make auditing easier.""" """Custom admin to make auditing easier."""
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select contact to change", display "Contacts")
# see "base_site.html" for the <title> code.
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def history_view(self, request, object_id, extra_context=None): def history_view(self, request, object_id, extra_context=None):
"""On clicking 'History', take admin to the auditlog view for an object.""" """On clicking 'History', take admin to the auditlog view for an object."""
return HttpResponseRedirect( return HttpResponseRedirect(
@ -1028,6 +1058,18 @@ class MyUserAdmin(BaseUserAdmin, ImportExportModelAdmin):
extra_context = {"domain_requests": domain_requests, "domains": domains, "portfolios": portfolios} extra_context = {"domain_requests": domain_requests, "domains": domains, "portfolios": portfolios}
return super().change_view(request, object_id, form_url, extra_context) return super().change_view(request, object_id, form_url, extra_context)
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select contact to change", display "Contacts")
# see "base_site.html" for the <title> code.
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class HostIPInline(admin.StackedInline): class HostIPInline(admin.StackedInline):
"""Edit an ip address on the host page.""" """Edit an ip address on the host page."""
@ -1052,14 +1094,6 @@ class MyHostAdmin(AuditedAdmin, ImportExportModelAdmin):
search_help_text = "Search by domain or host name." search_help_text = "Search by domain or host name."
inlines = [HostIPInline] inlines = [HostIPInline]
# Select host to change -> Host
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Host"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class HostIpResource(resources.ModelResource): class HostIpResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """defines how each field in the referenced model should be mapped to the corresponding fields in the
@ -1075,14 +1109,6 @@ class HostIpAdmin(AuditedAdmin, ImportExportModelAdmin):
resource_classes = [HostIpResource] resource_classes = [HostIpResource]
model = models.HostIP model = models.HostIP
# Select host ip to change -> Host ip
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Host IP"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class ContactResource(resources.ModelResource): class ContactResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """defines how each field in the referenced model should be mapped to the corresponding fields in the
@ -1204,14 +1230,6 @@ class ContactAdmin(ListHeaderAdmin, ImportExportModelAdmin):
return super().change_view(request, object_id, form_url, extra_context=extra_context) return super().change_view(request, object_id, form_url, extra_context=extra_context)
# Select contact to change -> Contacts
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Contacts"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
# Clear warning messages before saving # Clear warning messages before saving
storage = messages.get_messages(request) storage = messages.get_messages(request)
@ -1326,6 +1344,7 @@ class UserPortfolioPermissionAdmin(ListHeaderAdmin):
search_help_text = "Search by first name, last name, email, or portfolio." search_help_text = "Search by first name, last name, email, or portfolio."
change_form_template = "django/admin/user_portfolio_permission_change_form.html" change_form_template = "django/admin/user_portfolio_permission_change_form.html"
delete_confirmation_template = "django/admin/user_portfolio_permission_delete_confirmation.html"
def get_roles(self, obj): def get_roles(self, obj):
readable_roles = obj.get_readable_roles() readable_roles = obj.get_readable_roles()
@ -1525,13 +1544,26 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
# Override for the delete confirmation page on the domain table (bulk delete action) # Override for the delete confirmation page on the domain table (bulk delete action)
delete_selected_confirmation_template = "django/admin/domain_invitation_delete_selected_confirmation.html" delete_selected_confirmation_template = "django/admin/domain_invitation_delete_selected_confirmation.html"
# Select domain invitations to change -> Domain invitations def change_view(self, request, object_id, form_url="", extra_context=None):
def changelist_view(self, request, extra_context=None): """Override the change_view to add the invitation obj for the change_form_object_tools template"""
if extra_context is None: if extra_context is None:
extra_context = {} extra_context = {}
extra_context["tabtitle"] = "Domain invitations"
# Get the filtered values # Get the domain invitation object
return super().changelist_view(request, extra_context=extra_context) invitation = get_object_or_404(DomainInvitation, id=object_id)
extra_context["invitation"] = invitation
if request.method == "POST" and "cancel_invitation" in request.POST:
if invitation.status == DomainInvitation.DomainInvitationStatus.INVITED:
invitation.cancel_invitation()
invitation.save(update_fields=["status"])
messages.success(request, _("Invitation canceled successfully."))
# Redirect back to the change view
return redirect(reverse("admin:registrar_domaininvitation_change", args=[object_id]))
return super().change_view(request, object_id, form_url, extra_context)
def delete_view(self, request, object_id, extra_context=None): def delete_view(self, request, object_id, extra_context=None):
""" """
@ -1551,6 +1583,7 @@ class DomainInvitationAdmin(BaseInvitationAdmin):
which will be successful if a single User exists for that email; otherwise, will which will be successful if a single User exists for that email; otherwise, will
just continue to create the invitation. just continue to create the invitation.
""" """
if not change: if not change:
domain = obj.domain domain = obj.domain
domain_org = getattr(domain.domain_info, "portfolio", None) domain_org = getattr(domain.domain_info, "portfolio", None)
@ -1647,14 +1680,7 @@ class PortfolioInvitationAdmin(BaseInvitationAdmin):
autocomplete_fields = ["portfolio"] autocomplete_fields = ["portfolio"]
change_form_template = "django/admin/portfolio_invitation_change_form.html" change_form_template = "django/admin/portfolio_invitation_change_form.html"
delete_confirmation_template = "django/admin/portfolio_invitation_delete_confirmation.html"
# Select portfolio invitations to change -> Portfolio invitations
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Portfolio invitations"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def save_model(self, request, obj, form, change): def save_model(self, request, obj, form, change):
""" """
@ -2045,14 +2071,6 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin):
readonly_fields.extend([field for field in self.analyst_readonly_fields]) readonly_fields.extend([field for field in self.analyst_readonly_fields])
return readonly_fields # Read-only fields for analysts return readonly_fields # Read-only fields for analysts
# Select domain information to change -> Domain information
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Domain information"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
def formfield_for_foreignkey(self, db_field, request, **kwargs): def formfield_for_foreignkey(self, db_field, request, **kwargs):
"""Customize the behavior of formfields with foreign key relationships. This will customize """Customize the behavior of formfields with foreign key relationships. This will customize
the behavior of selects. Customized behavior includes sorting of objects in list.""" the behavior of selects. Customized behavior includes sorting of objects in list."""
@ -2264,11 +2282,12 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
@admin.display(description=_("Requested Domain")) @admin.display(description=_("Requested Domain"))
def custom_requested_domain(self, obj): def custom_requested_domain(self, obj):
# Example: Show different icons based on `status` # Example: Show different icons based on `status`
url = reverse("admin:registrar_domainrequest_changelist") + f"{obj.id}"
text = obj.requested_domain text = obj.requested_domain
if obj.portfolio: if obj.portfolio:
return format_html('<a href="{}"><img src="/public/admin/img/icon-yes.svg"> {}</a>', url, text) return format_html(
return format_html('<a href="{}">{}</a>', url, text) f'<img class="padding-right-05" src="/public/admin/img/icon-yes.svg" aria-hidden="true">{escape(text)}'
)
return text
custom_requested_domain.admin_order_field = "requested_domain__name" # type: ignore custom_requested_domain.admin_order_field = "requested_domain__name" # type: ignore
@ -2872,11 +2891,6 @@ class DomainRequestAdmin(ListHeaderAdmin, ImportExportModelAdmin):
if next_char.isdigit(): if next_char.isdigit():
should_apply_default_filter = True should_apply_default_filter = True
# Select domain request to change -> Domain requests
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Domain requests"
if should_apply_default_filter: if should_apply_default_filter:
# modify the GET of the request to set the selected filter # modify the GET of the request to set the selected filter
modified_get = copy.deepcopy(request.GET) modified_get = copy.deepcopy(request.GET)
@ -3715,11 +3729,13 @@ class DomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# Using variables to get past the linter # Using variables to get past the linter
message1 = f"Cannot delete Domain when in state {obj.state}" message1 = f"Cannot delete Domain when in state {obj.state}"
message2 = f"This subdomain is being used as a hostname on another domain: {err.note}" message2 = f"This subdomain is being used as a hostname on another domain: {err.note}"
message3 = f"Command failed with note: {err.note}"
# Human-readable mappings of ErrorCodes. Can be expanded. # Human-readable mappings of ErrorCodes. Can be expanded.
error_messages = { error_messages = {
# noqa on these items as black wants to reformat to an invalid length # noqa on these items as black wants to reformat to an invalid length
ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION: message1, ErrorCode.OBJECT_STATUS_PROHIBITS_OPERATION: message1,
ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION: message2, ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION: message2,
ErrorCode.COMMAND_FAILED: message3,
} }
message = "Cannot connect to the registry" message = "Cannot connect to the registry"
@ -3931,14 +3947,6 @@ class DraftDomainAdmin(ListHeaderAdmin, ImportExportModelAdmin):
# If no redirection is needed, return the original response # If no redirection is needed, return the original response
return response return response
# Select draft domain to change -> Draft domains
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "Draft domains"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class PublicContactResource(resources.ModelResource): class PublicContactResource(resources.ModelResource):
"""defines how each field in the referenced model should be mapped to the corresponding fields in the """defines how each field in the referenced model should be mapped to the corresponding fields in the
@ -4360,14 +4368,6 @@ class UserGroupAdmin(AuditedAdmin):
def user_group(self, obj): def user_group(self, obj):
return obj.name return obj.name
# Select user groups to change -> User groups
def changelist_view(self, request, extra_context=None):
if extra_context is None:
extra_context = {}
extra_context["tabtitle"] = "User groups"
# Get the filtered values
return super().changelist_view(request, extra_context=extra_context)
class WaffleFlagAdmin(FlagAdmin): class WaffleFlagAdmin(FlagAdmin):
"""Custom admin implementation of django-waffle's Flag class""" """Custom admin implementation of django-waffle's Flag class"""
@ -4384,6 +4384,13 @@ class WaffleFlagAdmin(FlagAdmin):
if extra_context is None: if extra_context is None:
extra_context = {} extra_context = {}
extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag") extra_context["dns_prototype_flag"] = flag_is_active_for_user(request.user, "dns_prototype_flag")
# Loads "tabtitle" for this admin page so that on render the <title>
# element will only have the model name instead of
# the default string loaded by native Django admin code.
# (Eg. instead of "Select waffle flags to change", display "Waffle Flags")
# see "base_site.html" for the <title> code.
extra_context["tabtitle"] = str(self.opts.verbose_name_plural).title()
return super().changelist_view(request, extra_context=extra_context) return super().changelist_view(request, extra_context=extra_context)

View file

@ -1,130 +0,0 @@
/** An IIFE for admin in DjangoAdmin to listen to clicks on the growth report export button,
* attach the seleted start and end dates to a url that'll trigger the view, and finally
* redirect to that url.
*
* This function also sets the start and end dates to match the url params if they exist
*/
(function () {
// Function to get URL parameter value by name
function getParameterByName(name, url) {
if (!url) url = window.location.href;
name = name.replace(/[\[\]]/g, '\\$&');
var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'),
results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}
// Get the current date in the format YYYY-MM-DD
let currentDate = new Date().toISOString().split('T')[0];
// Default the value of the start date input field to the current date
let startDateInput = document.getElementById('start');
// Default the value of the end date input field to the current date
let endDateInput = document.getElementById('end');
let exportButtons = document.querySelectorAll('.exportLink');
if (exportButtons.length > 0) {
// Check if start and end dates are present in the URL
let urlStartDate = getParameterByName('start_date');
let urlEndDate = getParameterByName('end_date');
// Set input values based on URL parameters or current date
startDateInput.value = urlStartDate || currentDate;
endDateInput.value = urlEndDate || currentDate;
exportButtons.forEach((btn) => {
btn.addEventListener('click', function () {
// Get the selected start and end dates
let startDate = startDateInput.value;
let endDate = endDateInput.value;
let exportUrl = btn.dataset.exportUrl;
// Build the URL with parameters
exportUrl += "?start_date=" + startDate + "&end_date=" + endDate;
// Redirect to the export URL
window.location.href = exportUrl;
});
});
}
})();
/** An IIFE to initialize the analytics page
*/
(function () {
function createComparativeColumnChart(canvasId, title, labelOne, labelTwo) {
var canvas = document.getElementById(canvasId);
if (!canvas) {
return
}
var ctx = canvas.getContext("2d");
var listOne = JSON.parse(canvas.getAttribute('data-list-one'));
var listTwo = JSON.parse(canvas.getAttribute('data-list-two'));
var data = {
labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"],
datasets: [
{
label: labelOne,
backgroundColor: "rgba(255, 99, 132, 0.2)",
borderColor: "rgba(255, 99, 132, 1)",
borderWidth: 1,
data: listOne,
},
{
label: labelTwo,
backgroundColor: "rgba(75, 192, 192, 0.2)",
borderColor: "rgba(75, 192, 192, 1)",
borderWidth: 1,
data: listTwo,
},
],
};
var options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
title: {
display: true,
text: title
}
},
scales: {
y: {
beginAtZero: true,
},
},
};
new Chart(ctx, {
type: "bar",
data: data,
options: options,
});
}
function initComparativeColumnCharts() {
document.addEventListener("DOMContentLoaded", function () {
createComparativeColumnChart("myChart1", "Managed domains", "Start Date", "End Date");
createComparativeColumnChart("myChart2", "Unmanaged domains", "Start Date", "End Date");
createComparativeColumnChart("myChart3", "Deleted domains", "Start Date", "End Date");
createComparativeColumnChart("myChart4", "Ready domains", "Start Date", "End Date");
createComparativeColumnChart("myChart5", "Submitted requests", "Start Date", "End Date");
createComparativeColumnChart("myChart6", "All requests", "Start Date", "End Date");
});
};
initComparativeColumnCharts();
})();

View file

@ -5695,19 +5695,35 @@ const createHeaderButton = (header, headerName) => {
buttonEl.setAttribute("tabindex", "0"); buttonEl.setAttribute("tabindex", "0");
buttonEl.classList.add(SORT_BUTTON_CLASS); buttonEl.classList.add(SORT_BUTTON_CLASS);
// ICON_SOURCE // ICON_SOURCE
// ---- END DOTGOV EDIT
// Change icons on sort, use source from arro_upward and arrow_downward
// buttonEl.innerHTML = Sanitizer.escapeHTML`
// <svg class="${PREFIX}-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
// <g class="descending" fill="transparent">
// <path d="M17 17L15.59 15.59L12.9999 18.17V2H10.9999V18.17L8.41 15.58L7 17L11.9999 22L17 17Z" />
// </g>
// <g class="ascending" fill="transparent">
// <path transform="rotate(180, 12, 12)" d="M17 17L15.59 15.59L12.9999 18.17V2H10.9999V18.17L8.41 15.58L7 17L11.9999 22L17 17Z" />
// </g>
// <g class="unsorted" fill="transparent">
// <polygon points="15.17 15 13 17.17 13 6.83 15.17 9 16.58 7.59 12 3 7.41 7.59 8.83 9 11 6.83 11 17.17 8.83 15 7.42 16.41 12 21 16.59 16.41 15.17 15"/>
// </g>
// </svg>
// `;
buttonEl.innerHTML = Sanitizer.escapeHTML` buttonEl.innerHTML = Sanitizer.escapeHTML`
<svg class="${PREFIX}-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <svg class="${PREFIX}-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g class="descending" fill="transparent"> <g class="descending" fill="transparent">
<path d="M17 17L15.59 15.59L12.9999 18.17V2H10.9999V18.17L8.41 15.58L7 17L11.9999 22L17 17Z" /> <path d="m20 12-1.41-1.41L13 16.17V4h-2v12.17l-5.58-5.59L4 12l8 8 8-8z"/>
</g> </g>
<g class="ascending" fill="transparent"> <g class="ascending" fill="transparent">
<path transform="rotate(180, 12, 12)" d="M17 17L15.59 15.59L12.9999 18.17V2H10.9999V18.17L8.41 15.58L7 17L11.9999 22L17 17Z" /> <path d="m4 12 1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8-8 8z"/>
</g> </g>
<g class="unsorted" fill="transparent"> <g class="unsorted" fill="transparent">
<polygon points="15.17 15 13 17.17 13 6.83 15.17 9 16.58 7.59 12 3 7.41 7.59 8.83 9 11 6.83 11 17.17 8.83 15 7.42 16.41 12 21 16.59 16.41 15.17 15"/> <polygon points="15.17 15 13 17.17 13 6.83 15.17 9 16.58 7.59 12 3 7.41 7.59 8.83 9 11 6.83 11 17.17 8.83 15 7.42 16.41 12 21 16.59 16.41 15.17 15"/>
</g> </g>
</svg> </svg>
`; `;
// ---- END DOTGOV EDIT
header.appendChild(buttonEl); header.appendChild(buttonEl);
updateSortLabel(header, headerName); updateSortLabel(header, headerName);
}; };

View file

@ -0,0 +1,177 @@
import { debounce } from '../getgov/helpers.js';
import { getParameterByName } from './helpers-admin.js';
/** This function also sets the start and end dates to match the url params if they exist
*/
function initAnalyticsExportButtons() {
// Get the current date in the format YYYY-MM-DD
let currentDate = new Date().toISOString().split('T')[0];
// Default the value of the start date input field to the current date
let startDateInput = document.getElementById('start');
// Default the value of the end date input field to the current date
let endDateInput = document.getElementById('end');
let exportButtons = document.querySelectorAll('.exportLink');
if (exportButtons.length > 0) {
// Check if start and end dates are present in the URL
let urlStartDate = getParameterByName('start_date');
let urlEndDate = getParameterByName('end_date');
// Set input values based on URL parameters or current date
startDateInput.value = urlStartDate || currentDate;
endDateInput.value = urlEndDate || currentDate;
exportButtons.forEach((btn) => {
btn.addEventListener('click', function () {
// Get the selected start and end dates
let startDate = startDateInput.value;
let endDate = endDateInput.value;
let exportUrl = btn.dataset.exportUrl;
// Build the URL with parameters
exportUrl += "?start_date=" + startDate + "&end_date=" + endDate;
// Redirect to the export URL
window.location.href = exportUrl;
});
});
}
};
/**
* Creates a diagonal stripe pattern for chart.js
* Inspired by https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
* and https://github.com/ashiguruma/patternomaly
* @param {string} backgroundColor - Background color of the pattern
* @param {string} [lineColor="white"] - Color of the diagonal lines
* @param {boolean} [rightToLeft=false] - Direction of the diagonal lines
* @param {number} [lineGap=1] - Gap between lines
* @returns {CanvasPattern} A canvas pattern object for use with backgroundColor
*/
function createDiagonalPattern(backgroundColor, lineColor, rightToLeft=false, lineGap=1) {
// Define the canvas and the 2d context so we can draw on it
let shape = document.createElement("canvas");
shape.width = 20;
shape.height = 20;
let context = shape.getContext("2d");
// Fill with specified background color
context.fillStyle = backgroundColor;
context.fillRect(0, 0, shape.width, shape.height);
// Set stroke properties
context.strokeStyle = lineColor;
context.lineWidth = 2;
// Rotate canvas for a right-to-left pattern
if (rightToLeft) {
context.translate(shape.width, 0);
context.rotate(90 * Math.PI / 180);
};
// First diagonal line
let halfSize = shape.width / 2;
context.moveTo(halfSize - lineGap, -lineGap);
context.lineTo(shape.width + lineGap, halfSize + lineGap);
// Second diagonal line (x,y are swapped)
context.moveTo(-lineGap, halfSize - lineGap);
context.lineTo(halfSize + lineGap, shape.width + lineGap);
context.stroke();
return context.createPattern(shape, "repeat");
}
function createComparativeColumnChart(id, title, labelOne, labelTwo) {
var canvas = document.getElementById(id);
if (!canvas) {
return
}
var ctx = canvas.getContext("2d");
var listOne = JSON.parse(canvas.getAttribute('data-list-one'));
var listTwo = JSON.parse(canvas.getAttribute('data-list-two'));
var data = {
labels: ["Total", "Federal", "Interstate", "State/Territory", "Tribal", "County", "City", "Special District", "School District", "Election Board"],
datasets: [
{
label: labelOne,
backgroundColor: "rgba(255, 99, 132, 0.3)",
borderColor: "rgba(255, 99, 132, 1)",
borderWidth: 1,
data: listOne,
// Set this line style to be rightToLeft for visual distinction
backgroundColor: createDiagonalPattern('rgba(255, 99, 132, 0.3)', 'white', true)
},
{
label: labelTwo,
backgroundColor: "rgba(75, 192, 192, 0.3)",
borderColor: "rgba(75, 192, 192, 1)",
borderWidth: 1,
data: listTwo,
backgroundColor: createDiagonalPattern('rgba(75, 192, 192, 0.3)', 'white')
},
],
};
var options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
title: {
display: true,
text: title
}
},
scales: {
y: {
beginAtZero: true,
},
},
};
return new Chart(ctx, {
type: "bar",
data: data,
options: options,
});
}
/** An IIFE to initialize the analytics page
*/
export function initAnalyticsDashboard() {
const analyticsPageContainer = document.querySelector('.analytics-dashboard-charts');
if (analyticsPageContainer) {
document.addEventListener("DOMContentLoaded", function () {
initAnalyticsExportButtons();
// Create charts and store each instance of it
const chartInstances = new Map();
const charts = [
{ id: "managed-domains-chart", title: "Managed domains" },
{ id: "unmanaged-domains-chart", title: "Unmanaged domains" },
{ id: "deleted-domains-chart", title: "Deleted domains" },
{ id: "ready-domains-chart", title: "Ready domains" },
{ id: "submitted-requests-chart", title: "Submitted requests" },
{ id: "all-requests-chart", title: "All requests" }
];
charts.forEach(chart => {
if (chartInstances.has(chart.id)) chartInstances.get(chart.id).destroy();
chartInstances.set(chart.id, createComparativeColumnChart(chart.id, chart.title, "Start Date", "End Date"));
});
// Add resize listener to each chart
window.addEventListener("resize", debounce(() => {
chartInstances.forEach((chart) => {
if (chart?.canvas) chart.resize();
});
}, 200));
});
}
};

View file

@ -0,0 +1,15 @@
/**
* Initializes buttons to behave like links by navigating to their data-url attribute
* Example usage: <button class="use-button-as-link" data-url="/some/path">Click me</button>
*/
export function initButtonLinks() {
document.querySelectorAll('button.use-button-as-link').forEach(button => {
button.addEventListener('click', function() {
// Equivalent to button.getAttribute("data-href")
const href = this.dataset.href;
if (href) {
window.location.href = href;
}
});
});
}

View file

@ -22,3 +22,13 @@ export function addOrRemoveSessionBoolean(name, add){
sessionStorage.removeItem(name); sessionStorage.removeItem(name);
} }
} }
export function getParameterByName(name, url) {
if (!url) url = window.location.href;
name = name.replace(/[\[\]]/g, '\\$&');
var regex = new RegExp('[?&]' + name + '(=([^&#]*)|&|#|$)'),
results = regex.exec(url);
if (!results) return null;
if (!results[2]) return '';
return decodeURIComponent(results[2].replace(/\+/g, ' '));
}

View file

@ -15,6 +15,8 @@ import { initDomainFormTargetBlankButtons } from './domain-form.js';
import { initDynamicPortfolioFields } from './portfolio-form.js'; import { initDynamicPortfolioFields } from './portfolio-form.js';
import { initDynamicDomainInformationFields } from './domain-information-form.js'; import { initDynamicDomainInformationFields } from './domain-information-form.js';
import { initDynamicDomainFields } from './domain-form.js'; import { initDynamicDomainFields } from './domain-form.js';
import { initAnalyticsDashboard } from './analytics.js';
import { initButtonLinks } from './button-utils.js';
// General // General
initModals(); initModals();
@ -22,6 +24,7 @@ initCopyToClipboard();
initFilterHorizontalWidget(); initFilterHorizontalWidget();
initDescriptions(); initDescriptions();
initSubmitBar(); initSubmitBar();
initButtonLinks();
// Domain request // Domain request
initIneligibleModal(); initIneligibleModal();
@ -41,3 +44,6 @@ initDynamicPortfolioFields();
// Domain information // Domain information
initDynamicDomainInformationFields(); initDynamicDomainInformationFields();
// Analytics dashboard
initAnalyticsDashboard();

View file

@ -2,11 +2,41 @@ import { submitForm } from './helpers.js';
export function initDomainRequestForm() { export function initDomainRequestForm() {
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
const button = document.getElementById("domain-request-form-submit-button"); // These are the request steps in DomainRequestWizard, such as current_websites or review
if (button) { initRequestStepCurrentWebsitesListener();
button.addEventListener("click", function () { initRequestStepReviewListener();
submitForm("submit-domain-request-form");
});
}
}); });
}
function initRequestStepReviewListener() {
const button = document.getElementById("domain-request-form-submit-button");
if (button) {
button.addEventListener("click", function () {
submitForm("submit-domain-request-form");
});
}
}
function initRequestStepCurrentWebsitesListener() {
//register-form-step
const addAnotherSiteButton = document.getElementById("submit-domain-request--site-button");
if (addAnotherSiteButton) {
// Check for focus state in sessionStorage
const focusTarget = sessionStorage.getItem("lastFocusedElement");
if (focusTarget) {
document.querySelector(focusTarget)?.focus();
}
// Add form submit handler to store focus state
const form = document.querySelector("form");
if (form) {
form.addEventListener("submit", () => {
const activeElement = document.activeElement;
if (activeElement) {
sessionStorage.setItem("lastFocusedElement", "#" + activeElement.id);
}
});
}
// We only want to do this action once, so we clear out the session
sessionStorage.removeItem("lastFocusedElement");
}
} }

View file

@ -96,3 +96,14 @@ export function submitForm(form_id) {
console.error("Form '" + form_id + "' not found."); console.error("Form '" + form_id + "' not found.");
} }
} }
/**
* Helper function to strip HTML tags
* THIS IS NOT SUITABLE FOR SANITIZING DANGEROUS STRINGS
*/
export function unsafeStripHtmlTags(input) {
const tempDiv = document.createElement("div");
// NOTE: THIS IS NOT SUITABLE FOR SANITIZING DANGEROUS STRINGS
tempDiv.innerHTML = input;
return tempDiv.textContent || tempDiv.innerText || "";
}

View file

@ -18,7 +18,7 @@ export function initPortfolioNewMemberPageToggle() {
const unique_id = `${member_type}-${member_id}`; const unique_id = `${member_type}-${member_id}`;
let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member";
wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `More Options for ${member_name}`); wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `More Options for ${member_name}`, "usa-icon--large");
// This easter egg is only for fixtures that dont have names as we are displaying their emails // This easter egg is only for fixtures that dont have names as we are displaying their emails
// All prod users will have emails linked to their account // All prod users will have emails linked to their account
@ -100,8 +100,8 @@ export function initAddNewMemberPageListeners() {
const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`);
permissionSections.forEach(section => { permissionSections.forEach(section => {
// Find the <h3> element text // Find the <h3> element text, strip out the '*'
const sectionTitle = section.textContent; const sectionTitle = section.textContent.trim().replace(/\*$/, "") + ": ";
// Find the associated radio buttons container (next fieldset) // Find the associated radio buttons container (next fieldset)
const fieldset = section.nextElementSibling; const fieldset = section.nextElementSibling;
@ -128,25 +128,29 @@ export function initAddNewMemberPageListeners() {
}); });
} else { } else {
// for admin users, the permissions are always the same // for admin users, the permissions are always the same
appendPermissionInContainer('Domains', 'Viewer, all', permissionDetailsContainer); appendPermissionInContainer('Domains: ', 'Viewer', permissionDetailsContainer);
appendPermissionInContainer('Domain requests', 'Creator', permissionDetailsContainer); appendPermissionInContainer('Domain requests: ', 'Creator', permissionDetailsContainer);
appendPermissionInContainer('Members', 'Manager', permissionDetailsContainer); appendPermissionInContainer('Members: ', 'Manager', permissionDetailsContainer);
} }
} }
function appendPermissionInContainer(sectionTitle, permissionDisplay, permissionContainer) { function appendPermissionInContainer(sectionTitle, permissionDisplay, permissionContainer) {
// Create new elements for the content // Create new elements for the content
const titleElement = document.createElement("h4"); const elementContainer = document.createElement("p");
titleElement.textContent = sectionTitle; elementContainer.classList.add("margin-top-0", "margin-bottom-1");
titleElement.classList.add("text-primary", "margin-bottom-0");
const permissionElement = document.createElement("p"); const titleElement = document.createElement("strong");
titleElement.textContent = sectionTitle;
titleElement.classList.add("text-primary-darker");
const permissionElement = document.createElement("span");
permissionElement.textContent = permissionDisplay; permissionElement.textContent = permissionDisplay;
permissionElement.classList.add("margin-top-0");
// Append to the content container // Append to the content container
permissionContainer.appendChild(titleElement); elementContainer.appendChild(titleElement);
permissionContainer.appendChild(permissionElement); elementContainer.appendChild(permissionElement);
permissionContainer.appendChild(elementContainer);
} }
/* /*

View file

@ -79,13 +79,13 @@ export function addModal(id, ariaLabelledby, ariaDescribedby, modalHeading, moda
* @param {string} modal_button_text - The action button's text * @param {string} modal_button_text - The action button's text
* @param {string} screen_reader_text - A screen reader helper * @param {string} screen_reader_text - A screen reader helper
*/ */
export function generateKebabHTML(action, unique_id, modal_button_text, screen_reader_text) { export function generateKebabHTML(action, unique_id, modal_button_text, screen_reader_text, icon_class) {
const generateModalButton = (mobileOnly = false) => ` const generateModalButton = (mobileOnly = false) => `
<a <a
role="button" role="button"
id="button-trigger-${action}-${unique_id}" id="button-trigger-${action}-${unique_id}"
href="#toggle-${action}-${unique_id}" href="#toggle-${action}-${unique_id}"
class="usa-button usa-button--unstyled text-no-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary ${mobileOnly ? 'visible-mobile-flex' : ''}" class="usa-button usa-button--unstyled text-underline late-loading-modal-trigger margin-top-2 line-height-sans-5 text-secondary ${mobileOnly ? 'visible-mobile-flex' : ''}"
aria-controls="toggle-${action}-${unique_id}" aria-controls="toggle-${action}-${unique_id}"
data-open-modal data-open-modal
> >
@ -99,7 +99,7 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
// Main kebab structure // Main kebab structure
const kebab = ` const kebab = `
${generateModalButton(true)} <!-- Mobile button --> ${generateModalButton(true)} <!-- Mobile button -->
<div class="usa-accordion usa-accordion--more-actions margin-right-2 hidden-mobile-flex"> <div class="usa-accordion usa-accordion--more-actions margin-right-2 margin-top-3px hidden-mobile-flex">
<div class="usa-accordion__heading"> <div class="usa-accordion__heading">
<button <button
type="button" type="button"
@ -108,12 +108,12 @@ export function generateKebabHTML(action, unique_id, modal_button_text, screen_r
aria-controls="more-actions-${unique_id}" aria-controls="more-actions-${unique_id}"
aria-label="${screen_reader_text}" aria-label="${screen_reader_text}"
> >
<svg class="usa-icon top-2px" aria-hidden="true" focusable="false" role="img" width="24"> <svg class="usa-icon${icon_class ? " " + icon_class : ""}" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#more_vert"></use> <use xlink:href="/public/img/sprite.svg#more_vert"></use>
</svg> </svg>
</button> </button>
</div> </div>
<div id="more-actions-${unique_id}" class="usa-accordion__content usa-prose shadow-1 left-auto right-neg-1" hidden> <div id="more-actions-${unique_id}" class="usa-accordion__content usa-prose shadow-1 left-auto right-0${icon_class === 'usa-icon--large' ? ' top-28px' : ''}" hidden>
<h2>More options</h2> <h2>More options</h2>
${generateModalButton()} <!-- Desktop button --> ${generateModalButton()} <!-- Desktop button -->
</div> </div>

View file

@ -1,4 +1,4 @@
import { hideElement, showElement, getCsrfToken } from './helpers.js'; import { hideElement, showElement, getCsrfToken, unsafeStripHtmlTags } from './helpers.js';
import { uswdsInitializeModals, uswdsUnloadModals } from './helpers-uswds.js'; import { uswdsInitializeModals, uswdsUnloadModals } from './helpers-uswds.js';
import { BaseTable, addModal, generateKebabHTML } from './table-base.js'; import { BaseTable, addModal, generateKebabHTML } from './table-base.js';
@ -98,9 +98,10 @@ export class DomainRequestsTable extends BaseTable {
// Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly // Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly
if (this.portfolioValue) { if (this.portfolioValue) {
// NOTE: THIS IS NOT SUITABLE FOR SANITIZING DANGEROUS STRINGS
const sanitizedDomainName = unsafeStripHtmlTags(domainName);
// 2nd path (org model): Just a modal trigger on mobile for org users or kebab + accordion with nested modal trigger on desktop for org users // 2nd path (org model): Just a modal trigger on mobile for org users or kebab + accordion with nested modal trigger on desktop for org users
modalTrigger = generateKebabHTML('delete-domain', request.id, 'Delete', domainName); modalTrigger = generateKebabHTML('delete-domain', request.id, 'Delete', sanitizedDomainName);
} }
} }
@ -117,7 +118,7 @@ export class DomainRequestsTable extends BaseTable {
${request.status} ${request.status}
</td> </td>
<td class="width--action-column"> <td class="width--action-column">
<div class="tablet:display-flex tablet:flex-row flex-wrap"> <div class="tablet:display-flex tablet:flex-row">
<a href="${actionUrl}" ${customTableOptions.hasAdditionalActions ? "class='margin-right-2'" : ''}> <a href="${actionUrl}" ${customTableOptions.hasAdditionalActions ? "class='margin-right-2'" : ''}>
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24"> <svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#${request.svg_icon}"></use> <use xlink:href="/public/img/sprite.svg#${request.svg_icon}"></use>

View file

@ -23,10 +23,13 @@ export class EditMemberDomainsTable extends BaseTable {
this.readonlyModeContainer = document.getElementById('domain-assignments-readonly-view'); this.readonlyModeContainer = document.getElementById('domain-assignments-readonly-view');
this.reviewButton = document.getElementById('review-domain-assignments'); this.reviewButton = document.getElementById('review-domain-assignments');
this.backButton = document.getElementById('back-to-edit-domain-assignments'); this.backButton = document.getElementById('back-to-edit-domain-assignments');
this.saveButton = document.getElementById('save-domain-assignments'); this.saveButton = document.getElementById('save-domain-assignments');
this.initializeDomainAssignments(); }
async init() {
await this.initializeDomainAssignments();
this.initCancelEditDomainAssignmentButton(); this.initCancelEditDomainAssignmentButton();
this.initEventListeners(); this.initEventListeners();
return this;
} }
getBaseUrl() { getBaseUrl() {
return document.getElementById("get_member_domains_json_url"); return document.getElementById("get_member_domains_json_url");
@ -134,27 +137,33 @@ export class EditMemberDomainsTable extends BaseTable {
* member. It populates both initialDomainAssignments and initialDomainAssignmentsOnlyMember. * member. It populates both initialDomainAssignments and initialDomainAssignmentsOnlyMember.
* It is called once per page load, but not called with subsequent table changes. * It is called once per page load, but not called with subsequent table changes.
*/ */
initializeDomainAssignments() { async initializeDomainAssignments() {
const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null; const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null;
if (!baseUrlValue) return; if (!baseUrlValue) {
let searchParams = this.getDomainAssignmentSearchParams(this.portfolioValue); console.error("Base URL not found");
let url = baseUrlValue + "?" + searchParams.toString();
fetch(url)
.then(response => response.json())
.then(data => {
if (data.error) {
console.error('Error in AJAX call: ' + data.error);
return; return;
}
try {
let searchParams = this.getDomainAssignmentSearchParams(this.portfolioValue);
let url = baseUrlValue + "?" + searchParams.toString();
let response = await fetch(url);
let data = await response.json();
if (data.error) {
console.error("Error in AJAX call:", data.error);
return;
} }
let dataObjects = this.getDataObjects(data); let dataObjects = this.getDataObjects(data);
// Map the id attributes of dataObjects to this.initialDomainAssignments
this.initialDomainAssignments = dataObjects.map(obj => obj.id); this.initialDomainAssignments = dataObjects.map(obj => obj.id);
this.initialDomainAssignmentsOnlyMember = dataObjects this.initialDomainAssignmentsOnlyMember = dataObjects
.filter(obj => obj.member_is_only_manager) .filter(obj => obj.member_is_only_manager)
.map(obj => obj.id); .map(obj => obj.id);
}) } catch (error) {
.catch(error => console.error('Error fetching domain assignments:', error)); console.error("Error fetching domain assignments:", error);
}
} }
/** /**
* Initializes listeners on checkboxes in the table. Checkbox listeners are used * Initializes listeners on checkboxes in the table. Checkbox listeners are used
@ -232,8 +241,6 @@ export class EditMemberDomainsTable extends BaseTable {
} }
updateReadonlyDisplay() { updateReadonlyDisplay() {
let totalAssignedDomains = this.getCheckedDomains().length;
// Create unassigned domains list // Create unassigned domains list
const unassignedDomainsList = document.createElement('ul'); const unassignedDomainsList = document.createElement('ul');
unassignedDomainsList.classList.add('usa-list', 'usa-list--unstyled'); unassignedDomainsList.classList.add('usa-list', 'usa-list--unstyled');
@ -260,35 +267,30 @@ export class EditMemberDomainsTable extends BaseTable {
// Clear existing content // Clear existing content
domainAssignmentSummary.innerHTML = ''; domainAssignmentSummary.innerHTML = '';
// Append unassigned domains section
if (this.removedDomains.length) {
const unassignedHeader = document.createElement('h3');
unassignedHeader.classList.add('margin-bottom-05', 'h4');
unassignedHeader.textContent = 'Unassigned domains';
domainAssignmentSummary.appendChild(unassignedHeader);
domainAssignmentSummary.appendChild(unassignedDomainsList);
}
// Append assigned domains section // Append assigned domains section
if (this.addedDomains.length) { if (this.addedDomains.length) {
const assignedHeader = document.createElement('h3'); const assignedHeader = document.createElement('h3');
// Make this h3 look like a h4 // Make this h3 look like a h4
assignedHeader.classList.add('margin-bottom-05', 'h4'); assignedHeader.classList.add('margin-bottom-05', 'h4');
assignedHeader.textContent = 'Assigned domains'; assignedHeader.textContent = `New assignments (${this.addedDomains.length})`;
domainAssignmentSummary.appendChild(assignedHeader); domainAssignmentSummary.appendChild(assignedHeader);
domainAssignmentSummary.appendChild(assignedDomainsList); domainAssignmentSummary.appendChild(assignedDomainsList);
} }
// Append total assigned domains section // Append unassigned domains section
const totalHeader = document.createElement('h3'); if (this.removedDomains.length) {
// Make this h3 look like a h4 const unassignedHeader = document.createElement('h3');
totalHeader.classList.add('margin-bottom-05', 'h4'); unassignedHeader.classList.add('margin-bottom-05', 'h4');
totalHeader.textContent = 'Total assigned domains'; unassignedHeader.textContent =`Removed assignments (${this.removedDomains.length})`;
domainAssignmentSummary.appendChild(totalHeader); domainAssignmentSummary.appendChild(unassignedHeader);
const totalCount = document.createElement('p'); domainAssignmentSummary.appendChild(unassignedDomainsList);
totalCount.classList.add('margin-y-0'); }
totalCount.textContent = totalAssignedDomains;
domainAssignmentSummary.appendChild(totalCount); if (!this.addedDomains.length && !this.removedDomains.length) {
const noChangesParagraph = document.createElement('p');
noChangesParagraph.textContent = "No changes were detected. Click the “Back” button to edit this members domain assignments.";
domainAssignmentSummary.appendChild(noChangesParagraph);
}
} }
showReadonlyMode() { showReadonlyMode() {
@ -355,14 +357,14 @@ export class EditMemberDomainsTable extends BaseTable {
} }
export function initEditMemberDomainsTable() { export function initEditMemberDomainsTable() {
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', async function() {
const isEditMemberDomainsPage = document.getElementById("edit-member-domains"); const isEditMemberDomainsPage = document.getElementById("edit-member-domains");
if (isEditMemberDomainsPage) { if (!isEditMemberDomainsPage) return; // Exit if not on the right page
const editMemberDomainsTable = new EditMemberDomainsTable();
if (editMemberDomainsTable.tableWrapper) { const editMemberDomainsTable = await new EditMemberDomainsTable().init();
// Initial load
editMemberDomainsTable.loadTable(1); if (editMemberDomainsTable.tableWrapper) {
} editMemberDomainsTable.loadTable(1); // Initial load
} }
}); });
} }

View file

@ -69,13 +69,14 @@ export class MembersTable extends BaseTable {
const kebabHTML = customTableOptions.hasAdditionalActions ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `Expand for more options for ${member.name}`): ''; const kebabHTML = customTableOptions.hasAdditionalActions ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `Expand for more options for ${member.name}`): '';
const row = document.createElement('tr'); const row = document.createElement('tr');
row.classList.add('hide-td-borders');
let admin_tagHTML = ``; let admin_tagHTML = ``;
if (member.is_admin) if (member.is_admin)
admin_tagHTML = `<span class="usa-tag margin-left-1 bg-primary-dark text-semibold">Admin</span>` admin_tagHTML = `<span class="usa-tag margin-left-1 bg-primary-dark text-semibold">Admin</span>`
// generate html blocks for domains and permissions for the member // generate html blocks for domains and permissions for the member
let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url); let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url, unique_id);
let permissionsHTML = this.generatePermissionsHTML(member.permissions, customTableOptions.UserPortfolioPermissionChoices); let permissionsHTML = this.generatePermissionsHTML(member.is_admin, member.permissions, customTableOptions.UserPortfolioPermissionChoices, unique_id);
// domainsHTML block and permissionsHTML block need to be wrapped with hide/show toggle, Expand // domainsHTML block and permissionsHTML block need to be wrapped with hide/show toggle, Expand
let showMoreButton = ''; let showMoreButton = '';
@ -96,28 +97,34 @@ export class MembersTable extends BaseTable {
</button> </button>
`; `;
showMoreRow.innerHTML = `<td colspan='3' headers="header-member row-header-${unique_id}" class="padding-top-0"><div class='grid-row'>${domainsHTML} ${permissionsHTML}</div></td>`; showMoreRow.innerHTML = `
showMoreRow.classList.add('show-more-content'); <td colspan='4' headers="header-member row-header-${unique_id}" class="padding-top-0">
showMoreRow.classList.add('display-none'); ${showMoreButton}
<div class='grid-row grid-gap-2 show-more-content display-none'>
${domainsHTML}
${permissionsHTML}
</div>
</td>
`;
showMoreRow.id = unique_id; showMoreRow.id = unique_id;
} }
row.innerHTML = ` row.innerHTML = `
<th role="rowheader" headers="header-member" data-label="member email" id='row-header-${unique_id}'> <th class="padding-bottom-0" role="rowheader" headers="header-member" data-label="Member" id='row-header-${unique_id}'>
${member.member_display} ${admin_tagHTML} ${showMoreButton} ${member.member_display} ${admin_tagHTML}
</th> </th>
<td headers="header-last-active row-header-${unique_id}" data-sort-value="${last_active.sort_value}" data-label="last_active"> <td class="padding-bottom-0" headers="header-last-active row-header-${unique_id}" data-sort-value="${last_active.sort_value}" data-label="Last active">
${last_active.display_value} ${last_active.display_value}
</td> </td>
<td headers="header-action row-header-${unique_id}" class="width--action-column"> <td class="padding-bottom-0" headers="header-action row-header-${unique_id}" class="width--action-column">
<div class="tablet:display-flex tablet:flex-row flex-align-center"> <div class="tablet:display-flex tablet:flex-row flex-align-center">
<a href="${member.action_url}"> <a href="${member.action_url}" ${customTableOptions.hasAdditionalActions ? "class='margin-right-2'" : ''}>
<svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24"> <svg class="usa-icon top-1px" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="/public/img/sprite.svg#${member.svg_icon}"></use> <use xlink:href="/public/img/sprite.svg#${member.svg_icon}"></use>
</svg> </svg>
${member.action_label} <span class="usa-sr-only">${member.name}</span> ${member.action_label} <span class="usa-sr-only">${member.name}</span>
</a> </a>
<span class="padding-left-1">${customTableOptions.hasAdditionalActions ? kebabHTML : ''}</span> ${customTableOptions.hasAdditionalActions ? kebabHTML : ''}
</div> </div>
</td> </td>
`; `;
@ -146,16 +153,15 @@ export class MembersTable extends BaseTable {
* *
* @param {HTMLElement} toggleButton - The button that toggles the content visibility. * @param {HTMLElement} toggleButton - The button that toggles the content visibility.
* @param {HTMLElement} contentDiv - The content div whose visibility is toggled. * @param {HTMLElement} contentDiv - The content div whose visibility is toggled.
* @param {HTMLElement} buttonParentRow - The parent row element containing the button.
*/ */
function toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow) { function toggleShowMoreButton(toggleButton, contentDiv) {
const spanElement = toggleButton.querySelector('span'); const spanElement = toggleButton.querySelector('span');
const useElement = toggleButton.querySelector('use'); const useElement = toggleButton.querySelector('use');
if (contentDiv.classList.contains('display-none')) { if (contentDiv.classList.contains('display-none')) {
showElement(contentDiv); showElement(contentDiv);
spanElement.textContent = 'Close'; spanElement.textContent = 'Close';
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less'); useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less');
buttonParentRow.classList.add('hide-td-borders'); toggleButton.classList.add('margin-bottom-2');
let ariaLabelText = "Close additional information"; let ariaLabelText = "Close additional information";
let ariaLabelPlaceholder = toggleButton.getAttribute("aria-label-placeholder"); let ariaLabelPlaceholder = toggleButton.getAttribute("aria-label-placeholder");
@ -169,7 +175,7 @@ export class MembersTable extends BaseTable {
hideElement(contentDiv); hideElement(contentDiv);
spanElement.textContent = 'Expand'; spanElement.textContent = 'Expand';
useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more'); useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more');
buttonParentRow.classList.remove('hide-td-borders'); toggleButton.classList.remove('margin-bottom-2');
let ariaLabelText = "Expand for additional information"; let ariaLabelText = "Expand for additional information";
let ariaLabelPlaceholder = toggleButton.getAttribute("aria-label-placeholder"); let ariaLabelPlaceholder = toggleButton.getAttribute("aria-label-placeholder");
@ -182,14 +188,11 @@ export class MembersTable extends BaseTable {
let toggleButtons = document.querySelectorAll('.usa-button--show-more-button'); let toggleButtons = document.querySelectorAll('.usa-button--show-more-button');
toggleButtons.forEach((toggleButton) => { toggleButtons.forEach((toggleButton) => {
// get contentDiv for element specified in data-for attribute of toggleButton
let dataFor = toggleButton.dataset.for;
let contentDiv = document.getElementById(dataFor);
let buttonParentRow = toggleButton.parentElement.parentElement; let buttonParentRow = toggleButton.parentElement.parentElement;
if (contentDiv && contentDiv.tagName.toLowerCase() === 'tr' && contentDiv.classList.contains('show-more-content') && buttonParentRow && buttonParentRow.tagName.toLowerCase() === 'tr') { let contentDiv = buttonParentRow.querySelector(".show-more-content");
if (contentDiv && buttonParentRow && buttonParentRow.tagName.toLowerCase() === 'tr') {
toggleButton.addEventListener('click', function() { toggleButton.addEventListener('click', function() {
toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow); toggleShowMoreButton(toggleButton, contentDiv);
}); });
} else { } else {
console.warn('Found a toggle button with no associated toggleable content or parent row'); console.warn('Found a toggle button with no associated toggleable content or parent row');
@ -240,32 +243,42 @@ export class MembersTable extends BaseTable {
* @param {number} num_domains - The number of domains the member is assigned to. * @param {number} num_domains - The number of domains the member is assigned to.
* @param {Array} domain_names - An array of domain names. * @param {Array} domain_names - An array of domain names.
* @param {Array} domain_urls - An array of corresponding domain URLs. * @param {Array} domain_urls - An array of corresponding domain URLs.
* @param {Array} unique_id - A unique row id.
* @returns {string} - A string of HTML displaying the domains assigned to the member. * @returns {string} - A string of HTML displaying the domains assigned to the member.
*/ */
generateDomainsHTML(num_domains, domain_names, domain_urls, action_url) { generateDomainsHTML(num_domains, domain_names, domain_urls, action_url, unique_id) {
// Initialize an empty string for the HTML // Initialize an empty string for the HTML
let domainsHTML = ''; let domainsHTML = '';
// Only generate HTML if the member has one or more assigned domains // Only generate HTML if the member has one or more assigned domains
domainsHTML += "<div class='desktop:grid-col-4 margin-bottom-2 desktop:margin-bottom-0'>";
domainsHTML += `<h4 id='domains-assigned--heading-${unique_id}' class='font-body-xs margin-y-0'>Domains assigned</h4>`;
domainsHTML += `<section aria-labelledby='domains-assigned--heading-${unique_id}' tabindex='0'>`
if (num_domains > 0) { if (num_domains > 0) {
domainsHTML += "<div class='desktop:grid-col-5 margin-bottom-2 desktop:margin-bottom-0'>"; domainsHTML += `<p class='font-body-xs text-base-darker margin-y-0'>This member is assigned to ${num_domains} domain${num_domains > 1 ? 's' : ''}:</p>`;
domainsHTML += "<h4 class='font-body-xs margin-y-0'>Domains assigned</h4>"; if (num_domains > 1) {
domainsHTML += `<p class='font-body-xs text-base-dark margin-y-0'>This member is assigned to ${num_domains} domain${num_domains > 1 ? 's' : ''}:</p>`; domainsHTML += "<ul class='usa-list usa-list--unstyled margin-y-0'>";
domainsHTML += "<ul class='usa-list usa-list--unstyled margin-y-0'>";
// Display up to 6 domains with their URLs // Display up to 6 domains with their URLs
for (let i = 0; i < num_domains && i < 6; i++) { for (let i = 0; i < num_domains && i < 6; i++) {
domainsHTML += `<li><a class="font-body-xs" href="${domain_urls[i]}">${domain_names[i]}</a></li>`; domainsHTML += `<li><a class="font-body-xs" href="${domain_urls[i]}">${domain_names[i]}</a></li>`;
}
domainsHTML += "</ul>";
} else {
// We don't display this in a list for better screenreader support, when only one item exists.
domainsHTML += `<a class="font-body-xs" href="${domain_urls[0]}">${domain_names[0]}</a>`;
} }
} else {
domainsHTML += "</ul>"; domainsHTML += `<p class='font-body-xs text-base-darker margin-y-0'>This member is assigned to 0 domains.</p>`;
// If there are more than 6 domains, display a "View assigned domains" link
domainsHTML += `<p class="font-body-xs"><a href="${action_url}/domains">View assigned domains</a></p>`;
domainsHTML += "</div>";
} }
// If there are more than 6 domains, display a "View assigned domains" link
domainsHTML += `<p class="font-body-xs"><a href="${action_url}/domains">View domain assignments</a></p>`;
domainsHTML += "</section>"
domainsHTML += "</div>";
return domainsHTML; return domainsHTML;
} }
@ -362,7 +375,7 @@ export class MembersTable extends BaseTable {
* - VIEW_ALL_REQUESTS * - VIEW_ALL_REQUESTS
* - EDIT_MEMBERS * - EDIT_MEMBERS
* - VIEW_MEMBERS * - VIEW_MEMBERS
* * @param {String} unique_id
* @returns {string} - A string of HTML representing the user's additional permissions. * @returns {string} - A string of HTML representing the user's additional permissions.
* If the user has no specific permissions, it returns a default message * If the user has no specific permissions, it returns a default message
* indicating no additional permissions. * indicating no additional permissions.
@ -377,40 +390,51 @@ export class MembersTable extends BaseTable {
* - If no relevant permissions are found, the function returns a message stating that the user has no additional permissions. * - If no relevant permissions are found, the function returns a message stating that the user has no additional permissions.
* - The resulting HTML always includes a header "Additional permissions for this member" and appends the relevant permission descriptions. * - The resulting HTML always includes a header "Additional permissions for this member" and appends the relevant permission descriptions.
*/ */
generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices) { generatePermissionsHTML(is_admin, member_permissions, UserPortfolioPermissionChoices, unique_id) {
let permissionsHTML = ''; // 1. Role
const memberAccessValue = is_admin ? "Admin" : "Basic";
// Define shared classes across elements for easier refactoring
let sharedParagraphClasses = "font-body-xs text-base-dark margin-top-1 p--blockquote"; // 2. Domain access
let domainValue = "No access";
// Check domain-related permissions
if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)) { if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domains:</strong> Can view all organization domains. Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>`; domainValue = "Viewer";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)) { } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domains:</strong> Can manage domains they are assigned to and edit information about the domain (including DNS settings).</p>`; domainValue = "Viewer, limited";
} }
// Check request-related permissions // 3. Request access
let requestValue = "No access";
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_REQUESTS)) { if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_REQUESTS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domain requests:</strong> Can view all organization domain requests. Can create domain requests and modify their own requests.</p>`; requestValue = "Creator";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)) { } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Domain requests (view-only):</strong> Can view all organization domain requests. Can't create or modify any domain requests.</p>`; requestValue = "Viewer";
} }
// Check member-related permissions // 4. Member access
let memberValue = "No access";
if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) { if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Members:</strong> Can manage members including inviting new members, removing current members, and assigning domains to members.</p>`; memberValue = "Manager";
} else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) { } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) {
permissionsHTML += `<p class='${sharedParagraphClasses}'><strong class='text-base-dark'>Members (view-only):</strong> Can view all organizational members. Can't manage any members.</p>`; memberValue = "Viewer";
} }
// If no specific permissions are assigned, display a message indicating no additional permissions // Helper function for faster element refactoring
if (!permissionsHTML) { const createPermissionItem = (label, value) => {
permissionsHTML += `<p class='${sharedParagraphClasses}'><b>No additional permissions:</b> There are no additional permissions for this member.</p>`; return `<p class="font-body-xs text-base-darker margin-top-1 p--blockquote">${label}: <strong>${value}</strong></p>`;
} };
const permissionsHTML = `
// Add a permissions header and wrap the entire output in a container <div class="desktop:grid-col-8">
permissionsHTML = `<div class='desktop:grid-col-7'><h4 class='font-body-xs margin-y-0'>Additional permissions for this member</h4>${permissionsHTML}</div>`; <h4 id="member-access--heading-${unique_id}" class="font-body-xs margin-y-0">
Member access and permissions
</h4>
<section aria-labelledby="member-access--heading-${unique_id}" tabindex="0">
${createPermissionItem("Member access", memberAccessValue)}
${createPermissionItem("Domains", domainValue)}
${createPermissionItem("Domain requests", requestValue)}
${createPermissionItem("Members", memberValue)}
</section>
</div>
`;
return permissionsHTML; return permissionsHTML;
} }

View file

@ -5,17 +5,26 @@
display: inline-block; display: inline-block;
width: auto; width: auto;
position: relative; position: relative;
.usa-accordion__button {
border-radius: units(.5);
}
.usa-accordion__button:focus {
outline-offset: 0;
outline-width: 3px;
}
.usa-accordion__button[aria-expanded=false], .usa-accordion__button[aria-expanded=false],
.usa-accordion__button[aria-expanded=false]:hover, .usa-accordion__button[aria-expanded=false]:hover,
.usa-accordion__button[aria-expanded=true], .usa-accordion__button[aria-expanded=true],
.usa-accordion__button[aria-expanded=true]:hover { .usa-accordion__button[aria-expanded=true]:hover {
background-image: none; background-image: none;
} }
.usa-accordion__button[aria-expanded=true] {
background-color: color('primary-lighter');
}
.usa-accordion__content { .usa-accordion__content {
// Note, width is determined by a custom width class on one of the children // Note, width is determined by a custom width class on one of the children
position: absolute; position: absolute;
z-index: 1; z-index: 1;
left: 0;
border-radius: 4px; border-radius: 4px;
border: solid 1px color('base-lighter'); border: solid 1px color('base-lighter');
padding: units(2) units(2) units(3) units(2); padding: units(2) units(2) units(3) units(2);
@ -32,22 +41,37 @@
} }
} }
// This will work in responsive tables if we overwrite the overflow value on the table container
// Works with styles in _tables
@include at-media(desktop) {
.usa-accordion--more-actions .usa-accordion__content {
left: 0;
}
}
.usa-accordion--select .usa-accordion__content { .usa-accordion--select .usa-accordion__content {
top: 33.88px; top: 33.88px;
} }
.usa-accordion--more-actions .usa-accordion__content { .usa-accordion--more-actions .usa-accordion__content {
top: 30px; // We need to match the height of the trigger button
// to align the 'popup' underneath
top: 20px;
&.top-28px {
top: 28px;
}
} }
// Special positioning for the kabob menu popup in the last row on a given page // Special positioning for the kabob menu popup in the last row on a given page
// This won't work on the Members table rows because that table has show-more rows // This won't work on the Members table rows because that table has show-more rows
// Currently, that's not an issue since that Members table is not wrapped in the // Currently, that's not an issue since that Members table is not wrapped in the
// reponsive wrapper. // reponsive wrapper.
tr:last-of-type .usa-accordion--more-actions .usa-accordion__content { @include at-media-max("desktop") {
top: auto; tr:last-of-type .usa-accordion--more-actions .usa-accordion__content {
bottom: -10px; top: auto;
right: 30px; bottom: -10px;
right: 30px;
}
} }
// A CSS only show-more/show-less based on usa-accordion // A CSS only show-more/show-less based on usa-accordion

View file

@ -498,15 +498,36 @@ input[type=submit].button--dja-toolbar:focus, input[type=submit].button--dja-too
font-size: 13px; font-size: 13px;
} }
.module--custom { .object-tools li button, button.addlink {
a { font-family: Source Sans Pro Web, Helvetica Neue, Helvetica, Roboto, Arial, sans-serif;
font-size: 13px; text-transform: none !important;
font-weight: 600; font-size: 14px !important;
border: solid 1px var(--darkened-bg); display: block;
background: var(--darkened-bg); float: left;
padding: 3px 12px;
background: var(--object-tools-bg) !important;
color: var(--object-tools-fg);
font-weight: 400;
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
border-radius: 15px;
cursor: pointer;
border: none;
line-height: 20px;
&:focus, &:hover{
background: var(--object-tools-hover-bg) !important;
} }
} }
// Mimic the style for <a>
.object-tools > p > button.addlink {
background-image: url(../admin/img/tooltag-add.svg) !important;
background-repeat: no-repeat !important;
background-position: right 7px center !important;
padding-right: 25px;
}
.usa-modal--django-admin .usa-prose ul > li { .usa-modal--django-admin .usa-prose ul > li {
list-style-type: inherit; list-style-type: inherit;
// Styling based off of the <p> styling in django admin // Styling based off of the <p> styling in django admin
@ -536,13 +557,18 @@ details.dja-detail-table {
background-color: transparent; background-color: transparent;
} }
thead tr {
background-color: var(--darkened-bg);
}
td, th { td, th {
padding-left: 12px; padding-left: 12px;
border: none border: none;
background-color: var(--darkened-bg);
color: var(--body-quiet-color);
} }
thead > tr > th { thead > tr > th {
border-radius: 4px;
border-top: none; border-top: none;
border-bottom: none; border-bottom: none;
} }
@ -812,6 +838,17 @@ div.dja__model-description{
text-transform: capitalize; text-transform: capitalize;
} }
.module caption {
// Match the old <h2> size for django admin
font-size: 0.8125rem;
}
// text-bold doesn't work here due to style overrides, unfortunately.
// This is a workaround.
caption.text-bold {
font-weight: font-weight('bold');
}
.wrapped-button-group { .wrapped-button-group {
// This button group has too many items // This button group has too many items
flex-wrap: wrap; flex-wrap: wrap;
@ -924,3 +961,38 @@ ul.add-list-reset {
background-color: transparent !important; background-color: transparent !important;
} }
} }
@media (min-width: 1080px) {
.analytics-dashboard-charts {
// Desktop layout - charts in top row, details in bottom row
display: grid;
gap: 2rem;
// Equal columns each gets 1/2 of the space
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
grid-template-areas:
"chart1 chart2"
"details1 details2"
"chart3 chart4"
"details3 details4"
"chart5 chart6"
"details5 details6";
.chart-1 { grid-area: chart1; }
.chart-2 { grid-area: chart2; }
.chart-3 { grid-area: chart3; }
.chart-4 { grid-area: chart4; }
.chart-5 { grid-area: chart5; }
.chart-6 { grid-area: chart6; }
.details-1 { grid-area: details1; }
.details-2 { grid-area: details2; }
.details-3 { grid-area: details3; }
.details-4 { grid-area: details4; }
.details-5 { grid-area: details5; }
.details-6 { grid-area: details6; }
}
}
#result_list > tbody tr > th > a {
text-decoration: underline;
}

View file

@ -46,7 +46,6 @@ body {
background-color: color('gray-1'); background-color: color('gray-1');
} }
.section-outlined { .section-outlined {
background-color: color('white'); background-color: color('white');
border: 1px solid color('base-lighter'); border: 1px solid color('base-lighter');
@ -227,11 +226,6 @@ abbr[title] {
} }
} }
// Boost this USWDS utility class for the accordions in the portfolio requests table
.left-auto {
left: auto!important;
}
.usa-banner__inner--widescreen { .usa-banner__inner--widescreen {
max-width: $widescreen-max-width; max-width: $widescreen-max-width;
} }
@ -276,6 +270,14 @@ abbr[title] {
width: 25%; width: 25%;
} }
.margin-top-3px {
margin-top: 3px;
}
.top-28px {
top: 28px;
}
/* /*
NOTE: width: 3% basically forces a fit-content effect in the table. NOTE: width: 3% basically forces a fit-content effect in the table.
Fit-content itself does not work. Fit-content itself does not work.

View file

@ -41,13 +41,8 @@ th {
} }
} }
// The member table has an extra "expand" row, which looks like a single row. // .dotgov-table allows us to customize .usa-table on the user-facing pages,
// But the DOM disagrees - so we basically need to hide the border on both rows. // while leaving the default styles for use on the admin pages
#members__table-wrapper .dotgov-table tr:nth-last-child(2) td,
#members__table-wrapper .dotgov-table tr:nth-last-child(2) th {
border-bottom: none;
}
.dotgov-table { .dotgov-table {
width: 100%; width: 100%;
@ -68,7 +63,8 @@ th {
border-bottom: 1px solid color('base-lighter'); border-bottom: 1px solid color('base-lighter');
} }
thead th { thead th,
thead th[aria-sort] {
color: color('primary-darker'); color: color('primary-darker');
border-bottom: 2px solid color('base-light'); border-bottom: 2px solid color('base-light');
} }
@ -93,17 +89,46 @@ th {
} }
} }
@include at-media(tablet-lg) { // Sortable headers
th[data-sortable] .usa-table__header__button { th[data-sortable][aria-sort=ascending],
right: auto; th[data-sortable][aria-sort=descending] {
background-color: transparent;
&[aria-sort=ascending], .usa-table__header__button {
&[aria-sort=descending], background-color: color('accent-cool-lightest');
&:not([aria-sort]) { border-radius: units(.5);
right: auto; color: color('primary-darker');
&:hover {
background-color: color('accent-cool-lightest');
} }
} }
} }
@include at-media(tablet-lg) {
th[data-sortable]:not(.left-align-sort-button) .usa-table__header__button {
// position next to the copy
right: auto;
// slide left to mock a margin between the copy and the icon
transform: translateX(units(1));
// fix vertical alignment
top: units(1.5);
}
th[data-sortable].left-align-sort-button .usa-table__header__button {
left: 0;
}
}
// Currently the 'flash' when sort is clicked,
// this will become persistent if the double-sort bug is fixed
td[data-sort-active],
th[data-sort-active] {
background-color: color('primary-lightest');
}
}
// The member table has an extra "expand" row, which looks like a single row.
// But the DOM disagrees - so we basically need to hide the border on both rows.
#members__table-wrapper .dotgov-table tr:nth-last-child(2) td,
#members__table-wrapper .dotgov-table tr:nth-last-child(2) th {
border-bottom: none;
} }
.dotgov-table--cell-padding-2 { .dotgov-table--cell-padding-2 {
@ -118,7 +143,7 @@ th {
} }
.usa-table--bg-transparent { .usa-table--bg-transparent {
td, thead th { td, th, thead th {
background-color: transparent; background-color: transparent;
} }
} }
@ -127,3 +152,12 @@ th {
.usa-table--full-borderless th { .usa-table--full-borderless th {
border: none !important; border: none !important;
} }
// This is an override to overflow on certain tables (note the custom class)
// so that a popup menu can appear and starddle the edge of the table on large
// screen sizes. Works with styles in _accordions
@include at-media(desktop) {
.usa-table-container--scrollable.usa-table-container--override-overflow {
overflow-y: visible;
}
}

View file

@ -70,6 +70,7 @@ in the form $setting: value,
----------------------------*/ ----------------------------*/
$theme-font-weight-medium: 400, $theme-font-weight-medium: 400,
$theme-font-weight-semibold: 600, $theme-font-weight-semibold: 600,
$theme-font-weight-bold: 700,
/*--------------------------- /*---------------------------
## Font roles ## Font roles

View file

@ -107,6 +107,7 @@ DEBUG = env_debug
# Controls production specific feature toggles # Controls production specific feature toggles
IS_PRODUCTION = env_is_production IS_PRODUCTION = env_is_production
SECRET_ENCRYPT_METADATA = secret_encrypt_metadata SECRET_ENCRYPT_METADATA = secret_encrypt_metadata
BASE_URL = env_base_url
# Applications are modular pieces of code. # Applications are modular pieces of code.
# They are provided by Django, by third-parties, or by yourself. # They are provided by Django, by third-parties, or by yourself.
@ -200,6 +201,8 @@ MIDDLEWARE = [
"waffle.middleware.WaffleMiddleware", "waffle.middleware.WaffleMiddleware",
"registrar.registrar_middleware.CheckUserProfileMiddleware", "registrar.registrar_middleware.CheckUserProfileMiddleware",
"registrar.registrar_middleware.CheckPortfolioMiddleware", "registrar.registrar_middleware.CheckPortfolioMiddleware",
# Restrict access using Opt-Out approach
"registrar.registrar_middleware.RestrictAccessMiddleware",
] ]
# application object used by Django's built-in servers (e.g. `runserver`) # application object used by Django's built-in servers (e.g. `runserver`)

View file

@ -68,7 +68,7 @@ for step, view in [
(PortfolioDomainRequestStep.REQUESTING_ENTITY, views.RequestingEntity), (PortfolioDomainRequestStep.REQUESTING_ENTITY, views.RequestingEntity),
(PortfolioDomainRequestStep.ADDITIONAL_DETAILS, views.PortfolioAdditionalDetails), (PortfolioDomainRequestStep.ADDITIONAL_DETAILS, views.PortfolioAdditionalDetails),
]: ]:
domain_request_urls.append(path(f"<int:id>/{step}/", view.as_view(), name=step)) domain_request_urls.append(path(f"<int:domain_request_pk>/{step}/", view.as_view(), name=step))
urlpatterns = [ urlpatterns = [
@ -260,27 +260,27 @@ urlpatterns = [
name="export_data_type_user", name="export_data_type_user",
), ),
path( path(
"domain-request/<int:id>/edit/", "domain-request/<int:domain_request_pk>/edit/",
views.DomainRequestWizard.as_view(), views.DomainRequestWizard.as_view(),
name=views.DomainRequestWizard.EDIT_URL_NAME, name=views.DomainRequestWizard.EDIT_URL_NAME,
), ),
path( path(
"domain-request/<int:pk>", "domain-request/<int:domain_request_pk>",
views.DomainRequestStatus.as_view(), views.DomainRequestStatus.as_view(),
name="domain-request-status", name="domain-request-status",
), ),
path( path(
"domain-request/viewonly/<int:pk>", "domain-request/viewonly/<int:domain_request_pk>",
views.PortfolioDomainRequestStatusViewOnly.as_view(), views.PortfolioDomainRequestStatusViewOnly.as_view(),
name="domain-request-status-viewonly", name="domain-request-status-viewonly",
), ),
path( path(
"domain-request/<int:pk>/withdraw", "domain-request/<int:domain_request_pk>/withdraw",
views.DomainRequestWithdrawConfirmation.as_view(), views.DomainRequestWithdrawConfirmation.as_view(),
name="domain-request-withdraw-confirmation", name="domain-request-withdraw-confirmation",
), ),
path( path(
"domain-request/<int:pk>/withdrawconfirmed", "domain-request/<int:domain_request_pk>/withdrawconfirmed",
views.DomainRequestWithdrawn.as_view(), views.DomainRequestWithdrawn.as_view(),
name="domain-request-withdrawn", name="domain-request-withdrawn",
), ),
@ -296,56 +296,60 @@ urlpatterns = [
lambda r: always_404(r, "We forgot to include this link, sorry."), lambda r: always_404(r, "We forgot to include this link, sorry."),
name="todo", name="todo",
), ),
path("domain/<int:pk>", views.DomainView.as_view(), name="domain"), path("domain/<int:domain_pk>", views.DomainView.as_view(), name="domain"),
path("domain/<int:pk>/prototype-dns", views.PrototypeDomainDNSRecordView.as_view(), name="prototype-domain-dns"),
path("domain/<int:pk>/users", views.DomainUsersView.as_view(), name="domain-users"),
path( path(
"domain/<int:pk>/dns", "domain/<int:domain_pk>/prototype-dns",
views.PrototypeDomainDNSRecordView.as_view(),
name="prototype-domain-dns",
),
path("domain/<int:domain_pk>/users", views.DomainUsersView.as_view(), name="domain-users"),
path(
"domain/<int:domain_pk>/dns",
views.DomainDNSView.as_view(), views.DomainDNSView.as_view(),
name="domain-dns", name="domain-dns",
), ),
path( path(
"domain/<int:pk>/dns/nameservers", "domain/<int:domain_pk>/dns/nameservers",
views.DomainNameserversView.as_view(), views.DomainNameserversView.as_view(),
name="domain-dns-nameservers", name="domain-dns-nameservers",
), ),
path( path(
"domain/<int:pk>/dns/dnssec", "domain/<int:domain_pk>/dns/dnssec",
views.DomainDNSSECView.as_view(), views.DomainDNSSECView.as_view(),
name="domain-dns-dnssec", name="domain-dns-dnssec",
), ),
path( path(
"domain/<int:pk>/dns/dnssec/dsdata", "domain/<int:domain_pk>/dns/dnssec/dsdata",
views.DomainDsDataView.as_view(), views.DomainDsDataView.as_view(),
name="domain-dns-dnssec-dsdata", name="domain-dns-dnssec-dsdata",
), ),
path( path(
"domain/<int:pk>/org-name-address", "domain/<int:domain_pk>/org-name-address",
views.DomainOrgNameAddressView.as_view(), views.DomainOrgNameAddressView.as_view(),
name="domain-org-name-address", name="domain-org-name-address",
), ),
path( path(
"domain/<int:pk>/suborganization", "domain/<int:domain_pk>/suborganization",
views.DomainSubOrganizationView.as_view(), views.DomainSubOrganizationView.as_view(),
name="domain-suborganization", name="domain-suborganization",
), ),
path( path(
"domain/<int:pk>/senior-official", "domain/<int:domain_pk>/senior-official",
views.DomainSeniorOfficialView.as_view(), views.DomainSeniorOfficialView.as_view(),
name="domain-senior-official", name="domain-senior-official",
), ),
path( path(
"domain/<int:pk>/security-email", "domain/<int:domain_pk>/security-email",
views.DomainSecurityEmailView.as_view(), views.DomainSecurityEmailView.as_view(),
name="domain-security-email", name="domain-security-email",
), ),
path( path(
"domain/<int:pk>/renewal", "domain/<int:domain_pk>/renewal",
views.DomainRenewalView.as_view(), views.DomainRenewalView.as_view(),
name="domain-renewal", name="domain-renewal",
), ),
path( path(
"domain/<int:pk>/users/add", "domain/<int:domain_pk>/users/add",
views.DomainAddUserView.as_view(), views.DomainAddUserView.as_view(),
name="domain-users-add", name="domain-users-add",
), ),
@ -360,17 +364,17 @@ urlpatterns = [
name="user-profile", name="user-profile",
), ),
path( path(
"invitation/<int:pk>/cancel", "invitation/<int:domain_invitation_pk>/cancel",
views.DomainInvitationCancelView.as_view(http_method_names=["post"]), views.DomainInvitationCancelView.as_view(http_method_names=["post"]),
name="invitation-cancel", name="invitation-cancel",
), ),
path( path(
"domain-request/<int:pk>/delete", "domain-request/<int:domain_request_pk>/delete",
views.DomainRequestDeleteView.as_view(http_method_names=["post"]), views.DomainRequestDeleteView.as_view(http_method_names=["post"]),
name="domain-request-delete", name="domain-request-delete",
), ),
path( path(
"domain/<int:pk>/users/<int:user_pk>/delete", "domain/<int:domain_pk>/users/<int:user_pk>/delete",
views.DomainDeleteUserView.as_view(http_method_names=["post"]), views.DomainDeleteUserView.as_view(http_method_names=["post"]),
name="domain-user-delete", name="domain-user-delete",
), ),
@ -392,6 +396,7 @@ urlpatterns = [
# This way, we can share a view for djangooidc, and other pages as we see fit. # This way, we can share a view for djangooidc, and other pages as we see fit.
handler500 = "registrar.views.utility.error_views.custom_500_error_view" handler500 = "registrar.views.utility.error_views.custom_500_error_view"
handler403 = "registrar.views.utility.error_views.custom_403_error_view" handler403 = "registrar.views.utility.error_views.custom_403_error_view"
handler404 = "registrar.views.utility.error_views.custom_404_error_view"
# we normally would guard these with `if settings.DEBUG` but tests run with # we normally would guard these with `if settings.DEBUG` but tests run with
# DEBUG = False even when these apps have been loaded because settings.DEBUG # DEBUG = False even when these apps have been loaded because settings.DEBUG

View file

@ -68,19 +68,9 @@ def portfolio_permissions(request):
"has_organization_requests_flag": False, "has_organization_requests_flag": False,
"has_organization_members_flag": False, "has_organization_members_flag": False,
"is_portfolio_admin": False, "is_portfolio_admin": False,
"has_domain_renewal_flag": False,
} }
try: try:
portfolio = request.session.get("portfolio") portfolio = request.session.get("portfolio")
# These feature flags will display and doesn't depend on portfolio
portfolio_context.update(
{
"has_organization_feature_flag": True,
"has_domain_renewal_flag": request.user.has_domain_renewal_flag(),
}
)
if portfolio: if portfolio:
return { return {
"has_view_portfolio_permission": request.user.has_view_portfolio_permission(portfolio), "has_view_portfolio_permission": request.user.has_view_portfolio_permission(portfolio),
@ -95,7 +85,6 @@ def portfolio_permissions(request):
"has_organization_requests_flag": request.user.has_organization_requests_flag(), "has_organization_requests_flag": request.user.has_organization_requests_flag(),
"has_organization_members_flag": request.user.has_organization_members_flag(), "has_organization_members_flag": request.user.has_organization_members_flag(),
"is_portfolio_admin": request.user.is_portfolio_admin(portfolio), "is_portfolio_admin": request.user.is_portfolio_admin(portfolio),
"has_domain_renewal_flag": request.user.has_domain_renewal_flag(),
} }
return portfolio_context return portfolio_context

300
src/registrar/decorators.py Normal file
View file

@ -0,0 +1,300 @@
import functools
from django.core.exceptions import PermissionDenied
from django.utils.decorators import method_decorator
from registrar.models import Domain, DomainInformation, DomainInvitation, DomainRequest, UserDomainRole
# Constants for clarity
ALL = "all"
IS_STAFF = "is_staff"
IS_DOMAIN_MANAGER = "is_domain_manager"
IS_DOMAIN_REQUEST_CREATOR = "is_domain_request_creator"
IS_STAFF_MANAGING_DOMAIN = "is_staff_managing_domain"
IS_PORTFOLIO_MEMBER = "is_portfolio_member"
IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER = "is_portfolio_member_and_domain_manager"
IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER = "is_domain_manager_and_not_portfolio_member"
HAS_PORTFOLIO_DOMAINS_ANY_PERM = "has_portfolio_domains_any_perm"
HAS_PORTFOLIO_DOMAINS_VIEW_ALL = "has_portfolio_domains_view_all"
HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM = "has_portfolio_domain_requests_any_perm"
HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL = "has_portfolio_domain_requests_view_all"
HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT = "has_portfolio_domain_requests_edit"
HAS_PORTFOLIO_MEMBERS_ANY_PERM = "has_portfolio_members_any_perm"
HAS_PORTFOLIO_MEMBERS_EDIT = "has_portfolio_members_edit"
HAS_PORTFOLIO_MEMBERS_VIEW = "has_portfolio_members_view"
def grant_access(*rules):
"""
A decorator that enforces access control based on specified rules.
Usage:
- Multiple rules in a single decorator:
@grant_access(IS_STAFF, IS_SUPERUSER, IS_DOMAIN_MANAGER)
- Stacked decorators for separate rules:
@grant_access(IS_SUPERUSER)
@grant_access(IS_DOMAIN_MANAGER)
The decorator supports both function-based views (FBVs) and class-based views (CBVs).
"""
def decorator(view):
if isinstance(view, type): # Check if decorating a class-based view (CBV)
original_dispatch = view.dispatch # Store the original dispatch method
@method_decorator(grant_access(*rules)) # Apply the decorator to dispatch
def wrapped_dispatch(self, request, *args, **kwargs):
if not _user_has_permission(request.user, request, rules, **kwargs):
raise PermissionDenied # Deny access if the user lacks permission
return original_dispatch(self, request, *args, **kwargs)
view.dispatch = wrapped_dispatch # Replace the dispatch method
return view
else: # If decorating a function-based view (FBV)
view.has_explicit_access = True # Mark the view as having explicit access control
existing_rules = getattr(view, "_access_rules", set()) # Retrieve existing rules
existing_rules.update(rules) # Merge with new rules
view._access_rules = existing_rules # Store updated rules
@functools.wraps(view)
def wrapper(request, *args, **kwargs):
if not _user_has_permission(request.user, request, rules, **kwargs):
raise PermissionDenied # Deny access if the user lacks permission
return view(request, *args, **kwargs) # Proceed with the original view
return wrapper
return decorator
def _user_has_permission(user, request, rules, **kwargs):
"""
Determines if the user meets the required permission rules.
This function evaluates a set of predefined permission rules to check whether a user has access
to a specific view. It supports various access control conditions, including staff status,
domain management roles, and portfolio-related permissions.
Parameters:
- user: The user requesting access.
- request: The HTTP request object.
- rules: A set of access control rules to evaluate.
- **kwargs: Additional keyword arguments used in specific permission checks.
Returns:
- True if the user satisfies any of the specified rules.
- False otherwise.
"""
# Skip authentication if @login_not_required is applied
if getattr(request, "login_not_required", False):
return True
# Allow everyone if `ALL` is in rules
if ALL in rules:
return True
# Ensure user is authenticated and not restricted
if not user.is_authenticated or user.is_restricted():
return False
# Define permission checks
permission_checks = [
(IS_STAFF, lambda: user.is_staff),
(IS_DOMAIN_MANAGER, lambda: _is_domain_manager(user, **kwargs)),
(IS_STAFF_MANAGING_DOMAIN, lambda: _is_staff_managing_domain(request, **kwargs)),
(IS_PORTFOLIO_MEMBER, lambda: user.is_org_user(request)),
(
HAS_PORTFOLIO_DOMAINS_VIEW_ALL,
lambda: _has_portfolio_view_all_domains(request, kwargs.get("domain_pk")),
),
(
HAS_PORTFOLIO_DOMAINS_ANY_PERM,
lambda: user.is_org_user(request)
and user.has_any_domains_portfolio_permission(request.session.get("portfolio")),
),
(
IS_PORTFOLIO_MEMBER_AND_DOMAIN_MANAGER,
lambda: _is_domain_manager(user, **kwargs) and _is_portfolio_member(request),
),
(
IS_DOMAIN_MANAGER_AND_NOT_PORTFOLIO_MEMBER,
lambda: _is_domain_manager(user, **kwargs) and not _is_portfolio_member(request),
),
(
IS_DOMAIN_REQUEST_CREATOR,
lambda: _is_domain_request_creator(user, kwargs.get("domain_request_pk"))
and not _is_portfolio_member(request),
),
(
HAS_PORTFOLIO_DOMAIN_REQUESTS_ANY_PERM,
lambda: user.is_org_user(request)
and user.has_any_requests_portfolio_permission(request.session.get("portfolio")),
),
(
HAS_PORTFOLIO_DOMAIN_REQUESTS_VIEW_ALL,
lambda: user.is_org_user(request)
and user.has_view_all_domain_requests_portfolio_permission(request.session.get("portfolio")),
),
(
HAS_PORTFOLIO_DOMAIN_REQUESTS_EDIT,
lambda: _has_portfolio_domain_requests_edit(user, request, kwargs.get("domain_request_pk")),
),
(
HAS_PORTFOLIO_MEMBERS_ANY_PERM,
lambda: user.is_org_user(request)
and (
user.has_view_members_portfolio_permission(request.session.get("portfolio"))
or user.has_edit_members_portfolio_permission(request.session.get("portfolio"))
),
),
(
HAS_PORTFOLIO_MEMBERS_EDIT,
lambda: user.is_org_user(request)
and user.has_edit_members_portfolio_permission(request.session.get("portfolio")),
),
(
HAS_PORTFOLIO_MEMBERS_VIEW,
lambda: user.is_org_user(request)
and user.has_view_members_portfolio_permission(request.session.get("portfolio")),
),
]
# Check conditions iteratively
return any(check() for rule, check in permission_checks if rule in rules)
def _has_portfolio_domain_requests_edit(user, request, domain_request_id):
if domain_request_id and not _is_domain_request_creator(user, domain_request_id):
return False
return user.is_org_user(request) and user.has_edit_request_portfolio_permission(request.session.get("portfolio"))
def _is_domain_manager(user, **kwargs):
"""
Determines if the given user is a domain manager for a specified domain.
- First, it checks if 'domain_pk' is present in the URL parameters.
- If 'domain_pk' exists, it verifies if the user has a domain role for that domain.
- If 'domain_pk' is absent, it checks for 'domain_invitation_pk' to determine if the user
has domain permissions through an invitation.
Returns:
bool: True if the user is a domain manager, False otherwise.
"""
domain_id = kwargs.get("domain_pk")
if domain_id:
return UserDomainRole.objects.filter(user=user, domain_id=domain_id).exists()
domain_invitation_id = kwargs.get("domain_invitation_pk")
if domain_invitation_id:
return DomainInvitation.objects.filter(id=domain_invitation_id, domain__permissions__user=user).exists()
return False
def _is_domain_request_creator(user, domain_request_pk):
"""Checks to see if the user is the creator of a domain request
with domain_request_pk."""
if domain_request_pk:
return DomainRequest.objects.filter(creator=user, id=domain_request_pk).exists()
return True
def _is_portfolio_member(request):
"""Checks to see if the user in the request is a member of the
portfolio in the request's session."""
return request.user.is_org_user(request)
def _is_staff_managing_domain(request, **kwargs):
"""
Determines whether a staff user (analyst or superuser) has permission to manage a domain
that they did not create or were not invited to.
The function enforces:
1. **User Authorization** - The user must have `analyst_access_permission` or `full_access_permission`.
2. **Valid Session Context** - The user must have explicitly selected the domain for management
via an 'analyst action' (e.g., by clicking 'Manage Domain' in the admin interface).
3. **Domain Status Check** - Only domains in specific statuses (e.g., APPROVED, IN_REVIEW, etc.)
can be managed, except in cases where the domain lacks a status due to errors.
Process:
- First, the function retrieves the `domain_pk` from the URL parameters.
- If `domain_pk` is not provided, it attempts to resolve the domain via `domain_invitation_pk`.
- It checks if the user has the required permissions.
- It verifies that the user has an active 'analyst action' session for the domain.
- Finally, it ensures that the domain is in a status that allows management.
Returns:
bool: True if the user is allowed to manage the domain, False otherwise.
"""
domain_id = kwargs.get("domain_pk")
if not domain_id:
domain_invitation_id = kwargs.get("domain_invitation_pk")
domain_invitation = DomainInvitation.objects.filter(id=domain_invitation_id).first()
if domain_invitation:
domain_id = domain_invitation.domain_id
# Check if the request user is permissioned...
user_is_analyst_or_superuser = request.user.has_perm(
"registrar.analyst_access_permission"
) or request.user.has_perm("registrar.full_access_permission")
if not user_is_analyst_or_superuser:
return False
# Check if the user is attempting a valid edit action.
# In other words, if the analyst/admin did not click
# the 'Manage Domain' button in /admin,
# then they cannot access this page.
session = request.session
can_do_action = (
"analyst_action" in session
and "analyst_action_location" in session
and session["analyst_action_location"] == domain_id
)
if not can_do_action:
return False
# Analysts may manage domains, when they are in these statuses:
valid_domain_statuses = [
DomainRequest.DomainRequestStatus.APPROVED,
DomainRequest.DomainRequestStatus.IN_REVIEW,
DomainRequest.DomainRequestStatus.REJECTED,
DomainRequest.DomainRequestStatus.ACTION_NEEDED,
# Edge case - some domains do not have
# a status or DomainInformation... aka a status of 'None'.
# It is necessary to access those to correct errors.
None,
]
requested_domain = DomainInformation.objects.filter(domain_id=domain_id).first()
# if no domain information or domain request exist, the user
# should be able to manage the domain; however, if domain information
# and domain request exist, and domain request is not in valid status,
# user should not be able to manage domain
if (
requested_domain
and requested_domain.domain_request
and requested_domain.domain_request.status not in valid_domain_statuses
):
return False
# Valid session keys exist,
# the user is permissioned,
# and it is in a valid status
return True
def _has_portfolio_view_all_domains(request, domain_pk):
"""Returns whether the user in the request can access the domain
via portfolio view all domains permission."""
portfolio = request.session.get("portfolio")
if request.user.has_view_all_domains_portfolio_permission(portfolio):
if Domain.objects.filter(id=domain_pk).exists():
domain = Domain.objects.get(id=domain_pk)
if domain.domain_info.portfolio == portfolio:
return True
return False

View file

@ -171,6 +171,13 @@ class UserFixture:
"email": "gina.summers@ecstech.com", "email": "gina.summers@ecstech.com",
"title": "Scrum Master", "title": "Scrum Master",
}, },
{
"username": "89f2db87-87a2-4778-a5ea-5b27b585b131",
"first_name": "Jaxon",
"last_name": "Silva",
"email": "jaxon.silva@cisa.dhs.gov",
"title": "Designer",
},
] ]
STAFF = [ STAFF = [

View file

@ -13,7 +13,16 @@ from registrar.models import (
Portfolio, Portfolio,
SeniorOfficial, SeniorOfficial,
) )
from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from registrar.models.utility.portfolio_helper import (
UserPortfolioPermissionChoices,
UserPortfolioRoleChoices,
get_domain_requests_description_display,
get_domain_requests_display,
get_domains_description_display,
get_domains_display,
get_members_description_display,
get_members_display,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -126,8 +135,16 @@ class BasePortfolioMemberForm(forms.ModelForm):
domain_permissions = forms.ChoiceField( domain_permissions = forms.ChoiceField(
choices=[ choices=[
(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, "Viewer, limited"), (
(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer, all"), UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value,
get_domains_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None),
),
(
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value,
get_domains_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
),
),
], ],
widget=forms.RadioSelect, widget=forms.RadioSelect,
required=False, required=False,
@ -139,9 +156,19 @@ class BasePortfolioMemberForm(forms.ModelForm):
domain_request_permissions = forms.ChoiceField( domain_request_permissions = forms.ChoiceField(
choices=[ choices=[
("no_access", "No access"), ("no_access", get_domain_requests_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None)),
(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "Viewer"), (
(UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "Creator"), UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value,
get_domain_requests_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS]
),
),
(
UserPortfolioPermissionChoices.EDIT_REQUESTS.value,
get_domain_requests_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.EDIT_REQUESTS]
),
),
], ],
widget=forms.RadioSelect, widget=forms.RadioSelect,
required=False, required=False,
@ -153,8 +180,13 @@ class BasePortfolioMemberForm(forms.ModelForm):
member_permissions = forms.ChoiceField( member_permissions = forms.ChoiceField(
choices=[ choices=[
("no_access", "No access"), ("no_access", get_members_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None)),
(UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "Viewer"), (
UserPortfolioPermissionChoices.VIEW_MEMBERS.value,
get_members_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_MEMBERS]
),
),
], ],
widget=forms.RadioSelect, widget=forms.RadioSelect,
required=False, required=False,
@ -191,19 +223,31 @@ class BasePortfolioMemberForm(forms.ModelForm):
# Adds a <p> description beneath each option # Adds a <p> description beneath each option
self.fields["domain_permissions"].descriptions = { self.fields["domain_permissions"].descriptions = {
UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: "Can view only the domains they manage", UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: get_domains_description_display(
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: "Can view all domains for the organization", UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None
),
UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: get_domains_description_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS]
),
} }
self.fields["domain_request_permissions"].descriptions = { self.fields["domain_request_permissions"].descriptions = {
UserPortfolioPermissionChoices.EDIT_REQUESTS.value: ( UserPortfolioPermissionChoices.EDIT_REQUESTS.value: (
"Can view all domain requests for the organization and create requests" get_domain_requests_description_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.EDIT_REQUESTS]
)
), ),
UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: "Can view all domain requests for the organization", UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: (
"no_access": "Cannot view or create domain requests", get_domain_requests_description_display(
UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS]
)
),
"no_access": get_domain_requests_description_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None),
} }
self.fields["member_permissions"].descriptions = { self.fields["member_permissions"].descriptions = {
UserPortfolioPermissionChoices.VIEW_MEMBERS.value: "Can view all members permissions", UserPortfolioPermissionChoices.VIEW_MEMBERS.value: get_members_description_display(
"no_access": "Cannot view member permissions", UserPortfolioRoleChoices.ORGANIZATION_MEMBER, [UserPortfolioPermissionChoices.VIEW_MEMBERS]
),
"no_access": get_members_description_display(UserPortfolioRoleChoices.ORGANIZATION_MEMBER, None),
} }
# Map model instance values to custom form fields # Map model instance values to custom form fields
@ -218,6 +262,9 @@ class BasePortfolioMemberForm(forms.ModelForm):
cleaned_data = super().clean() cleaned_data = super().clean()
role = cleaned_data.get("role") role = cleaned_data.get("role")
# handle role
cleaned_data["roles"] = [role] if role else []
# Get required fields for the selected role. Then validate all required fields for the role. # Get required fields for the selected role. Then validate all required fields for the role.
required_fields = self.ROLE_REQUIRED_FIELDS.get(role, []) required_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
for field_name in required_fields: for field_name in required_fields:
@ -236,9 +283,6 @@ class BasePortfolioMemberForm(forms.ModelForm):
if cleaned_data.get("member_permissions") == "no_access": if cleaned_data.get("member_permissions") == "no_access":
cleaned_data["member_permissions"] = None cleaned_data["member_permissions"] = None
# Handle roles
cleaned_data["roles"] = [role]
# Handle additional_permissions # Handle additional_permissions
valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, []) valid_fields = self.ROLE_REQUIRED_FIELDS.get(role, [])
additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)} additional_permissions = {cleaned_data.get(field) for field in valid_fields if cleaned_data.get(field)}
@ -338,6 +382,24 @@ class BasePortfolioMemberForm(forms.ModelForm):
and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in new_roles and UserPortfolioRoleChoices.ORGANIZATION_ADMIN not in new_roles
) )
def is_change(self) -> bool:
"""
Determines if the form has changed by comparing the initial data
with the submitted cleaned data.
Returns:
bool: True if the form has changed, False otherwise.
"""
# Compare role values
previous_roles = set(self.initial.get("roles", []))
new_roles = set(self.cleaned_data.get("roles", []))
# Compare additional permissions values
previous_permissions = set(self.initial.get("additional_permissions") or [])
new_permissions = set(self.cleaned_data.get("additional_permissions") or [])
return previous_roles != new_roles or previous_permissions != new_permissions
class PortfolioMemberForm(BasePortfolioMemberForm): class PortfolioMemberForm(BasePortfolioMemberForm):
""" """

View file

@ -1,4 +1,4 @@
"""" """ "
Converts all ready and DNS needed domains with a non-default public contact Converts all ready and DNS needed domains with a non-default public contact
to disclose their public contact. Created for Issue#1535 to resolve to disclose their public contact. Created for Issue#1535 to resolve
disclose issue of domains with missing security emails. disclose issue of domains with missing security emails.

View file

@ -1,8 +1,8 @@
"""Data migration: """Data migration:
1 - generates a report of data integrity across all 1 - generates a report of data integrity across all
transition domain related tables transition domain related tables
2 - allows users to run all migration scripts for 2 - allows users to run all migration scripts for
transition domain data transition domain data
""" """
import logging import logging

View file

@ -1,4 +1,4 @@
"""" """ "
Data migration: Renaming deprecated Federal Agencies to Data migration: Renaming deprecated Federal Agencies to
their new updated names ie (U.S. Peace Corps to Peace Corps) their new updated names ie (U.S. Peace Corps to Peace Corps)
within Domain Information and Domain Requests within Domain Information and Domain Requests

View file

@ -2,6 +2,7 @@ from itertools import zip_longest
import logging import logging
import ipaddress import ipaddress
import re import re
import time
from datetime import date, timedelta from datetime import date, timedelta
from typing import Optional from typing import Optional
from django.db import transaction from django.db import transaction
@ -41,7 +42,6 @@ from .utility.time_stamped_model import TimeStampedModel
from .public_contact import PublicContact from .public_contact import PublicContact
from .user_domain_role import UserDomainRole from .user_domain_role import UserDomainRole
from waffle.decorators import flag_is_active
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -751,11 +751,7 @@ class Domain(TimeStampedModel, DomainHelper):
successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount successTotalNameservers = len(oldNameservers) - deleteCount + addToDomainCount
try: self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
except Exception as e:
# we don't need this part to succeed in order to continue.
logger.error("Failed to delete nameserver hosts: %s", e)
if successTotalNameservers < 2: if successTotalNameservers < 2:
try: try:
@ -1039,13 +1035,13 @@ class Domain(TimeStampedModel, DomainHelper):
logger.error(f"registry error removing client hold: {err}") logger.error(f"registry error removing client hold: {err}")
raise (err) raise (err)
def _delete_domain(self): def _delete_domain(self): # noqa
"""This domain should be deleted from the registry """This domain should be deleted from the registry
may raises RegistryError, should be caught or handled correctly by caller""" may raises RegistryError, should be caught or handled correctly by caller"""
logger.info("Deleting subdomains for %s", self.name) logger.info("Deleting subdomains for %s", self.name)
# check if any subdomains are in use by another domain # check if any subdomains are in use by another domain
hosts = Host.objects.filter(name__regex=r".+{}".format(self.name)) hosts = Host.objects.filter(name__regex=r".+\.{}".format(self.name))
for host in hosts: for host in hosts:
if host.domain != self: if host.domain != self:
logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain) logger.error("Unable to delete host: %s is in use by another domain: %s", host.name, host.domain)
@ -1053,38 +1049,119 @@ class Domain(TimeStampedModel, DomainHelper):
code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION, code=ErrorCode.OBJECT_ASSOCIATION_PROHIBITS_OPERATION,
note=f"Host {host.name} is in use by {host.domain}", note=f"Host {host.name} is in use by {host.domain}",
) )
try:
# set hosts to empty list so nameservers are deleted
(
deleted_values,
updated_values,
new_values,
oldNameservers,
) = self.getNameserverChanges(hosts=[])
( # update the hosts
deleted_values, _ = self._update_host_values(
updated_values, updated_values, oldNameservers
new_values, ) # returns nothing, just need to be run and errors
oldNameservers, addToDomainList, _ = self.createNewHostList(new_values)
) = self.getNameserverChanges(hosts=[]) deleteHostList, _ = self.createDeleteHostList(deleted_values)
responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList)
_ = self._update_host_values(updated_values, oldNameservers) # returns nothing, just need to be run and errors except RegistryError as e:
addToDomainList, _ = self.createNewHostList(new_values) logger.error(f"Error trying to delete hosts from domain {self}: {e}")
deleteHostList, _ = self.createDeleteHostList(deleted_values) raise e
responseCode = self.addAndRemoveHostsFromDomain(hostsToAdd=addToDomainList, hostsToDelete=deleteHostList)
# if unable to update domain raise error and stop # if unable to update domain raise error and stop
if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY: if responseCode != ErrorCode.COMMAND_COMPLETED_SUCCESSFULLY:
raise NameserverError(code=nsErrorCodes.BAD_DATA) raise NameserverError(code=nsErrorCodes.BAD_DATA)
logger.info("Finished removing nameservers from domain")
# addAndRemoveHostsFromDomain removes the hosts from the domain object, # addAndRemoveHostsFromDomain removes the hosts from the domain object,
# but we still need to delete the object themselves # but we still need to delete the object themselves
self._delete_hosts_if_not_used(hostsToDelete=deleted_values) self._delete_hosts_if_not_used(hostsToDelete=deleted_values)
logger.info("Finished _delete_hosts_if_not_used inside _delete_domain()")
# delete the non-registrant contacts
logger.debug("Deleting non-registrant contacts for %s", self.name) logger.debug("Deleting non-registrant contacts for %s", self.name)
contacts = PublicContact.objects.filter(domain=self) contacts = PublicContact.objects.filter(domain=self)
for contact in contacts: logger.info(f"retrieved contacts for domain: {contacts}")
if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT:
self._update_domain_with_contact(contact, rem=True)
request = commands.DeleteContact(contact.registry_id)
registry.send(request, cleaned=True)
logger.info("Deleting domain %s", self.name) for contact in contacts:
try:
if contact.contact_type != PublicContact.ContactTypeChoices.REGISTRANT:
logger.info(f"Deleting contact: {contact}")
try:
self._update_domain_with_contact(contact, rem=True)
except Exception as e:
logger.error(f"Error while updating domain with contact: {contact}, e: {e}", exc_info=True)
request = commands.DeleteContact(contact.registry_id)
registry.send(request, cleaned=True)
logger.info(f"sent DeleteContact for {contact}")
except RegistryError as e:
logger.error(f"Error deleting contact: {contact}, {e}", exc_info=True)
logger.info(f"Finished deleting contacts for {self.name}")
# delete ds data if it exists
if self.dnssecdata:
logger.debug("Deleting ds data for %s", self.name)
try:
# set and unset client hold to be able to change ds data
logger.info("removing client hold")
self._remove_client_hold()
self.dnssecdata = None
logger.info("placing client hold")
self._place_client_hold()
except RegistryError as e:
logger.error("Error deleting ds data for %s: %s", self.name, e)
e.note = "Error deleting ds data for %s" % self.name
raise e
# check if the domain can be deleted
if not self._domain_can_be_deleted():
note = "Domain has associated objects that prevent deletion."
raise RegistryError(code=ErrorCode.COMMAND_FAILED, note=note)
# delete the domain
request = commands.DeleteDomain(name=self.name) request = commands.DeleteDomain(name=self.name)
registry.send(request, cleaned=True) try:
registry.send(request, cleaned=True)
logger.info("Domain %s deleted successfully.", self.name)
except RegistryError as e:
logger.error("Error deleting domain %s: %s", self.name, e)
raise e
def _domain_can_be_deleted(self, max_attempts=5, wait_interval=2) -> bool:
"""
Polls the registry using InfoDomain calls to confirm that the domain can be deleted.
Returns True if the domain can be deleted, False otherwise. Includes a retry mechanism
using wait_interval and max_attempts, which may be necessary if subdomains and other
associated objects were only recently deleted as the registry may not be immediately updated.
"""
logger.info("Polling registry to confirm deletion pre-conditions for %s", self.name)
last_info_error = None
for attempt in range(max_attempts):
try:
info_response = registry.send(commands.InfoDomain(name=self.name), cleaned=True)
domain_info = info_response.res_data[0]
hosts_associated = getattr(domain_info, "hosts", None)
if hosts_associated is None or len(hosts_associated) == 0:
logger.info("InfoDomain reports no associated hosts for %s. Proceeding with deletion.", self.name)
return True
else:
logger.info("Attempt %d: Domain %s still has hosts: %s", attempt + 1, self.name, hosts_associated)
except RegistryError as info_e:
# If the domain is already gone, we can assume deletion already occurred.
if info_e.code == ErrorCode.OBJECT_DOES_NOT_EXIST:
logger.info("InfoDomain check indicates domain %s no longer exists.", self.name)
raise info_e
logger.warning("Attempt %d: Error during InfoDomain check: %s", attempt + 1, info_e)
time.sleep(wait_interval)
else:
logger.error(
"Exceeded max attempts waiting for domain %s to clear associated objects; last error: %s",
self.name,
last_info_error,
)
return False
def __str__(self) -> str: def __str__(self) -> str:
return self.name return self.name
@ -1172,7 +1249,7 @@ class Domain(TimeStampedModel, DomainHelper):
"""Return the display status of the domain.""" """Return the display status of the domain."""
if self.is_expired() and (self.state != self.State.UNKNOWN): if self.is_expired() and (self.state != self.State.UNKNOWN):
return "Expired" return "Expired"
elif flag_is_active(request, "domain_renewal") and self.is_expiring(): elif self.is_expiring():
return "Expiring soon" return "Expiring soon"
elif self.state == self.State.UNKNOWN or self.state == self.State.DNS_NEEDED: elif self.state == self.State.UNKNOWN or self.state == self.State.DNS_NEEDED:
return "DNS needed" return "DNS needed"
@ -1588,7 +1665,7 @@ class Domain(TimeStampedModel, DomainHelper):
# Given expired is not a physical state, but it is displayed as such, # Given expired is not a physical state, but it is displayed as such,
# We need custom logic to determine this message. # We need custom logic to determine this message.
help_text = "This domain has expired. Complete the online renewal process to maintain access." help_text = "This domain has expired. Complete the online renewal process to maintain access."
elif flag_is_active(request, "domain_renewal") and self.is_expiring(): elif self.is_expiring():
help_text = "This domain is expiring soon. Complete the online renewal process to maintain access." help_text = "This domain is expiring soon. Complete the online renewal process to maintain access."
else: else:
help_text = Domain.State.get_help_text(self.state) help_text = Domain.State.get_help_text(self.state)
@ -1841,8 +1918,6 @@ class Domain(TimeStampedModel, DomainHelper):
else: else:
logger.error("Error _delete_hosts_if_not_used, code was %s error was %s" % (e.code, e)) logger.error("Error _delete_hosts_if_not_used, code was %s error was %s" % (e.code, e))
raise e
def _fix_unknown_state(self, cleaned): def _fix_unknown_state(self, cleaned):
""" """
_fix_unknown_state: Calls _add_missing_contacts_if_unknown _fix_unknown_state: Calls _add_missing_contacts_if_unknown

View file

@ -9,6 +9,13 @@ from .utility.portfolio_helper import (
UserPortfolioPermissionChoices, UserPortfolioPermissionChoices,
UserPortfolioRoleChoices, UserPortfolioRoleChoices,
cleanup_after_portfolio_member_deletion, cleanup_after_portfolio_member_deletion,
get_domain_requests_description_display,
get_domain_requests_display,
get_domains_description_display,
get_domains_display,
get_members_description_display,
get_members_display,
get_role_display,
validate_portfolio_invitation, validate_portfolio_invitation,
) # type: ignore ) # type: ignore
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
@ -85,6 +92,90 @@ class PortfolioInvitation(TimeStampedModel):
""" """
return UserPortfolioPermission.get_portfolio_permissions(self.roles, self.additional_permissions) return UserPortfolioPermission.get_portfolio_permissions(self.roles, self.additional_permissions)
@property
def role_display(self):
"""
Returns a human-readable display name for the user's role.
Uses the `get_role_display` function to determine if the user is an "Admin",
"Basic" member, or has no role assigned.
Returns:
str: The display name of the user's role.
"""
return get_role_display(self.roles)
@property
def domains_display(self):
"""
Returns a string representation of the user's domain access level.
Uses the `get_domains_display` function to determine whether the user has
"Viewer" access (can view all domains) or "Viewer, limited" access.
Returns:
str: The display name of the user's domain permissions.
"""
return get_domains_display(self.roles, self.additional_permissions)
@property
def domains_description_display(self):
"""
Returns a string description of the user's domain access level.
Returns:
str: The display name of the user's domain permissions description.
"""
return get_domains_description_display(self.roles, self.additional_permissions)
@property
def domain_requests_display(self):
"""
Returns a string representation of the user's access to domain requests.
Uses the `get_domain_requests_display` function to determine if the user
is a "Creator" (can create and edit requests), a "Viewer" (can only view requests),
or has "No access" to domain requests.
Returns:
str: The display name of the user's domain request permissions.
"""
return get_domain_requests_display(self.roles, self.additional_permissions)
@property
def domain_requests_description_display(self):
"""
Returns a string description of the user's access to domain requests.
Returns:
str: The display name of the user's domain request permissions description.
"""
return get_domain_requests_description_display(self.roles, self.additional_permissions)
@property
def members_display(self):
"""
Returns a string representation of the user's access to managing members.
Uses the `get_members_display` function to determine if the user is a
"Manager" (can edit members), a "Viewer" (can view members), or has "No access"
to member management.
Returns:
str: The display name of the user's member management permissions.
"""
return get_members_display(self.roles, self.additional_permissions)
@property
def members_description_display(self):
"""
Returns a string description of the user's access to managing members.
Returns:
str: The display name of the user's member management permissions description.
"""
return get_members_description_display(self.roles, self.additional_permissions)
@transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED) @transition(field="status", source=PortfolioInvitationStatus.INVITED, target=PortfolioInvitationStatus.RETRIEVED)
def retrieve(self): def retrieve(self):
"""When an invitation is retrieved, create the corresponding permission. """When an invitation is retrieved, create the corresponding permission.

View file

@ -269,10 +269,7 @@ class User(AbstractUser):
return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS) return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_REQUESTS)
def is_portfolio_admin(self, portfolio): def is_portfolio_admin(self, portfolio):
return "Admin" in self.portfolio_role_summary(portfolio) return self.has_edit_portfolio_permission(portfolio)
def has_domain_renewal_flag(self):
return flag_is_active_for_user(self, "domain_renewal")
def get_first_portfolio(self): def get_first_portfolio(self):
permission = self.portfolio_permissions.first() permission = self.portfolio_permissions.first()
@ -280,49 +277,6 @@ class User(AbstractUser):
return permission.portfolio return permission.portfolio
return None return None
def portfolio_role_summary(self, portfolio):
"""Returns a list of roles based on the user's permissions."""
roles = []
# Define the conditions and their corresponding roles
conditions_roles = [
(self.has_edit_portfolio_permission(portfolio), ["Admin"]),
(
self.has_view_all_domains_portfolio_permission(portfolio)
and self.has_any_requests_portfolio_permission(portfolio)
and self.has_edit_request_portfolio_permission(portfolio),
["View-only admin", "Domain requestor"],
),
(
self.has_view_all_domains_portfolio_permission(portfolio)
and self.has_any_requests_portfolio_permission(portfolio),
["View-only admin"],
),
(
self.has_view_portfolio_permission(portfolio)
and self.has_edit_request_portfolio_permission(portfolio)
and self.has_any_domains_portfolio_permission(portfolio),
["Domain requestor", "Domain manager"],
),
(
self.has_view_portfolio_permission(portfolio) and self.has_edit_request_portfolio_permission(portfolio),
["Domain requestor"],
),
(
self.has_view_portfolio_permission(portfolio) and self.has_any_domains_portfolio_permission(portfolio),
["Domain manager"],
),
(self.has_view_portfolio_permission(portfolio), ["Member"]),
]
# Evaluate conditions and add roles
for condition, role_list in conditions_roles:
if condition:
roles.extend(role_list)
break
return roles
def get_portfolios(self): def get_portfolios(self):
return self.portfolio_permissions.all() return self.portfolio_permissions.all()

View file

@ -6,6 +6,13 @@ from registrar.models.utility.portfolio_helper import (
DomainRequestPermissionDisplay, DomainRequestPermissionDisplay,
MemberPermissionDisplay, MemberPermissionDisplay,
cleanup_after_portfolio_member_deletion, cleanup_after_portfolio_member_deletion,
get_domain_requests_display,
get_domain_requests_description_display,
get_domains_display,
get_domains_description_display,
get_members_display,
get_members_description_display,
get_role_display,
validate_user_portfolio_permission, validate_user_portfolio_permission,
) )
from .utility.time_stamped_model import TimeStampedModel from .utility.time_stamped_model import TimeStampedModel
@ -181,6 +188,90 @@ class UserPortfolioPermission(TimeStampedModel):
# This is the same as portfolio_permissions & common_forbidden_perms. # This is the same as portfolio_permissions & common_forbidden_perms.
return portfolio_permissions.intersection(common_forbidden_perms) return portfolio_permissions.intersection(common_forbidden_perms)
@property
def role_display(self):
"""
Returns a human-readable display name for the user's role.
Uses the `get_role_display` function to determine if the user is an "Admin",
"Basic" member, or has no role assigned.
Returns:
str: The display name of the user's role.
"""
return get_role_display(self.roles)
@property
def domains_display(self):
"""
Returns a string representation of the user's domain access level.
Uses the `get_domains_display` function to determine whether the user has
"Viewer" access (can view all domains) or "Viewer, limited" access.
Returns:
str: The display name of the user's domain permissions.
"""
return get_domains_display(self.roles, self.additional_permissions)
@property
def domains_description_display(self):
"""
Returns a string description of the user's domain access level.
Returns:
str: The display name of the user's domain permissions description.
"""
return get_domains_description_display(self.roles, self.additional_permissions)
@property
def domain_requests_display(self):
"""
Returns a string representation of the user's access to domain requests.
Uses the `get_domain_requests_display` function to determine if the user
is a "Creator" (can create and edit requests), a "Viewer" (can only view requests),
or has "No access" to domain requests.
Returns:
str: The display name of the user's domain request permissions.
"""
return get_domain_requests_display(self.roles, self.additional_permissions)
@property
def domain_requests_description_display(self):
"""
Returns a string description of the user's access to domain requests.
Returns:
str: The display name of the user's domain request permissions description.
"""
return get_domain_requests_description_display(self.roles, self.additional_permissions)
@property
def members_display(self):
"""
Returns a string representation of the user's access to managing members.
Uses the `get_members_display` function to determine if the user is a
"Manager" (can edit members), a "Viewer" (can view members), or has "No access"
to member management.
Returns:
str: The display name of the user's member management permissions.
"""
return get_members_display(self.roles, self.additional_permissions)
@property
def members_description_display(self):
"""
Returns a string description of the user's access to managing members.
Returns:
str: The display name of the user's member management permissions description.
"""
return get_members_description_display(self.roles, self.additional_permissions)
def clean(self): def clean(self):
"""Extends clean method to perform additional validation, which can raise errors in django admin.""" """Extends clean method to perform additional validation, which can raise errors in django admin."""
super().clean() super().clean()

View file

@ -79,6 +79,161 @@ class MemberPermissionDisplay(StrEnum):
NONE = "None" NONE = "None"
def get_role_display(roles):
"""
Returns a user-friendly display name for a given list of user roles.
- If the user has the ORGANIZATION_ADMIN role, return "Admin".
- If the user has the ORGANIZATION_MEMBER role, return "Basic".
- If the user has neither role, return "-".
Args:
roles (list): A list of role strings assigned to the user.
Returns:
str: The display name for the highest applicable role.
"""
if UserPortfolioRoleChoices.ORGANIZATION_ADMIN in roles:
return "Admin"
elif UserPortfolioRoleChoices.ORGANIZATION_MEMBER in roles:
return "Basic"
else:
return "-"
def get_domains_display(roles, permissions):
"""
Determines the display name for a user's domain viewing permissions.
- If the user has the VIEW_ALL_DOMAINS permission, return "Viewer".
- Otherwise, return "Viewer, limited".
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's domain viewing access.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS in all_permissions:
return "Viewer"
else:
return "Viewer, limited"
def get_domains_description_display(roles, permissions):
"""
Determines the display description for a user's domain viewing permissions.
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's domain viewing access description.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS in all_permissions:
return "Can view all domains for the organization"
else:
return "Can view only the domains they manage"
def get_domain_requests_display(roles, permissions):
"""
Determines the display name for a user's domain request permissions.
- If the user has the EDIT_REQUESTS permission, return "Creator".
- If the user has the VIEW_ALL_REQUESTS permission, return "Viewer".
- Otherwise, return "No access".
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's domain request access level.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.EDIT_REQUESTS in all_permissions:
return "Creator"
elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions:
return "Viewer"
else:
return "No access"
def get_domain_requests_description_display(roles, permissions):
"""
Determines the display description for a user's domain request permissions.
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's domain request access level description.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.EDIT_REQUESTS in all_permissions:
return "Can view all domain requests for the organization and create requests"
elif UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS in all_permissions:
return "Can view all domain requests for the organization"
else:
return "Cannot view or create domain requests"
def get_members_display(roles, permissions):
"""
Determines the display name for a user's member management permissions.
- If the user has the EDIT_MEMBERS permission, return "Manager".
- If the user has the VIEW_MEMBERS permission, return "Viewer".
- Otherwise, return "No access".
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's member management access level.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions:
return "Manager"
elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions:
return "Viewer"
else:
return "No access"
def get_members_description_display(roles, permissions):
"""
Determines the display description for a user's member management permissions.
Args:
roles (list): A list of role strings assigned to the user.
permissions (list): A list of additional permissions assigned to the user.
Returns:
str: A string representing the user's member management access level description.
"""
UserPortfolioPermission = apps.get_model("registrar.UserPortfolioPermission")
all_permissions = UserPortfolioPermission.get_portfolio_permissions(roles, permissions)
if UserPortfolioPermissionChoices.EDIT_MEMBERS in all_permissions:
return "Can view and manage all member permissions"
elif UserPortfolioPermissionChoices.VIEW_MEMBERS in all_permissions:
return "Can view all member permissions"
else:
return "Cannot view member permissions"
def validate_user_portfolio_permission(user_portfolio_permission): def validate_user_portfolio_permission(user_portfolio_permission):
""" """
Validates a UserPortfolioPermission instance. Located in portfolio_helper to avoid circular imports Validates a UserPortfolioPermission instance. Located in portfolio_helper to avoid circular imports

View file

@ -3,9 +3,13 @@ Contains middleware used in settings.py
""" """
import logging import logging
import re
from urllib.parse import parse_qs from urllib.parse import parse_qs
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.urls import reverse from django.urls import reverse
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.urls import resolve
from registrar.models import User from registrar.models import User
from waffle.decorators import flag_is_active from waffle.decorators import flag_is_active
@ -170,3 +174,51 @@ class CheckPortfolioMiddleware:
request.session["portfolio"] = request.user.get_first_portfolio() request.session["portfolio"] = request.user.get_first_portfolio()
else: else:
request.session["portfolio"] = request.user.get_first_portfolio() request.session["portfolio"] = request.user.get_first_portfolio()
class RestrictAccessMiddleware:
"""
Middleware that blocks access to all views unless explicitly permitted.
This middleware enforces authentication by default. Views must explicitly allow access
using access control mechanisms such as the `@grant_access` decorator. Exceptions are made
for Django admin views, explicitly ignored paths, and views that opt out of login requirements.
"""
def __init__(self, get_response):
self.get_response = get_response
# Compile regex patterns from settings to identify paths that bypass login requirements
self.ignored_paths = [re.compile(pattern) for pattern in getattr(settings, "LOGIN_REQUIRED_IGNORE_PATHS", [])]
def __call__(self, request):
# Allow requests to Django Debug Toolbar
if request.path.startswith("/__debug__/"):
return self.get_response(request)
# Allow requests matching configured ignored paths
if any(pattern.match(request.path) for pattern in self.ignored_paths):
return self.get_response(request)
# Attempt to resolve the request path to a view function
try:
resolver_match = resolve(request.path_info)
view_func = resolver_match.func
app_name = resolver_match.app_name # Get the app name of the resolved view
except Exception:
# If resolution fails, allow the request to proceed (avoid blocking non-view routes)
return self.get_response(request)
# Automatically allow access to Django's built-in admin views (excluding custom /admin/* views)
if app_name == "admin":
return self.get_response(request)
# Allow access if the view explicitly opts out of login requirements
if getattr(view_func, "login_required", True) is False:
return self.get_response(request)
# Restrict access to views that do not explicitly declare access rules
if not getattr(view_func, "has_explicit_access", False):
raise PermissionDenied # Deny access if the view lacks explicit permission handling
return self.get_response(request)

View file

@ -2,6 +2,10 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% block title %}
Registrar Analytics | Django admin
{% endblock %}
{% block content_title %}<h1>Registrar Analytics</h1>{% endblock %} {% block content_title %}<h1>Registrar Analytics</h1>{% endblock %}
{% block breadcrumbs %} {% block breadcrumbs %}
@ -18,7 +22,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
{% block content %} {% block content %}
<div id="content-main" class="custom-admin-template"> <div id="content-main" class="custom-admin-template analytics-dashboard">
<div class="grid-row grid-gap-2"> <div class="grid-row grid-gap-2">
<div class="tablet:grid-col-6 margin-top-2"> <div class="tablet:grid-col-6 margin-top-2">
@ -95,7 +99,7 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
<input type="date" id="end" name="end" value="2023-12-01" min="2023-12-01" /> <input type="date" id="end" name="end" value="2023-12-01" min="2023-12-01" />
</div> </div>
</div> </div>
<ul class="usa-button-group"> <ul class="usa-button-group flex-wrap">
<li class="usa-button-group__item"> <li class="usa-button-group__item">
<button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button"> <button class="usa-button usa-button--dja exportLink" data-export-url="{% url 'export_domains_growth' %}" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
@ -133,80 +137,127 @@ https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/
</li> </li>
</ul> </ul>
<div class="grid-row grid-gap-2 margin-y-2"> <div class="analytics-dashboard-charts margin-top-2">
<div class="grid-col"> {% comment %} Managed/Unmanaged domains {% endcomment %}
<canvas id="myChart1" width="400" height="200" <div class="chart-1 grid-col">
aria-label="Chart: {{ data.managed_domains_sliced_at_end_date.0 }} managed domains for {{ data.end_date }}" <canvas id="managed-domains-chart" width="400" height="200"
role="img" aria-label="Chart: {{ data.managed_domains.end_date_count.0 }} managed domains for {{ data.end_date }}"
data-list-one="{{data.managed_domains_sliced_at_start_date}}" role="img"
data-list-two="{{data.managed_domains_sliced_at_end_date}}" data-list-one="{{ data.managed_domains.start_date_count }}"
> data-list-two="{{ data.managed_domains.end_date_count }}"
<h2>Chart: Managed domains</h2> >
<p>{{ data.managed_domains_sliced_at_end_date.0 }} managed domains for {{ data.end_date }}</p> <h2>Chart: Managed domains</h2>
</canvas> <p>{{ data.managed_domains.end_date_count.0 }} managed domains for {{ data.end_date }}</p>
</div> </canvas>
<div class="grid-col"> </div>
<canvas id="myChart2" width="400" height="200" <div class="details-1 grid-col margin-bottom-2">
aria-label="Chart: {{ data.unmanaged_domains_sliced_at_end_date.0 }} unmanaged domains for {{ data.end_date }}" <details class="dja-detail-table" aria-role="button" closed>
role="img" <summary class="dja-details-summary">Details for managed domains</summary>
data-list-one="{{data.unmanaged_domains_sliced_at_start_date}}" <div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
data-list-two="{{data.unmanaged_domains_sliced_at_end_date}}" {% include "admin/analytics_graph_table.html" with data=data property_name="managed_domains" %}
> </div>
<h2>Chart: Unmanaged domains</h2> </details>
<p>{{ data.unmanaged_domains_sliced_at_end_date.0 }} unmanaged domains for {{ data.end_date }}</p> </div>
</canvas> <div class="chart-2 grid-col">
</div> <canvas id="unmanaged-domains-chart" width="400" height="200"
</div> aria-label="Chart: {{ data.unmanaged_domains.end_date_count.0 }} unmanaged domains for {{ data.end_date }}"
role="img"
data-list-one="{{ data.unmanaged_domains.start_date_count }}"
data-list-two="{{ data.unmanaged_domains.end_date_count }}"
>
<h2>Chart: Unmanaged domains</h2>
<p>{{ data.unmanaged_domains.end_date_count.0 }} unmanaged domains for {{ data.end_date }}</p>
</canvas>
</div>
<div class="details-2 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for unmanaged domains</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="unmanaged_domains" %}
</div>
</details>
</div>
<div class="grid-row grid-gap-2 margin-y-2"> {% comment %} Deleted/Ready domains {% endcomment %}
<div class="grid-col"> <div class="chart-3 grid-col">
<canvas id="myChart3" width="400" height="200" <canvas id="deleted-domains-chart" width="400" height="200"
aria-label="Chart: {{ data.deleted_domains_sliced_at_end_date.0 }} deleted domains for {{ data.end_date }}" aria-label="Chart: {{ data.deleted_domains.end_date_count.0 }} deleted domains for {{ data.end_date }}"
role="img" role="img"
data-list-one="{{data.deleted_domains_sliced_at_start_date}}" data-list-one="{{ data.deleted_domains.start_date_count }}"
data-list-two="{{data.deleted_domains_sliced_at_end_date}}" data-list-two="{{ data.deleted_domains.end_date_count }}"
> >
<h2>Chart: Deleted domains</h2> <h2>Chart: Deleted domains</h2>
<p>{{ data.deleted_domains_sliced_at_end_date.0 }} deleted domains for {{ data.end_date }}</p> <p>{{ data.deleted_domains.end_date_count.0 }} deleted domains for {{ data.end_date }}</p>
</canvas> </canvas>
</div> </div>
<div class="grid-col"> <div class="details-3 grid-col margin-bottom-2">
<canvas id="myChart4" width="400" height="200" <details class="dja-detail-table" aria-role="button" closed>
aria-label="Chart: {{ data.ready_domains_sliced_at_end_date.0 }} ready domains for {{ data.end_date }}" <summary class="dja-details-summary">Details for deleted domains</summary>
role="img" <div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
data-list-one="{{data.ready_domains_sliced_at_start_date}}" {% include "admin/analytics_graph_table.html" with data=data property_name="deleted_domains" %}
data-list-two="{{data.ready_domains_sliced_at_end_date}}" </div>
> </details>
<h2>Chart: Ready domains</h2> </div>
<p>{{ data.ready_domains_sliced_at_end_date.0 }} ready domains for {{ data.end_date }}</p> <div class="chart-4 grid-col">
</canvas> <canvas id="ready-domains-chart" width="400" height="200"
</div> aria-label="Chart: {{ data.ready_domains.end_date_count.0 }} ready domains for {{ data.end_date }}"
</div> role="img"
data-list-one="{{ data.ready_domains.start_date_count }}"
data-list-two="{{ data.ready_domains.end_date_count }}"
>
<h2>Chart: Ready domains</h2>
<p>{{ data.ready_domains.end_date_count.0 }} ready domains for {{ data.end_date }}</p>
</canvas>
</div>
<div class="details-4 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for ready domains</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="ready_domains" %}
</div>
</details>
</div>
<div class="grid-row grid-gap-2 margin-y-2"> {% comment %} Requests {% endcomment %}
<div class="grid-col"> <div class="chart-5 grid-col">
<canvas id="myChart5" width="400" height="200" <canvas id="submitted-requests-chart" width="400" height="200"
aria-label="Chart: {{ data.submitted_requests_sliced_at_end_date.0 }} submitted requests for {{ data.end_date }}" aria-label="Chart: {{ data.submitted_requests.end_date_count.0 }} submitted requests for {{ data.end_date }}"
role="img" role="img"
data-list-one="{{data.submitted_requests_sliced_at_start_date}}" data-list-one="{{ data.submitted_requests.start_date_count }}"
data-list-two="{{data.submitted_requests_sliced_at_end_date}}" data-list-two="{{ data.submitted_requests.end_date_count }}"
> >
<h2>Chart: Submitted requests</h2> <h2>Chart: Submitted requests</h2>
<p>{{ data.submitted_requests_sliced_at_end_date.0 }} submitted requests for {{ data.end_date }}</p> <p>{{ data.submitted_requests.end_date_count.0 }} submitted requests for {{ data.end_date }}</p>
</canvas> </canvas>
</div> </div>
<div class="grid-col"> <div class="details-5 grid-col margin-bottom-2">
<canvas id="myChart6" width="400" height="200" <details class="dja-detail-table" aria-role="button" closed>
aria-label="Chart: {{ data.requests_sliced_at_end_date.0 }} requests for {{ data.end_date }}" <summary class="dja-details-summary">Details for submitted requests</summary>
role="img" <div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
data-list-one="{{data.requests_sliced_at_start_date}}" {% include "admin/analytics_graph_table.html" with data=data property_name="submitted_requests" %}
data-list-two="{{data.requests_sliced_at_end_date}}" </div>
> </details>
<h2>Chart: All requests</h2> </div>
<p>{{ data.requests_sliced_at_end_date.0 }} requests for {{ data.end_date }}</p> <div class="chart-6 grid-col">
</canvas> <canvas id="all-requests-chart" width="400" height="200"
</div> aria-label="Chart: {{ data.requests.end_date_count.0 }} requests for {{ data.end_date }}"
</div> role="img"
data-list-one="{{ data.requests.start_date_count }}"
data-list-two="{{ data.requests.end_date_count }}"
>
<h2>Chart: All requests</h2>
<p>{{ data.requests.end_date_count.0 }} requests for {{ data.end_date }}</p>
</canvas>
</div>
<div class="details-6 grid-col margin-bottom-2">
<details class="dja-detail-table" aria-role="button" closed>
<summary class="dja-details-summary">Details for all requests</summary>
<div class="grid-container margin-left-0 padding-left-0 padding-right-0 dja-details-contents">
{% include "admin/analytics_graph_table.html" with data=data property_name="requests" %}
</div>
</details>
</div>
</div>
</div> </div>
</div> </div>

View file

@ -0,0 +1,26 @@
<table class="usa-table usa-table--borderless">
<thead>
<tr>
<th scope="col">Type</th>
<th scope="col">Start date {{ data.start_date }}</th>
<th scope="col">End date {{ data.end_date }} </th>
<tr>
</thead>
<tbody>
{% comment %}
This ugly notation is equivalent to data.property_name.start_date_count.index.
Or represented in the pure python way: data[property_name]["start_date_count"][index]
{% endcomment %}
{% with start_counts=data|get_item:property_name|get_item:"start_date_count" end_counts=data|get_item:property_name|get_item:"end_date_count" %}
{% for org_count_type in data.org_count_types %}
{% with index=forloop.counter %}
<tr>
<th class="padding-left-1" scope="row">{{ org_count_type }}</th>
<td class="padding-left-1">{{ start_counts|slice:index|last }}</td>
<td class="padding-left-1">{{ end_counts|slice:index|last }}</td>
</tr>
{% endwith %}
{% endfor %}
{% endwith %}
</tbody>
</table>

View file

@ -4,24 +4,22 @@
{% for app in app_list %} {% for app in app_list %}
<div class="app-{{ app.app_label }} module{% if app.app_url in request.path|urlencode %} current-app{% endif %}"> <div class="app-{{ app.app_label }} module{% if app.app_url in request.path|urlencode %} current-app{% endif %}">
<table> <table>
{# .gov override: display the app name as a caption rather than a table header #}
{# .gov override: add headers #} <caption class="text-bold">{{ app.name }}</caption>
{% if show_changelinks %}
<colgroup span="3"></colgroup>
{% else %}
<colgroup span="2"></colgroup>
{% endif %}
<thead> <thead>
<tr> <tr>
{% if show_changelinks %} {# .gov override: hide headers #}
<th colspan="3" class="primary-th" scope="colgroup"> {% comment %}
{{ app.name }} {% if show_changelinks %}
</th> <th colspan="3" class="primary-th" scope="colgroup">
{% else %} {{ app.name }}
<th colspan="2" class="primary-th" scope="colgroup"> </th>
{{ app.name }} {% else %}
</th> <th colspan="2" class="primary-th" scope="colgroup">
{% endif %} {{ app.name }}
</th>
{% endif %}
{% endcomment %}
</tr> </tr>
<tr> <tr>
<th scope="col">Model</th> <th scope="col">Model</th>
@ -45,16 +43,17 @@
{% endif %} {% endif %}
{% if model.add_url %} {% if model.add_url %}
<td><a href="{{ model.add_url }}" class="addlink">{% translate 'Add' %}</a></td> {% comment %} Remove the 's' from the end of the string to avoid text like "Add domain requests" {% endcomment %}
<td><a href="{{ model.add_url }}" class="addlink" aria-label="Add {{ model.name|slice:":-1" }}">{% translate 'Add' %}</a></td>
{% else %} {% else %}
<td></td> <td></td>
{% endif %} {% endif %}
{% if model.admin_url and show_changelinks %} {% if model.admin_url and show_changelinks %}
{% if model.view_only %} {% if model.view_only %}
<td><a href="{{ model.admin_url }}" class="viewlink">{% translate 'View' %}</a></td> <td><a href="{{ model.admin_url }}" class="viewlink" aria-label="View {{ model.name }}">{% translate 'View' %}</a></td>
{% else %} {% else %}
<td><a href="{{ model.admin_url }}" class="changelink">{% translate 'Change' %}</a></td> <td><a href="{{ model.admin_url }}" class="changelink" aria-label="Change {{ model.name }}">{% translate 'Change' %}</a></td>
{% endif %} {% endif %}
{% elif show_changelinks %} {% elif show_changelinks %}
<td></td> <td></td>
@ -64,9 +63,20 @@
</table> </table>
</div> </div>
{% endfor %} {% endfor %}
<div class="module module--custom"> <div class="module">
<h2>Analytics</h2> <table class="width-full">
<a class="display-block padding-y-1 padding-x-1" href="{% url 'analytics' %}">Dashboard</a> <caption class="text-bold">Analytics</caption>
<thead>
<tr>
<th scope="col">Reports</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row"><a href="{% url 'analytics' %}">Dashboard</a></th>
</tr>
</tbody>
</table>
</div> </div>
{% else %} {% else %}
<p>{% translate 'You dont have permission to view or edit anything.' %}</p> <p>{% translate 'You dont have permission to view or edit anything.' %}</p>

View file

@ -22,7 +22,6 @@
<script src="{% static 'js/uswds.min.js' %}" defer></script> <script src="{% static 'js/uswds.min.js' %}" defer></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script type="application/javascript" src="{% static 'js/getgov-admin.min.js' %}" defer></script> <script type="application/javascript" src="{% static 'js/getgov-admin.min.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/get-gov-reports.js' %}" defer></script>
<script type="application/javascript" src="{% static 'js/dja-collapse.js' %}" defer></script> <script type="application/javascript" src="{% static 'js/dja-collapse.js' %}" defer></script>
{% endblock %} {% endblock %}
@ -34,8 +33,8 @@
{{ tabtitle }} | {{ tabtitle }} |
{% else %} {% else %}
{{ title }} | {{ title }} |
{% endif %} {% endif %}
{{ site_title|default:_('Django site admin') }} Django admin
{% endblock %} {% endblock %}
{% block extrastyle %}{{ block.super }} {% block extrastyle %}{{ block.super }}
@ -49,6 +48,10 @@
{% endwith %} {% endwith %}
{% endif %} {% endif %}
{% if opts.model_name %}
<a class="usa-skipnav" href="#changelist-filter" aria-label="Skip to the filters section">Skip to filters</a>
{% endif %}
{# Djando update: this div will change to header #} {# Djando update: this div will change to header #}
<div id="header"> <div id="header">
<div id="branding"> <div id="branding">

View file

@ -7,29 +7,43 @@
{% if has_absolute_url %} {% if has_absolute_url %}
<ul> <ul>
<li> <li>
<a href="{% add_preserved_filters history_url %}" class="historylink">{% translate "History" %}</a> <button data-href="{% add_preserved_filters history_url %}" class="historylink use-button-as-link">{% translate "History" %}</button>
</li> </li>
<li> <li>
<a href="{{ absolute_url }}" class="viewsitelink">{% translate "View on site" %}</a> <button data-href="{{ absolute_url }}" class="viewsitelink use-button-as-link">{% translate "View on site" %}</button>
</li> </li>
</ul> </ul>
{% else %} {% else %}
<ul> <ul>
{% if opts.model_name == 'domaininvitation' %}
{% if invitation.status == invitation.DomainInvitationStatus.INVITED %}
<li>
<form method="post">
{% csrf_token %}
<input type="hidden" name="cancel_invitation" value="true">
<button type="submit" class="usa-button--dja">
Cancel invitation
</button>
</form>
</li>
{% endif %}
{% endif %}
<li> <li>
<a href="{% add_preserved_filters history_url %}">{% translate "History" %}</a> <button data-href="{% add_preserved_filters history_url %}" class="historylink use-button-as-link">{% translate "History" %}</button>
</li> </li>
{% if opts.model_name == 'domainrequest' %} {% if opts.model_name == 'domainrequest' %}
<li> <li>
<a id="id-copy-to-clipboard-summary" class="usa-button--dja" type="button" href="#"> <button id="id-copy-to-clipboard-summary" class="usa-button--dja">
<svg class="usa-icon" > <svg class="usa-icon">
<use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use> <use xlink:href="{%static 'img/sprite.svg'%}#content_copy"></use>
</svg> </svg>
<!-- the span is targeted in JS, do not remove --> <!-- the span is targeted in JS, do not remove -->
<span>{% translate "Copy request summary" %}</span> <span>{% translate "Copy request summary" %}</span>
</a> </button>
</li> </li>
{% endif %} {% endif %}
</ul> </ul>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -1,4 +1,5 @@
{% extends "admin/change_list.html" %} {% extends "admin/change_list.html" %}
{% load i18n admin_urls static admin_list %}
{% block content_title %} {% block content_title %}
<h1>{{ title }}</h1> <h1>{{ title }}</h1>
@ -37,6 +38,7 @@
for {{ search_query }} for {{ search_query }}
{% endif %} {% endif %}
</h2> </h2>
{% endblock %} {% endblock %}
{% comment %} Replace the Django ul markup with a div. We'll replace the li with a p in change_list_object_tools {% endcomment %} {% comment %} Replace the Django ul markup with a div. We'll replace the li with a p in change_list_object_tools {% endcomment %}
@ -46,4 +48,25 @@
{{ block.super }} {{ block.super }}
{% endblock %} {% endblock %}
</div> </div>
{% endblock %} {% endblock %}
{% comment %} Replace the Django header markup for clearing all filters with a div. {% endcomment %}
{% block filters %}
{% if cl.has_filters %}
<nav id="changelist-filter" aria-labelledby="changelist-filter-header">
<h2 id="changelist-filter-header">{% translate 'Filter' %}</h2>
{% if cl.is_facets_optional or cl.has_active_filters %}<div id="changelist-filter-extra-actions">
{% if cl.is_facets_optional %}<h3>
{% if cl.add_facets %}<a href="{{ cl.remove_facet_link }}" class="hidelink">{% translate "Hide counts" %}</a>
{% else %}<a href="{{ cl.add_facet_link }}" class="viewlink">{% translate "Show counts" %}</a>{% endif %}
</h3>{% endif %}
{% if cl.has_active_filters %}<div class="margin-2">
<a href="{{ cl.clear_all_filters_qs }}" role="link">&#10006; {% translate "Clear all filters" %}</a>
</div>{% endif %}
</div>{% endif %}
{% for spec in cl.filter_specs %}{% admin_list_filter cl spec %}{% endfor %}
</nav>
{% endif %}
{% endblock %}

View file

@ -5,9 +5,9 @@
{% if has_add_permission %} {% if has_add_permission %}
<p class="margin-0 padding-0"> <p class="margin-0 padding-0">
{% url cl.opts|admin_urlname:'add' as add_url %} {% url cl.opts|admin_urlname:'add' as add_url %}
<a href="{% add_preserved_filters add_url is_popup to_field %}" class="addlink"> <button data-href="{% add_preserved_filters add_url is_popup to_field %}" class="addlink use-button-as-link">
{% blocktranslate with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktranslate %} {% blocktranslate with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktranslate %}
</a> </button>
</p> </p>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View file

@ -19,11 +19,11 @@ Load our custom filters to extract info from the django generated markup.
{% if results.0|contains_checkbox %} {% if results.0|contains_checkbox %}
{# .gov - hardcode the select all checkbox #} {# .gov - hardcode the select all checkbox #}
<th scope="col" class="action-checkbox-column" title="Toggle all"> <th scope="col" class="action-checkbox-column" title="Toggle">
<div class="text"> <div class="text">
<span> <span>
<input type="checkbox" id="action-toggle">
<label for="action-toggle" class="usa-sr-only">Toggle all</label> <label for="action-toggle" class="usa-sr-only">Toggle all</label>
<input type="checkbox" id="action-toggle">
</span> </span>
</div> </div>
<div class="clear"></div> <div class="clear"></div>
@ -34,9 +34,9 @@ Load our custom filters to extract info from the django generated markup.
{% if header.sortable %} {% if header.sortable %}
{% if header.sort_priority > 0 %} {% if header.sort_priority > 0 %}
<div class="sortoptions"> <div class="sortoptions">
<a class="sortremove" href="{{ header.url_remove }}" title="{% translate "Remove from sorting" %}"></a> <a class="sortremove" href="{{ header.url_remove }}" aria-label="{{ header.text }}" title="{% translate "Remove from sorting" %}"></a>
{% if num_sorted_fields > 1 %}<span class="sortpriority" title="{% blocktranslate with priority_number=header.sort_priority %}Sorting priority: {{ priority_number }}{% endblocktranslate %}">{{ header.sort_priority }}</span>{% endif %} {% if num_sorted_fields > 1 %}<span class="sortpriority" title="{% blocktranslate with priority_number=header.sort_priority %}Sorting priority: {{ priority_number }}{% endblocktranslate %}">{{ header.sort_priority }}</span>{% endif %}
<a href="{{ header.url_toggle }}" class="toggle {% if header.ascending %}ascending{% else %}descending{% endif %}" title="{% translate "Toggle sorting" %}"></a> <a href="{{ header.url_toggle }}" aria-label="{{ header.text }} sorting {% if header.ascending %}ascending{% else %}descending{% endif %}" class="toggle {% if header.ascending %}ascending{% else %}descending{% endif %}" title="{% translate "Toggle sorting" %}"></a>
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
@ -61,10 +61,10 @@ Load our custom filters to extract info from the django generated markup.
{% endif %} {% endif %}
<tr> <tr>
{% with result_value=result.0|extract_value %} {% with result_value=result.0|extract_value %}
{% with result_label=result.1|extract_a_text %} {% with result_label=result.1|extract_a_text checkbox_id="select-"|add:result_value %}
<td> <td>
<input type="checkbox" name="_selected_action" value="{{ result_value|default:'value' }}" id="{{ result_value|default:'value' }}-{{ result_label|default:'label' }}" class="action-select"> <label class="usa-sr-only" for="{{ checkbox_id }}">Select row {{ result_label|default:'label' }}</label>
<label class="usa-sr-only" for="{{ result_value|default:'value' }}-{{ result_label|default:'label' }}">{{ result_label|default:'label' }}</label> <input type="checkbox" name="_selected_action" value="{{ result_value|default:'value' }}" id="{{ checkbox_id }}" class="action-select">
</td> </td>
{% endwith %} {% endwith %}
{% endwith %} {% endwith %}

View file

@ -0,0 +1,7 @@
{% load i18n %}
{% load admin_urls %}
{% if has_export_permission %}
{% comment %} Uses the initButtonLinks {% endcomment %}
<li><button class="export_link use-button-as-link" data-href="{% url opts|admin_urlname:"export" %}">{% trans "Export" %}</button></li>
{% endif %}

View file

@ -3,6 +3,6 @@
{% if has_import_permission %} {% if has_import_permission %}
{% if not IS_PRODUCTION %} {% if not IS_PRODUCTION %}
<li><a href='{% url opts|admin_urlname:"import" %}' class="import_link">{% trans "Import" %}</a></li> <li><button class="import_link use-button-as-link" data-href="{% url opts|admin_urlname:"import" %}">{% trans "Import" %}</button></li>
{% endif %} {% endif %}
{% endif %} {% endif %}

View file

@ -0,0 +1,26 @@
{% comment %} This is an override of the django search bar to add better accessibility compliance.
There are no blocks defined here, so we had to copy the code.
https://github.com/django/django/blob/main/django/contrib/admin/templates/admin/search_form.html
{% endcomment %}
{% load i18n static %}
{% if cl.search_fields %}
<div id="toolbar"><form id="changelist-search" method="get" role="search">
<div><!-- DIV needed for valid HTML -->
{% comment %} .gov override - removed for="searchbar" {% endcomment %}
<label><img src="{% static "admin/img/search.svg" %}" alt="Search"></label>
<input type="text" size="40" name="{{ search_var }}" value="{{ cl.query }}" id="searchbar"{% if cl.search_help_text %} aria-describedby="searchbar_helptext"{% endif %}>
<input type="submit" value="{% translate 'Search' %}">
{% if show_result_count %}
<span class="small quiet">{% blocktranslate count counter=cl.result_count %}{{ counter }} result{% plural %}{{ counter }} results{% endblocktranslate %} (<a href="?{% if cl.is_popup %}{{ is_popup_var }}=1{% if cl.add_facets %}&{% endif %}{% endif %}{% if cl.add_facets %}{{ is_facets_var }}{% endif %}">{% if cl.show_full_result_count %}{% blocktranslate with full_result_count=cl.full_result_count %}{{ full_result_count }} total{% endblocktranslate %}{% else %}{% translate "Show all" %}{% endif %}</a>)</span>
{% endif %}
{% for pair in cl.params.items %}
{% if pair.0 != search_var %}<input type="hidden" name="{{ pair.0 }}" value="{{ pair.1 }}">{% endif %}
{% endfor %}
</div>
{% if cl.search_help_text %}
<br class="clear">
{% comment %} .gov override - added for="searchbar" {% endcomment %}
<label class="help" id="searchbar_helptext" for="searchbar">{{ cl.search_help_text }}</label>
{% endif %}
</form></div>
{% endif %}

View file

@ -11,4 +11,4 @@
</div> </div>
</div> </div>
{{ block.super }} {{ block.super }}
{% endblock %} {% endblock %}

View file

@ -10,7 +10,6 @@
<th>Title</th> <th>Title</th>
<th>Email</th> <th>Email</th>
<th>Phone</th> <th>Phone</th>
<th>Roles</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
</thead> </thead>
@ -28,11 +27,6 @@
{% endif %} {% endif %}
</td> </td>
<td>{{ member.user.phone }}</td> <td>{{ member.user.phone }}</td>
<td>
{% for role in member.user|portfolio_role_summary:original %}
<span class="usa-tag bg-primary-dark text-semibold">{{ role }}</span>
{% endfor %}
</td>
<td class="padding-left-1 text-size-small"> <td class="padding-left-1 text-size-small">
{% if member.user.email %} {% if member.user.email %}
<input aria-hidden="true" class="display-none" value="{{ member.user.email }}" /> <input aria-hidden="true" class="display-none" value="{{ member.user.email }}" />

View file

@ -1,37 +1,41 @@
{% load i18n %} {% load i18n %}
{% load static field_helpers url_helpers %} {% load static field_helpers url_helpers %}
<details data-filter-title="{{ title }}" open="">
<summary aria-label="Show/hide {{ title }} filters" role="button">
{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}
</summary>
<ul class="mulitple-choice">
{% for choice in choices %}
{% if choice.reset %}
<li{% if choice.selected %} class="selected"{% endif %}">
<a href="{{ choice.query_string|iriencode }}" title="{{ choice.display }}">{{ choice.display }}</a>
</li>
{% endif %}
{% endfor %}
<h3>{% blocktrans with filter_title=title %} By {{ filter_title }} {% endblocktrans %}</h3> {% for choice in choices %}
<ul class="mulitple-choice"> {% if not choice.reset %}
{% for choice in choices %} <li{% if choice.selected %} class="selected"{% endif %}">
{% if choice.reset %} {% if choice.selected and choice.exclude_query_string %}
<li{% if choice.selected %} class="selected"{% endif %}"> <a role="menuitemcheckbox" class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }}
<a href="{{ choice.query_string|iriencode }}" title="{{ choice.display }}">{{ choice.display }}</a> <svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
</li> <use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
{% endif %} </svg>
{% endfor %} <svg class="usa-icon position-absolute z-100 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
{% for choice in choices %} <use xlink:href="{%static 'img/sprite.svg'%}#check"></use>
{% if not choice.reset %} </svg>
<li{% if choice.selected %} class="selected"{% endif %}"> </a>
{% if choice.selected and choice.exclude_query_string %} {% endif %}
<a class="choice-filter choice-filter--checked" href="{{ choice.exclude_query_string|iriencode }}">{{ choice.display }} {% if not choice.selected and choice.include_query_string %}
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <a role="menuitemcheckbox" class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }}
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use> <svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24">
</svg> <use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
<svg class="usa-icon position-absolute z-100 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24"> </svg>
<use xlink:href="{%static 'img/sprite.svg'%}#check"></use> </a>
</svg> {% endif %}
</a> </li>
{% endif %} {% endif %}
{% if not choice.selected and choice.include_query_string %} {% endfor %}
<a class="choice-filter" href="{{ choice.include_query_string|iriencode }}">{{ choice.display }} </ul>
<svg class="usa-icon position-absolute z-0 left-0" aria-hidden="true" focusable="false" role="img" width="24" height="24"> </details>
<use xlink:href="{%static 'img/sprite.svg'%}#check_box_outline_blank"></use>
</svg>
</a>
{% endif %}
</li>
{% endif %}
{% endfor %}
</ul>

View file

@ -0,0 +1,17 @@
{% extends "admin/delete_confirmation.html" %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you cancel the portfolio invitation here, it won't trigger any emails. It also won't remove the user's
portfolio access if they already logged in. Go to the
<a href="{% url 'admin:registrar_userportfoliopermission_changelist' %}">
User Portfolio Permissions
</a>
table if you want to remove the user from a portfolio.
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends "admin/delete_confirmation.html" %}
{% block content_subtitle %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body margin-left-1 maxw-none">
<p class="usa-alert__text maxw-none">
If you remove someone from a portfolio here, it will not send any emails when you click "Save".
</p>
</div>
</div>
{{ block.super }}
{% endblock %}

View file

@ -21,7 +21,7 @@
{% if field and field.field and field.field.descriptions %} {% if field and field.field and field.field.descriptions %}
{% with description=field.field.descriptions|get_dict_value:option.value %} {% with description=field.field.descriptions|get_dict_value:option.value %}
{% if description %} {% if description %}
<p class="margin-0 margin-top-1 font-body-2xs">{{ description }}</p> <p class="margin-0 font-body-2xs">{{ description }}</p>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% endif %} {% endif %}

View file

@ -16,10 +16,10 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a> <a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a> <a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-users' pk=domain.id %}" class="usa-breadcrumb__link"><span>Domain managers</span></a> <a href="{% url 'domain-users' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>Domain managers</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page"> <li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Add a domain manager</span> <span>Add a domain manager</span>
@ -27,7 +27,7 @@
</ol> </ol>
</nav> </nav>
{% else %} {% else %}
{% url 'domain-users' pk=domain.id as url %} {% url 'domain-users' domain_pk=domain.id as url %}
<nav class="usa-breadcrumb padding-top-0" aria-label="Domain manager breadcrumb"> <nav class="usa-breadcrumb padding-top-0" aria-label="Domain manager breadcrumb">
<ol class="usa-breadcrumb__list"> <ol class="usa-breadcrumb__list">
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
@ -42,17 +42,18 @@
{% endblock breadcrumb %} {% endblock breadcrumb %}
<h1>Add a domain manager</h1> <h1>Add a domain manager</h1>
{% if has_organization_feature_flag %} {% if portfolio %}
<p>
Provide an email address for the domain manager youd like to add.
Theyll need to access the registrar using a Login.gov account thats associated with this email address.
Domain managers can be a member of only one .gov organization.
</p>
{% else %}
<p> <p>
You can add another user to help manage your domain. Users can only be a member of one .gov organization, Provide an email address for the domain manager youd like to add.
and they'll need to sign in with their Login.gov account. Theyll need to access the registrar using a Login.gov account thats associated with this email address.
</p> </p>
{% else %} {% endif %}
<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> <form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %} {% csrf_token %}

View file

@ -35,7 +35,7 @@
{# UNKNOWN domains would not have an expiration date and thus would show 'Expired' #} {# UNKNOWN domains would not have an expiration date and thus would show 'Expired' #}
{% if domain.is_expired and domain.state != domain.State.UNKNOWN %} {% if domain.is_expired and domain.state != domain.State.UNKNOWN %}
Expired Expired
{% elif has_domain_renewal_flag and domain.is_expiring %} {% elif domain.is_expiring %}
Expiring soon Expiring soon
{% elif domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED %} {% elif domain.state == domain.State.UNKNOWN or domain.state == domain.State.DNS_NEEDED %}
DNS needed DNS needed
@ -46,17 +46,17 @@
{% if domain.get_state_help_text %} {% if domain.get_state_help_text %}
<p class="margin-y-0 text-primary-darker"> <p class="margin-y-0 text-primary-darker">
{% if has_domain_renewal_flag and domain.is_expired and is_domain_manager %} {% if domain.is_expired and is_domain_manager %}
This domain has expired, but it is still online. This domain has expired, but it is still online.
{% url 'domain-renewal' pk=domain.id as url %} {% url 'domain-renewal' domain_pk=domain.id as url %}
<a href="{{ url }}" class="usa-link">Renew to maintain access.</a> <a href="{{ url }}" class="usa-link">Renew to maintain access.</a>
{% elif has_domain_renewal_flag and domain.is_expiring and is_domain_manager %} {% elif domain.is_expiring and is_domain_manager %}
This domain will expire soon. This domain will expire soon.
{% url 'domain-renewal' pk=domain.id as url %} {% url 'domain-renewal' domain_pk=domain.id as url %}
<a href="{{ url }}" class="usa-link">Renew to maintain access.</a> <a href="{{ url }}" class="usa-link">Renew to maintain access.</a>
{% elif has_domain_renewal_flag and domain.is_expiring and is_portfolio_user %} {% elif domain.is_expiring and is_portfolio_user %}
This domain will expire soon. Contact one of the listed domain managers to renew the domain. This domain will expire soon. Contact one of the listed domain managers to renew the domain.
{% elif has_domain_renewal_flag and domain.is_expired and is_portfolio_user %} {% elif domain.is_expired and is_portfolio_user %}
This domain has expired, but it is still online. Contact one of the listed domain managers to renew the domain. This domain has expired, but it is still online. Contact one of the listed domain managers to renew the domain.
{% else %} {% else %}
{{ domain.get_state_help_text }} {{ domain.get_state_help_text }}
@ -82,7 +82,7 @@
{% endif %} {% endif %}
{% url 'domain-dns-nameservers' pk=domain.id as url %} {% url 'domain-dns-nameservers' domain_pk=domain.id as url %}
{% if domain.nameservers|length > 0 %} {% if domain.nameservers|length > 0 %}
{% include "includes/summary_item.html" with title='DNS name servers' domains='true' value=domain.nameservers list='true' edit_link=url editable=is_editable %} {% include "includes/summary_item.html" with title='DNS name servers' domains='true' value=domain.nameservers list='true' edit_link=url editable=is_editable %}
{% else %} {% else %}
@ -95,7 +95,7 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% url 'domain-dns-dnssec' pk=domain.id as url %} {% url 'domain-dns-dnssec' domain_pk=domain.id as url %}
{% if domain.dnssecdata is not None %} {% if domain.dnssecdata is not None %}
{% include "includes/summary_item.html" with title='DNSSEC' value='Enabled' edit_link=url editable=is_editable %} {% include "includes/summary_item.html" with title='DNSSEC' value='Enabled' edit_link=url editable=is_editable %}
{% else %} {% else %}
@ -104,26 +104,26 @@
{% if portfolio %} {% if portfolio %}
{% if has_any_domains_portfolio_permission and has_edit_portfolio_permission %} {% if has_any_domains_portfolio_permission and has_edit_portfolio_permission %}
{% url 'domain-suborganization' pk=domain.id as url %} {% url 'domain-suborganization' domain_pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_portfolio_permission %} {% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_portfolio_permission %}
{% elif has_any_domains_portfolio_permission and has_view_portfolio_permission %} {% elif has_any_domains_portfolio_permission and has_view_portfolio_permission %}
{% url 'domain-suborganization' pk=domain.id as url %} {% url 'domain-suborganization' domain_pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_view_portfolio_permission view_button=True %} {% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_view_portfolio_permission view_button=True %}
{% endif %} {% endif %}
{% else %} {% else %}
{% url 'domain-org-name-address' pk=domain.id as url %} {% url 'domain-org-name-address' domain_pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Organization' value=domain.domain_info address='true' edit_link=url editable=is_editable %} {% include "includes/summary_item.html" with title='Organization' value=domain.domain_info address='true' edit_link=url editable=is_editable %}
{% url 'domain-senior-official' pk=domain.id as url %} {% url 'domain-senior-official' domain_pk=domain.id as url %}
{% include "includes/summary_item.html" with title='Senior official' value=domain.domain_info.senior_official contact='true' edit_link=url editable=is_editable %} {% include "includes/summary_item.html" with title='Senior official' value=domain.domain_info.senior_official contact='true' edit_link=url editable=is_editable %}
{% endif %} {% endif %}
{% url 'domain-security-email' pk=domain.id as url %} {% url 'domain-security-email' domain_pk=domain.id as url %}
{% if security_email is not None and security_email not in hidden_security_emails%} {% if security_email is not None and security_email not in hidden_security_emails%}
{% include "includes/summary_item.html" with title='Security email' value=security_email edit_link=url editable=is_editable %} {% include "includes/summary_item.html" with title='Security email' value=security_email edit_link=url editable=is_editable %}
{% else %} {% else %}
{% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url editable=is_editable %} {% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url editable=is_editable %}
{% endif %} {% endif %}
{% url 'domain-users' pk=domain.id as url %} {% url 'domain-users' domain_pk=domain.id as url %}
{% if portfolio %} {% if portfolio %}
{% include "includes/summary_item.html" with title='Domain managers' domain_permissions=True value=domain edit_link=url editable=is_editable %} {% include "includes/summary_item.html" with title='Domain managers' domain_permissions=True value=domain edit_link=url editable=is_editable %}
{% else %} {% else %}

View file

@ -13,7 +13,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a> <a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a> <a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page"> <li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>DNS</span> <span>DNS</span>
@ -30,14 +30,14 @@
<p>You can enter your name servers, as well as other DNS-related information, in the following sections:</p> <p>You can enter your name servers, as well as other DNS-related information, in the following sections:</p>
{% url 'domain-dns-nameservers' pk=domain.id as url %} {% url 'domain-dns-nameservers' domain_pk=domain.id as url %}
<ul class="usa-list"> <ul class="usa-list">
<li><a href="{{ url }}">Name servers</a></li> <li><a href="{{ url }}">Name servers</a></li>
{% url 'domain-dns-dnssec' pk=domain.id as url %} {% url 'domain-dns-dnssec' domain_pk=domain.id as url %}
<li><a href="{{ url }}">DNSSEC</a></li> <li><a href="{{ url }}">DNSSEC</a></li>
{% if dns_prototype_flag and is_valid_domain %} {% if dns_prototype_flag and is_valid_domain %}
<li><a href="{% url 'prototype-domain-dns' pk=domain.id %}">Prototype DNS record creator</a></li> <li><a href="{% url 'prototype-domain-dns' domain_pk=domain.id %}">Prototype DNS record creator</a></li>
{% endif %} {% endif %}
</ul> </ul>

View file

@ -14,10 +14,10 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a> <a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a> <a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-dns' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a> <a href="{% url 'domain-dns' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page"> <li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>DNSSEC</span> <span>DNSSEC</span>
@ -69,7 +69,7 @@
<p class="margin-y-0">It is strongly recommended that you only enable DNSSEC if you know how to set it up properly at your hosting service. If you make a mistake, it could cause your domain name to stop working.</p> <p class="margin-y-0">It is strongly recommended that you only enable DNSSEC if you know how to set it up properly at your hosting service. If you make a mistake, it could cause your domain name to stop working.</p>
</div> </div>
</div> </div>
<a href="{% url 'domain-dns-dnssec-dsdata' pk=domain.id %}" class="usa-button">Enable DNSSEC</a> <a href="{% url 'domain-dns-dnssec-dsdata' domain_pk=domain.id %}" class="usa-button">Enable DNSSEC</a>
</div> </div>
{% endif %} {% endif %}
</form> </form>

View file

@ -18,13 +18,13 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a> <a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a> <a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-dns' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a> <a href="{% url 'domain-dns' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-dns-dnssec' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNSSEC</span></a> <a href="{% url 'domain-dns-dnssec' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>DNSSEC</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page"> <li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>DS data</span> <span>DS data</span>

View file

@ -19,10 +19,10 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a> <a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a> <a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
<a href="{% url 'domain-dns' pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a> <a href="{% url 'domain-dns' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>DNS</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page"> <li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>DNS name servers</span> <span>DNS name servers</span>

View file

@ -29,7 +29,10 @@
{% csrf_token %} {% csrf_token %}
{% if domain.domain_info.generic_org_type == 'federal' %} {% if domain.domain_info.generic_org_type == 'federal' %}
{% input_with_errors form.federal_agency %} <h4 class="margin-bottom-05">Federal Agency</h4>
<p class="margin-top-0">
{{ domain.domain_info.federal_agency }}
</p>
{% endif %} {% endif %}
{% input_with_errors form.organization_name %} {% input_with_errors form.organization_name %}

View file

@ -24,7 +24,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a> <a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{domain.name}}</span></a> <a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{domain.name}}</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page"> <li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Renewal Form</span> <span>Renewal Form</span>
@ -63,14 +63,14 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
{% url 'domain-security-email' pk=domain.id as url %} {% url 'domain-security-email' domain_pk=domain.id as url %}
{% if security_email is not None and security_email not in hidden_security_emails%} {% if security_email is not None and security_email not in hidden_security_emails%}
{% include "includes/summary_item.html" with title='Security email' value=security_email custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %} {% include "includes/summary_item.html" with title='Security email' value=security_email custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %}
{% else %} {% else %}
{% include "includes/summary_item.html" with title='Security email' value='None provided' custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %} {% include "includes/summary_item.html" with title='Security email' value='None provided' custom_text_for_value_none='We strongly recommend that you provide a security email. This email will allow the public to report observed or suspected security issues on your domain.' edit_link=url editable=is_editable %}
{% endif %} {% endif %}
{% url 'domain-users' pk=domain.id as url %} {% url 'domain-users' domain_pk=domain.id as url %}
{% if portfolio %} {% if portfolio %}
{% include "includes/summary_item.html" with title='Domain managers' domain_permissions=True value=domain edit_link=url editable=is_editable %} {% include "includes/summary_item.html" with title='Domain managers' domain_permissions=True value=domain edit_link=url editable=is_editable %}
{% else %} {% else %}
@ -91,7 +91,7 @@
Acknowledgement of .gov domain requirements </h3> Acknowledgement of .gov domain requirements </h3>
</legend> </legend>
<form method="post" action="{% url 'domain-renewal' pk=domain.id %}"> <form method="post" action="{% url 'domain-renewal' domain_pk=domain.id %}">
{% csrf_token %} {% csrf_token %}
<div class="usa-checkbox"> <div class="usa-checkbox">

View file

@ -20,7 +20,7 @@
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
<button type="submit" name="submit_button" value="save" class="usa-button usa-button--with-icon usa-button--unstyled"> <button id="submit-domain-request--site-button" type="submit" name="submit_button" value="save" class="usa-button usa-button--with-icon usa-button--unstyled">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use> <use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add another site</span> </svg><span class="margin-left-05">Add another site</span>

View file

@ -36,7 +36,7 @@
</ol> </ol>
</nav> </nav>
{% elif steps.prev %} {% elif steps.prev %}
<a href="{% namespaced_url 'domain-request' steps.prev id=domain_request_id %}" class="breadcrumb__back"> <a href="{% namespaced_url 'domain-request' steps.prev domain_request_pk=domain_request_id %}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#arrow_back"></use> <use xlink:href="{%static 'img/sprite.svg'%}#arrow_back"></use>
</svg><span class="margin-left-05">Previous step</span> </svg><span class="margin-left-05">Previous step</span>

View file

@ -15,7 +15,7 @@
</svg> </svg>
{% endif %} {% endif %}
{% endif %} {% endif %}
<a href="{% namespaced_url 'domain-request' this_step id=domain_request_id %}" <a href="{% namespaced_url 'domain-request' this_step domain_request_pk=domain_request_id %}"
{% if this_step == steps.current %} {% if this_step == steps.current %}
class="usa-current" class="usa-current"
{% else %} {% else %}

View file

@ -17,8 +17,8 @@
<p>If you withdraw your request, we won't review it. Once you withdraw your request, you can edit it and submit it again. </p> <p>If you withdraw your request, we won't review it. Once you withdraw your request, you can edit it and submit it again. </p>
<p><a href="{% url 'domain-request-withdrawn' DomainRequest.id %}" class="usa-button withdraw">Withdraw request</a> <p><a href="{% url 'domain-request-withdrawn' domain_request_pk=DomainRequest.id %}" class="usa-button withdraw">Withdraw request</a>
<a href="{% url 'domain-request-status' DomainRequest.id %}">Cancel</a></p> <a href="{% url 'domain-request-status' domain_request_pk=DomainRequest.id %}">Cancel</a></p>
</div> </div>
</div> </div>

View file

@ -16,7 +16,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a> <a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a> <a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page"> <li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Security email</span> <span>Security email</span>

View file

@ -17,14 +17,14 @@
{% endif %} {% endif %}
<li class="usa-sidenav__item"> <li class="usa-sidenav__item">
{% url 'domain-dns' pk=domain.id as url %} {% url 'domain-dns' domain_pk=domain.id as url %}
<a href="{{ url }}" {% if request.path|startswith:url %}class="usa-current"{% endif %}"> <a href="{{ url }}" {% if request.path|startswith:url %}class="usa-current"{% endif %}">
DNS DNS
</a> </a>
{% if request.path|startswith:url %} {% if request.path|startswith:url %}
<ul class="usa-sidenav__sublist"> <ul class="usa-sidenav__sublist">
<li class="usa-sidenav__item"> <li class="usa-sidenav__item">
{% url 'domain-dns-nameservers' pk=domain.id as url %} {% url 'domain-dns-nameservers' domain_pk=domain.id as url %}
<a href="{{ url }}" <a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %} {% if request.path == url %}class="usa-current"{% endif %}
> >
@ -33,7 +33,7 @@
</li> </li>
<li class="usa-sidenav__item"> <li class="usa-sidenav__item">
{% url 'domain-dns-dnssec' pk=domain.id as url %} {% url 'domain-dns-dnssec' domain_pk=domain.id as url %}
<a href="{{ url }}" <a href="{{ url }}"
{% if request.path|startswith:url %}class="usa-current"{% endif %} {% if request.path|startswith:url %}class="usa-current"{% endif %}
> >
@ -43,7 +43,7 @@
{% if domain.dnssecdata is not None or request.path|startswith:url and request.path|endswith:'dsdata' %} {% if domain.dnssecdata is not None or request.path|startswith:url and request.path|endswith:'dsdata' %}
<ul class="usa-sidenav__sublist"> <ul class="usa-sidenav__sublist">
<li class="usa-sidenav__item"> <li class="usa-sidenav__item">
{% url 'domain-dns-dnssec-dsdata' pk=domain.id as url %} {% url 'domain-dns-dnssec-dsdata' domain_pk=domain.id as url %}
<a href="{{ url }}" <a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %} {% if request.path == url %}class="usa-current"{% endif %}
> >
@ -81,7 +81,7 @@
{% endwith %} {% endwith %}
{% if has_domain_renewal_flag and is_domain_manager%} {% if is_domain_manager%}
{% if domain.is_expiring or domain.is_expired %} {% if domain.is_expiring or domain.is_expired %}
{% with url_name="domain-renewal" %} {% with url_name="domain-renewal" %}
{% include "includes/domain_sidenav_item.html" with item_text="Renewal form" %} {% include "includes/domain_sidenav_item.html" with item_text="Renewal form" %}

View file

@ -16,7 +16,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a> <a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a> <a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page"> <li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Suborganization</span> <span>Suborganization</span>

View file

@ -13,7 +13,7 @@
<a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a> <a href="{% url 'domains' %}" class="usa-breadcrumb__link"><span>Domains</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item"> <li class="usa-breadcrumb__list-item">
<a href="{% url 'domain' pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a> <a href="{% url 'domain' domain_pk=domain.id %}" class="usa-breadcrumb__link"><span>{{ domain.name }}</span></a>
</li> </li>
<li class="usa-breadcrumb__list-item usa-current" aria-current="page"> <li class="usa-breadcrumb__list-item usa-current" aria-current="page">
<span>Domain managers</span> <span>Domain managers</span>
@ -25,29 +25,25 @@
<h1>Domain managers</h1> <h1>Domain managers</h1>
{% comment %}Copy below differs depending on whether view is in portfolio mode.{% endcomment %}
{% if not portfolio %}
<p> <p>
Domain managers can update all information related to a domain within the Domain managers can update information related to this domain, including security email and DNS name servers.
.gov registrar, including security email and DNS name servers.
</p> </p>
{% else %}
<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.
</p>
{% endif %}
<ul class="usa-list"> <ul class="usa-list">
<li>There is no limit to the number of domain managers you can add.</li> <li>There is no limit on the number of domain managers you can add.</li>
<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>All domain managers must keep their contact information updated and be responsive if contacted by the .gov team.</li>
{% if not portfolio %}<li>All domain managers will be notified when updates are made to this domain.</li>{% endif %} <li>All domain managers will be notified when updates are made to this domain and when managers are added or removed.</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>Domains must have at least one manager. You cant remove yourself if youre the only one assigned to this domain.</li>
{% if portfolio %} Add another domain manager before you remove yourself from this domain.{% endif %}</li>
</ul> </ul>
{% if domain_manager_roles and domain_manager_roles|length == 1 %}
<div class="usa-alert usa-alert--info usa-alert--slim">
<div class="usa-alert__body">
This domain has only one manager. Consider adding another manager to ensure the domain has continuous oversight and support.
</div>
</div>
{% endif %}
{% if domain_manager_roles %} {% if domain_manager_roles %}
<section class="section-outlined" id="domain-managers"> <section class="section-outlined" id="domain-managers">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
@ -93,7 +89,7 @@
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove yourself as a domain manager?" modal_description="You will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button_id="user-delete-button-"|add:counter_str|safe modal_button_text="Yes, remove myself" modal_button_class="usa-button--secondary" %} {% include 'includes/modal.html' with modal_heading="Are you sure you want to remove yourself as a domain manager?" modal_description="You will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button_id="user-delete-button-"|add:counter_str|safe modal_button_text="Yes, remove myself" modal_button_class="usa-button--secondary" %}
{% endwith %} {% endwith %}
</div> </div>
<form method="POST" id="user-delete-form-{{ forloop.counter }}" action="{% url "domain-user-delete" pk=domain.id user_pk=item.permission.user.id %}" > <form method="POST" id="user-delete-form-{{ forloop.counter }}" action="{% url "domain-user-delete" domain_pk=domain.id user_pk=item.permission.user.id %}" >
{% csrf_token %} {% csrf_token %}
</form> </form>
{% else %} {% else %}
@ -108,7 +104,7 @@
{% include 'includes/modal.html' with modal_heading="Are you sure you want to remove " heading_value=email|add:"?" modal_description="<strong>"|add:email|add:"</strong> will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button_id="user-delete-button-"|add:counter_str|safe modal_button_text="Yes, remove domain manager" modal_button_class="usa-button--secondary" %} {% include 'includes/modal.html' with modal_heading="Are you sure you want to remove " heading_value=email|add:"?" modal_description="<strong>"|add:email|add:"</strong> will no longer be able to manage the domain <strong>"|add:domain_name|add:"</strong>."|safe modal_button_id="user-delete-button-"|add:counter_str|safe modal_button_text="Yes, remove domain manager" modal_button_class="usa-button--secondary" %}
{% endwith %} {% endwith %}
</div> </div>
<form method="POST" id="user-delete-form-{{ forloop.counter }}" action="{% url "domain-user-delete" pk=domain.id user_pk=item.permission.user.id %}"> <form method="POST" id="user-delete-form-{{ forloop.counter }}" action="{% url "domain-user-delete" domain_pk=domain.id user_pk=item.permission.user.id %}">
{% csrf_token %} {% csrf_token %}
</form> </form>
{% endif %} {% endif %}
@ -123,7 +119,7 @@
></div> ></div>
{% endif %} {% endif %}
<a class="usa-button usa-button--unstyled usa-button--with-icon" href="{% url 'domain-users-add' pk=domain.id %}"> <a class="usa-button usa-button--unstyled usa-button--with-icon" href="{% url 'domain-users-add' domain_pk=domain.id %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24"> <svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use> <use xlink:href="{%static 'img/sprite.svg'%}#add_circle"></use>
</svg><span class="margin-left-05">Add a domain manager</span> </svg><span class="margin-left-05">Add a domain manager</span>
@ -154,7 +150,7 @@
{% if not portfolio %}<td data-label="Status">{{ invitation.domain_invitation.status|title }}</td>{% endif %} {% if not portfolio %}<td data-label="Status">{{ invitation.domain_invitation.status|title }}</td>{% endif %}
<td> <td>
{% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %} {% if invitation.domain_invitation.status == invitation.domain_invitation.DomainInvitationStatus.INVITED %}
<form method="POST" action="{% url "invitation-cancel" pk=invitation.domain_invitation.id %}"> <form method="POST" action="{% url "invitation-cancel" domain_invitation_pk=invitation.domain_invitation.id %}">
{% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel"> {% csrf_token %}<input type="submit" class="usa-button--unstyled text-no-underline cursor-pointer" value="Cancel">
</form> </form>
{% endif %} {% endif %}

View file

@ -17,7 +17,7 @@ Domains should uniquely identify a government organization and be clear to the g
ACTION NEEDED ACTION NEEDED
First, we need you to identify a new domain name that meets our naming requirements for your type of organization. Then, log in to the registrar and update the name in your domain request. <https://manage.get.gov/> Once you submit your updated request, well resume the adjudication process. First, we need you to identify a new domain name that meets our naming requirements for your type of organization. Then, log in to the registrar and update the name in your domain request. <{{ manage_url }}> Once you submit your updated request, well resume the adjudication process.
If you have questions or want to discuss potential domain names, reply to this email. If you have questions or want to discuss potential domain names, reply to this email.

View file

@ -21,7 +21,7 @@ We expect a senior official to be someone in a role of significant, executive re
ACTION NEEDED ACTION NEEDED
Reply to this email with a justification for naming {{ domain_request.senior_official.get_formatted_name }} as the senior official. If you have questions or comments, include those in your reply. Reply to this email with a justification for naming {{ domain_request.senior_official.get_formatted_name }} as the senior official. If you have questions or comments, include those in your reply.
Alternatively, you can log in to the registrar and enter a different senior official for this domain request. <https://manage.get.gov/> Once you submit your updated request, well resume the adjudication process. Alternatively, you can log in to the registrar and enter a different senior official for this domain request. <{{ manage_url }}> Once you submit your updated request, well resume the adjudication process.
THANK YOU THANK YOU

View file

@ -4,11 +4,10 @@ Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first
{{ requestor_email }} has invited you to manage: {{ requestor_email }} has invited you to manage:
{% for domain in domains %}{{ domain.name }} {% for domain in domains %}{{ domain.name }}
{% endfor %} {% endfor %}
To manage domain information, visit the .gov registrar <https://manage.get.gov>. To manage domain information, visit the .gov registrar <{{ manage_url }}>.
---------------------------------------------------------------- ----------------------------------------------------------------
{% if not requested_user %} {% if not requested_user %}
YOU NEED A LOGIN.GOV ACCOUNT YOU NEED A LOGIN.GOV ACCOUNT
Youll need a Login.gov account to access the .gov registrar. That account needs to be Youll need a Login.gov account to access the .gov registrar. That account needs to be
associated with the following email address: {{ invitee_email_address }} associated with the following email address: {{ invitee_email_address }}
@ -17,8 +16,6 @@ Login.gov provides a simple and secure process for signing in to many government
services with one account. If you dont already have one, follow these steps to create services with one account. If you dont already have one, follow these steps to create
your Login.gov account <https://login.gov/help/get-started/create-your-account/>. your Login.gov account <https://login.gov/help/get-started/create-your-account/>.
{% endif %} {% endif %}
DOMAIN MANAGEMENT DOMAIN MANAGEMENT
As a .gov domain manager, you can add or update information like name servers. Youll As a .gov domain manager, you can add or update information like name servers. Youll
also serve as a contact for the domains you manage. Please keep your contact also serve as a contact for the domains you manage. Please keep your contact

View file

@ -11,6 +11,7 @@ MANAGER REMOVED: {{ manager_removed.email }}
WHY DID YOU RECEIVE THIS EMAIL? WHY DID YOU RECEIVE THIS EMAIL?
Youre listed as a domain manager for {{ domain.name }}, so youll receive a notification whenever a domain manager is removed from that domain. Youre listed as a domain manager for {{ domain.name }}, so youll receive a notification whenever a domain manager is removed from that domain.
If you have questions or concerns, reach out to the person who removed the domain manager or reply to this email. If you have questions or concerns, reach out to the person who removed the domain manager or reply to this email.
THANK YOU THANK YOU

View file

@ -15,7 +15,7 @@ The person who received the invitation will become a domain manager once they lo
associated with the invited email address. associated with the invited email address.
If you need to cancel this invitation or remove the domain manager, you can do that by going to If you need to cancel this invitation or remove the domain manager, you can do that by going to
this domain in the .gov registrar <https://manage.get.gov/>. this domain in the .gov registrar <{{ manage_url }}>.
WHY DID YOU RECEIVE THIS EMAIL? WHY DID YOU RECEIVE THIS EMAIL?

View file

@ -11,7 +11,7 @@ STATUS: Withdrawn
---------------------------------------------------------------- ----------------------------------------------------------------
YOU CAN EDIT YOUR WITHDRAWN REQUEST YOU CAN EDIT YOUR WITHDRAWN REQUEST
You can edit and resubmit this request by signing in to the registrar <https://manage.get.gov/>. You can edit and resubmit this request by signing in to the registrar <{{ manage_url }}>.
SOMETHING WRONG? SOMETHING WRONG?

View file

@ -16,7 +16,7 @@ The person who received the invitation will become an admin once they log in to
associated with the invited email address. associated with the invited email address.
If you need to cancel this invitation or remove the admin, you can do that by going to If you need to cancel this invitation or remove the admin, you can do that by going to
the Members section for your organization <https://manage.get.gov/>. the Members section for your organization <{{ manage_url }}>.
WHY DID YOU RECEIVE THIS EMAIL? WHY DID YOU RECEIVE THIS EMAIL?

View file

@ -8,7 +8,7 @@ REMOVED BY: {{ requestor_email }}
REMOVED ON: {{date}} REMOVED ON: {{date}}
ADMIN REMOVED: {{ removed_email_address }} ADMIN REMOVED: {{ removed_email_address }}
You can view this update by going to the Members section for your .gov organization <https://manage.get.gov/>. You can view this update by going to the Members section for your .gov organization <{{ manage_url }}>.
---------------------------------------------------------------- ----------------------------------------------------------------

View file

@ -3,7 +3,7 @@ Hi.
{{ requestor_email }} has invited you to {{ portfolio.organization_name }}. {{ requestor_email }} has invited you to {{ portfolio.organization_name }}.
You can view this organization on the .gov registrar <https://manage.get.gov>. You can view this organization on the .gov registrar <{{ manage_url }}>.
---------------------------------------------------------------- ----------------------------------------------------------------

View file

@ -0,0 +1,21 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %}
{{ requestor_email }} has removed you from {{ portfolio.organization_name }}.
You can no longer view this organization or its related domains within the .gov registrar.
SOMETHING WRONG?
If you have questions or concerns, reach out to the person who removed you from the
organization, or reply to this email.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
You've been removed from a .gov organization

View file

@ -0,0 +1,35 @@
{% autoescape off %}{# In a text file, we don't want to have HTML entities escaped #}
Hi,{% if requested_user and requested_user.first_name %} {{ requested_user.first_name }}.{% endif %}
Your permissions were updated in the .gov registrar.
ORGANIZATION: {{ portfolio.organization_name }}
UPDATED BY: {{ requestor_email }}
UPDATED ON: {{ date }}
YOUR PERMISSIONS: {{ permissions.role_display }}
Domains - {{ permissions.domains_display }}
Domain requests - {{ permissions.domain_requests_display }}
Members - {{ permissions.members_display }}
Your updated permissions are now active in the .gov registrar <https://manage.get.gov>.
----------------------------------------------------------------
SOMETHING WRONG?
If you have questions or concerns, reach out to the person who updated your
permissions, or reply to this email.
THANK YOU
.Gov helps the public identify official, trusted information. Thank you for using a .gov
domain.
----------------------------------------------------------------
The .gov team
Contact us: <https://get.gov/contact/>
Learn about .gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency
(CISA) <https://cisa.gov/>
{% endautoescape %}

View file

@ -0,0 +1 @@
Your permissions were updated in the .gov registrar

View file

@ -8,7 +8,7 @@ REQUESTED BY: {{ domain_request.creator.email }}
REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }} REQUEST RECEIVED ON: {{ domain_request.last_submitted_date|date }}
STATUS: Approved STATUS: Approved
You can manage your approved domain on the .gov registrar <https://manage.get.gov>. You can manage your approved domain on the .gov registrar <{{ manage_url }}>.
---------------------------------------------------------------- ----------------------------------------------------------------

View file

@ -68,10 +68,12 @@ Learn more about:
NEED ASSISTANCE? NEED ASSISTANCE?
If you have questions about this domain request or need help choosing a new domain name, reply to this email. If you have questions about this domain request or need help choosing a new domain name, reply to this email.
{% endif %} {% endif %}
{% if reason != domain_request.RejectionReasons.REQUESTOR_NOT_ELIGIBLE and reason != domain_request.RejectionReasons.ORG_NOT_ELIGIBLE %}
THANK YOU THANK YOU
.Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain. .Gov helps the public identify official, trusted information. Thank you for requesting a .gov domain.
{% endif %}
---------------------------------------------------------------- ----------------------------------------------------------------
The .gov team The .gov team

View file

@ -20,7 +20,7 @@ During our review, well verify that:
- You work at the organization and/or can make requests on its behalf - You work at the organization and/or can make requests on its behalf
- Your requested domain meets our naming requirements - Your requested domain meets our naming requirements
{% endif %} {% endif %}
Well email you if we have questions. Well also email you as soon as we complete our review. You can check the status of your request at any time on the registrar. <https://manage.get.gov>. Well email you if we have questions. Well also email you as soon as we complete our review. You can check the status of your request at any time on the registrar. <{{ manage_url }}>.
NEED TO MAKE CHANGES? NEED TO MAKE CHANGES?

View file

@ -31,7 +31,7 @@ CHECK YOUR .GOV DOMAIN CONTACTS
This is a good time to check who has access to your .gov domain{% if domains|length > 1 %}s{% endif %}. The admin, technical, and billing contacts listed for your domain{% if domains|length > 1 %}s{% endif %} in our old system also received this email. In our new registrar, these contacts are all considered “domain managers.” We no longer have the admin, technical, and billing roles, and you arent limited to three domain managers like in the old system. This is a good time to check who has access to your .gov domain{% if domains|length > 1 %}s{% endif %}. The admin, technical, and billing contacts listed for your domain{% if domains|length > 1 %}s{% endif %} in our old system also received this email. In our new registrar, these contacts are all considered “domain managers.” We no longer have the admin, technical, and billing roles, and you arent limited to three domain managers like in the old system.
1. Once you have your Login.gov account, sign in to the new registrar at <https://manage.get.gov>. 1. Once you have your Login.gov account, sign in to the new registrar at <{{ manage_url }}>.
2. Click the “Manage” link next to your .gov domain, then click on “Domain managers” to see who has access to your domain. 2. Click the “Manage” link next to your .gov domain, then click on “Domain managers” to see who has access to your domain.
3. If any of these users should not have access to your domain, let us know in a reply to this email. 3. If any of these users should not have access to your domain, let us know in a reply to this email.
@ -57,7 +57,7 @@ THANK YOU
The .gov team The .gov team
.Gov blog <https://get.gov/updates/> .Gov blog <https://get.gov/updates/>
Domain management <https://manage.get.gov> Domain management <{{ manage_url }}}>
Get.gov <https://get.gov> Get.gov <https://get.gov>
The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/> The .gov registry is a part of the Cybersecurity and Infrastructure Security Agency (CISA) <https://cisa.gov/>

View file

@ -8,7 +8,7 @@ UPDATED BY: {{user}}
UPDATED ON: {{date}} UPDATED ON: {{date}}
INFORMATION UPDATED: {{changes}} INFORMATION UPDATED: {{changes}}
You can view this update in the .gov registrar <https://manage.get.gov/>. You can view this update in the .gov registrar <{{ manage_url }}>.
Get help with managing your .gov domain <https://get.gov/help/domain-management/>. Get help with managing your .gov domain <https://get.gov/help/domain-management/>.

View file

@ -163,7 +163,7 @@
</div> </div>
{% endif %} {% endif %}
<div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="domain-requests__table-wrapper"> <div class="display-none usa-table-container--scrollable usa-table-container--override-overflow margin-top-0" tabindex="0" id="domain-requests__table-wrapper">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
<caption class="sr-only">Your domain requests</caption> <caption class="sr-only">Your domain requests</caption>
<thead> <thead>

View file

@ -1,6 +1,6 @@
<li class="usa-sidenav__item"> <li class="usa-sidenav__item">
{% if url_name %} {% if url_name %}
{% url url_name pk=domain.id as url %} {% url url_name domain_pk=domain.id as url %}
{% endif %} {% endif %}
<a href="{{ url }}" <a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %} {% if request.path == url %}class="usa-current"{% endif %}

View file

@ -9,7 +9,7 @@
<span id="get_domains_json_url" class="display-none">{{url}}</span> <span id="get_domains_json_url" class="display-none">{{url}}</span>
<!-- Org model banner (org manager can view, domain manager can edit) --> <!-- Org model banner (org manager can view, domain manager can edit) -->
{% if has_domain_renewal_flag and num_expiring_domains > 0 and has_any_domains_portfolio_permission %} {% if num_expiring_domains > 0 and has_any_domains_portfolio_permission %}
<section class="usa-site-alert--slim usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert"> <section class="usa-site-alert--slim usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert"> <div class="usa-alert">
<div class="usa-alert__body"> <div class="usa-alert__body">
@ -75,7 +75,7 @@
</div> </div>
<!-- Non org model banner --> <!-- Non org model banner -->
{% if has_domain_renewal_flag and num_expiring_domains > 0 and not portfolio %} {% if num_expiring_domains > 0 and not portfolio %}
<section class="usa-site-alert--slim usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert"> <section class="usa-site-alert--slim usa-site-alert--info margin-bottom-2 {% if add_class %}{{ add_class }}{% endif %}" aria-label="Site alert">
<div class="usa-alert"> <div class="usa-alert">
<div class="usa-alert__body"> <div class="usa-alert__body">
@ -173,7 +173,6 @@
>Deleted</label >Deleted</label
> >
</div> </div>
{% if has_domain_renewal_flag %}
<div class="usa-checkbox"> <div class="usa-checkbox">
<input <input
class="usa-checkbox__input" class="usa-checkbox__input"
@ -185,7 +184,6 @@
<label class="usa-checkbox__label" for="filter-status-expiring" <label class="usa-checkbox__label" for="filter-status-expiring"
>Expiring soon</label> >Expiring soon</label>
</div> </div>
{% endif %}
</fieldset> </fieldset>
</div> </div>
</div> </div>
@ -200,7 +198,7 @@
</svg> </svg>
</button> </button>
</div> </div>
<div class="display-none usa-table-container--scrollable margin-top-0" tabindex="0" id="domains__table-wrapper"> <div class="display-none usa-table-container--scrollable usa-table-container--override-overflow margin-top-0" tabindex="0" id="domains__table-wrapper">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked"> <table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
<caption class="sr-only">Your registered domains</caption> <caption class="sr-only">Your registered domains</caption>
<thead> <thead>

View file

@ -1,20 +1,20 @@
{% load field_helpers %} {% load field_helpers %}
<div id="member-basic-permissions" class="margin-top-2"> <div id="member-basic-permissions" class="margin-top-2">
<h2>What permissions do you want to add?</h2> <h2>Member permissions</h2>
<p>Configure the permissions for this member. Basic members cannot manage member permissions or organization metadata.</p> <p>Configure the permissions for this member. Basic members cannot manage member permissions or organization metadata.</p>
<h3 class="margin-bottom-0">Domains <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3> <h3 class="margin-bottom-neg-1 margin-top-4">Domains <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-bottom-2px border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" %} {% with group_classes="bg-gray-1 border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" label_classes="margin-top-1" %}
{% input_with_errors form.domain_permissions %} {% input_with_errors form.domain_permissions %}
{% endwith %} {% endwith %}
<h3 class="margin-bottom-0">Domain requests <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3> <h3 class="margin-bottom-neg-1 margin-top-2">Domain requests <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-bottom-2px border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" %} {% with group_classes="bg-gray-1 border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" label_classes="margin-top-1" %}
{% input_with_errors form.domain_request_permissions %} {% input_with_errors form.domain_request_permissions %}
{% endwith %} {% endwith %}
<h3 class="margin-bottom-0">Members <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3> <h3 class="margin-bottom-neg-1 margin-top-2">Members <abbr class="usa-hint usa-hint--required" title="required">*</abbr></h3>
{% with group_classes="bg-gray-1 border-bottom-2px border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" %} {% with group_classes="bg-gray-1 border-base-lighter padding-bottom-2 margin-top-0" add_legend_class="usa-sr-only" label_classes="margin-top-1" %}
{% input_with_errors form.member_permissions %} {% input_with_errors form.member_permissions %}
{% endwith %} {% endwith %}
</div> </div>

View file

@ -1,6 +1,6 @@
<h4 class="margin-bottom-0">Assigned domains</h4>
{% if domain_count > 0 %} {% if domain_count > 0 %}
<h4 class="margin-bottom-0">Domains assigned</h4>
<p class="margin-top-0">{{domain_count}}</p> <p class="margin-top-0">{{domain_count}}</p>
{% else %} {% else %}
<p class="margin-top-0">This member does not manage any domains.{% if manage_button %} To assign this member a domain, click "Manage".{% endif %}</p> <p class="margin-top-0">This member does not manage any domains.{% if manage_button %} To assign this member a domain, click "Edit".{% endif %}</p>
{% endif %} {% endif %}

View file

@ -1,5 +1,3 @@
{% load static %}
{% if member %} {% if member %}
<span <span
id="portfolio-js-value" id="portfolio-js-value"
@ -36,47 +34,9 @@
<div class="section-outlined__header margin-bottom-3 grid-row"> <div class="section-outlined__header margin-bottom-3 grid-row">
<!-- ---------- SEARCH ---------- --> <!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9"> {% with label_text="Search all domains" item_name="edit-member-domains" aria_label_text="Member domains search component" %}
<section aria-label="Member domains search component"> {% include "includes/search.html" %}
<form class="usa-search usa-search--show-label" method="POST" role="search"> {% endwith %}
{% csrf_token %}
<label class="usa-label display-block margin-bottom-05" for="edit-member-domains__search-field">
{% if has_edit_members_portfolio_permission %}
Search all domains
{% else %}
Search domains assigned to
{% if member %}
{{ member.email }}
{% else %}
{{ portfolio_invitation.email }}
{% endif %}
{% endif %}
</label>
<div class="usa-search--show-label__input-wrapper">
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="edit-member-domains__reset-search" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset
</button>
<input
class="usa-input"
id="edit-member-domains__search-field"
type="search"
name="member-domains-search"
/>
<button class="usa-button" type="submit" id="edit-member-domains__search-field-submit">
<span class="usa-search__submit-text">Search </span>
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</div>
</form>
</section>
</div>
</div> </div>
<!-- ---------- MAIN TABLE ---------- --> <!-- ---------- MAIN TABLE ---------- -->
@ -85,7 +45,7 @@
<caption class="sr-only">member domains</caption> <caption class="sr-only">member domains</caption>
<thead> <thead>
<tr> <tr>
<th data-sortable="checked" scope="col" role="columnheader" class="padding-right-105"><span class="sr-only">Assigned domains</span></th> <th data-sortable="checked" scope="col" role="columnheader" class="padding-right-105 width-6 left-align-sort-button"><span class="sr-only">Assigned domains</span></th>
<!-- We override default sort to be name/ascending in the JSON endpoint. We add the correct aria-sort attribute here to reflect that in the UI --> <!-- We override default sort to be name/ascending in the JSON endpoint. We add the correct aria-sort attribute here to reflect that in the UI -->
<th data-sortable="name" scope="col" role="columnheader" aria-sort="descending">Domains</th> <th data-sortable="name" scope="col" role="columnheader" aria-sort="descending">Domains</th>
</tr> </tr>
@ -100,7 +60,7 @@
></div> ></div>
</div> </div>
<div class="display-none margin-bottom-4" id="edit-member-domains__no-data"> <div class="display-none margin-bottom-4" id="edit-member-domains__no-data">
<p>This member does not manage any domains. Click the Edit domain assignments buttons to assign domains.</p> <p>This member does not manage any domains.</p>
</div> </div>
<div class="display-none margin-bottom-4" id="edit-member-domains__no-search-results"> <div class="display-none margin-bottom-4" id="edit-member-domains__no-search-results">
<p>No results found</p> <p>No results found</p>

View file

@ -1,5 +1,3 @@
{% load static %}
{% if member %} {% if member %}
<span <span
id="portfolio-js-value" id="portfolio-js-value"
@ -34,45 +32,19 @@
{% endif %} {% endif %}
</h2> </h2>
<div class="section-outlined__header margin-bottom-3 grid-row" id="edit-member-domains__search"> <div class="section-outlined__header margin-bottom-3 grid-row" id="edit-member-domains__search">
<!-- ---------- SEARCH ---------- --> <!-- ---------- SEARCH ---------- -->
<div class="section-outlined__search mobile:grid-col-12 desktop:grid-col-9"> {% with label_text="Domains assigned to " %}
<section aria-label="Member domains search component"> {% if member %}
<form class="usa-search usa-search--show-label" method="POST" role="search"> {% with label_text=label_text|add:member.email item_name="member-domains" aria_label_text="Member domains search component" %}
{% csrf_token %} {% include "includes/search.html" %}
<label class="usa-label display-block margin-bottom-05" for="member-domains__search-field"> {% endwith %}
Search domains assigned to {% else %}
{% if member %} {% with label_text=label_text|add:portfolio_invitation.email item_name="member-domains" aria_label_text="Member domains search component" %}
{{ member.email }} {% include "includes/search.html" %}
{% else %} {% endwith %}
{{ portfolio_invitation.email }} {% endif %}
{% endif %} {% endwith %}
</label>
<div class="usa-search--show-label__input-wrapper">
<button class="usa-button usa-button--unstyled margin-right-3 display-none" id="member-domains__reset-search" type="button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24">
<use xlink:href="{%static 'img/sprite.svg'%}#close"></use>
</svg>
Reset
</button>
<input
class="usa-input"
id="member-domains__search-field"
type="search"
name="member-domains-search"
/>
<button class="usa-button" type="submit" id="member-domains__search-field-submit">
<span class="usa-search__submit-text">Search </span>
<img
src="{% static 'img/usa-icons-bg/search--white.svg' %}"
class="usa-search__submit-icon"
alt="Search"
/>
</button>
</div>
</form>
</section>
</div>
</div> </div>
<!-- ---------- MAIN TABLE ---------- --> <!-- ---------- MAIN TABLE ---------- -->
@ -95,7 +67,7 @@
></div> ></div>
</div> </div>
<div class="display-none margin-bottom-4" id="member-domains__no-data"> <div class="display-none margin-bottom-4" id="member-domains__no-data">
<p>This member does not manage any domains. Click the Edit domain assignments buttons to assign domains.</p> <p>This member does not manage any domains.</p>
</div> </div>
<div class="display-none margin-bottom-4" id="member-domains__no-search-results"> <div class="display-none margin-bottom-4" id="member-domains__no-search-results">
<p>No results found</p> <p>No results found</p>

View file

@ -1,33 +1,11 @@
<h4 class="margin-bottom-0">Member access</h4> <h4 class="margin-bottom-0">Member access</h4>
{% if permissions.roles and 'organization_admin' in permissions.roles %} <p class="margin-top-0">{{ permissions.role_display }}</p>
<p class="margin-top-0">Admin</p>
{% elif permissions.roles and 'organization_member' in permissions.roles %}
<p class="margin-top-0">Basic</p>
{% else %}
<p class="margin-top-0"></p>
{% endif %}
<h4 class="margin-bottom-0 text-primary">Domains</h4> <h4 class="margin-bottom-0 text-primary">Domains</h4>
{% if member_has_view_all_domains_portfolio_permission %} <p class="margin-top-0">{{ permissions.domains_display }}: {{ permissions.domains_description_display }}</p>
<p class="margin-top-0">Viewer, all</p>
{% else %}
<p class="margin-top-0">Viewer, limited</p>
{% endif %}
<h4 class="margin-bottom-0 text-primary">Domain requests</h4> <h4 class="margin-bottom-0 text-primary">Domain requests</h4>
{% if member_has_edit_request_portfolio_permission %} <p class="margin-top-0">{{ permissions.domain_requests_display }}: {{ permissions.domain_requests_description_display }}</p>
<p class="margin-top-0">Creator</p>
{% elif member_has_view_all_requests_portfolio_permission %}
<p class="margin-top-0">Viewer</p>
{% else %}
<p class="margin-top-0">No access</p>
{% endif %}
<h4 class="margin-bottom-0 text-primary">Members</h4> <h4 class="margin-bottom-0 text-primary">Members</h4>
{% if member_has_edit_members_portfolio_permission %} <p class="margin-top-0">{{ permissions.members_display }}: {{ permissions.members_description_display }}</p>
<p class="margin-top-0">Manager</p>
{% elif member_has_view_members_portfolio_permission %}
<p class="margin-top-0">Viewer</p>
{% else %}
<p class="margin-top-0">No access</p>
{% endif %}

View file

@ -4,7 +4,7 @@
{% for step in steps %} {% for step in steps %}
<section class="summary-item margin-top-3"> <section class="summary-item margin-top-3">
{% if is_editable %} {% if is_editable %}
{% namespaced_url 'domain-request' step id=domain_request_id as domain_request_url %} {% namespaced_url 'domain-request' step domain_request_pk=domain_request_id as domain_request_url %}
{% endif %} {% endif %}
{% if step == Step.REQUESTING_ENTITY %} {% if step == Step.REQUESTING_ENTITY %}

View file

@ -4,7 +4,7 @@
{% for step in steps %} {% for step in steps %}
<section class="summary-item margin-top-3"> <section class="summary-item margin-top-3">
{% if is_editable %} {% if is_editable %}
{% namespaced_url 'domain-request' step id=domain_request_id as domain_request_url %} {% namespaced_url 'domain-request' step domain_request_pk=domain_request_id as domain_request_url %}
{% endif %} {% endif %}
{% if step == Step.ORGANIZATION_TYPE %} {% if step == Step.ORGANIZATION_TYPE %}

Some files were not shown because too many files have changed in this diff Show more