Merge pull request #480 from cisagov/nmb/approved-users

Simple user management
This commit is contained in:
Neil MartinsenBurrell 2023-03-23 14:48:45 -05:00 committed by GitHub
commit 7a8f77dde9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 463 additions and 65 deletions

View file

@ -223,7 +223,17 @@ section.dashboard {
margin-bottom: 0;
}
.usa-table {
@include at-media(mobile-lg) {
margin-top: units(5);
h2 {
margin-bottom: units(3);
}
}
}
.dotgov-table {
width: 100%;
a {
@ -234,16 +244,20 @@ section.dashboard {
&:visited {
color: color('primary');
}
.usa-icon {
// align icon with x height
margin-top: units(0.5);
margin-right: units(0.5);
}
}
th[data-sortable]:not([aria-sort]) .usa-table__header__button {
right: auto;
}
}
// Table on small mobile
.usa-table--stacked {
.dotgov-table--stacked {
td, th {
padding: units(1) units(2) units(2px) 0;
border: none;
@ -271,17 +285,12 @@ section.dashboard {
}
@include at-media(mobile-lg) {
margin-top: units(5);
h2 {
margin-bottom: units(3);
}
.usa-table tr {
.dotgov-table {
tr {
border: none;
}
.usa-table {
td, th {
border-bottom: 1px solid color('base-light');
}
@ -297,8 +306,13 @@ section.dashboard {
}
}
td, th {
padding: units(2);
tbody th {
word-break: break-word;
}
td, th,
.usa-tabel th{
padding: units(2) units(2) units(2) 0;
}
th:first-of-type {
@ -310,7 +324,6 @@ section.dashboard {
}
}
}
}
#wrapper {

View file

@ -62,6 +62,12 @@ urlpatterns = [
name="todo",
),
path("domain/<int:pk>", views.DomainView.as_view(), name="domain"),
path("domain/<int:pk>/users", views.DomainUsersView.as_view(), name="domain-users"),
path(
"domain/<int:pk>/users/add",
views.DomainAddUserView.as_view(),
name="domain-users-add",
),
]

View file

@ -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()

View file

@ -1 +1,2 @@
from .application_wizard import *
from .domain import DomainAddUserForm

View file

@ -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

View file

@ -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.")

View file

@ -0,0 +1,31 @@
{% extends "domain_base.html" %}
{% load static field_helpers %}
{% block title %}Add another user{% endblock %}
{% block domain_content %}
<p><a href="{% url "domain-users" pk=domain.id %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
</svg>
Back to user management
</a>
</p>
<h1>Add another user</h1>
<p>You can add another user to help manage your domain. They will need to sign
into the .gov registrar with their Login.gov account.
</p>
<form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %}
{% input_with_errors form.email %}
<button
type="submit"
class="usa-button"
>Add user</button>
</form>
{% endblock %} {# domain_content #}

View file

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Domain {{ domain.name }}{% endblock %}
{% block content %}
<div class="grid-container">
<div class="grid-row grid-gap">
<div class="grid-col-3">
{% include 'domain_sidebar.html' %}
</div>
<div class="grid-col-9">
<main id="main-content" class="grid-container">
{% if messages %}
{% for message in messages %}
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-2">
<div class="usa-alert__body">
{{ message }}
</div>
</div>
{% endfor %}
{% endif %}
{% block domain_content %}
<p><a href="{% url 'home' %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
</svg>
Back to manage your domains
</a></p>
<h1>Domain {{ domain.name }}</h1>
{% endblock %} {# domain_content #}
</main>
</div>
</div>
</div>
{% endblock %} {# content #}

View file

@ -1,14 +1,6 @@
{% extends "dashboard_base.html" %}
{% block title %}Domain {{ domain.name }}{% endblock %}
{% block content %}
<main id="main-content" class="grid-container">
<div class="tablet:grid-offset-1 desktop:grid-offset-2">
<h1>{{ domain.name }}</h1>
{% extends "domain_base.html" %}
{% block domain_content %}
{{ block.super }}
<p>Active: {% if domain.is_active %}Yes{% else %}No{% endif %}</p>
</div>
</main>
{% endblock %} {# content #}
{% endblock %} {# domain_content #}

View file

@ -0,0 +1,61 @@
{% load static url_helpers %}
<div class="margin-bottom-4 tablet:margin-bottom-0">
<nav aria-label="Domain sections">
<ul class="usa-sidenav">
<li class="usa-sidenav__item">
{% url 'domain' pk=domain.id as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>
Domain Overview
</a>
</li>
<li class="usa-sidenav__item">
{% url 'todo' as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>
DNS name servers
</a>
</li>
<li class="usa-sidenav__item">
{% url 'todo' as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>
Authorizing official
</a>
</li>
<li class="usa-sidenav__item">
{% url 'todo' as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>
Your contact information
</a>
</li>
<li class="usa-sidenav__item">
{% url 'todo' as url %}
<a href="{{ url }}"
{% if request.path == url %}class="usa-current"{% endif %}
>
Security email
</a>
</li>
<li class="usa-sidenav__item">
{% url 'domain-users' pk=domain.id as url %}
<a href="{{ url }}"
{% if request.path|startswith:url %}class="usa-current"{% endif %}
>
User management
</a>
</li>
</ul>
</nav>
</div>

View file

@ -0,0 +1,48 @@
{% extends "domain_base.html" %}
{% load static %}
{% block title %}User management{% endblock %}
{% block domain_content %}
<p><a href="{% url 'home' %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
</svg>
Back to manage your domains
</a></p>
<h1>User management</h1>
{% if domain.permissions %}
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
<caption class="sr-only">Domain users</caption>
<thead>
<tr>
<th data-sortable scope="col" role="columnheader">Email</th>
<th data-sortable scope="col" role="columnheader">Role</th>
</tr>
</thead>
<tbody>
{% for permission in domain.permissions.all %}
<tr>
<th scope="row" role="rowheader" data-sort-value="{{ user.email }}" data-label="Domain name">
{{ permission.user.email }}
</th>
<td data-label="Role">{{ permission.role|title }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<div
class="usa-sr-only usa-table__announcement-region"
aria-live="polite"
></div>
{% endif %}
<a class="usa-button usa-button--unstyled" href="{% url 'domain-users-add' pk=domain.id %}">
<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>
</svg><span class="margin-left-05">Add another user</span>
</a>
{% endblock %} {# domain_content #}

View file

@ -17,7 +17,7 @@
<section class="dashboard tablet:grid-col-11 desktop:grid-col-10">
<h2>Registered domains</h2>
{% if domains %}
<table class="usa-table usa-table--borderless usa-table--stacked">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
<caption class="sr-only">Your domain applications</caption>
<thead>
<tr>
@ -56,7 +56,7 @@
<section class="dashboard tablet:grid-col-11 desktop:grid-col-10">
<h2>Active domain requests</h2>
{% if domain_applications %}
<table class="usa-table usa-table--borderless usa-table--stacked">
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table dotgov-table--stacked">
<caption class="sr-only">Your domain applications</caption>
<thead>
<tr>
@ -111,7 +111,7 @@
<section class="tablet:grid-col-11 desktop:grid-col-10">
<h2 class="padding-top-1 mobile-lg:padding-top-3"> Export domains</h2>
<p>If you would like to analyze your list of domains further, you can download the list of domains and their statuses as csv file</p>
<p>Download a list of your domains and their statuses as a csv file.</p>
<a href="{% url 'todo' %}" class="usa-button usa-button--outline">
Export domains as csv
</a>

View file

@ -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

View file

@ -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")

View file

@ -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 *

View file

@ -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())

View file

@ -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