diff --git a/backend/src/@types/fastify.d.ts b/backend/src/@types/fastify.d.ts index 8ae892560..cfffdeac8 100644 --- a/backend/src/@types/fastify.d.ts +++ b/backend/src/@types/fastify.d.ts @@ -50,6 +50,7 @@ import { TIntegrationServiceFactory } from "@app/services/integration/integratio import { TIntegrationAuthServiceFactory } from "@app/services/integration-auth/integration-auth-service"; import { TOrgRoleServiceFactory } from "@app/services/org/org-role-service"; import { TOrgServiceFactory } from "@app/services/org/org-service"; +import { TOrgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { TProjectServiceFactory } from "@app/services/project/project-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env-service"; @@ -165,6 +166,7 @@ declare module "fastify" { rateLimit: TRateLimitServiceFactory; userEngagement: TUserEngagementServiceFactory; externalKms: TExternalKmsServiceFactory; + orgAdmin: TOrgAdminServiceFactory; }; // this is exclusive use for middlewares in which we need to inject data // everywhere else access using service layer diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index bfc1dbf92..6f78ea0d6 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -147,7 +147,8 @@ export enum EventType { GET_KMS = "get-kms", UPDATE_PROJECT_KMS = "update-project-kms", GET_PROJECT_KMS_BACKUP = "get-project-kms-backup", - LOAD_PROJECT_KMS_BACKUP = "load-project-kms-backup" + LOAD_PROJECT_KMS_BACKUP = "load-project-kms-backup", + ORG_ADMIN_ACCESS_PROJECT = "org-admin-accessed-project" } interface UserActorMetadata { @@ -1245,6 +1246,16 @@ interface LoadProjectKmsBackupEvent { metadata: Record; // no metadata yet } +interface OrgAdminAccessProjectEvent { + type: EventType.ORG_ADMIN_ACCESS_PROJECT; + metadata: { + userId: string; + username: string; + email: string; + projectId: string; + }; // no metadata yet +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -1354,4 +1365,5 @@ export type Event = | GetKmsEvent | UpdateProjectKmsEvent | GetProjectKmsBackupEvent - | LoadProjectKmsBackupEvent; + | LoadProjectKmsBackupEvent + | OrgAdminAccessProjectEvent; diff --git a/backend/src/ee/services/permission/org-permission.ts b/backend/src/ee/services/permission/org-permission.ts index 77eaacd3b..4c0770ad9 100644 --- a/backend/src/ee/services/permission/org-permission.ts +++ b/backend/src/ee/services/permission/org-permission.ts @@ -9,6 +9,10 @@ export enum OrgPermissionActions { Delete = "delete" } +export enum OrgPermissionAdminConsoleAction { + AccessAllProjects = "access-all-projects" +} + export enum OrgPermissionSubjects { Workspace = "workspace", Role = "role", @@ -22,7 +26,8 @@ export enum OrgPermissionSubjects { Billing = "billing", SecretScanning = "secret-scanning", Identity = "identity", - Kms = "kms" + Kms = "kms", + AdminConsole = "organization-admin-console" } export type OrgPermissionSet = @@ -39,7 +44,8 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Identity] - | [OrgPermissionActions, OrgPermissionSubjects.Kms]; + | [OrgPermissionActions, OrgPermissionSubjects.Kms] + | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; const buildAdminPermission = () => { const { can, build } = new AbilityBuilder>(createMongoAbility); @@ -107,6 +113,8 @@ const buildAdminPermission = () => { can(OrgPermissionActions.Edit, OrgPermissionSubjects.Kms); can(OrgPermissionActions.Delete, OrgPermissionSubjects.Kms); + can(OrgPermissionAdminConsoleAction.AccessAllProjects, OrgPermissionSubjects.AdminConsole); + return build({ conditionsMatcher }); }; diff --git a/backend/src/lib/knex/index.ts b/backend/src/lib/knex/index.ts index c01d146ec..dcab16218 100644 --- a/backend/src/lib/knex/index.ts +++ b/backend/src/lib/knex/index.ts @@ -19,23 +19,43 @@ export const withTransaction = (db: Knex, dal: K) => ({ export type TFindFilter = Partial & { $in?: Partial<{ [k in keyof R]: R[k][] }>; + $search?: Partial<{ [k in keyof R]: R[k] }>; }; export const buildFindFilter = - ({ $in, ...filter }: TFindFilter) => + ({ $in, $search, ...filter }: TFindFilter) => (bd: Knex.QueryBuilder) => { void bd.where(filter); if ($in) { Object.entries($in).forEach(([key, val]) => { - void bd.whereIn(key as never, val as never); + if (val) { + void bd.whereIn(key as never, val as never); + } + }); + } + if ($search) { + Object.entries($search).forEach(([key, val]) => { + if (val) { + void bd.whereILike(key as never, val as never); + } }); } return bd; }; -export type TFindOpt = { +export type TFindReturn = Array< + Awaited[0] & + (TCount extends true + ? { + count: string; + } + : unknown) +>; + +export type TFindOpt = { limit?: number; offset?: number; sort?: Array<[keyof R, "asc" | "desc"] | [keyof R, "asc" | "desc", "first" | "last"]>; + count?: TCount; tx?: Knex; }; @@ -66,18 +86,22 @@ export const ormify = (db: Kne throw new DatabaseError({ error, name: "Find one" }); } }, - find: async ( + find: async ( filter: TFindFilter, - { offset, limit, sort, tx }: TFindOpt = {} + { offset, limit, sort, count, tx }: TFindOpt = {} ) => { try { const query = (tx || db.replicaNode())(tableName).where(buildFindFilter(filter)); + if (count) { + void query.select(db.raw("COUNT(*) OVER() AS count")); + void query.select("*"); + } if (limit) void query.limit(limit); if (offset) void query.offset(offset); if (sort) { void query.orderBy(sort.map(([column, order, nulls]) => ({ column: column as string, order, nulls }))); } - const res = await query; + const res = (await query) as TFindReturn; return res; } catch (error) { throw new DatabaseError({ error, name: "Find one" }); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index e8f80f020..a6ef33a72 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -129,6 +129,7 @@ import { orgDALFactory } from "@app/services/org/org-dal"; import { orgRoleDALFactory } from "@app/services/org/org-role-dal"; import { orgRoleServiceFactory } from "@app/services/org/org-role-service"; import { orgServiceFactory } from "@app/services/org/org-service"; +import { orgAdminServiceFactory } from "@app/services/org-admin/org-admin-service"; import { orgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal"; import { projectDALFactory } from "@app/services/project/project-dal"; import { projectQueueFactory } from "@app/services/project/project-queue"; @@ -498,6 +499,16 @@ export const registerRoutes = async ( keyStore, licenseService }); + const orgAdminService = orgAdminServiceFactory({ + projectDAL, + permissionService, + projectUserMembershipRoleDAL, + userDAL, + projectBotDAL, + projectKeyDAL, + projectMembershipDAL + }); + const rateLimitService = rateLimitServiceFactory({ rateLimitDAL, licenseService @@ -1113,7 +1124,8 @@ export const registerRoutes = async ( identityProjectAdditionalPrivilege: identityProjectAdditionalPrivilegeService, secretSharing: secretSharingService, userEngagement: userEngagementService, - externalKms: externalKmsService + externalKms: externalKmsService, + orgAdmin: orgAdminService }); const cronJobs: CronJob[] = []; diff --git a/backend/src/server/routes/v1/index.ts b/backend/src/server/routes/v1/index.ts index 43ce44eaa..6c988d995 100644 --- a/backend/src/server/routes/v1/index.ts +++ b/backend/src/server/routes/v1/index.ts @@ -15,6 +15,7 @@ import { registerIdentityUaRouter } from "./identity-universal-auth-router"; import { registerIntegrationAuthRouter } from "./integration-auth-router"; import { registerIntegrationRouter } from "./integration-router"; import { registerInviteOrgRouter } from "./invite-org-router"; +import { registerOrgAdminRouter } from "./org-admin-router"; import { registerOrgRouter } from "./organization-router"; import { registerPasswordRouter } from "./password-router"; import { registerProjectEnvRouter } from "./project-env-router"; @@ -50,6 +51,7 @@ export const registerV1Routes = async (server: FastifyZodProvider) => { await server.register(registerPasswordRouter, { prefix: "/password" }); await server.register(registerOrgRouter, { prefix: "/organization" }); await server.register(registerAdminRouter, { prefix: "/admin" }); + await server.register(registerOrgAdminRouter, { prefix: "/organization-admin" }); await server.register(registerUserRouter, { prefix: "/user" }); await server.register(registerInviteOrgRouter, { prefix: "/invite-org" }); await server.register(registerUserActionRouter, { prefix: "/user-action" }); diff --git a/backend/src/server/routes/v1/org-admin-router.ts b/backend/src/server/routes/v1/org-admin-router.ts new file mode 100644 index 000000000..2d28b09bd --- /dev/null +++ b/backend/src/server/routes/v1/org-admin-router.ts @@ -0,0 +1,90 @@ +import { z } from "zod"; + +import { ProjectMembershipsSchema } from "@app/db/schemas"; +import { EventType } from "@app/ee/services/audit-log/audit-log-types"; +import { readLimit } from "@app/server/config/rateLimiter"; +import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; +import { AuthMode } from "@app/services/auth/auth-type"; + +import { SanitizedProjectSchema } from "../sanitizedSchemas"; + +export const registerOrgAdminRouter = async (server: FastifyZodProvider) => { + server.route({ + method: "GET", + url: "/projects", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + search: z.string().optional(), + offset: z.coerce.number().default(0), + limit: z.coerce.number().max(100).default(50) + }), + response: { + 200: z.object({ + projects: SanitizedProjectSchema.array(), + count: z.coerce.number() + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const { projects, count } = await server.services.orgAdmin.listOrgProjects({ + limit: req.query.limit, + offset: req.query.offset, + search: req.query.search, + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actor: req.permission.type + }); + return { projects, count }; + } + }); + + server.route({ + method: "POST", + url: "/projects/:projectId/grant-admin-access", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + projectId: z.string() + }), + response: { + 200: z.object({ + membership: ProjectMembershipsSchema + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT]), + handler: async (req) => { + const { membership } = await server.services.orgAdmin.grantProjectAdminAccess({ + actorOrgId: req.permission.orgId, + actorAuthMethod: req.permission.authMethod, + actorId: req.permission.id, + actor: req.permission.type, + projectId: req.params.projectId + }); + if (req.auth.authMode === AuthMode.JWT) { + await server.services.auditLog.createAuditLog({ + ...req.auditLogInfo, + projectId: req.params.projectId, + event: { + type: EventType.ORG_ADMIN_ACCESS_PROJECT, + metadata: { + projectId: req.params.projectId, + username: req.auth.user.username, + email: req.auth.user.email || "", + userId: req.auth.userId + } + } + }); + } + + return { membership }; + } + }); +}; diff --git a/backend/src/services/org-admin/org-admin-dal.ts b/backend/src/services/org-admin/org-admin-dal.ts new file mode 100644 index 000000000..da2ccf2f6 --- /dev/null +++ b/backend/src/services/org-admin/org-admin-dal.ts @@ -0,0 +1,5 @@ +export type TOrgAdminDALFactory = ReturnType; + +export const orgAdminDALFactory = () => { + return {}; +}; diff --git a/backend/src/services/org-admin/org-admin-service.ts b/backend/src/services/org-admin/org-admin-service.ts new file mode 100644 index 000000000..4759db309 --- /dev/null +++ b/backend/src/services/org-admin/org-admin-service.ts @@ -0,0 +1,191 @@ +import { ForbiddenError } from "@casl/ability"; + +import { ProjectMembershipRole, ProjectVersion, SecretKeyEncoding } from "@app/db/schemas"; +import { OrgPermissionAdminConsoleAction, OrgPermissionSubjects } from "@app/ee/services/permission/org-permission"; +import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; +import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; +import { BadRequestError } from "@app/lib/errors"; + +import { TProjectDALFactory } from "../project/project-dal"; +import { assignWorkspaceKeysToMembers } from "../project/project-fns"; +import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; +import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; +import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal"; +import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal"; +import { TUserDALFactory } from "../user/user-dal"; +import { TAccessProjectDTO, TListOrgProjectsDTO } from "./org-admin-types"; + +type TOrgAdminServiceFactoryDep = { + permissionService: Pick; + projectDAL: Pick; + projectMembershipDAL: Pick; + projectKeyDAL: Pick; + projectBotDAL: Pick; + userDAL: Pick; + projectUserMembershipRoleDAL: Pick; +}; + +export type TOrgAdminServiceFactory = ReturnType; + +export const orgAdminServiceFactory = ({ + permissionService, + projectDAL, + projectMembershipDAL, + projectKeyDAL, + projectBotDAL, + userDAL, + projectUserMembershipRoleDAL +}: TOrgAdminServiceFactoryDep) => { + const listOrgProjects = async ({ + actor, + limit, + actorId, + offset, + search, + actorOrgId, + actorAuthMethod + }: TListOrgProjectsDTO) => { + const { permission } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAdminConsoleAction.AccessAllProjects, + OrgPermissionSubjects.AdminConsole + ); + const projects = await projectDAL.find( + { + orgId: actorOrgId, + $search: { + name: search ? `%${search}%` : undefined + } + }, + { offset, limit, sort: [["name", "asc"]], count: true } + ); + + const count = projects?.[0]?.count ? parseInt(projects?.[0]?.count, 10) : 0; + return { projects, count }; + }; + + const grantProjectAdminAccess = async ({ + actor, + actorId, + actorOrgId, + actorAuthMethod, + projectId + }: TAccessProjectDTO) => { + const { permission, membership } = await permissionService.getOrgPermission( + actor, + actorId, + actorOrgId, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan( + OrgPermissionAdminConsoleAction.AccessAllProjects, + OrgPermissionSubjects.AdminConsole + ); + + const project = await projectDAL.findById(projectId); + if (!project) throw new BadRequestError({ message: "Project not found" }); + + if (project.version === ProjectVersion.V1) { + throw new BadRequestError({ message: "Please upgrade your project on your dashboard" }); + } + + // check already there exist a membership if there return it + const projectMembership = await projectMembershipDAL.findOne({ + projectId, + userId: actorId + }); + if (projectMembership) { + // reset and make the user admin + await projectMembershipDAL.transaction(async (tx) => { + await projectUserMembershipRoleDAL.delete({ projectMembershipId: projectMembership.id }, tx); + await projectUserMembershipRoleDAL.create( + { + projectMembershipId: projectMembership.id, + role: ProjectMembershipRole.Admin + }, + tx + ); + }); + return { isExistingMember: true, membership: projectMembership }; + } + + // missing membership thus add admin back as admin to project + const ghostUser = await projectDAL.findProjectGhostUser(projectId); + if (!ghostUser) { + throw new BadRequestError({ + message: "Failed to find sudo user" + }); + } + + const ghostUserLatestKey = await projectKeyDAL.findLatestProjectKey(ghostUser.id, projectId); + if (!ghostUserLatestKey) { + throw new BadRequestError({ + message: "Failed to find sudo user latest key" + }); + } + + const bot = await projectBotDAL.findOne({ projectId }); + if (!bot) { + throw new BadRequestError({ + message: "Failed to find bot" + }); + } + + const botPrivateKey = infisicalSymmetricDecrypt({ + keyEncoding: bot.keyEncoding as SecretKeyEncoding, + iv: bot.iv, + tag: bot.tag, + ciphertext: bot.encryptedPrivateKey + }); + + const userEncryptionKey = await userDAL.findUserEncKeyByUserId(actorId); + if (!userEncryptionKey) throw new BadRequestError({ message: "user encryption key not found" }); + const [newWsMember] = assignWorkspaceKeysToMembers({ + decryptKey: ghostUserLatestKey, + userPrivateKey: botPrivateKey, + members: [ + { + orgMembershipId: membership.id, + projectMembershipRole: ProjectMembershipRole.Admin, + userPublicKey: userEncryptionKey.publicKey + } + ] + }); + + const updatedMembership = await projectMembershipDAL.transaction(async (tx) => { + const newProjectMembership = await projectMembershipDAL.create( + { + projectId, + userId: actorId + }, + tx + ); + await projectUserMembershipRoleDAL.create( + { projectMembershipId: newProjectMembership.id, role: ProjectMembershipRole.Admin }, + tx + ); + + await projectKeyDAL.create( + { + encryptedKey: newWsMember.workspaceEncryptedKey, + nonce: newWsMember.workspaceEncryptedNonce, + senderId: ghostUser.id, + receiverId: actorId, + projectId + }, + tx + ); + return newProjectMembership; + }); + return { isExistingMember: false, membership: updatedMembership }; + }; + + return { listOrgProjects, grantProjectAdminAccess }; +}; diff --git a/backend/src/services/org-admin/org-admin-types.ts b/backend/src/services/org-admin/org-admin-types.ts new file mode 100644 index 000000000..85669fc56 --- /dev/null +++ b/backend/src/services/org-admin/org-admin-types.ts @@ -0,0 +1,11 @@ +import { TOrgPermission } from "@app/lib/types"; + +export type TListOrgProjectsDTO = { + limit?: number; + offset?: number; + search?: string; +} & Omit; + +export type TAccessProjectDTO = { + projectId: string; +} & Omit; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index a03aec934..8f87e8d55 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -256,7 +256,6 @@ export const projectMembershipServiceFactory = ({ } const bot = await projectBotDAL.findOne({ projectId }); - if (!bot) { throw new BadRequestError({ message: "Failed to find bot" diff --git a/frontend/src/components/v2/Pagination/Pagination.tsx b/frontend/src/components/v2/Pagination/Pagination.tsx index c8afb389b..f0ba950c1 100644 --- a/frontend/src/components/v2/Pagination/Pagination.tsx +++ b/frontend/src/components/v2/Pagination/Pagination.tsx @@ -50,7 +50,7 @@ export const Pagination = ({ >
- {(page - 1) * perPage} - {(page - 1) * perPage + perPage} of {count} + {(page - 1) * perPage} - {Math.min((page - 1) * perPage + perPage, count)} of {count}
diff --git a/frontend/src/context/OrgPermissionContext/types.ts b/frontend/src/context/OrgPermissionContext/types.ts index 36206873d..5b7ef0174 100644 --- a/frontend/src/context/OrgPermissionContext/types.ts +++ b/frontend/src/context/OrgPermissionContext/types.ts @@ -20,7 +20,12 @@ export enum OrgPermissionSubjects { Billing = "billing", SecretScanning = "secret-scanning", Identity = "identity", - Kms = "kms" + Kms = "kms", + AdminConsole = "organization-admin-console" +} + +export enum OrgPermissionAdminConsoleAction { + AccessAllProjects = "access-all-projects" } export type OrgPermissionSet = @@ -37,6 +42,7 @@ export type OrgPermissionSet = | [OrgPermissionActions, OrgPermissionSubjects.SecretScanning] | [OrgPermissionActions, OrgPermissionSubjects.Billing] | [OrgPermissionActions, OrgPermissionSubjects.Identity] - | [OrgPermissionActions, OrgPermissionSubjects.Kms]; + | [OrgPermissionActions, OrgPermissionSubjects.Kms] + | [OrgPermissionAdminConsoleAction, OrgPermissionSubjects.AdminConsole]; export type TOrgPermission = MongoAbility; diff --git a/frontend/src/hooks/api/auditLogs/constants.tsx b/frontend/src/hooks/api/auditLogs/constants.tsx index 082bff02c..819c36c20 100644 --- a/frontend/src/hooks/api/auditLogs/constants.tsx +++ b/frontend/src/hooks/api/auditLogs/constants.tsx @@ -56,7 +56,8 @@ export const eventToNameMap: { [K in EventType]: string } = { [EventType.GET_CERT]: "Get certificate", [EventType.DELETE_CERT]: "Delete certificate", [EventType.REVOKE_CERT]: "Revoke certificate", - [EventType.GET_CERT_BODY]: "Get certificate body" + [EventType.GET_CERT_BODY]: "Get certificate body", + [EventType.ORG_ADMIN_ACCESS_PROJECT]: "Org admin accessed project" }; export const userAgentTTypeoNameMap: { [K in UserAgentType]: string } = { diff --git a/frontend/src/hooks/api/auditLogs/enums.tsx b/frontend/src/hooks/api/auditLogs/enums.tsx index ad49998c5..94963f640 100644 --- a/frontend/src/hooks/api/auditLogs/enums.tsx +++ b/frontend/src/hooks/api/auditLogs/enums.tsx @@ -70,5 +70,6 @@ export enum EventType { GET_CERT = "get-cert", DELETE_CERT = "delete-cert", REVOKE_CERT = "revoke-cert", - GET_CERT_BODY = "get-cert-body" + GET_CERT_BODY = "get-cert-body", + ORG_ADMIN_ACCESS_PROJECT = "org-admin-accessed-project" } diff --git a/frontend/src/hooks/api/auditLogs/types.tsx b/frontend/src/hooks/api/auditLogs/types.tsx index cdc973ed9..1d80d6128 100644 --- a/frontend/src/hooks/api/auditLogs/types.tsx +++ b/frontend/src/hooks/api/auditLogs/types.tsx @@ -579,6 +579,16 @@ interface GetCertBody { }; } +interface OrgAdminAccessProjectEvent { + type: EventType.ORG_ADMIN_ACCESS_PROJECT; + metadata: { + userId: string; + username: string; + email: string; + projectId: string; + }; // no metadata yet +} + export type Event = | GetSecretsEvent | GetSecretEvent @@ -635,7 +645,8 @@ export type Event = | GetCert | DeleteCert | RevokeCert - | GetCertBody; + | GetCertBody + | OrgAdminAccessProjectEvent; export type AuditLog = { id: string; diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index ea3e7f560..5cbd1fb27 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -19,6 +19,7 @@ export * from "./keys"; export * from "./kms"; export * from "./ldapConfig"; export * from "./oidcConfig"; +export * from "./orgAdmin"; export * from "./organization"; export * from "./projectUserAdditionalPrivilege"; export * from "./rateLimit"; diff --git a/frontend/src/hooks/api/orgAdmin/index.tsx b/frontend/src/hooks/api/orgAdmin/index.tsx new file mode 100644 index 000000000..57eed413a --- /dev/null +++ b/frontend/src/hooks/api/orgAdmin/index.tsx @@ -0,0 +1,2 @@ +export { useOrgAdminAccessProject } from "./mutation"; +export { useOrgAdminGetProjects } from "./queries"; diff --git a/frontend/src/hooks/api/orgAdmin/mutation.tsx b/frontend/src/hooks/api/orgAdmin/mutation.tsx new file mode 100644 index 000000000..9fa93722e --- /dev/null +++ b/frontend/src/hooks/api/orgAdmin/mutation.tsx @@ -0,0 +1,15 @@ +import { useMutation } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { TOrgAdminAccessProjectDTO } from "./types"; + +export const useOrgAdminAccessProject = () => + useMutation({ + mutationFn: async ({ projectId }: TOrgAdminAccessProjectDTO) => { + const { data } = await apiRequest.post( + `/api/v1/organization-admin/projects/${projectId}/grant-admin-access` + ); + return data; + } + }); diff --git a/frontend/src/hooks/api/orgAdmin/queries.tsx b/frontend/src/hooks/api/orgAdmin/queries.tsx new file mode 100644 index 000000000..2856de0a2 --- /dev/null +++ b/frontend/src/hooks/api/orgAdmin/queries.tsx @@ -0,0 +1,30 @@ +import { useQuery } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { Workspace } from "../types"; +import { TOrgAdminGetProjectsDTO } from "./types"; + +export const orgAdminQueryKeys = { + getProjects: (filter: TOrgAdminGetProjectsDTO) => ["org-admin-projects", filter] as const +}; + +export const useOrgAdminGetProjects = ({ search, offset, limit = 50 }: TOrgAdminGetProjectsDTO) => { + return useQuery({ + queryKey: orgAdminQueryKeys.getProjects({ search, offset, limit }), + queryFn: async () => { + const { data } = await apiRequest.get<{ projects: Workspace[]; count: number }>( + "/api/v1/organization-admin/projects", + { + params: { + limit, + offset, + search + } + } + ); + + return data; + } + }); +}; diff --git a/frontend/src/hooks/api/orgAdmin/types.ts b/frontend/src/hooks/api/orgAdmin/types.ts new file mode 100644 index 000000000..87626a466 --- /dev/null +++ b/frontend/src/hooks/api/orgAdmin/types.ts @@ -0,0 +1,9 @@ +export type TOrgAdminGetProjectsDTO = { + limit?: number; + offset?: number; + search?: string; +}; + +export type TOrgAdminAccessProjectDTO = { + projectId: string; +}; diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index f3ffaf86e..57087feb9 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -317,6 +317,7 @@ export const useDeleteWorkspace = () => { }, onSuccess: () => { queryClient.invalidateQueries(workspaceKeys.getAllUserWorkspace); + queryClient.invalidateQueries(["org-admin-projects"]); } }); }; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 783b15f1e..51bb08e3d 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -21,6 +21,7 @@ export type Workspace = { pitVersionLimit: number; auditLogsRetentionDays: number; slug: string; + createdAt: string; }; export type WorkspaceEnv = { diff --git a/frontend/src/hooks/usePopUp.tsx b/frontend/src/hooks/usePopUp.tsx index e9d8257e3..28780db9e 100644 --- a/frontend/src/hooks/usePopUp.tsx +++ b/frontend/src/hooks/usePopUp.tsx @@ -13,7 +13,7 @@ interface UsePopUpProps { export type UsePopUpState | UsePopUpProps[]> = { [P in T extends UsePopUpProps[] ? T[number]["name"] : T[number]]: { isOpen: boolean; - data?: unknown; + data?: any; }; }; diff --git a/frontend/src/layouts/AppLayout/AppLayout.tsx b/frontend/src/layouts/AppLayout/AppLayout.tsx index 87d4c8458..6a65e32e8 100644 --- a/frontend/src/layouts/AppLayout/AppLayout.tsx +++ b/frontend/src/layouts/AppLayout/AppLayout.tsx @@ -476,10 +476,15 @@ export const AppLayout = ({ children }: LayoutProps) => { {user?.superAdmin && ( - Admin Panel + Server Admin Panel )} + + + Organization Admin Console + +
+
+ ); + + const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
{ + router.push(`/project/${workspace.id}/secrets/overview`); + localStorage.setItem("projectData.id", workspace.id); + }} + key={workspace.id} + className={`min-w-72 group grid h-14 cursor-pointer grid-cols-6 border-t border-l border-r border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ + index === 0 && "rounded-t-md" + } ${index === filteredWorkspaces.length - 1 && "rounded-b-md border-b"}`} + > +
+ +
{workspace.name}
+
+
+
{workspace.environments?.length || 0} environments
- -
- ); - - const renderProjectListItem = (workspace: Workspace, isFavorite: boolean, index: number) => ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -
{ - router.push(`/project/${workspace.id}/secrets/overview`); - localStorage.setItem("projectData.id", workspace.id); - }} - key={workspace.id} - className={`min-w-72 group grid h-14 cursor-pointer grid-cols-6 border-t border-l border-r border-mineshaft-600 bg-mineshaft-800 px-6 hover:bg-mineshaft-700 ${ - index === 0 && "rounded-t-md" - } ${index === filteredWorkspaces.length - 1 && "rounded-b-md border-b"}`} - > -
- -
{workspace.name}
-
-
-
- {workspace.environments?.length || 0} environments -
- {isFavorite ? ( - { - e.stopPropagation(); - removeProjectFromFavorites(workspace.id); - }} - /> - ) : ( - { - e.stopPropagation(); - addProjectToFavorites(workspace.id); - }} - /> - )} -
-
- ); - - const projectsGridView = ( - <> - {favoriteWorkspaces.length > 0 && ( - <> -

Favorites

-
0 && "border-b border-mineshaft-600" - } py-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4`} - > - {favoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, true))} -
- + {isFavorite ? ( + { + e.stopPropagation(); + removeProjectFromFavorites(workspace.id); + }} + /> + ) : ( + { + e.stopPropagation(); + addProjectToFavorites(workspace.id); + }} + /> )} -
- {isProjectViewLoading && - Array.apply(0, Array(3)).map((_x, i) => ( -
-
- -
-
- -
-
- -
-
- ))} - {!isProjectViewLoading && - nonFavoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, false))} -
- - ); +
+
+ ); - const projectsListView = ( -
+ const projectsGridView = ( + <> + {favoriteWorkspaces.length > 0 && ( + <> +

Favorites

+
0 && "border-b border-mineshaft-600" + } py-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4`} + > + {favoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, true))} +
+ + )} +
{isProjectViewLoading && Array.apply(0, Array(3)).map((_x, i) => (
- +
+ +
+
+ +
+
+ +
))} {!isProjectViewLoading && - workspacesWithFaveProp.map((workspace, ind) => - renderProjectListItem(workspace, workspace.isFavorite, ind) - )} + nonFavoriteWorkspaces.map((workspace) => renderProjectGridItem(workspace, false))}
- ); + + ); - return ( -
- - {t("common.head-title", { title: t("settings.members.title") })} - - - {!serverDetails?.redisConfigured && ( -
-

Announcements

-
- - Attention: Updated versions of Infisical now require Redis for full functionality. - Learn how to configure it - + {isProjectViewLoading && + Array.apply(0, Array(3)).map((_x, i) => ( +
+ +
+ ))} + {!isProjectViewLoading && + workspacesWithFaveProp.map((workspace, ind) => + renderProjectListItem(workspace, workspace.isFavorite, ind) + )} +
+ ); + + return ( +
+ + {t("common.head-title", { title: t("settings.members.title") })} + + + {!serverDetails?.redisConfigured && ( +
+

Announcements

+
+ + Attention: Updated versions of Infisical now require Redis for full functionality. Learn + how to configure it + + + here + + + . +
+
+ )} +
+
+

Projects

+
+
+ setSearchFilter(e.target.value)} + leftIcon={} + /> +
+ { + localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID); + setProjectsViewMode(ProjectsViewMode.GRID); + }} + ariaLabel="grid" + size="xs" + className={`${ + projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent" + } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} + > + + + { + localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST); + setProjectsViewMode(ProjectsViewMode.LIST); + }} + ariaLabel="list" + size="xs" + className={`${ + projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent" + } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} + > + + +
+ + {(isAllowed) => ( + + )} + +
+ {projectsViewMode === ProjectsViewMode.LIST ? projectsListView : projectsGridView} + {isWorkspaceEmpty && ( +
+ +
+ You are not part of any projects in this organization yet. When you are, they will + appear here. +
+
+ Create a new project, or ask other organization members to give you necessary + permissions.
)} -
-
-

Projects

-
-
- setSearchFilter(e.target.value)} - leftIcon={} - /> -
- { - localStorage.setItem("projectsViewMode", ProjectsViewMode.GRID); - setProjectsViewMode(ProjectsViewMode.GRID); - }} - ariaLabel="grid" - size="xs" - className={`${ - projectsViewMode === ProjectsViewMode.GRID ? "bg-mineshaft-500" : "bg-transparent" - } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} - > - - - { - localStorage.setItem("projectsViewMode", ProjectsViewMode.LIST); - setProjectsViewMode(ProjectsViewMode.LIST); - }} - ariaLabel="list" - size="xs" - className={`${ - projectsViewMode === ProjectsViewMode.LIST ? "bg-mineshaft-500" : "bg-transparent" - } min-w-[2.4rem] border-none hover:bg-mineshaft-600`} - > - - -
- - {(isAllowed) => ( -
+
+

Explore Infisical

+
+ {features.map((feature) => ( + -
-

Explore Infisical

-
- {features.map((feature) => ( -
-
{feature.name}
-
- {feature.description} -
-
-

- Setup time: 20 min -

- - Learn more{" "} - - -
-
- ))} -
-
- {!( - new Date().getTime() - new Date(user?.createdAt).getTime() < - 30 * 24 * 60 * 60 * 1000 - ) && ( -
-

Onboarding Guide

-
- - {orgWorkspaces.length !== 0 && ( - <> - - - - )} -
- -
-
+
+ {!(new Date().getTime() - new Date(user?.createdAt).getTime() < 30 * 24 * 60 * 60 * 1000) && ( +
+

Onboarding Guide

+
+ {orgWorkspaces.length !== 0 && ( -
-
-
- - {false && ( -
- -
- )} -
-
Inject secrets locally
-
- Replace .env files with a more secure and efficient alternative. -
+ <> + + + + )} +
+ +
+
+ {orgWorkspaces.length !== 0 && ( +
+
+
+ + {false && ( +
+ +
+ )} +
+
Inject secrets locally
+
+ Replace .env files with a more secure and efficient alternative.
-
- About 2 min -
- - {false &&
} +
+ About 2 min +
- )} - {orgWorkspaces.length !== 0 && ( - - )} -
- )} - { - handlePopUpToggle("addNewWs", isModalOpen); - reset(); - }} + + {false &&
} +
+ )} + {orgWorkspaces.length !== 0 && ( + + )} +
+ )} + { + handlePopUpToggle("addNewWs", isModalOpen); + reset(); + }} + > + - -
+ + ( + + + + )} + /> +
( - - - + name="addMembers" + defaultValue={false} + render={({ field: { onBlur, value, onChange } }) => ( + + {(isAllowed) => ( +
+ + Add all members of my organization to this project + +
+ )} +
)} /> -
- ( - - {(isAllowed) => ( -
- +
+ + + +
Advanced Settings
+
+ + ( + + { - onChange(e); - }} - className="mb-12 w-full bg-mineshaft-600" - > - - Default Infisical KMS + + Default Infisical KMS + + {externalKmsList?.map((kms) => ( + + {kms.slug} - {externalKmsList?.map((kms) => ( - - {kms.slug} - - ))} - - - )} - control={control} - name="kmsKeyId" - /> - -
-
-
- - -
+ ))} + + + )} + control={control} + name="kmsKeyId" + /> + + + +
+ +
- - - - handlePopUpToggle("upgradePlan", isOpen)} - text="You have exceeded the number of projects allowed on the free plan." - /> - {/* */} -
- ); - }, - { - action: OrgPermissionActions.Read, - subject: OrgPermissionSubjects.Workspace - } -); +
+ + + + handlePopUpToggle("upgradePlan", isOpen)} + text="You have exceeded the number of projects allowed on the free plan." + /> + {/* */} +
+ ); +}; Object.assign(OrganizationPage, { requireAuth: true }); diff --git a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx b/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx deleted file mode 100644 index 465029f8b..000000000 --- a/frontend/src/views/Org/MembersPage/components/OrgRoleTabSection/OrgRoleModifySection/WorkspacePermission.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useEffect, useMemo } from "react"; -import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; -import { faMoneyBill } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { motion } from "framer-motion"; -import { twMerge } from "tailwind-merge"; - -import { Checkbox, Select, SelectItem } from "@app/components/v2"; -import { useToggle } from "@app/hooks"; - -import { TFormSchema } from "../../../../RolePage/components/OrgRoleModifySection.utils"; - -type Props = { - isNonEditable?: boolean; - setValue: UseFormSetValue; - control: Control; -}; - -enum Permission { - NoAccess = "no-access", - ReadOnly = "read-only", - FullAccess = "full-acess", - Custom = "custom" -} - -const PERMISSIONS = [ - { action: "read", label: "View projects" }, - { action: "create", label: "Create new projects" } -] as const; - -export const WorkspacePermission = ({ isNonEditable, setValue, control }: Props) => { - const rule = useWatch({ - control, - name: "permissions.workspace" - }); - const [isCustom, setIsCustom] = useToggle(); - - const selectedPermissionCategory = useMemo(() => { - const actions = Object.keys(rule || {}) as Array; - const totalActions = PERMISSIONS.length; - const score = actions.map((key) => (rule?.[key] ? 1 : 0)).reduce((a, b) => a + b, 0 as number); - - if (isCustom) return Permission.Custom; - if (score === 0) return Permission.NoAccess; - if (score === totalActions) return Permission.FullAccess; - if (score === 1 && rule?.read) return Permission.ReadOnly; - - return Permission.Custom; - }, [rule, isCustom]); - - useEffect(() => { - if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); - else setIsCustom.off(); - }, [selectedPermissionCategory]); - - const handlePermissionChange = (val: Permission) => { - if (val === Permission.Custom) setIsCustom.on(); - else setIsCustom.off(); - - switch (val) { - case Permission.NoAccess: - setValue("permissions.workspace", { read: false, create: false }, { shouldDirty: true }); - break; - case Permission.FullAccess: - setValue("permissions.workspace", { read: true, create: true }, { shouldDirty: true }); - break; - case Permission.ReadOnly: - setValue("permissions.workspace", { read: true, create: false }, { shouldDirty: true }); - break; - default: - setValue("permissions.workspace", { read: false, create: false }, { shouldDirty: true }); - break; - } - }; - - return ( -
-
-
- -
-
-
Project
-
- View and create new projects in this organization -
-
-
- -
-
- - {isCustom && - PERMISSIONS.map(({ action, label }) => ( - ( - - {label} - - )} - /> - ))} - -
- ); -}; diff --git a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts index e85e62d0c..13cf2316b 100644 --- a/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts +++ b/frontend/src/views/Org/RolePage/components/OrgRoleModifySection.utils.ts @@ -12,6 +12,12 @@ const generalPermissionSchema = z }) .optional(); +const adminConsolePermissionSchmea = z + .object({ + "access-all-projects": z.boolean().optional() + }) + .optional(); + export const formSchema = z.object({ name: z.string().trim(), description: z.string().trim().optional(), @@ -23,7 +29,6 @@ export const formSchema = z.object({ .object({ workspace: z .object({ - read: z.boolean().optional(), create: z.boolean().optional() }) .optional(), @@ -38,7 +43,8 @@ export const formSchema = z.object({ scim: generalPermissionSchema, ldap: generalPermissionSchema, billing: generalPermissionSchema, - identity: generalPermissionSchema + identity: generalPermissionSchema, + "organization-admin-console": adminConsolePermissionSchmea }) .optional() }); diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/OrgPermissionAdminConsoleRow.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/OrgPermissionAdminConsoleRow.tsx new file mode 100644 index 000000000..cc21abdf1 --- /dev/null +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/OrgPermissionAdminConsoleRow.tsx @@ -0,0 +1,135 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { TFormSchema } from "@app/views/Org/RolePage/components/OrgRoleModifySection.utils"; + +type Props = { + isEditable: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + Custom = "custom" +} + +const PERMISSION_ACTIONS = [ + { action: "access-all-projects", label: "Access all organization projects" } +] as const; + +export const OrgPermissionAdminConsoleRow = ({ isEditable, control, setValue }: Props) => { + const [isRowExpanded, setIsRowExpanded] = useToggle(); + const [isCustom, setIsCustom] = useToggle(); + + const rule = useWatch({ + control, + name: "permissions.organization-admin-console" + }); + + const selectedPermissionCategory = useMemo(() => { + if (rule?.["access-all-projects"]) { + return Permission.Custom; + } + return Permission.NoAccess; + }, [rule, isCustom]); + + useEffect(() => { + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + }, [selectedPermissionCategory]); + + useEffect(() => { + const isRowCustom = selectedPermissionCategory === Permission.Custom; + if (isRowCustom) { + setIsRowExpanded.on(); + } + }, []); + + const handlePermissionChange = (val: Permission) => { + if (!val) return; + if (val === Permission.Custom) { + setIsRowExpanded.on(); + setIsCustom.on(); + return; + } + setIsCustom.off(); + + if (val === Permission.NoAccess) { + setValue( + "permissions.organization-admin-console", + { "access-all-projects": false }, + { shouldDirty: true } + ); + } + }; + + return ( + <> + setIsRowExpanded.toggle()} + > + + + + Organization Admin Console + + + + + {isRowExpanded && ( + + +
+ {PERMISSION_ACTIONS.map(({ action, label }) => { + return ( + ( + { + if (!isEditable) { + createNotification({ + type: "error", + text: "Failed to update default role" + }); + return; + } + field.onChange(e); + }} + id={`permissions.organization-admin-console.${action}`} + > + {label} + + )} + /> + ); + })} +
+ + + )} + + ); +}; diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/OrgRoleWorkspaceRow.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/OrgRoleWorkspaceRow.tsx new file mode 100644 index 000000000..a4eb51774 --- /dev/null +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/OrgRoleWorkspaceRow.tsx @@ -0,0 +1,129 @@ +import { useEffect, useMemo } from "react"; +import { Control, Controller, UseFormSetValue, useWatch } from "react-hook-form"; +import { faChevronDown, faChevronRight } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { createNotification } from "@app/components/notifications"; +import { Checkbox, Select, SelectItem, Td, Tr } from "@app/components/v2"; +import { useToggle } from "@app/hooks"; +import { TFormSchema } from "@app/views/Org/RolePage/components/OrgRoleModifySection.utils"; + +type Props = { + isEditable: boolean; + setValue: UseFormSetValue; + control: Control; +}; + +enum Permission { + NoAccess = "no-access", + Custom = "custom" +} + +const PERMISSION_ACTIONS = [{ action: "create", label: "Create projects" }] as const; + +export const OrgRoleWorkspaceRow = ({ isEditable, control, setValue }: Props) => { + const [isRowExpanded, setIsRowExpanded] = useToggle(); + const [isCustom, setIsCustom] = useToggle(); + + const rule = useWatch({ + control, + name: "permissions.workspace" + }); + + const selectedPermissionCategory = useMemo(() => { + if (rule?.create) { + return Permission.Custom; + } + return Permission.NoAccess; + }, [rule, isCustom]); + + useEffect(() => { + if (selectedPermissionCategory === Permission.Custom) setIsCustom.on(); + else setIsCustom.off(); + }, [selectedPermissionCategory]); + + useEffect(() => { + const isRowCustom = selectedPermissionCategory === Permission.Custom; + if (isRowCustom) { + setIsRowExpanded.on(); + } + }, []); + + const handlePermissionChange = (val: Permission) => { + if (!val) return; + if (val === Permission.Custom) { + setIsRowExpanded.on(); + setIsCustom.on(); + return; + } + setIsCustom.off(); + + if (val === Permission.NoAccess) { + setValue("permissions.workspace", { create: false }, { shouldDirty: true }); + } + }; + + return ( + <> + setIsRowExpanded.toggle()} + > + + + + Project + + + + + {isRowExpanded && ( + + +
+ {PERMISSION_ACTIONS.map(({ action, label }) => { + return ( + ( + { + if (!isEditable) { + createNotification({ + type: "error", + text: "Failed to update default role" + }); + return; + } + field.onChange(e); + }} + id={`permissions.organization-admin-console.${action}`} + > + {label} + + )} + /> + ); + })} +
+ + + )} + + ); +}; diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx index 6f4cc7c88..ba76effe1 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionRow.tsx @@ -61,7 +61,10 @@ const getPermissionList = (option: string) => { type Props = { isEditable: boolean; title: string; - formName: keyof Omit, "workspace">; + formName: keyof Omit< + Exclude, + "workspace" | "organization-admin-console" + >; setValue: UseFormSetValue; control: Control; }; diff --git a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx index fe19620f5..f4b237cfe 100644 --- a/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx +++ b/frontend/src/views/Org/RolePage/components/RolePermissionsSection/RolePermissionsSection.tsx @@ -12,6 +12,8 @@ import { TFormSchema } from "@app/views/Org/RolePage/components/OrgRoleModifySection.utils"; +import { OrgPermissionAdminConsoleRow } from "./OrgPermissionAdminConsoleRow"; +import { OrgRoleWorkspaceRow } from "./OrgRoleWorkspaceRow"; import { RolePermissionRow } from "./RolePermissionRow"; const SIMPLE_PERMISSION_OPTIONS = [ @@ -153,6 +155,16 @@ export const RolePermissionsSection = ({ roleId }: Props) => { /> ); })} + + diff --git a/frontend/src/views/OrgAdminPage/OrgAdminPage.tsx b/frontend/src/views/OrgAdminPage/OrgAdminPage.tsx new file mode 100644 index 000000000..406b65c69 --- /dev/null +++ b/frontend/src/views/OrgAdminPage/OrgAdminPage.tsx @@ -0,0 +1,30 @@ +import { useState } from "react"; + +import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2"; + +import { OrgAdminProjects } from "./components/OrgAdminProjects"; + +enum TabSections { + Projects = "projects" +} + +export const OrgAdminPage = () => { + const [activeTab, setActiveTab] = useState(TabSections.Projects); + return ( +
+
+
+

Organization Admin Console

+
+ setActiveTab(el as TabSections)}> + + Projects + + + + + +
+
+ ); +}; diff --git a/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/OrgAdminProjects.tsx b/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/OrgAdminProjects.tsx new file mode 100644 index 000000000..516f248f0 --- /dev/null +++ b/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/OrgAdminProjects.tsx @@ -0,0 +1,167 @@ +import { useState } from "react"; +import { useRouter } from "next/router"; +import { faEllipsis, faMagnifyingGlass, faSignIn } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { format } from "date-fns"; +import { motion } from "framer-motion"; + +import { createNotification } from "@app/components/notifications"; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + EmptyState, + Input, + Pagination, + Spinner, + Table, + TableContainer, + TableSkeleton, + TBody, + Td, + Th, + THead, + Tr +} from "@app/components/v2"; +import { + OrgPermissionAdminConsoleAction, + OrgPermissionSubjects +} from "@app/context/OrgPermissionContext/types"; +import { withPermission } from "@app/hoc"; +import { useDebounce } from "@app/hooks"; +import { useOrgAdminAccessProject, useOrgAdminGetProjects } from "@app/hooks/api"; + +export const OrgAdminProjects = withPermission( + () => { + const [page, setPage] = useState(1); + const [search, setSearch] = useState(""); + const debouncedSearch = useDebounce(search); + const [perPage, setPerPage] = useState(25); + const router = useRouter(); + const orgAdminAccessProject = useOrgAdminAccessProject(); + + const { data, isLoading: isProjectsLoading } = useOrgAdminGetProjects({ + offset: (page - 1) * perPage, + limit: perPage, + search: debouncedSearch || undefined + }); + + const projects = data?.projects || []; + const projectCount = data?.count || 0; + const isEmpty = !isProjectsLoading && projects.length === 0; + + const handleAccessProject = async (projectId: string) => { + try { + await orgAdminAccessProject.mutateAsync({ + projectId + }); + await router.push({ + pathname: "/project/[projectId]/secrets/overview", + query: { + projectId + } + }); + } catch { + createNotification({ + text: "Failed to access project", + type: "error" + }); + } + }; + + return ( + +
+
+

Projects

+
+
+ setSearch(e.target.value)} + leftIcon={} + placeholder="Search by project name" + /> + + + + + + + + + + + {isProjectsLoading && } + {!isProjectsLoading && + projects?.map(({ name, slug, createdAt, id }) => ( + + + + + + + ))} + +
NameSlugCreated At +
{name}{slug}{format(new Date(createdAt), "yyyy-MM-dd, hh:mm aaa")} +
+ + + + + + { + e.stopPropagation(); + e.preventDefault(); + handleAccessProject(id); + }} + icon={} + disabled={ + orgAdminAccessProject.variables?.projectId === id && + orgAdminAccessProject.isLoading + } + > + Access{" "} + {orgAdminAccessProject.variables?.projectId === id && + orgAdminAccessProject.isLoading && } + + + +
+
+ {!isProjectsLoading && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {isEmpty && } +
+
+
+
+ ); + }, + { + action: OrgPermissionAdminConsoleAction.AccessAllProjects, + subject: OrgPermissionSubjects.AdminConsole + } +); diff --git a/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/index.tsx b/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/index.tsx new file mode 100644 index 000000000..b331589a4 --- /dev/null +++ b/frontend/src/views/OrgAdminPage/components/OrgAdminProjects/index.tsx @@ -0,0 +1 @@ +export { OrgAdminProjects } from "./OrgAdminProjects"; diff --git a/frontend/src/views/OrgAdminPage/index.tsx b/frontend/src/views/OrgAdminPage/index.tsx new file mode 100644 index 000000000..1fe7b5541 --- /dev/null +++ b/frontend/src/views/OrgAdminPage/index.tsx @@ -0,0 +1 @@ +export { OrgAdminPage } from "./OrgAdminPage"; diff --git a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx index 5eada2663..668b5e5c6 100644 --- a/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx +++ b/frontend/src/views/Project/AuditLogsPage/components/LogsTableRow.tsx @@ -317,6 +317,12 @@ export const LogsTableRow = ({ auditLog }: Props) => { })} ); + case EventType.ORG_ADMIN_ACCESS_PROJECT: + return ( + +

{`Email: ${event.metadata.email}`}

+ + ); case EventType.CREATE_CA: case EventType.GET_CA: case EventType.UPDATE_CA: