Merge pull request #2168 from cisagov/za/2047-force-new-users-profile

(getgov-za) Ticket #2047: Force new users to finish setting up profile
This commit is contained in:
zandercymatics 2024-06-03 15:33:38 -06:00 committed by GitHub
commit 55be26aa50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 944 additions and 111 deletions

View file

@ -1248,3 +1248,125 @@ document.addEventListener('DOMContentLoaded', function() {
loadDomainRequests(1);
}
});
/**
* An IIFE that hooks up the edit buttons on the finish-user-setup page
*/
(function finishUserSetupListener() {
function getInputField(fieldName){
return document.querySelector(`#id_${fieldName}`)
}
// Shows the hidden input field and hides the readonly one
function showInputFieldHideReadonlyField(fieldName, button) {
let inputField = getInputField(fieldName)
let readonlyField = document.querySelector(`#${fieldName}__edit-button-readonly`)
readonlyField.classList.toggle('display-none');
inputField.classList.toggle('display-none');
// Toggle the bold style on the grid row
let gridRow = button.closest(".grid-col-2").closest(".grid-row")
if (gridRow){
gridRow.classList.toggle("bold-usa-label")
}
}
function handleFullNameField(fieldName = "full_name") {
// Remove the display-none class from the nearest parent div
let nameFieldset = document.querySelector("#profile-name-group");
if (nameFieldset){
nameFieldset.classList.remove("display-none");
}
// Hide the "full_name" field
let inputField = getInputField(fieldName);
if (inputField) {
inputFieldParentDiv = inputField.closest("div");
if (inputFieldParentDiv) {
inputFieldParentDiv.classList.add("display-none");
}
}
}
function handleEditButtonClick(fieldName, button){
button.addEventListener('click', function() {
// Lock the edit button while this operation occurs
button.disabled = true
if (fieldName == "full_name"){
handleFullNameField();
}else {
showInputFieldHideReadonlyField(fieldName, button);
}
// Hide the button itself
button.classList.add("display-none");
// Unlock after it completes
button.disabled = false
});
}
function setupListener(){
document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) {
// Get the "{field_name}" and "edit-button"
let fieldIdParts = button.id.split("__")
if (fieldIdParts && fieldIdParts.length > 0){
let fieldName = fieldIdParts[0]
// When the edit button is clicked, show the input field under it
handleEditButtonClick(fieldName, button);
}
});
}
function showInputOnErrorFields(){
document.addEventListener('DOMContentLoaded', function() {
// Get all input elements within the form
let form = document.querySelector("#finish-profile-setup-form");
let inputs = form ? form.querySelectorAll("input") : null;
if (!inputs) {
return null;
}
let fullNameButtonClicked = false
inputs.forEach(function(input) {
let fieldName = input.name;
let errorMessage = document.querySelector(`#id_${fieldName}__error-message`);
// If no error message is found, do nothing
if (!fieldName || !errorMessage) {
return null;
}
let editButton = document.querySelector(`#${fieldName}__edit-button`);
if (editButton){
// Show the input field of the field that errored out
editButton.click();
}
// If either the full_name field errors out,
// or if any of its associated fields do - show all name related fields.
let nameFields = ["first_name", "middle_name", "last_name"];
if (nameFields.includes(fieldName) && !fullNameButtonClicked){
// Click the full name button if any of its related fields error out
fullNameButton = document.querySelector("#full_name__edit-button");
if (fullNameButton) {
fullNameButton.click();
fullNameButtonClicked = true;
}
}
});
});
};
// Hookup all edit buttons to the `handleEditButtonClick` function
setupListener();
// Show the input fields if an error exists
showInputOnErrorFields();
})();

View file

@ -1,4 +1,5 @@
@use "uswds-core" as *;
@use "cisa_colors" as *;
/* Styles for making visible to screen reader / AT users only. */
.sr-only {
@ -169,3 +170,44 @@ abbr[title] {
.cursor-pointer {
cursor: pointer;
}
.input-with-edit-button {
svg.usa-icon {
width: 1.5em !important;
height: 1.5em !important;
color: #{$dhs-green};
position: absolute;
}
&.input-with-edit-button__error {
svg.usa-icon {
color: #{$dhs-red};
}
div.readonly-field {
color: #{$dhs-red};
}
}
}
// We need to deviate from some default USWDS styles here
// in this particular case, so we have to override this.
.usa-form .usa-button.readonly-edit-button {
margin-top: 0px !important;
padding-top: 0px !important;
svg {
width: 1.25em !important;
height: 1.25em !important;
}
}
// Define some styles for the .gov header/logo
.usa-logo button {
color: #{$dhs-dark-gray-85};
font-weight: 700;
font-family: family('sans');
font-size: 1.6rem;
line-height: 1.1;
}
.usa-logo button.usa-button--unstyled.disabled-button:hover{
color: #{$dhs-dark-gray-85};
}

View file

@ -1,4 +1,5 @@
@use "uswds-core" as *;
@use "cisa_colors" as *;
/* Make "placeholder" links visually obvious */
a[href$="todo"]::after {
@ -7,11 +8,16 @@ a[href$="todo"]::after {
content: " [link TBD]";
font-style: italic;
}
a.usa-link.usa-link--always-blue {
color: #{$dhs-blue};
}
a.breadcrumb__back {
display:flex;
align-items: center;
margin-bottom: units(2.5);
color: #{$dhs-blue};
&:visited {
color: color('primary');
}

View file

@ -46,6 +46,7 @@ $dhs-gray-10: #fcfdfd;
/*--- Dark Gray ---*/
$dhs-dark-gray-90: #040404;
$dhs-dark-gray-85: #1b1b1b;
$dhs-dark-gray-80: #19191a;
$dhs-dark-gray-70: #2f2f30;
$dhs-dark-gray-60: #444547;

View file

@ -1,4 +1,5 @@
@use "uswds-core" as *;
@use "cisa_colors" as *;
.usa-form .usa-button {
margin-top: units(3);
@ -26,6 +27,34 @@
}
}
.usa-form-editable {
border-top: 2px #{$dhs-dark-gray-15} solid;
.bold-usa-label label.usa-label{
font-weight: bold;
}
&.bold-usa-label label.usa-label{
font-weight: bold;
}
&.usa-form-editable--no-border {
border-top: None;
margin-top: 0px !important;
}
}
.usa-form-editable > .usa-form-group:first-of-type {
margin-top: unset;
}
@media (min-width: 35em) {
.usa-form--largest {
max-width: 35rem;
}
}
.usa-form-group--unstyled-error {
margin-left: 0;
padding-left: 0;

View file

@ -24,3 +24,7 @@
text-align: center !important;
}
}
#extended-logo .usa-tooltip__body {
font-weight: 400 !important;
}

View file

@ -162,7 +162,7 @@ MIDDLEWARE = [
# django-cors-headers: listen to cors responses
"corsheaders.middleware.CorsMiddleware",
# custom middleware to stop caching from CloudFront
"registrar.no_cache_middleware.NoCacheMiddleware",
"registrar.registrar_middleware.NoCacheMiddleware",
# serve static assets in production
"whitenoise.middleware.WhiteNoiseMiddleware",
# provide security enhancements to the request/response cycle
@ -188,6 +188,7 @@ MIDDLEWARE = [
"auditlog.middleware.AuditlogMiddleware",
# Used for waffle feature flags
"waffle.middleware.WaffleMiddleware",
"registrar.registrar_middleware.CheckUserProfileMiddleware",
]
# application object used by Djangos built-in servers (e.g. `runserver`)

View file

@ -180,6 +180,11 @@ urlpatterns = [
views.DomainAddUserView.as_view(),
name="domain-users-add",
),
path(
"finish-profile-setup",
views.FinishProfileSetupView.as_view(),
name="finish-user-profile-setup",
),
path(
"user-profile",
views.UserProfileView.as_view(),

View file

@ -60,4 +60,35 @@ class UserProfileForm(forms.ModelForm):
}
self.fields["phone"].error_messages["required"] = "Enter your phone number."
if self.instance and self.instance.phone:
self.fields["phone"].initial = self.instance.phone.as_national
DomainHelper.disable_field(self.fields["email"], disable_required=True)
class FinishSetupProfileForm(UserProfileForm):
"""Form for updating user profile."""
full_name = forms.CharField(required=True, label="Full name")
def clean(self):
cleaned_data = super().clean()
# Remove the full name property
if "full_name" in cleaned_data:
# Delete the full name element as its purely decorative.
# We include it as a normal Charfield for all the advantages
# and utility that it brings, but we're playing pretend.
del cleaned_data["full_name"]
return cleaned_data
def __init__(self, *args, **kwargs):
"""Override the inerited __init__ method to update the fields."""
super().__init__(*args, **kwargs)
# Set custom form label for email
self.fields["email"].label = "Organization email"
self.fields["title"].label = "Title or role in your organization"
# Define the "full_name" value
self.fields["full_name"].initial = self.instance.get_formatted_name()

View file

@ -98,6 +98,24 @@ class User(AbstractUser):
help_text="The means through which this user was verified",
)
@property
def finished_setup(self):
"""
Tracks if the user finished their profile setup or not. This is so
we can globally enforce that new users provide additional account information before proceeding.
"""
# Change this to self once the user and contact objects are merged.
# For now, since they are linked, lets test on the underlying contact object.
user_info = self.contact # noqa
user_values = [
user_info.first_name,
user_info.last_name,
user_info.title,
user_info.phone,
]
return None not in user_values
def __str__(self):
# this info is pulled from Login.gov
if self.first_name or self.last_name:

View file

@ -1,17 +0,0 @@
"""Middleware to add Cache-control: no-cache to every response.
Used to force Cloudfront caching to leave us alone while we develop
better caching responses.
"""
class NoCacheMiddleware:
"""Middleware to add a single header to every response."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
response["Cache-Control"] = "no-cache"
return response

View file

@ -0,0 +1,100 @@
"""
Contains middleware used in settings.py
"""
from urllib.parse import parse_qs
from django.urls import reverse
from django.http import HttpResponseRedirect
from waffle.decorators import flag_is_active
from registrar.models.utility.generic_helper import replace_url_queryparams
class NoCacheMiddleware:
"""
Middleware to add Cache-control: no-cache to every response.
Used to force Cloudfront caching to leave us alone while we develop
better caching responses.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
response["Cache-Control"] = "no-cache"
return response
class CheckUserProfileMiddleware:
"""
Checks if the current user has finished_setup = False.
If they do, redirect them to the setup page regardless of where they are in
the application.
"""
def __init__(self, get_response):
self.get_response = get_response
self.setup_page = reverse("finish-user-profile-setup")
self.logout_page = reverse("logout")
self.excluded_pages = [
self.setup_page,
self.logout_page,
]
def __call__(self, request):
response = self.get_response(request)
return response
def process_view(self, request, view_func, view_args, view_kwargs):
"""Runs pre-processing logic for each view. Checks for the
finished_setup flag on the current user. If they haven't done so,
then we redirect them to the finish setup page."""
# Check that the user is "opted-in" to the profile feature flag
has_profile_feature_flag = flag_is_active(request, "profile_feature")
# If they aren't, skip this check entirely
if not has_profile_feature_flag:
return None
if request.user.is_authenticated:
if hasattr(request.user, "finished_setup") and not request.user.finished_setup:
return self._handle_setup_not_finished(request)
# Continue processing the view
return None
def _handle_setup_not_finished(self, request):
"""Redirects the given user to the finish setup page.
We set the "redirect" query param equal to where the user wants to go.
If the user wants to go to '/request/', then we set that
information in the query param.
Otherwise, we assume they want to go to the home page.
"""
# In some cases, we don't want to redirect to home. This handles that.
# Can easily be generalized if need be, but for now lets keep this easy to read.
custom_redirect = "domain-request:" if request.path == "/request/" else None
# Don't redirect on excluded pages (such as the setup page itself)
if not any(request.path.startswith(page) for page in self.excluded_pages):
# Preserve the original query parameters, and coerce them into a dict
query_params = parse_qs(request.META["QUERY_STRING"])
# Set the redirect value to our redirect location
if custom_redirect is not None:
query_params["redirect"] = custom_redirect
# Add our new query param, while preserving old ones
new_setup_page = replace_url_queryparams(self.setup_page, query_params) if query_params else self.setup_page
return HttpResponseRedirect(new_setup_page)
else:
# Process the view as normal
return None

View file

@ -139,11 +139,7 @@
<div class="usa-nav-container">
<div class="usa-navbar">
{% block logo %}
<div class="usa-logo display-inline-block" id="extended-logo">
<strong class="usa-logo__text" >
<a href="{% url 'home' %}">.gov Registrar </a>
</strong>
</div>
{% include "includes/gov_extended_logo.html" with logo_clickable=True %}
{% endblock %}
<button type="button" class="usa-menu-btn">Menu</button>
</div>
@ -160,7 +156,8 @@
{% if has_profile_feature_flag %}
<li class="usa-nav__primary-item">
{% url 'user-profile' as user_profile_url %}
<a class="usa-nav-link {% if request.path == user_profile_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
{% url 'finish-user-profile-setup' as finish_setup_url %}
<a class="usa-nav-link {% if request.path == user_profile_url or request.path == finish_setup_url %}usa-current{% endif %}" href="{{ user_profile_url }}">
<span class="text-primary">Your profile</span>
</a>
</li>
@ -206,7 +203,9 @@
</div>
{% endblock wrapper%}
{% include "includes/footer.html" %}
{% block footer %}
{% include "includes/footer.html" with show_manage_your_domains=True %}
{% endblock footer %}
</div> <!-- /#wrapper -->
{% block init_js %}{% endblock %}{# useful for vars and other initializations #}

View file

@ -0,0 +1,20 @@
{% extends "profile.html" %}
{% load static form_helpers url_helpers field_helpers %}
{% block title %} Finish setting up your profile | {% endblock %}
{# Disable the redirect #}
{% block logo %}
{% include "includes/gov_extended_logo.html" with logo_clickable=confirm_changes %}
{% endblock %}
{# Add the new form #}
{% block content_bottom %}
{% include "includes/finish_profile_form.html" with form=form %}
</div>
</main>
{% endblock content_bottom %}
{% block footer %}
{% include "includes/footer.html" with show_manage_your_domains=confirm_changes %}
{% endblock footer %}

View file

@ -0,0 +1,89 @@
{% extends 'includes/profile_form.html' %}
{% load static url_helpers %}
{% load field_helpers %}
{% block profile_header %}
<h1>Finish setting up your profile</h1>
{% endblock profile_header %}
{% block profile_blurb %}
<p>
We <a class="usa-link usa-link--always-blue" href="{% public_site_url 'domains/requirements/#keep-your-contact-information-updated' %}" target="_blank">require</a>
that you maintain accurate contact information.
The details you provide will only be used to support the administration of .gov and wont be made public.
</p>
<h2>What contact information should we use to reach you?</h2>
<p>
Review the details below and update any required information.
Note that editing this information wont affect your Login.gov account information.
</p>
{# We use a var called 'remove_margin_top' rather than 'add_margin_top' because this is more useful as a default #}
{% include "includes/required_fields.html" with remove_margin_top=True %}
{% endblock profile_blurb %}
{% block profile_form %}
<form id="finish-profile-setup-form" class="usa-form usa-form--largest" method="post" novalidate>
{% csrf_token %}
<fieldset class="usa-fieldset">
<legend class="usa-sr-only">
Your contact information
</legend>
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable usa-form-editable--no-border padding-top-2" %}
{% input_with_errors form.full_name %}
{% endwith %}
<div id="profile-name-group" class="display-none" role="group">
{% with group_classes="usa-form-editable usa-form-editable--no-border padding-top-2" %}
{% input_with_errors form.first_name %}
{% endwith %}
{% with group_classes="usa-form-editable padding-top-2" %}
{% input_with_errors form.middle_name %}
{% endwith %}
{% with group_classes="usa-form-editable padding-top-2" %}
{% input_with_errors form.last_name %}
{% endwith %}
</div>
{% public_site_url "help/account-management/#get-help-with-login.gov" as login_help_url %}
{% with show_readonly=True add_class="display-none" group_classes="usa-form-editable usa-form-editable padding-top-2 bold-usa-label" %}
{% with link_href=login_help_url %}
{% with sublabel_text="We recommend using your work email for your .gov account. If the wrong email is displayed below, youll need to update your Login.gov account and log back in. Get help with your Login.gov account." %}
{% with link_text="Get help with your Login.gov account" target_blank=True do_not_show_max_chars=True %}
{% input_with_errors form.email %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %}
{% input_with_errors form.title %}
{% endwith %}
{% with show_edit_button=True show_readonly=True group_classes="usa-form-editable padding-top-2" %}
{% with add_class="usa-input--medium" %}
{% input_with_errors form.phone %}
{% endwith %}
{% endwith %}
</fieldset>
<div>
<button type="submit" name="contact_setup_save_button" class="usa-button ">
Save
</button>
{% if confirm_changes and going_to_specific_page %}
<button type="submit" name="contact_setup_submit_button" class="usa-button usa-button--outline">
{{redirect_button_text }}
</button>
{% endif %}
</div>
</form>
{% endblock profile_form %}

View file

@ -26,10 +26,12 @@
>
<address class="usa-footer__address">
<div class="usa-footer__contact-info grid-row grid-gap-md">
{% if show_manage_your_domains %}
<div class="grid-col-auto">
<a class="usa-link" rel="noopener noreferrer" href="{% url 'home' %}">Manage your domains</a>
</div>
<span class=""> | </span>
{% endif %}
<div class="grid-col-auto">
<a class="usa-link" rel="noopener noreferrer" target="_blank" href="{% public_site_url 'help/' %}">Help </a>
</div>

View file

@ -0,0 +1,17 @@
{# Q: For reviewers -- What should this file be called? #}
<div class="usa-logo display-inline-block" id="extended-logo">
<strong class="usa-logo__text" >
{% if logo_clickable %}
<a href="{% url 'home' %}">.gov Registrar </a>
{% else %}
<button
class="usa-button--unstyled disabled-button usa-tooltip"
data-position="bottom"
title="Before you can manage your domains, we need you to add contact information."
data-tooltip="true"
role="button"
>.gov Registrar</button>
{% endif %}
</strong>
</div>

View file

@ -2,7 +2,7 @@
Template include for form fields with classes and their corresponding
error messages, if necessary.
{% endcomment %}
{% load static field_helpers url_helpers %}
{% load custom_filters %}
{% load widget_tweaks %}
@ -27,7 +27,11 @@ error messages, if necessary.
{% endif %}
{% if not field.widget_type == "checkbox" %}
{% include "django/forms/label.html" %}
{% if show_edit_button %}
{% include "includes/label_with_edit_button.html" with bold_label=True %}
{% else %}
{% include "django/forms/label.html" %}
{% endif %}
{% endif %}
{% if sublabel_text %}
@ -58,6 +62,11 @@ error messages, if necessary.
{% if append_gov %}
<div class="display-flex flex-align-center">
{% endif %}
{% if show_readonly %}
{% include "includes/readonly_input.html" %}
{% endif %}
{# this is the input field, itself #}
{% include widget.template_name %}

View file

@ -0,0 +1,14 @@
{% load static field_helpers url_helpers %}
<div class="grid-row {% if bold_label %}bold-usa-label{% endif %}">
<div class="grid-col">
{% include "django/forms/label.html" %}
</div>
<div class="grid-col-2 text-right">
<button type="button" id="{{field.name}}__edit-button" class="usa-button usa-button--unstyled readonly-edit-button">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
<use xlink:href="{%static 'img/sprite.svg'%}#edit"></use>
</svg>Edit
</button>
</div>
</div>

View file

@ -0,0 +1,50 @@
{% load static url_helpers %}
{% load field_helpers %}
{% block profile_header %}
<h1>Your profile</h1>
{% endblock profile_header %}
{% block profile_blurb %}
<p>We <a class="usa-link usa-link--always-blue" href="{% public_site_url 'domains/requirements/#keep-your-contact-information-updated' %}" target="_blank">require</a> that you maintain accurate contact information. The details you provide will only be used to support the administration of .gov and wont be made public.</p>
<h2>Contact information</h2>
<p>Review the details below and update any required information. Note that editing this information wont affect your Login.gov account information.</p>
{% include "includes/required_fields.html" %}
{% endblock profile_blurb %}
{% block profile_form %}
<form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %}
{% input_with_errors form.first_name %}
{% input_with_errors form.middle_name %}
{% input_with_errors form.last_name %}
{% input_with_errors form.title %}
{% public_site_url "help/account-management/#get-help-with-login.gov" as login_help_url %}
{% with link_href=login_help_url %}
{% with sublabel_text="We recommend using your work email for your .gov account. If the wrong email is displayed below, youll need to update your Login.gov account and log back in. Get help with your Login.gov account." %}
{% with link_text="Get help with your Login.gov account" %}
{% with target_blank=True %}
{% with do_not_show_max_chars=True %}
{% input_with_errors form.email %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% with add_class="usa-input--medium" %}
{% input_with_errors form.phone %}
{% endwith %}
<button type="submit" class="usa-button">Save</button>
</form>
{% endblock profile_form %}

View file

@ -0,0 +1,18 @@
{% load static field_helpers url_helpers custom_filters %}
<div id="{{field.name}}__edit-button-readonly" class="margin-top-2 margin-bottom-1 input-with-edit-button {% if not field.value and field.field.required %}input-with-edit-button__error{% endif %}">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img" width="24" height="24">
{% if field.value or not field.field.required %}
<use xlink:href="{%static 'img/sprite.svg'%}#check_circle"></use>
{%elif not field.value %}
<use xlink:href="{%static 'img/sprite.svg'%}#error"></use>
{%endif %}
</svg>
<div class="display-inline padding-left-05 margin-left-3 readonly-field {% if not field.field.required %}text-base{% endif %}">
{% if field.name != "phone" %}
{{ field.value }}
{% else %}
{{ field.value|format_phone }}
{% endif %}
</div>
</div>

View file

@ -1,3 +1,3 @@
<p class="margin-top-3">
<p class="{% if not remove_margin_top %}margin-top-3 {% endif %}">
<em>Required fields are marked with an asterisk (<abbr class="usa-hint usa-hint--required" title="required">*</abbr>).</em>
</p>

View file

@ -1,16 +1,13 @@
{% extends 'base.html' %}
{% block title %}
Edit your User Profile |
{% endblock title %}
{% load static url_helpers %}
{% load field_helpers %}
{% block content %}
<main id="main-content" class="grid-container">
<div class="grid-col desktop:grid-offset-2 desktop:grid-col-8">
{# messages block #}
{% if messages %}
{% for message in messages %}
<div class="usa-alert usa-alert--{{ message.tags }} usa-alert--slim margin-bottom-3">
@ -21,61 +18,28 @@ Edit your User Profile |
{% endfor %}
{% endif %}
{% include "includes/form_errors.html" with form=form %}
<a href="{% if not return_to_request %}{% url 'home' %}{% else %}{% url 'domain-request:' %}{% endif %}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
</svg>
{% if not return_to_request %}
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
Back to manage your domains
</p>
{% else %}
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
Go back to your domain request
</p>
{% endif %}
</a>
<h1>Your profile</h1>
<p>We <a href="{% public_site_url 'domains/requirements/#what-.gov-domain-registrants-must-do' %}" target="_blank">require</a> that you maintain accurate contact information. The details you provide will only be used to support the administration of .gov and wont be made public.</p>
<h2>Contact information</h2>
<p>Review the details below and update any required information. Note that editing this information wont affect your Login.gov account information.</p>
{% if show_back_button %}
<a href="{% if not return_to_request %}{% url 'home' %}{% else %}{% url 'domain-request:' %}{% endif %}" class="breadcrumb__back">
<svg class="usa-icon" aria-hidden="true" focusable="false" role="img">
<use xlink:href="{% static 'img/sprite.svg' %}#arrow_back"></use>
</svg>
{% if not return_to_request %}
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
{{ profile_back_button_text }}
</p>
{% else %}
<p class="margin-left-05 margin-top-0 margin-bottom-0 line-height-sans-1">
Go back to your domain request
</p>
{% endif %}
</a>
{% endif %}
{% include "includes/required_fields.html" %}
<form class="usa-form usa-form--large" method="post" novalidate>
{% csrf_token %}
{% input_with_errors form.first_name %}
{% input_with_errors form.middle_name %}
{% input_with_errors form.last_name %}
{% input_with_errors form.title %}
{% public_site_url "help/account-management/#get-help-with-login.gov" as login_help_url %}
{% with link_href=login_help_url %}
{% with sublabel_text="We recommend using your work email for your .gov account. If the wrong email is displayed below, youll need to update your Login.gov account and log back in. Get help with your Login.gov account." %}
{% with link_text="Get help with your Login.gov account" %}
{% with target_blank=True %}
{% with do_not_show_max_chars=True %}
{% input_with_errors form.email %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% endwith %}
{% with add_class="usa-input--medium" %}
{% input_with_errors form.phone %}
{% endwith %}
<button type="submit" class="usa-button">Save</button>
</form>
</main>
{% endblock content %}
{% block content_bottom %}
{% include "includes/profile_form.html" with form=form %}
</div>
</main>
{% endblock content_bottom %}

View file

@ -2,6 +2,7 @@ import logging
from django import template
import re
from registrar.models.domain_request import DomainRequest
from phonenumber_field.phonenumber import PhoneNumber
register = template.Library()
logger = logging.getLogger(__name__)
@ -133,3 +134,14 @@ def get_region(state):
return regions.get(state.upper(), "N/A")
else:
return None
@register.filter
def format_phone(value):
"""Converts a phonenumber to a national format"""
if value:
phone_number = value
if isinstance(value, str):
phone_number = PhoneNumber.from_string(value)
return phone_number.as_national
return value

View file

@ -26,6 +26,7 @@ def input_with_errors(context, field=None): # noqa: C901
add_group_class: append to input element's surrounding tag's `class` attribute
attr_* - adds or replaces any single html attribute for the input
add_error_attr_* - like `attr_*` but only if field.errors is not empty
show_edit_button: shows a simple edit button, and adds display-none to the input field.
Example usage:
```
@ -91,6 +92,12 @@ def input_with_errors(context, field=None): # noqa: C901
elif key == "add_group_class":
group_classes.append(value)
elif key == "show_edit_button":
# Hide the primary input field.
# Used such that we can toggle it with JS
if "display-none" not in classes:
classes.append("display-none")
attrs["id"] = field.auto_id
# do some work for various edge cases

View file

@ -20,6 +20,7 @@ from django.urls import reverse
from registrar.models import (
DomainRequest,
DomainInformation,
Website,
)
from waffle.testutils import override_flag
import logging
@ -54,8 +55,19 @@ class TestWithUser(MockEppLib):
first_name = "First"
last_name = "Last"
email = "info@example.com"
phone = "8003111234"
self.user = get_user_model().objects.create(
username=username, first_name=first_name, last_name=last_name, email=email
username=username, first_name=first_name, last_name=last_name, email=email, phone=phone
)
title = "test title"
self.user.contact.title = title
self.user.contact.save()
username_incomplete = "test_user_incomplete"
first_name_2 = "Incomplete"
email_2 = "unicorn@igorville.com"
self.incomplete_user = get_user_model().objects.create(
username=username_incomplete, first_name=first_name_2, email=email_2
)
def tearDown(self):
@ -64,6 +76,7 @@ class TestWithUser(MockEppLib):
DomainRequest.objects.all().delete()
DomainInformation.objects.all().delete()
self.user.delete()
self.incomplete_user.delete()
class TestEnvironmentVariablesEffects(TestCase):
@ -474,6 +487,137 @@ class HomeTests(TestWithUser):
self.assertEqual(response.status_code, 403)
class FinishUserProfileTests(TestWithUser, WebTest):
"""A series of tests that target the finish setup page for user profile"""
# csrf checks do not work well with WebTest.
# We disable them here.
csrf_checks = False
def setUp(self):
super().setUp()
self.user.title = None
self.user.save()
self.client.force_login(self.user)
self.domain, _ = Domain.objects.get_or_create(name="sampledomain.gov", state=Domain.State.READY)
self.role, _ = UserDomainRole.objects.get_or_create(
user=self.user, domain=self.domain, role=UserDomainRole.Roles.MANAGER
)
def tearDown(self):
super().tearDown()
PublicContact.objects.filter(domain=self.domain).delete()
self.role.delete()
self.domain.delete()
Domain.objects.all().delete()
Website.objects.all().delete()
Contact.objects.all().delete()
def _set_session_cookie(self):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
def _submit_form_webtest(self, form, follow=False):
page = form.submit()
self._set_session_cookie()
return page.follow() if follow else page
@less_console_noise_decorator
def test_new_user_with_profile_feature_on(self):
"""Tests that a new user is redirected to the profile setup page when profile_feature is on"""
self.app.set_user(self.incomplete_user.username)
with override_flag("profile_feature", active=True):
# This will redirect the user to the setup page.
# Follow implicity checks if our redirect is working.
finish_setup_page = self.app.get(reverse("home")).follow()
self._set_session_cookie()
# Assert that we're on the right page
self.assertContains(finish_setup_page, "Finish setting up your profile")
finish_setup_page = self._submit_form_webtest(finish_setup_page.form)
self.assertEqual(finish_setup_page.status_code, 200)
# We're missing a phone number, so the page should tell us that
self.assertContains(finish_setup_page, "Enter your phone number.")
# Check for the name of the save button
self.assertContains(finish_setup_page, "contact_setup_save_button")
# Add a phone number
finish_setup_form = finish_setup_page.form
finish_setup_form["phone"] = "(201) 555-0123"
finish_setup_form["title"] = "CEO"
finish_setup_form["last_name"] = "example"
save_page = self._submit_form_webtest(finish_setup_form, follow=True)
self.assertEqual(save_page.status_code, 200)
self.assertContains(save_page, "Your profile has been updated.")
# Try to navigate back to the home page.
# This is the same as clicking the back button.
completed_setup_page = self.app.get(reverse("home"))
self.assertContains(completed_setup_page, "Manage your domain")
@less_console_noise_decorator
def test_new_user_goes_to_domain_request_with_profile_feature_on(self):
"""Tests that a new user is redirected to the domain request page when profile_feature is on"""
self.app.set_user(self.incomplete_user.username)
with override_flag("profile_feature", active=True):
# This will redirect the user to the setup page
finish_setup_page = self.app.get(reverse("domain-request:")).follow()
self._set_session_cookie()
# Assert that we're on the right page
self.assertContains(finish_setup_page, "Finish setting up your profile")
finish_setup_page = self._submit_form_webtest(finish_setup_page.form)
self.assertEqual(finish_setup_page.status_code, 200)
# We're missing a phone number, so the page should tell us that
self.assertContains(finish_setup_page, "Enter your phone number.")
# Check for the name of the save button
self.assertContains(finish_setup_page, "contact_setup_save_button")
# Add a phone number
finish_setup_form = finish_setup_page.form
finish_setup_form["phone"] = "(201) 555-0123"
finish_setup_form["title"] = "CEO"
finish_setup_form["last_name"] = "example"
completed_setup_page = self._submit_form_webtest(finish_setup_page.form, follow=True)
self.assertEqual(completed_setup_page.status_code, 200)
# Assert that we're on the domain request page
self.assertNotContains(completed_setup_page, "Finish setting up your profile")
self.assertNotContains(completed_setup_page, "What contact information should we use to reach you?")
self.assertContains(completed_setup_page, "Youre about to start your .gov domain request")
@less_console_noise_decorator
def test_new_user_with_profile_feature_off(self):
"""Tests that a new user is not redirected to the profile setup page when profile_feature is off"""
with override_flag("profile_feature", active=False):
response = self.client.get("/")
self.assertNotContains(response, "Finish setting up your profile")
@less_console_noise_decorator
def test_new_user_goes_to_domain_request_with_profile_feature_off(self):
"""Tests that a new user is redirected to the domain request page
when profile_feature is off but not the setup page"""
with override_flag("profile_feature", active=False):
response = self.client.get("/request/")
self.assertNotContains(response, "Finish setting up your profile")
self.assertNotContains(response, "What contact information should we use to reach you?")
self.assertContains(response, "Youre about to start your .gov domain request")
class UserProfileTests(TestWithUser, WebTest):
"""A series of tests that target your profile functionality"""
@ -502,7 +646,7 @@ class UserProfileTests(TestWithUser, WebTest):
assume that the same test results hold true for 401 and 403."""
with override_flag("profile_feature", active=True):
with self.assertRaises(Exception):
response = self.client.get(reverse("home"))
response = self.client.get(reverse("home"), follow=True)
self.assertEqual(response.status_code, 500)
self.assertContains(response, "Your profile")
@ -522,42 +666,42 @@ class UserProfileTests(TestWithUser, WebTest):
def test_home_page_main_nav_with_profile_feature_on(self):
"""test that Your profile is in main nav of home page when profile_feature is on"""
with override_flag("profile_feature", active=True):
response = self.client.get("/")
response = self.client.get("/", follow=True)
self.assertContains(response, "Your profile")
@less_console_noise_decorator
def test_home_page_main_nav_with_profile_feature_off(self):
"""test that Your profile is not in main nav of home page when profile_feature is off"""
with override_flag("profile_feature", active=False):
response = self.client.get("/")
response = self.client.get("/", follow=True)
self.assertNotContains(response, "Your profile")
@less_console_noise_decorator
def test_new_request_main_nav_with_profile_feature_on(self):
"""test that Your profile is in main nav of new request when profile_feature is on"""
with override_flag("profile_feature", active=True):
response = self.client.get("/request/")
response = self.client.get("/request/", follow=True)
self.assertContains(response, "Your profile")
@less_console_noise_decorator
def test_new_request_main_nav_with_profile_feature_off(self):
"""test that Your profile is not in main nav of new request when profile_feature is off"""
with override_flag("profile_feature", active=False):
response = self.client.get("/request/")
response = self.client.get("/request/", follow=True)
self.assertNotContains(response, "Your profile")
@less_console_noise_decorator
def test_user_profile_main_nav_with_profile_feature_on(self):
"""test that Your profile is in main nav of user profile when profile_feature is on"""
with override_flag("profile_feature", active=True):
response = self.client.get("/user-profile")
response = self.client.get("/user-profile", follow=True)
self.assertContains(response, "Your profile")
@less_console_noise_decorator
def test_user_profile_returns_404_when_feature_off(self):
"""test that Your profile returns 404 when profile_feature is off"""
with override_flag("profile_feature", active=False):
response = self.client.get("/user-profile")
response = self.client.get("/user-profile", follow=True)
self.assertEqual(response.status_code, 404)
@less_console_noise_decorator
@ -582,14 +726,14 @@ class UserProfileTests(TestWithUser, WebTest):
def test_domain_your_contact_information_when_profile_feature_off(self):
"""test that Your contact information is accessible when profile_feature is off"""
with override_flag("profile_feature", active=False):
response = self.client.get(f"/domain/{self.domain.id}/your-contact-information")
response = self.client.get(f"/domain/{self.domain.id}/your-contact-information", follow=True)
self.assertContains(response, "Your contact information")
@less_console_noise_decorator
def test_domain_your_contact_information_when_profile_feature_on(self):
"""test that Your contact information is not accessible when profile feature is on"""
with override_flag("profile_feature", active=True):
response = self.client.get(f"/domain/{self.domain.id}/your-contact-information")
response = self.client.get(f"/domain/{self.domain.id}/your-contact-information", follow=True)
self.assertEqual(response.status_code, 404)
@less_console_noise_decorator
@ -606,9 +750,9 @@ class UserProfileTests(TestWithUser, WebTest):
submitter=contact_user,
)
with override_flag("profile_feature", active=True):
response = self.client.get(f"/domain-request/{domain_request.id}")
response = self.client.get(f"/domain-request/{domain_request.id}", follow=True)
self.assertContains(response, "Your profile")
response = self.client.get(f"/domain-request/{domain_request.id}/withdraw")
response = self.client.get(f"/domain-request/{domain_request.id}/withdraw", follow=True)
self.assertContains(response, "Your profile")
@less_console_noise_decorator
@ -625,9 +769,9 @@ class UserProfileTests(TestWithUser, WebTest):
submitter=contact_user,
)
with override_flag("profile_feature", active=False):
response = self.client.get(f"/domain-request/{domain_request.id}")
response = self.client.get(f"/domain-request/{domain_request.id}", follow=True)
self.assertNotContains(response, "Your profile")
response = self.client.get(f"/domain-request/{domain_request.id}/withdraw")
response = self.client.get(f"/domain-request/{domain_request.id}/withdraw", follow=True)
self.assertNotContains(response, "Your profile")
# cleanup
domain_request.delete()
@ -642,13 +786,6 @@ class UserProfileTests(TestWithUser, WebTest):
session_id = self.app.cookies[settings.SESSION_COOKIE_NAME]
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
profile_form = profile_page.form
profile_page = profile_form.submit()
self.app.set_cookie(settings.SESSION_COOKIE_NAME, session_id)
# assert that first result contains errors
self.assertContains(profile_page, "Enter your title")
self.assertContains(profile_page, "Enter your phone number")
profile_form = profile_page.form
profile_form["title"] = "sample title"
profile_form["phone"] = "(201) 555-1212"
profile_page = profile_form.submit()

View file

@ -14,6 +14,6 @@ from .domain import (
DomainInvitationDeleteView,
DomainDeleteUserView,
)
from .user_profile import UserProfileView
from .user_profile import UserProfileView, FinishProfileSetupView
from .health import *
from .index import *

View file

@ -2,13 +2,16 @@
"""
from enum import Enum
import logging
from urllib.parse import parse_qs, unquote
from urllib.parse import quote
from django.contrib import messages
from django.views.generic.edit import FormMixin
from registrar.forms.user_profile import UserProfileForm
from django.urls import reverse
from registrar.forms.user_profile import UserProfileForm, FinishSetupProfileForm
from django.urls import NoReverseMatch, reverse
from registrar.models import (
Contact,
)
@ -16,6 +19,8 @@ from registrar.models.utility.generic_helper import replace_url_queryparams
from registrar.views.utility.permission_views import UserProfilePermissionView
from waffle.decorators import flag_is_active, waffle_flag
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import csrf_protect
logger = logging.getLogger(__name__)
@ -59,6 +64,11 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
context = super().get_context_data(**kwargs)
# This is a django waffle flag which toggles features based off of the "flag" table
context["has_profile_feature_flag"] = flag_is_active(self.request, "profile_feature")
# The text for the back button on this page
context["profile_back_button_text"] = "Go to manage your domains"
context["show_back_button"] = True
return context
def get_success_url(self):
@ -99,3 +109,145 @@ class UserProfileView(UserProfilePermissionView, FormMixin):
if hasattr(user, "contact"): # Check if the user has a contact instance
return user.contact
return None
class FinishProfileSetupView(UserProfileView):
"""This view forces the user into providing additional details that
we may have missed from Login.gov"""
class RedirectType(Enum):
"""
Enums for each type of redirection. Enforces behaviour on `get_redirect_url()`.
- HOME: We want to redirect to reverse("home")
- BACK_TO_SELF: We want to redirect back to this page
- TO_SPECIFIC_PAGE: We want to redirect to the page specified in the queryparam "redirect"
- COMPLETE_SETUP: Indicates that we want to navigate BACK_TO_SELF, but the subsequent
redirect after the next POST should be either HOME or TO_SPECIFIC_PAGE
"""
HOME = "home"
TO_SPECIFIC_PAGE = "domain_request"
BACK_TO_SELF = "back_to_self"
COMPLETE_SETUP = "complete_setup"
@classmethod
def get_all_redirect_types(cls) -> list[str]:
"""Returns the value of every redirect type defined in this enum."""
return [r.value for r in cls]
template_name = "finish_profile_setup.html"
form_class = FinishSetupProfileForm
model = Contact
all_redirect_types = RedirectType.get_all_redirect_types()
redirect_type: RedirectType
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Hide the back button by default
context["show_back_button"] = False
if self.redirect_type == self.RedirectType.COMPLETE_SETUP:
context["confirm_changes"] = True
if "redirect_viewname" not in self.session:
context["show_back_button"] = True
else:
context["going_to_specific_page"] = True
context["redirect_button_text"] = "Continue to your request"
return context
@method_decorator(csrf_protect)
def dispatch(self, request, *args, **kwargs):
"""
Handles dispatching of the view, applying CSRF protection and checking the 'profile_feature' flag.
This method sets the redirect type based on the 'redirect' query parameter,
defaulting to BACK_TO_SELF if not provided.
It updates the session with the redirect view name if the redirect type is TO_SPECIFIC_PAGE.
Returns:
HttpResponse: The response generated by the parent class's dispatch method.
"""
# Update redirect type based on the query parameter if present
default_redirect_value = self.RedirectType.BACK_TO_SELF.value
redirect_value = request.GET.get("redirect", default_redirect_value)
if redirect_value in self.all_redirect_types:
# If the redirect value is a preexisting value in our enum, set it to that.
self.redirect_type = self.RedirectType(redirect_value)
else:
# If the redirect type is undefined, then we assume that we are specifying a particular page to redirect to.
self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE
# Store the page that we want to redirect to for later use
request.session["redirect_viewname"] = str(redirect_value)
return super().dispatch(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
"""Form submission posts to this view."""
self._refresh_session_and_object(request)
form = self.form_class(request.POST, instance=self.object)
# Get the current form and validate it
if form.is_valid():
if "contact_setup_save_button" in request.POST:
# Logic for when the 'Save' button is clicked
self.redirect_type = self.RedirectType.COMPLETE_SETUP
elif "contact_setup_submit_button" in request.POST:
specific_redirect = "redirect_viewname" in self.session
self.redirect_type = self.RedirectType.TO_SPECIFIC_PAGE if specific_redirect else self.RedirectType.HOME
return self.form_valid(form)
else:
return self.form_invalid(form)
def get_success_url(self):
"""Redirect to the nameservers page for the domain."""
return self.get_redirect_url()
def get_redirect_url(self):
"""
Returns a URL string based on the current value of self.redirect_type.
Depending on self.redirect_type, constructs a base URL and appends a
'redirect' query parameter. Handles different redirection types such as
HOME, BACK_TO_SELF, COMPLETE_SETUP, and TO_SPECIFIC_PAGE.
Returns:
str: The full URL with the appropriate query parameters.
"""
# These redirect types redirect to the same page
self_redirect = [self.RedirectType.BACK_TO_SELF, self.RedirectType.COMPLETE_SETUP]
# Maps the redirect type to a URL
base_url = ""
try:
if self.redirect_type in self_redirect:
base_url = reverse("finish-user-profile-setup")
elif self.redirect_type == self.RedirectType.TO_SPECIFIC_PAGE:
# We only allow this session value to use viewnames,
# because this restricts what can be redirected to.
desired_view = self.session["redirect_viewname"]
self.session.pop("redirect_viewname")
base_url = reverse(desired_view)
else:
base_url = reverse("home")
except NoReverseMatch as err:
logger.error(f"get_redirect_url -> Could not find the specified page. Err: {err}")
query_params = {}
# Quote cleans up the value so that it can be used in a url
if self.redirect_type and self.redirect_type.value:
query_params["redirect"] = quote(self.redirect_type.value)
# Generate the full url from the given query params
full_url = replace_url_queryparams(base_url, query_params)
return full_url

View file

@ -296,7 +296,6 @@ class UserDeleteDomainRolePermission(PermissionsLoginMixin):
domain_pk = self.kwargs["pk"]
user_pk = self.kwargs["user_pk"]
# Check if the user is authenticated
if not self.request.user.is_authenticated:
return False
@ -393,6 +392,8 @@ class UserProfilePermission(PermissionsLoginMixin):
If the user is authenticated, they have access
"""
# Check if the user is authenticated
if not self.request.user.is_authenticated:
return False