diff --git a/docs/developer/cloning-databases.md b/docs/developer/cloning-databases.md new file mode 100644 index 000000000..3c8a3c3fa --- /dev/null +++ b/docs/developer/cloning-databases.md @@ -0,0 +1,17 @@ +# Cloning Databases +The clone-db workflow clones a Source database to a Destination database using cloud.gov's cg-manage-rds tool. This document contains additional information needed to understand how the workflow functions. + +## Additional Roles Required +The clone-db workflow functions by temporarily sharing the Destination database with the space of the Source database. This is because cloning databases across spaces is hard. Sharing is done via the `cf share-service` command, but requires that the authenticated user (in this case this will be a user from the Source space) have the `space-developer` role in *both* the Source and Destination spaces. This must be set by someone with permission to edit space roles *before* the workflow runs. The user in question can be found using the `cf space-users [ORG] [SPACE]` command where the SPACE is the Source space, and will appear as a UAA user with a UUID as the name. There is only one such user per space by default (this is a [service account](https://cloud.gov/docs/services/cloud-gov-service-account/) set up by cloud.gov for our Github workflows). This user needs to be provided with the `space-developer` role in the Destination space, which can be accomplished using `cf set-space-role [USER] [ORG] [DESTINATION SPACE] SpaceDeveloper`. + +## Turning Off DB Cloning Fast (For Emergencies or other Scenarios) +Note: In less urgent situations it may be better to make a PR removing the scheduled workflow trigger. + +Step 1: +Get the name of the correct service using `cf spaces-users cisa-dotgov stable`. There should only be one user with a name that is a UUID, that is the one you want. + +step 2: +Remove the space developer role by doing the following command: +`cf unset-space-role [USER] cisa-dotgov staging SpaceDeveloper` + +This will cause the job to fail without requiring pushing anything to main. diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js index d3422b722..c96677ebc 100644 --- a/src/registrar/assets/src/js/getgov/portfolio-member-page.js +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -87,14 +87,6 @@ export function initAddNewMemberPageListeners() { }); }); - /* - Helper function to capitalize the first letter in a string (for display purposes) - */ - function capitalizeFirstLetter(text) { - if (!text) return ''; // Return empty string if input is falsy - return text.charAt(0).toUpperCase() + text.slice(1); - } - /* Populates contents of the "Add Member" confirmation modal */ @@ -102,10 +94,12 @@ export function initAddNewMemberPageListeners() { const permissionDetailsContainer = document.getElementById("permission_details"); permissionDetailsContainer.innerHTML = ""; // Clear previous content - // Get all permission sections (divs with h3 and radio inputs) - const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); + if (permission_details_div_id == 'member-basic-permissions') { + // for basic users, display values are based on selections in the form + // Get all permission sections (divs with h3 and radio inputs) + const permissionSections = document.querySelectorAll(`#${permission_details_div_id} > h3`); - permissionSections.forEach(section => { + permissionSections.forEach(section => { // Find the
) + const mainText = Array.from(label.childNodes) + .filter(node => node.nodeType === Node.TEXT_NODE) + .map(node => node.textContent.trim()) + .join(""); // Combine and trim whitespace + selectedPermission = mainText || "No permission selected"; } - - // Create new elements for the modal content - const titleElement = document.createElement("h4"); - titleElement.textContent = sectionTitle; - titleElement.classList.add("text-primary"); - titleElement.classList.add("margin-bottom-0"); - - const permissionElement = document.createElement("p"); - permissionElement.textContent = selectedPermission; - permissionElement.classList.add("margin-top-0"); - - // Append to the modal content container - permissionDetailsContainer.appendChild(titleElement); - permissionDetailsContainer.appendChild(permissionElement); + } + appendPermissionInContainer(sectionTitle, selectedPermission, permissionDetailsContainer); } - }); + }); + } else { + // for admin users, the permissions are always the same + appendPermissionInContainer('Domains', 'Viewer, all', permissionDetailsContainer); + appendPermissionInContainer('Domain requests', 'Creator', permissionDetailsContainer); + appendPermissionInContainer('Members', 'Manager', permissionDetailsContainer); + } + } + + function appendPermissionInContainer(sectionTitle, permissionDisplay, permissionContainer) { + // Create new elements for the content + const titleElement = document.createElement("h4"); + titleElement.textContent = sectionTitle; + titleElement.classList.add("text-primary", "margin-bottom-0"); + + const permissionElement = document.createElement("p"); + permissionElement.textContent = permissionDisplay; + permissionElement.classList.add("margin-top-0"); + + // Append to the content container + permissionContainer.appendChild(titleElement); + permissionContainer.appendChild(permissionElement); } /* @@ -149,18 +158,25 @@ export function initAddNewMemberPageListeners() { let emailValue = document.getElementById('id_email').value; document.getElementById('modalEmail').textContent = emailValue; - // Get selected radio button for access level + // Get selected radio button for member access level let selectedAccess = document.querySelector('input[name="role"]:checked'); - // Set the selected permission text to 'Basic' or 'Admin' (the value of the selected radio button) - // This value does not have the first letter capitalized so let's capitalize it - let accessText = selectedAccess ? capitalizeFirstLetter(selectedAccess.value) : "No access level selected"; + // Map the access level values to user-friendly labels + const accessLevelMapping = { + organization_admin: "Admin", + organization_member: "Basic", + }; + // Determine the access text based on the selected value + let accessText = selectedAccess + ? accessLevelMapping[selectedAccess.value] || "Unknown access level" + : "No access level selected"; + // Update the modal with the appropriate member access level text document.getElementById('modalAccessLevel').textContent = accessText; // Populate permission details based on access level if (selectedAccess && selectedAccess.value === 'organization_admin') { - populatePermissionDetails('new-member-admin-permissions'); + populatePermissionDetails('admin'); } else { - populatePermissionDetails('new-member-basic-permissions'); + populatePermissionDetails('member-basic-permissions'); } //------- Show the modal @@ -177,22 +193,14 @@ export function initPortfolioMemberPageRadio() { document.addEventListener("DOMContentLoaded", () => { let memberForm = document.getElementById("member_form"); let newMemberForm = document.getElementById("add_member_form") - if (memberForm) { + if (memberForm || newMemberForm) { hookupRadioTogglerListener( 'role', { - 'organization_admin': 'member-admin-permissions', + 'organization_admin': '', 'organization_member': 'member-basic-permissions' } ); - }else if (newMemberForm){ - hookupRadioTogglerListener( - 'role', - { - 'organization_admin': 'new-member-admin-permissions', - 'organization_member': 'new-member-basic-permissions' - } - ); } }); } diff --git a/src/registrar/assets/src/sass/_theme/_accordions.scss b/src/registrar/assets/src/sass/_theme/_accordions.scss index 762618415..ca9990ca9 100644 --- a/src/registrar/assets/src/sass/_theme/_accordions.scss +++ b/src/registrar/assets/src/sass/_theme/_accordions.scss @@ -49,3 +49,30 @@ tr:last-of-type .usa-accordion--more-actions .usa-accordion__content { bottom: -10px; right: 30px; } + +// A CSS only show-more/show-less based on usa-accordion +.usa-accordion--show-more { + width: auto; + .usa-accordion__button[aria-expanded=false], + .usa-accordion__button[aria-expanded=false]:hover, + .usa-accordion__button[aria-expanded=true], + .usa-accordion__button[aria-expanded=true]:hover { + background-image: none; + background-color: transparent; + padding-right: 0; + padding-left: 0; + font-weight: normal; + } + .usa-accordion__button[aria-expanded=true] .expand-more { + display: inline-block; + } + .usa-accordion__button[aria-expanded=true] .expand-less { + display: none; + } + .usa-accordion__button[aria-expanded=false] .expand-more { + display: none; + } + .usa-accordion__button[aria-expanded=false] .expand-less { + display: inline-block; + } +} diff --git a/src/registrar/assets/src/sass/_theme/_tables.scss b/src/registrar/assets/src/sass/_theme/_tables.scss index a8a829a45..37ae22b1b 100644 --- a/src/registrar/assets/src/sass/_theme/_tables.scss +++ b/src/registrar/assets/src/sass/_theme/_tables.scss @@ -105,3 +105,25 @@ th { } } } + +.dotgov-table--cell-padding-2 { + td, th { + padding: units(2); + } +} + +.usa-table--striped tbody tr:nth-child(odd) th, +.usa-table--striped tbody tr:nth-child(odd) td { + background-color: color('primary-lightest'); +} + +.usa-table--bg-transparent { + td, thead th { + background-color: transparent; + } +} + +.usa-table--full-borderless td, +.usa-table--full-borderless th { + border: none !important; +} diff --git a/src/registrar/assets/src/sass/_theme/_typography.scss b/src/registrar/assets/src/sass/_theme/_typography.scss index 5e00bf1b4..22069f726 100644 --- a/src/registrar/assets/src/sass/_theme/_typography.scss +++ b/src/registrar/assets/src/sass/_theme/_typography.scss @@ -11,7 +11,8 @@ address, } h1:not(.usa-alert__heading), -h2:not(.usa-alert__heading), +// .module h2 excludes headers in DJA +h2:not(.usa-alert__heading, .module h2), h3:not(.usa-alert__heading), h4:not(.usa-alert__heading), h5:not(.usa-alert__heading), @@ -45,3 +46,7 @@ h4, .h4 { padding-left: units(1); border-left: 2px solid color('base-lighter'); } + +.font-body-1 { + font-size: size('body', 1); +} diff --git a/src/registrar/config/settings.py b/src/registrar/config/settings.py index 0111245a1..a58e3e2f9 100644 --- a/src/registrar/config/settings.py +++ b/src/registrar/config/settings.py @@ -520,7 +520,7 @@ LOGGING = { "()": JsonFormatter, }, }, - # define where log messages will be sent; + # define where log messages will be sent # each logger can have one or more handlers "handlers": { "console": { diff --git a/src/registrar/forms/portfolio.py b/src/registrar/forms/portfolio.py index e57b56c4f..c9ef280b0 100644 --- a/src/registrar/forms/portfolio.py +++ b/src/registrar/forms/portfolio.py @@ -4,7 +4,6 @@ import logging from django import forms from django.core.validators import RegexValidator from django.core.validators import MaxLengthValidator -from django.utils.safestring import mark_safe from registrar.forms.utility.combobox import ComboboxWidget from registrar.models import ( @@ -121,47 +120,47 @@ class BasePortfolioMemberForm(forms.ModelForm): widget=forms.RadioSelect, required=True, error_messages={ - "required": "Member access level is required", + "required": "Select the level of access you would like to grant this member.", }, ) - domain_request_permission_admin = forms.ChoiceField( - label=mark_safe(f"Select permission {required_star}"), # nosec + domain_permissions = forms.ChoiceField( choices=[ - (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), - (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"), + (UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, "Viewer, limited"), + (UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value, "Viewer, all"), ], widget=forms.RadioSelect, required=False, + initial=UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, error_messages={ - "required": "Admin domain request permission is required", + "required": "Domain permission is required.", }, ) - member_permission_admin = forms.ChoiceField( - label=mark_safe(f"Select permission {required_star}"), # nosec + domain_request_permissions = forms.ChoiceField( choices=[ - (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "View all members"), - (UserPortfolioPermissionChoices.EDIT_MEMBERS.value, "View all members plus manage members"), - ], - widget=forms.RadioSelect, - required=False, - error_messages={ - "required": "Admin member permission is required", - }, - ) - - domain_request_permission_member = forms.ChoiceField( - label=mark_safe(f"Select permission {required_star}"), # nosec - choices=[ - (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "View all requests"), - (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "View all requests plus create requests"), ("no_access", "No access"), + (UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value, "Viewer"), + (UserPortfolioPermissionChoices.EDIT_REQUESTS.value, "Creator"), ], widget=forms.RadioSelect, required=False, + initial="no_access", error_messages={ - "required": "Basic member permission is required", + "required": "Domain request permission is required.", + }, + ) + + member_permissions = forms.ChoiceField( + choices=[ + ("no_access", "No access"), + (UserPortfolioPermissionChoices.VIEW_MEMBERS.value, "Viewer"), + ], + widget=forms.RadioSelect, + required=False, + initial="no_access", + error_messages={ + "required": "Member permission is required.", }, ) @@ -169,12 +168,11 @@ class BasePortfolioMemberForm(forms.ModelForm): # All of the fields included here have "required=False" by default as they are conditionally required. # see def clean() for more details. ROLE_REQUIRED_FIELDS = { - UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ - "domain_request_permission_admin", - "member_permission_admin", - ], + UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [], UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ - "domain_request_permission_member", + "domain_permissions", + "member_permissions", + "domain_request_permissions", ], } @@ -190,15 +188,24 @@ class BasePortfolioMemberForm(forms.ModelForm): Update field descriptions. """ super().__init__(*args, **kwargs) - # Adds a
description beneath each role option - self.fields["role"].descriptions = { - "organization_admin": UserPortfolioRoleChoices.get_role_description( - UserPortfolioRoleChoices.ORGANIZATION_ADMIN - ), - "organization_member": UserPortfolioRoleChoices.get_role_description( - UserPortfolioRoleChoices.ORGANIZATION_MEMBER - ), + + # Adds a
description beneath each option + self.fields["domain_permissions"].descriptions = { + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value: "Can view only the domains they manage", + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS.value: "Can view all domains for the organization", } + self.fields["domain_request_permissions"].descriptions = { + UserPortfolioPermissionChoices.EDIT_REQUESTS.value: ( + "Can view all domain requests for the organization and create requests" + ), + UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS.value: "Can view all domain requests for the organization", + "no_access": "Cannot view or create domain requests", + } + self.fields["member_permissions"].descriptions = { + UserPortfolioPermissionChoices.VIEW_MEMBERS.value: "Can view all members permissions", + "no_access": "Cannot view member permissions", + } + # Map model instance values to custom form fields if self.instance: self.map_instance_to_initial() @@ -222,8 +229,12 @@ class BasePortfolioMemberForm(forms.ModelForm): self.add_error(field_name, self.fields.get(field_name).error_messages.get("required")) # Edgecase: Member uses a special form value for None called "no_access". - if cleaned_data.get("domain_request_permission_member") == "no_access": - cleaned_data["domain_request_permission_member"] = None + if cleaned_data.get("domain_request_permissions") == "no_access": + cleaned_data["domain_request_permissions"] = None + + # Edgecase: Member uses a special form value for None called "no_access". + if cleaned_data.get("member_permissions") == "no_access": + cleaned_data["member_permissions"] = None # Handle roles cleaned_data["roles"] = [role] @@ -253,7 +264,7 @@ class BasePortfolioMemberForm(forms.ModelForm): "role": "organization_admin" or "organization_member", "member_permission_admin": permission level if admin, "domain_request_permission_admin": permission level if admin, - "domain_request_permission_member": permission level if member + "domain_request_permissions": permission level if member } """ if self.initial is None: @@ -267,12 +278,15 @@ class BasePortfolioMemberForm(forms.ModelForm): UserPortfolioRoleChoices.ORGANIZATION_ADMIN, UserPortfolioRoleChoices.ORGANIZATION_MEMBER, ] - domain_perms = [ + domain_request_perms = [ UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, ] + domain_perms = [ + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS, + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + ] member_perms = [ - UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_MEMBERS, ] @@ -282,16 +296,21 @@ class BasePortfolioMemberForm(forms.ModelForm): roles = self.instance.roles or [] selected_role = next((role for role in roles if role in roles), None) self.initial["role"] = selected_role - is_admin = selected_role == UserPortfolioRoleChoices.ORGANIZATION_ADMIN - if is_admin: - selected_domain_permission = next((perm for perm in domain_perms if perm in perms), None) - selected_member_permission = next((perm for perm in member_perms if perm in perms), None) - self.initial["domain_request_permission_admin"] = selected_domain_permission - self.initial["member_permission_admin"] = selected_member_permission - else: - # Edgecase: Member uses a special form value for None called "no_access". This ensures a form selection. - selected_domain_permission = next((perm for perm in domain_perms if perm in perms), "no_access") - self.initial["domain_request_permission_member"] = selected_domain_permission + is_member = selected_role == UserPortfolioRoleChoices.ORGANIZATION_MEMBER + if is_member: + # Edgecase: Member and domain request use a special form value for None called "no_access". + # This ensures a form selection. + selected_domain_permission = next( + (perm for perm in domain_perms if perm in perms), + UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS.value, + ) + selected_domain_request_permission = next( + (perm for perm in domain_request_perms if perm in perms), "no_access" + ) + selected_member_permission = next((perm for perm in member_perms if perm in perms), "no_access") + self.initial["domain_request_permissions"] = selected_domain_request_permission + self.initial["domain_permissions"] = selected_domain_permission + self.initial["member_permissions"] = selected_member_permission class PortfolioMemberForm(BasePortfolioMemberForm): @@ -320,7 +339,7 @@ class PortfolioNewMemberForm(BasePortfolioMemberForm): """ email = forms.EmailField( - label="Enter the email of the member you'd like to invite", + label="Email", max_length=None, error_messages={ "invalid": ("Enter an email address in the required format, like name@example.com."), diff --git a/src/registrar/models/user_portfolio_permission.py b/src/registrar/models/user_portfolio_permission.py index 03a01b80d..c4be90a9b 100644 --- a/src/registrar/models/user_portfolio_permission.py +++ b/src/registrar/models/user_portfolio_permission.py @@ -21,16 +21,18 @@ class UserPortfolioPermission(TimeStampedModel): UserPortfolioRoleChoices.ORGANIZATION_ADMIN: [ UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS, + UserPortfolioPermissionChoices.EDIT_REQUESTS, UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_MEMBERS, UserPortfolioPermissionChoices.VIEW_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_PORTFOLIO, - # Domain: field specific permissions UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION, ], # NOTE: Check FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS before adding roles here. UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ UserPortfolioPermissionChoices.VIEW_PORTFOLIO, + UserPortfolioPermissionChoices.VIEW_SUBORGANIZATION, ], } @@ -38,9 +40,9 @@ class UserPortfolioPermission(TimeStampedModel): # Used to throw a ValidationError on clean() for UserPortfolioPermission and PortfolioInvitation. FORBIDDEN_PORTFOLIO_ROLE_PERMISSIONS = { UserPortfolioRoleChoices.ORGANIZATION_MEMBER: [ - UserPortfolioPermissionChoices.VIEW_MEMBERS, + UserPortfolioPermissionChoices.EDIT_PORTFOLIO, UserPortfolioPermissionChoices.EDIT_MEMBERS, - UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION, ], } diff --git a/src/registrar/models/utility/portfolio_helper.py b/src/registrar/models/utility/portfolio_helper.py index 4ae282f21..b3bb07c3d 100644 --- a/src/registrar/models/utility/portfolio_helper.py +++ b/src/registrar/models/utility/portfolio_helper.py @@ -25,23 +25,6 @@ class UserPortfolioRoleChoices(models.TextChoices): logger.warning(f"Invalid portfolio role: {user_portfolio_role}") return f"Unknown ({user_portfolio_role})" - @classmethod - def get_role_description(cls, user_portfolio_role): - """Returns a detailed description for a given role.""" - descriptions = { - cls.ORGANIZATION_ADMIN: ( - "Grants this member access to the organization-wide information " - "on domains, domain requests, and members. Domain management can be assigned separately." - ), - cls.ORGANIZATION_MEMBER: ( - "Grants this member access to the organization. They can be given extra permissions to view all " - "organization domain requests and submit domain requests on behalf of the organization. Basic access " - "members can’t view all members of an organization or manage them. " - "Domain management can be assigned separately." - ), - } - return descriptions.get(user_portfolio_role) - class UserPortfolioPermissionChoices(models.TextChoices): """ """ diff --git a/src/registrar/templates/django/forms/widgets/multiple_input.html b/src/registrar/templates/django/forms/widgets/multiple_input.html index cc0e11989..af98e898b 100644 --- a/src/registrar/templates/django/forms/widgets/multiple_input.html +++ b/src/registrar/templates/django/forms/widgets/multiple_input.html @@ -21,7 +21,7 @@ {% if field and field.field and field.field.descriptions %} {% with description=field.field.descriptions|get_dict_value:option.value %} {% if description %} -
{{ description }}
+{{ description }}
{% endif %} {% endwith %} {% endif %} diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html index 165441c91..58038d0a4 100644 --- a/src/registrar/templates/domain_base.html +++ b/src/registrar/templates/domain_base.html @@ -46,7 +46,7 @@ {# messages block is under the back breadcrumb link #} {% if messages %} {% for message in messages %} -