diff --git a/src/djangooidc/backends.py b/src/djangooidc/backends.py index ac65c89d4..08ca47d2e 100644 --- a/src/djangooidc/backends.py +++ b/src/djangooidc/backends.py @@ -49,6 +49,8 @@ class OpenIdConnectBackend(ModelBackend): user, created = UserModel.objects.update_or_create(**args) if created: user = self.configure_user(user, **kwargs) + # run a newly created user's callback for a first-time login + user.first_login() else: try: user = UserModel.objects.get_by_natural_key(username) diff --git a/src/registrar/admin.py b/src/registrar/admin.py index b2d291ce5..30c8e8b89 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -51,7 +51,9 @@ class MyHostAdmin(AuditedAdmin): admin.site.register(models.User, MyUserAdmin) +admin.site.register(models.UserDomainRole, AuditedAdmin) admin.site.register(models.Contact, AuditedAdmin) +admin.site.register(models.DomainInvitation, AuditedAdmin) admin.site.register(models.DomainApplication, AuditedAdmin) admin.site.register(models.Domain, AuditedAdmin) admin.site.register(models.Host, MyHostAdmin) diff --git a/src/registrar/config/urls.py b/src/registrar/config/urls.py index 0d0ec89f5..8674348c8 100644 --- a/src/registrar/config/urls.py +++ b/src/registrar/config/urls.py @@ -68,6 +68,11 @@ urlpatterns = [ views.DomainAddUserView.as_view(), name="domain-users-add", ), + path( + "invitation//delete", + views.DomainInvitationDeleteView.as_view(http_method_names=["post"]), + name="invitation-delete", + ), ] diff --git a/src/registrar/fixtures.py b/src/registrar/fixtures.py index 4edca7cf6..4f56a2336 100644 --- a/src/registrar/fixtures.py +++ b/src/registrar/fixtures.py @@ -39,6 +39,11 @@ class UserFixture: "first_name": "Logan", "last_name": "", }, + { + "username": "2ffe71b0-cea4-4097-8fb6-7a35b901dd70", + "first_name": "Neil", + "last_name": "Martinsen-Burrell", + }, ] @classmethod diff --git a/src/registrar/forms/domain.py b/src/registrar/forms/domain.py index 6a2229961..3d5941eed 100644 --- a/src/registrar/forms/domain.py +++ b/src/registrar/forms/domain.py @@ -2,20 +2,9 @@ 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/migrations/0016_domaininvitation.py b/src/registrar/migrations/0016_domaininvitation.py new file mode 100644 index 000000000..f7756ef1d --- /dev/null +++ b/src/registrar/migrations/0016_domaininvitation.py @@ -0,0 +1,51 @@ +# Generated by Django 4.1.6 on 2023-03-24 16:56 + +from django.db import migrations, models +import django.db.models.deletion +import django_fsm # type: ignore + + +class Migration(migrations.Migration): + dependencies = [ + ("registrar", "0015_remove_domain_owners_userdomainrole_user_domains_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="DomainInvitation", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("email", models.EmailField(max_length=254)), + ( + "status", + django_fsm.FSMField( + choices=[("sent", "sent"), ("retrieved", "retrieved")], + default="sent", + max_length=50, + protected=True, + ), + ), + ( + "domain", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="invitations", + to="registrar.domain", + ), + ), + ], + options={ + "abstract": False, + }, + ), + ] diff --git a/src/registrar/models/__init__.py b/src/registrar/models/__init__.py index 969915969..0fcfeca40 100644 --- a/src/registrar/models/__init__.py +++ b/src/registrar/models/__init__.py @@ -5,6 +5,7 @@ from .domain_application import DomainApplication from .domain import Domain from .host_ip import HostIP from .host import Host +from .domain_invitation import DomainInvitation from .nameserver import Nameserver from .user_domain_role import UserDomainRole from .public_contact import PublicContact @@ -15,6 +16,7 @@ __all__ = [ "Contact", "DomainApplication", "Domain", + "DomainInvitation", "HostIP", "Host", "Nameserver", @@ -27,6 +29,7 @@ __all__ = [ auditlog.register(Contact) auditlog.register(DomainApplication) auditlog.register(Domain) +auditlog.register(DomainInvitation) auditlog.register(HostIP) auditlog.register(Host) auditlog.register(Nameserver) diff --git a/src/registrar/models/domain.py b/src/registrar/models/domain.py index 6697e2e64..09b0fd211 100644 --- a/src/registrar/models/domain.py +++ b/src/registrar/models/domain.py @@ -281,3 +281,6 @@ class Domain(TimeStampedModel): # ManyToManyField on User creates a "users" member for all of the # users who have some role on this domain + + # ForeignKey on DomainInvitation creates an "invitations" member for + # all of the invitations that have been sent for this domain diff --git a/src/registrar/models/domain_application.py b/src/registrar/models/domain_application.py index 08343e863..526e39798 100644 --- a/src/registrar/models/domain_application.py +++ b/src/registrar/models/domain_application.py @@ -476,6 +476,7 @@ class DomainApplication(TimeStampedModel): try: send_templated_email( "emails/submission_confirmation.txt", + "emails/submission_confirmation_subject.txt", self.submitter.email, context={"id": self.id, "domain_name": self.requested_domain.name}, ) diff --git a/src/registrar/models/domain_invitation.py b/src/registrar/models/domain_invitation.py new file mode 100644 index 000000000..7cc2a5432 --- /dev/null +++ b/src/registrar/models/domain_invitation.py @@ -0,0 +1,73 @@ +"""People are invited by email to administer domains.""" + +import logging + +from django.contrib.auth import get_user_model +from django.db import models + +from django_fsm import FSMField, transition # type: ignore + +from .utility.time_stamped_model import TimeStampedModel +from .user_domain_role import UserDomainRole + + +logger = logging.getLogger(__name__) + + +class DomainInvitation(TimeStampedModel): + INVITED = "invited" + RETRIEVED = "retrieved" + + email = models.EmailField( + null=False, + blank=False, + ) + + domain = models.ForeignKey( + "registrar.Domain", + on_delete=models.CASCADE, # delete domain, then get rid of invitations + null=False, + related_name="invitations", + ) + + status = FSMField( + choices=[ + (INVITED, INVITED), + (RETRIEVED, RETRIEVED), + ], + default=INVITED, + protected=True, # can't alter state except through transition methods! + ) + + def __str__(self): + return f"Invitation for {self.email} on {self.domain} is {self.status}" + + @transition(field="status", source=INVITED, target=RETRIEVED) + def retrieve(self): + """When an invitation is retrieved, create the corresponding permission. + + Raises: + RuntimeError if no matching user can be found. + """ + + # get a user with this email address + User = get_user_model() + try: + user = User.objects.get(email=self.email) + except User.DoesNotExist: + # should not happen because a matching user should exist before + # we retrieve this invitation + raise RuntimeError( + "Cannot find the user to retrieve this domain invitation." + ) + + # and create a role for that user on this domain + _, created = UserDomainRole.objects.get_or_create( + user=user, domain=self.domain, role=UserDomainRole.Roles.ADMIN + ) + if not created: + # something strange happened and this role already existed when + # the invitation was retrieved. Log that this occurred. + logger.warn( + "Invitation %s was retrieved for a role that already exists.", self + ) diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 97f5753b3..4cd8b6c90 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -1,9 +1,16 @@ +import logging + from django.contrib.auth.models import AbstractUser from django.db import models +from .domain_invitation import DomainInvitation + from phonenumber_field.modelfields import PhoneNumberField # type: ignore +logger = logging.getLogger(__name__) + + class User(AbstractUser): """ A custom user model that performs identically to the default user model @@ -31,3 +38,23 @@ class User(AbstractUser): return self.email else: return self.username + + def first_login(self): + """Callback when the user is authenticated for the very first time. + + When a user first arrives on the site, we need to retrieve any domain + invitations that match their email address. + """ + for invitation in DomainInvitation.objects.filter( + email=self.email, status=DomainInvitation.INVITED + ): + try: + invitation.retrieve() + invitation.save() + except RuntimeError: + # retrieving should not fail because of a missing user, but + # if it does fail, log the error so a new user can continue + # logging in + logger.warn( + "Failed to retrieve invitation %s", invitation, exc_info=True + ) diff --git a/src/registrar/templates/domain_add_user.html b/src/registrar/templates/domain_add_user.html index 1a1c45aa3..87b141570 100644 --- a/src/registrar/templates/domain_add_user.html +++ b/src/registrar/templates/domain_add_user.html @@ -4,13 +4,6 @@ {% 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 diff --git a/src/registrar/templates/domain_base.html b/src/registrar/templates/domain_base.html index 5a6bf75ae..494491190 100644 --- a/src/registrar/templates/domain_base.html +++ b/src/registrar/templates/domain_base.html @@ -20,15 +20,6 @@

- {% if messages %} - {% for message in messages %} -
-
- {{ message }} -
-
- {% endfor %} - {% endif %} + {# messages block is under the back breadcrumb link #} + {% if messages %} + {% for message in messages %} +
+
+ {{ message }} +
+
+ {% endfor %} + {% endif %} + {% block domain_content %}

{{ domain.name }}

diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index d242b9c68..f842cff6c 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -18,7 +18,7 @@ {% for permission in domain.permissions.all %} - + {{ permission.user.email }} {{ permission.role|title }} @@ -38,4 +38,34 @@ Add another user + {% if domain.invitations.exists %} +

Invitations

+ + + + + + + + + + + + {% for invitation in domain.invitations.all %} + + + + + + + {% endfor %} + +
Domain invitations
EmailDate createdStatusAction
+ {{ invitation.email }} + {{ invitation.created_at|date }} {{ invitation.status|title }}
+ {% csrf_token %} +
+
+ {% endif %} + {% endblock %} {# domain_content #} diff --git a/src/registrar/templates/emails/domain_invitation.txt b/src/registrar/templates/emails/domain_invitation.txt new file mode 100644 index 000000000..8bfb53933 --- /dev/null +++ b/src/registrar/templates/emails/domain_invitation.txt @@ -0,0 +1,6 @@ +You have been invited to manage the domain {{ domain.name }} on get.gov, +the registrar for .gov domain names. + +To accept your invitation, go to <{{ domain_url }}>. + +You will need to log in with a Login.gov account using this email address. diff --git a/src/registrar/templates/emails/domain_invitation_subject.txt b/src/registrar/templates/emails/domain_invitation_subject.txt new file mode 100644 index 000000000..60db880de --- /dev/null +++ b/src/registrar/templates/emails/domain_invitation_subject.txt @@ -0,0 +1 @@ +You are invited to manage {{ domain.name }} on get.gov diff --git a/src/registrar/templates/emails/submission_confirmation_subject.txt b/src/registrar/templates/emails/submission_confirmation_subject.txt new file mode 100644 index 000000000..34b958b3a --- /dev/null +++ b/src/registrar/templates/emails/submission_confirmation_subject.txt @@ -0,0 +1 @@ +Thank you for applying for a .gov domain diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 69c80c52d..42b8803c3 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -7,6 +7,7 @@ from registrar.models import ( User, Website, Domain, + DomainInvitation, UserDomainRole, ) from unittest import skip @@ -164,6 +165,47 @@ class TestPermissions(TestCase): self.assertTrue(UserDomainRole.objects.get(user=user, domain=domain)) +class TestInvitations(TestCase): + + """Test the retrieval of invitations.""" + + def setUp(self): + self.domain, _ = Domain.objects.get_or_create(name="igorville.gov") + self.email = "mayor@igorville.gov" + self.invitation, _ = DomainInvitation.objects.get_or_create( + email=self.email, domain=self.domain + ) + self.user, _ = User.objects.get_or_create(email=self.email) + + # clean out the roles each time + UserDomainRole.objects.all().delete() + + def test_retrieval_creates_role(self): + self.invitation.retrieve() + self.assertTrue(UserDomainRole.objects.get(user=self.user, domain=self.domain)) + + def test_retrieve_missing_user_error(self): + # get rid of matching users + User.objects.filter(email=self.email).delete() + with self.assertRaises(RuntimeError): + self.invitation.retrieve() + + def test_retrieve_existing_role_no_error(self): + # make the overlapping role + UserDomainRole.objects.get_or_create( + user=self.user, domain=self.domain, role=UserDomainRole.Roles.ADMIN + ) + # this is not an error but does produce a console warning + with less_console_noise(): + self.invitation.retrieve() + self.assertEqual(self.invitation.status, DomainInvitation.RETRIEVED) + + def test_retrieve_on_first_login(self): + """A new user's first_login callback retrieves their invitations.""" + self.user.first_login() + self.assertTrue(UserDomainRole.objects.get(user=self.user, domain=self.domain)) + + @skip("Not implemented yet.") class TestDomainApplicationLifeCycle(TestCase): def test_application_approval(self): diff --git a/src/registrar/tests/test_views.py b/src/registrar/tests/test_views.py index b044e5b33..7f3dc6429 100644 --- a/src/registrar/tests/test_views.py +++ b/src/registrar/tests/test_views.py @@ -1,4 +1,5 @@ from unittest import skip +from unittest.mock import MagicMock, ANY from django.conf import settings from django.test import Client, TestCase @@ -9,7 +10,15 @@ from django_webtest import WebTest # type: ignore import boto3_mocking # type: ignore -from registrar.models import DomainApplication, Domain, Contact, Website, UserDomainRole +from registrar.models import ( + DomainApplication, + Domain, + DomainInvitation, + Contact, + Website, + UserDomainRole, + User, +) from registrar.views.application import ApplicationWizard, Step from .common import less_console_noise @@ -1128,3 +1137,87 @@ class TestDomainDetail(TestWithDomainPermissions, WebTest): self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) success_page = success_result.follow() self.assertContains(success_page, "mayor@igorville.gov") + + @boto3_mocking.patching + def test_domain_invitation_created(self): + """Add user on a nonexistent email creates an invitation. + + Adding a non-existent user sends an email as a side-effect, so mock + out the boto3 SES email sending here. + """ + # make sure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + 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"] = EMAIL + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_result = add_page.form.submit() + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + success_page = success_result.follow() + + self.assertContains(success_page, EMAIL) + self.assertContains(success_page, "Cancel") # link to cancel invitation + self.assertTrue(DomainInvitation.objects.filter(email=EMAIL).exists()) + + @boto3_mocking.patching + def test_domain_invitation_email_sent(self): + """Inviting a non-existent user sends them an email.""" + # make sure there is no user with this email + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + mock_client = MagicMock() + mock_client_instance = mock_client.return_value + with boto3_mocking.clients.handler_for("sesv2", mock_client): + 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"] = EMAIL + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + add_page.form.submit() + # check the mock instance to see if `send_email` was called right + mock_client_instance.send_email.assert_called_once_with( + FromEmailAddress=settings.DEFAULT_FROM_EMAIL, + Destination={"ToAddresses": [EMAIL]}, + Content=ANY, + ) + + def test_domain_invitation_cancel(self): + """Posting to the delete view deletes an invitation.""" + EMAIL = "mayor@igorville.gov" + invitation, _ = DomainInvitation.objects.get_or_create( + domain=self.domain, email=EMAIL + ) + self.client.post(reverse("invitation-delete", kwargs={"pk": invitation.id})) + with self.assertRaises(DomainInvitation.DoesNotExist): + DomainInvitation.objects.get(id=invitation.id) + + @boto3_mocking.patching + def test_domain_invitation_flow(self): + """Send an invitation to a new user, log in and load the dashboard.""" + EMAIL = "mayor@igorville.gov" + User.objects.filter(email=EMAIL).delete() + + 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"] = EMAIL + self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id) + add_page.form.submit() + + # user was invited, create them + new_user = User.objects.create(username=EMAIL, email=EMAIL) + # log them in to `self.app` + self.app.set_user(new_user.username) + # and manually call the first login callback + new_user.first_login() + + # Now load the home page and make sure our domain appears there + home_page = self.app.get(reverse("home")) + self.assertContains(home_page, self.domain.name) diff --git a/src/registrar/utility/email.py b/src/registrar/utility/email.py index e6de4c330..6491008cf 100644 --- a/src/registrar/utility/email.py +++ b/src/registrar/utility/email.py @@ -13,16 +13,22 @@ class EmailSendingError(RuntimeError): pass -def send_templated_email(template_name: str, to_address: str, context={}): +def send_templated_email( + template_name: str, subject_template_name: str, to_address: str, context={} +): """Send an email built from a template to one email address. - template_name is relative to the same template context as Django's HTML - templates. context gives additional information that the template may use. + template_name and subject_template_name are relative to the same template + context as Django's HTML templates. context gives additional information + that the template may use. """ template = get_template(template_name) email_body = template.render(context=context) + subject_template = get_template(subject_template_name) + subject = subject_template.render(context=context) + try: ses_client = boto3.client( "sesv2", @@ -40,7 +46,7 @@ def send_templated_email(template_name: str, to_address: str, context={}): Destination={"ToAddresses": [to_address]}, Content={ "Simple": { - "Subject": {"Data": "Thank you for applying for a .gov domain"}, + "Subject": {"Data": subject}, "Body": {"Text": {"Data": email_body}}, }, }, diff --git a/src/registrar/views/__init__.py b/src/registrar/views/__init__.py index e1ae2cc32..0776f70fe 100644 --- a/src/registrar/views/__init__.py +++ b/src/registrar/views/__init__.py @@ -1,5 +1,10 @@ from .application import * -from .domain import DomainView, DomainUsersView, DomainAddUserView +from .domain import ( + DomainView, + DomainUsersView, + DomainAddUserView, + DomainInvitationDeleteView, +) from .health import * from .index import * from .whoami import * diff --git a/src/registrar/views/domain.py b/src/registrar/views/domain.py index 150efab81..5c199066f 100644 --- a/src/registrar/views/domain.py +++ b/src/registrar/views/domain.py @@ -1,18 +1,25 @@ """View for a single Domain.""" -from django import forms +import logging + from django.contrib import messages +from django.contrib.messages.views import SuccessMessageMixin 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 django.views.generic.edit import DeleteView, FormMixin -from registrar.models import Domain, User, UserDomainRole +from registrar.models import Domain, DomainInvitation, User, UserDomainRole +from ..forms import DomainAddUserForm +from ..utility.email import send_templated_email, EmailSendingError from .utility import DomainPermission +logger = logging.getLogger(__name__) + + class DomainView(DomainPermission, DetailView): """Domain detail overview page.""" @@ -31,22 +38,6 @@ class DomainUsersView(DomainPermission, DetailView): 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. @@ -66,16 +57,63 @@ class DomainAddUserView(DomainPermission, FormMixin, DetailView): self.object = self.get_object() form = self.get_form() if form.is_valid(): + # there is a valid email address in the form return self.form_valid(form) else: return self.form_invalid(form) + def _domain_abs_url(self): + """Get an absolute URL for this domain.""" + return self.request.build_absolute_uri( + reverse("domain", kwargs={"pk": self.object.id}) + ) + + def _make_invitation(self, email_address): + """Make a Domain invitation for this email and redirect with a message.""" + invitation, created = DomainInvitation.objects.get_or_create( + email=email_address, domain=self.object + ) + if not created: + # that invitation already existed + messages.warning( + self.request, + f"{email_address} has already been invited to this domain.", + ) + else: + # created a new invitation in the database, so send an email + try: + send_templated_email( + "emails/domain_invitation.txt", + "emails/domain_invitation_subject.txt", + to_address=email_address, + context={ + "domain_url": self._domain_abs_url(), + "domain": self.object, + }, + ) + except EmailSendingError: + messages.warning(self.request, "Could not send email invitation.") + logger.warn( + "Could not sent email invitation to %s for domain %s", + email_address, + self.object, + exc_info=True, + ) + else: + messages.success( + self.request, f"Invited {email_address} to this domain." + ) + return redirect(self.get_success_url()) + 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: + requested_user = User.objects.get(email=requested_email) + except User.DoesNotExist: + # no matching user, go make an invitation + return self._make_invitation(requested_email) try: UserDomainRole.objects.create( @@ -87,3 +125,14 @@ class DomainAddUserView(DomainPermission, FormMixin, DetailView): messages.success(self.request, f"Added user {requested_email}.") return redirect(self.get_success_url()) + + +class DomainInvitationDeleteView(SuccessMessageMixin, DeleteView): + model = DomainInvitation + object: DomainInvitation # workaround for type mismatch in DeleteView + + def get_success_url(self): + return reverse("domain-users", kwargs={"pk": self.object.domain.id}) + + def get_success_message(self, cleaned_data): + return f"Successfully canceled invitation for {self.object.email}." diff --git a/src/zap.conf b/src/zap.conf index ba0ef6a89..ad01388a7 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -51,6 +51,7 @@ 10038 OUTOFSCOPE http://app:8080/(robots.txt|sitemap.xml|TODO|edit/) 10038 OUTOFSCOPE http://app:8080/users 10038 OUTOFSCOPE http://app:8080/users/add +10038 OUTOFSCOPE http://app:8080/delete # 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