From 7d08c00ac43eabdc44d09ee7067c5ca8d93474c3 Mon Sep 17 00:00:00 2001
From: Charles Dufour
Date: Tue, 23 Jan 2024 09:55:43 +0100
Subject: [PATCH] [TRELLO-2160] Implement routes to update the email address
(#1527)
* [TRELLO-2160] Implement routes to update the email address
* [TRELLO-2160] Implement update my email address
* [TRELLO-2160] Implement update my email address
* [TRELLO-2160] Handle token correctly
---
app/config/SignalConsoConfiguration.scala | 3 +-
app/controllers/AccountController.scala | 17 ++++
app/controllers/error/AppError.scala | 14 +++
app/models/AccessToken.scala | 9 +-
app/models/token/TokenKind.scala | 1 +
app/orchestrators/AccessesOrchestrator.scala | 86 +++++++++++++++++++
app/orchestrators/CompanyOrchestrator.scala | 2 +-
app/orchestrators/UserOrchestrator.scala | 5 ++
.../accesstoken/AccessTokenRepository.scala | 9 ++
.../AccessTokenRepositoryInterface.scala | 2 +
.../accesstoken/AccessTokenTable.scala | 4 +-
app/services/Email.scala | 10 +++
app/utils/EmailSubjects.scala | 1 +
app/utils/FrontRoute.scala | 1 +
app/views/mails/updateEmailAddress.scala.html | 22 +++++
conf/common/app.conf | 1 +
.../V18__add_user_id_to_access_tokens.sql | 3 +
conf/routes | 2 +
test/controllers/ReportControllerSpec.scala | 2 +-
19 files changed, 187 insertions(+), 7 deletions(-)
create mode 100644 app/views/mails/updateEmailAddress.scala.html
create mode 100644 conf/db/migration/default/V18__add_user_id_to_access_tokens.sql
diff --git a/app/config/SignalConsoConfiguration.scala b/app/config/SignalConsoConfiguration.scala
index 8971592f1..c0aa6e6b1 100644
--- a/app/config/SignalConsoConfiguration.scala
+++ b/app/config/SignalConsoConfiguration.scala
@@ -25,7 +25,8 @@ case class TokenConfiguration(
adminJoinDuration: FiniteDuration,
dgccrfJoinDuration: Period,
dgccrfDelayBeforeRevalidation: Period,
- dgccrfRevalidationTokenDuration: Option[Period]
+ dgccrfRevalidationTokenDuration: Option[Period],
+ updateEmailAddressDuration: Period
)
case class MobileAppConfiguration(
diff --git a/app/controllers/AccountController.scala b/app/controllers/AccountController.scala
index 1d8a45623..f87092f7b 100644
--- a/app/controllers/AccountController.scala
+++ b/app/controllers/AccountController.scala
@@ -151,6 +151,23 @@ class AccountController(
}
}
+ def sendEmailAddressUpdateValidation() = SecuredAction.async(parse.json) { implicit request =>
+ for {
+ emailAddress <- request.parseBody[EmailAddress](JsPath \ "email")
+ _ <- accessesOrchestrator.sendEmailAddressUpdateValidation(request.identity, emailAddress)
+ } yield NoContent
+ }
+
+ def updateEmailAddress(token: String) = SecuredAction.async { implicit request =>
+ for {
+ updatedUser <- accessesOrchestrator.updateEmailAddress(request.identity, token)
+ cookie <- authenticator.init(updatedUser.email) match {
+ case Right(value) => Future.successful(value)
+ case Left(error) => Future.failed(error)
+ }
+ } yield authenticator.embed(cookie, Ok(Json.toJson(updatedUser)))
+ }
+
def softDelete(id: UUID) =
SecuredAction.andThen(WithPermission(UserPermission.softDeleteUsers)).async { request =>
userOrchestrator.softDelete(targetUserId = id, currentUserId = request.identity.id).map(_ => NoContent)
diff --git a/app/controllers/error/AppError.scala b/app/controllers/error/AppError.scala
index b221d8d44..116ae3bbf 100644
--- a/app/controllers/error/AppError.scala
+++ b/app/controllers/error/AppError.scala
@@ -493,4 +493,18 @@ object AppError {
override val titleForLogs: String = "no_report_files"
}
+ final case class UpdateEmailTokenNotFound(token: String) extends NotFoundError {
+ override val `type`: String = "SC-0052"
+ override val title: String = s"Update email token $token not found"
+ override val details: String = s"Le lien de modification d'email n'est pas valide ($token)."
+ override val titleForLogs: String = "update_email_token_not_found"
+ }
+
+ final case class DifferentUserFromRequest(userId: UUID, initialUserId: Option[UUID]) extends BadRequestError {
+ override val `type`: String = "SC-0053"
+ override val title: String = s"The user that initiated the update request is different"
+ override val details: String = s"The user that initiated ($initialUserId) the update request is different ($userId)"
+ override val titleForLogs: String = "different_user_from_request"
+ }
+
}
diff --git a/app/models/AccessToken.scala b/app/models/AccessToken.scala
index 07922db50..849dd77bc 100644
--- a/app/models/AccessToken.scala
+++ b/app/models/AccessToken.scala
@@ -18,7 +18,8 @@ case class AccessToken(
companyId: Option[UUID],
companyLevel: Option[AccessLevel],
emailedTo: Option[EmailAddress],
- expirationDate: Option[OffsetDateTime]
+ expirationDate: Option[OffsetDateTime],
+ userId: Option[UUID]
)
object AccessToken {
@@ -30,7 +31,8 @@ object AccessToken {
companyId: Option[UUID],
level: Option[AccessLevel],
emailedTo: Option[EmailAddress] = None,
- creationDate: OffsetDateTime = OffsetDateTime.now()
+ creationDate: OffsetDateTime = OffsetDateTime.now(),
+ userId: Option[UUID] = None
): AccessToken = AccessToken(
creationDate = creationDate,
kind = kind,
@@ -39,7 +41,8 @@ object AccessToken {
companyId = companyId,
companyLevel = level,
emailedTo = emailedTo,
- expirationDate = validity.map(OffsetDateTime.now().plus(_))
+ expirationDate = validity.map(OffsetDateTime.now().plus(_)),
+ userId = userId
)
def resetExpirationDate(accessToken: AccessToken, validity: java.time.temporal.TemporalAmount) =
diff --git a/app/models/token/TokenKind.scala b/app/models/token/TokenKind.scala
index f9a1408df..ee115477a 100644
--- a/app/models/token/TokenKind.scala
+++ b/app/models/token/TokenKind.scala
@@ -16,6 +16,7 @@ object TokenKind extends PlayEnum[TokenKind] {
case object CompanyFollowUp extends TokenKind
case object CompanyJoin extends TokenKind
case object ValidateEmail extends TokenKind
+ case object UpdateEmail extends TokenKind
case object DGCCRFAccount extends AdminOrDgccrfTokenKind
case object DGALAccount extends AdminOrDgccrfTokenKind
case object AdminAccount extends AdminOrDgccrfTokenKind
diff --git a/app/orchestrators/AccessesOrchestrator.scala b/app/orchestrators/AccessesOrchestrator.scala
index 454db9b03..570f91d68 100644
--- a/app/orchestrators/AccessesOrchestrator.scala
+++ b/app/orchestrators/AccessesOrchestrator.scala
@@ -12,6 +12,7 @@ import models.token.AgentAccessToken
import models.token.DGCCRFUserActivationToken
import models.token.TokenKind
import models.token.TokenKind.AdminAccount
+import models.token.TokenKind.UpdateEmail
import models.token.TokenKind.DGALAccount
import models.token.TokenKind.DGCCRFAccount
import models.token.TokenKind.ValidateEmail
@@ -19,6 +20,7 @@ import play.api.Logger
import repositories.accesstoken.AccessTokenRepositoryInterface
import services.Email.AdminAccessLink
import services.Email.AgentAccessLink
+import services.Email.UpdateEmailAddress
import services.Email
import services.EmailAddressService
import services.MailServiceInterface
@@ -49,6 +51,90 @@ class AccessesOrchestrator(
case _ => None
}
+ def updateEmailAddress(user: User, token: String): Future[User] =
+ for {
+ accessToken <- accessTokenRepository.findToken(token)
+ updateEmailToken <- accessToken.filter(_.kind == UpdateEmail).liftTo[Future](UpdateEmailTokenNotFound(token))
+ isSameUser = updateEmailToken.userId.contains(user.id)
+ emailedTo <- updateEmailToken.emailedTo.liftTo[Future](
+ ServerError(s"Email should be defined for access token $token")
+ )
+ _ <- user.userRole match {
+ case UserRole.DGAL | UserRole.DGCCRF =>
+ accessTokenRepository.validateEmail(updateEmailToken, user)
+ case UserRole.Admin | UserRole.Professionnel =>
+ accessTokenRepository.invalidateToken(updateEmailToken)
+ }
+ updatedUser <-
+ if (isSameUser) userOrchestrator.updateEmail(user, emailedTo)
+ else Future.failed(DifferentUserFromRequest(user.id, updateEmailToken.userId))
+ } yield updatedUser
+
+ def sendEmailAddressUpdateValidation(user: User, newEmail: EmailAddress): Future[Unit] = {
+ val emailValidationFunction = user.userRole match {
+ case UserRole.Admin =>
+ EmailAddressService.isEmailAcceptableForAdminAccount _
+ case UserRole.DGCCRF =>
+ EmailAddressService.isEmailAcceptableForDgccrfAccount _
+ case UserRole.DGAL =>
+ EmailAddressService.isEmailAcceptableForDgalAccount _
+ case UserRole.Professionnel => (_: String) => true
+ }
+
+ for {
+ _ <-
+ if (emailValidationFunction(newEmail.value)) Future.unit
+ else Future.failed(InvalidDGCCRFOrAdminEmail(List(newEmail)))
+ existingTokens <- accessTokenRepository.fetchPendingTokens(user)
+ existingToken = existingTokens.headOption
+ token <-
+ existingToken match {
+ case Some(token) if token.emailedTo.contains(newEmail) =>
+ logger.debug("reseting token validity and email")
+ accessTokenRepository.update(
+ token.id,
+ AccessToken.resetExpirationDate(token, tokenConfiguration.updateEmailAddressDuration)
+ )
+ case Some(token) =>
+ logger.debug("invalidating old token and create new one")
+ for {
+ _ <- accessTokenRepository.invalidateToken(token)
+ createdToken <- accessTokenRepository.create(
+ AccessToken.build(
+ kind = UpdateEmail,
+ token = UUID.randomUUID.toString,
+ validity = Some(tokenConfiguration.updateEmailAddressDuration),
+ companyId = None,
+ level = None,
+ emailedTo = Some(newEmail),
+ userId = Some(user.id)
+ )
+ )
+ } yield createdToken
+ case None =>
+ logger.debug("creating token")
+ accessTokenRepository.create(
+ AccessToken.build(
+ kind = UpdateEmail,
+ token = UUID.randomUUID.toString,
+ validity = Some(tokenConfiguration.updateEmailAddressDuration),
+ companyId = None,
+ level = None,
+ emailedTo = Some(newEmail),
+ userId = Some(user.id)
+ )
+ )
+ }
+ _ <- mailService.send(
+ UpdateEmailAddress(
+ newEmail,
+ frontRoute.dashboard.updateEmail(token.token),
+ tokenConfiguration.updateEmailAddressDuration.getDays
+ )
+ )
+ } yield ()
+ }
+
def listAgentPendingTokens(user: User, maybeRequestedRole: Option[UserRole]): Future[List[AgentAccessToken]] =
user.userRole match {
case UserRole.Admin =>
diff --git a/app/orchestrators/CompanyOrchestrator.scala b/app/orchestrators/CompanyOrchestrator.scala
index 171e73678..00ec6a427 100644
--- a/app/orchestrators/CompanyOrchestrator.scala
+++ b/app/orchestrators/CompanyOrchestrator.scala
@@ -235,7 +235,7 @@ class CompanyOrchestrator(
for {
companies <- companyRepository.fetchCompanies(companyIds)
followUpTokens <- companies.traverse(getFollowUpToken(_, userId))
- tokenMap = followUpTokens.collect { case token @ AccessToken(_, _, _, _, _, Some(companyId), _, _, _) =>
+ tokenMap = followUpTokens.collect { case token @ AccessToken(_, _, _, _, _, Some(companyId), _, _, _, _) =>
(companyId, token)
}.toMap
htmlDocuments = companies.flatMap(company =>
diff --git a/app/orchestrators/UserOrchestrator.scala b/app/orchestrators/UserOrchestrator.scala
index b46ea25f6..b92065190 100644
--- a/app/orchestrators/UserOrchestrator.scala
+++ b/app/orchestrators/UserOrchestrator.scala
@@ -35,6 +35,8 @@ trait UserOrchestratorInterface {
def edit(userId: UUID, update: UserUpdate): Future[Option[User]]
def softDelete(targetUserId: UUID, currentUserId: UUID): Future[Unit]
+
+ def updateEmail(user: User, newEmail: EmailAddress): Future[User]
}
class UserOrchestrator(userRepository: UserRepositoryInterface, eventRepository: EventRepositoryInterface)(implicit
@@ -50,6 +52,9 @@ class UserOrchestrator(userRepository: UserRepositoryInterface, eventRepository:
.getOrElse(Future(None))
} yield updatedUser
+ def updateEmail(user: User, newEmail: EmailAddress): Future[User] =
+ userRepository.update(user.id, user.copy(email = newEmail))
+
override def createUser(draftUser: DraftUser, accessToken: AccessToken, role: UserRole): Future[User] = {
val email: EmailAddress = accessToken.emailedTo.getOrElse(draftUser.email)
val user = User(
diff --git a/app/repositories/accesstoken/AccessTokenRepository.scala b/app/repositories/accesstoken/AccessTokenRepository.scala
index 11e7372a8..20beee977 100644
--- a/app/repositories/accesstoken/AccessTokenRepository.scala
+++ b/app/repositories/accesstoken/AccessTokenRepository.scala
@@ -9,6 +9,7 @@ import models.token.TokenKind.CompanyFollowUp
import models.token.TokenKind.CompanyInit
import models.token.TokenKind.DGALAccount
import models.token.TokenKind.DGCCRFAccount
+import models.token.TokenKind.UpdateEmail
import repositories.accesstoken.AccessTokenColumnType._
import repositories.company.CompanyTable
import repositories.companyaccess.CompanyAccessColumnType._
@@ -123,6 +124,14 @@ class AccessTokenRepository(
fetchCompanyValidTokens(company).delete
)
+ override def fetchPendingTokens(user: User): Future[List[AccessToken]] = db.run(
+ fetchValidTokens
+ .filter(_.userId === user.id)
+ .filter(_.kind === (UpdateEmail: TokenKind))
+ .to[List]
+ .result
+ )
+
override def fetchPendingTokens(emailedTo: EmailAddress): Future[List[AccessToken]] =
db.run(
table
diff --git a/app/repositories/accesstoken/AccessTokenRepositoryInterface.scala b/app/repositories/accesstoken/AccessTokenRepositoryInterface.scala
index 825359be6..aebbaa520 100644
--- a/app/repositories/accesstoken/AccessTokenRepositoryInterface.scala
+++ b/app/repositories/accesstoken/AccessTokenRepositoryInterface.scala
@@ -28,6 +28,8 @@ trait AccessTokenRepositoryInterface extends CRUDRepositoryInterface[AccessToken
def findValidToken(company: Company, token: String): Future[Option[AccessToken]]
+ def fetchPendingTokens(user: User): Future[List[AccessToken]]
+
def fetchPendingTokens(company: Company): Future[List[AccessToken]]
def removePendingTokens(company: Company): Future[Int]
diff --git a/app/repositories/accesstoken/AccessTokenTable.scala b/app/repositories/accesstoken/AccessTokenTable.scala
index b9624241c..0eef27bab 100644
--- a/app/repositories/accesstoken/AccessTokenTable.scala
+++ b/app/repositories/accesstoken/AccessTokenTable.scala
@@ -21,6 +21,7 @@ class AccessTokenTable(tag: Tag) extends DatabaseTable[AccessToken](tag, "access
def level = column[Option[AccessLevel]]("level")
def emailedTo = column[Option[EmailAddress]]("emailed_to")
def expirationDate = column[Option[OffsetDateTime]]("expiration_date")
+ def userId = column[Option[UUID]]("user_id")
def * = (
id,
creationDate,
@@ -30,7 +31,8 @@ class AccessTokenTable(tag: Tag) extends DatabaseTable[AccessToken](tag, "access
companyId,
level,
emailedTo,
- expirationDate
+ expirationDate,
+ userId
) <> ((AccessToken.apply _).tupled, AccessToken.unapply)
}
diff --git a/app/services/Email.scala b/app/services/Email.scala
index a77cf4b98..ab6787c9f 100644
--- a/app/services/Email.scala
+++ b/app/services/Email.scala
@@ -395,5 +395,15 @@ object Email {
_.attachmentSeqForWorkflowStepN(3, report.lang.getOrElse(Locale.FRENCH))
}
+ final case class UpdateEmailAddress(recipient: EmailAddress, invitationUrl: URI, daysBeforeExpiry: Int)
+ extends Email {
+
+ override val recipients: Seq[EmailAddress] = List(recipient)
+ override val subject: String = EmailSubjects.UPDATE_EMAIL_ADDRESS
+
+ override def getBody: (FrontRoute, EmailAddress) => String = (_, _) =>
+ views.html.mails.updateEmailAddress(invitationUrl, daysBeforeExpiry).toString()
+ }
+
private def getLocaleOrDefault(locale: Option[Locale]): Locale = locale.getOrElse(Locale.FRENCH)
}
diff --git a/app/utils/EmailSubjects.scala b/app/utils/EmailSubjects.scala
index e3d4a6d5e..6901ce684 100644
--- a/app/utils/EmailSubjects.scala
+++ b/app/utils/EmailSubjects.scala
@@ -21,4 +21,5 @@ object EmailSubjects {
val INACTIVE_DGCCRF_ACCOUNT_REMINDER = "Votre compte SignalConso est inactif"
val PRO_NEW_COMPANIES_ACCESSES = (siren: SIREN) => s"Vous avez maintenant accès à l'entreprise $siren sur SignalConso"
val PRO_COMPANIES_ACCESSES_INVITATIONS = (siren: SIREN) => s"Rejoignez l'entreprise $siren sur SignalConso"
+ val UPDATE_EMAIL_ADDRESS = "Validez votre nouvelle adresse email SignalConso"
}
diff --git a/app/utils/FrontRoute.scala b/app/utils/FrontRoute.scala
index 7eb75085e..018edf8b7 100644
--- a/app/utils/FrontRoute.scala
+++ b/app/utils/FrontRoute.scala
@@ -29,6 +29,7 @@ class FrontRoute(signalConsoConfiguration: SignalConsoConfiguration) {
def resetPassword(authToken: AuthToken) = url(s"/connexion/nouveau-mot-de-passe/${authToken.id}")
def activation = url("/activation")
def welcome = url("/")
+ def updateEmail(token: String) = url(s"/parametres/update-email/$token")
object Admin {
def register(token: String) = url(s"/admin/rejoindre/?token=$token")
diff --git a/app/views/mails/updateEmailAddress.scala.html b/app/views/mails/updateEmailAddress.scala.html
new file mode 100644
index 000000000..55c28983f
--- /dev/null
+++ b/app/views/mails/updateEmailAddress.scala.html
@@ -0,0 +1,22 @@
+@import java.net.URI
+@(validationUrl: URI, daysBeforeExpiry: Int)
+
+@views.html.mails.layout("Validez votre nouvelle adresse email SignalConso") {
+
+ Bonjour,
+
+
+ Pour valider votre nouvelle adresse email sur SignalConso veuillez cliquer sur lien suivant :
+
+
+ Validez votre nouvelle adresse email
+
+
+
+ ATTENTION : Ce lien n'est valable que @daysBeforeExpiry jours après l'envoi de cet e-mail
+
+
+
+ L'équipe SignalConso
+
+}
\ No newline at end of file
diff --git a/conf/common/app.conf b/conf/common/app.conf
index d023a71cc..502b795e6 100644
--- a/conf/common/app.conf
+++ b/conf/common/app.conf
@@ -29,6 +29,7 @@ app {
dgccrf-join-duration = "P60D"
dgccrf-delay-before-revalidation = "P90D"
dgccrf-revalidation-token-duration = "P7D"
+ update-email-address-duration = "P2D"
}
mobile-app {
diff --git a/conf/db/migration/default/V18__add_user_id_to_access_tokens.sql b/conf/db/migration/default/V18__add_user_id_to_access_tokens.sql
new file mode 100644
index 000000000..5e7136f62
--- /dev/null
+++ b/conf/db/migration/default/V18__add_user_id_to_access_tokens.sql
@@ -0,0 +1,3 @@
+ALTER TABLE access_tokens
+ ADD user_id UUID,
+ ADD CONSTRAINT fk_user_id FOREIGN KEY (user_id) REFERENCES users(id);
\ No newline at end of file
diff --git a/conf/routes b/conf/routes
index 22d517ddc..5cee2e78a 100644
--- a/conf/routes
+++ b/conf/routes
@@ -125,6 +125,8 @@ POST /api/account/validate-email controll
POST /api/account/validate-email/:email controllers.AccountController.forceValidateEmail(email : String)
PUT /api/account controllers.AccountController.edit()
DELETE /api/account/:id controllers.AccountController.softDelete(id: java.util.UUID)
+POST /api/account/send-email-update-validation controllers.AccountController.sendEmailAddressUpdateValidation()
+PUT /api/account/update-email/:token controllers.AccountController.updateEmailAddress(token: String)
# EmailValidation API
POST /api/email-validation/check controllers.EmailValidationController.check()
diff --git a/test/controllers/ReportControllerSpec.scala b/test/controllers/ReportControllerSpec.scala
index da4d8184c..65b47c32a 100644
--- a/test/controllers/ReportControllerSpec.scala
+++ b/test/controllers/ReportControllerSpec.scala
@@ -304,7 +304,7 @@ class ReportControllerSpec(implicit ee: ExecutionEnv) extends Specification with
override def s3Service: S3ServiceInterface = mockS3Service
override def tokenConfiguration =
- TokenConfiguration(None, None, 12.hours, Period.ofDays(60), Period.ZERO, None)
+ TokenConfiguration(None, None, 12.hours, Period.ofDays(60), Period.ZERO, None, Period.ZERO)
override def uploadConfiguration = UploadConfiguration(Seq.empty, false, "/tmp")
override def mobileAppConfiguration = MobileAppConfiguration(
minimumAppVersionIos = "1.0.0",