diff --git a/app/config/SignalConsoConfiguration.scala b/app/config/SignalConsoConfiguration.scala index 8971592f1..5497e8d1f 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], + updateEmailAddress: Period ) case class MobileAppConfiguration( diff --git a/app/controllers/AccountController.scala b/app/controllers/AccountController.scala index 1d8a45623..482230987 100644 --- a/app/controllers/AccountController.scala +++ b/app/controllers/AccountController.scala @@ -151,6 +151,19 @@ 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(parse.json) { implicit request => + for { + updatedUser <- accessesOrchestrator.updateEmailAddress(request.identity, token) + } yield 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..c605e37d1 100644 --- a/app/controllers/error/AppError.scala +++ b/app/controllers/error/AppError.scala @@ -493,4 +493,19 @@ 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). Merci de contacter le support" + 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..cd0b28533 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,68 @@ 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") + ) + 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(newEmail) + existingToken = existingTokens.find(_.kind == UpdateEmail) + token <- + existingToken match { + case Some(token) => + logger.debug("reseting token validity") + accessTokenRepository.update( + token.id, + AccessToken.resetExpirationDate(token, tokenConfiguration.updateEmailAddress) + ) + case None => + logger.debug("creating token") + accessTokenRepository.create( + AccessToken.build( + kind = UpdateEmail, + token = UUID.randomUUID.toString, + validity = Some(tokenConfiguration.updateEmailAddress), + companyId = None, + level = None, + emailedTo = Some(newEmail), + userId = Some(user.id) + ) + ) + } + _ <- mailService.send( + UpdateEmailAddress( + newEmail, + frontRoute.dashboard.updateEmail(token.token), + tokenConfiguration.updateEmailAddress.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/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..53310ae0e 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"/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/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",