Skip to content

Commit

Permalink
[TRELLO-2160] Implement routes to update the email address (#1527)
Browse files Browse the repository at this point in the history
* [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
  • Loading branch information
charlescd authored Jan 23, 2024
1 parent 6c3ae0d commit 7d08c00
Show file tree
Hide file tree
Showing 19 changed files with 187 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],
updateEmailAddressDuration: Period
)

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

}
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
86 changes: 86 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,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 =>
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
9 changes: 9 additions & 0 deletions app/repositories/accesstoken/AccessTokenRepository.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
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"/parametres/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>
}
1 change: 1 addition & 0 deletions conf/common/app.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE access_tokens
ADD user_id UUID,
ADD CONSTRAINT fk_user_id FOREIGN KEY (user_id) REFERENCES users(id);
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 7d08c00

Please sign in to comment.