diff --git a/backend/src/services/org/org-dal.ts b/backend/src/services/org/org-dal.ts index 54b0e1b0f..c1736bfb1 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 22fc51d1a..b5f4b1934 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, @@ -519,6 +526,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, @@ -661,6 +709,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/pages/admin/OverviewPage/OverviewPage.tsx b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx index 790adff50..9bc53907b 100644 --- a/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/admin/OverviewPage/OverviewPage.tsx @@ -34,6 +34,7 @@ import { AuthPanel } from "./components/AuthPanel"; import { CachingPanel } from "./components/CachingPanel"; import { EncryptionPanel } from "./components/EncryptionPanel"; import { IntegrationPanel } from "./components/IntegrationPanel"; +import { OrganizationsPanel } from "./components/OrganizationsPanel"; import { UserPanel } from "./components/UserPanel"; enum TabSections { @@ -42,6 +43,7 @@ enum TabSections { Auth = "auth", Integrations = "integrations", Users = "users", + Organizations = "organizations", Identities = "identities", Kmip = "kmip", Caching = "caching" @@ -157,16 +159,20 @@ export const OverviewPage = () => { ) : (
- - -
+ + +
General Encryption Authentication Integrations + Caching +
+ +
+ Organizations User Identities Machine Identities - Caching
@@ -405,6 +411,9 @@ export const OverviewPage = () => { + + + diff --git a/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx index 1df9a7cd3..3fcd2308b 100644 --- a/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx +++ b/frontend/src/pages/admin/OverviewPage/components/IdentityPanel.tsx @@ -120,7 +120,7 @@ const IdentityPanelTable = ({ + )} +
+ handlePopUpToggle("viewMembers", isOpen)} + /> + + ); +}; + +export const OrganizationsPanel = () => { + const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([ + "deleteOrganization", + "deleteOrganizationMembership", + "deleteUser", + "viewMembers" + ] as const); + + const { mutateAsync: deleteOrganization } = useAdminDeleteOrganization(); + const { mutateAsync: deleteOrganizationMembership } = useAdminDeleteOrganizationMembership(); + const { mutateAsync: deleteUser } = useAdminDeleteUser(); + + const handleDeleteOrganization = async () => { + const { orgId } = popUp?.deleteOrganization?.data as { orgId: string }; + + await deleteOrganization(orgId); + createNotification({ + type: "success", + text: "Successfully deleted organization" + }); + + handlePopUpClose("deleteOrganization"); + }; + + const handleDeleteOrganizationMembership = async () => { + const { orgId, membershipId } = popUp?.deleteOrganizationMembership?.data as { + orgId: string; + membershipId: string; + }; + + if (!orgId || !membershipId) { + return; + } + + await deleteOrganizationMembership({ organizationId: orgId, membershipId }); + createNotification({ + type: "success", + text: "Successfully removed user from organization" + }); + + handlePopUpClose("viewMembers"); + handlePopUpClose("deleteOrganizationMembership"); + }; + + const handleDeleteUser = async () => { + const { userId } = popUp?.deleteUser?.data as { userId: string }; + + if (!userId) { + return; + } + + await deleteUser(userId); + createNotification({ + type: "success", + text: "Successfully deleted user" + }); + + handlePopUpClose("viewMembers"); + handlePopUpClose("deleteUser"); + }; + + return ( +
+
+

Organizations

+
+ + handlePopUpToggle("deleteOrganization", isOpen)} + onDeleteApproved={handleDeleteOrganization} + /> + handlePopUpToggle("deleteOrganizationMembership", isOpen)} + onDeleteApproved={handleDeleteOrganizationMembership} + /> + handlePopUpToggle("deleteUser", isOpen)} + onDeleteApproved={handleDeleteUser} + /> +
+ ); +}; diff --git a/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx b/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx index 9395a9a8a..ed90f7cd2 100644 --- a/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx +++ b/frontend/src/pages/admin/OverviewPage/components/UserPanel.tsx @@ -213,7 +213,7 @@ const UserPanelTable = ({