From 20dd4bfe80352f21d53a859a618c4c8a1a202063 Mon Sep 17 00:00:00 2001 From: HelaKaraa Date: Fri, 6 Sep 2024 16:52:36 +0200 Subject: [PATCH] feat #728: add validUntil field to API key subscriptions --- daikoku/app/controllers/ApiController.scala | 1 + daikoku/app/domain/SchemaDefinition.scala | 1 + daikoku/app/domain/apikeyEntities.scala | 2 + daikoku/app/domain/json.scala | 2 + daikoku/app/utils/ApiService.scala | 2 + .../backoffice/apis/TeamApiSubscriptions.tsx | 164 +++++----- .../modals/SubscriptionMetadataModal.tsx | 281 +++++++++++------- .../src/locales/en/translation.json | 3 + .../src/locales/fr/translation.json | 3 + daikoku/javascript/src/services/index.ts | 1 + daikoku/javascript/src/types/api.ts | 2 + 11 files changed, 281 insertions(+), 181 deletions(-) diff --git a/daikoku/app/controllers/ApiController.scala b/daikoku/app/controllers/ApiController.scala index 7e20ef0ea..92d5e455e 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 = DateTime.now(), team = data.team, api = data.api, by = ctx.user.id, diff --git a/daikoku/app/domain/SchemaDefinition.scala b/daikoku/app/domain/SchemaDefinition.scala index 64f73e029..04e520b46 100644 --- a/daikoku/app/domain/SchemaDefinition.scala +++ b/daikoku/app/domain/SchemaDefinition.scala @@ -2109,6 +2109,7 @@ object SchemaDefinition { ) ), Field("createdAt", DateTimeUnitype, resolve = _.value.createdAt), + Field("validUntil", DateTimeUnitype, resolve = _.value.validUntil), Field( "team", OptionType(TeamObjectType), diff --git a/daikoku/app/domain/apikeyEntities.scala b/daikoku/app/domain/apikeyEntities.scala index 907bd60e4..ae19d536e 100644 --- a/daikoku/app/domain/apikeyEntities.scala +++ b/daikoku/app/domain/apikeyEntities.scala @@ -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: DateTime, team: TeamId, api: ApiId, by: UserId, @@ -108,6 +109,7 @@ case class ApiSubscription( "team" -> json.TeamIdFormat.writes(team), "api" -> json.ApiIdFormat.writes(api), "createdAt" -> json.DateTimeFormat.writes(createdAt), + "validUntil" -> json.DateTimeFormat.writes(validUntil), "customName" -> customName .map(id => JsString(id)) .getOrElse(JsNull) diff --git a/daikoku/app/domain/json.scala b/daikoku/app/domain/json.scala index e632c15b9..058ac77aa 100644 --- a/daikoku/app/domain/json.scala +++ b/daikoku/app/domain/json.scala @@ -2731,6 +2731,7 @@ object json { team = (json \ "team").as(TeamIdFormat), api = (json \ "api").as(ApiIdFormat), createdAt = (json \ "createdAt").as(DateTimeFormat), + validUntil = (json \ "validUntil").as(DateTimeFormat), by = (json \ "by").as(UserIdFormat), customName = (json \ "customName").asOpt[String], adminCustomName = (json \ "adminCustomName").asOpt[String], @@ -2779,6 +2780,7 @@ object json { "team" -> TeamIdFormat.writes(o.team), "api" -> ApiIdFormat.writes(o.api), "createdAt" -> DateTimeFormat.writes(o.createdAt), + "validUntil"-> DateTimeFormat.writes(o.validUntil), "by" -> UserIdFormat.writes(o.by), "customName" -> o.customName .map(id => JsString(id)) diff --git a/daikoku/app/utils/ApiService.scala b/daikoku/app/utils/ApiService.scala index 2d88f3803..6edde4729 100644 --- a/daikoku/app/utils/ApiService.scala +++ b/daikoku/app/utils/ApiService.scala @@ -256,6 +256,7 @@ class ApiService( apiKey = tunedApiKey.asOtoroshiApiKey, plan = plan.id, createdAt = DateTime.now(), + validUntil = DateTime.now(), 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 = DateTime.now(), team = team.id, api = api.id, by = user.id, diff --git a/daikoku/javascript/src/components/backoffice/apis/TeamApiSubscriptions.tsx b/daikoku/javascript/src/components/backoffice/apis/TeamApiSubscriptions.tsx index d6e42ef74..025e64b80 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: string; api: { _id: string; }; @@ -106,17 +108,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 +188,7 @@ export const TeamApiSubscriptions = ({ }); useEffect(() => { - document.title = `${currentTeam.name} - ${translate('Subscriptions')}`; + document.title = `${currentTeam.name} - ${translate("Subscriptions")}`; }, []); useEffect(() => { @@ -204,9 +206,9 @@ 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 = @@ -218,34 +220,52 @@ export const TeamApiSubscriptions = ({ .toLocaleLowerCase() .includes(value.toLocaleLowerCase()); }, - sortingFn: 'basic', + sortingFn: "basic", cell: (info) => { const sub = info.row.original; + const titleDate = `
+ ${translate("validationDate.apikey.badge.title")} : + ${sub.validUntil ? formatDate(sub.validUntil, language) : "N/A"} +
`; 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
-
+
+ +
V
+
+ + +
A
+
+
); } - return
{info.getValue()}
; + + return ( +
+ {info.getValue()} + +
V
+
+
+ ); }, } ), - 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 +275,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 +294,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 +311,7 @@ export const TeamApiSubscriptions = ({ !sub.enabled ).then(() => { tableRef.current?.update(); - queryClient.invalidateQueries({ queryKey: ['subscriptions'] }); + queryClient.invalidateQueries({ queryKey: ["subscriptions"] }); }) } checked={sub.enabled} @@ -299,38 +319,38 @@ 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 && (
(props: SubscriptionMetadataModalProps & IBaseModalProps) => { + customMetadata: { [key: string]: string }; + customMaxPerSecond: number; + customMaxPerDay: number; + customMaxPerMonth: number; + customReadOnly: boolean; + adminCustomName: string; + validUntil: Date; +}; +export const SubscriptionMetadataModal = ( + props: SubscriptionMetadataModalProps & IBaseModalProps +) => { const { translate, Translation } = useContext(I18nContext); - const formRef = useRef() - + const formRef = useRef(); const apiQuery = useQuery({ - queryKey: ['api'], + queryKey: ["api"], queryFn: () => Services.getVisibleApiWithId(props.api!), - enabled: !!props.api - }) + enabled: !!props.api, + }); const planQuery = useQuery({ - queryKey: ['plan'], + queryKey: ["plan"], queryFn: () => { - const api = apiQuery.data as IApi - return Services.getVisiblePlan(api._humanReadableId, api.currentVersion, props.plan!) + const api = apiQuery.data as IApi; + return Services.getVisiblePlan( + api._humanReadableId, + api.currentVersion, + props.plan! + ); }, - enabled: !!props.plan && !!apiQuery.data && !isError(apiQuery.data) - }) + enabled: !!props.plan && !!apiQuery.data && !isError(apiQuery.data), + }); const actionAndClose = (formData) => { const subProps: CustomSubscriptionData = { @@ -60,10 +65,11 @@ export const SubscriptionMetadataModal = (props: Subscri customMaxPerDay: formData.customQuotas.customMaxPerDay, customMaxPerMonth: formData.customQuotas.customMaxPerMonth, customReadOnly: formData.customReadOnly, - adminCustomName: formData.adminCustomName + adminCustomName: formData.adminCustomName, + validUntil: formData.validUntil, }; - const res = props.save(subProps) + const res = props.save(subProps); if (res instanceof Promise) { res.then(() => !props.noClose && props.close()); } else if (!props.noClose) { @@ -74,55 +80,68 @@ export const SubscriptionMetadataModal = (props: Subscri const schema = { customMetadata: { type: type.object, - label: translate('Additional metadata'), + label: translate("Additional metadata"), }, customQuotas: { type: type.object, format: format.form, - label: translate('Custom quotas'), + label: translate("Custom quotas"), schema: { customMaxPerSecond: { type: type.number, - label: translate('Max. requests per second'), + label: translate("Max. requests per second"), constraints: [ - constraints.min(0, translate('constraints.min.0')) //todo: translate - ] + constraints.min(0, translate("constraints.min.0")), //todo: translate + ], }, customMaxPerDay: { type: type.number, - label: translate('Max. requests per day'), + label: translate("Max. requests per day"), constraints: [ - constraints.min(0, translate('constraints.min.0')) //todo: translate - ] + constraints.min(0, translate("constraints.min.0")), //todo: translate + ], }, customMaxPerMonth: { type: type.number, - label: translate('Max. requests per month'), + label: translate("Max. requests per month"), constraints: [ - constraints.min(0, translate('constraints.min.0')) //todo: translate - ] + constraints.min(0, translate("constraints.min.0")), //todo: translate + ], }, - } + }, }, customReadOnly: { type: type.bool, - label: translate('Read only apikey') + label: translate("Read only apikey"), }, adminCustomName: { type: type.string, - label: translate('sub.meta.modal.admin.custom.name.label'), - help: translate('sub.meta.modal.admin.custom.name.help'), - } - } + label: translate("sub.meta.modal.admin.custom.name.label"), + help: translate("sub.meta.modal.admin.custom.name.help"), + }, + validUntil: { + type: type.date, + label: translate("sub.meta.modal.valid.until.label"), + help: translate("sub.meta.modal.valid.until.help"), + }, + }; const mandatoryMetadataSchema = (plan?: IUsagePlan) => ({ metadata: { type: type.object, format: format.form, visible: !!plan, - label: translate({ key: 'mandatory.metadata.label', replacements: [plan?.otoroshiTarget?.apikeyCustomization.customMetadata.length.toString() || ''] }), - schema: sortBy(plan?.otoroshiTarget?.apikeyCustomization.customMetadata, ['key']) - .map((meta: { key: string, possibleValues: Array }) => { + label: translate({ + key: "mandatory.metadata.label", + replacements: [ + plan?.otoroshiTarget?.apikeyCustomization.customMetadata.length.toString() || + "", + ], + }), + schema: sortBy(plan?.otoroshiTarget?.apikeyCustomization.customMetadata, [ + "key", + ]) + .map((meta: { key: string; possibleValues: Array }) => { return { key: meta.key, schemaEntry: { @@ -131,40 +150,68 @@ export const SubscriptionMetadataModal = (props: Subscri createOption: true, options: meta.possibleValues, constraints: [ - constraints.required(translate('constraints.required.value')) - ] - } - } + constraints.required(translate("constraints.required.value")), + ], + }, + }; }) .reduce((acc, curr) => { - return { ...acc, [curr.key]: curr.schemaEntry } + return { ...acc, [curr.key]: curr.schemaEntry }; }, {}), }, - }) + }); - if (!!props.api && apiQuery.isLoading) { - return (
) + if (!!props.api && apiQuery.isLoading) { + return ( +
+ +
+ ); } else if (props.plan && planQuery.isLoading) { - return (
) + return ( +
+ +
+ ); } else if (apiQuery.error || planQuery.error) { - } - - if (!!props.api && apiQuery.isLoading || props.plan && planQuery.isLoading) { - return
- } else if (!props.api && planQuery.data || (apiQuery.data && !isError(apiQuery.data))) { - const plan = !!props.plan ? !isError(planQuery.data) ? planQuery.data : undefined : undefined + if ( + (!!props.api && apiQuery.isLoading) || + (props.plan && planQuery.isLoading) + ) { + return ( +
+ +
+ ); + } else if ( + (!props.api && planQuery.data) || + (apiQuery.data && !isError(apiQuery.data)) + ) { + const plan = !!props.plan + ? !isError(planQuery.data) + ? planQuery.data + : undefined + : undefined; const maybeSubMetadata = Option(props.subscription?.customMetadata) .orElse(props.config?.customMetadata) - .orElse({...props.subscriptionDemand?.motivation, ...props.subscriptionDemand?.customMetadata}) + .orElse({ + ...props.subscriptionDemand?.motivation, + ...props.subscriptionDemand?.customMetadata, + }) .map((v) => Object.entries(v)) .getOrElse([]); const [maybeMetadata, maybeCustomMetadata] = maybeSubMetadata.reduce( ([accMeta, accCustomMeta]: any, item: any) => { - if (plan && plan.otoroshiTarget?.apikeyCustomization.customMetadata.some((x: any) => x.key === item[0])) { + if ( + plan && + plan.otoroshiTarget?.apikeyCustomization.customMetadata.some( + (x: any) => x.key === item[0] + ) + ) { return [[...accMeta, item], accCustomMeta]; } return [accMeta, [...accCustomMeta, item]]; @@ -195,45 +242,61 @@ export const SubscriptionMetadataModal = (props: Subscri .getOrNull(), adminCustomName: Option(props.subscription?.adminCustomName) .orElse(props.subscriptionDemand?.adminCustomName) - .getOrNull() - } - - + .getOrNull(), + validUntil: Option(props.subscription?.validUntil) + .orElse(props.subscription?.validUntil) + .getOrNull(), + }; - const _schema = { ...mandatoryMetadataSchema(plan), ...schema } - return (
-
-
- Subscription metadata -
-
-
- {props.description &&
{props.description}
} -
<>} - className='mb-1' - /> - -
- + const _schema = { ...mandatoryMetadataSchema(plan), ...schema }; + return ( +
+
+
+ + Subscription metadata + +
+ className="btn-close" + aria-label="Close" + onClick={props.close} + /> +
+
+ {props.description && ( +
{props.description}
+ )} + <>} + className="mb-1" + /> + +
+ + +
-
); + ); } else { - return
Error while fetching metadata
+ return
Error while fetching metadata
; } - }; diff --git a/daikoku/javascript/src/locales/en/translation.json b/daikoku/javascript/src/locales/en/translation.json index ea55b3aca..5ad2609e4 100644 --- a/daikoku/javascript/src/locales/en/translation.json +++ b/daikoku/javascript/src/locales/en/translation.json @@ -1371,6 +1371,8 @@ "motivation.form.sample.button.label": "Test", "sub.meta.modal.admin.custom.name.label": "Custom key name", "sub.meta.modal.admin.custom.name.help": "this custom name will only be visible to the admin team", + "sub.meta.modal.valid.until.label": "Valid until", + "sub.meta.modal.valid.until.help": "The date until which the subscription will be valid", "documentation.add.page.btn.label": "Add page", "DisplayMode": "Display mode", "display.environment.label": "Environment", @@ -1394,6 +1396,7 @@ "aggregated.apikey.badge.apikey.name": "API key custom name", "apisubscription.lastUsage.label": "Last usage", "N/A": "N/A", + "validationDate.apikey.badge.title": "Valid Until", "semver.error.message": "Can't create a version with special characters : %s", "version.creation.success.message": "New version of API created successfully", "error.message.creation.security.enabled": "You're not authorized to create an API, please contact your administrator.", diff --git a/daikoku/javascript/src/locales/fr/translation.json b/daikoku/javascript/src/locales/fr/translation.json index ae3718b39..31135dd6d 100644 --- a/daikoku/javascript/src/locales/fr/translation.json +++ b/daikoku/javascript/src/locales/fr/translation.json @@ -1371,6 +1371,8 @@ "motivation.form.sample.button.label": "Tester", "sub.meta.modal.admin.custom.name.label": "Nom personnalisé de la clé", "sub.meta.modal.admin.custom.name.help": "ce nom personnalisé ne sera visible que de l'équipe d'administration", + "sub.meta.modal.valid.until.label": "Valide jusqu'à", + "sub.meta.modal.valid.until.help": "La date de fin de validité de la clé", "documentation.add.page.btn.label": "Ajouter une page", "DisplayMode": "Affichage", "display.environment.label": "Environnement", @@ -1394,6 +1396,7 @@ "aggregated.apikey.badge.apikey.name": "Nom personnalisée de la clé", "apisubscription.lastUsage.label": "Dernier usage", "N/A": "N/A", + "validationDate.apikey.badge.title": "Date de validation", "semver.error.message": "Une version ne peut pas être créée avec des caractères spéciaux : %s", "version.creation.success.message": "La nouvelle version de l'API a été créée avec succès", "error.message.creation.security.enabled": "Vous n'êtes pas autorisé à créer d'API, merci de contacter votre administrateur.", diff --git a/daikoku/javascript/src/services/index.ts b/daikoku/javascript/src/services/index.ts index 1a430bfb7..16fdd1994 100644 --- a/daikoku/javascript/src/services/index.ts +++ b/daikoku/javascript/src/services/index.ts @@ -1601,6 +1601,7 @@ export const graphql = { type } createdAt + validUntil api { _id name diff --git a/daikoku/javascript/src/types/api.ts b/daikoku/javascript/src/types/api.ts index edf8614ee..723bd68b5 100644 --- a/daikoku/javascript/src/types/api.ts +++ b/daikoku/javascript/src/types/api.ts @@ -378,6 +378,7 @@ export interface IBaseSubscription { team: string; api: string; createdAt: string; + validUntil: string; by: string; customName: string | null; enabled: boolean; @@ -442,6 +443,7 @@ export interface ISubscriptionCustomization { customMaxPerDay?: number; customReadOnly?: boolean; adminCustomName?: string; + validUntil: string; } export interface ISubscriptionExtended extends ISubscription {