diff --git a/daikoku/app/controllers/AdminApiController.scala b/daikoku/app/controllers/AdminApiController.scala index 2a85859b2..1cbb41249 100644 --- a/daikoku/app/controllers/AdminApiController.scala +++ b/daikoku/app/controllers/AdminApiController.scala @@ -330,7 +330,9 @@ class StateAdminApiController( AppError.SecurityError("Action not avalaible") ) _ <- EitherT.liftF[Future, AppError, Unit](env.dataStore.clear()) - _ <- EitherT.liftF[Future, AppError, Done](env.initDatastore(ctx.request.getQueryString("path"))) + _ <- EitherT.liftF[Future, AppError, Done]( + env.initDatastore(ctx.request.getQueryString("path")) + ) } yield Ok(Json.obj("done" -> true))) .leftMap(_.render()) .merge diff --git a/daikoku/app/controllers/ApiController.scala b/daikoku/app/controllers/ApiController.scala index 6405921e6..ca2bd2b57 100644 --- a/daikoku/app/controllers/ApiController.scala +++ b/daikoku/app/controllers/ApiController.scala @@ -28,7 +28,7 @@ import fr.maif.otoroshi.daikoku.domain._ import fr.maif.otoroshi.daikoku.domain.json._ import fr.maif.otoroshi.daikoku.env.Env import fr.maif.otoroshi.daikoku.logger.AppLogger -import fr.maif.otoroshi.daikoku.utils.Cypher.decrypt +import fr.maif.otoroshi.daikoku.utils.Cypher.{decrypt, encrypt} import fr.maif.otoroshi.daikoku.utils.RequestImplicits.EnhancedRequestHeader import fr.maif.otoroshi.daikoku.utils.StringImplicits.BetterString import fr.maif.otoroshi.daikoku.utils._ @@ -2077,6 +2077,90 @@ class ApiController( } } + def checkTransferLink() = + DaikokuActionMaybeWithGuest.async { ctx => + PublicUserAccess( + AuditTrailEvent("@{user.name} has check a transfer link for @{subscription.id}") + )(ctx) { + + (for { + cypheredInfos <- EitherT.fromOption[Future][AppError, String](ctx.request.getQueryString("token"), AppError.EntityNotFound("token")) + transferToken <- EitherT.pure[Future, AppError](decrypt(env.config.cypherSecret, cypheredInfos, ctx.tenant)) + transfer <- EitherT.fromOptionF[Future, AppError, ApiSubscriptionTransfer](env.dataStore.apiSubscriptionTransferRepo.forTenant(ctx.tenant).findOneNotDeleted(Json.obj("token" -> transferToken)), + AppError.Unauthorized) + _ <- EitherT.cond[Future][AppError, Unit](transfer.date.plusDays(1).isAfter(DateTime.now()), (), AppError.ForbiddenAction) //give reason + subscription <- EitherT.fromOptionF[Future, AppError, ApiSubscription](env.dataStore.apiSubscriptionRepo.forTenant(ctx.tenant).findByIdNotDeleted(transfer.subscription), AppError.SubscriptionNotFound) + team <- EitherT.fromOptionF[Future, AppError, Team](env.dataStore.teamRepo.forTenant(ctx.tenant).findByIdNotDeleted(subscription.team), AppError.TeamNotFound) + usagePlan <- EitherT.fromOptionF[Future, AppError, UsagePlan](env.dataStore.usagePlanRepo.forTenant(ctx.tenant).findByIdNotDeleted(subscription.plan), AppError.PlanNotFound) + api <- EitherT.fromOptionF[Future, AppError, Api](env.dataStore.apiRepo.forTenant(ctx.tenant).findByIdNotDeleted(subscription.api), AppError.ApiNotFound) + } yield { + ctx.setCtxValue("subscription.id", subscription.id.value) + Ok(Json.obj( + "subscription" -> subscription.asJson, + "api" -> api.asJson, + "plan" -> usagePlan.asJson, + "ownerTeam" -> team.asJson + )) + }) + .leftMap(_.render()) + .merge + } + } + + def getTransferLink(teamId: String, subscriptionId: String) = + DaikokuAction.async { ctx => + TeamAdminOnly( + AuditTrailEvent(s"@{user.name} has generated a link to transfer subscription @{subscription.id}"))(teamId, ctx) { team => { + + ctx.setCtxValue("subscription.id", subscriptionId) + (for { + subscription <- EitherT.fromOptionF[Future, AppError, ApiSubscription](env.dataStore.apiSubscriptionRepo.forTenant(ctx.tenant).findByIdOrHrIdNotDeleted(subscriptionId), + AppError.SubscriptionNotFound) + _ <- EitherT.cond[Future][AppError, Unit](subscription.parent.isEmpty, (), AppError.EntityConflict("Subscription is part of aggregation")) + + transfer = ApiSubscriptionTransfer( + id = DatastoreId(IdGenerator.token(16)), + tenant = ctx.tenant.id, + token = IdGenerator.token, + subscription = subscription.id, + by = ctx.user.id, + date = DateTime.now() + ) + cipheredToken = encrypt(env.config.cypherSecret, transfer.token, ctx.tenant) + _ <- EitherT.liftF[Future, AppError, Boolean](env.dataStore.apiSubscriptionTransferRepo.forTenant(ctx.tenant).delete(Json.obj("subscription" -> subscription.id.asJson))) + _ <- EitherT.liftF[Future, AppError, Boolean](env.dataStore.apiSubscriptionTransferRepo.forTenant(ctx.tenant).save(transfer)) + link <- EitherT.pure[Future, AppError](s"${env.getDaikokuUrl(ctx.tenant, "/subscriptions/_retrieve")}?token=$cipheredToken") + } yield Ok(Json.obj("link" -> link))) + .leftMap(_.render()) + .merge + } + } + } + + def transferSubscription(teamId: String, subscriptionId: String) = + DaikokuAction.async(parse.json) { ctx => + TeamAdminOnly( + AuditTrailEvent(s"@{user.name} has ask to transfer subscription @{subscriptionId} to team @{teamId}"))(teamId, ctx) { team => { + + (for { + extract <- apiService.checkAndExtractTransferLink(ctx.tenant, subscriptionId, (ctx.request.body \ "token").as[String], team) + otoroshiSettings <- EitherT.fromOption[Future][AppError, OtoroshiSettings](extract.plan.otoroshiTarget.flatMap(target => ctx.tenant.otoroshiSettings.find(_.id == target.otoroshiSettings)), AppError.EntityNotFound("Otoroshi settings")) + _ <- apiService.transferSubscription( + newTeam = team, + subscription = extract.subscription, + childs = extract.childSubscriptions, + tenant = ctx.tenant, + user = ctx.user, + api = extract.api, + plan = extract.plan, + otoroshiSettings = otoroshiSettings) + } yield Ok(Json.obj("done" -> true))) + .leftMap(_.render()) + .merge + } + } + } + def makeUniqueSubscription(teamId: String, subscriptionId: String) = DaikokuAction.async { ctx => TeamApiKeyAction( @@ -3312,13 +3396,21 @@ class ApiController( if (ctx.user.isDaikokuAdmin) Json.obj() else Json.obj("users.userId" -> ctx.user.id.value) + val typeFilter = if (ctx.tenant.subscriptionSecurity.isDefined + && ctx.tenant.subscriptionSecurity.exists(identity)) { + Json.obj( + "type" -> Json.obj("$ne" -> TeamType.Personal.name) + ) + } else { + Json.obj() + } for { myTeams <- env.dataStore.teamRepo.myTeams(ctx.tenant, ctx.user) teams <- env.dataStore.teamRepo .forTenant(ctx.tenant.id) .findNotDeleted( - Json.obj("name" -> searchAsRegex) ++ teamUsersFilter, + Json.obj("name" -> searchAsRegex) ++ teamUsersFilter ++ typeFilter, 5, Json.obj("name" -> 1).some ) diff --git a/daikoku/app/controllers/AppError.scala b/daikoku/app/controllers/AppError.scala index 11d46e2c6..c2b4f797c 100644 --- a/daikoku/app/controllers/AppError.scala +++ b/daikoku/app/controllers/AppError.scala @@ -96,18 +96,19 @@ object AppError { case ApiNotLinked => BadRequest(toJson(error)) case UserNotTeamAdmin(userId, teamId) => play.api.mvc.Results.Unauthorized(toJson(error)) - case OtoroshiError(e) => BadRequest(e) - case PaymentError(_) => BadRequest(toJson(error)) - case SubscriptionConflict => Conflict(toJson(error)) - case ApiKeyRotationConflict => Conflict(toJson(error)) - case EntityConflict(_) => Conflict(toJson(error)) - case ApiKeyRotationError(e) => BadRequest(e) - case ForbiddenAction => Forbidden(toJson(error)) - case ApiKeyCustomMetadataNotPrivided => BadRequest(toJson(error)) - case SubscriptionNotFound => NotFound(toJson(error)) - case SubscriptionParentExisted => Conflict(toJson(error)) - case SubscriptionAggregationDisabled => BadRequest(toJson(error)) - case EnvironmentSubscriptionAggregationDisabled => BadRequest(toJson(error)) + case OtoroshiError(e) => BadRequest(e) + case PaymentError(_) => BadRequest(toJson(error)) + case SubscriptionConflict => Conflict(toJson(error)) + case ApiKeyRotationConflict => Conflict(toJson(error)) + case EntityConflict(_) => Conflict(toJson(error)) + case ApiKeyRotationError(e) => BadRequest(e) + case ForbiddenAction => Forbidden(toJson(error)) + case ApiKeyCustomMetadataNotPrivided => BadRequest(toJson(error)) + case SubscriptionNotFound => NotFound(toJson(error)) + case SubscriptionParentExisted => Conflict(toJson(error)) + case SubscriptionAggregationDisabled => BadRequest(toJson(error)) + case EnvironmentSubscriptionAggregationDisabled => + BadRequest(toJson(error)) case SubscriptionAggregationTeamConflict => Conflict(toJson(error)) case SubscriptionAggregationOtoroshiConflict => Conflict(toJson(error)) case MissingParentSubscription => NotFound(toJson(error)) diff --git a/daikoku/app/controllers/NotificationController.scala b/daikoku/app/controllers/NotificationController.scala index 28d1ff001..03e889791 100644 --- a/daikoku/app/controllers/NotificationController.scala +++ b/daikoku/app/controllers/NotificationController.scala @@ -386,7 +386,7 @@ class NotificationController( AuditTrailEvent( s"@{user.name} has rejected a notifications for team @{team.name} - @{team.id} => @{notification.id}" ) - )(teamId.value, ctx) { _ => + )(teamId.value, ctx) { team => { ctx.setCtxValue("notification.id", notification.id) diff --git a/daikoku/app/domain/CommonServices.scala b/daikoku/app/domain/CommonServices.scala index 9c0b03ccb..063b06c12 100644 --- a/daikoku/app/domain/CommonServices.scala +++ b/daikoku/app/domain/CommonServices.scala @@ -648,19 +648,34 @@ object CommonServices { env: Env, ec: ExecutionContext ) = { + + val typeFilter = + if ( + ctx.tenant.subscriptionSecurity.isDefined + && ctx.tenant.subscriptionSecurity.exists(identity) + ) { + Json.obj( + "type" -> Json.obj("$ne" -> TeamType.Personal.name) + ) + } else { + Json.obj() + } _UberPublicUserAccess( AuditTrailEvent("@{user.name} has accessed his team list") )(ctx) { (if (ctx.user.isDaikokuAdmin) env.dataStore.teamRepo .forTenant(ctx.tenant) - .findAllNotDeleted() + .findNotDeleted(typeFilter) else env.dataStore.teamRepo .forTenant(ctx.tenant) - .findNotDeleted(Json.obj("users.userId" -> ctx.user.id.value))) + .findNotDeleted( + Json.obj("users.userId" -> ctx.user.id.value) ++ typeFilter + )) .map(teams => - teams.sortWith((a, b) => a.name.compareToIgnoreCase(b.name) < 0) + teams + .sortWith((a, b) => a.name.compareToIgnoreCase(b.name) < 0) ) } } @@ -673,6 +688,17 @@ object CommonServices { _TenantAdminAccessTenant( AuditTrailEvent("@{user.name} has accessed to all teams list") )(ctx) { + val typeFilter = + if ( + ctx.tenant.subscriptionSecurity.isDefined + && ctx.tenant.subscriptionSecurity.exists(identity) + ) { + Json.obj( + "type" -> TeamType.Organization.name + ) + } else { + Json.obj() + } for { teams <- env.dataStore.teamRepo @@ -681,7 +707,7 @@ object CommonServices { Json.obj( "_deleted" -> false, "name" -> Json.obj("$regex" -> research) - ), + ) ++ typeFilter, offset, limit, Some(Json.obj("_humanReadableId" -> 1)) diff --git a/daikoku/app/domain/SchemaDefinition.scala b/daikoku/app/domain/SchemaDefinition.scala index 0064193ac..20a35b2f6 100644 --- a/daikoku/app/domain/SchemaDefinition.scala +++ b/daikoku/app/domain/SchemaDefinition.scala @@ -248,7 +248,8 @@ object SchemaDefinition { Field( "environmentAggregationApiKeysSecurity", OptionType(BooleanType), - resolve = _.value.environmentAggregationApiKeysSecurity), + resolve = _.value.environmentAggregationApiKeysSecurity + ), Field( "display", OptionType(StringType), diff --git a/daikoku/app/domain/apikeyEntities.scala b/daikoku/app/domain/apikeyEntities.scala index a6417040f..7f4259058 100644 --- a/daikoku/app/domain/apikeyEntities.scala +++ b/daikoku/app/domain/apikeyEntities.scala @@ -344,3 +344,15 @@ case class StepValidator( ) extends CanJson[StepValidator] { override def asJson: JsValue = json.StepValidatorFormat.writes(this) } + +case class ApiSubscriptionTransfer( + id: DatastoreId, + tenant: TenantId, + deleted: Boolean = false, + token: String, + subscription: ApiSubscriptionId, + date: DateTime, + by: UserId +) extends CanJson[ApiSubscriptionTransfer] { + override def asJson: JsValue = json.ApiSubscriptionTransferFormat.writes(this) +} diff --git a/daikoku/app/domain/json.scala b/daikoku/app/domain/json.scala index 66c456ba2..2f050afd3 100644 --- a/daikoku/app/domain/json.scala +++ b/daikoku/app/domain/json.scala @@ -1007,7 +1007,7 @@ object json { otoroshiTarget = (json \ "otoroshiTarget").asOpt(OtoroshiTargetFormat), aggregationApiKeysSecurity = - (json \ "aggregationApiKeysSecurity").asOpt[Boolean], + (json \ "aggregationApiKeysSecurity").asOpt[Boolean] ) ) } recover { @@ -2205,8 +2205,9 @@ object json { tenantMode = (json \ "tenantMode").asOpt(TenantModeFormat), aggregationApiKeysSecurity = (json \ "aggregationApiKeysSecurity") .asOpt[Boolean], - environmentAggregationApiKeysSecurity = (json \ "environmentAggregationApiKeysSecurity") - .asOpt[Boolean], + environmentAggregationApiKeysSecurity = + (json \ "environmentAggregationApiKeysSecurity") + .asOpt[Boolean], robotTxt = (json \ "robotTxt").asOpt[String], thirdPartyPaymentSettings = (json \ "thirdPartyPaymentSettings") .asOpt(SeqThirdPartyPaymentSettingsFormat) @@ -3230,6 +3231,8 @@ object json { case "NewIssueOpen" => NewIssueOpenFormat.reads(json) case "NewCommentOnIssue" => NewCommentOnIssueFormat.reads(json) case "TransferApiOwnership" => TransferApiOwnershipFormat.reads(json) + case "ApiSubscriptionTransferSuccess" => + ApiSubscriptionTransferSuccessFormat.reads(json) case "CheckoutForSubscription" => CheckoutForSubscriptionFormat.reads(json) case str => JsError(s"Bad notification value: $str") @@ -3249,6 +3252,11 @@ object json { ApiSubscriptionDemandFormat.writes(p).as[JsObject] ++ Json.obj( "type" -> "ApiSubscription" ) + case p: ApiSubscriptionTransferSuccess => + ApiSubscriptionTransferSuccessFormat.writes(p).as[JsObject] ++ Json + .obj( + "type" -> "ApiSubscriptionTransferSuccess" + ) case p: ApiSubscriptionReject => ApiSubscriptionRejectFormat.writes(p).as[JsObject] ++ Json.obj( "type" -> "ApiSubscriptionReject" @@ -3492,6 +3500,27 @@ object json { .as[JsValue] ) } + val ApiSubscriptionTransferSuccessFormat = + new Format[ApiSubscriptionTransferSuccess] { + + override def reads( + json: JsValue + ): JsResult[ApiSubscriptionTransferSuccess] = + Try { + JsSuccess( + ApiSubscriptionTransferSuccess( + subscription = (json \ "subscription").as(ApiSubscriptionIdFormat) + ) + ) + } recover { + case e => JsError(e.getMessage) + } get + + override def writes(o: ApiSubscriptionTransferSuccess): JsValue = + Json.obj( + "subscription" -> o.subscription.asJson + ) + } val ApiSubscriptionRejectFormat = new Format[ApiSubscriptionReject] { override def reads(json: JsValue): JsResult[ApiSubscriptionReject] = Try { @@ -4749,6 +4778,38 @@ object json { ) } + val ApiSubscriptionTransferFormat = new Format[ApiSubscriptionTransfer] { + + override def reads(json: JsValue): JsResult[ApiSubscriptionTransfer] = + Try { + ApiSubscriptionTransfer( + id = (json \ "_id").as(DatastoreIdFormat), + tenant = (json \ "_tenant").as(TenantIdFormat), + deleted = (json \ "_deleted").as[Boolean], + token = (json \ "token").as[String], + subscription = (json \ "subscription").as(ApiSubscriptionIdFormat), + by = (json \ "by").as(UserIdFormat), + date = (json \ "date").as(DateTimeFormat) + ) + } match { + case Failure(e) => + AppLogger.error(e.getMessage, e) + JsError(e.getMessage) + case Success(value) => JsSuccess(value) + } + + override def writes(o: ApiSubscriptionTransfer): JsValue = + Json.obj( + "_id" -> o.id.asJson, + "_tenant" -> o.tenant.asJson, + "_deleted" -> o.deleted, + "token" -> o.token, + "subscription" -> o.subscription.asJson, + "by" -> o.by.asJson, + "date" -> DateTimeFormat.writes(o.date) + ) + } + val SetOtoroshiServicesIdFormat = Format( Reads.set(OtoroshiServiceIdFormat), diff --git a/daikoku/app/domain/teamEntities.scala b/daikoku/app/domain/teamEntities.scala index 73421b6d1..f78c3ebdd 100644 --- a/daikoku/app/domain/teamEntities.scala +++ b/daikoku/app/domain/teamEntities.scala @@ -227,6 +227,10 @@ object NotificationAction { motivation: Option[String] ) extends NotificationAction + case class ApiSubscriptionTransferSuccess( + subscription: ApiSubscriptionId + ) extends NotificationAction + case class OtoroshiSyncSubscriptionError( subscription: ApiSubscription, message: String diff --git a/daikoku/app/env/env.scala b/daikoku/app/env/env.scala index 76a9e3b01..c0e77a4e3 100644 --- a/daikoku/app/env/env.scala +++ b/daikoku/app/env/env.scala @@ -306,7 +306,9 @@ sealed trait Env { def getDaikokuUrl(tenant: Tenant, path: String): String - def initDatastore(path: Option[String]= None)(implicit ec: ExecutionContext): Future[Done] + def initDatastore(path: Option[String] = None)(implicit + ec: ExecutionContext + ): Future[Done] } class DaikokuEnv( @@ -376,14 +378,16 @@ class DaikokuEnv( } } - override def initDatastore(path: Option[String]=None)(implicit ec: ExecutionContext): Future[Done] = { + override def initDatastore( + path: Option[String] = None + )(implicit ec: ExecutionContext): Future[Done] = { def run(isEmpty: Boolean): Future[Unit] = { if (isEmpty) { (dataStore match { case store: PostgresDataStore => store.checkDatabase() case _ => FastFuture.successful(None) }).map { _ => - path.orElse(config.init.data.from) match { + path.orElse(config.init.data.from) match { case Some(path) if path.startsWith("http://") || path .startsWith("https://") => diff --git a/daikoku/app/storage/api.scala b/daikoku/app/storage/api.scala index fa054245f..0081e59af 100644 --- a/daikoku/app/storage/api.scala +++ b/daikoku/app/storage/api.scala @@ -395,20 +395,37 @@ trait UserRepo extends Repo[User, UserId] trait EvolutionRepo extends Repo[Evolution, DatastoreId] trait ReportsInfoRepo extends Repo[ReportsInfo, DatastoreId] +trait ApiSubscriptionTransferRepo + extends TenantCapableRepo[ApiSubscriptionTransfer, DatastoreId] + trait TeamRepo extends TenantCapableRepo[Team, TeamId] { def myTeams(tenant: Tenant, user: User)(implicit env: Env, ec: ExecutionContext ): Future[Seq[Team]] = { + val typeFilter = + if ( + tenant.subscriptionSecurity.isDefined + && tenant.subscriptionSecurity.exists(identity) + ) { + Json.obj( + "type" -> Json.obj("$ne" -> TeamType.Personal.name) + ) + } else { + Json.obj() + } if (user.isDaikokuAdmin) { env.dataStore.teamRepo .forTenant(tenant.id) - .findAllNotDeleted() + .findNotDeleted( + typeFilter + ) + } else { env.dataStore.teamRepo .forTenant(tenant.id) .findNotDeleted( - Json.obj("users.userId" -> user.id.value) + Json.obj("users.userId" -> user.id.value) ++ typeFilter ) } } @@ -571,6 +588,8 @@ trait DataStore { def reportsInfoRepo: ReportsInfoRepo + def apiSubscriptionTransferRepo: ApiSubscriptionTransferRepo + def exportAsStream(pretty: Boolean, exportAuditTrail: Boolean = true)(implicit ec: ExecutionContext, mat: Materializer, diff --git a/daikoku/app/storage/drivers/postgres/PostgresDataStore.scala b/daikoku/app/storage/drivers/postgres/PostgresDataStore.scala index ab497796f..fc0f03dbb 100644 --- a/daikoku/app/storage/drivers/postgres/PostgresDataStore.scala +++ b/daikoku/app/storage/drivers/postgres/PostgresDataStore.scala @@ -286,6 +286,23 @@ case class PostgresTenantCapableUsagePlanRepo( _tenantRepo(tenant) } +case class PostgresTenantCapableApiSubscriptionTransferRepo( + _repo: () => PostgresRepo[ApiSubscriptionTransfer, DatastoreId], + _tenantRepo: TenantId => PostgresTenantAwareRepo[ + ApiSubscriptionTransfer, + DatastoreId + ] +) extends PostgresTenantCapableRepo[ApiSubscriptionTransfer, DatastoreId] + with ApiSubscriptionTransferRepo { + override def repo(): PostgresRepo[ApiSubscriptionTransfer, DatastoreId] = + _repo() + + override def tenantRepo( + tenant: TenantId + ): PostgresTenantAwareRepo[ApiSubscriptionTransfer, DatastoreId] = + _tenantRepo(tenant) +} + case class PostgresTenantCapableConsumptionRepo( _repo: () => PostgresRepo[ApiKeyConsumption, DatastoreId], _tenantRepo: TenantId => PostgresTenantAwareRepo[ @@ -413,7 +430,8 @@ class PostgresDataStore(configuration: Configuration, env: Env, pgPool: PgPool) "step_validators" -> true, "usage_plans" -> true, "assets" -> true, - "reports_info" -> true + "reports_info" -> true, + "api_subscription_transfers" -> true ) private lazy val poolOptions: PoolOptions = new PoolOptions() @@ -614,6 +632,12 @@ class PostgresDataStore(configuration: Configuration, env: Env, pgPool: PgPool) t => new PostgresTenantUsagePlanRepo(env, reactivePg, t) ) + private val _apiSubscriptionTransferRepo: ApiSubscriptionTransferRepo = + PostgresTenantCapableApiSubscriptionTransferRepo( + () => new PostgresApiSubscriptionTransferRepo(env, reactivePg), + t => new PostgresTenantApiSubscriptionTransferRepo(env, reactivePg, t) + ) + override def tenantRepo: TenantRepo = _tenantRepo override def userRepo: UserRepo = _userRepo @@ -666,6 +690,9 @@ class PostgresDataStore(configuration: Configuration, env: Env, pgPool: PgPool) override def usagePlanRepo: UsagePlanRepo = _usagePlanRepo + override def apiSubscriptionTransferRepo: ApiSubscriptionTransferRepo = + _apiSubscriptionTransferRepo + override def start(): Future[Unit] = { Future.successful(()) } @@ -788,7 +815,8 @@ class PostgresDataStore(configuration: Configuration, env: Env, pgPool: PgPool) assetRepo.forAllTenant(), stepValidatorRepo.forAllTenant(), subscriptionDemandRepo.forAllTenant(), - usagePlanRepo.forAllTenant() + usagePlanRepo.forAllTenant(), + apiSubscriptionTransferRepo.forAllTenant() ) if (exportAuditTrail) { @@ -912,6 +940,10 @@ class PostgresDataStore(configuration: Configuration, env: Env, pgPool: PgPool) emailVerificationRepo .forAllTenant() .save(json.EmailVerificationFormat.reads(payload).get) + case ("apisubscriptiontransfers", payload) => + apiSubscriptionTransferRepo + .forAllTenant() + .save(json.ApiSubscriptionTransferFormat.reads(payload).get) case (typ, _) => logger.error(s"Unknown type: $typ") FastFuture.successful(false) @@ -1108,6 +1140,25 @@ class PostgresTenantUsagePlanRepo( override def extractId(value: UsagePlan): String = value.id.value } +class PostgresTenantApiSubscriptionTransferRepo( + env: Env, + reactivePg: ReactivePg, + tenant: TenantId +) extends PostgresTenantAwareRepo[ApiSubscriptionTransfer, DatastoreId]( + env, + reactivePg, + tenant + ) { + + override def tableName: String = "api_subscription_transfers" + + override def format: Format[ApiSubscriptionTransfer] = + json.ApiSubscriptionTransferFormat + + override def extractId(value: ApiSubscriptionTransfer): String = + value.id.value +} + class PostgresTenantCmsPageRepo( env: Env, reactivePg: ReactivePg, @@ -1372,6 +1423,20 @@ class PostgresUsagePlanRepo(env: Env, reactivePg: ReactivePg) override def extractId(value: UsagePlan): String = value.id.value } +class PostgresApiSubscriptionTransferRepo(env: Env, reactivePg: ReactivePg) + extends PostgresRepo[ApiSubscriptionTransfer, DatastoreId]( + env, + reactivePg + ) { + override def tableName: String = "api_subscription_transfers" + + override def format: Format[ApiSubscriptionTransfer] = + json.ApiSubscriptionTransferFormat + + override def extractId(value: ApiSubscriptionTransfer): String = + value.id.value +} + class PostgresApiRepo(env: Env, reactivePg: ReactivePg) extends PostgresRepo[Api, ApiId](env, reactivePg) { override def tableName: String = "apis" diff --git a/daikoku/app/utils/ApiService.scala b/daikoku/app/utils/ApiService.scala index f438c81d6..c93bb6e0e 100644 --- a/daikoku/app/utils/ApiService.scala +++ b/daikoku/app/utils/ApiService.scala @@ -15,7 +15,7 @@ import fr.maif.otoroshi.daikoku.domain.UsagePlan._ import fr.maif.otoroshi.daikoku.domain._ import fr.maif.otoroshi.daikoku.env.Env import fr.maif.otoroshi.daikoku.logger.AppLogger -import fr.maif.otoroshi.daikoku.utils.Cypher.encrypt +import fr.maif.otoroshi.daikoku.utils.Cypher.{decrypt, encrypt} import fr.maif.otoroshi.daikoku.utils.StringImplicits._ import fr.maif.otoroshi.daikoku.utils.future.EnhancedObject import jobs.{ApiKeyStatsJob, OtoroshiVerifierJob} @@ -2335,11 +2335,13 @@ class ApiService( ) _ <- EitherT.cond[Future][AppError, Unit]( tenant.display != TenantDisplay.Environment || (tenant.environmentAggregationApiKeysSecurity match { - case Some(true) => plan.customName == parentPlan.customName - case _ => true + case Some(true) => plan.customName == parentPlan.customName + case _ => true }), (), - AppError.SecurityError(s"Environment Subscription Aggregation security is enabled, a subscription cannot be extended by another environment") + AppError.SecurityError( + s"Environment Subscription Aggregation security is enabled, a subscription cannot be extended by another environment" + ) ) _ <- EitherT.cond[Future][AppError, Unit]( parentPlan.otoroshiTarget @@ -2612,4 +2614,188 @@ class ApiService( } yield Ok(Json.obj("creation" -> "refused")) } + case class ExtractTransferLink( + subscription: ApiSubscription, + childSubscriptions: Seq[ApiSubscription], + plan: UsagePlan, + api: Api + ) + + def checkAndExtractTransferLink( + tenant: Tenant, + subscriptionId: String, + token: String, + team: Team + ): EitherT[Future, AppError, ExtractTransferLink] = + for { + transferToken <- EitherT.pure[Future, AppError]( + decrypt(env.config.cypherSecret, token, tenant) + ) + transfer <- + EitherT.fromOptionF[Future, AppError, ApiSubscriptionTransfer]( + env.dataStore.apiSubscriptionTransferRepo + .forTenant(tenant) + .findOneNotDeleted(Json.obj("token" -> transferToken)), + AppError.Unauthorized + ) + _ <- EitherT.cond[Future][AppError, Unit]( + transfer.subscription.value == subscriptionId, + (), + AppError.EntityConflict("Subscription") + ) + subscription <- EitherT.fromOptionF[Future, AppError, ApiSubscription]( + env.dataStore.apiSubscriptionRepo + .forTenant(tenant) + .findByIdNotDeleted(transfer.subscription), + AppError.SubscriptionNotFound + ) + _ <- EitherT.cond[Future][AppError, Unit]( + subscription.parent.isEmpty, + (), + AppError.EntityConflict("Subscription is part of aggregation") + ) + api <- EitherT.fromOptionF[Future, AppError, Api]( + env.dataStore.apiRepo + .forTenant(tenant) + .findByIdNotDeleted(subscription.api), + AppError.ApiNotFound + ) + plan <- EitherT.fromOptionF[Future, AppError, UsagePlan]( + env.dataStore.usagePlanRepo + .forTenant(tenant) + .findByIdNotDeleted(subscription.plan), + AppError.PlanNotFound + ) + _ <- EitherT.cond[Future][AppError, Unit]( + api.visibility == ApiVisibility.Public || api.authorizedTeams + .contains(team.id), + (), + AppError.Unauthorized + ) + _ <- EitherT.cond[Future][AppError, Unit]( + plan.visibility == UsagePlanVisibility.Public || plan.authorizedTeams + .contains(team.id), + (), + AppError.Unauthorized + ) + childSubscriptions <- + EitherT.liftF[Future, AppError, Seq[ApiSubscription]]( + env.dataStore.apiSubscriptionRepo + .forTenant(tenant) + .findNotDeleted(Json.obj("parent" -> subscription.id.asJson)) + ) + childApis <- EitherT.liftF[Future, AppError, Seq[Api]]( + env.dataStore.apiRepo + .forTenant(tenant) + .findNotDeleted( + Json.obj( + "_id" -> Json + .obj("$in" -> JsArray(childSubscriptions.map(_.api.asJson))) + ) + ) + ) + childPlans <- EitherT.liftF[Future, AppError, Seq[UsagePlan]]( + env.dataStore.usagePlanRepo + .forTenant(tenant) + .findNotDeleted( + Json.obj( + "_id" -> Json + .obj("$in" -> JsArray(childSubscriptions.map(_.plan.asJson))) + ) + ) + ) + _ <- EitherT.cond[Future][AppError, Unit]( + childApis.forall(a => + a.visibility == ApiVisibility.Public || a.authorizedTeams + .contains(team.id) + ), + (), + AppError.Unauthorized + ) + _ <- EitherT.cond[Future][AppError, Unit]( + childPlans.forall(p => + p.visibility == UsagePlanVisibility.Public || p.authorizedTeams + .contains(team.id) + ), + (), + AppError.Unauthorized + ) + teamSubscriptions <- + EitherT.liftF[Future, AppError, Seq[ApiSubscription]]( + env.dataStore.apiSubscriptionRepo + .forTenant(tenant) + .findNotDeleted(Json.obj("team" -> team.id.asJson)) + ) + _ <- EitherT.cond[Future][AppError, Unit]( + (childPlans :+ plan).forall(p => + p.allowMultipleKeys.getOrElse(false) || !teamSubscriptions.exists(s => + s.plan == p.id + ) + ), + (), + AppError.EntityConflict("plan not allow multiple subscription") + ) + } yield ExtractTransferLink(subscription, childSubscriptions, plan, api) + + def transferSubscription( + newTeam: Team, + subscription: ApiSubscription, + childs: Seq[ApiSubscription], + tenant: Tenant, + user: User, + plan: UsagePlan, + api: Api, + otoroshiSettings: OtoroshiSettings + ) = + for { + result <- EitherT.liftF[Future, AppError, Long]( + env.dataStore.apiSubscriptionRepo + .forTenant(tenant) + .updateManyByQuery( + Json.obj( + "_id" -> Json + .obj( + "$in" -> JsArray( + childs.map(_.id.asJson) :+ subscription.id.asJson + ) + ) + ), + Json.obj( + "$set" -> Json.obj("team" -> newTeam.id.asJson) + ) + ) + ) + apk <- EitherT[Future, AppError, ActualOtoroshiApiKey]( + otoroshiClient.getApikey(subscription.apiKey.clientId)(otoroshiSettings) + ) + newApk = apk.copy( + clientName = s"daikoku-api-key-${api.humanReadableId}-${plan.customName + .getOrElse(plan.typeName) + .urlPathSegmentSanitized}-${newTeam.humanReadableId}-${System + .currentTimeMillis()}-${api.currentVersion.value}", + metadata = + apk.metadata + ("daikoku_transfer_to_team_id" -> newTeam.id.value) + ("daikoku_transfer_to_team" -> newTeam.name) + ) + _ <- EitherT[Future, AppError, ActualOtoroshiApiKey]( + otoroshiClient.updateApiKey(newApk)(otoroshiSettings) + ) + _ <- EitherT.liftF[Future, AppError, Boolean]( + env.dataStore.notificationRepo + .forTenant(tenant) + .save( + Notification( + id = NotificationId( + IdGenerator.token(32) + ), + tenant = tenant.id, + sender = user.asNotificationSender, + action = NotificationAction.ApiSubscriptionTransferSuccess( + subscription = subscription.id + ), + notificationType = NotificationType.AcceptOnly, + team = newTeam.id.some + ) + ) + ) + } yield result } diff --git a/daikoku/conf/messages.en b/daikoku/conf/messages.en index 3d1a7eae7..627e91c1b 100644 --- a/daikoku/conf/messages.en +++ b/daikoku/conf/messages.en @@ -94,4 +94,5 @@ mail.subscription.validation.body = Hello,\ mail.checkout.title = You can checkout your subscription mail.checkout.body = Your subscription demand for [api.name]/[api.plan] has been accepted\ you can now checkout.\ -please click the following link to Checkout \ No newline at end of file +please click the following link to Checkout +mail.api.subscription.transfer.rejection.body = Your request to transfer to your subscription [subscription] has been rejected. \ No newline at end of file diff --git a/daikoku/conf/messages.fr b/daikoku/conf/messages.fr index c91b6f767..cb5ba41e4 100644 --- a/daikoku/conf/messages.fr +++ b/daikoku/conf/messages.fr @@ -94,4 +94,5 @@ mail.subscription.validation.body = Bonjour,\ mail.checkout.title = Un paiement en attente mail.checkout.body = Votre demande de souscription pour l'api [api.name] et le plan [api.plan] a été accepté\ Vous pouvez effectuer maintenant le reglement.\ -Vous pouvez finaliser votre demande : Reglement \ No newline at end of file +Vous pouvez finaliser votre demande : Reglement +mail.api.subscription.transfer.rejection.body = Votre demande de transfert de le souscription [subscription] a été rejetée. \ No newline at end of file diff --git a/daikoku/conf/routes b/daikoku/conf/routes index f2bf2d1ca..96bb96fed 100644 --- a/daikoku/conf/routes +++ b/daikoku/conf/routes @@ -52,6 +52,7 @@ POST /api/auth/ldap/_check fr.maif.otoroshi.daikoku GET /api/me/teams fr.maif.otoroshi.daikoku.ctrls.ApiController.myTeams() GET /api/me/teams/own fr.maif.otoroshi.daikoku.ctrls.ApiController.myOwnTeam() GET /api/me/teams/:id fr.maif.otoroshi.daikoku.ctrls.ApiController.oneOfMyTeam(id) +GET /api/me/subscription/_retrieve fr.maif.otoroshi.daikoku.ctrls.ApiController.checkTransferLink() GET /api/me fr.maif.otoroshi.daikoku.ctrls.ApiController.me() DELETE /api/me fr.maif.otoroshi.daikoku.ctrls.UsersController.deleteSelfUser() @@ -114,6 +115,8 @@ GET /api/apis/:apiId/plan/:planId/pages/:pageId fr.maif.otoroshi POST /api/teams/:teamId/subscriptions/:id/name fr.maif.otoroshi.daikoku.ctrls.ApiController.updateApiSubscriptionCustomName(teamId, id) PUT /api/teams/:teamId/subscriptions/:id/_archive fr.maif.otoroshi.daikoku.ctrls.ApiController.toggleApiSubscription(teamId, id, enabled: Option[Boolean] ?= Some(false)) PUT /api/teams/:teamId/subscriptions/:id/_archiveByOwner fr.maif.otoroshi.daikoku.ctrls.ApiController.toggleApiSubscriptionByApiOwner(teamId, id, enabled: Option[Boolean] ?= Some(false)) +GET /api/teams/:teamId/subscriptions/:id/_transfer fr.maif.otoroshi.daikoku.ctrls.ApiController.getTransferLink(teamId, id) +PUT /api/teams/:teamId/subscriptions/:id/_retrieve fr.maif.otoroshi.daikoku.ctrls.ApiController.transferSubscription(teamId, id) POST /api/teams/:teamId/subscriptions/:id/_makeUnique fr.maif.otoroshi.daikoku.ctrls.ApiController.makeUniqueSubscription(teamId, id) PUT /api/teams/:teamId/subscriptions/:id fr.maif.otoroshi.daikoku.ctrls.ApiController.updateApiSubscription(teamId, id) DELETE /api/teams/:teamId/subscriptions/:id fr.maif.otoroshi.daikoku.ctrls.ApiController.deleteApiSubscription(teamId, id, action: Option[String] ?= Some("promotion"), child: Option[String] ?= None) diff --git a/daikoku/javascript/src/apps/DaikokuApp.tsx b/daikoku/javascript/src/apps/DaikokuApp.tsx index b0b03652d..91f4c23ba 100644 --- a/daikoku/javascript/src/apps/DaikokuApp.tsx +++ b/daikoku/javascript/src/apps/DaikokuApp.tsx @@ -14,7 +14,8 @@ import { MaybeHomePage, MyHome, TeamHome, - AtomicDesign + AtomicDesign, + SubscriptionRetrieve } from '../components/frontend'; import { MessagesProvider, MyProfile, NotificationList } from '../components/backoffice'; @@ -47,6 +48,7 @@ import { I18nContext } from '../contexts/i18n-context'; import { MessagesEvents } from '../services/messages'; import { ResetPassword, Signup, TwoFactorAuthentication } from './DaikokuHomeApp'; import { AnonymousReporting } from "../components/adminbackoffice/anonymousreporting/AnonymousReporting"; +import { RightPanel } from '../components/utils/sidebar/RightPanel'; export const DaikokuApp = () => { const { connectedUser, tenant } = useContext(GlobalContext) @@ -146,7 +148,8 @@ export const DaikokuApp = () => {
-
+ +
{ } /> + + + + } + />
diff --git a/daikoku/javascript/src/components/adminbackoffice/messages/messages.tsx b/daikoku/javascript/src/components/adminbackoffice/messages/messages.tsx index 6f8e8d3ea..ae8820b08 100644 --- a/daikoku/javascript/src/components/adminbackoffice/messages/messages.tsx +++ b/daikoku/javascript/src/components/adminbackoffice/messages/messages.tsx @@ -6,7 +6,8 @@ import sortBy from 'lodash/sortBy'; import values from 'lodash/values'; import moment from 'moment'; import { useContext, useEffect, useState } from 'react'; -import { ChevronLeft, Send } from 'react-feather'; +import Send from 'react-feather/dist/icons/send'; +import ChevronLeft from 'react-feather/dist/icons/chevron-left'; import Select from 'react-select'; import { useTenantBackOffice } from '../../../contexts'; diff --git a/daikoku/javascript/src/components/adminbackoffice/teams/TeamList.tsx b/daikoku/javascript/src/components/adminbackoffice/teams/TeamList.tsx index a9788c4c1..2f917a665 100644 --- a/daikoku/javascript/src/components/adminbackoffice/teams/TeamList.tsx +++ b/daikoku/javascript/src/components/adminbackoffice/teams/TeamList.tsx @@ -264,50 +264,52 @@ export const TeamList = () => { return (
-
-

- Teams -

-
- { - debouncedResults(e) - }} /> +
+
+

+ Teams +

+
+ { + debouncedResults(e) + }} /> +
-
- {!dataRequest.isLoading && !dataRequest.isError && dataRequest.data && -
{ - dataRequest.data.teams.map((team) => { - return ( - - {team.name} - } actions={actions(team)} />) - })} -
-
-
+ {!dataRequest.isLoading && !dataRequest.isError && dataRequest.data && +
{ + dataRequest.data.teams.map((team) => { + return ( + + {team.name} + } actions={actions(team)} />) + })} +
+
+
+
-
-
- handlePageClick(data)} - containerClassName={'pagination'} - pageClassName={'page-selector'} - forcePage={page} - activeClassName={'active'} /> -
-
} +
+ handlePageClick(data)} + containerClassName={'pagination'} + pageClassName={'page-selector'} + forcePage={page} + activeClassName={'active'} /> +
+
} +
); }; diff --git a/daikoku/javascript/src/components/adminbackoffice/tenants/forms/CustomizationForm.tsx b/daikoku/javascript/src/components/adminbackoffice/tenants/forms/CustomizationForm.tsx index a51b1c493..680cec3d2 100644 --- a/daikoku/javascript/src/components/adminbackoffice/tenants/forms/CustomizationForm.tsx +++ b/daikoku/javascript/src/components/adminbackoffice/tenants/forms/CustomizationForm.tsx @@ -2,7 +2,6 @@ import { getApolloContext, gql } from '@apollo/client'; import { Flow, Form, FormRef, Schema, SchemaEntry, format, type } from '@maif/react-forms'; import { UseMutationResult, useQuery } from '@tanstack/react-query'; import { useContext, useRef } from 'react'; -import { Settings } from 'react-feather'; import { useNavigate } from 'react-router-dom'; import { ModalContext } from '../../../../contexts'; diff --git a/daikoku/javascript/src/components/backoffice/apikeys/TeamApiKeysForApi.tsx b/daikoku/javascript/src/components/backoffice/apikeys/TeamApiKeysForApi.tsx index 08f92346b..e7c69fa2a 100644 --- a/daikoku/javascript/src/components/backoffice/apikeys/TeamApiKeysForApi.tsx +++ b/daikoku/javascript/src/components/backoffice/apikeys/TeamApiKeysForApi.tsx @@ -1,12 +1,14 @@ import { getApolloContext } from '@apollo/client'; -import { Form, constraints, type, format } from '@maif/react-forms'; +import { constraints, format, type } from '@maif/react-forms'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import classNames from 'classnames'; import sortBy from 'lodash/sortBy'; -import React, { useContext, useEffect, useState } from 'react'; +import moment from 'moment'; +import { useContext, useEffect, useState } from 'react'; import { Link, useLocation, useParams } from 'react-router-dom'; import { toast } from 'sonner'; +import { Key } from 'react-feather/dist/icons/key'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; import { I18nContext, ModalContext, @@ -16,30 +18,50 @@ import * as Services from '../../../services'; import { IApi, IRotation, - ISafeSubscription, ISubscription, ISubscriptionExtended, ITeamSimple, IUsagePlan, - ResponseError, - isError, + isError } from '../../../types'; import { BeautifulTitle, Can, - Option, PaginatedComponent, Spinner, apikey, formatPlanType, - read, - stat, + read } from '../../utils'; type ISubscriptionWithChildren = ISubscriptionExtended & { children: Array; }; +const DisplayLink = ({ value }: { value: string }) => { + const [DisplayLink, setDisplayLink] = useState(false) + const { translate } = useContext(I18nContext); + return ( +
{translate("subscriptions.link.explanation.1")} +
    +
  1. {translate("subscriptions.link.explanation.2")}
  2. +
  3. {translate("subscriptions.link.explanation.3")}
  4. +
  5. {translate("subscriptions.link.explanation.4")}
  6. +
+ setDisplayLink(!DisplayLink)}> + + {DisplayLink ? translate('subscriptions.hide.link') : translate('subscriptions.display.link')} + + {DisplayLink &&
+ {value} +
} +
+ ) +} + export const TeamApiKeysForApi = () => { const { isLoading, currentTeam, error } = useTeamBackOffice(); const [searched, setSearched] = useState(''); @@ -48,7 +70,7 @@ export const TeamApiKeysForApi = () => { const params = useParams(); const { client } = useContext(getApolloContext()); const { translate, Translation } = useContext(I18nContext); - const { confirm, openFormModal } = useContext(ModalContext); + const { confirm, openFormModal, openCustomModal } = useContext(ModalContext); const queryClient = useQueryClient(); const apiQuery = useQuery({ @@ -115,16 +137,26 @@ export const TeamApiKeysForApi = () => { currentTeam, subscription, customName - ); + ).then(() => { + toast.success(translate("subscription.custom.name.successfuly.updated")) + queryClient.invalidateQueries({ queryKey: ['data', 'subscriptions'] }) + }); }; - const archiveApiKey = (subscription: ISubscription) => { + const toggleApiKey = (subscription: ISubscription) => { + console.debug("toggle") return Services.archiveApiKey( currentTeam._id, subscription._id, !subscription.enabled - ).then(() => + ).then(() => { + if (subscription.enabled) { + toast.success(translate("subscription.successfully.disabled")) + } else { + toast.success(translate("subscription.successfully.enabled")) + } queryClient.invalidateQueries({ queryKey: ['data', 'subscriptions'] }) + } ); }; @@ -185,17 +217,17 @@ export const TeamApiKeysForApi = () => { format: format.select, label: translate("apikeys.delete.child.label"), options: subscription.children, - transformer: (s: ISubscriptionExtended) => ({value: s._id, label: `${s.apiName}/${s.planName}`}), + transformer: (s: ISubscriptionExtended) => ({ value: s._id, label: `${s.apiName}/${s.planName}` }), visible: (d) => d.rawValues.choice === 'promotion', } }, - onSubmit: ({choice, childId}) => openFormModal( + onSubmit: ({ choice, childId }) => openFormModal( { title: translate("apikeys.delete.confirm.modal.title"), schema: { validation: { type: type.string, - label: translate({ key: "apikeys.delete.confirm.label", replacements: [`${subscription.apiName}/${subscription.customName ?? subscription.planName}`]}), + label: translate({ key: "apikeys.delete.confirm.label", replacements: [`${subscription.apiName}/${subscription.customName ?? subscription.planName}`] }), constraints: [ constraints.required(translate('constraints.required.value')), constraints.matches(new RegExp(`${subscription.apiName}/${subscription.customName ?? subscription.planName}`), translate('constraints.match.subscription')) @@ -253,28 +285,56 @@ export const TeamApiKeysForApi = () => { rotationEvery, gracePeriod ).then((r) => { + toast.success(translate("subscription.rotation.successfully.setup")) queryClient.invalidateQueries({ queryKey: ['data', 'subscriptions'] }); }); }; const regenerateApiKeySecret = (subscription: ISubscription) => { - return confirm({ message: translate('reset.secret.confirm') }).then( - (ok) => { - if (ok) { - Services.regenerateApiKeySecret( - currentTeam._id, - subscription._id - ).then(() => { - queryClient.invalidateQueries({ - queryKey: ['data', 'subscriptions'], + return confirm({ message: translate('reset.secret.confirm') }) + .then( + (ok) => { + if (ok) { + Services.regenerateApiKeySecret( + currentTeam._id, + subscription._id + ).then(() => { + queryClient.invalidateQueries({ + queryKey: ['data', 'subscriptions'], + }); + toast.success(translate('secret reseted successfully')); }); - toast.success(translate('secret reseted successfully')); - }); + } } - } - ); + ); }; + const transferApiKey = (subscription: ISubscription) => { + return Services.getSubscriptionTransferLink(currentTeam._id, subscription._id) + .then((response) => { + if (isError(response)) { + + } else { + openCustomModal({ + title: translate("subscriptions.transfer.modal.title"), + content: , + actions: (close) => + }) + } + } + ) + } + if ( apiQuery.isLoading && subsQuery.isLoading && @@ -302,24 +362,24 @@ export const TeamApiKeysForApi = () => { search === '' ? subscriptions : subscriptions.filter((subs) => { - if ( - subs.apiKey.clientName - .replace('-', ' ') - .toLowerCase() - .includes(search) - ) { - return true; - } else if ( - subs.customName && - subs.customName.toLowerCase().includes(search) - ) { - return true; - } else { - return formatPlanType(subs.planType, translate) - .toLowerCase() - .includes(search); - } - }); + if ( + subs.apiKey.clientName + .replace('-', ' ') + .toLowerCase() + .includes(search) + ) { + return true; + } else if ( + subs.customName && + subs.customName.toLowerCase().includes(search) + ) { + return true; + } else { + return formatPlanType(subs.planType, translate) + .toLowerCase() + .includes(search); + } + }); const sorted = sortBy(filteredApiKeys, ['plan', 'customName', 'parent']); const sortedApiKeys = sorted @@ -338,6 +398,7 @@ export const TeamApiKeysForApi = () => { .map((sub) => ({ ...sub, children: [] })) ); + const apiLink = `/${apiTeam._humanReadableId}/${api._humanReadableId}/${api.currentVersion}/description`; return ( {api && apiTeam ? ( @@ -347,8 +408,8 @@ export const TeamApiKeysForApi = () => { Api keys for   {api.name}
@@ -372,6 +433,7 @@ export const TeamApiKeysForApi = () => { { updateCustomName={(name) => updateCustomName(subscription, name) } - archiveApiKey={() => archiveApiKey(subscription)} + toggle={() => toggleApiKey(subscription)} makeUniqueApiKey={() => makeUniqueApiKey(subscription)} deleteApiKey={() => deleteApiKey(subscription)} toggleRotation={( @@ -396,9 +458,8 @@ export const TeamApiKeysForApi = () => { gracePeriod ) } - regenerateSecret={() => - regenerateApiKeySecret(subscription) - } + regenerateSecret={() => regenerateApiKeySecret(subscription)} + transferKey={() => transferApiKey(subscription)} /> ); }} @@ -421,9 +482,10 @@ type ApiKeyCardProps = { subscription: ISubscriptionWithChildren; updateCustomName: ( name: string - ) => Promise; + ) => Promise; statsLink: string; - archiveApiKey: () => void; + apiLink: string; + toggle: () => void; makeUniqueApiKey: () => void; deleteApiKey: () => void; toggleRotation: ( @@ -435,30 +497,24 @@ type ApiKeyCardProps = { regenerateSecret: () => void; currentTeam: ITeamSimple; subscribedApis: Array; + transferKey: () => void; }; const ApiKeyCard = ({ api, subscription, updateCustomName, + apiLink, statsLink, - archiveApiKey, + toggle, makeUniqueApiKey, toggleRotation, regenerateSecret, + deleteApiKey, + transferKey, currentTeam, - subscribedApis, - deleteApiKey + subscribedApis }: ApiKeyCardProps) => { - const [hide, setHide] = useState(true); - const [settingMode, setSettingMode] = useState(false); - const [customName, setCustomName] = useState(); - - const [editMode, setEditMode] = useState(false); - - const [activeTab, setActiveTab] = useState<'apikey' | 'token' | 'basicAuth'>( - 'apikey' - ); const apiKeyValues = { apikey: `${subscription.apiKey?.clientId}:${subscription.apiKey?.clientSecret}`, token: subscription.integrationToken, @@ -467,9 +523,8 @@ const ApiKeyCard = ({ const [showAggregatePlan, setAggregatePlan] = useState(false); - const { _id, integrationToken } = subscription; - const { translate, Translation } = useContext(I18nContext); + const { openFormModal, openRightPanel } = useContext(ModalContext); const planQuery = useQuery({ queryKey: ['plan', subscription.plan], @@ -477,29 +532,6 @@ const ApiKeyCard = ({ Services.getVisiblePlan(api._id, api.currentVersion, subscription.plan), }); - useEffect(() => { - if (planQuery.data && !isError(planQuery.data)) { - setActiveTab( - planQuery.data.integrationProcess === 'Automatic' ? 'token' : 'apikey' - ); - if (!customName) { - setCustomName( - subscription.customName || - planQuery.data.customName || - planQuery.data.type - ); - } - } - }, [planQuery.data]); - - let inputRef = React.createRef(); - - useEffect(() => { - if (editMode) { - inputRef.current?.focus(); - } - }, [editMode]); - if (planQuery.isLoading) { return (
@@ -540,24 +572,6 @@ const ApiKeyCard = ({ }, }; - const handleCustomNameChange = () => { - const _customName = inputRef.current?.value.trim(); - if (_customName) { - updateCustomName(_customName).then(() => { - setCustomName(_customName); - setEditMode(false); - }); - } - }; - - const abort = () => { - setSettingMode(false); - }; - - const abortCustomNameEdit = () => { - setEditMode(false); - }; - const handleChanges = (rotation: IRotation) => { if (subscription.enabled) { toggleRotation( @@ -565,412 +579,212 @@ const ApiKeyCard = ({ rotation.enabled, rotation.rotationEvery, rotation.gracePeriod - ).then(() => setSettingMode(false)); + ) } }; const disableRotation = api.visibility === 'AdminOnly' || !!plan.autoRotation; + + const _customName = subscription.customName || + planQuery.data.customName || + planQuery.data.type + return ( -
-
-
-
- - - - - +
+
+
+ {subscription.children.length === 0 && } + {subscription.children.length > 0 && + + } +
+ {subscription.enabled ? translate("subscription.enable.label") : translate("subscription.disable.label")} +
-
- {!settingMode && - (!editMode ? ( -
- - {customName} - - -
- ) : ( -
- -
- - - - - - -
-
- ))} - {settingMode ? ( -

- - ApiKey rotation - -

- ) : ( -
- Type : {formatPlanType(plan, translate)} -
- )} +
+ +
+
{_customName}
+
{`${subscription.apiKey.clientId}:${subscription.apiKey.clientSecret}`}
+
+ + +
+
{ + translate({ + key: 'subscription.create.at', replacements: [moment(subscription.createdAt).format(translate('moment.date.format.without.hours'))] + }) + }
-
- {!settingMode && ( -
-
-
- {!subscription.parent && ( - - - - )} - - - - - - - - - - - {!subscription.parent && !disableRotation && ( - - - - )} - - - - - - - {subscription.parent && ( - - - - )} -
-
- {subscription.apiKey && ( -
-
    -
  • - setActiveTab('apikey')} - > - ApiKey - -
  • - {!disableRotation && ( -
  • - setActiveTab('token')} - > - - Integration token - - -
  • - )} -
  • - setActiveTab('basicAuth')} - > - - Basic auth - - -
  • -
-
- )} - {activeTab == 'apikey' && ( - <> -
- -
- -
-
-
- -
- - { - if (subscription.enabled) { - setHide(!hide); - } - }} - className={classNames('input-group-text', { - 'cursor-pointer': subscription.enabled, - 'cursor-forbidden': !subscription.enabled, - })} - id={`client-secret-addon-${_id}`} - > - {hide ? ( - - ) : ( - - )} - -
-
- - )} - {activeTab == 'token' && ( - <> -
- -
-