From 5e9b66596991e254dd926ebb46dfb2bede4d8f63 Mon Sep 17 00:00:00 2001 From: Gabriel Menant Date: Thu, 2 Apr 2026 10:52:26 +0200 Subject: [PATCH] feat: all Users Daikoku teams --- .../daikoku/controllers/ApiController.scala | 151 +++++++++++++++++ .../drivers/postgres/PostgresDataStore.scala | 158 ++++++++++++++++-- .../storage/drivers/postgres/ReactivePg.scala | 57 +++++++ daikoku/conf/routes | 1 + daikoku/javascript/src/apps/DaikokuApp.tsx | 9 + .../adminbackoffice/teams/UserTeamList.tsx | 127 ++++++++++++++ .../src/components/utils/AvatarWithAction.tsx | 6 +- .../javascript/src/contexts/navContext.tsx | 9 +- .../src/locales/en/translation.json | 2 + .../src/locales/fr/translation.json | 2 + daikoku/javascript/src/services/index.ts | 42 ++++- daikoku/javascript/src/types/team.ts | 29 ++++ .../controllers/ApiControllerSpec.scala | 22 +++ 13 files changed, 593 insertions(+), 22 deletions(-) create mode 100644 daikoku/javascript/src/components/adminbackoffice/teams/UserTeamList.tsx diff --git a/daikoku/app/fr/maif/daikoku/controllers/ApiController.scala b/daikoku/app/fr/maif/daikoku/controllers/ApiController.scala index da2323411..ead3013a2 100644 --- a/daikoku/app/fr/maif/daikoku/controllers/ApiController.scala +++ b/daikoku/app/fr/maif/daikoku/controllers/ApiController.scala @@ -41,6 +41,14 @@ import play.api.libs.json.* import play.api.libs.streams.Accumulator import play.api.mvc.* import fr.maif.daikoku.utils.StringImplicits.BetterString +import fr.maif.daikoku.utils.future.EnhancedObject + +import fr.maif.daikoku.storage.drivers.postgres.{ + Col, + ColString, + ColJsonArray, + PostgresDataStore +} import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success, Try} @@ -316,6 +324,149 @@ class ApiController( } } + case class UserSimple(_id: String, name: String, email: String ) { + def json = Json.obj ( + "_id" -> _id, + "name" -> name, + "email" -> email, + ) + } + + object UserSimple { + def readFromJson(json: JsValue): UserSimple = { + UserSimple( + _id = (json \ "_id").as[String], + name = (json \ "name").as[String], + email = (json \ "email").as[String], + ) + } + } + + def getUsers() = + DaikokuAction.async { ctx => + TenantAdminOnly( + AuditTrailEvent( + s"@{user.name} has get users" + ) + )(ctx.tenant.id.value, ctx) { (tenant, _) => + implicit val mat: Materializer = env.defaultMaterializer + val teamId = ctx.request.getQueryString("teamId") + val userId = ctx.request.getQueryString("userId") + + env.dataStore + .asInstanceOf[PostgresDataStore] + .queryRawMappedStream( + s"""SELECT + | t._id AS team_id, + | t.content ->> 'name' AS team_name, + | t.content ->> 'avatar' AS team_avatar, + | t.content ->> '_tenant' AS team_tenant, + | json_agg(json_build_object( + | '_id', u.content ->> '_id', + | 'name', u.content ->> 'name', + | 'email', u.content ->> 'email' + | ) + | ) AS users + |FROM teams t + | CROSS JOIN LATERAL jsonb_array_elements(t.content->'users') AS team_user(val) + | JOIN users u ON u._id = (team_user.val->>'userId')::text + |GROUP BY t._id, t.content ->> 'name';""".stripMargin, + Seq( + Col("team_id", ColString), + Col("team_name", ColString), + Col("team_tenant", ColString), + Col("team_avatar", ColString), + Col("users", ColJsonArray) + ) + ) + .map(row => + val teamId = (row \"team_id").as[String] + val teamName = (row \"team_name").as[String] + val teamAvatar = (row \"team_avatar").as[String] + val teamTenant = (row \"team_tenant").as[String] + val users = (row \"users").asOpt[Seq[JsValue]] + .map(_.filter(_ != JsNull).map(UserSimple.readFromJson)) + .getOrElse(Seq.empty) + + Json.obj( + "teamId" -> teamId, + "teamName" -> teamName, + "teamAvatar" -> teamAvatar, + "teamTenant" -> teamTenant, + "users" -> JsArray(users.map(_.json))) + ) + .runWith(Sink.seq) + .map( teams => Ok(JsArray(teams))) + } + } + + def userTeams(userId: String) = + DaikokuAction.async { ctx => + env.dataStore.userRepo + .findById(userId) + .flatMap { + case Some(user) => env.dataStore + .teamRepo + .forTenant(ctx.tenant.id) + .findNotDeleted( + Json.obj("users.userId" -> userId) + ).map( + teams => Ok( + JsArray( + teams.map( + team => team.asSimpleJson + ) + ) + ) + ) + case None => FastFuture.successful( + NotFound(Json.obj("error" -> "teams not found")) + ) + } + } + + def oneOfMyTeam(teamId: String) = + DaikokuAction.async { ctx => + TeamMemberOnly( + AuditTrailEvent( + "@{user.name} has accessed on of his team @{team.name} - @{team.id}" + ) + )(teamId, ctx) { team => + ctx.setCtxValue("team.name", team.name) + ctx.setCtxValue("team.id", team.id) + + FastFuture.successful(Right(Ok(team.toUiPayload()))) + } + } + + def myOwnTeam() = + DaikokuAction.async { ctx => + PublicUserAccess( + AuditTrailEvent( + s"@{user.name} has accessed its first team on @{tenant.name}" + ) + )(ctx) { + env.dataStore.teamRepo + .forTenant(ctx.tenant.id) + .findOne( + Json.obj( + "_deleted" -> false, + "type" -> TeamType.Personal.name, + "users.userId" -> ctx.user.id.asJson + ) + ) + .map { + case None => NotFound(Json.obj("error" -> "Team not found")) + case Some(team) if team.includeUser(ctx.user.id) => + Ok(team.asSimpleJson) + case _ => + Unauthorized( + Json.obj("error" -> "You're not authorized on this team") + ) + } + } + } + def subscribedApis(teamId: String) = DaikokuAction.async { ctx => TeamMemberOnly( diff --git a/daikoku/app/fr/maif/daikoku/storage/drivers/postgres/PostgresDataStore.scala b/daikoku/app/fr/maif/daikoku/storage/drivers/postgres/PostgresDataStore.scala index 5a31a0da4..9341905f8 100644 --- a/daikoku/app/fr/maif/daikoku/storage/drivers/postgres/PostgresDataStore.scala +++ b/daikoku/app/fr/maif/daikoku/storage/drivers/postgres/PostgresDataStore.scala @@ -6,7 +6,7 @@ import fr.maif.daikoku.domain.json._ import fr.maif.daikoku.env.Env import fr.maif.daikoku.logger.AppLogger import io.vertx.core.json.JsonObject -import io.vertx.sqlclient.Pool +import io.vertx.sqlclient.{Pool, Row} import org.apache.pekko.NotUsed import org.apache.pekko.http.scaladsl.util.FastFuture import org.apache.pekko.stream.Materializer @@ -23,6 +23,65 @@ import scala.collection.mutable.ListBuffer import scala.concurrent.{ExecutionContext, Future} import scala.jdk.CollectionConverters.IterableHasAsScala +sealed trait ColType +case object ColString extends ColType +case object ColUUID extends ColType +case object ColInt extends ColType +case object ColLong extends ColType +//case object ColDouble extends ColType +//case object ColFloat extends ColType +//case object ColBigDecimal extends ColType +case object ColBoolean extends ColType +case object ColJson extends ColType +case object ColJsonArray extends ColType +//case object ColInstant extends ColType +//case object ColLocalDate extends ColType +//case object ColLocalTime extends ColType +//case object ColLocalDateTime extends ColType +//case object ColDuration extends ColType +//case object ColByteArray extends ColType + +case class Col(name: String, tpe: ColType) + +object Col { + def str(name: String) = Col(name, ColString) + def uuid(name: String) = Col(name, ColUUID) + def int(name: String) = Col(name, ColInt) + def long(name: String) = Col(name, ColLong) + // def dbl(name: String) = Col(name, ColDouble) + // def float(name: String) = Col(name, ColFloat) + // def decimal(name: String) = Col(name, ColBigDecimal) + def bool(name: String) = Col(name, ColBoolean) + def array(name: String) = Col(name, ColJsonArray) + def json(name: String) = Col(name, ColJson) + // def instant(name: String) = Col(name, ColInstant) + // def date(name: String) = Col(name, ColLocalDate) + // def time(name: String) = Col(name, ColLocalTime) + // def dateTime(name: String) = Col(name, ColLocalDateTime) + // def duration(name: String) = Col(name, ColDuration) + // def bytes(name: String) = Col(name, ColByteArray) +} + +private def readCol(row: Row, col: Col): Option[JsValue] = + col.tpe match { + case ColString => row.optString(col.name).map(JsString(_)) + case ColUUID => row.optString(col.name).map(JsString(_)) + case ColInt => row.optLong(col.name).map(v => JsNumber(v)) + case ColLong => row.optLong(col.name).map(v => JsNumber(v)) + // case ColDouble => row.optDouble(col.name).map(v => JsNumber(v)) + // case ColFloat => row.optFloat(col.name).map(v => JsNumber(v.toDouble)) + // case ColBigDecimal => row.optBigDecimal(col.name).map(v => JsNumber(v)) + case ColBoolean => row.optBoolean(col.name).map(JsBoolean(_)) + case ColJson => row.optJsObject(col.name) + case ColJsonArray => row.optJsArray(col.name) + // case ColInstant => row.optInstant(col.name).map(v => JsString(v.toString)) + // case ColLocalDate => row.optLocalDate(col.name).map(v => JsString(v.toString)) + // case ColLocalTime => row.optLocalTime(col.name).map(v => JsString(v.toString)) + // case ColLocalDateTime => row.optLocalDateTime(col.name).map(v => JsString(v.toString)) + // case ColDuration => row.optDuration(col.name).map(v => JsString(v.toString)) + // case ColByteArray => row.optByteArray(col.name).map(v => JsString(java.util.Base64.getEncoder.encodeToString(v))) + } + trait PostgresTenantCapableRepo[A, Id <: ValueType] extends TenantCapableRepo[A, Id] { @@ -258,6 +317,23 @@ case class PostgresTenantCapableSubscriptionDemandRepo( _tenantRepo(tenant) } +//case class PostgresTenantCapableJobInformationRepo( +// _repo: () => PostgresRepo[JobInformation, DatastoreId], +// _tenantRepo: TenantId => PostgresTenantAwareRepo[ +// JobInformation, +// DatastoreId +// ] +//) extends PostgresTenantCapableRepo[JobInformation, DatastoreId] +// with JobInformationRepo { +// override def repo(): PostgresRepo[JobInformation, DatastoreId] = +// _repo() +// +// override def tenantRepo( +// tenant: TenantId +// ): PostgresTenantAwareRepo[JobInformation, DatastoreId] = +// _tenantRepo(tenant) +//} + case class PostgresTenantCapableStepValidatorRepo( _repo: () => PostgresRepo[StepValidator, DatastoreId], _tenantRepo: TenantId => PostgresTenantAwareRepo[StepValidator, DatastoreId] @@ -313,7 +389,7 @@ case class PostgresTenantCapableConsumptionRepo( implicit val jsObjectFormat: OFormat[JsObject] = new OFormat[JsObject] { override def reads(json: JsValue): JsResult[JsObject] = - json.validate[JsObject](Reads.JsObjectReads) + json.validate[JsObject](using Reads.JsObjectReads) override def writes(o: JsObject): JsObject = o } @@ -429,11 +505,12 @@ class PostgresDataStore(configuration: Configuration, env: Env, pgPool: Pool) "usage_plans" -> true, "assets" -> true, "reports_info" -> true, - "api_subscription_transfers" -> true + "api_subscription_transfers" -> true, + "job_informations" -> true ) private lazy val reactivePg = - new ReactivePg(pgPool, configuration)(ec) + new ReactivePg(pgPool, configuration)(using ec) def getSchema: String = configuration.get[String]("daikoku.postgres.schema") @@ -531,6 +608,12 @@ class PostgresDataStore(configuration: Configuration, env: Env, pgPool: Pool) t => new PostgresTenantSubscriptionDemandRepo(env, reactivePg, t) ) +// private val _jobInformationRepo: JobInformationRepo = +// PostgresTenantCapableJobInformationRepo( +// () => new PostgresJobInformationRepo(env, reactivePg), +// t => new PostgresTenantJobInformationRepo(env, reactivePg, t) +// ) + private val _stepValidatorRepo: StepValidatorRepo = PostgresTenantCapableStepValidatorRepo( () => new PostgresStepValidatorRepo(env, reactivePg), @@ -604,6 +687,8 @@ class PostgresDataStore(configuration: Configuration, env: Env, pgPool: Pool) override def apiSubscriptionTransferRepo: ApiSubscriptionTransferRepo = _apiSubscriptionTransferRepo +// override def JobInformationRepo: JobInformationRepo = _jobInformationRepo + override def queryOneRaw(query: String, name: String, params: Seq[AnyRef])( implicit ec: ExecutionContext ): Future[Option[JsObject]] = { @@ -659,6 +744,31 @@ class PostgresDataStore(configuration: Configuration, env: Env, pgPool: Pool) } } + def queryRawMapped(query: String, columns: Seq[Col], params: Seq[AnyRef])( + implicit ec: ExecutionContext + ): Future[Seq[JsObject]] = { + logger.debug(s"queryRaw($query)") + + reactivePg.querySeq(query = query, params = params) { row => + val fields = columns.flatMap(col => readCol(row, col).map(col.name -> _)) + Some(JsObject(fields)) + } + } + + def queryRawMappedStream( + query: String, + columns: Seq[Col], + params: Seq[AnyRef] = Seq.empty, + fetchSize: Int = 50 + )(implicit mat: org.apache.pekko.stream.Materializer): Source[JsObject, ?] = { + logger.debug(s"queryRawMappedStream($query)") + + reactivePg.queryStreamSource(query, params, fetchSize) { row => + val fields = columns.flatMap(col => readCol(row, col).map(col.name -> _)) + Some(JsObject(fields)) + } + } + override def queryString(query: String, name: String, params: Seq[AnyRef])( implicit ec: ExecutionContext ): Future[Seq[String]] = { @@ -769,8 +879,8 @@ class PostgresDataStore(configuration: Configuration, env: Env, pgPool: Pool) ec: ExecutionContext, mat: Materializer, env: Env - ): Source[ByteString, _] = { - val collections = ListBuffer[Repo[_, _]]() + ): Source[ByteString, ?] = { + val collections = ListBuffer[Repo[?, ?]]() collections ++= List( tenantRepo, userRepo, @@ -822,7 +932,7 @@ class PostgresDataStore(configuration: Configuration, env: Env, pgPool: Pool) } } - override def importFromStream(source: Source[ByteString, _]): Future[Unit] = { + override def importFromStream(source: Source[ByteString, ?]): Future[Unit] = { logger.debug("importFromStream") Future @@ -930,7 +1040,7 @@ class PostgresDataStore(configuration: Configuration, env: Env, pgPool: Pool) FastFuture.successful(false) } .toMat(Sink.ignore)(Keep.right) - .run()(env.defaultMaterializer) + .run()(using env.defaultMaterializer) } .map(_ => logger.info("importFromStream ended")) } @@ -948,7 +1058,7 @@ class PostgresDataStore(configuration: Configuration, env: Env, pgPool: Pool) .mapAsync(5)(query => { reactivePg.query(query) }) - .runWith(Sink.ignore)(env.defaultMaterializer) + .runWith(Sink.ignore)(using env.defaultMaterializer) .map(_ => ()) } } @@ -1088,6 +1198,22 @@ class PostgresTenantSubscriptionDemandRepo( override def extractId(value: SubscriptionDemand): String = value.id.value } +//class PostgresTenantJobInformationRepo( +// env: Env, +// reactivePg: ReactivePg, +// tenant: TenantId +//) extends PostgresTenantAwareRepo[JobInformation, DatastoreId]( +// env, +// reactivePg, +// tenant +// ) { +// override def tableName: String = "job_informations" +// +// override def format: Format[JobInformation] = +// json.JobInformationFormat +// +// override def extractId(value: JobInformation): String = value.id.value +//} class PostgresTenantStepValidatorRepo( env: Env, @@ -1386,6 +1512,18 @@ class PostgresSubscriptionDemandRepo(env: Env, reactivePg: ReactivePg) override def extractId(value: SubscriptionDemand): String = value.id.value } +//class PostgresJobInformationRepo(env: Env, reactivePg: ReactivePg) +// extends PostgresRepo[JobInformation, DatastoreId]( +// env, +// reactivePg +// ) { +// override def tableName: String = "job_informations" +// +// override def format: Format[JobInformation] = +// json.JobInformationFormat +// +// override def extractId(value: JobInformation): String = value.id.value +//} class PostgresStepValidatorRepo(env: Env, reactivePg: ReactivePg) extends PostgresRepo[StepValidator, DatastoreId](env, reactivePg) { @@ -2041,7 +2179,7 @@ abstract class CommonRepo[Of, Id <: ValueType](env: Env, reactivePg: ReactivePg) implicit val jsObjectFormat: OFormat[JsObject] = new OFormat[JsObject] { override def reads(json: JsValue): JsResult[JsObject] = - json.validate[JsObject](Reads.JsObjectReads) + json.validate[JsObject](using Reads.JsObjectReads) override def writes(o: JsObject): JsObject = o } diff --git a/daikoku/app/fr/maif/daikoku/storage/drivers/postgres/ReactivePg.scala b/daikoku/app/fr/maif/daikoku/storage/drivers/postgres/ReactivePg.scala index 1ab0b757c..27d92311d 100644 --- a/daikoku/app/fr/maif/daikoku/storage/drivers/postgres/ReactivePg.scala +++ b/daikoku/app/fr/maif/daikoku/storage/drivers/postgres/ReactivePg.scala @@ -2,6 +2,12 @@ package fr.maif.daikoku.storage.drivers.postgres import io.vertx.sqlclient.{Pool, Row, RowSet} import org.apache.pekko.http.scaladsl.util.FastFuture +import org.apache.pekko.stream.{ + Materializer, + OverflowStrategy, + QueueOfferResult +} +import org.apache.pekko.stream.scaladsl.Source import play.api.libs.json.{JsArray, JsObject, Json} import play.api.{Configuration, Logger} @@ -164,6 +170,57 @@ class ReactivePg(pool: Pool, configuration: Configuration)(implicit ) .scala + def queryStreamSource[A]( + sql: String, + params: Seq[AnyRef] = Seq.empty, + fetchSize: Int = 50 + )(f: Row => Option[A])(implicit mat: Materializer): Source[A, ?] = { + val (queue, source) = Source + .queue[Row](fetchSize * 2, OverflowStrategy.backpressure) + .preMaterialize() + + pool + .withTransaction(conn => + conn.prepare(sql).compose { ps => + val stream = ps.createStream( + fetchSize, + io.vertx.sqlclient.Tuple.from(params.toArray) + ) + val streamDone = io.vertx.core.Promise.promise[Void]() + stream.pause() + + def feedNext(): Unit = stream.fetch(1) + + stream.handler { row => + queue + .offer(row) + .foreach { + case QueueOfferResult.Enqueued => feedNext() + case QueueOfferResult.Dropped => feedNext() + case QueueOfferResult.QueueClosed => stream.close() + case QueueOfferResult.Failure(e) => + logger.error(s"Queue offer failed: ${e.getMessage}", e) + stream.close() + }(using ec) + } + stream.endHandler { _ => + queue.complete() + streamDone.complete(null) + } + stream.exceptionHandler { e => + logger.error(s"Stream error: ${e.getMessage}", e) + queue.fail(e) + streamDone.fail(e) + } + + feedNext() + streamDone.future() + } + ) + + source.mapConcat(row => f(row).toList) + } + def execute(sql: String, params: Seq[AnyRef] = Seq.empty): Future[Long] = { val promise = Promise[Long]() diff --git a/daikoku/conf/routes b/daikoku/conf/routes index 15d27ac08..29e3bb15c 100644 --- a/daikoku/conf/routes +++ b/daikoku/conf/routes @@ -488,6 +488,7 @@ GET /integration-api/:teamId/:apiId/documentation fr.maif.daikoku.controll GET /integration-api/:teamId/:apiId/apidoc fr.maif.daikoku.controllers.IntegrationApiController.apiSwagger(teamId, apiId) GET /integration-api/:teamId/:apiId fr.maif.daikoku.controllers.IntegrationApiController.api(teamId, apiId) +GET /api/users fr.maif.daikoku.controllers.ApiController.getUsers() ### NoDocs ### GET /*path fr.maif.daikoku.controllers.HomeController.indexWithPath(path) diff --git a/daikoku/javascript/src/apps/DaikokuApp.tsx b/daikoku/javascript/src/apps/DaikokuApp.tsx index f502cb64b..b800dc45a 100644 --- a/daikoku/javascript/src/apps/DaikokuApp.tsx +++ b/daikoku/javascript/src/apps/DaikokuApp.tsx @@ -48,6 +48,7 @@ import { MessagesEvents } from '../services/messages'; import { ITenant } from '../types'; import { ResetPassword, ResetPasswordEnd, TwoFactorAuthentication } from './DaikokuHomeApp'; import {MaintenancePage} from "../components/frontend/Maintenance"; +import {UserTeamList, UserTeamPanel} from "../components/adminbackoffice/teams/UserTeamList"; const RouteWithFooterLayout = () => ( <> @@ -338,6 +339,14 @@ export const DaikokuApp = () => { } /> + + + + } + /> { + if (!fullName) return ""; + + const parts = fullName.trim().split(/\s+/); + + if (parts.length === 0) return; + if (parts.length === 1) { + return parts[0][0].toUpperCase(); + } + + const first = parts[0][0].toUpperCase(); + const last = parts[parts.length - 1][0].toUpperCase(); + + return first + last; +} + + +export const UserTeamList = () => { + const {translate} = useContext(I18nContext) + const { tenant } = useTenantBackOffice(); + + const teamsWithUsersRequest = useQuery({ + queryKey: ['tenantUsers'], + queryFn: () => Services.userTeams(tenant._id, "", "") + }); + + const invertTeamsToUsers = (teams : ITeamsWithUsers[]) =>{ + return teams.reduce((acc, team) => { + const users = team.users ?? []; + users.forEach(({ _id, name, email }) => { + if (!acc[_id]) { + acc[_id] = {_id, name, email, teams: []} + } + acc[_id].teams.push({ + teamId: team.teamId, + teamName : team.teamName + }); + }); + return acc; + }, {}); + } + + if (teamsWithUsersRequest.isLoading) { + return + } else if (teamsWithUsersRequest.data && !isError(teamsWithUsersRequest.data)) { + const usersWithTeams : IUserWithTeams[] = Object.values(invertTeamsToUsers(teamsWithUsersRequest.data)) + return ( +
+
+
+
+
+
+ { + usersWithTeams + .map((user) => { + return ( +
+ +
+ {user.teams.map( team => + +
+
+
+
+ {team.teamAvatar?.includes('anonymous') || !team.teamAvatar &&
{getInitials(team.teamName)}
} + {!team.teamAvatar?.includes('anonymous') && !!team.teamAvatar && avatar} +
+
{team.teamName}
+
+
+ + )} +
+
+ ); + })} +
+
+
+
+
+
+ ) + } else { + return ( + {translate('oops, something went wrong.')} + ) +} +} + +export const UserTeamPanel = () => { + const {connectedUser} = useContext(GlobalContext) + + if (connectedUser.isGuest) { + return null; + } + return ( + + ) +} diff --git a/daikoku/javascript/src/components/utils/AvatarWithAction.tsx b/daikoku/javascript/src/components/utils/AvatarWithAction.tsx index 75620cb2b..67636a216 100644 --- a/daikoku/javascript/src/components/utils/AvatarWithAction.tsx +++ b/daikoku/javascript/src/components/utils/AvatarWithAction.tsx @@ -50,7 +50,7 @@ export const AvatarWithAction = (props: Props) => { action(); }; - const getAction = (action: any, idx: any) => { + const getAction = (action: any) => { const uuid = nanoid(); let ActionComponent; @@ -108,8 +108,8 @@ export const AvatarWithAction = (props: Props) => { {!props.avatar?.includes('anonymous') && !!props.avatar && avatar}
{props.infos}
- {!secondaryActions.length && props.actions.map((action, idx) => getAction(action, idx))} - {!!secondaryActions.length && secondaryActions.map((action, idx) => getAction(action, idx))} + {!secondaryActions.length && props.actions.map((action) => getAction(action))} + {!!secondaryActions.length && secondaryActions.map((action) => getAction(action))} ); diff --git a/daikoku/javascript/src/contexts/navContext.tsx b/daikoku/javascript/src/contexts/navContext.tsx index 7154f7a11..685fe9fb7 100644 --- a/daikoku/javascript/src/contexts/navContext.tsx +++ b/daikoku/javascript/src/contexts/navContext.tsx @@ -173,7 +173,7 @@ export const useApiFrontOffice = (api?: IApi, team?: ITeamSimple, plans?: IUsage order: 2, links: { contact: { - component: ( connectedUser.isGuest ? + component: ( connectedUser.isGuest ? { action: () => navigateTo('audit'), className: { active: currentTab === 'audit' }, }, + users: { + label: "Tenant Users", + action: () => navigateTo('tenantUsers'), + className: { active: currentTab === 'tenantUsers' }, + }, teams: { label: translate('Teams'), action: () => navigateTo('teams'), @@ -543,4 +548,4 @@ export const useDaikokuBackOffice = (props?: { creation?: boolean }) => { }, [match]); return { addMenu }; -}; \ No newline at end of file +}; diff --git a/daikoku/javascript/src/locales/en/translation.json b/daikoku/javascript/src/locales/en/translation.json index 96c7e1a00..f4085bcf6 100644 --- a/daikoku/javascript/src/locales/en/translation.json +++ b/daikoku/javascript/src/locales/en/translation.json @@ -55,6 +55,8 @@ "p": "Notifications" }, "Tenant administration": "Tenant administration", + "navigation.users": "Users", + "tenant.user.teams": "Teams's user", "Audit trail": "Audit trail", "Tenant assets": "Tenant assets", "Daikoku administration": "Daikoku administration", diff --git a/daikoku/javascript/src/locales/fr/translation.json b/daikoku/javascript/src/locales/fr/translation.json index 5d5ee13af..e8d86bfee 100644 --- a/daikoku/javascript/src/locales/fr/translation.json +++ b/daikoku/javascript/src/locales/fr/translation.json @@ -56,6 +56,8 @@ }, "Tenant administration": "Administration du tenant", "Audit trail": "Piste d'audit", + "navigation.users": "Utilisateurs", + "tenant.user.teams": "Equipes de l'utilisateur", "Tenant assets": "Tenant assets", "Daikoku administration": "Administration Daikoku", "Users": "Utilisateurs", diff --git a/daikoku/javascript/src/services/index.ts b/daikoku/javascript/src/services/index.ts index b3d9d391e..ce38e8aac 100644 --- a/daikoku/javascript/src/services/index.ts +++ b/daikoku/javascript/src/services/index.ts @@ -1,5 +1,6 @@ -import { TDashboardData } from '../components/frontend/dashboard/Dashboard'; -import { SearchResult } from '../components/utils/sidebar/panels/SearchPanel'; +import { types } from "sass"; +import {TDashboardData} from '../components/frontend/dashboard/Dashboard'; +import {SearchResult} from '../components/utils/sidebar/panels/SearchPanel'; import { I2FAQrCode, IAsset, @@ -24,7 +25,7 @@ import { ISimpleOtoroshiSettings, IAnonymousState, IAuthContext, - OAuthSettings, + OAuthSettings, IUserTeamsSimple, } from '../types'; import { IApi, @@ -48,7 +49,7 @@ import { ResponseDone, ResponseError, } from '../types/api'; -import { IChatInfo } from '../types/chat'; +import {IChatInfo} from '../types/chat'; const HEADERS = { Accept: 'application/json', @@ -58,9 +59,9 @@ const HEADERS = { type PromiseWithError = Promise; const customFetch = ( url: string, - { headers = HEADERS, method = 'GET', body, ...props }: any = {} + {headers = HEADERS, method = 'GET', body, ...props}: any = {} ) => - fetch(url, { headers, method, body, ...props }).then((r) => { + fetch(url, {headers, method, body, ...props}).then((r) => { if (r.status === 503) { location.href = "/maintenance" } @@ -97,7 +98,15 @@ export const getTeamVisibleApi = ( customFetch(`/api/me/teams/${teamId}/visible-apis/${apiId}/${version}`); export const myTeams = (): Promise> => customFetch('/api/me/teams'); - +export const userTeams = (tenantId: string, teamId: string = "", userId: string = ""): Promise> => +{ + const params = new URLSearchParams() + if (userId) params.append("userId", userId); + if (teamId) params.append("teamId", teamId); + if (tenantId) params.append("tenantId", tenantId); + const queryString = params.toString(); + return customFetch(`/api/users${queryString ? `?${queryString}` : ""}`); +} export const myUnreadNotificationsCount = (): Promise<{ count: number }> => fetch('/api/me/notifications/unread-count') .then( @@ -1338,6 +1347,25 @@ export const graphql = { } } `, + userTeams: ` + query userTeams { + userTeams { + name + _humanReadableId + _id + type + apiKeyVisibility + apisCreationPermission + verified + users { + user { + userId: id + } + teamPermission + } + } + } + `, apisByIds: ` query filteredApis ($ids: [String!]) { apis (ids: $ids) { diff --git a/daikoku/javascript/src/types/team.ts b/daikoku/javascript/src/types/team.ts index 1d5b9b234..116213c69 100644 --- a/daikoku/javascript/src/types/team.ts +++ b/daikoku/javascript/src/types/team.ts @@ -29,6 +29,35 @@ export interface ITeamFull extends ITeamSimple { metadata: object; } + +export interface IUserTeamsSimple { + teamId: string; + teamName: string; + teamAvatar: string; + teamTenant: string; + users: IUserVerySimple[]; +} + +export interface IUserVerySimple { + _id: string; + name: string; + email: string; +} + +export interface IUserWithTeams extends IUserVerySimple{ + teams: ITeamsForUsers[]; +} + +export interface ITeamsForUsers { + teamId: string; + teamName: string; + teamAvatar: string; +} + +export interface ITeamsWithUsers extends ITeamsForUsers{ + users: Array; +} + export interface IUserSimple { _id: string; _humanReadableId: string; diff --git a/daikoku/test/fr/maif/daikoku/controllers/ApiControllerSpec.scala b/daikoku/test/fr/maif/daikoku/controllers/ApiControllerSpec.scala index b3f966756..dfeeb13ac 100644 --- a/daikoku/test/fr/maif/daikoku/controllers/ApiControllerSpec.scala +++ b/daikoku/test/fr/maif/daikoku/controllers/ApiControllerSpec.scala @@ -15,6 +15,8 @@ import fr.maif.daikoku.domain.NotificationType.AcceptOrReject import fr.maif.daikoku.domain.TeamPermission.Administrator import fr.maif.daikoku.domain.UsagePlanVisibility.{Private, Public} import fr.maif.daikoku.domain.json.{ApiFormat, SeqApiSubscriptionFormat} +import fr.maif.daikoku.domain._ +import fr.maif.daikoku.domain.json.{ApiFormat, SeqApiSubscriptionFormat} import fr.maif.daikoku.testUtils.DaikokuSpecHelper import fr.maif.daikoku.utils.IdGenerator import fr.maif.daikoku.utils.LoggerImplicits.BetterLogger @@ -506,6 +508,26 @@ class ApiControllerSpec() sub.apiKey.clientName mustBe expectedName } + "sees his daikoku users' teams" in { + setupEnvBlocking( + tenants = Seq(tenant), + users = Seq(tenantAdmin, userAdmin), + teams = Seq(defaultAdminTeam, teamOwner, teamConsumer), + apis = Seq.empty + ) + + val session = loginWithBlocking(tenantAdmin, tenant) + val resp = httpJsonCallBlocking("/api/users")(tenant, session) + + resp.status mustBe 200 + + val resultTeams = resp.json.as[JsArray].value + resultTeams.length mustBe 4 + + val resultUsers = + resultTeams.flatMap(team => (team \ "users").as[JsArray].value) + resultUsers.length mustBe 4 + } } "a team administrator" can {