diff --git a/app/main/forms.py b/app/main/forms.py index 70dbfa37f..13a463a51 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -605,6 +605,15 @@ class RegisterUserForm(StripWhitespaceForm): auth_type = HiddenField("auth_type", default="sms_auth") +class SetupUserProfileForm(StripWhitespaceForm): + name = GovukTextInputField( + "Full name", validators=[DataRequired(message="Cannot be empty")] + ) + mobile_number = international_phone_number() + # TODO This should be replaced with a select widget when one is available. + preferred_timezone = HiddenField("preferred_timezone", default="US/Eastern") + + class RegisterUserFromInviteForm(RegisterUserForm): def __init__(self, invited_user): super().__init__( diff --git a/app/main/views/index.py b/app/main/views/index.py index e6107ab10..c68605b2e 100644 --- a/app/main/views/index.py +++ b/app/main/views/index.py @@ -1,3 +1,5 @@ +import os + from flask import abort, redirect, render_template, request, url_for from flask_login import current_user @@ -8,6 +10,8 @@ from app.main.views.pricing import CURRENT_SMS_RATE from app.main.views.sub_navigation_dictionaries import features_nav, using_notify_nav from app.utils.user import user_is_logged_in +login_dot_gov_url = os.getenv("LOGIN_DOT_GOV_INITIAL_SIGNIN_URL") + @main.route("/") def index(): @@ -18,6 +22,7 @@ def index(): "views/signedout.html", sms_rate=CURRENT_SMS_RATE, counts=status_api_client.get_count_of_live_services_and_organizations(), + login_dot_gov_url=login_dot_gov_url, ) diff --git a/app/main/views/register.py b/app/main/views/register.py index 8d3809ae9..7069270ea 100644 --- a/app/main/views/register.py +++ b/app/main/views/register.py @@ -1,14 +1,26 @@ +import uuid from datetime import datetime, timedelta -from flask import abort, redirect, render_template, session, url_for +from flask import ( + abort, + current_app, + redirect, + render_template, + request, + session, + url_for, +) from flask_login import current_user +from app import user_api_client from app.main import main from app.main.forms import ( RegisterUserForm, RegisterUserFromInviteForm, RegisterUserFromOrgInviteForm, + SetupUserProfileForm, ) +from app.main.views import sign_in from app.main.views.verify import activate_user from app.models.user import InvitedOrgUser, InvitedUser, User from app.utils import hide_from_search_engines @@ -120,4 +132,44 @@ def _do_registration(form, send_sms=True, send_email=True, organization_id=None) def registration_continue(): if not session.get("user_details"): return redirect(url_for(".show_accounts_or_dashboard")) - return render_template("views/registration-continue.html") + else: + raise Exception("Unexpected routing in registration_continue") + + +@main.route("/set-up-your-profile", methods=["GET", "POST"]) +@hide_from_search_engines +def set_up_your_profile(): + + form = SetupUserProfileForm() + + if form.validate_on_submit(): + # start login.gov + code = request.args.get("code") + state = request.args.get("state") + login_gov_error = request.args.get("error") + if code and state: + access_token = sign_in._get_access_token(code, state) + user_email, user_uuid = sign_in._get_user_email_and_uuid(access_token) + redirect_url = request.args.get("next") + + elif login_gov_error: + current_app.logger.error(f"login.gov error: {login_gov_error}") + raise Exception(f"Could not login with login.gov {login_gov_error}") + # end login.gov + + # create the user + # TODO we have to provide something for password until that column goes away + # TODO ideally we would set the user's preferred timezone here as well + user = User.register( + name=form.name.data, + email_address=user_email, + mobile_number=form.mobile_number.data, + password=str(uuid.uuid4()), + auth_type="sms_auth", + ) + + # activate the user + user = user_api_client.get_user_by_uuid_or_email(user_uuid, user_email) + activate_user(user["id"]) + return redirect(url_for("main.show_accounts_or_dashboard", next=redirect_url)) + return render_template("views/set-up-your-profile.html", form=form) diff --git a/app/main/views/sign_in.py b/app/main/views/sign_in.py index 5c5cf8a45..6bfe17b52 100644 --- a/app/main/views/sign_in.py +++ b/app/main/views/sign_in.py @@ -165,31 +165,8 @@ def sign_in(): ) other_device = current_user.logged_in_elsewhere() - notify_env = os.getenv("NOTIFY_ENVIRONMENT") - current_app.logger.info("should render the sign in template") - # TODO REMOVE THIS INFO ONCE STAGING WORKS WITH LOGIN DOT GOV - current_app.logger.info(f"NOTIFY ENV = {notify_env}") - current_app.logger.info( - f"LOGIN_DOT_GOV_CLIENT_ID={os.getenv('LOGIN_DOT_GOV_CLIENT_ID')}" - ) - current_app.logger.info( - f"LOGIN_DOT_GOV_USER_INFO_URL={os.getenv('LOGIN_DOT_GOV_USER_INFO_URL')}" - ) - current_app.logger.info( - f"LOGIN_DOT_GOV_ACCESS_TOKEN_URL={os.getenv('LOGIN_DOT_GOV_ACCESS_TOKEN_URL')}" - ) - current_app.logger.info( - f"LOGIN_DOT_GOV_LOGOUT_URL={os.getenv('LOGIN_DOT_GOV_LOGOUT_URL')}" - ) - current_app.logger.info( - f"LOGIN_DOT_GOV_BASE_LOGOUT_URL={os.getenv('LOGIN_DOT_GOV_BASE_LOGOUT_URL')}" - ) - current_app.logger.info( - f"LOGIN_DOT_GOV_SIGNOUT_REDIRECT={os.getenv('LOGIN_DOT_GOV_SIGNOUT_REDIRECT')}" - ) initial_signin_url = os.getenv("LOGIN_DOT_GOV_INITIAL_SIGNIN_URL") - current_app.logger.info(f"LOGIN_DOT_GOV_INITIAL_SIGNIN_URL={initial_signin_url}") return render_template( "views/signin.html", diff --git a/app/main/views/sign_out.py b/app/main/views/sign_out.py index 6d03665da..5ec96e189 100644 --- a/app/main/views/sign_out.py +++ b/app/main/views/sign_out.py @@ -6,10 +6,6 @@ from flask_login import current_user from app.main import main -# ask login.gov if we really need manual logout and what's up with one hour sessions -# ask login.gov how they recommend approaching dev environment -# ask Tim Donaworth the same for #2 - def _sign_out_at_login_dot_gov(): base_url = os.getenv("LOGIN_DOT_GOV_BASE_LOGOUT_URL") diff --git a/app/main/views/verify.py b/app/main/views/verify.py index 0201edfcc..f6bb47ab7 100644 --- a/app/main/views/verify.py +++ b/app/main/views/verify.py @@ -5,6 +5,7 @@ from itsdangerous import SignatureExpired from notifications_utils.url_safe_token import check_token from app import user_api_client +from app.extensions import redis_client from app.main import main from app.main.forms import TwoFactorForm from app.models.user import InvitedOrgUser, InvitedUser, User @@ -64,20 +65,44 @@ def verify_email(token): def activate_user(user_id): user = User.from_id(user_id) - # the user will have a new current_session_id set by the API - store it in the cookie for future requests + + # This is the login.gov path + login_gov_invite_data = redis_client.get(f"service-invite-{user.email_address}") + if login_gov_invite_data: + login_gov_invite_data = json.loads(login_gov_invite_data.decode("utf8")) + + # This is the deprecated path for organization invites where we get id from session session["current_session_id"] = user.current_session_id organization_id = session.get("organization_id") + activated_user = user.activate() activated_user.login() + # TODO when login.gov is mandatory, get rid of the if clause, it is deprecated. invited_user = InvitedUser.from_session() if invited_user: service_id = _add_invited_user_to_service(invited_user) return redirect(url_for("main.service_dashboard", service_id=service_id)) + elif login_gov_invite_data: + service_id = login_gov_invite_data["service_id"] + user.add_to_service( + service_id, + login_gov_invite_data["permissions"], + login_gov_invite_data["folder_permissions"], + login_gov_invite_data["from_user_id"], + ) + return redirect(url_for("main.service_dashboard", service_id=service_id)) + + # TODO when login.gov is mandatory, git rid of the if clause, it is deprecated. invited_org_user = InvitedOrgUser.from_session() if invited_org_user: user_api_client.add_user_to_organization(invited_org_user.organization, user_id) + elif redis_client.get(f"organization-invite-{user.email_address}"): + organization_id = redis_client.get(f"organization-invite-{user.email_address}") + user_api_client.add_user_to_organization( + organization_id.decode("utf8"), user_id + ) if organization_id: return redirect(url_for("main.organization_dashboard", org_id=organization_id)) diff --git a/app/templates/components/components/select/README.md b/app/templates/components/components/select/README.md new file mode 100644 index 000000000..c656980d6 --- /dev/null +++ b/app/templates/components/components/select/README.md @@ -0,0 +1,13 @@ +# Select + +## Installation + +See the [main README quick start guide](https://github.com/alphagov/govuk-frontend#quick-start) for how to install this component. + +## Guidance and Examples + +Find out when to use the select component in your service in the [GOV.UK Design System](https://design-system.service.gov.uk/components/select/). + +## Component options + +Use options to customize the appearance, content and behavior of a component when using a macro, for example, changing the text. diff --git a/app/templates/components/components/select/macro-options.json b/app/templates/components/components/select/macro-options.json new file mode 100644 index 000000000..c786613f7 --- /dev/null +++ b/app/templates/components/components/select/macro-options.json @@ -0,0 +1,58 @@ +[ + { + "name": "id", + "type": "string", + "required": true, + "description": "ID for each select box." + }, + { + "name": "name", + "type": "string", + "required": true, + "description": "Name property for the select." + }, + { + "name": "items", + "type": "array", + "required": true, + "description": "The items within the select component." + }, + { + "name": "value", + "type": "string", + "required": false, + "description": "Value for the option which should be selected. Use this as an alternative to setting the selected option on each individual item." + }, + { + "name": "disabled", + "type": "boolean", + "required": false, + "description": "If true, select box will be disabled. Use the disabled option on each individual item to only disable certain options." + }, + { + "name": "describedBy", + "type": "string", + "required": false, + "description": "One or more element IDs to add to the `aria-describedby` attribute, used to provide additional descriptive information for screenreader users." + }, + { + "name": "label", + "type": "object", + "required": true, + "description": "The label used by the select component.", + "isComponent": true + }, + { + "name": "hint", + "type": "object", + "required": false, + "description": "Can be used to add a hint to the select component.", + "isComponent": true + }, + { + "name": "classes", + "type": "string", + "required": false, + "description": "Classes to add to the select." + } +] diff --git a/app/templates/components/components/select/macro.njk b/app/templates/components/components/select/macro.njk new file mode 100644 index 000000000..9c1333ca8 --- /dev/null +++ b/app/templates/components/components/select/macro.njk @@ -0,0 +1,3 @@ +{% macro usaSelect(params) %} + {%- include "./template.njk" -%} +{% endmacro %} diff --git a/app/templates/components/components/select/template.njk b/app/templates/components/components/select/template.njk new file mode 100644 index 000000000..9468ea4ee --- /dev/null +++ b/app/templates/components/components/select/template.njk @@ -0,0 +1,15 @@ +{% set describedBy = params.describedBy if params.describedBy else "" %} +
+ + {% if params.hint %} +
{{ params.hint }}
+ {% endif %} + +
diff --git a/app/templates/views/set-up-your-profile.html b/app/templates/views/set-up-your-profile.html new file mode 100644 index 000000000..8f37d3617 --- /dev/null +++ b/app/templates/views/set-up-your-profile.html @@ -0,0 +1,77 @@ +{% extends "withoutnav_template.html" %} +{% from "components/page-footer.html" import page_footer %} +{% from "components/form.html" import form_wrapper %} +{% from "components/components/select/macro.njk" import usaSelect -%} + +{% block per_page_title %} +Set up your profile +{% endblock %} + +{% block maincolumn_content %} + +
+
+

Set up your profile

+ {% call form_wrapper(autocomplete=True) %} + {{ form.name(param_extensions={}) }} +
+ {{ form.mobile_number(param_extensions={ + "hint": {"text": "We'll send you a security code by text message"}, + }) }} +
+ + {{form.auth_type}} + {{ page_footer("Save") }} + {% endcall %} +
+
+ +{% endblock %} diff --git a/app/templates/views/signedout.html b/app/templates/views/signedout.html index 9c7699329..3289dd121 100644 --- a/app/templates/views/signedout.html +++ b/app/templates/views/signedout.html @@ -21,7 +21,7 @@ Notify.gov

Reach people where they are with government-powered text messages

Notify.gov is a text message service that helps federal, state, local, tribal and territorial governments more effectively communicate with the people they serve.

- Sign in + Sign in if you are an existing pilot partner

Currently we are only working with select pilot partners. If you are interested in using Notify.gov in the future, please contact
tts-benefits-studio@gsa.gov to learn more.

diff --git a/tests/app/main/views/test_register.py b/tests/app/main/views/test_register.py index e7bfc9327..d5fa744c6 100644 --- a/tests/app/main/views/test_register.py +++ b/tests/app/main/views/test_register.py @@ -30,6 +30,7 @@ def test_logged_in_user_redirects_to_account( ) +@pytest.mark.skip("Deprecated due to change to login-dot-gov-only registration") @pytest.mark.parametrize( "phone_number_to_register_with", [ @@ -75,14 +76,14 @@ def test_register_creates_new_user_and_redirects_to_continue_page( == "An email has been sent to notfound@example.gsa.gov." ) - mock_send_verify_email.assert_called_with(ANY, user_data["email_address"]) - mock_register_user.assert_called_with( - user_data["name"], - user_data["email_address"], - user_data["mobile_number"], - user_data["password"], - user_data["auth_type"], - ) + # mock_send_verify_email.assert_called_with(ANY, user_data["email_address"]) + # mock_register_user.assert_called_with( + # user_data["name"], + # user_data["email_address"], + # user_data["mobile_number"], + # user_data["password"], + # user_data["auth_type"], + # ) def test_register_continue_handles_missing_session_sensibly( diff --git a/tests/app/test_navigation.py b/tests/app/test_navigation.py index 5ee810f33..0732ab7e0 100644 --- a/tests/app/test_navigation.py +++ b/tests/app/test_navigation.py @@ -166,6 +166,7 @@ EXCLUDED_ENDPOINTS = tuple( "send_one_off", "send_one_off_step", "send_one_off_to_myself", + "set_up_your_profile", "service_add_email_reply_to", "service_add_sms_sender", "service_confirm_delete_email_reply_to",