mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 14:27:30 +00:00
feat(instance-management): organizations overview and control
This commit is contained in:
@@ -2,6 +2,7 @@ import { Knex } from "knex";
|
|||||||
|
|
||||||
import { TDbClient } from "@app/db";
|
import { TDbClient } from "@app/db";
|
||||||
import {
|
import {
|
||||||
|
OrganizationsSchema,
|
||||||
OrgMembershipRole,
|
OrgMembershipRole,
|
||||||
TableName,
|
TableName,
|
||||||
TOrganizations,
|
TOrganizations,
|
||||||
@@ -12,7 +13,15 @@ import {
|
|||||||
TUserEncryptionKeys
|
TUserEncryptionKeys
|
||||||
} from "@app/db/schemas";
|
} from "@app/db/schemas";
|
||||||
import { DatabaseError } from "@app/lib/errors";
|
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 { generateKnexQueryFromScim } from "@app/lib/knex/scim";
|
||||||
|
|
||||||
import { OrgAuthMethod } from "./org-types";
|
import { OrgAuthMethod } from "./org-types";
|
||||||
@@ -22,6 +31,110 @@ export type TOrgDALFactory = ReturnType<typeof orgDALFactory>;
|
|||||||
export const orgDALFactory = (db: TDbClient) => {
|
export const orgDALFactory = (db: TDbClient) => {
|
||||||
const orgOrm = ormify(db, TableName.Organization);
|
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) => {
|
const findOrgById = async (orgId: string) => {
|
||||||
try {
|
try {
|
||||||
const org = (await db
|
const org = (await db
|
||||||
@@ -507,6 +620,7 @@ export const orgDALFactory = (db: TDbClient) => {
|
|||||||
findOrgById,
|
findOrgById,
|
||||||
findOrgBySlug,
|
findOrgBySlug,
|
||||||
findAllOrgsByUserId,
|
findAllOrgsByUserId,
|
||||||
|
findOrganizationsByFilter,
|
||||||
ghostUserExists,
|
ghostUserExists,
|
||||||
findOrgMembersByUsername,
|
findOrgMembersByUsername,
|
||||||
findOrgMembersByRole,
|
findOrgMembersByRole,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
|||||||
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
|
import { TIdentityDALFactory } from "@app/services/identity/identity-dal";
|
||||||
|
|
||||||
import { TAuthLoginFactory } from "../auth/auth-login-service";
|
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 { TIdentityOrgDALFactory } from "../identity/identity-org-dal";
|
||||||
import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal";
|
import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal";
|
||||||
import { TIdentityAccessTokenJwtPayload } from "../identity-access-token/identity-access-token-types";
|
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 { TKmsServiceFactory } from "../kms/kms-service";
|
||||||
import { RootKeyEncryptionStrategy } from "../kms/kms-types";
|
import { RootKeyEncryptionStrategy } from "../kms/kms-types";
|
||||||
import { TMicrosoftTeamsServiceFactory } from "../microsoft-teams/microsoft-teams-service";
|
import { TMicrosoftTeamsServiceFactory } from "../microsoft-teams/microsoft-teams-service";
|
||||||
|
import { TOrgDALFactory } from "../org/org-dal";
|
||||||
import { TOrgServiceFactory } from "../org/org-service";
|
import { TOrgServiceFactory } from "../org/org-service";
|
||||||
|
import { TOrgMembershipDALFactory } from "../org-membership/org-membership-dal";
|
||||||
import { TUserDALFactory } from "../user/user-dal";
|
import { TUserDALFactory } from "../user/user-dal";
|
||||||
import { TUserAliasDALFactory } from "../user-alias/user-alias-dal";
|
import { TUserAliasDALFactory } from "../user-alias/user-alias-dal";
|
||||||
import { UserAliasType } from "../user-alias/user-alias-types";
|
import { UserAliasType } from "../user-alias/user-alias-types";
|
||||||
@@ -33,7 +35,8 @@ import {
|
|||||||
TAdminBootstrapInstanceDTO,
|
TAdminBootstrapInstanceDTO,
|
||||||
TAdminGetIdentitiesDTO,
|
TAdminGetIdentitiesDTO,
|
||||||
TAdminGetUsersDTO,
|
TAdminGetUsersDTO,
|
||||||
TAdminSignUpDTO
|
TAdminSignUpDTO,
|
||||||
|
TGetOrganizationsDTO
|
||||||
} from "./super-admin-types";
|
} from "./super-admin-types";
|
||||||
|
|
||||||
type TSuperAdminServiceFactoryDep = {
|
type TSuperAdminServiceFactoryDep = {
|
||||||
@@ -41,6 +44,8 @@ type TSuperAdminServiceFactoryDep = {
|
|||||||
identityTokenAuthDAL: TIdentityTokenAuthDALFactory;
|
identityTokenAuthDAL: TIdentityTokenAuthDALFactory;
|
||||||
identityAccessTokenDAL: TIdentityAccessTokenDALFactory;
|
identityAccessTokenDAL: TIdentityAccessTokenDALFactory;
|
||||||
identityOrgMembershipDAL: TIdentityOrgDALFactory;
|
identityOrgMembershipDAL: TIdentityOrgDALFactory;
|
||||||
|
orgDAL: TOrgDALFactory;
|
||||||
|
orgMembershipDAL: TOrgMembershipDALFactory;
|
||||||
serverCfgDAL: TSuperAdminDALFactory;
|
serverCfgDAL: TSuperAdminDALFactory;
|
||||||
userDAL: TUserDALFactory;
|
userDAL: TUserDALFactory;
|
||||||
userAliasDAL: Pick<TUserAliasDALFactory, "findOne">;
|
userAliasDAL: Pick<TUserAliasDALFactory, "findOne">;
|
||||||
@@ -73,6 +78,8 @@ export const superAdminServiceFactory = ({
|
|||||||
serverCfgDAL,
|
serverCfgDAL,
|
||||||
userDAL,
|
userDAL,
|
||||||
identityDAL,
|
identityDAL,
|
||||||
|
orgDAL,
|
||||||
|
orgMembershipDAL,
|
||||||
userAliasDAL,
|
userAliasDAL,
|
||||||
authService,
|
authService,
|
||||||
orgService,
|
orgService,
|
||||||
@@ -519,6 +526,47 @@ export const superAdminServiceFactory = ({
|
|||||||
return updatedUser;
|
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 getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => {
|
||||||
const identities = await identityDAL.getIdentitiesByFilter({
|
const identities = await identityDAL.getIdentitiesByFilter({
|
||||||
limit,
|
limit,
|
||||||
@@ -661,6 +709,9 @@ export const superAdminServiceFactory = ({
|
|||||||
deleteIdentitySuperAdminAccess,
|
deleteIdentitySuperAdminAccess,
|
||||||
deleteUserSuperAdminAccess,
|
deleteUserSuperAdminAccess,
|
||||||
invalidateCache,
|
invalidateCache,
|
||||||
checkIfInvalidatingCache
|
checkIfInvalidatingCache,
|
||||||
|
getOrganizations,
|
||||||
|
deleteOrganization,
|
||||||
|
deleteOrganizationMembership
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ export type TAdminGetIdentitiesDTO = {
|
|||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TGetOrganizationsDTO = {
|
||||||
|
offset: number;
|
||||||
|
limit: number;
|
||||||
|
searchTerm: string;
|
||||||
|
};
|
||||||
|
|
||||||
export enum LoginMethod {
|
export enum LoginMethod {
|
||||||
EMAIL = "email",
|
EMAIL = "email",
|
||||||
GOOGLE = "google",
|
GOOGLE = "google",
|
||||||
|
|||||||
@@ -1,16 +1,2 @@
|
|||||||
export {
|
export * from "./mutation";
|
||||||
useAdminDeleteUser,
|
export * from "./queries";
|
||||||
useAdminGrantServerAdminAccess,
|
|
||||||
useAdminRemoveIdentitySuperAdminAccess,
|
|
||||||
useCreateAdminUser,
|
|
||||||
useInvalidateCache,
|
|
||||||
useRemoveUserServerAdminAccess,
|
|
||||||
useUpdateServerConfig,
|
|
||||||
useUpdateServerEncryptionStrategy
|
|
||||||
} from "./mutation";
|
|
||||||
export {
|
|
||||||
useAdminGetUsers,
|
|
||||||
useGetAdminIntegrationsConfig,
|
|
||||||
useGetServerConfig,
|
|
||||||
useGetServerRootKmsEncryptionDetails
|
|
||||||
} from "./queries";
|
|
||||||
|
|||||||
@@ -63,6 +63,41 @@ export const useAdminDeleteUser = () => {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: [adminStandaloneKeys.getUsers]
|
queryKey: [adminStandaloneKeys.getUsers]
|
||||||
});
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: adminStandaloneKeys.getOrganizations });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAdminDeleteOrganizationMembership = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<object, object, { organizationId: string; membershipId: string }>({
|
||||||
|
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]
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { Identity } from "@app/hooks/api/identities/types";
|
|||||||
import { User } from "../types";
|
import { User } from "../types";
|
||||||
import {
|
import {
|
||||||
AdminGetIdentitiesFilters,
|
AdminGetIdentitiesFilters,
|
||||||
|
AdminGetOrganizationsFilters,
|
||||||
AdminGetUsersFilters,
|
AdminGetUsersFilters,
|
||||||
AdminIntegrationsConfig,
|
AdminIntegrationsConfig,
|
||||||
|
OrganizationWithProjects,
|
||||||
TGetInvalidatingCacheStatus,
|
TGetInvalidatingCacheStatus,
|
||||||
TGetServerRootKmsEncryptionDetails,
|
TGetServerRootKmsEncryptionDetails,
|
||||||
TServerConfig
|
TServerConfig
|
||||||
@@ -15,12 +17,15 @@ import {
|
|||||||
|
|
||||||
export const adminStandaloneKeys = {
|
export const adminStandaloneKeys = {
|
||||||
getUsers: "get-users",
|
getUsers: "get-users",
|
||||||
|
getOrganizations: "get-organizations",
|
||||||
getIdentities: "get-identities"
|
getIdentities: "get-identities"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const adminQueryKeys = {
|
export const adminQueryKeys = {
|
||||||
serverConfig: () => ["server-config"] as const,
|
serverConfig: () => ["server-config"] as const,
|
||||||
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const,
|
getUsers: (filters: AdminGetUsersFilters) => [adminStandaloneKeys.getUsers, { filters }] as const,
|
||||||
|
getOrganizations: (filters: AdminGetOrganizationsFilters) =>
|
||||||
|
[adminStandaloneKeys.getOrganizations, { filters }] as const,
|
||||||
getIdentities: (filters: AdminGetIdentitiesFilters) =>
|
getIdentities: (filters: AdminGetIdentitiesFilters) =>
|
||||||
[adminStandaloneKeys.getIdentities, { filters }] as const,
|
[adminStandaloneKeys.getIdentities, { filters }] as const,
|
||||||
getAdminSlackConfig: () => ["admin-slack-config"] as const,
|
getAdminSlackConfig: () => ["admin-slack-config"] as const,
|
||||||
@@ -34,6 +39,28 @@ export const fetchServerConfig = async () => {
|
|||||||
return data.config;
|
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 = ({
|
export const useGetServerConfig = ({
|
||||||
options = {}
|
options = {}
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Organization } from "../types";
|
||||||
|
|
||||||
export enum LoginMethod {
|
export enum LoginMethod {
|
||||||
EMAIL = "email",
|
EMAIL = "email",
|
||||||
GOOGLE = "google",
|
GOOGLE = "google",
|
||||||
@@ -8,6 +10,27 @@ export enum LoginMethod {
|
|||||||
OIDC = "oidc"
|
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 = {
|
export type TServerConfig = {
|
||||||
initialized: boolean;
|
initialized: boolean;
|
||||||
allowSignUp: boolean;
|
allowSignUp: boolean;
|
||||||
@@ -51,6 +74,11 @@ export type TCreateAdminUserDTO = {
|
|||||||
salt: string;
|
salt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminGetOrganizationsFilters = {
|
||||||
|
limit: number;
|
||||||
|
searchTerm: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminGetUsersFilters = {
|
export type AdminGetUsersFilters = {
|
||||||
limit: number;
|
limit: number;
|
||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { AuthPanel } from "./components/AuthPanel";
|
|||||||
import { CachingPanel } from "./components/CachingPanel";
|
import { CachingPanel } from "./components/CachingPanel";
|
||||||
import { EncryptionPanel } from "./components/EncryptionPanel";
|
import { EncryptionPanel } from "./components/EncryptionPanel";
|
||||||
import { IntegrationPanel } from "./components/IntegrationPanel";
|
import { IntegrationPanel } from "./components/IntegrationPanel";
|
||||||
|
import { OrganizationsPanel } from "./components/OrganizationsPanel";
|
||||||
import { UserPanel } from "./components/UserPanel";
|
import { UserPanel } from "./components/UserPanel";
|
||||||
|
|
||||||
enum TabSections {
|
enum TabSections {
|
||||||
@@ -42,6 +43,7 @@ enum TabSections {
|
|||||||
Auth = "auth",
|
Auth = "auth",
|
||||||
Integrations = "integrations",
|
Integrations = "integrations",
|
||||||
Users = "users",
|
Users = "users",
|
||||||
|
Organizations = "organizations",
|
||||||
Identities = "identities",
|
Identities = "identities",
|
||||||
Kmip = "kmip",
|
Kmip = "kmip",
|
||||||
Caching = "caching"
|
Caching = "caching"
|
||||||
@@ -157,16 +159,20 @@ export const OverviewPage = () => {
|
|||||||
<ContentLoader text={isNotAllowed ? "Redirecting to org page..." : undefined} />
|
<ContentLoader text={isNotAllowed ? "Redirecting to org page..." : undefined} />
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<Tabs defaultValue={TabSections.Settings}>
|
<Tabs defaultValue={TabSections.Settings} className="w-full">
|
||||||
<TabList>
|
<TabList className="flex w-full flex-row justify-between">
|
||||||
<div className="flex w-full flex-row border-b border-mineshaft-600">
|
<div className="flex w-full">
|
||||||
<Tab value={TabSections.Settings}>General</Tab>
|
<Tab value={TabSections.Settings}>General</Tab>
|
||||||
<Tab value={TabSections.Encryption}>Encryption</Tab>
|
<Tab value={TabSections.Encryption}>Encryption</Tab>
|
||||||
<Tab value={TabSections.Auth}>Authentication</Tab>
|
<Tab value={TabSections.Auth}>Authentication</Tab>
|
||||||
<Tab value={TabSections.Integrations}>Integrations</Tab>
|
<Tab value={TabSections.Integrations}>Integrations</Tab>
|
||||||
|
<Tab value={TabSections.Caching}>Caching</Tab>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex w-full justify-end">
|
||||||
|
<Tab value={TabSections.Organizations}>Organizations</Tab>
|
||||||
<Tab value={TabSections.Users}>User Identities</Tab>
|
<Tab value={TabSections.Users}>User Identities</Tab>
|
||||||
<Tab value={TabSections.Identities}>Machine Identities</Tab>
|
<Tab value={TabSections.Identities}>Machine Identities</Tab>
|
||||||
<Tab value={TabSections.Caching}>Caching</Tab>
|
|
||||||
</div>
|
</div>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanel value={TabSections.Settings}>
|
<TabPanel value={TabSections.Settings}>
|
||||||
@@ -405,6 +411,9 @@ export const OverviewPage = () => {
|
|||||||
<TabPanel value={TabSections.Integrations}>
|
<TabPanel value={TabSections.Integrations}>
|
||||||
<IntegrationPanel />
|
<IntegrationPanel />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
<TabPanel value={TabSections.Organizations}>
|
||||||
|
<OrganizationsPanel />
|
||||||
|
</TabPanel>
|
||||||
<TabPanel value={TabSections.Users}>
|
<TabPanel value={TabSections.Users}>
|
||||||
<UserPanel />
|
<UserPanel />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ const IdentityPanelTable = ({
|
|||||||
<Button
|
<Button
|
||||||
className="mt-4 py-3 text-sm"
|
className="mt-4 py-3 text-sm"
|
||||||
isFullWidth
|
isFullWidth
|
||||||
variant="star"
|
variant="outline_bg"
|
||||||
isLoading={isFetchingNextPage}
|
isLoading={isFetchingNextPage}
|
||||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||||
onClick={() => fetchNextPage()}
|
onClick={() => fetchNextPage()}
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
faBuilding,
|
||||||
|
faCircleQuestion,
|
||||||
|
faEllipsis,
|
||||||
|
faMagnifyingGlass
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
DeleteActionModal,
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
EmptyState,
|
||||||
|
Input,
|
||||||
|
Modal,
|
||||||
|
ModalContent,
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableSkeleton,
|
||||||
|
TBody,
|
||||||
|
Td,
|
||||||
|
Th,
|
||||||
|
THead,
|
||||||
|
Tooltip,
|
||||||
|
Tr
|
||||||
|
} from "@app/components/v2";
|
||||||
|
import { useDebounce, usePopUp } from "@app/hooks";
|
||||||
|
import {
|
||||||
|
useAdminDeleteOrganization,
|
||||||
|
useAdminDeleteOrganizationMembership,
|
||||||
|
useAdminDeleteUser,
|
||||||
|
useAdminGetOrganizations
|
||||||
|
} from "@app/hooks/api";
|
||||||
|
import { OrganizationWithProjects } from "@app/hooks/api/admin/types";
|
||||||
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
|
const ViewMembersModalContent = ({
|
||||||
|
popUp,
|
||||||
|
handlePopUpOpen
|
||||||
|
}: {
|
||||||
|
popUp: UsePopUpState<["viewMembers"]>;
|
||||||
|
handlePopUpOpen: (
|
||||||
|
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>,
|
||||||
|
data?: {
|
||||||
|
username?: string;
|
||||||
|
membershipId?: string;
|
||||||
|
userId?: string;
|
||||||
|
orgName?: string;
|
||||||
|
orgId?: string;
|
||||||
|
organization?: OrganizationWithProjects;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
}) => {
|
||||||
|
const organization = popUp.viewMembers?.data?.organization as OrganizationWithProjects;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{organization?.members?.map((member) => (
|
||||||
|
<div className="flex items-center justify-between gap-2 rounded-md bg-mineshaft-700 px-4 py-2">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-xs text-mineshaft-100">
|
||||||
|
<div>
|
||||||
|
{member.user.firstName ? (
|
||||||
|
<div>
|
||||||
|
{member.user.firstName} {member.user.lastName}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-mineshaft-400">Not set</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 opacity-80">
|
||||||
|
<div>{member.user.username || member.user.email}</div>
|
||||||
|
<Badge variant="primary">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="capitalize">{member.role.replace("-", " ")}</span>
|
||||||
|
{Boolean(member.roleId) && (
|
||||||
|
<Tooltip content="This member has a custom role assigned.">
|
||||||
|
<FontAwesomeIcon icon={faCircleQuestion} className="text-xs" />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faEllipsis}
|
||||||
|
className="cursor-pointer text-sm text-mineshaft-400 transition-all hover:text-primary-500"
|
||||||
|
/>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="p-1">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() =>
|
||||||
|
handlePopUpOpen("deleteOrganizationMembership", {
|
||||||
|
membershipId: member.membershipId,
|
||||||
|
orgId: organization.id,
|
||||||
|
username: member.user.username,
|
||||||
|
orgName: organization.name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Remove From Organization
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handlePopUpOpen("deleteUser", { userId: member.user.id })}
|
||||||
|
>
|
||||||
|
Delete User
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ViewMembersModal = ({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
popUp,
|
||||||
|
handlePopUpOpen
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
|
popUp: UsePopUpState<["viewMembers", "deleteOrganizationMembership", "deleteUser"]>;
|
||||||
|
handlePopUpOpen: (
|
||||||
|
popUpName: keyof UsePopUpState<["deleteOrganizationMembership", "deleteUser"]>
|
||||||
|
) => void;
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<ModalContent
|
||||||
|
onOpenAutoFocus={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
}}
|
||||||
|
title="Organization Members"
|
||||||
|
subTitle="View the members of the organization."
|
||||||
|
>
|
||||||
|
<ViewMembersModalContent popUp={popUp} handlePopUpOpen={handlePopUpOpen} />
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OrganizationsPanelTable = ({
|
||||||
|
popUp,
|
||||||
|
handlePopUpOpen,
|
||||||
|
handlePopUpToggle
|
||||||
|
}: {
|
||||||
|
popUp: UsePopUpState<
|
||||||
|
["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"]
|
||||||
|
>;
|
||||||
|
handlePopUpOpen: (
|
||||||
|
popUpName: keyof UsePopUpState<
|
||||||
|
["deleteOrganization", "viewMembers", "deleteOrganizationMembership", "deleteUser"]
|
||||||
|
>,
|
||||||
|
data?: {
|
||||||
|
orgName?: string;
|
||||||
|
orgId?: string;
|
||||||
|
message?: string;
|
||||||
|
organization?: OrganizationWithProjects;
|
||||||
|
}
|
||||||
|
) => void;
|
||||||
|
handlePopUpToggle: (
|
||||||
|
popUpName: keyof UsePopUpState<["deleteOrganization", "viewMembers"]>,
|
||||||
|
isOpen?: boolean
|
||||||
|
) => void;
|
||||||
|
}) => {
|
||||||
|
const [searchOrganizationsFilter, setSearchOrganizationsFilter] = useState("");
|
||||||
|
const [debouncedSearchTerm] = useDebounce(searchOrganizationsFilter, 500);
|
||||||
|
|
||||||
|
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
|
||||||
|
useAdminGetOrganizations({
|
||||||
|
limit: 20,
|
||||||
|
searchTerm: debouncedSearchTerm
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEmpty = !isPending && !data?.pages?.[0].length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={searchOrganizationsFilter}
|
||||||
|
onChange={(e) => setSearchOrganizationsFilter(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
placeholder="Search organizations..."
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<TableContainer>
|
||||||
|
<Table>
|
||||||
|
<THead>
|
||||||
|
<Tr>
|
||||||
|
<Th className="w-5/12">Name</Th>
|
||||||
|
<Th className="w-5/12">Members</Th>
|
||||||
|
<Th className="w-5/12">Projects</Th>
|
||||||
|
<Th className="w-5" />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isPending && <TableSkeleton columns={4} innerKey="organizations" />}
|
||||||
|
{!isPending &&
|
||||||
|
data?.pages?.map((orgs) =>
|
||||||
|
orgs.map((org) => {
|
||||||
|
return (
|
||||||
|
<Tr key={`org-${org.id}`} className="w-full">
|
||||||
|
<Td className="w-5/12">
|
||||||
|
{org.name ? (
|
||||||
|
org.name
|
||||||
|
) : (
|
||||||
|
<span className="text-mineshaft-400">Not set</span>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td className="w-5/12">
|
||||||
|
{org.members.length} {org.members.length === 1 ? "member" : "members"}
|
||||||
|
<Button
|
||||||
|
variant="outline_bg"
|
||||||
|
size="xs"
|
||||||
|
className="ml-2"
|
||||||
|
onClick={() => handlePopUpOpen("viewMembers", { organization: org })}
|
||||||
|
>
|
||||||
|
View Members
|
||||||
|
</Button>
|
||||||
|
</Td>
|
||||||
|
<Td className="w-5/12">
|
||||||
|
{org.projects.length} {org.projects.length === 1 ? "project" : "projects"}
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild className="rounded-lg">
|
||||||
|
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
||||||
|
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
||||||
|
</div>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="p-1">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePopUpOpen("deleteOrganization", {
|
||||||
|
orgId: org.id,
|
||||||
|
orgName: org.name
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete Organization
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
{!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />}
|
||||||
|
</TableContainer>
|
||||||
|
{!isEmpty && (
|
||||||
|
<Button
|
||||||
|
className="mt-4 py-3 text-sm"
|
||||||
|
isFullWidth
|
||||||
|
variant="outline_bg"
|
||||||
|
isLoading={isFetchingNextPage}
|
||||||
|
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||||
|
onClick={() => fetchNextPage()}
|
||||||
|
>
|
||||||
|
{hasNextPage ? "Load More" : "End of list"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ViewMembersModal
|
||||||
|
popUp={popUp}
|
||||||
|
handlePopUpOpen={handlePopUpOpen}
|
||||||
|
isOpen={popUp.viewMembers.isOpen}
|
||||||
|
onOpenChange={(isOpen) => 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 (
|
||||||
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-xl font-semibold text-mineshaft-100">Organizations</p>
|
||||||
|
</div>
|
||||||
|
<OrganizationsPanelTable
|
||||||
|
popUp={popUp}
|
||||||
|
handlePopUpOpen={handlePopUpOpen}
|
||||||
|
handlePopUpToggle={handlePopUpToggle}
|
||||||
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deleteOrganization.isOpen}
|
||||||
|
deleteKey="delete"
|
||||||
|
title={`Are you sure you want to delete organization ${
|
||||||
|
(popUp?.deleteOrganization?.data as { orgName: string })?.orgName || ""
|
||||||
|
}?`}
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deleteOrganization", isOpen)}
|
||||||
|
onDeleteApproved={handleDeleteOrganization}
|
||||||
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deleteOrganizationMembership.isOpen}
|
||||||
|
deleteKey="delete"
|
||||||
|
title={`Are you sure you want to remove ${
|
||||||
|
(popUp?.deleteOrganizationMembership?.data as { username: string })?.username || ""
|
||||||
|
} from organization ${
|
||||||
|
(popUp?.deleteOrganizationMembership?.data as { orgName: string })?.orgName || ""
|
||||||
|
}?`}
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deleteOrganizationMembership", isOpen)}
|
||||||
|
onDeleteApproved={handleDeleteOrganizationMembership}
|
||||||
|
/>
|
||||||
|
<DeleteActionModal
|
||||||
|
isOpen={popUp.deleteUser.isOpen}
|
||||||
|
deleteKey="delete"
|
||||||
|
title={`Are you sure you want to delete user ${
|
||||||
|
(popUp?.deleteUser?.data as { username: string })?.username || ""
|
||||||
|
}?`}
|
||||||
|
onChange={(isOpen) => handlePopUpToggle("deleteUser", isOpen)}
|
||||||
|
onDeleteApproved={handleDeleteUser}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -213,7 +213,7 @@ const UserPanelTable = ({
|
|||||||
<Button
|
<Button
|
||||||
className="mt-4 py-3 text-sm"
|
className="mt-4 py-3 text-sm"
|
||||||
isFullWidth
|
isFullWidth
|
||||||
variant="star"
|
variant="outline_bg"
|
||||||
isLoading={isFetchingNextPage}
|
isLoading={isFetchingNextPage}
|
||||||
isDisabled={isFetchingNextPage || !hasNextPage}
|
isDisabled={isFetchingNextPage || !hasNextPage}
|
||||||
onClick={() => fetchNextPage()}
|
onClick={() => fetchNextPage()}
|
||||||
|
|||||||
Reference in New Issue
Block a user