diff --git a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss index 1f8f4bd4d..8514b2ab8 100644 --- a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss +++ b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss @@ -223,27 +223,41 @@ section.dashboard { margin-bottom: 0; } - .usa-table { - width: 100%; + @include at-media(mobile-lg) { + margin-top: units(5); - a { - display: flex; - align-items: flex-start; + h2 { + margin-bottom: units(3); + } + } +} + + +.dotgov-table { + width: 100%; + + a { + display: flex; + align-items: flex-start; + color: color('primary'); + + &:visited { color: color('primary'); + } - &:visited { - color: color('primary'); - } - .usa-icon { - // align icon with x height - margin-top: units(0.5); - margin-right: units(0.5); - } + .usa-icon { + // align icon with x height + margin-top: units(0.5); + margin-right: units(0.5); } } - // Table on small mobile - .usa-table--stacked { + th[data-sortable]:not([aria-sort]) .usa-table__header__button { + right: auto; + } +} + +.dotgov-table--stacked { td, th { padding: units(1) units(2) units(2px) 0; border: none; @@ -268,46 +282,45 @@ section.dashboard { color: color('primary-darker'); padding-bottom: units(2px); } - } +} - @include at-media(mobile-lg) { - margin-top: units(5); +@include at-media(mobile-lg) { - h2 { - margin-bottom: units(3); - } - - .usa-table tr { + .dotgov-table { + tr { border: none; } - .usa-table { + td, th { + border-bottom: 1px solid color('base-light'); + } + + thead th { + color: color('primary-darker'); + border-bottom: 2px solid color('base-light'); + } + + tbody tr:last-of-type { td, th { - border-bottom: 1px solid color('base-light'); + border-bottom: 0; } + } + + tbody th { + word-break: break-word; + } - thead th { - color: color('primary-darker'); - border-bottom: 2px solid color('base-light'); - } + td, th, + .usa-tabel th{ + padding: units(2) units(2) units(2) 0; + } - tbody tr:last-of-type { - td, th { - border-bottom: 0; - } - } + th:first-of-type { + padding-left: 0; + } - td, th { - padding: units(2); - } - - th:first-of-type { - padding-left: 0; - } - - thead tr:first-child th:first-child { - border-top: none; - } + thead tr:first-child th:first-child { + border-top: none; } } } diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 53cd5c374..0d0ec89f5 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -62,6 +62,12 @@ urlpatterns = [ name="todo", ), path("domain/", views.DomainView.as_view(), name="domain"), + path("domain//users", views.DomainUsersView.as_view(), name="domain-users"), + path( + "domain//users/add", + views.DomainAddUserView.as_view(), + name="domain-users-add", + ), ] diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py index 9f54b20a7..4edca7cf6 100644 --- a/src/registrar/fixtures.py +++ b/src/registrar/fixtures.py @@ -107,6 +107,10 @@ class DomainApplicationFixture: "status": "investigating", "organization_name": "Example - In Investigation", }, + { + "status": "investigating", + "organization_name": "Example - Approved", + }, ] @classmethod @@ -249,3 +253,25 @@ class DomainApplicationFixture: cls._set_many_to_many_relations(da, app) except Exception as e: logger.warning(e) + + +class DomainFixture(DomainApplicationFixture): + + """Create one domain and permissions on it for each user.""" + + @classmethod + def load(cls): + try: + users = list(User.objects.all()) # force evaluation to catch db errors + except Exception as e: + logger.warning(e) + return + + for user in users: + # approve one of each users investigating status domains + application = DomainApplication.objects.filter( + creator=user, status=DomainApplication.INVESTIGATING + ).last() + logger.debug(f"Approving {application} for {user}") + application.approve() + application.save() diff --git a/src/registrar/forms/__init__.py b/src/registrar/forms/__init__.py index bd0426884..d48dd037b 100644 --- a/src/registrar/forms/__init__.py +++ b/src/registrar/forms/__init__.py @@ -1 +1,2 @@ from .application_wizard import * +from .domain import DomainAddUserForm diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py new file mode 100644 index 000000000..6a2229961 --- /dev/null +++ b/src/registrar/forms/domain.py @@ -0,0 +1,21 @@ +"""Forms for domain management.""" + +from django import forms + +from registrar.models import User + + +class DomainAddUserForm(forms.Form): + + """Form for adding a user to a domain.""" + + email = forms.EmailField(label="Email") + + def clean_email(self): + requested_email = self.cleaned_data["email"] + try: + User.objects.get(email=requested_email) + except User.DoesNotExist: + # TODO: send an invitation email to a non-existent user + raise forms.ValidationError("That user does not exist in this system.") + return requested_email diff --git a/src/registrar/management/commands/load.py b/src/registrar/management/commands/load.py index 0203a2e75..e48d3f211 100644 --- a/src/registrar/management/commands/load.py +++ b/src/registrar/management/commands/load.py @@ -3,7 +3,7 @@ import logging from django.core.management.base import BaseCommand from auditlog.context import disable_auditlog # type: ignore -from registrar.fixtures import UserFixture, DomainApplicationFixture +from registrar.fixtures import UserFixture, DomainApplicationFixture, DomainFixture logger = logging.getLogger(__name__) @@ -15,4 +15,5 @@ class Command(BaseCommand): with disable_auditlog(): UserFixture.load() DomainApplicationFixture.load() + DomainFixture.load() logger.info("All fixtures loaded.") diff --git a/src/registrar/templates/domain_add_user.html b/src/registrar/templates/domain_add_user.html new file mode 100644 index 000000000..1a1c45aa3 --- /dev/null +++ b/src/registrar/templates/domain_add_user.html @@ -0,0 +1,31 @@ +{% extends "domain_base.html" %} +{% load static field_helpers %} + +{% block title %}Add another user{% endblock %} + +{% block domain_content %} +

+ + Back to user management + +

+

Add another user

+ +

You can add another user to help manage your domain. They will need to sign + into the .gov registrar with their Login.gov account. +

+ +
+ {% csrf_token %} + + {% input_with_errors form.email %} + + +
+ +{% endblock %} {# domain_content #} diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html new file mode 100644 index 000000000..3dcb79bb2 --- /dev/null +++ b/src/registrar/templates/domain_base.html @@ -0,0 +1,42 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Domain {{ domain.name }}{% endblock %} + +{% block content %} +
+
+
+ {% include 'domain_sidebar.html' %} +
+ +
+
+ + {% if messages %} + {% for message in messages %} +
+
+ {{ message }} +
+
+ {% endfor %} + {% endif %} + + {% block domain_content %} +

+ + Back to manage your domains +

+ +

Domain {{ domain.name }}

+ + {% endblock %} {# domain_content #} + +
+
+
+
+{% endblock %} {# content #} diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index 5d0624209..dd6faa1be 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -1,14 +1,6 @@ -{% extends "dashboard_base.html" %} - - -{% block title %}Domain {{ domain.name }}{% endblock %} - -{% block content %} -
-
-

{{ domain.name }}

+{% extends "domain_base.html" %} +{% block domain_content %} + {{ block.super }}

Active: {% if domain.is_active %}Yes{% else %}No{% endif %}

-
-
-{% endblock %} {# content #} +{% endblock %} {# domain_content #} diff --git a/src/registrar/templates/domain_sidebar.html b/src/registrar/templates/domain_sidebar.html new file mode 100644 index 000000000..7f3a66be6 --- /dev/null +++ b/src/registrar/templates/domain_sidebar.html @@ -0,0 +1,61 @@ +{% load static url_helpers %} + +
+ +
diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html new file mode 100644 index 000000000..a8091fa1c --- /dev/null +++ b/src/registrar/templates/domain_users.html @@ -0,0 +1,48 @@ +{% extends "domain_base.html" %} +{% load static %} + +{% block title %}User management{% endblock %} + +{% block domain_content %} +

+ + Back to manage your domains +

+ +

User management

+ + {% if domain.permissions %} + + + + + + + + + + {% for permission in domain.permissions.all %} + + + + + {% endfor %} + +
Domain users
EmailRole
+ {{ permission.user.email }} + {{ permission.role|title }}
+
+ {% endif %} + + + Add another user + + +{% endblock %} {# domain_content #} diff --git a/src/registrar/templates/home.html b/src/registrar/templates/home.html index f15f1d1a8..5d819772d 100644 --- a/src/registrar/templates/home.html +++ b/src/registrar/templates/home.html @@ -17,7 +17,7 @@

Registered domains

{% if domains %} - +
@@ -56,7 +56,7 @@

Active domain requests

{% if domain_applications %} -
Your domain applications
+
@@ -111,7 +111,7 @@

Export domains

-

If you would like to analyze your list of domains further, you can download the list of domains and their statuses as csv file

+

Download a list of your domains and their statuses as a csv file.

Export domains as csv diff --git a/src/registrar/templatetags/url_helpers.py b/src/registrar/templatetags/url_helpers.py index 63ff9db6c..6201e61eb 100644 --- a/src/registrar/templatetags/url_helpers.py +++ b/src/registrar/templatetags/url_helpers.py @@ -8,3 +8,10 @@ register = template.Library() def namespaced_url(namespace, name="", **kwargs): """Get a URL, given its Django namespace and name.""" return reverse(f"{namespace}:{name}", kwargs=kwargs) + + +@register.filter("startswith") +def startswith(text, starts): + if isinstance(text, str): + return text.startswith(starts) + return False diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index 22f2e2ac9..c621cf986 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -1018,7 +1018,7 @@ class DomainApplicationTests(TestWithUser, WebTest): # self.assertNotContains(page, "VALUE") -class TestDomainPermissions(TestWithUser): +class TestWithDomainPermissions(TestWithUser): def setUp(self): super().setUp() self.domain, _ = Domain.objects.get_or_create(name="igorville.gov") @@ -1034,24 +1034,50 @@ class TestDomainPermissions(TestWithUser): pass super().tearDown() + +class TestDomainPermissions(TestWithDomainPermissions): def test_not_logged_in(self): """Not logged in gets a redirect to Login.""" response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id})) self.assertEqual(response.status_code, 302) + response = self.client.get( + reverse("domain-users", kwargs={"pk": self.domain.id}) + ) + self.assertEqual(response.status_code, 302) + + response = self.client.get( + reverse("domain-users-add", kwargs={"pk": self.domain.id}) + ) + self.assertEqual(response.status_code, 302) + def test_no_domain_role(self): """Logged in but no role gets 403 Forbidden.""" self.client.force_login(self.user) self.role.delete() # user no longer has a role on this domain + with less_console_noise(): response = self.client.get(reverse("domain", kwargs={"pk": self.domain.id})) self.assertEqual(response.status_code, 403) + with less_console_noise(): + response = self.client.get( + reverse("domain-users", kwargs={"pk": self.domain.id}) + ) + self.assertEqual(response.status_code, 403) -class TestDomainDetail(TestDomainPermissions, WebTest): + with less_console_noise(): + response = self.client.get( + reverse("domain-users-add", kwargs={"pk": self.domain.id}) + ) + self.assertEqual(response.status_code, 403) + + +class TestDomainDetail(TestWithDomainPermissions, WebTest): def setUp(self): super().setUp() self.app.set_user(self.user.username) + self.client.force_login(self.user) def test_domain_detail_link_works(self): home_page = self.app.get("/") @@ -1059,3 +1085,48 @@ class TestDomainDetail(TestDomainPermissions, WebTest): # click the "Edit" link detail_page = home_page.click("Edit") self.assertContains(detail_page, "igorville.gov") + + def test_domain_user_management(self): + response = self.client.get( + reverse("domain-users", kwargs={"pk": self.domain.id}) + ) + self.assertContains(response, "User management") + + def test_domain_user_management_add_link(self): + """Button to get to user add page works.""" + management_page = self.app.get( + reverse("domain-users", kwargs={"pk": self.domain.id}) + ) + add_page = management_page.click("Add another user") + self.assertContains(add_page, "Add another user") + + def test_domain_user_add(self): + response = self.client.get( + reverse("domain-users-add", kwargs={"pk": self.domain.id}) + ) + self.assertContains(response, "Add another user") + + def test_domain_user_add_form(self): + """Adding a user works.""" + other_user, _ = get_user_model().objects.get_or_create( + email="mayor@igorville.gov" + ) + add_page = self.app.get( + reverse("domain-users-add", kwargs={"pk": self.domain.id}) + ) + session_id = self.app.cookies[settings.SESSION_COOKIE_NAME] + + add_page.form["email"] = "mayor@igorville.gov" + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_result = add_page.form.submit() + + self.assertEqual(success_result.status_code, 302) + self.assertEqual( + success_result["Location"], + reverse("domain-users", kwargs={"pk": self.domain.id}), + ) + + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_page = success_result.follow() + self.assertContains(success_page, "mayor@igorville.gov") diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index 696c4640d..e1ae2cc32 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -1,5 +1,5 @@ from .application import * -from .domain import * +from .domain import DomainView, DomainUsersView, DomainAddUserView from .health import * from .index import * from .whoami import * diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index e2553ce44..150efab81 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -1,13 +1,89 @@ """View for a single Domain.""" +from django import forms +from django.contrib import messages +from django.db import IntegrityError +from django.shortcuts import redirect +from django.urls import reverse from django.views.generic import DetailView +from django.views.generic.edit import FormMixin -from registrar.models import Domain +from registrar.models import Domain, User, UserDomainRole from .utility import DomainPermission class DomainView(DomainPermission, DetailView): + + """Domain detail overview page.""" + model = Domain template_name = "domain_detail.html" context_object_name = "domain" + + +class DomainUsersView(DomainPermission, DetailView): + + """User management page in the domain details.""" + + model = Domain + template_name = "domain_users.html" + context_object_name = "domain" + + +class DomainAddUserForm(DomainPermission, forms.Form): + + """Form for adding a user to a domain.""" + + email = forms.EmailField(label="Email") + + def clean_email(self): + requested_email = self.cleaned_data["email"] + try: + User.objects.get(email=requested_email) + except User.DoesNotExist: + # TODO: send an invitation email to a non-existent user + raise forms.ValidationError("That user does not exist in this system.") + return requested_email + + +class DomainAddUserView(DomainPermission, FormMixin, DetailView): + + """Inside of a domain's user management, a form for adding users. + + Multiple inheritance is used here for permissions, form handling, and + details of the individual domain. + """ + + template_name = "domain_add_user.html" + model = Domain + form_class = DomainAddUserForm + + def get_success_url(self): + return reverse("domain-users", kwargs={"pk": self.object.pk}) + + def post(self, request, *args, **kwargs): + self.object = self.get_object() + form = self.get_form() + if form.is_valid(): + return self.form_valid(form) + else: + return self.form_invalid(form) + + def form_valid(self, form): + """Add the specified user on this domain.""" + requested_email = form.cleaned_data["email"] + # look up a user with that email + # they should exist because we checked in clean_email + requested_user = User.objects.get(email=requested_email) + + try: + UserDomainRole.objects.create( + user=requested_user, domain=self.object, role=UserDomainRole.Roles.ADMIN + ) + except IntegrityError: + # User already has the desired role! Do nothing?? + pass + + messages.success(self.request, f"Added user {requested_email}.") + return redirect(self.get_success_url()) diff --git a/src/zap.conf b/src/zap.conf index 640883adc..ba0ef6a89 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -49,6 +49,8 @@ 10038 OUTOFSCOPE http://app:8080/public/css/.* 10038 OUTOFSCOPE http://app:8080/public/js/.* 10038 OUTOFSCOPE http://app:8080/(robots.txt|sitemap.xml|TODO|edit/) +10038 OUTOFSCOPE http://app:8080/users +10038 OUTOFSCOPE http://app:8080/users/add # This URL always returns 404, so include it as well. 10038 OUTOFSCOPE http://app:8080/todo # OIDC isn't configured in the test environment and DEBUG=True so this gives a 500 without CSP headers
Your domain applications