Skip to content

Commit ccbcdf7

Browse files
authored
Merge pull request #1002 from hmrc/BDOG-3350
BDOG-3350: Allow User/Team Admin to amend user details
2 parents a6d8f10 + 1fa88e3 commit ccbcdf7

File tree

6 files changed

+426
-37
lines changed

6 files changed

+426
-37
lines changed

app/uk/gov/hmrc/cataloguefrontend/connector/UserManagementConnector.scala

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import play.api.Logging
2020
import play.api.libs.json.*
2121
import play.api.libs.ws.writeableOf_JsValue
2222
import uk.gov.hmrc.cataloguefrontend.model.{TeamName, UserName}
23-
import uk.gov.hmrc.cataloguefrontend.users.{CreateUserRequest, EditUserAccessRequest, ResetLdapPassword, UmpTeam, User, UserAccess}
23+
import uk.gov.hmrc.cataloguefrontend.users.{CreateUserRequest, EditUserAccessRequest, ResetLdapPassword, UmpTeam, User, UserAccess, EditUserDetailsRequest}
2424
import uk.gov.hmrc.http.client.HttpClientV2
2525
import uk.gov.hmrc.http.{HeaderCarrier, HttpReads, StringContextOps, UpstreamErrorResponse}
2626
import uk.gov.hmrc.play.bootstrap.config.ServicesConfig
@@ -103,6 +103,16 @@ class UserManagementConnector @Inject()(
103103
case Right(res) => Future.successful(res)
104104
case Left(err) => Future.failed(RuntimeException(s"Request to $url failed with upstream error: ${err.message}"))
105105

106+
def editUserDetails(editUserDetails: EditUserDetailsRequest)(using HeaderCarrier): Future[Unit] =
107+
val url: URL = url"$baseUrl/user-management/edit-user-details"
108+
httpClientV2
109+
.put(url)
110+
.withBody(Json.toJson(editUserDetails)(EditUserDetailsRequest.writes))
111+
.execute[Either[UpstreamErrorResponse, Unit]]
112+
.flatMap:
113+
case Right(res) => Future.successful(res)
114+
case Left(err) => Future.failed(RuntimeException(s"Request to $url failed with upstream error: ${err.message}"))
115+
106116
def requestNewVpnCert(username: UserName)(using HeaderCarrier): Future[Option[String]] =
107117
val url: URL = url"$baseUrl/user-management/users/${username.asString}/vpn"
108118
httpClientV2

app/uk/gov/hmrc/cataloguefrontend/users/UsersController.scala

Lines changed: 109 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
package uk.gov.hmrc.cataloguefrontend.users
1818

1919
import play.api.Logging
20-
import play.api.data.validation.Constraints
20+
import play.api.data.validation.{Constraint, Constraints, Invalid, Valid}
2121
import play.api.data.{Form, Forms}
2222
import play.api.mvc.*
2323
import play.twirl.api.Html
@@ -54,10 +54,11 @@ class UsersController @Inject()(
5454
with Logging:
5555

5656
private def showUserInfoPage(
57-
username: UserName,
58-
retrieval: Option[Set[Resource]],
59-
resultType: Html => Result,
60-
form: Form[_]
57+
username : UserName,
58+
retrieval : Option[Set[Resource]],
59+
resultType : Html => Result,
60+
ldapForm : Form[ResetLdapPassword],
61+
userDetailsForm: Form[EditUserDetailsRequest]
6162
)(using HeaderCarrier, RequestHeader): Future[Result] =
6263
for
6364
userOpt <- userManagementConnector.getUser(username)
@@ -70,7 +71,7 @@ class UsersController @Inject()(
7071
userOpt match
7172
case Some(user) =>
7273
val umpProfileUrl = s"${umpConfig.userManagementProfileBaseUrl}/${user.username.asString}"
73-
resultType(userInfoPage(form, isAdminForUser(retrieval, user), userTooling, user, umpProfileUrl))
74+
resultType(userInfoPage(ldapForm, userDetailsForm, isAdminForUser(retrieval, user), userTooling, user, umpProfileUrl))
7475
case None =>
7576
NotFound(error_404_template())
7677

@@ -82,9 +83,32 @@ class UsersController @Inject()(
8283
resourceType = Some(ResourceType("catalogue-frontend")),
8384
action = Some(IAAction("EDIT_USER"))
8485
))
85-
result <- showUserInfoPage(username, retrieval, Ok(_), LdapResetForm.form)
86+
result <- showUserInfoPage(username, retrieval, Ok(_), LdapResetForm.form, EditUserDetailsForm.form)
8687
yield result
8788

89+
def updateUserDetails(username: UserName): Action[AnyContent] =
90+
BasicAuthAction.async: request =>
91+
given MessagesRequest[AnyContent] = request
92+
EditUserDetailsForm.form.bindFromRequest().fold(
93+
formWithErrors =>
94+
for
95+
retrieval <- auth.verify(Retrieval.locations(
96+
resourceType = Some(ResourceType("catalogue-frontend")),
97+
action = Some(IAAction("EDIT_USER"))
98+
))
99+
result <- showUserInfoPage(username, retrieval, BadRequest(_), LdapResetForm.form, formWithErrors)
100+
yield result
101+
, editRequest =>
102+
userManagementConnector.editUserDetails(editRequest)
103+
.map: _ =>
104+
Redirect(routes.UsersController.user(UserName(editRequest.username)))
105+
.flashing("success" -> s"User's ${editRequest.attribute.description} has been updated successfully")
106+
.recover:
107+
case NonFatal(e) =>
108+
Redirect(routes.UsersController.user(UserName(editRequest.username)))
109+
.flashing(s"error" -> s"Error updating User Details for user's ${editRequest.attribute.description}. Contact #team-platops")
110+
)
111+
88112
def requestLdapReset(username: UserName): Action[AnyContent] =
89113
auth.authenticatedAction(
90114
continueUrl = routes.UsersController.user(username),
@@ -93,8 +117,8 @@ class UsersController @Inject()(
93117
given AuthenticatedRequest[AnyContent, Set[Resource]] = request
94118
LdapResetForm.form.bindFromRequest().fold(
95119
formWithErrors =>
96-
showUserInfoPage(username, Some(request.retrieval), BadRequest(_), formWithErrors)
97-
, formData =>
120+
showUserInfoPage(username, Some(request.retrieval), BadRequest(_), formWithErrors, EditUserDetailsForm.form)
121+
, formData =>
98122
userManagementConnector.resetLdapPassword(formData).map: ticketOpt =>
99123
Ok(ldapResetRequestSentPage(username, ticketOpt))
100124
.recover:
@@ -162,3 +186,79 @@ object LdapResetForm:
162186
"email" -> Forms.text.verifying(Constraints.emailAddress(errorMessage = "Please provide a valid email address."))
163187
)(ResetLdapPassword.apply)(f => Some(Tuple.fromProductTyped(f)))
164188
)
189+
190+
object EditUserDetailsForm:
191+
val form: Form[EditUserDetailsRequest] = Form(
192+
Forms.mapping(
193+
"username" -> Forms.nonEmptyText,
194+
"attribute" -> Forms.nonEmptyText.transform[UserAttribute](UserAttribute.fromString(_).get, _.name),
195+
"displayName" -> Forms.optional(Forms.text),
196+
"phoneNumber" -> Forms.optional(Forms.text),
197+
"github" -> Forms.optional(Forms.text),
198+
"organisation" -> Forms.optional(Forms.text)
199+
) { (username, attribute, displayNameOpt, phoneNumberOpt, githubOpt, organisationOpt) =>
200+
val value = attribute match
201+
case UserAttribute.DisplayName => displayNameOpt.getOrElse("")
202+
case UserAttribute.PhoneNumber => phoneNumberOpt.getOrElse("")
203+
case UserAttribute.Github => githubOpt.getOrElse("")
204+
case UserAttribute.Organisation => organisationOpt.getOrElse("")
205+
EditUserDetailsRequest(username, attribute, value)
206+
} { editUserDetailsRequest =>
207+
Some((
208+
editUserDetailsRequest.username,
209+
editUserDetailsRequest.attribute,
210+
if editUserDetailsRequest.attribute == UserAttribute.DisplayName then Some(editUserDetailsRequest.value) else None,
211+
if editUserDetailsRequest.attribute == UserAttribute.PhoneNumber then Some(editUserDetailsRequest.value) else None,
212+
if editUserDetailsRequest.attribute == UserAttribute.Github then Some(editUserDetailsRequest.value) else None,
213+
if editUserDetailsRequest.attribute == UserAttribute.Organisation then Some(editUserDetailsRequest.value) else None
214+
))
215+
}.verifying(validateByAttribute)
216+
)
217+
218+
private def mkConstraint[T](constraintName: String)(constraint: T => Boolean, error: String): Constraint[T] =
219+
Constraint(constraintName): toBeValidated =>
220+
if constraint(toBeValidated) then Valid else Invalid(error)
221+
222+
private val nameConstraints: Constraint[String] =
223+
val nameLengthValidation: String => Boolean = str => str.length >= 2 && str.length <= 30
224+
225+
mkConstraint(s"constraints.displayNameLengthCheck")(
226+
constraint = nameLengthValidation,
227+
error = "Name should be between 2 and 30 characters long"
228+
)
229+
230+
private val phoneNumberConstraint: Constraint[String] =
231+
val phoneNumberValidation: String => Boolean =
232+
_.matches("""^(?=.*\d)[\d\s/+]*$""")
233+
234+
mkConstraint("constraints.phoneNumber")(
235+
constraint = phoneNumberValidation,
236+
error = "Phone number can only contain digits, spaces, plus signs, or slashes."
237+
)
238+
239+
private val githubUsernameConstraint: Constraint[String] =
240+
val githubUsernameValidation: String => Boolean =
241+
!_.isBlank
242+
243+
mkConstraint("constraints.githubUsername")(
244+
constraint = githubUsernameValidation,
245+
error = "GitHub username cannot be set to empty once it has been provided."
246+
)
247+
248+
private val organisationConstraint: Constraint[String] =
249+
val organisationValidation: String => Boolean =
250+
Organisation.values.map(_.asString).contains(_)
251+
252+
mkConstraint("constraints.organisation")(
253+
constraint = organisationValidation,
254+
error = "Organisation must be MDTP, VOA, or Other."
255+
)
256+
257+
private def validateByAttribute: Constraint[EditUserDetailsRequest] = Constraint("constraints.editUserDetailsRequest") { editUserDetailsRequest =>
258+
editUserDetailsRequest.attribute match
259+
case UserAttribute.DisplayName => nameConstraints(editUserDetailsRequest.value)
260+
case UserAttribute.PhoneNumber => phoneNumberConstraint(editUserDetailsRequest.value)
261+
case UserAttribute.Github => githubUsernameConstraint(editUserDetailsRequest.value)
262+
case UserAttribute.Organisation => organisationConstraint(editUserDetailsRequest.value)
263+
}
264+

app/uk/gov/hmrc/cataloguefrontend/users/model.scala

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616

1717
package uk.gov.hmrc.cataloguefrontend.users
1818

19-
import play.api.libs.json._
20-
import play.api.libs.functional.syntax._
19+
import play.api.libs.json.*
20+
import play.api.libs.functional.syntax.*
2121
import uk.gov.hmrc.cataloguefrontend.model.{TeamName, UserName}
2222
import uk.gov.hmrc.cataloguefrontend.util.FromString
2323

@@ -235,3 +235,25 @@ object ResetLdapPassword:
235235
( (__ \ "username").write[String]
236236
~ (__ \ "email_address").write[String]
237237
)(r => Tuple.fromProductTyped(r))
238+
239+
enum UserAttribute(val name: String, val description: String):
240+
case DisplayName extends UserAttribute("displayName" , "name" )
241+
case Github extends UserAttribute("github" , "GitHub ID" )
242+
case Organisation extends UserAttribute("organisation", "organisation")
243+
case PhoneNumber extends UserAttribute("phoneNumber" , "phone number")
244+
245+
object UserAttribute:
246+
def fromString(value: String): Option[UserAttribute] =
247+
UserAttribute.values.find(_.name == value)
248+
249+
val writes: Writes[UserAttribute] =
250+
Writes { attr => JsString(attr.name) }
251+
252+
case class EditUserDetailsRequest(username: String, attribute: UserAttribute, value: String)
253+
254+
object EditUserDetailsRequest:
255+
val writes: Writes[EditUserDetailsRequest] =
256+
( (__ \ "username" ).write[String]
257+
~ (__ \ "attribute").write[UserAttribute](UserAttribute.writes)
258+
~ (__ \ "value" ).write[String]
259+
)(e => Tuple.fromProductTyped(e))

0 commit comments

Comments
 (0)