diff --git a/app/uk/gov/hmrc/thirdpartydeveloperfrontend/controllers/ManageApplicationController.scala b/app/uk/gov/hmrc/thirdpartydeveloperfrontend/controllers/ManageApplicationController.scala
new file mode 100644
index 000000000..441b27a89
--- /dev/null
+++ b/app/uk/gov/hmrc/thirdpartydeveloperfrontend/controllers/ManageApplicationController.scala
@@ -0,0 +1,52 @@
+/*
+ * Copyright 2023 HM Revenue & Customs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package uk.gov.hmrc.thirdpartydeveloperfrontend.controllers
+
+import java.time.Clock
+import javax.inject.{Inject, Singleton}
+import scala.concurrent.ExecutionContext
+import scala.concurrent.Future.successful
+
+import views.html._
+
+import play.api.libs.crypto.CookieSigner
+import play.api.mvc._
+
+import uk.gov.hmrc.apiplatform.modules.common.domain.models.ApplicationId
+import uk.gov.hmrc.apiplatform.modules.common.services.ClockNow
+import uk.gov.hmrc.thirdpartydeveloperfrontend.config.{ApplicationConfig, ErrorHandler}
+import uk.gov.hmrc.thirdpartydeveloperfrontend.service._
+
+@Singleton
+class ManageApplicationController @Inject() (
+ val errorHandler: ErrorHandler,
+ val applicationService: ApplicationService,
+ val applicationActionService: ApplicationActionService,
+ val sessionService: SessionService,
+ mcc: MessagesControllerComponents,
+ val cookieSigner: CookieSigner,
+ val clock: Clock,
+ applicationDetailsView: ApplicationDetailsView
+ )(implicit val ec: ExecutionContext,
+ val appConfig: ApplicationConfig
+ ) extends ApplicationController(mcc)
+ with ClockNow {
+
+ def applicationDetails(applicationId: ApplicationId): Action[AnyContent] = whenTeamMemberOnApp(applicationId) { implicit request =>
+ successful(Ok(applicationDetailsView(applicationViewModelFromApplicationRequest(), request.subscriptions)))
+ }
+}
diff --git a/app/views/ApplicationDetailsView.scala.html b/app/views/ApplicationDetailsView.scala.html
new file mode 100644
index 000000000..a7b72f92a
--- /dev/null
+++ b/app/views/ApplicationDetailsView.scala.html
@@ -0,0 +1,149 @@
+@*
+ * Copyright 2023 HM Revenue & Customs
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *@
+
+@import uk.gov.hmrc.govukfrontend.views.html.components._
+@import include._
+@import uk.gov.hmrc.thirdpartydeveloperfrontend.domain.models.controllers.Crumb
+@import uk.gov.hmrc.thirdpartydeveloperfrontend.domain.models.controllers.ApplicationViewModel
+@import uk.gov.hmrc.thirdpartydeveloperfrontend.domain.models.apidefinitions.APISubscriptionStatus
+@import uk.gov.hmrc.apiplatform.modules.tpd.session.domain.models.UserSession
+@import uk.gov.hmrc.thirdpartydeveloperfrontend.controllers.routes
+@import uk.gov.hmrc.apiplatform.modules.applications.core.domain.models._
+@import uk.gov.hmrc.apiplatform.modules.applications.access.domain.models.Access
+@import uk.gov.hmrc.apiplatform.modules.applications.submissions.domain.models.{PrivacyPolicyLocations, TermsAndConditionsLocations}
+@import uk.gov.hmrc.thirdpartydeveloperfrontend.domain.models.applications.ApplicationSyntaxes._
+@import java.util.Locale
+@import java.time.Instant
+@import java.time.ZoneId
+@import java.time.format.DateTimeFormatter
+
+@this(
+ devMain: DevMain,
+ govukTable: GovukTable
+)
+
+@(applicationViewModel: ApplicationViewModel, subscriptions: List[APISubscriptionStatus])(
+ implicit request: play.api.mvc.Request[Any],
+ loggedIn: UserSession,
+ messages: Messages,
+ appConfig: ApplicationConfig,
+ navSection: String = "details"
+)
+
+@formatMaybeDate(maybeDate: Option[Instant], dateFormatter: DateTimeFormatter, defaultString: String) = @{
+ maybeDate match {
+ case Some(date) => dateFormatter.format(date)
+ case _ => defaultString
+ }
+}
+
+@formatRedirectUris(access: Access, defaultString: String) = @{
+ access match {
+ case Access.Standard(redirectUris, _, _, _, _, _, _) => formatListOfStrings(redirectUris.toList.map(r => r.toString()), defaultString)
+ case _ => defaultString
+ }
+}
+
+@formatListOfStrings(values: List[String], defaultString: String) = @{
+ values.isEmpty match {
+ case false => values.mkString("
")
+ case true => defaultString
+ }
+}
+
+@app = @{applicationViewModel.application}
+
+@devMain(
+ title = messages("application.details"),
+ userFullName = loggedIn.loggedInName,
+ breadcrumbs = Seq(
+ Crumb.viewAllApplications,
+ Crumb.home
+ ),
+ leftNav = None,
+ developerSession = Some(loggedIn)
+) {
+
+
@messages("application.details.apis.hint")
+ + @govukTable(Table( + head = Some(Seq( + HeadCell(content = Text(messages("application.details.api.name"))) + )), + rows = subscriptions.filter(sub => sub.subscribed == true).map(subscription => Seq( + TableRow(content = HtmlContent(s"${subscription.name} ${subscription.apiVersion.versionNbr.toString()}")) + )) + )) + +@messages("application.details.authorisation.hint")
+ + @govukTable(Table( + firstCellIsHeader = true, + rows = Seq( + Seq(TableRow(content = Text(messages("application.details.client.id"))), TableRow(content = Text(app.details.token.clientId.toString()), attributes = Map("id" -> "clientId"))), + Seq(TableRow(content = Text(messages("application.details.client.secrets"))), TableRow(content = Text(""))), + Seq(TableRow(content = Text(messages("application.details.redirect.uris"))), TableRow(content = HtmlContent(formatRedirectUris(app.details.access, "None added")))), + Seq(TableRow(content = Text(messages("application.details.ip.allow.list"))), TableRow(content = HtmlContent(formatListOfStrings(app.details.ipAllowlist.allowlist.toList, "No IP addresses added")))) + ) + )) + +@messages("application.details.team.hint")
+ + @govukTable(Table( + head = Some(Seq( + HeadCell(content = Text(messages("application.details.team.email"))), + HeadCell(content = Text(messages("application.details.team.role"))) + )), + rows = app.collaborators.toList.map(collaborator => Seq( + TableRow(content = Text(collaborator.emailAddress.text)), + TableRow(content = Text(collaborator.role.displayText)) + )) + )) + +@messages("application.details.customer.hint")
+ + @govukTable(Table( + firstCellIsHeader = true, + rows = Seq( + Seq(TableRow(content = Text(messages("application.details.privacy.policy.url"))), TableRow(content = Text(app.privacyPolicyLocation.getOrElse(PrivacyPolicyLocations.NoneProvided).describe()), attributes = Map("id" -> "privacyPolicy"))), + Seq(TableRow(content = Text(messages("application.details.terms.conditions.url"))), TableRow(content = Text(app.termsAndConditionsLocation.getOrElse(TermsAndConditionsLocations.NoneProvided).describe()), attributes = Map("id" -> "termsAndConditions"))), + Seq(TableRow(content = Text(messages("application.details.grant.length"))), TableRow(content = Text(app.details.grantLength.show()), attributes = Map("id" -> "grantLength"))) + ) + )) + +} diff --git a/app/views/DashboardView.scala.html b/app/views/DashboardView.scala.html index 78500ea22..40101c56f 100644 --- a/app/views/DashboardView.scala.html +++ b/app/views/DashboardView.scala.html @@ -33,7 +33,7 @@ @(apps: Seq[ApplicationSummary], orgs: Seq[Organisation])(implicit request: play.api.mvc.Request[Any], loggedIn: UserSession, messages: Messages, appConfig: ApplicationConfig) @buildAppLinkHtml(app: ApplicationSummary) = { - @{ + @{ app.name.toString() } } diff --git a/conf/app.routes b/conf/app.routes index 7070d4436..aa85b116f 100644 --- a/conf/app.routes +++ b/conf/app.routes @@ -77,6 +77,9 @@ POST /applications/:id/details/change-terms-conditions-location GET /applications/:id/details/terms-of-use uk.gov.hmrc.thirdpartydeveloperfrontend.controllers.TermsOfUse.termsOfUse(id: ApplicationId) +GET /applications/:id/manage uk.gov.hmrc.thirdpartydeveloperfrontend.controllers.ManageApplicationController.applicationDetails(id: ApplicationId) + + # Used by DOCS Fe GET /partials/terms-of-use uk.gov.hmrc.thirdpartydeveloperfrontend.controllers.TermsOfUse.termsOfUsePartial() diff --git a/conf/messages b/conf/messages index 4b73bcf63..643a31199 100644 --- a/conf/messages +++ b/conf/messages @@ -213,3 +213,28 @@ dashboard.applications.environment=Environment dashboard.applications.viewall=View all applications dashboard.applications.create=Create a new application dashboard.organisations.your=Your organisations + +application.details=Application details +application.details.name=Application name +application.details.environment=Environment +application.details.created=Created +application.details.last.api.call=Last API call +application.details.description=Application description +application.details.apis.added=APIs added +application.details.apis.hint=These are the authenticated REST APIs you have added to your application. +application.details.api.name=Name +application.details.authorisation.details=Authorisation details +application.details.authorisation.hint=To use authenticated REST APIs you need to use your client ID and a client secret to authorise your calls. +application.details.client.id=Client ID +application.details.client.secrets=Client secrets +application.details.redirect.uris=Redirect URIs +application.details.ip.allow.list=IP allow list +application.details.team.members=Team members +application.details.team.hint=If other people need to be able to manage this application you can invite them to your team. +application.details.team.email=Email address +application.details.team.role=Role +application.details.customer.usage=Customer usage information +application.details.customer.hint=If you are building software for customers, they need to know how you intend to use their data. +application.details.privacy.policy.url=Privacy policy URL +application.details.terms.conditions.url=Terms and conditions URL +application.details.grant.length=Application grant length diff --git a/project/AppDependencies.scala b/project/AppDependencies.scala index f9c2fd0b9..aabeda8e5 100644 --- a/project/AppDependencies.scala +++ b/project/AppDependencies.scala @@ -19,7 +19,7 @@ object AppDependencies { "uk.gov.hmrc" %% "bootstrap-frontend-play-30" % bootstrapVersion, "uk.gov.hmrc" %% "play-partials-play-30" % "10.2.0", "uk.gov.hmrc" %% "domain-play-30" % "11.0.0", - "uk.gov.hmrc" %% "play-frontend-hmrc-play-30" % "12.23.0", + "uk.gov.hmrc" %% "play-frontend-hmrc-play-30" % "12.25.0", "uk.gov.hmrc.mongo" %% "hmrc-mongo-play-30" % mongoVersion, "uk.gov.hmrc" %% "crypto-json-play-30" % "8.4.0", "uk.gov.hmrc" %% "http-metrics" % "2.9.0", diff --git a/test/uk/gov/hmrc/thirdpartydeveloperfrontend/controllers/ManageApplicationControllerSpec.scala b/test/uk/gov/hmrc/thirdpartydeveloperfrontend/controllers/ManageApplicationControllerSpec.scala new file mode 100644 index 000000000..66cf292e9 --- /dev/null +++ b/test/uk/gov/hmrc/thirdpartydeveloperfrontend/controllers/ManageApplicationControllerSpec.scala @@ -0,0 +1,240 @@ +/* + * Copyright 2023 HM Revenue & Customs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package uk.gov.hmrc.thirdpartydeveloperfrontend.controllers + +import scala.concurrent.ExecutionContext.Implicits.global +import scala.concurrent.Future +import scala.concurrent.Future._ + +import org.jsoup.Jsoup +import org.mockito.captor.ArgCaptor +import views.html._ + +import play.api.libs.json.{Json, OFormat} +import play.api.mvc.Result +import play.api.test.Helpers._ + +import uk.gov.hmrc.apiplatform.modules.applications.access.domain.models.Access +import uk.gov.hmrc.apiplatform.modules.applications.common.domain.models.FullName +import uk.gov.hmrc.apiplatform.modules.applications.core.domain.models._ +import uk.gov.hmrc.apiplatform.modules.applications.core.interface.models._ +import uk.gov.hmrc.apiplatform.modules.applications.submissions.domain.models._ +import uk.gov.hmrc.apiplatform.modules.commands.applications.domain.models.ApplicationCommand +import uk.gov.hmrc.apiplatform.modules.common.domain.models.ApplicationId +import uk.gov.hmrc.apiplatform.modules.common.domain.models.LaxEmailAddress.StringSyntax +import uk.gov.hmrc.apiplatform.modules.submissions.SubmissionsTestData +import uk.gov.hmrc.apiplatform.modules.submissions.services.mocks.SubmissionServiceMockModule +import uk.gov.hmrc.apiplatform.modules.tpd.session.domain.models.UserSession +import uk.gov.hmrc.thirdpartydeveloperfrontend.domain._ +import uk.gov.hmrc.thirdpartydeveloperfrontend.mocks.service._ +import uk.gov.hmrc.thirdpartydeveloperfrontend.utils.ViewHelpers._ +import uk.gov.hmrc.thirdpartydeveloperfrontend.utils.WithCSRFAddToken + +class ManageApplicationControllerSpec + extends BaseControllerSpec + with WithCSRFAddToken + with SubmissionsTestData + with ApplicationWithCollaboratorsFixtures { + + val approvedApplication = standardApp.withAccess(standardAccessOne).modify(_.copy(description = Some("Some App Description"))) + val sandboxApplication = approvedApplication.inSandbox() + val inTestingApp = approvedApplication.withState(appStateTesting) + + "details" when { + "logged in as a Developer on an application" should { + + "return the view for a standard production app with no change link" in new Setup { + detailsShouldRenderThePage(devSession)(approvedApplication) + } + } + + "logged in as an Administrator on an application" should { + "return the view for a standard production app" in new Setup { + SubmissionServiceMock.FetchLatestSubmission.thenReturns(aSubmission) + detailsShouldRenderThePage(adminSession)(approvedApplication) + } + } + + "not a team member on an application" should { + "return see other" in new Setup { + val application = approvedApplication + givenApplicationAction(application, altDevSession) + + val result = application.callDetails + + status(result) shouldBe SEE_OTHER + } + } + + "not logged in" should { + "redirect to login" in new Setup { + val application = approvedApplication + givenApplicationAction(application, devSession) + + val result = application.callDetailsNotLoggedIn + + redirectsToLogin(result) + } + } + } + + trait Setup + extends ApplicationServiceMock + with ApplicationActionServiceMock + with SubmissionServiceMockModule + with TermsOfUseServiceMock { + + val detailsView = app.injector.instanceOf[ApplicationDetailsView] + + val underTest = new ManageApplicationController( + mockErrorHandler, + applicationServiceMock, + applicationActionServiceMock, + sessionServiceMock, + mcc, + cookieSigner, + clock, + detailsView + ) + + val newName = ApplicationName("new name") + val newDescription = Some("new description") + val newTermsUrl = Some("http://example.com/new-terms") + val newPrivacyUrl = Some("http://example.com/new-privacy") + + val termsAndConditionsUrl = "http://example.com/terms-conds" + val privacyPolicyUrl = "http://example.com/priv-policy" + + when(underTest.applicationService.isApplicationNameValid(*, *, *)(*)) + .thenReturn(Future.successful(ApplicationNameValidationResult.Valid)) + + when(underTest.applicationService.dispatchCmd(*[ApplicationId], *)(*)) + .thenReturn(successful(ApplicationUpdateSuccessful)) + + def legacyAppWithTermsAndConditionsLocation(maybeTermsAndConditionsUrl: Option[String]) = + standardApp.withAccess(Access.Standard(List.empty, List.empty, maybeTermsAndConditionsUrl, None, Set.empty, None, None)) + + def legacyAppWithPrivacyPolicyLocation(maybePrivacyPolicyUrl: Option[String]) = + standardApp.withAccess(Access.Standard(List.empty, List.empty, None, maybePrivacyPolicyUrl, Set.empty, None, None)) + + def appWithTermsAndConditionsLocation(termsAndConditionsLocation: TermsAndConditionsLocation) = standardApp.withAccess( + Access.Standard( + List.empty, + List.empty, + None, + None, + Set.empty, + None, + Some( + ImportantSubmissionData( + None, + ResponsibleIndividual(FullName("bob example"), "bob@example.com".toLaxEmail), + Set.empty, + termsAndConditionsLocation, + PrivacyPolicyLocations.InDesktopSoftware, + List.empty + ) + ) + ) + ) + + def appWithPrivacyPolicyLocation(privacyPolicyLocation: PrivacyPolicyLocation) = standardApp.withAccess( + Access.Standard( + List.empty, + List.empty, + None, + None, + Set.empty, + None, + Some( + ImportantSubmissionData( + None, + ResponsibleIndividual(FullName("bob example"), "bob@example.com".toLaxEmail), + Set.empty, + TermsAndConditionsLocations.InDesktopSoftware, + privacyPolicyLocation, + List.empty + ) + ) + ) + ) + + def captureApplicationCmd: ApplicationCommand = { + val captor = ArgCaptor[ApplicationCommand] + verify(underTest.applicationService).dispatchCmd(*[ApplicationId], captor)(*) + captor.value + } + + def captureAllApplicationCmds: List[ApplicationCommand] = { + val captor = ArgCaptor[ApplicationCommand] + verify(underTest.applicationService, atLeast(1)).dispatchCmd(*[ApplicationId], captor)(*) + captor.values + } + + def redirectsToLogin(result: Future[Result]) = { + status(result) shouldBe SEE_OTHER + redirectLocation(result) shouldBe Some(routes.UserLoginAccount.login().url) + } + + def detailsShouldRenderThePage(userSession: UserSession)(application: ApplicationWithCollaborators) = { + givenApplicationAction(application, userSession) + + returnAgreementDetails() + + val result = application.callDetails + + status(result) shouldBe OK + val doc = Jsoup.parse(contentAsString(result)) + withClue("name")(elementIdentifiedByIdContainsText(doc, "applicationName", application.name.value) shouldBe true) + withClue("environment")(elementIdentifiedByIdContainsText(doc, "environment", application.details.deployedTo.displayText) shouldBe true) + withClue("description")(elementIdentifiedByIdContainsText(doc, "description", application.details.description.getOrElse("None")) shouldBe true) + withClue("clientId")(elementIdentifiedByIdContainsText(doc, "clientId", application.details.token.clientId.value) shouldBe true) + withClue("privacyPolicy")(elementIdentifiedByIdContainsText( + doc, + "privacyPolicy", + application.details.privacyPolicyLocation.getOrElse(PrivacyPolicyLocations.NoneProvided).describe() + ) shouldBe true) + withClue("termsAndConditions")(elementIdentifiedByIdContainsText( + doc, + "termsAndConditions", + application.details.termsAndConditionsLocation.getOrElse(TermsAndConditionsLocations.NoneProvided).describe() + ) shouldBe true) + withClue("grantLength")(elementIdentifiedByIdContainsText(doc, "grantLength", application.details.grantLength.show()) shouldBe true) + } + + implicit class AppAugment(val app: ApplicationWithCollaborators) { + final def withDescription(description: Option[String]): ApplicationWithCollaborators = app.modify(_.copy(description = description)) + + final def withTermsAndConditionsUrl(url: Option[String]): ApplicationWithCollaborators = app.withAccess(standardAccess.copy(termsAndConditionsUrl = url)) + + final def withPrivacyPolicyUrl(url: Option[String]): ApplicationWithCollaborators = app.withAccess(standardAccess.copy(privacyPolicyUrl = url)) + } + + implicit val format: OFormat[EditApplicationForm] = Json.format[EditApplicationForm] + + implicit class ChangeDetailsAppAugment(val app: ApplicationWithCollaborators) { + private val appAccess = app.access.asInstanceOf[Access.Standard] + + final def toForm = + EditApplicationForm(app.id, app.details.name.value, app.details.description, appAccess.privacyPolicyUrl, appAccess.termsAndConditionsUrl, app.details.grantLength.show()) + + final def callDetails: Future[Result] = underTest.applicationDetails(app.id)(loggedInDevRequest) + + final def callDetailsNotLoggedIn: Future[Result] = underTest.applicationDetails(app.id)(loggedOutRequest) + } + } +}