diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index a86dd3db7..422f5162b 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -741,12 +741,14 @@ export const registerRoutes = async ( userAliasDAL, identityTokenAuthDAL, identityAccessTokenDAL, + orgMembershipDAL, identityOrgMembershipDAL, authService: loginService, serverCfgDAL: superAdminDAL, kmsRootConfigDAL, orgService, keyStore, + orgDAL, licenseService, kmsService, microsoftTeamsService, diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 8610a611b..0bade9904 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -1,7 +1,13 @@ import DOMPurify from "isomorphic-dompurify"; import { z } from "zod"; -import { IdentitiesSchema, OrganizationsSchema, SuperAdminSchema, UsersSchema } from "@app/db/schemas"; +import { + IdentitiesSchema, + OrganizationsSchema, + OrgMembershipsSchema, + SuperAdminSchema, + UsersSchema +} from "@app/db/schemas"; import { getConfig } from "@app/lib/config/env"; import { BadRequestError } from "@app/lib/errors"; import { invalidateCacheLimit, readLimit, writeLimit } from "@app/server/config/rateLimiter"; @@ -161,6 +167,129 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { } }); + server.route({ + method: "GET", + url: "/organization-management/organizations", + config: { + rateLimit: readLimit + }, + schema: { + querystring: z.object({ + searchTerm: z.string().default(""), + offset: z.coerce.number().default(0), + limit: z.coerce.number().max(100).default(20) + }), + response: { + 200: z.object({ + organizations: OrganizationsSchema.extend({ + members: z + .object({ + user: z.object({ + id: z.string(), + email: z.string().nullish(), + username: z.string(), + firstName: z.string().nullish(), + lastName: z.string().nullish() + }), + membershipId: z.string(), + role: z.string(), + roleId: z.string().nullish() + }) + .array(), + projects: z + .object({ + name: z.string(), + id: z.string(), + slug: z.string(), + createdAt: z.date() + }) + .array() + }).array() + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organizations = await server.services.superAdmin.getOrganizations({ + ...req.query + }); + + return { + organizations + }; + } + }); + + server.route({ + method: "DELETE", + url: "/organization-management/organizations/:organizationId/memberships/:membershipId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + organizationId: z.string(), + membershipId: z.string() + }), + response: { + 200: z.object({ + organizationMembership: OrgMembershipsSchema + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organizationMembership = await server.services.superAdmin.deleteOrganizationMembership( + req.params.organizationId, + req.params.membershipId, + req.permission.id, + req.permission.type + ); + + return { + organizationMembership + }; + } + }); + + server.route({ + method: "DELETE", + url: "/organization-management/organizations/:organizationId", + config: { + rateLimit: writeLimit + }, + schema: { + params: z.object({ + organizationId: z.string() + }), + response: { + 200: z.object({ + organization: OrganizationsSchema + }) + } + }, + onRequest: (req, res, done) => { + verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN])(req, res, () => { + verifySuperAdmin(req, res, done); + }); + }, + handler: async (req) => { + const organization = await server.services.superAdmin.deleteOrganization(req.params.organizationId); + + return { + organization + }; + } + }); + server.route({ method: "GET", url: "/identity-management/identities", diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 32cabf444..c4f0856a1 100644 --- a/backend/src/services/org/org-dal.ts +++ b/backend/src/services/org/org-dal.ts @@ -2,6 +2,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; import { + OrganizationsSchema, OrgMembershipRole, TableName, TOrganizations, @@ -12,7 +13,15 @@ import { TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; -import { buildFindFilter, ormify, selectAllTableCols, TFindFilter, TFindOpt, withTransaction } from "@app/lib/knex"; +import { + buildFindFilter, + ormify, + selectAllTableCols, + sqlNestRelationships, + TFindFilter, + TFindOpt, + withTransaction +} from "@app/lib/knex"; import { generateKnexQueryFromScim } from "@app/lib/knex/scim"; import { OrgAuthMethod } from "./org-types"; @@ -22,6 +31,110 @@ export type TOrgDALFactory = ReturnType; export const orgDALFactory = (db: TDbClient) => { const orgOrm = ormify(db, TableName.Organization); + const findOrganizationsByFilter = async ({ + limit, + offset, + searchTerm, + sortBy + }: { + limit: number; + offset: number; + searchTerm: string; + sortBy?: keyof TOrganizations; + }) => { + try { + const query = db.replicaNode()(TableName.Organization); + + // Build the subquery for limited organization IDs + const orgSubquery = db.replicaNode().select("id").from(TableName.Organization); + + if (searchTerm) { + void orgSubquery.where((qb) => { + void qb.whereILike(`${TableName.Organization}.name`, `%${searchTerm}%`); + }); + } + + if (sortBy) { + void orgSubquery.orderBy(sortBy); + } + + void orgSubquery.limit(limit).offset(offset); + + // Main query with joins, limited to the subquery results + const docs = await query + .whereIn(`${TableName.Organization}.id`, orgSubquery) + .leftJoin(TableName.Project, `${TableName.Organization}.id`, `${TableName.Project}.orgId`) + .leftJoin(TableName.OrgMembership, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) + .leftJoin(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.OrgRoles, `${TableName.OrgMembership}.roleId`, `${TableName.OrgRoles}.id`) + .where((qb) => { + void qb.where(`${TableName.Users}.isGhost`, false).orWhereNull(`${TableName.Users}.id`); + }) + .select(selectAllTableCols(TableName.Organization)) + .select(db.ref("name").withSchema(TableName.Project).as("projectName")) + .select(db.ref("id").withSchema(TableName.Project).as("projectId")) + .select(db.ref("slug").withSchema(TableName.Project).as("projectSlug")) + .select(db.ref("createdAt").withSchema(TableName.Project).as("projectCreatedAt")) + .select(db.ref("email").withSchema(TableName.Users).as("userEmail")) + .select(db.ref("username").withSchema(TableName.Users).as("username")) + .select(db.ref("firstName").withSchema(TableName.Users).as("firstName")) + .select(db.ref("lastName").withSchema(TableName.Users).as("lastName")) + .select(db.ref("id").withSchema(TableName.Users).as("userId")) + .select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId")) + .select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole")) + .select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId")) + .select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName")); + + const formattedDocs = sqlNestRelationships({ + data: docs, + key: "id", + parentMapper: (data) => OrganizationsSchema.parse(data), + childrenMapper: [ + { + key: "projectId", + label: "projects" as const, + mapper: ({ projectId, projectName, projectSlug, projectCreatedAt }) => ({ + id: projectId, + name: projectName, + slug: projectSlug, + createdAt: projectCreatedAt + }) + }, + { + key: "userId", + label: "members" as const, + mapper: ({ + userId, + userEmail, + username, + firstName, + lastName, + orgMembershipId, + orgMembershipRole, + orgMembershipRoleName, + orgMembershipRoleId + }) => ({ + user: { + id: userId, + email: userEmail, + username, + firstName, + lastName + }, + membershipId: orgMembershipId, + role: orgMembershipRoleName || orgMembershipRole, // custom role name or pre-defined role name + roleId: orgMembershipRoleId + }) + } + ] + }); + + return formattedDocs; + } catch (error) { + throw new DatabaseError({ error, name: "Find organizations by filter" }); + } + }; + const findOrgById = async (orgId: string) => { try { const org = (await db @@ -507,6 +620,7 @@ export const orgDALFactory = (db: TDbClient) => { findOrgById, findOrgBySlug, findAllOrgsByUserId, + findOrganizationsByFilter, ghostUserExists, findOrgMembersByUsername, findOrgMembersByRole, diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index 04dfa253b..ff319796e 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -11,7 +11,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors"; import { TIdentityDALFactory } from "@app/services/identity/identity-dal"; import { TAuthLoginFactory } from "../auth/auth-login-service"; -import { AuthMethod, AuthTokenType } from "../auth/auth-type"; +import { ActorType, AuthMethod, AuthTokenType } from "../auth/auth-type"; import { TIdentityOrgDALFactory } from "../identity/identity-org-dal"; import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal"; import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types"; @@ -21,7 +21,9 @@ import { TKmsRootConfigDALFactory } from "../kms/kms-root-config-dal"; import { TKmsServiceFactory } from "../kms/kms-service"; import { RootKeyEncryptionStrategy } from "../kms/kms-types"; import { TMicrosoftTeamsServiceFactory } from "../microsoft-teams/microsoft-teams-service"; +import { TOrgDALFactory } from "../org/org-dal"; import { TOrgServiceFactory } from "../org/org-service"; +import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal"; import { TUserDALFactory } from "../user/user-dal"; import { TUserAliasDALFactory } from "../user-alias/user-alias-dal"; import { UserAliasType } from "../user-alias/user-alias-types"; @@ -33,7 +35,8 @@ import { TAdminBootstrapInstanceDTO, TAdminGetIdentitiesDTO, TAdminGetUsersDTO, - TAdminSignUpDTO + TAdminSignUpDTO, + TGetOrganizationsDTO } from "./super-admin-types"; type TSuperAdminServiceFactoryDep = { @@ -41,6 +44,8 @@ type TSuperAdminServiceFactoryDep = { identityTokenAuthDAL: TIdentityTokenAuthDALFactory; identityAccessTokenDAL: TIdentityAccessTokenDALFactory; identityOrgMembershipDAL: TIdentityOrgDALFactory; + orgDAL: TOrgDALFactory; + orgMembershipDAL: TOrgMembershipDALFactory; serverCfgDAL: TSuperAdminDALFactory; userDAL: TUserDALFactory; userAliasDAL: Pick; @@ -73,6 +78,8 @@ export const superAdminServiceFactory = ({ serverCfgDAL, userDAL, identityDAL, + orgDAL, + orgMembershipDAL, userAliasDAL, authService, orgService, @@ -521,6 +528,47 @@ export const superAdminServiceFactory = ({ return updatedUser; }; + const getOrganizations = async ({ offset, limit, searchTerm }: TGetOrganizationsDTO) => { + const organizations = await orgDAL.findOrganizationsByFilter({ + offset, + searchTerm, + sortBy: "name", + limit + }); + return organizations; + }; + + const deleteOrganization = async (organizationId: string) => { + const organization = await orgDAL.deleteById(organizationId); + return organization; + }; + + const deleteOrganizationMembership = async ( + organizationId: string, + membershipId: string, + actorId: string, + actorType: ActorType + ) => { + if (actorType === ActorType.USER) { + const orgMembership = await orgMembershipDAL.findById(membershipId); + if (!orgMembership) { + throw new NotFoundError({ name: "Organization Membership", message: "Organization membership not found" }); + } + + if (orgMembership.userId === actorId) { + throw new BadRequestError({ + message: "You cannot remove yourself from the organization from the instance management panel." + }); + } + } + + const [organizationMembership] = await orgMembershipDAL.delete({ + orgId: organizationId, + id: membershipId + }); + return organizationMembership; + }; + const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => { const identities = await identityDAL.getIdentitiesByFilter({ limit, @@ -663,6 +711,9 @@ export const superAdminServiceFactory = ({ deleteIdentitySuperAdminAccess, deleteUserSuperAdminAccess, invalidateCache, - checkIfInvalidatingCache + checkIfInvalidatingCache, + getOrganizations, + deleteOrganization, + deleteOrganizationMembership }; }; diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index c804bed74..22803a650 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -35,6 +35,12 @@ export type TAdminGetIdentitiesDTO = { searchTerm: string; }; +export type TGetOrganizationsDTO = { + offset: number; + limit: number; + searchTerm: string; +}; + export enum LoginMethod { EMAIL = "email", GOOGLE = "google", diff --git a/frontend/src/hooks/api/admin/index.ts b/frontend/src/hooks/api/admin/index.ts index 5bedf1158..f4e57d7cc 100644 --- a/frontend/src/hooks/api/admin/index.ts +++ b/frontend/src/hooks/api/admin/index.ts @@ -1,16 +1,2 @@ -export { - useAdminDeleteUser, - useAdminGrantServerAdminAccess, - useAdminRemoveIdentitySuperAdminAccess, - useCreateAdminUser, - useInvalidateCache, - useRemoveUserServerAdminAccess, - useUpdateServerConfig, - useUpdateServerEncryptionStrategy -} from "./mutation"; -export { - useAdminGetUsers, - useGetAdminIntegrationsConfig, - useGetServerConfig, - useGetServerRootKmsEncryptionDetails -} from "./queries"; +export * from "./mutation"; +export * from "./queries"; diff --git a/frontend/src/hooks/api/admin/mutation.ts b/frontend/src/hooks/api/admin/mutation.ts index f220573c9..916067f00 100644 --- a/frontend/src/hooks/api/admin/mutation.ts +++ b/frontend/src/hooks/api/admin/mutation.ts @@ -63,6 +63,41 @@ export const useAdminDeleteUser = () => { queryClient.invalidateQueries({ queryKey: [adminStandaloneKeys.getUsers] }); + queryClient.invalidateQueries({ queryKey: adminStandaloneKeys.getOrganizations }); + } + }); +}; + +export const useAdminDeleteOrganizationMembership = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ organizationId, membershipId }) => { + await apiRequest.delete( + `/api/v1/admin/organization-management/organizations/${organizationId}/memberships/${membershipId}` + ); + + return {}; + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [adminStandaloneKeys.getOrganizations] + }); + } + }); +}; + +export const useAdminDeleteOrganization = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (organizationId: string) => { + await apiRequest.delete( + `/api/v1/admin/organization-management/organizations/${organizationId}` + ); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [adminStandaloneKeys.getOrganizations] + }); } }); }; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index 85c6c153e..c628df955 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -6,8 +6,10 @@ import { Identity } from "@app/hooks/api/identities/types"; import { User } from "../types"; import { AdminGetIdentitiesFilters, + AdminGetOrganizationsFilters, AdminGetUsersFilters, AdminIntegrationsConfig, + OrganizationWithProjects, TGetInvalidatingCacheStatus, TGetServerRootKmsEncryptionDetails, TServerConfig @@ -15,12 +17,15 @@ import { export const adminStandaloneKeys = { getUsers: "get-users", + getOrganizations: "get-organizations", getIdentities: "get-identities" }; export const adminQueryKeys = { serverConfig: () => ["server-config"] as const, getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const, + getOrganizations: (filters: AdminGetOrganizationsFilters) => + [adminStandaloneKeys.getOrganizations, { filters }] as const, getIdentities: (filters: AdminGetIdentitiesFilters) => [adminStandaloneKeys.getIdentities, { filters }] as const, getAdminSlackConfig: () => ["admin-slack-config"] as const, @@ -34,6 +39,28 @@ export const fetchServerConfig = async () => { return data.config; }; +export const useAdminGetOrganizations = (filters: AdminGetOrganizationsFilters) => { + return useInfiniteQuery({ + initialPageParam: 0, + queryKey: adminQueryKeys.getOrganizations(filters), + queryFn: async ({ pageParam }) => { + const { data } = await apiRequest.get<{ organizations: OrganizationWithProjects[] }>( + "/api/v1/admin/organization-management/organizations", + { + params: { + ...filters, + offset: pageParam + } + } + ); + + return data.organizations; + }, + getNextPageParam: (lastPage, pages) => + lastPage.length !== 0 ? pages.length * filters.limit : undefined + }); +}; + export const useGetServerConfig = ({ options = {} }: { diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index 8850b3375..149014d85 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -1,3 +1,5 @@ +import { Organization } from "../types"; + export enum LoginMethod { EMAIL = "email", GOOGLE = "google", @@ -8,6 +10,27 @@ export enum LoginMethod { OIDC = "oidc" } +export type OrganizationWithProjects = Organization & { + members: { + user: { + id: string; + email: string | null; + username: string; + firstName: string | null; + lastName: string | null; + }; + membershipId: string; + role: string; + roleId: string | null; + }[]; + projects: { + name: string; + id: string; + slug: string; + createdAt: string; + }[]; +}; + export type TServerConfig = { initialized: boolean; allowSignUp: boolean; @@ -51,6 +74,11 @@ export type TCreateAdminUserDTO = { salt: string; }; +export type AdminGetOrganizationsFilters = { + limit: number; + searchTerm: string; +}; + export type AdminGetUsersFilters = { limit: number; searchTerm: string; diff --git a/frontend/src/layouts/AdminLayout/AdminLayout.tsx b/frontend/src/layouts/AdminLayout/AdminLayout.tsx index 41e9e6606..097654f26 100644 --- a/frontend/src/layouts/AdminLayout/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout/AdminLayout.tsx @@ -1,27 +1,23 @@ import { useTranslation } from "react-i18next"; -import { faArrowLeft, faInfo, faMobile, faQuestion } from "@fortawesome/free-solid-svg-icons"; +import { faMobile } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { Link, Outlet } from "@tanstack/react-router"; +import { Outlet, useRouterState } from "@tanstack/react-router"; -import { WishForm } from "@app/components/features/WishForm"; import { Banner } from "@app/components/page-frames/Banner"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger -} from "@app/components/v2"; -import { envConfig } from "@app/config/env"; +import { BreadcrumbContainer, TBreadcrumbFormat } from "@app/components/v2"; import { useServerConfig } from "@app/context"; -import { ProjectType } from "@app/hooks/api/workspace/types"; import { InsecureConnectionBanner } from "../OrganizationLayout/components/InsecureConnectionBanner"; -import { INFISICAL_SUPPORT_OPTIONS } from "../OrganizationLayout/components/MinimizedOrgSidebar/MinimizedOrgSidebar"; +import { AdminSidebar } from "./Sidebar"; export const AdminLayout = () => { const { t } = useTranslation(); const { config } = useServerConfig(); + const matches = useRouterState({ select: (s) => s.matches.at(-1)?.context }); + + const breadcrumbs = matches && "breadcrumbs" in matches ? matches.breadcrumbs : undefined; + const containerHeight = config.pageFrameContent ? "h-[94vh]" : "h-screen"; return ( @@ -30,54 +26,11 @@ export const AdminLayout = () => {