Skip to content

Commit

Permalink
[TRELLO-2160] Implement routes to update the email address
Browse files Browse the repository at this point in the history
  • Loading branch information
charlescd committed Jan 19, 2024
1 parent 6d5f96a commit 4b99f70
Show file tree
Hide file tree
Showing 15 changed files with 147 additions and 7 deletions.
3 changes: 2 additions & 1 deletion app/config/SignalConsoConfiguration.scala
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ case class TokenConfiguration(
adminJoinDuration: FiniteDuration,
dgccrfJoinDuration: Period,
dgccrfDelayBeforeRevalidation: Period,
dgccrfRevalidationTokenDuration: Option[Period]
dgccrfRevalidationTokenDuration: Option[Period],
updateEmailAddress: Period
)

case class MobileAppConfiguration(
Expand Down
13 changes: 13 additions & 0 deletions app/controllers/AccountController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions app/controllers/error/AppError.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

}
9 changes: 6 additions & 3 deletions app/models/AccessToken.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand All @@ -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) =
Expand Down
1 change: 1 addition & 0 deletions app/models/token/TokenKind.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
64 changes: 64 additions & 0 deletions app/orchestrators/AccessesOrchestrator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ 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
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
Expand Down Expand Up @@ -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 =>
Expand Down
2 changes: 1 addition & 1 deletion app/orchestrators/CompanyOrchestrator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
5 changes: 5 additions & 0 deletions app/orchestrators/UserOrchestrator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
4 changes: 3 additions & 1 deletion app/repositories/accesstoken/AccessTokenTable.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -30,7 +31,8 @@ class AccessTokenTable(tag: Tag) extends DatabaseTable[AccessToken](tag, "access
companyId,
level,
emailedTo,
expirationDate
expirationDate,
userId
) <> ((AccessToken.apply _).tupled, AccessToken.unapply)
}

Expand Down
10 changes: 10 additions & 0 deletions app/services/Email.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions app/utils/EmailSubjects.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
1 change: 1 addition & 0 deletions app/utils/FrontRoute.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
22 changes: 22 additions & 0 deletions app/views/mails/updateEmailAddress.scala.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
@import java.net.URI
@(validationUrl: URI, daysBeforeExpiry: Int)

@views.html.mails.layout("Validez votre nouvelle adresse email SignalConso") {
<p>
Bonjour,
</p>
<p>
Pour valider votre nouvelle adresse email sur SignalConso veuillez cliquer sur lien suivant :

<p style="text-align: center;">
<a href="@validationUrl">Validez votre nouvelle adresse email</a>
</p>
</p>
<p>
ATTENTION : Ce lien n'est valable que @daysBeforeExpiry jours après l'envoi de cet e-mail
</p>

<p>
<i>L'équipe SignalConso</i>
</p>
}
2 changes: 2 additions & 0 deletions conf/routes
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion test/controllers/ReportControllerSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit 4b99f70

Please sign in to comment.