Skip to content

Commit

Permalink
Merge pull request #490 from cisagov/nmb/user-invitations
Browse files Browse the repository at this point in the history
Invite new users to manage domains
  • Loading branch information
neilmb committed Apr 4, 2023
2 parents 7e6b731 + 64d0312 commit f82cb06
Show file tree
Hide file tree
Showing 23 changed files with 445 additions and 55 deletions.
2 changes: 2 additions & 0 deletions src/djangooidc/backends.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ def authenticate(self, request, **kwargs):
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)
Expand Down
2 changes: 2 additions & 0 deletions src/registrar/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions src/registrar/config/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@
views.DomainAddUserView.as_view(),
name="domain-users-add",
),
path(
"invitation/<int:pk>/delete",
views.DomainInvitationDeleteView.as_view(http_method_names=["post"]),
name="invitation-delete",
),
]


Expand Down
5 changes: 5 additions & 0 deletions src/registrar/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 0 additions & 11 deletions src/registrar/forms/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
51 changes: 51 additions & 0 deletions src/registrar/migrations/0016_domaininvitation.py
Original file line number Diff line number Diff line change
@@ -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,
},
),
]
3 changes: 3 additions & 0 deletions src/registrar/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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
Expand All @@ -15,6 +16,7 @@
"Contact",
"DomainApplication",
"Domain",
"DomainInvitation",
"HostIP",
"Host",
"Nameserver",
Expand All @@ -27,6 +29,7 @@
auditlog.register(Contact)
auditlog.register(DomainApplication)
auditlog.register(Domain)
auditlog.register(DomainInvitation)
auditlog.register(HostIP)
auditlog.register(Host)
auditlog.register(Nameserver)
Expand Down
3 changes: 3 additions & 0 deletions src/registrar/models/domain.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,6 @@ def last_transfer_date(self):

# 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
1 change: 1 addition & 0 deletions src/registrar/models/domain_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,7 @@ def _send_confirmation_email(self):
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},
)
Expand Down
73 changes: 73 additions & 0 deletions src/registrar/models/domain_invitation.py
Original file line number Diff line number Diff line change
@@ -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
)
27 changes: 27 additions & 0 deletions src/registrar/models/user.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -31,3 +38,23 @@ def __str__(self):
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
)
7 changes: 0 additions & 7 deletions src/registrar/templates/domain_add_user.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,6 @@
{% 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
Expand Down
20 changes: 11 additions & 9 deletions src/registrar/templates/domain_base.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,6 @@
<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 %}
<a href="{% url 'home' %}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
Expand All @@ -38,6 +29,17 @@
</p>
</a>

{# messages block is under the back breadcrumb link #}
{% if messages %}
{% for message in messages %}
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-3">
<div class="usa-alert__body">
{{ message }}
</div>
</div>
{% endfor %}
{% endif %}

{% block domain_content %}

<h1 class="break-word">{{ domain.name }}</h1>
Expand Down
32 changes: 31 additions & 1 deletion src/registrar/templates/domain_users.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ <h1>User management</h1>
<tbody>
{% for permission in domain.permissions.all %}
<tr>
<th scope="row" role="rowheader" data-sort-value="{{ user.email }}" data-label="Domain name">
<th scope="row" role="rowheader" data-sort-value="{{ user.email }}" data-label="Email">
{{ permission.user.email }}
</th>
<td data-label="Role">{{ permission.role|title }}</td>
Expand All @@ -38,4 +38,34 @@ <h1>User management</h1>
</svg><span class="margin-left-05">Add another user</span>
</a>

{% if domain.invitations.exists %}
<h2>Invitations</h2>
<table class="usa-table usa-table--borderless usa-table--stacked dotgov-table--stacked dotgov-table">
<caption class="sr-only">Domain invitations</caption>
<thead>
<tr>
<th data-sortable scope="col" role="columnheader">Email</th>
<th data-sortable scope="col" role="columnheader">Date created</th>
<th data-sortable scope="col" role="columnheader">Status</th>
<th scope="col" role="columnheader"><span class="sr-only">Action</span></th>
</tr>
</thead>
<tbody>
{% for invitation in domain.invitations.all %}
<tr>
<th scope="row" role="rowheader" data-sort-value="{{ user.email }}" data-label="Email">
{{ invitation.email }}
</th>
<td data-sort-value="{{ invitation.created_at|date:"U" }}" data-label="Date created">{{ invitation.created_at|date }} </td>
<td data-label="Status">{{ invitation.status|title }}</td>
<td><form method="POST" action="{% url "invitation-delete" pk=invitation.id %}">
{% csrf_token %}<input type="submit" class="usa-button--unstyled" value="Cancel">
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}

{% endblock %} {# domain_content #}
6 changes: 6 additions & 0 deletions src/registrar/templates/emails/domain_invitation.txt
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
You are invited to manage {{ domain.name }} on get.gov
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Thank you for applying for a .gov domain

0 comments on commit f82cb06

Please sign in to comment.