Skip to content

Commit

Permalink
Redesign JobSeeker login process
Browse files Browse the repository at this point in the history
A new process in which the user submits their email address and is redirected to the correct login method instead of being depended on to choose which service they use to connect. Includes also a graphical redesign, and some redirection options to the registration process
  • Loading branch information
calummackervoy committed Dec 18, 2024
1 parent 44842fe commit 06b789a
Show file tree
Hide file tree
Showing 13 changed files with 210 additions and 49 deletions.
10 changes: 9 additions & 1 deletion itou/static/img/login.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
26 changes: 25 additions & 1 deletion itou/static/img/peamu_btn.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 34 additions & 21 deletions itou/templates/account/login_job_seeker.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
{# django-allauth template override. #}
{% extends "layout/base.html" %}
{% load static %}
{% load django_bootstrap5 %}
{% load redirection_fields %}
{% load buttons_form %}

{% block title %}Connexion candidat {{ block.super }}{% endblock %}

Expand All @@ -12,31 +15,41 @@
<div class="s-section__row row">
<div class="s-section__col col-12 col-lg-6">
<div class="c-form mb-5">
<p class="h4">Se connecter avec FranceConnect</p>
{% if show_france_connect %}
<div class="mt-4">{% include "signup/includes/france_connect_button.html" %}</div>
{% else %}
<div class="alert alert-info" role="status">
<p class="mb-0">FranceConnect est désactivé.</p>
</div>
{% endif %}
<hr class="my-5" data-it-text="ou">
<p class="h4 text-primary">Quelle adresse mail utilisez-vous ?</p>
<p class="text-secondary fs-sm">
Si vous avec un compte <strong>France Travail</strong> ou <strong>FranceConnect</strong> et que vous souhaitez vous connecter avec ce compte,
merci de renseigner dans ce champs l’adresse e-mail qui y est associé.
</p>

<form method="post" class="js-prevent-multiple-submit">
{% csrf_token %}
{% redirection_input_field value=redirect_field_value %}

<p class="h4">Se connecter avec France Travail</p>
{% if show_peamu %}
<div class="row mt-3">
<div class="col-sm">
<div class="text-center">{% include "signup/includes/peamu_button.html" %}</div>
<div class="form-group mb-1 form-group-required text-primary">
{% bootstrap_label "Adresse e-mail" label_for="id_email" %}
{% bootstrap_field form.email wrapper_class="form-group mb-0" show_label=False %}
<div class="form-text mb-3">
<div class="text-end mt-1">
<a href="#" class="fs-sm text-secondary" data-bs-toggle="modal" data-bs-target="#no-email-modal">Pas d'adresse e-mail ?</a>
</div>
{% include "signup/includes/no_email_link.html" with exclude_button=True only %}
</div>
</div>
{% else %}
<div class="alert alert-info" role="status">
<p class="mb-0">France Travail est désactivé.</p>
</div>
{% endif %}
<hr class="my-5" data-it-text="ou">
{% url 'home:hp' as reset_url %}
{% itou_buttons_form primary_label="Suivant" reset_url=reset_url %}

{% include "account/includes/login_form.html" %}
<div class="mt-5 mb-5 text-end text-primary">
<p>
Vous n'avez pas de compte ? <a href="{% url 'signup:job_seeker_situation' %}">Inscription</a>
</p>
</div>
</form>
</div>
</div>
<!-- Hide left column on small devices. -->
<div class="d-none d-lg-inline-flex align-items-center col-lg-6 justify-content-center">
<div class="w-75">
<img class="img-fluid img-fitcover" src="{% static 'img/login.svg' %}" alt="">
</div>
</div>
</div>
Expand Down
19 changes: 19 additions & 0 deletions itou/templates/utils/modal_includes/login_failure.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{% load static %}
{% load theme_inclusion %}
{% load django_bootstrap5 %}

<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="message-modal-{{ forloop.counter }}-label">
{% if "email_does_not_exist" in message.extra_tags %}
Adresse e-mail inconnue
{% else %}
Le connexion a échoué
{% endif %}
</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
</div>
{{ message }}
</div>
</div>
2 changes: 2 additions & 0 deletions itou/templates/utils/modals.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
{% include "utils/modal_includes/sso_email_conflict_registration_failure.html" %}
{% elif "registration_failure" in message.extra_tags %}
{% include "utils/modal_includes/registration_failure.html" %}
{% elif "login_failure" in message.extra_tags %}
{% include "utils/modal_includes/login_failure.html" %}
{% endif %}
</div>

Expand Down
44 changes: 44 additions & 0 deletions itou/www/login/forms.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,57 @@
from allauth.account.forms import LoginForm
from django import forms
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.utils.html import format_html
from django.utils.safestring import mark_safe

from itou.openid_connect.errors import format_error_modal_content
from itou.users.enums import IdentityProvider
from itou.users.models import User


class FindExistingUserViaEmailForm(forms.Form):
"""
Validates only the email field. Displays a modal to user if email not in use
"""

email = forms.EmailField(
label="Adresse e-mail",
required=True,
widget=forms.TextInput(
attrs={"type": "email", "placeholder": "[email protected]", "autocomplete": "email", "autofocus": True}
),
)

def __init__(self, *args, **kwargs):
self.request = kwargs.pop("request", None)
super().__init__(*args, **kwargs)

def clean_email(self):
email = self.cleaned_data.get("email")
self.user = User.objects.filter(email__iexact=email).first()
if self.user is None:
messages.error(
self.request,
format_error_modal_content(
mark_safe(
"<p>Cette adresse e-mail est inconnue de nos services.</p>"
"<p>Si vous êtes déjà inscrit(e), "
"assurez-vous de saisir correctement votre adresse e-mail.</p>"
"<p>Si vous n'êtes pas encore inscrit(e), "
"nous vous invitons à cliquer sur Inscription pour créer votre compte.</p>"
),
reverse("signup:job_seeker_situation"),
"Inscription",
),
extra_tags="modal login_failure email_does_not_exist",
)
raise ValidationError("Cette adresse e-mail est inconnue. Veuillez soumettre une autre, ou vous inscrire.")
return email


class ItouLoginForm(LoginForm):
# Hidden field allowing demo prescribers and employers to log in using the banner form
demo_banner_account = forms.BooleanField(widget=forms.HiddenInput(), required=False)
Expand Down
2 changes: 1 addition & 1 deletion itou/www/login/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@
path("prescriber", views.PrescriberLoginView.as_view(), name="prescriber"),
path("employer", views.EmployerLoginView.as_view(), name="employer"),
path("labor_inspector", views.LaborInspectorLoginView.as_view(), name="labor_inspector"),
path("job_seeker", views.JobSeekerLoginView.as_view(), name="job_seeker"),
path("job_seeker", views.JobSeekerPreLoginView.as_view(), name="job_seeker"),
path("existing/<uuid:user_public_id>", views.ExistingUserLoginView.as_view(), name="existing_user"),
]
48 changes: 34 additions & 14 deletions itou/www/login/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,20 @@
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.views.generic.edit import FormView

from itou.openid_connect.inclusion_connect.enums import InclusionConnectChannel
from itou.users.enums import MATOMO_ACCOUNT_TYPE, IdentityProvider, UserKind
from itou.users.models import User
from itou.utils.urls import add_url_params, get_safe_url, get_url_param_value
from itou.www.login.forms import ItouLoginForm
from itou.www.login.forms import FindExistingUserViaEmailForm, ItouLoginForm


class ItouLoginView(LoginView):
class UserKindLoginMixin:
"""
Generic authentication entry point.
This view is used only in one case:
when a user confirms its email after updating it.
Allauth magic is complicated to debug.
Mixin class which adds functionality relating to the different IdentityProviders,
configured to be used according to UserKind (certain identity providers accessible only to certain user kinds).
django-allauth provides the login behaviour, extended by views for each UserKind.
"""

form_class = ItouLoginForm
Expand Down Expand Up @@ -90,6 +90,12 @@ def dispatch(self, request, *args, **kwargs):
return super().dispatch(request, *args, **kwargs)


class ItouLoginView(UserKindLoginMixin, LoginView):
"""Generic authentication entry point."""

pass


class PrescriberLoginView(ItouLoginView):
template_name = "account/login_generic.html"
user_kind = UserKind.PRESCRIBER
Expand Down Expand Up @@ -138,20 +144,34 @@ def get_context_data(self, **kwargs):
return context | extra_context


class JobSeekerLoginView(ItouLoginView):
class JobSeekerPreLoginView(UserKindLoginMixin, FormView):
"""
JobSeeker's do not log in directly.
Instead they enter their email and they are redirected to the login method configured on their account.
"""

template_name = "account/login_job_seeker.html"
user_kind = UserKind.JOB_SEEKER
form_class = FindExistingUserViaEmailForm

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
extra_context = {
"show_france_connect": bool(settings.FRANCE_CONNECT_BASE_URL),
"show_peamu": bool(settings.PEAMU_AUTH_BASE_URL),
}
return context | extra_context
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["request"] = self.request
return kwargs

def form_valid(self, form):
self.user = form.user
return super().form_valid(form)

def get_success_url(self):
return f'{reverse("login:existing_user", args=(self.user.public_id,))}?back_url={reverse("login:job_seeker")}'


class ExistingUserLoginView(ItouLoginView):
"""
Allows a user to login with the provider configured on their account.
"""

template_name = "account/login_existing_user.html"

def setup(self, request, *args, **kwargs):
Expand Down
1 change: 0 additions & 1 deletion itou/www/signup/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,6 @@ def __init__(self, prior_cleaned_data, *args, **kwargs):
self.fields["birthdate"].initial = prior_cleaned_data.get("birthdate")
self.fields["nir"].initial = prior_cleaned_data.get("nir")

# self.fields["password1"].help_text = CnilCompositionPasswordValidator().get_help_text()
for password_field in [self.fields["password1"], self.fields["password2"]]:
password_field.widget.attrs["placeholder"] = "**********"
self.fields["password1"].help_text = CnilCompositionPasswordValidator().get_help_text()
Expand Down
3 changes: 1 addition & 2 deletions tests/www/dashboard/test_edit_user_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,13 @@ def test_update_email(self, client, mailoutbox):

# User cannot log in with his old address
post_data = {"login": old_email, "password": DEFAULT_PASSWORD}
url = reverse("login:job_seeker")
url = reverse("login:existing_user", args=(user.public_id,))
response = client.post(url, data=post_data)
assert response.status_code == 200
assert not response.context_data["form"].is_valid()

# User cannot log in until confirmation
post_data = {"login": new_email, "password": DEFAULT_PASSWORD}
url = reverse("login:job_seeker")
response = client.post(url, data=post_data)
assert response.status_code == 302
assert response.url == reverse("account_email_verification_sent")
Expand Down
3 changes: 3 additions & 0 deletions tests/www/login/__snapshots__/tests.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -230,3 +230,6 @@
</div>
'''
# ---
# name: TestJobSeekerPreLogin.test_pre_login_email_unknown
'<div class="modal-body"><p>Cette adresse e-mail est inconnue de nos services.</p><p>Si vous êtes déjà inscrit(e), assurez-vous de saisir correctement votre adresse e-mail.</p><p>Si vous n\'êtes pas encore inscrit(e), nous vous invitons à cliquer sur Inscription pour créer votre compte.</p></div><div class="modal-footer"><button type="button" class="btn btn-sm btn-link" data-bs-dismiss="modal">Retour</button><a href="/signup/job_seeker/situation" class="btn btn-sm btn-primary">Inscription</a></div>'
# ---
44 changes: 37 additions & 7 deletions tests/www/login/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,19 +208,36 @@ def test_login(self, client):
assertRedirects(response, reverse("account_email_verification_sent"))


class TestJopbSeekerLogin:
def test_login(self, client):
class TestJobSeekerPreLogin:
def test_pre_login_email_invalid(self, client):
form_data = {"email": "emailinvalid"}
response = client.post(reverse("login:job_seeker"), data=form_data)
assert response.status_code == 200
assert response.context["form"].errors["email"] == ["Saisissez une adresse e-mail valide."]

def test_pre_login_redirects_to_existing_user(self, client):
user = JobSeekerFactory()
url = reverse("login:job_seeker")
response = client.get(url)
assert response.status_code == 200

form_data = {
"login": user.email,
"password": DEFAULT_PASSWORD,
}
form_data = {"email": user.email}
response = client.post(url, data=form_data)
assertRedirects(response, reverse("account_email_verification_sent"))
assertRedirects(response, f'{reverse("login:existing_user", args=(user.public_id,))}?back_url={url}')

def test_pre_login_email_unknown(self, client, snapshot):
url = reverse("login:job_seeker")
response = client.get(url)

form_data = {"email": "[email protected]"}
response = client.post(url, data=form_data)
assert response.status_code == 200

assert response.context["form"].errors["email"] == [
"Cette adresse e-mail est inconnue. Veuillez soumettre une autre, ou vous inscrire."
]
assertMessages(response, [messages.Message(messages.ERROR, snapshot)])
assertContains(response, reverse("signup:job_seeker_situation"))

@respx.mock
@override_settings(
Expand Down Expand Up @@ -290,6 +307,19 @@ def test_login(self, client, snapshot, identity_provider):
assertNotContains(response, self.UNSUPPORTED_IDENTITY_PROVIDER_TEXT)
assert str(parse_response_to_soup(response, selector=".c-form")) == snapshot

def test_login_django(self, client):
user = JobSeekerFactory(identity_provider=IdentityProvider.DJANGO)
url = reverse("login:existing_user", args=(user.public_id,))
response = client.get(url)
assert response.status_code == 200

form_data = {
"login": user.email,
"password": DEFAULT_PASSWORD,
}
response = client.post(url, data=form_data)
assertRedirects(response, reverse("account_email_verification_sent"))

@pytest.mark.parametrize(
"identity_provider",
[
Expand Down
2 changes: 1 addition & 1 deletion tests/www/signup/test_job_seeker.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def test_job_seeker_signup(self, client, snapshot, mailoutbox):

# User cannot log in until confirmation.
post_data = {"login": user.email, "password": DEFAULT_PASSWORD}
url = reverse("login:job_seeker")
url = reverse("login:existing_user", args=(user.public_id,))
response = client.post(url, data=post_data)
assert response.status_code == 302
assert response.url == reverse("account_email_verification_sent")
Expand Down

0 comments on commit 06b789a

Please sign in to comment.