Skip to content

Commit

Permalink
Merge pull request #338 from betagouv/resend-email
Browse files Browse the repository at this point in the history
Renvoi d'un e-mail en cas de non réception
  • Loading branch information
ddahan authored Mar 28, 2024
2 parents 9e215d7 + 8c5c702 commit 6f29633
Show file tree
Hide file tree
Showing 10 changed files with 130 additions and 18 deletions.
5 changes: 5 additions & 0 deletions api/exception_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ def __init__(
non_field_errors: list[str] | None = None,
field_errors: dict[str, list[str]] | None = None,
log_level: int | None = logging.INFO,
extra: dict = None,
**kwargs
):
super().__init__(**kwargs)
Expand All @@ -60,6 +61,10 @@ def __init__(
if not any([self.global_error, self.non_field_errors, self.field_errors]):
raise ValueError("An exception must contain at least one error type.")

# Inject addtional data of your choice into the response
if extra:
self.extra = extra

# Create an optional log using provided log level
if log_level:
logger.log(level=log_level, msg=self.__class__.__name__, extra=self.__dict__)
Expand Down
5 changes: 5 additions & 0 deletions api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
path("signup/", views.SignupView.as_view(), name="signup"),
path("generate-username/", views.GenerateUsernameView.as_view(), name="generate_username"),
path("verify-email/", views.VerifyEmailView.as_view(), name="verify_email"),
path(
"send-new-signup-verification-email/<int:user_id>",
views.SendNewSignupVerificationEmailView.as_view(),
name="send_new_signup_verification_email",
),
}

urlpatterns = format_suffix_patterns(urlpatterns)
8 changes: 7 additions & 1 deletion api/views/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
from .blog import BlogPostsView, BlogPostView # noqa: F401
from .newsletter import SubscribeNewsletter # noqa: F401
from .report_issue import ReportIssue # noqa: F401
from .user import LoggedUserView, SignupView, GenerateUsernameView, VerifyEmailView # noqa: F401
from .user import ( # noqa: F401
LoggedUserView,
SignupView,
GenerateUsernameView,
VerifyEmailView,
SendNewSignupVerificationEmailView,
)
from .webinar import WebinarView # noqa
from .search import SearchView # noqa: F401
from .plant import PlantRetrieveView, PlantPartListView # noqa: F401
Expand Down
3 changes: 2 additions & 1 deletion api/views/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ def post(self, request, *args, **kwargs):
raise ProjectAPIException(
non_field_errors=[
"Votre compte n'est pas encore vérifié. Veuillez vérifier vos e-mails reçus, et vos courriers indésirables."
]
],
extra={"user_id": user.id},
)
login(request, user) # will create the user session
return Response({"csrf_token": get_token(request)})
Expand Down
30 changes: 21 additions & 9 deletions api/views/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from django.contrib.auth import login
from django.middleware.csrf import get_token
from django.core.mail import send_mail
from django.shortcuts import get_object_or_404
from rest_framework.views import APIView
from rest_framework.generics import RetrieveAPIView
from rest_framework.response import Response
Expand Down Expand Up @@ -31,20 +32,31 @@ def get_object(self):
return self.request.user


def _send_verification_mail(request, user):
new_token = MagicLinkToken.objects.create(user=user, usage=MagicLinkUsage.VERIFY_EMAIL_ADDRESS)
verification_url = urljoin(get_base_url(request), new_token.as_url(key=new_token.key))
send_mail(
subject="Vérifiez votre adresse e-mail",
message=f"Cliquez sur le lien suivant pour vérifier votre adresse e-mail : {verification_url}",
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
)


class SignupView(APIView):
def post(self, request, *args, **kwargs):
serializer = UserInputSerializer(data=request.data)
if serializer.is_valid(raise_exception=True):
new_user = serializer.save()
new_token = MagicLinkToken.objects.create(user=new_user, usage=MagicLinkUsage.VERIFY_EMAIL_ADDRESS)
verification_url = urljoin(get_base_url(request), new_token.as_url(key=new_token.key))
send_mail(
subject="Vérifiez votre adresse e-mail",
message=f"Cliquez sur le lien suivant pour vérifier votre adresse e-mail : {verification_url}",
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[new_user.email],
)
return Response({}, status=status.HTTP_201_CREATED)
_send_verification_mail(request, new_user)
return Response({"user_id": new_user.id}, status=status.HTTP_201_CREATED)


class SendNewSignupVerificationEmailView(APIView):
def get(self, request, user_id, *args, **kwargs):
user = get_object_or_404(User, id=user_id)
_send_verification_mail(request, user)
return Response(None, status=status.HTTP_204_NO_CONTENT)


class GenerateUsernameView(APIView):
Expand Down
64 changes: 64 additions & 0 deletions frontend/src/components/SendNewSignupVerificationEmail.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<template>
<div>
<DsfrButton
secondary
icon="ri-mail-forbid-line"
size="sm"
label="Je n'ai pas reçu d'email"
@click="opened = true"
ref="modalOrigin"
/>
<DsfrModal
:actions="actions"
ref="modal"
:opened="opened"
@close="close"
title="E-mail de vérification non reçu ?"
icon="ri-mail-forbid-line"
>
<p>
Si vous n'avez pas reçu d'email au bout de quelques minutes, veuillez vérifier l'adresse e-mail entrée, ainsi
que vos courriers indésirables. Sinon, cliquez sur le bouton ci-dessous pour recevoir un nouvel e-mail.
</p>
</DsfrModal>
</div>
</template>

<script setup>
import { computed, ref } from "vue"
import { useFetch } from "@vueuse/core"
import { handleError } from "@/utils/error-handling"
import useToaster from "@/composables/use-toaster"
const props = defineProps({ userId: Number })
const opened = ref(false)
const close = () => (opened.value = false)
// Main request definition
const url = computed(() => `/api/v1/send-new-signup-verification-email/${props.userId}`)
const { response, execute } = useFetch(url, {
immediate: false,
}).json()
const resendEmail = async () => {
await execute()
await handleError(response)
if (response.value.ok) {
useToaster().addSuccessMessage("L'email de vérification a été renvoyé.")
}
close()
}
const actions = [
{
label: "Renvoyer un nouvel e-mail",
onClick: resendEmail,
},
{
label: "Annuler",
onClick: close,
secondary: true,
},
]
</script>
9 changes: 9 additions & 0 deletions frontend/src/icons.js
Original file line number Diff line number Diff line change
Expand Up @@ -191,3 +191,12 @@ export const RiEyeOffLine = {
height: 24,
raw: '<path fill="none" d="M0 0h24v24H0z"/><path d="M17.882 19.297A10.949 10.949 0 0112 21c-5.392 0-9.878-3.88-10.819-9a10.982 10.982 0 013.34-6.066L1.392 2.808l1.415-1.415 19.799 19.8-1.415 1.414-3.31-3.31zM5.935 7.35A8.965 8.965 0 003.223 12a9.005 9.005 0 0013.201 5.838l-2.028-2.028A4.5 4.5 0 018.19 9.604L5.935 7.35zm6.979 6.978l-3.242-3.242a2.5 2.5 0 003.241 3.241zm7.893 2.264l-1.431-1.43A8.935 8.935 0 0020.777 12 9.005 9.005 0 009.552 5.338L7.974 3.76C9.221 3.27 10.58 3 12 3c5.392 0 9.878 3.88 10.819 9a10.947 10.947 0 01-2.012 4.592zm-9.084-9.084a4.5 4.5 0 014.769 4.769l-4.77-4.769z"/>',
}

export const RiMailForbidLine = {
name: "ri-mail-forbid-line",
minX: 0,
minY: 0,
width: 24,
height: 24,
raw: '<path fill="none" d="M0 0h24v24H0z"/><path d="M20 7.238l-7.928 7.1L4 7.216V19h7.07a6.95 6.95 0 00.604 2H3a1 1 0 01-1-1V4a1 1 0 011-1h18a1 1 0 011 1v8.255a6.972 6.972 0 00-2-.965V7.238zM19.501 5H4.511l7.55 6.662L19.502 5zm-2.794 15.708a3 3 0 004.001-4.001l-4.001 4zm-1.415-1.415l4.001-4a3 3 0 00-4.001 4.001zM18 23a5 5 0 110-10 5 5 0 010 10z"/>',
}
4 changes: 2 additions & 2 deletions frontend/src/utils/error-handling.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@ export const handleError = async (response) => {
// show an error toast
addErrorMessage(backErrorData.globalError)
}
// Return other errors to be handled by Vuelidate directly
// Return other errors to be handled by Vuelidate directly (and "extra" parameters to get additional data)
// If you don't have a form and expect global errors only, just ignore the result of this function when called.
return { nonFieldErrors: backErrorData.nonFieldErrors, ...backErrorData.fieldErrors }
return { nonFieldErrors: backErrorData.nonFieldErrors, ...backErrorData.fieldErrors, extra: backErrorData.extra }

// TODO LATER: auto logout (in case of 401) could be handled here
// TODO LATER: timeout could be handled here too
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/views/LoginPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<SingleItemWrapper>
<h1>Se connecter</h1>
<FormWrapper :externalResults="$externalResults">
<SendNewSignupVerificationEmail v-if="showSendNewConfirmationMail" :userId="userIdForNewConfirmationMail" />
<DsfrInputGroup :error-message="firstErrorMsg(v$, 'username')">
<DsfrInput v-model="state.username" label="Identifiant" labelVisible autofocus />
</DsfrInputGroup>
Expand Down Expand Up @@ -45,6 +46,7 @@ import { useRouter } from "vue-router"
import { useRootStore } from "@/stores/root"
import FormWrapper from "@/components/FormWrapper"
import SingleItemWrapper from "@/components/SingleItemWrapper"
import SendNewSignupVerificationEmail from "@/components/SendNewSignupVerificationEmail"
const router = useRouter()
const rootStore = useRootStore()
Expand Down Expand Up @@ -76,6 +78,9 @@ const { data, response, execute, isFetching } = useFetch(
.post(state)
.json()
const showSendNewConfirmationMail = ref(false)
const userIdForNewConfirmationMail = ref()
// Form validation
const submit = async () => {
v$.value.$validate()
Expand All @@ -85,6 +90,13 @@ const submit = async () => {
await execute()
$externalResults.value = await handleError(response)
// Give the ability to ask for a new e-email, only if the user is not verified yet.
// ⛔️ TODO: change this dirty hack: we use error message until having appropriate error codes in responses
if ($externalResults.value.nonFieldErrors[0].includes("vérifié")) {
showSendNewConfirmationMail.value = true
userIdForNewConfirmationMail.value = $externalResults.value.extra.userId
}
if (response.value.ok) {
{
await rootStore.fetchInitialData()
Expand Down
8 changes: 3 additions & 5 deletions frontend/src/views/SignupPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,7 @@
Veuillez cliquez dans le lien à l'intérieur pour vérifier votre adresse e-email et pouvoir utiliser votre
compte.
</p>
<p>
Si vous n'avez pas reçu l'email au bout de quelques minutes, veuillez vérifier l'adresse e-mail entrée, ainsi
que vos courriers indésirables.
</p>
<SendNewSignupVerificationEmail :userId="data?.userId" />
</DsfrCallout>
</SingleItemWrapper>
</template>
Expand All @@ -103,6 +100,7 @@
import { computed, ref } from "vue"
import { useVuelidate } from "@vuelidate/core"
import SingleItemWrapper from "@/components/SingleItemWrapper"
import SendNewSignupVerificationEmail from "@/components/SendNewSignupVerificationEmail"
import FormWrapper from "@/components/FormWrapper"
import { errorRequiredField, errorRequiredEmail, firstErrorMsg } from "@/utils/forms"
import { useFetch } from "@vueuse/core"
Expand Down Expand Up @@ -136,7 +134,7 @@ const $externalResults = ref({})
const v$ = useVuelidate(rules, state, { $externalResults })
// Main request definition
const { response, execute, isFetching } = useFetch(
const { data, response, execute, isFetching } = useFetch(
"/api/v1/signup/",
{
headers: headers(),
Expand Down

0 comments on commit 6f29633

Please sign in to comment.