diff --git a/daikoku/app/controllers/ApiController.scala b/daikoku/app/controllers/ApiController.scala index 592b17155..f9ff5f3a2 100644 --- a/daikoku/app/controllers/ApiController.scala +++ b/daikoku/app/controllers/ApiController.scala @@ -1072,6 +1072,7 @@ class ApiController( apiKey = data.apiKey, plan = data.plan, createdAt = DateTime.now(), + validUntil = None, team = data.team, api = data.api, by = ctx.user.id, @@ -1749,7 +1750,8 @@ class ApiController( customMaxPerDay = (body \ "customMaxPerDay").asOpt[Long], customMaxPerMonth = (body \ "customMaxPerMonth").asOpt[Long], customReadOnly = (body \ "customReadOnly").asOpt[Boolean], - adminCustomName = (body \ "adminCustomName").asOpt[String] + adminCustomName = (body \ "adminCustomName").asOpt[String], + validUntil = (body \ "validUntil").asOpt(DateTimeFormat), ) result <- EitherT(apiService.updateSubscription(ctx.tenant, subToSave, plan)) @@ -1781,6 +1783,7 @@ class ApiController( def subscriptionToJson( api: Api, + apiTeam: Team, plan: UsagePlan, sub: ApiSubscription, parentSub: Option[ApiSubscription] @@ -1797,8 +1800,9 @@ class ApiController( Json.obj("planName" -> name) ++ Json.obj("apiName" -> api.name) ++ Json.obj("_humanReadableId" -> api.humanReadableId) ++ - Json.obj("parentUp" -> false) - + Json.obj("parentUp" -> false) ++ + Json.obj("apiLink" -> s"/${apiTeam.humanReadableId}/${api.humanReadableId}/${api.currentVersion.value}/description") ++ + Json.obj("planLink" -> s"/${apiTeam.humanReadableId}/${api.humanReadableId}/${api.currentVersion.value}/pricing") sub.parent match { case None => FastFuture.successful(r) case Some(parentId) => @@ -1871,14 +1875,21 @@ class ApiController( case None => FastFuture.successful(Json.obj()) //FIXME case Some(plan) => - subscriptionToJson( - api = api, - plan = plan, - sub = sub, - parentSub = sub.parent.flatMap(p => - subscriptions.find(s => s.id == p) - ) - ) + env.dataStore.teamRepo.forTenant(ctx.tenant) + .findByIdNotDeleted(api.team) + .flatMap { + case Some(team) => subscriptionToJson( + api = api, + apiTeam = team, + plan = plan, + sub = sub, + parentSub = sub.parent.flatMap(p => + subscriptions.find(s => s.id == p) + ) + ) + case None => FastFuture.successful(Json.obj()) //FIXME + } + } case None => FastFuture.successful(Json.obj()) diff --git a/daikoku/app/controllers/OtoroshiSettingsController.scala b/daikoku/app/controllers/OtoroshiSettingsController.scala index e2f3da746..312f76d2d 100644 --- a/daikoku/app/controllers/OtoroshiSettingsController.scala +++ b/daikoku/app/controllers/OtoroshiSettingsController.scala @@ -531,7 +531,8 @@ class OtoroshiSettingsController( .flatMap(_.asOpt[Map[String, String]]) .getOrElse(Map.empty[String, String]), rotation = None, - readOnly = readOnlyOpt.getOrElse(false) + readOnly = readOnlyOpt.getOrElse(false), + validUntil = None, ) } diff --git a/daikoku/app/domain/SchemaDefinition.scala b/daikoku/app/domain/SchemaDefinition.scala index 6c82575a0..05740630f 100644 --- a/daikoku/app/domain/SchemaDefinition.scala +++ b/daikoku/app/domain/SchemaDefinition.scala @@ -481,7 +481,7 @@ object SchemaDefinition { ApiKeyRestrictionsType, resolve = _.value.restrictions ) - ) + ), ) lazy val AuthorizedEntitiesType = deriveObjectType[ @@ -2110,6 +2110,7 @@ object SchemaDefinition { ) ), Field("createdAt", DateTimeUnitype, resolve = _.value.createdAt), + Field("validUntil", OptionType(DateTimeUnitype), resolve = _.value.validUntil), Field( "team", OptionType(TeamObjectType), @@ -3567,7 +3568,7 @@ object SchemaDefinition { ), ReplaceField( "validUntil", - Field("validUntil", DateTimeUnitype, resolve = _.value.validUntil) + Field("validUntil", OptionType(DateTimeUnitype), resolve = _.value.validUntil) ) ) lazy val TranslationType = diff --git a/daikoku/app/domain/apikeyEntities.scala b/daikoku/app/domain/apikeyEntities.scala index 2649377eb..061740253 100644 --- a/daikoku/app/domain/apikeyEntities.scala +++ b/daikoku/app/domain/apikeyEntities.scala @@ -25,7 +25,7 @@ case class ApikeyCustomization( metadata: JsObject = play.api.libs.json.Json.obj(), customMetadata: Seq[CustomMetadata] = Seq.empty, tags: JsArray = play.api.libs.json.Json.arr(), - restrictions: ApiKeyRestrictions = ApiKeyRestrictions() + restrictions: ApiKeyRestrictions = ApiKeyRestrictions(), ) extends CanJson[ApikeyCustomization] { def asJson: JsValue = json.ApikeyCustomizationFormat.writes(this) } @@ -59,6 +59,7 @@ case class ApiSubscription( apiKey: OtoroshiApiKey, // TODO: add the actual plan at the time of the subscription plan: UsagePlanId, createdAt: DateTime, + validUntil: Option[DateTime] = None, team: TeamId, api: ApiId, by: UserId, @@ -108,6 +109,10 @@ case class ApiSubscription( "team" -> json.TeamIdFormat.writes(team), "api" -> json.ApiIdFormat.writes(api), "createdAt" -> json.DateTimeFormat.writes(createdAt), + "validUntil" -> validUntil + .map(json.DateTimeFormat.writes) + .getOrElse(JsNull) + .as[JsValue], "customName" -> customName .map(id => JsString(id)) .getOrElse(JsNull) @@ -135,7 +140,8 @@ case class ActualOtoroshiApiKey( tags: Set[String] = Set.empty[String], metadata: Map[String, String] = Map.empty[String, String], restrictions: ApiKeyRestrictions = ApiKeyRestrictions(), - rotation: Option[ApiKeyRotation] + rotation: Option[ApiKeyRotation], + validUntil : Option[Long] = None ) extends CanJson[OtoroshiApiKey] { override def asJson: JsValue = json.ActualOtoroshiApiKeyFormat.writes(this) def asOtoroshiApiKey: OtoroshiApiKey = diff --git a/daikoku/app/domain/json.scala b/daikoku/app/domain/json.scala index 37b9f6a4f..38c791215 100644 --- a/daikoku/app/domain/json.scala +++ b/daikoku/app/domain/json.scala @@ -1662,7 +1662,7 @@ object json { .asOpt(SeqCustomMetadataFormat) .getOrElse(Seq.empty), tags = (json \ "tags").asOpt[JsArray].getOrElse(Json.arr()), - restrictions = (json \ "restrictions").as(ApiKeyRestrictionsFormat) + restrictions = (json \ "restrictions").as(ApiKeyRestrictionsFormat), ) ) } recover { @@ -1679,7 +1679,7 @@ object json { o.customMetadata.map(CustomMetadataFormat.writes) ), "tags" -> o.tags, - "restrictions" -> o.restrictions.asJson + "restrictions" -> o.restrictions.asJson, ) } val ApiKeyRestrictionsFormat = new Format[ApiKeyRestrictions] { @@ -2732,6 +2732,7 @@ object json { team = (json \ "team").as(TeamIdFormat), api = (json \ "api").as(ApiIdFormat), createdAt = (json \ "createdAt").as(DateTimeFormat), + validUntil = (json \ "validUntil").asOpt(DateTimeFormat), by = (json \ "by").as(UserIdFormat), customName = (json \ "customName").asOpt[String], adminCustomName = (json \ "adminCustomName").asOpt[String], @@ -2780,6 +2781,10 @@ object json { "team" -> TeamIdFormat.writes(o.team), "api" -> ApiIdFormat.writes(o.api), "createdAt" -> DateTimeFormat.writes(o.createdAt), + "validUntil"-> o.validUntil + .map(DateTimeFormat.writes) + .getOrElse(JsNull) + .as[JsValue], "by" -> UserIdFormat.writes(o.by), "customName" -> o.customName .map(id => JsString(id)) @@ -3126,7 +3131,9 @@ object json { "rotation" -> apk.rotation .map(ApiKeyRotationFormat.writes) .getOrElse(JsNull) - .as[JsValue] + .as[JsValue], + "validUntil" -> apk.validUntil + ) override def reads(json: JsValue): JsResult[ActualOtoroshiApiKey] = @@ -3161,7 +3168,8 @@ object json { .asOpt[Set[String]] .getOrElse(Set.empty[String]), restrictions = (json \ "restrictions").as(ApiKeyRestrictionsFormat), - rotation = (json \ "rotation").asOpt(ApiKeyRotationFormat) + rotation = (json \ "rotation").asOpt(ApiKeyRotationFormat), + validUntil = (json \ "validUntil").asOpt[Long] ) } map { case sd => JsSuccess(sd) diff --git a/daikoku/app/jobs/OtoroshiVerifierJob.scala b/daikoku/app/jobs/OtoroshiVerifierJob.scala index 21c982ece..ea1f703cd 100644 --- a/daikoku/app/jobs/OtoroshiVerifierJob.scala +++ b/daikoku/app/jobs/OtoroshiVerifierJob.scala @@ -380,6 +380,8 @@ class OtoroshiVerifierJob( .map(_.apikeyCustomization.readOnly) ) .getOrElse(infos.apk.readOnly), + validUntil = subscription.validUntil + .map(_.getMillis), rotation = infos.apk.rotation .map(r => r.copy(enabled = diff --git a/daikoku/app/utils/ApiService.scala b/daikoku/app/utils/ApiService.scala index 8a3f6fa64..edb65d00b 100644 --- a/daikoku/app/utils/ApiService.scala +++ b/daikoku/app/utils/ApiService.scala @@ -83,7 +83,7 @@ class ApiService( customMaxPerDay: Option[Long] = None, customMaxPerMonth: Option[Long] = None, customReadOnly: Option[Boolean] = None, - maybeOtoroshiApiKey: Option[OtoroshiApiKey] = None + maybeOtoroshiApiKey: Option[OtoroshiApiKey] = None, ) = { val otoroshiApiKey = maybeOtoroshiApiKey.getOrElse( @@ -162,7 +162,7 @@ class ApiService( "daikoku__tags" -> processedTags.mkString(" | ") ) ++ processedMetadata, rotation = - plan.autoRotation.map(enabled => ApiKeyRotation(enabled = enabled)) + plan.autoRotation.map(enabled => ApiKeyRotation(enabled = enabled)), ) plan match { @@ -256,6 +256,7 @@ class ApiService( apiKey = tunedApiKey.asOtoroshiApiKey, plan = plan.id, createdAt = DateTime.now(), + validUntil = None, team = team.id, api = api.id, by = user.id, @@ -359,6 +360,7 @@ class ApiService( apiKey = OtoroshiApiKey(clientName, clientId, clientSecret), plan = plan.id, createdAt = DateTime.now(), + validUntil = None, team = team.id, api = api.id, by = user.id, @@ -759,7 +761,8 @@ class ApiService( metadata = apiKey.metadata ++ subscription.customMetadata .flatMap(_.asOpt[Map[String, String]]) .getOrElse(Map.empty[String, String]), - readOnly = subscription.customReadOnly.getOrElse(apiKey.readOnly) + readOnly = subscription.customReadOnly.getOrElse(apiKey.readOnly), + validUntil = subscription.validUntil.map(_.getMillis) ) )(otoSettings) ) diff --git a/daikoku/javascript/src/components/backoffice/apikeys/TeamApiKeysForApi.tsx b/daikoku/javascript/src/components/backoffice/apikeys/TeamApiKeysForApi.tsx index b02a053b0..aaffdadf9 100644 --- a/daikoku/javascript/src/components/backoffice/apikeys/TeamApiKeysForApi.tsx +++ b/daikoku/javascript/src/components/backoffice/apikeys/TeamApiKeysForApi.tsx @@ -7,7 +7,6 @@ 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 { I18nContext, @@ -495,12 +494,12 @@ type ApiKeyCardProps = { graceperiod: number ) => Promise; regenerateSecret: () => void; - currentTeam: ITeamSimple; + currentTeam?: ITeamSimple; subscribedApis: Array; transferKey: () => void; }; -const ApiKeyCard = ({ +export const ApiKeyCard = ({ api, subscription, updateCustomName, @@ -591,16 +590,24 @@ const ApiKeyCard = ({ planQuery.data.customName || planQuery.data.type + console.debug({ subscription }) return (
- {subscription.children.length === 0 && !subscription.parent && } - {subscription.children.length === 0 && subscription.parent &&
} - {subscription.children.length > 0 && - } + {subscription.children.length === 0 && } + {subscription.children.length > 0 && + + }
- {subscription.enabled ? translate("subscription.enable.label") : translate("subscription.disable.label")} + + {subscription.enabled ? translate("subscription.enable.label") : translate("subscription.disable.label")} +
{_customName}
-
{`${subscription.apiKey.clientId}:${subscription.apiKey.clientSecret}`}
-
- - - +
+ + + + + + + + +
{ - translate({ - key: 'subscription.create.at', replacements: [moment(subscription.createdAt).format(translate('moment.date.format.without.hours'))] - }) - }
+ translate("subscription.for")} + {subscription.apiName}/{subscription.planName} + {translate({ + key: 'subscription.created.at', replacements: [moment(subscription.createdAt).format(translate('moment.date.format.without.hours'))] + })} + + {subscription.validUntil && translate({ + key: 'subscription.valid.until', replacements: [moment(subscription.validUntil).format(translate('moment.date.format.without.hours'))] + })}
@@ -680,7 +699,7 @@ const ApiKeyCard = ({ aria-expanded="false" id="dropdownMenuButton" /> -
+
openFormModal({ @@ -701,7 +720,7 @@ const ApiKeyCard = ({ > {translate("subscription.custom.name.update.label")} - + {subscription.children.length > 0 && openRightPanel({ @@ -714,7 +733,7 @@ const ApiKeyCard = ({ return (
{`${aggregate.apiName}/${aggregate.planName || aggregate.planType}`} diff --git a/daikoku/javascript/src/components/backoffice/apis/TeamApiSubscriptions.tsx b/daikoku/javascript/src/components/backoffice/apis/TeamApiSubscriptions.tsx index d6e42ef74..6cabcb879 100644 --- a/daikoku/javascript/src/components/backoffice/apis/TeamApiSubscriptions.tsx +++ b/daikoku/javascript/src/components/backoffice/apis/TeamApiSubscriptions.tsx @@ -1,13 +1,13 @@ -import { getApolloContext } from '@apollo/client'; -import { format, type } from '@maif/react-forms'; -import { createColumnHelper } from '@tanstack/react-table'; -import { useContext, useEffect, useRef, useState } from 'react'; -import { toast } from 'sonner'; +import { getApolloContext } from "@apollo/client"; +import { format, type } from "@maif/react-forms"; +import { createColumnHelper } from "@tanstack/react-table"; +import { useContext, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; -import { ModalContext } from '../../../contexts'; -import { CustomSubscriptionData } from '../../../contexts/modals/SubscriptionMetadataModal'; -import { I18nContext } from '../../../contexts'; -import * as Services from '../../../services'; +import { ModalContext } from "../../../contexts"; +import { CustomSubscriptionData } from "../../../contexts/modals/SubscriptionMetadataModal"; +import { I18nContext } from "../../../contexts"; +import * as Services from "../../../services"; import { IApi, isError, @@ -16,8 +16,8 @@ import { ITeamSimple, IUsagePlan, ResponseError, -} from '../../../types'; -import { SwitchButton, Table, TableRef } from '../../inputs'; +} from "../../../types"; +import { SwitchButton, Table, TableRef } from "../../inputs"; import { api as API, BeautifulTitle, @@ -27,8 +27,9 @@ import { manage, Option, Spinner, -} from '../../utils'; -import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'; +} from "../../utils"; +import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; +import { cp } from "fs"; type TeamApiSubscriptionsProps = { api: IApi; @@ -58,6 +59,7 @@ interface IApiSubscriptionGql extends ISubscriptionCustomization { type: string; }; createdAt: string; + validUntil: number; api: { _id: string; }; @@ -75,6 +77,7 @@ interface IApiSubscriptionGql extends ISubscriptionCustomization { _id: string; adminCustomName: string; enabled: boolean; + validUntil: number; api: { _id: string; name: string; @@ -106,17 +109,17 @@ export const TeamApiSubscriptions = ({ useContext(ModalContext); const plansQuery = useQuery({ - queryKey: ['plans'], + queryKey: ["plans"], queryFn: () => Services.getAllPlanOfApi(api.team, api._id, api.currentVersion), }); const subscriptionsQuery = useQuery({ - queryKey: ['subscriptions'], + queryKey: ["subscriptions"], queryFn: () => client! .query<{ apiApiSubscriptions: Array }>({ query: Services.graphql.getApiSubscriptions, - fetchPolicy: 'no-cache', + fetchPolicy: "no-cache", variables: { apiId: api._id, teamId: currentTeam._id, @@ -186,7 +189,7 @@ export const TeamApiSubscriptions = ({ }); useEffect(() => { - document.title = `${currentTeam.name} - ${translate('Subscriptions')}`; + document.title = `${currentTeam.name} - ${translate("Subscriptions")}`; }, []); useEffect(() => { @@ -194,7 +197,6 @@ export const TeamApiSubscriptions = ({ tableRef.current?.update(); } }, [api, subscriptionsQuery.data]); - useEffect(() => { tableRef.current?.update(); }, [filters]); @@ -204,11 +206,12 @@ export const TeamApiSubscriptions = ({ columnHelper.accessor( (row) => row.adminCustomName || row.apiKey.clientName, { - id: 'adminCustomName', - header: translate('Name'), - meta: { style: { textAlign: 'left' } }, + id: "adminCustomName", + header: translate("Name"), + meta: { style: { textAlign: "left" } }, filterFn: (row, _, value) => { const sub = row.original; + const displayed: string = sub.team._id === currentTeam._id ? sub.customName || sub.apiKey.clientName @@ -218,34 +221,37 @@ export const TeamApiSubscriptions = ({ .toLocaleLowerCase() .includes(value.toLocaleLowerCase()); }, - sortingFn: 'basic', + sortingFn: "basic", cell: (info) => { const sub = info.row.original; if (sub.parent) { const title = `
- ${translate('aggregated.apikey.badge.title')} + ${translate("aggregated.apikey.badge.title")}
    -
  • ${translate('Api')}: ${sub.parent.api.name}
  • -
  • ${translate('Plan')}: ${sub.parent.plan.customName}
  • -
  • ${translate('aggregated.apikey.badge.apikey.name')}: ${sub.parent.adminCustomName}
  • +
  • ${translate("Api")}: ${sub.parent.api.name}
  • +
  • ${translate("Plan")}: ${sub.parent.plan.customName}
  • +
  • ${translate("aggregated.apikey.badge.apikey.name")}: ${sub.parent.adminCustomName}
`; return (
{info.getValue()} - -
A
-
+ +
A
+
); } - return
{info.getValue()}
; + + return ( + {info.getValue()} + ); }, } ), - columnHelper.accessor('plan', { - header: translate('Plan'), - meta: { style: { textAlign: 'left' } }, + columnHelper.accessor("plan", { + header: translate("Plan"), + meta: { style: { textAlign: "left" } }, cell: (info) => Option(usagePlans.find((pp) => pp._id === info.getValue()._id)) .map((p: IUsagePlan) => p.customName || formatPlanType(p, translate)) @@ -255,16 +261,16 @@ export const TeamApiSubscriptions = ({ usagePlans.find((pp) => pp._id === row.original.plan._id) ) .map((p: IUsagePlan) => p.customName || formatPlanType(p, translate)) - .getOrElse(''); + .getOrElse(""); return displayed .toLocaleLowerCase() .includes(value.toLocaleLowerCase()); }, }), - columnHelper.accessor('team', { - header: translate('Team'), - meta: { style: { textAlign: 'left' } }, + columnHelper.accessor("team", { + header: translate("Team"), + meta: { style: { textAlign: "left" } }, cell: (info) => info.getValue().name, filterFn: (row, columnId, value) => { const displayed: string = row.original.team.name; @@ -274,11 +280,11 @@ export const TeamApiSubscriptions = ({ .includes(value.toLocaleLowerCase()); }, }), - columnHelper.accessor('enabled', { - header: translate('Enabled'), + columnHelper.accessor("enabled", { + header: translate("Enabled"), enableColumnFilter: false, enableSorting: false, - meta: { style: { textAlign: 'center' } }, + meta: { style: { textAlign: "center" } }, cell: (info) => { const sub = info.row.original; return ( @@ -291,7 +297,7 @@ export const TeamApiSubscriptions = ({ !sub.enabled ).then(() => { tableRef.current?.update(); - queryClient.invalidateQueries({ queryKey: ['subscriptions'] }); + queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); }) } checked={sub.enabled} @@ -299,48 +305,49 @@ export const TeamApiSubscriptions = ({ ); }, }), - columnHelper.accessor('createdAt', { + columnHelper.accessor("createdAt", { enableColumnFilter: false, - header: translate('Created at'), - meta: { style: { textAlign: 'left' } }, + header: translate("Created at"), + meta: { style: { textAlign: "left" } }, cell: (info) => { const date = info.getValue(); if (!!date) { return formatDate(date, language); } - return translate('N/A'); + return translate("N/A"); }, }), - columnHelper.accessor('lastUsage', { + columnHelper.accessor("lastUsage", { enableColumnFilter: false, - header: translate('apisubscription.lastUsage.label'), - meta: { style: { textAlign: 'left' } }, + header: translate("apisubscription.lastUsage.label"), + meta: { style: { textAlign: "left" } }, cell: (info) => { const date = info.getValue(); if (!!date) { return formatDate(date, language); } - return translate('N/A'); + return translate("N/A"); }, }), columnHelper.display({ - header: translate('Actions'), - meta: { style: { textAlign: 'center', width: '120px' } }, + header: translate("Actions"), + meta: { style: { textAlign: "center", width: "120px" } }, cell: (info) => { const sub = info.row.original; return (
- + - + - + {!!filters && (
{ if (!plan) { @@ -69,6 +70,7 @@ const ApiPricingCard = (props: ApiPricingCardProps) => { openApiKeySelectModal, openCustomModal, close, + openRightPanel } = useContext(ModalContext); const { client } = useContext(getApolloContext()); @@ -227,6 +229,51 @@ const ApiPricingCard = (props: ApiPricingCardProps) => { }) } +// const displaySubscription = () => { +// Services.getMySubscriptions(props.api._id, props.api.currentVersion) +// .then(r => { +// openRightPanel({ +// title: "test", +// content:
+// {r.subscriptions.map(subscription => { +// return ( +// Promise.resolve()} +// toggle={console.debug} +// makeUniqueApiKey={console.debug} +// deleteApiKey={console.debug} +// toggleRotation={( +// plan, +// enabled, +// rotationEvery, +// gracePeriod +// ) => +// Promise.resolve() +// } +// regenerateSecret={console.debug} +// transferKey={console.debug} +// /> +// ) +// })} +//
+// }) +// }) +// } + return (
{ className="card-img-top card-link card-header" data-holder-rendered="true" > + {/*
+