Skip to content

Commit

Permalink
Merge pull request #1002 from hmrc/BDOG-3350
Browse files Browse the repository at this point in the history
BDOG-3350: Allow User/Team Admin to amend user details
  • Loading branch information
BriWak authored Jan 24, 2025
2 parents a6d8f10 + 1fa88e3 commit ccbcdf7
Show file tree
Hide file tree
Showing 6 changed files with 426 additions and 37 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import play.api.Logging
import play.api.libs.json.*
import play.api.libs.ws.writeableOf_JsValue
import uk.gov.hmrc.cataloguefrontend.model.{TeamName, UserName}
import uk.gov.hmrc.cataloguefrontend.users.{CreateUserRequest, EditUserAccessRequest, ResetLdapPassword, UmpTeam, User, UserAccess}
import uk.gov.hmrc.cataloguefrontend.users.{CreateUserRequest, EditUserAccessRequest, ResetLdapPassword, UmpTeam, User, UserAccess, EditUserDetailsRequest}
import uk.gov.hmrc.http.client.HttpClientV2
import uk.gov.hmrc.http.{HeaderCarrier, HttpReads, StringContextOps, UpstreamErrorResponse}
import uk.gov.hmrc.play.bootstrap.config.ServicesConfig
Expand Down Expand Up @@ -103,6 +103,16 @@ class UserManagementConnector @Inject()(
case Right(res) => Future.successful(res)
case Left(err) => Future.failed(RuntimeException(s"Request to $url failed with upstream error: ${err.message}"))

def editUserDetails(editUserDetails: EditUserDetailsRequest)(using HeaderCarrier): Future[Unit] =
val url: URL = url"$baseUrl/user-management/edit-user-details"
httpClientV2
.put(url)
.withBody(Json.toJson(editUserDetails)(EditUserDetailsRequest.writes))
.execute[Either[UpstreamErrorResponse, Unit]]
.flatMap:
case Right(res) => Future.successful(res)
case Left(err) => Future.failed(RuntimeException(s"Request to $url failed with upstream error: ${err.message}"))

def requestNewVpnCert(username: UserName)(using HeaderCarrier): Future[Option[String]] =
val url: URL = url"$baseUrl/user-management/users/${username.asString}/vpn"
httpClientV2
Expand Down
118 changes: 109 additions & 9 deletions app/uk/gov/hmrc/cataloguefrontend/users/UsersController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
package uk.gov.hmrc.cataloguefrontend.users

import play.api.Logging
import play.api.data.validation.Constraints
import play.api.data.validation.{Constraint, Constraints, Invalid, Valid}
import play.api.data.{Form, Forms}
import play.api.mvc.*
import play.twirl.api.Html
Expand Down Expand Up @@ -54,10 +54,11 @@ class UsersController @Inject()(
with Logging:

private def showUserInfoPage(
username: UserName,
retrieval: Option[Set[Resource]],
resultType: Html => Result,
form: Form[_]
username : UserName,
retrieval : Option[Set[Resource]],
resultType : Html => Result,
ldapForm : Form[ResetLdapPassword],
userDetailsForm: Form[EditUserDetailsRequest]
)(using HeaderCarrier, RequestHeader): Future[Result] =
for
userOpt <- userManagementConnector.getUser(username)
Expand All @@ -70,7 +71,7 @@ class UsersController @Inject()(
userOpt match
case Some(user) =>
val umpProfileUrl = s"${umpConfig.userManagementProfileBaseUrl}/${user.username.asString}"
resultType(userInfoPage(form, isAdminForUser(retrieval, user), userTooling, user, umpProfileUrl))
resultType(userInfoPage(ldapForm, userDetailsForm, isAdminForUser(retrieval, user), userTooling, user, umpProfileUrl))
case None =>
NotFound(error_404_template())

Expand All @@ -82,9 +83,32 @@ class UsersController @Inject()(
resourceType = Some(ResourceType("catalogue-frontend")),
action = Some(IAAction("EDIT_USER"))
))
result <- showUserInfoPage(username, retrieval, Ok(_), LdapResetForm.form)
result <- showUserInfoPage(username, retrieval, Ok(_), LdapResetForm.form, EditUserDetailsForm.form)
yield result

def updateUserDetails(username: UserName): Action[AnyContent] =
BasicAuthAction.async: request =>
given MessagesRequest[AnyContent] = request
EditUserDetailsForm.form.bindFromRequest().fold(
formWithErrors =>
for
retrieval <- auth.verify(Retrieval.locations(
resourceType = Some(ResourceType("catalogue-frontend")),
action = Some(IAAction("EDIT_USER"))
))
result <- showUserInfoPage(username, retrieval, BadRequest(_), LdapResetForm.form, formWithErrors)
yield result
, editRequest =>
userManagementConnector.editUserDetails(editRequest)
.map: _ =>
Redirect(routes.UsersController.user(UserName(editRequest.username)))
.flashing("success" -> s"User's ${editRequest.attribute.description} has been updated successfully")
.recover:
case NonFatal(e) =>
Redirect(routes.UsersController.user(UserName(editRequest.username)))
.flashing(s"error" -> s"Error updating User Details for user's ${editRequest.attribute.description}. Contact #team-platops")
)

def requestLdapReset(username: UserName): Action[AnyContent] =
auth.authenticatedAction(
continueUrl = routes.UsersController.user(username),
Expand All @@ -93,8 +117,8 @@ class UsersController @Inject()(
given AuthenticatedRequest[AnyContent, Set[Resource]] = request
LdapResetForm.form.bindFromRequest().fold(
formWithErrors =>
showUserInfoPage(username, Some(request.retrieval), BadRequest(_), formWithErrors)
, formData =>
showUserInfoPage(username, Some(request.retrieval), BadRequest(_), formWithErrors, EditUserDetailsForm.form)
, formData =>
userManagementConnector.resetLdapPassword(formData).map: ticketOpt =>
Ok(ldapResetRequestSentPage(username, ticketOpt))
.recover:
Expand Down Expand Up @@ -162,3 +186,79 @@ object LdapResetForm:
"email" -> Forms.text.verifying(Constraints.emailAddress(errorMessage = "Please provide a valid email address."))
)(ResetLdapPassword.apply)(f => Some(Tuple.fromProductTyped(f)))
)

object EditUserDetailsForm:
val form: Form[EditUserDetailsRequest] = Form(
Forms.mapping(
"username" -> Forms.nonEmptyText,
"attribute" -> Forms.nonEmptyText.transform[UserAttribute](UserAttribute.fromString(_).get, _.name),
"displayName" -> Forms.optional(Forms.text),
"phoneNumber" -> Forms.optional(Forms.text),
"github" -> Forms.optional(Forms.text),
"organisation" -> Forms.optional(Forms.text)
) { (username, attribute, displayNameOpt, phoneNumberOpt, githubOpt, organisationOpt) =>
val value = attribute match
case UserAttribute.DisplayName => displayNameOpt.getOrElse("")
case UserAttribute.PhoneNumber => phoneNumberOpt.getOrElse("")
case UserAttribute.Github => githubOpt.getOrElse("")
case UserAttribute.Organisation => organisationOpt.getOrElse("")
EditUserDetailsRequest(username, attribute, value)
} { editUserDetailsRequest =>
Some((
editUserDetailsRequest.username,
editUserDetailsRequest.attribute,
if editUserDetailsRequest.attribute == UserAttribute.DisplayName then Some(editUserDetailsRequest.value) else None,
if editUserDetailsRequest.attribute == UserAttribute.PhoneNumber then Some(editUserDetailsRequest.value) else None,
if editUserDetailsRequest.attribute == UserAttribute.Github then Some(editUserDetailsRequest.value) else None,
if editUserDetailsRequest.attribute == UserAttribute.Organisation then Some(editUserDetailsRequest.value) else None
))
}.verifying(validateByAttribute)
)

private def mkConstraint[T](constraintName: String)(constraint: T => Boolean, error: String): Constraint[T] =
Constraint(constraintName): toBeValidated =>
if constraint(toBeValidated) then Valid else Invalid(error)

private val nameConstraints: Constraint[String] =
val nameLengthValidation: String => Boolean = str => str.length >= 2 && str.length <= 30

mkConstraint(s"constraints.displayNameLengthCheck")(
constraint = nameLengthValidation,
error = "Name should be between 2 and 30 characters long"
)

private val phoneNumberConstraint: Constraint[String] =
val phoneNumberValidation: String => Boolean =
_.matches("""^(?=.*\d)[\d\s/+]*$""")

mkConstraint("constraints.phoneNumber")(
constraint = phoneNumberValidation,
error = "Phone number can only contain digits, spaces, plus signs, or slashes."
)

private val githubUsernameConstraint: Constraint[String] =
val githubUsernameValidation: String => Boolean =
!_.isBlank

mkConstraint("constraints.githubUsername")(
constraint = githubUsernameValidation,
error = "GitHub username cannot be set to empty once it has been provided."
)

private val organisationConstraint: Constraint[String] =
val organisationValidation: String => Boolean =
Organisation.values.map(_.asString).contains(_)

mkConstraint("constraints.organisation")(
constraint = organisationValidation,
error = "Organisation must be MDTP, VOA, or Other."
)

private def validateByAttribute: Constraint[EditUserDetailsRequest] = Constraint("constraints.editUserDetailsRequest") { editUserDetailsRequest =>
editUserDetailsRequest.attribute match
case UserAttribute.DisplayName => nameConstraints(editUserDetailsRequest.value)
case UserAttribute.PhoneNumber => phoneNumberConstraint(editUserDetailsRequest.value)
case UserAttribute.Github => githubUsernameConstraint(editUserDetailsRequest.value)
case UserAttribute.Organisation => organisationConstraint(editUserDetailsRequest.value)
}

26 changes: 24 additions & 2 deletions app/uk/gov/hmrc/cataloguefrontend/users/model.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@

package uk.gov.hmrc.cataloguefrontend.users

import play.api.libs.json._
import play.api.libs.functional.syntax._
import play.api.libs.json.*
import play.api.libs.functional.syntax.*
import uk.gov.hmrc.cataloguefrontend.model.{TeamName, UserName}
import uk.gov.hmrc.cataloguefrontend.util.FromString

Expand Down Expand Up @@ -235,3 +235,25 @@ object ResetLdapPassword:
( (__ \ "username").write[String]
~ (__ \ "email_address").write[String]
)(r => Tuple.fromProductTyped(r))

enum UserAttribute(val name: String, val description: String):
case DisplayName extends UserAttribute("displayName" , "name" )
case Github extends UserAttribute("github" , "GitHub ID" )
case Organisation extends UserAttribute("organisation", "organisation")
case PhoneNumber extends UserAttribute("phoneNumber" , "phone number")

object UserAttribute:
def fromString(value: String): Option[UserAttribute] =
UserAttribute.values.find(_.name == value)

val writes: Writes[UserAttribute] =
Writes { attr => JsString(attr.name) }

case class EditUserDetailsRequest(username: String, attribute: UserAttribute, value: String)

object EditUserDetailsRequest:
val writes: Writes[EditUserDetailsRequest] =
( (__ \ "username" ).write[String]
~ (__ \ "attribute").write[UserAttribute](UserAttribute.writes)
~ (__ \ "value" ).write[String]
)(e => Tuple.fromProductTyped(e))
Loading

0 comments on commit ccbcdf7

Please sign in to comment.