Merge pull request #4629 from Infisical/feat/pagination-in-resource-overview-tables

feat: adds pagination to resource overview tables
This commit is contained in:
Piyush Gupta
2025-10-09 21:17:25 +05:30
committed by GitHub
14 changed files with 489 additions and 416 deletions
+12 -15
View File
@@ -172,7 +172,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
email: true, email: true,
id: true, id: true,
superAdmin: true superAdmin: true
}).array() }).array(),
total: z.number()
}) })
} }
}, },
@@ -182,13 +183,11 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
}); });
}, },
handler: async (req) => { handler: async (req) => {
const users = await server.services.superAdmin.getUsers({ const result = await server.services.superAdmin.getUsers({
...req.query ...req.query
}); });
return { return result;
users
};
} }
}); });
@@ -230,7 +229,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
createdAt: z.date() createdAt: z.date()
}) })
.array() .array()
}).array() }).array(),
total: z.number()
}) })
} }
}, },
@@ -240,13 +240,11 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
}); });
}, },
handler: async (req) => { handler: async (req) => {
const organizations = await server.services.superAdmin.getOrganizations({ const result = await server.services.superAdmin.getOrganizations({
...req.query ...req.query
}); });
return { return result;
organizations
};
} }
}); });
@@ -337,7 +335,8 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
.extend({ .extend({
isInstanceAdmin: z.boolean() isInstanceAdmin: z.boolean()
}) })
.array() .array(),
total: z.number()
}) })
} }
}, },
@@ -347,13 +346,11 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => {
}); });
}, },
handler: async (req) => { handler: async (req) => {
const identities = await server.services.superAdmin.getIdentities({ const result = await server.services.superAdmin.getIdentities({
...req.query ...req.query
}); });
return { return result;
identities
};
} }
}); });
+13 -1
View File
@@ -50,11 +50,23 @@ export const identityDALFactory = (db: TDbClient) => {
}); });
} }
const countQuery = query.clone();
if (sortBy) { if (sortBy) {
query = query.orderBy(sortBy); query = query.orderBy(sortBy);
} }
return await query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Identity)); const [identities, totalResult] = await Promise.all([
query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Identity)),
countQuery.countDistinct(`${TableName.Identity}.id`, { as: "count" }).first()
]);
const total = Number(totalResult?.count || 0);
return {
identities,
total
};
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Get identities by filter" }); throw new DatabaseError({ error, name: "Get identities by filter" });
} }
+40 -29
View File
@@ -43,8 +43,6 @@ export const orgDALFactory = (db: TDbClient) => {
sortBy?: keyof TOrganizations; sortBy?: keyof TOrganizations;
}) => { }) => {
try { try {
const query = db.replicaNode()(TableName.Organization);
// Build the subquery for limited organization IDs // Build the subquery for limited organization IDs
const orgSubquery = db.replicaNode().select("id").from(TableName.Organization); const orgSubquery = db.replicaNode().select("id").from(TableName.Organization);
@@ -54,37 +52,45 @@ export const orgDALFactory = (db: TDbClient) => {
}); });
} }
const countQuery = orgSubquery.clone();
if (sortBy) { if (sortBy) {
void orgSubquery.orderBy(sortBy); void orgSubquery.orderBy(sortBy);
} }
void orgSubquery.limit(limit).offset(offset); void orgSubquery.limit(limit).offset(offset);
// Main query with joins, limited to the subquery results const buildBaseQuery = (orgIdSubquery: Knex.QueryBuilder) => {
const docs = await query return db
.whereIn(`${TableName.Organization}.id`, orgSubquery) .replicaNode()(TableName.Organization)
.leftJoin(TableName.Project, `${TableName.Organization}.id`, `${TableName.Project}.orgId`) .whereIn(`${TableName.Organization}.id`, orgIdSubquery)
.leftJoin(TableName.OrgMembership, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`) .leftJoin(TableName.Project, `${TableName.Organization}.id`, `${TableName.Project}.orgId`)
.leftJoin(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) .leftJoin(TableName.OrgMembership, `${TableName.Organization}.id`, `${TableName.OrgMembership}.orgId`)
.leftJoin(TableName.OrgRoles, `${TableName.OrgMembership}.roleId`, `${TableName.OrgRoles}.id`) .leftJoin(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`)
.where((qb) => { .leftJoin(TableName.OrgRoles, `${TableName.OrgMembership}.roleId`, `${TableName.OrgRoles}.id`)
void qb.where(`${TableName.Users}.isGhost`, false).orWhereNull(`${TableName.Users}.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")) const [docs, totalResult] = await Promise.all([
.select(db.ref("createdAt").withSchema(TableName.Project).as("projectCreatedAt")) buildBaseQuery(orgSubquery)
.select(db.ref("email").withSchema(TableName.Users).as("userEmail")) .select(selectAllTableCols(TableName.Organization))
.select(db.ref("username").withSchema(TableName.Users).as("username")) .select(db.ref("name").withSchema(TableName.Project).as("projectName"))
.select(db.ref("firstName").withSchema(TableName.Users).as("firstName")) .select(db.ref("id").withSchema(TableName.Project).as("projectId"))
.select(db.ref("lastName").withSchema(TableName.Users).as("lastName")) .select(db.ref("slug").withSchema(TableName.Project).as("projectSlug"))
.select(db.ref("id").withSchema(TableName.Users).as("userId")) .select(db.ref("createdAt").withSchema(TableName.Project).as("projectCreatedAt"))
.select(db.ref("id").withSchema(TableName.OrgMembership).as("orgMembershipId")) .select(db.ref("email").withSchema(TableName.Users).as("userEmail"))
.select(db.ref("role").withSchema(TableName.OrgMembership).as("orgMembershipRole")) .select(db.ref("username").withSchema(TableName.Users).as("username"))
.select(db.ref("roleId").withSchema(TableName.OrgMembership).as("orgMembershipRoleId")) .select(db.ref("firstName").withSchema(TableName.Users).as("firstName"))
.select(db.ref("status").withSchema(TableName.OrgMembership).as("orgMembershipStatus")) .select(db.ref("lastName").withSchema(TableName.Users).as("lastName"))
.select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName")); .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("status").withSchema(TableName.OrgMembership).as("orgMembershipStatus"))
.select(db.ref("name").withSchema(TableName.OrgRoles).as("orgMembershipRoleName")),
buildBaseQuery(countQuery).countDistinct(`${TableName.Organization}.id`, { as: "count" }).first()
]);
const formattedDocs = sqlNestRelationships({ const formattedDocs = sqlNestRelationships({
data: docs, data: docs,
@@ -132,7 +138,12 @@ export const orgDALFactory = (db: TDbClient) => {
] ]
}); });
return formattedDocs; const total = Number(totalResult?.count || 0);
return {
organizations: formattedDocs,
total
};
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Find organizations by filter" }); throw new DatabaseError({ error, name: "Find organizations by filter" });
} }
@@ -741,13 +741,12 @@ export const superAdminServiceFactory = ({
}; };
const getOrganizations = async ({ offset, limit, searchTerm }: TGetOrganizationsDTO) => { const getOrganizations = async ({ offset, limit, searchTerm }: TGetOrganizationsDTO) => {
const organizations = await orgDAL.findOrganizationsByFilter({ return orgDAL.findOrganizationsByFilter({
offset, offset,
searchTerm, searchTerm,
sortBy: "name", sortBy: "name",
limit limit
}); });
return organizations;
}; };
const createOrganization = async ( const createOrganization = async (
@@ -1015,7 +1014,7 @@ export const superAdminServiceFactory = ({
}; };
const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => { const getIdentities = async ({ offset, limit, searchTerm }: TAdminGetIdentitiesDTO) => {
const identities = await identityDAL.getIdentitiesByFilter({ const result = await identityDAL.getIdentitiesByFilter({
limit, limit,
offset, offset,
searchTerm, searchTerm,
@@ -1023,10 +1022,13 @@ export const superAdminServiceFactory = ({
}); });
const serverCfg = await getServerCfg(); const serverCfg = await getServerCfg();
return identities.map((identity) => ({ return {
...identity, identities: result.identities.map((identity) => ({
isInstanceAdmin: Boolean(serverCfg?.adminIdentityIds?.includes(identity.id)) ...identity,
})); isInstanceAdmin: Boolean(serverCfg?.adminIdentityIds?.includes(identity.id))
})),
total: result.total
};
}; };
const grantServerAdminAccessToUser = async (userId: string) => { const grantServerAdminAccessToUser = async (userId: string) => {
+10 -1
View File
@@ -60,11 +60,20 @@ export const userDALFactory = (db: TDbClient) => {
query = query.where("superAdmin", true); query = query.where("superAdmin", true);
} }
const countQuery = query.clone();
if (sortBy) { if (sortBy) {
query = query.orderBy(sortBy); query = query.orderBy(sortBy);
} }
return await query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Users)); const [users, totalResult] = await Promise.all([
query.limit(limit).offset(offset).select(selectAllTableCols(TableName.Users)),
countQuery.count("*", { as: "count" }).first()
]);
const total = Number(totalResult?.count || 0);
return { users, total };
} catch (error) { } catch (error) {
throw new DatabaseError({ error, name: "Get users by filter" }); throw new DatabaseError({ error, name: "Get users by filter" });
} }
+23 -51
View File
@@ -1,25 +1,18 @@
import { import { useQuery, UseQueryOptions } from "@tanstack/react-query";
DefaultError,
InfiniteData,
UndefinedInitialDataInfiniteOptions,
useInfiniteQuery,
useQuery,
UseQueryOptions
} from "@tanstack/react-query";
import { apiRequest } from "@app/config/request"; import { apiRequest } from "@app/config/request";
import { Identity } from "@app/hooks/api/identities/types";
import { User } from "../types";
import { import {
AdminGetIdentitiesFilters, AdminGetIdentitiesFilters,
AdminGetOrganizationsFilters, AdminGetOrganizationsFilters,
AdminGetUsersFilters, AdminGetUsersFilters,
AdminIntegrationsConfig, AdminIntegrationsConfig,
OrganizationWithProjects,
TGetEnvOverrides, TGetEnvOverrides,
TGetIdentitiesResponse,
TGetInvalidatingCacheStatus, TGetInvalidatingCacheStatus,
TGetOrganizationsResponse,
TGetServerRootKmsEncryptionDetails, TGetServerRootKmsEncryptionDetails,
TGetUsersResponse,
TServerConfig TServerConfig
} from "./types"; } from "./types";
@@ -49,24 +42,21 @@ export const fetchServerConfig = async () => {
}; };
export const useAdminGetOrganizations = (filters: AdminGetOrganizationsFilters) => { export const useAdminGetOrganizations = (filters: AdminGetOrganizationsFilters) => {
return useInfiniteQuery({ return useQuery({
initialPageParam: 0,
queryKey: adminQueryKeys.getOrganizations(filters), queryKey: adminQueryKeys.getOrganizations(filters),
queryFn: async ({ pageParam }) => { queryFn: async () => {
const { data } = await apiRequest.get<{ organizations: OrganizationWithProjects[] }>( const { data } = await apiRequest.get<TGetOrganizationsResponse>(
"/api/v1/admin/organization-management/organizations", "/api/v1/admin/organization-management/organizations",
{ {
params: { params: {
...filters, ...filters
offset: pageParam
} }
} }
); );
return data.organizations; return { organizations: data.organizations, totalCount: data.total };
}, },
getNextPageParam: (lastPage, pages) => placeholderData: (previousData) => previousData
lastPage.length !== 0 ? pages.length * filters.limit : undefined
}); });
}; };
@@ -90,59 +80,41 @@ export const useGetServerConfig = ({
enabled: options?.enabled ?? true enabled: options?.enabled ?? true
}); });
export const useAdminGetUsers = ( export const useAdminGetUsers = (filters: AdminGetUsersFilters) => {
filters: AdminGetUsersFilters, return useQuery({
options?: Partial<
UndefinedInitialDataInfiniteOptions<
User[],
DefaultError,
InfiniteData<User[]>,
ReturnType<typeof adminQueryKeys.getUsers>,
number
>
>
) => {
return useInfiniteQuery({
initialPageParam: 0,
queryKey: adminQueryKeys.getUsers(filters), queryKey: adminQueryKeys.getUsers(filters),
queryFn: async ({ pageParam }) => { queryFn: async () => {
const { data } = await apiRequest.get<{ users: User[] }>( const { data } = await apiRequest.get<TGetUsersResponse>(
"/api/v1/admin/user-management/users", "/api/v1/admin/user-management/users",
{ {
params: { params: {
...filters, ...filters
offset: pageParam
} }
} }
); );
return data.users; return { users: data.users, totalCount: data.total };
}, },
getNextPageParam: (lastPage, pages) => placeholderData: (previousData) => previousData
lastPage.length !== 0 ? pages.length * filters.limit : undefined,
...options
}); });
}; };
export const useAdminGetIdentities = (filters: AdminGetIdentitiesFilters) => { export const useAdminGetIdentities = (filters: AdminGetIdentitiesFilters) => {
return useInfiniteQuery({ return useQuery({
initialPageParam: 0,
queryKey: adminQueryKeys.getIdentities(filters), queryKey: adminQueryKeys.getIdentities(filters),
queryFn: async ({ pageParam }) => { queryFn: async () => {
const { data } = await apiRequest.get<{ identities: Identity[] }>( const { data } = await apiRequest.get<TGetIdentitiesResponse>(
"/api/v1/admin/identity-management/identities", "/api/v1/admin/identity-management/identities",
{ {
params: { params: {
...filters, ...filters
offset: pageParam
} }
} }
); );
return data.identities; return { identities: data.identities, totalCount: data.total };
}, },
getNextPageParam: (lastPage, pages) => placeholderData: (previousData) => previousData
lastPage.length !== 0 ? pages.length * filters.limit : undefined
}); });
}; };
+27 -8
View File
@@ -1,6 +1,7 @@
import { Identity } from "@app/hooks/api/identities/types";
import { OrgMembershipStatus } from "@app/hooks/api/organization/types"; import { OrgMembershipStatus } from "@app/hooks/api/organization/types";
import { Organization } from "../types"; import { Organization, User } from "../types";
export enum LoginMethod { export enum LoginMethod {
EMAIL = "email", EMAIL = "email",
@@ -34,6 +35,21 @@ export type OrganizationWithProjects = Organization & {
}[]; }[];
}; };
export type TGetOrganizationsResponse = {
organizations: OrganizationWithProjects[];
total: number;
};
export type TGetIdentitiesResponse = {
identities: Identity[];
total: number;
};
export type TGetUsersResponse = {
users: User[];
total: number;
};
export type TServerConfig = { export type TServerConfig = {
initialized: boolean; initialized: boolean;
allowSignUp: boolean; allowSignUp: boolean;
@@ -81,19 +97,22 @@ export type TCreateAdminUserDTO = {
}; };
export type AdminGetOrganizationsFilters = { export type AdminGetOrganizationsFilters = {
limit: number; searchTerm?: string;
searchTerm: string; limit?: number;
offset?: number;
}; };
export type AdminGetUsersFilters = { export type AdminGetUsersFilters = {
limit: number; limit?: number;
searchTerm: string; offset?: number;
adminsOnly: boolean; searchTerm?: string;
adminsOnly?: boolean;
}; };
export type AdminGetIdentitiesFilters = { export type AdminGetIdentitiesFilters = {
limit: number; limit?: number;
searchTerm: string; offset?: number;
searchTerm?: string;
}; };
export type AdminIntegrationsConfig = { export type AdminIntegrationsConfig = {
+22 -22
View File
@@ -103,29 +103,29 @@ export type TWorkspaceUser = {
organization: string; organization: string;
roles: ( roles: (
| { | {
id: string; id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom"; role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string; customRoleId: string;
customRoleName: string; customRoleName: string;
customRoleSlug: string; customRoleSlug: string;
isTemporary: false; isTemporary: false;
temporaryRange: null; temporaryRange: null;
temporaryMode: null; temporaryMode: null;
temporaryAccessEndTime: null; temporaryAccessEndTime: null;
temporaryAccessStartTime: null; temporaryAccessStartTime: null;
} }
| { | {
id: string; id: string;
role: "owner" | "admin" | "member" | "no-access" | "custom"; role: "owner" | "admin" | "member" | "no-access" | "custom";
customRoleId: string; customRoleId: string;
customRoleName: string; customRoleName: string;
customRoleSlug: string; customRoleSlug: string;
isTemporary: true; isTemporary: true;
temporaryRange: string; temporaryRange: string;
temporaryMode: ProjectUserMembershipTemporaryMode; temporaryMode: ProjectUserMembershipTemporaryMode;
temporaryAccessEndTime: string; temporaryAccessEndTime: string;
temporaryAccessStartTime: string; temporaryAccessStartTime: string;
} }
)[]; )[];
status: "invited" | "accepted" | "verified" | "completed"; status: "invited" | "accepted" | "verified" | "completed";
deniedPermissions: any[]; deniedPermissions: any[];
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useState } from "react";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@@ -55,18 +55,14 @@ const Content = ({ onClose }: ContentProps) => {
const [searchUserFilter, setSearchUserFilter] = useState(""); const [searchUserFilter, setSearchUserFilter] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500); const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500);
const { data, isFetching } = useAdminGetUsers( const { data, isPending } = useAdminGetUsers({
{ limit: 20,
limit: 20, searchTerm: debouncedSearchTerm,
searchTerm: debouncedSearchTerm, adminsOnly: false
adminsOnly: false });
},
{
placeholderData: (prev) => prev
}
);
const users = useMemo(() => data?.pages.flat().filter((user) => !user.superAdmin) ?? [], [data]); const { users: usersData = [] } = data ?? {};
const users = usersData.filter((user) => !user.superAdmin);
const onSubmit = async ({ user }: FormData) => { const onSubmit = async ({ user }: FormData) => {
try { try {
@@ -91,7 +87,7 @@ const Content = ({ onClose }: ContentProps) => {
render={({ field, fieldState: { error } }) => ( render={({ field, fieldState: { error } }) => (
<FormControl isError={Boolean(error)} errorText={error?.message} label="User"> <FormControl isError={Boolean(error)} errorText={error?.message} label="User">
<FilterableSelect <FilterableSelect
isLoading={searchUserFilter !== debouncedSearchTerm || isFetching} isLoading={searchUserFilter !== debouncedSearchTerm || isPending}
className="w-full" className="w-full"
placeholder="Search users..." placeholder="Search users..."
options={users} options={users}
@@ -11,7 +11,6 @@ import {
faXmark faXmark
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { InfiniteData } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
@@ -28,6 +27,7 @@ import {
EmptyState, EmptyState,
IconButton, IconButton,
Input, Input,
Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -39,7 +39,12 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useSubscription, useUser } from "@app/context"; import { useSubscription, useUser } from "@app/context";
import { useDebounce, usePopUp } from "@app/hooks"; import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, usePopUp } from "@app/hooks";
import { import {
useAdminBulkDeleteUsers, useAdminBulkDeleteUsers,
useAdminDeleteUser, useAdminDeleteUser,
@@ -54,15 +59,17 @@ const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions f
const ServerAdminsPanelTable = ({ const ServerAdminsPanelTable = ({
handlePopUpOpen, handlePopUpOpen,
users: usersPages, users,
isPending, isPending,
searchUserFilter, searchUserFilter,
setSearchUserFilter, setSearchUserFilter,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
selectedUsers, selectedUsers,
setSelectedUsers setSelectedUsers,
totalCount,
page,
perPage,
setPage,
handlePerPageChange
}: { }: {
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState< popUpName: keyof UsePopUpState<
@@ -75,20 +82,20 @@ const ServerAdminsPanelTable = ({
} }
) => void; ) => void;
isPending: boolean; isPending: boolean;
users: InfiniteData<User[], unknown> | undefined; users?: User[];
searchUserFilter: string; searchUserFilter: string;
setSearchUserFilter: (filter: string) => void; setSearchUserFilter: (filter: string) => void;
selectedUsers: User[]; selectedUsers: User[];
setSelectedUsers: Dispatch<SetStateAction<User[]>>; setSelectedUsers: Dispatch<SetStateAction<User[]>>;
isFetchingNextPage: boolean; totalCount: number;
fetchNextPage: () => void; page: number;
hasNextPage: boolean; perPage: number;
setPage: Dispatch<SetStateAction<number>>;
handlePerPageChange: (newPerPage: number) => void;
}) => { }) => {
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const users = usersPages?.pages.flat(); const isEmpty = !isPending && totalCount === 0;
const isEmpty = !isPending && !users?.length;
const selectedUserIds = selectedUsers.map((user) => user.id); const selectedUserIds = selectedUsers.map((user) => user.id);
@@ -241,16 +248,13 @@ const ServerAdminsPanelTable = ({
{!isPending && isEmpty && <EmptyState title="No users found" icon={faUsers} />} {!isPending && isEmpty && <EmptyState title="No users found" icon={faUsers} />}
</TableContainer> </TableContainer>
{!isEmpty && ( {!isEmpty && (
<Button <Pagination
className="mt-4 py-3 text-sm" count={totalCount}
isFullWidth page={page}
variant="outline_bg" perPage={perPage}
isLoading={isFetchingNextPage} onChangePage={(newPage) => setPage(newPage)}
isDisabled={isFetchingNextPage || !hasNextPage} onChangePerPage={handlePerPageChange}
onClick={() => fetchNextPage()} />
>
{hasNextPage ? "Load More" : "End of List"}
</Button>
)} )}
</div> </div>
</> </>
@@ -278,18 +282,24 @@ export const ServerAdminsTable = () => {
const [searchUserFilter, setSearchUserFilter] = useState(""); const [searchUserFilter, setSearchUserFilter] = useState("");
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500); const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
const { const { offset, limit, setPage, perPage, page, setPerPage } = usePagination("", {
data: users, initPerPage: getUserTablePreference("ServerAdminUsersTable", PreferenceKey.PerPage, 20)
isPending, });
isFetchingNextPage,
hasNextPage, const handlePerPageChange = (newPerPage: number) => {
fetchNextPage setPerPage(newPerPage);
} = useAdminGetUsers({ setUserTablePreference("ServerAdminUsersTable", PreferenceKey.PerPage, newPerPage);
limit: 20, };
const { data, isPending } = useAdminGetUsers({
limit,
offset,
searchTerm: debouncedSearchTerm, searchTerm: debouncedSearchTerm,
adminsOnly: true adminsOnly: true
}); });
const { users = [], totalCount = 0 } = data ?? {};
const handleRemoveUser = async () => { const handleRemoveUser = async () => {
const { id } = popUp?.removeUser?.data as { id: string; username: string }; const { id } = popUp?.removeUser?.data as { id: string; username: string };
@@ -389,9 +399,11 @@ export const ServerAdminsTable = () => {
searchUserFilter={searchUserFilter} searchUserFilter={searchUserFilter}
setSearchUserFilter={setSearchUserFilter} setSearchUserFilter={setSearchUserFilter}
isPending={isPending} isPending={isPending}
isFetchingNextPage={isFetchingNextPage} page={page}
hasNextPage={hasNextPage} perPage={perPage}
fetchNextPage={fetchNextPage} setPage={setPage}
handlePerPageChange={handlePerPageChange}
totalCount={totalCount}
/> />
<DeleteActionModal <DeleteActionModal
isOpen={popUp.removeUser.isOpen} isOpen={popUp.removeUser.isOpen}
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form"; import { Controller, useFieldArray, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@@ -72,18 +72,13 @@ const Content = ({ onClose }: ContentProps) => {
const [searchUserFilter, setSearchUserFilter] = useState(""); const [searchUserFilter, setSearchUserFilter] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500); const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500);
const { data, isFetching } = useAdminGetUsers( const { data, isPending } = useAdminGetUsers({
{ limit: 20,
limit: 20, searchTerm: debouncedSearchTerm,
searchTerm: debouncedSearchTerm, adminsOnly: false
adminsOnly: false });
},
{
placeholderData: (prev) => prev
}
);
const users = useMemo(() => data?.pages.flat() ?? [], [data]); const { users = [] } = data ?? {};
const onSubmit = async ({ name, invitees }: FormData) => { const onSubmit = async ({ name, invitees }: FormData) => {
try { try {
@@ -155,7 +150,7 @@ const Content = ({ onClose }: ContentProps) => {
}) })
.includes(input) .includes(input)
} }
isLoading={searchUserFilter !== debouncedSearchTerm || isFetching} isLoading={searchUserFilter !== debouncedSearchTerm || isPending}
className="w-full" className="w-full"
placeholder="Search users or invite new ones..." placeholder="Search users or invite new ones..."
isMulti isMulti
@@ -11,7 +11,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { import {
Badge, Badge,
Button,
DeleteActionModal, DeleteActionModal,
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -20,6 +19,7 @@ import {
EmptyState, EmptyState,
IconButton, IconButton,
Input, Input,
Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -29,7 +29,12 @@ import {
THead, THead,
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useDebounce, usePopUp } from "@app/hooks"; import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { useAdminRemoveIdentitySuperAdminAccess } from "@app/hooks/api/admin"; import { useAdminRemoveIdentitySuperAdminAccess } from "@app/hooks/api/admin";
import { useAdminGetIdentities } from "@app/hooks/api/admin/queries"; import { useAdminGetIdentities } from "@app/hooks/api/admin/queries";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
@@ -48,14 +53,34 @@ const IdentityPanelTable = ({
const [searchIdentityFilter, setSearchIdentityFilter] = useState(""); const [searchIdentityFilter, setSearchIdentityFilter] = useState("");
const [debouncedSearchTerm] = useDebounce(searchIdentityFilter, 500); const [debouncedSearchTerm] = useDebounce(searchIdentityFilter, 500);
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } = useAdminGetIdentities( const { offset, limit, setPage, perPage, page, setPerPage } = usePagination("", {
{ initPerPage: getUserTablePreference(
limit: 20, "ResourceOverviewIdentitiesTable",
searchTerm: debouncedSearchTerm PreferenceKey.PerPage,
} 10
); )
});
const isEmpty = !isPending && !data?.pages?.[0].length; const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("ResourceOverviewIdentitiesTable", PreferenceKey.PerPage, newPerPage);
};
const { data, isPending } = useAdminGetIdentities({
limit,
offset,
searchTerm: debouncedSearchTerm
});
const { identities, totalCount = 0 } = data ?? {};
const isEmpty = !isPending && !totalCount;
useResetPageHelper({
totalCount,
offset,
setPage
});
return ( return (
<> <>
@@ -80,75 +105,68 @@ const IdentityPanelTable = ({
<TBody> <TBody>
{isPending && <TableSkeleton columns={2} innerKey="identities" />} {isPending && <TableSkeleton columns={2} innerKey="identities" />}
{!isPending && {!isPending &&
data?.pages?.map((identities) => identities?.map(({ name, id, isInstanceAdmin }) => (
identities.map(({ name, id, isInstanceAdmin }) => ( <Tr key={`identity-${id}`} className="w-full">
<Tr key={`identity-${id}`} className="w-full"> <Td>
<Td> {name}
{name} {isInstanceAdmin && (
{isInstanceAdmin && ( <Badge variant="primary" className="ml-2">
<Badge variant="primary" className="ml-2"> Server Admin
Server Admin </Badge>
</Badge> )}
)} </Td>
</Td> <Td>
<Td> {isInstanceAdmin && (
{isInstanceAdmin && ( <div className="flex justify-end">
<div className="flex justify-end"> <DropdownMenu>
<DropdownMenu> <DropdownMenuTrigger asChild>
<DropdownMenuTrigger asChild> <IconButton
<IconButton ariaLabel="Options"
ariaLabel="Options" colorSchema="secondary"
colorSchema="secondary" className="w-6"
className="w-6" variant="plain"
variant="plain" >
> <FontAwesomeIcon icon={faEllipsisV} />
<FontAwesomeIcon icon={faEllipsisV} /> </IconButton>
</IconButton> </DropdownMenuTrigger>
</DropdownMenuTrigger> <DropdownMenuContent sideOffset={2} align="end">
<DropdownMenuContent sideOffset={2} align="end"> <DropdownMenuItem
{isInstanceAdmin && ( onClick={(e) => {
<DropdownMenuItem e.stopPropagation();
onClick={(e) => { handlePopUpOpen("removeServerAdmin", { name, id });
e.stopPropagation(); }}
handlePopUpOpen("removeServerAdmin", { name, id }); icon={
}} <div className="relative">
icon={ <FontAwesomeIcon icon={faShieldHalved} />
<div className="relative"> <FontAwesomeIcon
<FontAwesomeIcon icon={faShieldHalved} /> className="absolute -bottom-[0.01rem] -right-1"
<FontAwesomeIcon size="2xs"
className="absolute -bottom-[0.01rem] -right-1" icon={faXmark}
size="2xs" />
icon={faXmark} </div>
/> }
</div> >
} Remove Server Admin
> </DropdownMenuItem>
Remove Server Admin </DropdownMenuContent>
</DropdownMenuItem> </DropdownMenu>
)} </div>
</DropdownMenuContent> )}
</DropdownMenu> </Td>
</div> </Tr>
)} ))}
</Td>
</Tr>
))
)}
</TBody> </TBody>
</Table> </Table>
{!isPending && isEmpty && <EmptyState title="No identities found" icon={faWrench} />} {!isPending && isEmpty && <EmptyState title="No identities found" icon={faWrench} />}
</TableContainer> </TableContainer>
{!isEmpty && ( {!isPending && totalCount > 0 && (
<Button <Pagination
className="mt-4 py-3 text-sm" count={totalCount}
isFullWidth page={page}
variant="outline_bg" perPage={perPage}
isLoading={isFetchingNextPage} onChangePage={(newPage) => setPage(newPage)}
isDisabled={isFetchingNextPage || !hasNextPage} onChangePerPage={handlePerPageChange}
onClick={() => fetchNextPage()} />
>
{hasNextPage ? "Load More" : "End of List"}
</Button>
)} )}
</div> </div>
</> </>
@@ -48,6 +48,11 @@ import {
} from "@app/components/v2"; } from "@app/components/v2";
import { useUser } from "@app/context"; import { useUser } from "@app/context";
import { OrgMembershipRole } from "@app/helpers/roles"; import { OrgMembershipRole } from "@app/helpers/roles";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks"; import { useDebounce, usePagination, usePopUp, useResetPageHelper } from "@app/hooks";
import { import {
useAdminDeleteOrganization, useAdminDeleteOrganization,
@@ -443,13 +448,31 @@ const OrganizationsPanelTable = ({
const { user } = useUser(); const { user } = useUser();
const navigate = useNavigate(); const navigate = useNavigate();
const { data, isPending, isFetchingNextPage, hasNextPage, fetchNextPage } =
useAdminGetOrganizations({
limit: 20,
searchTerm: debouncedSearchTerm
});
const isEmpty = !isPending && !data?.pages?.[0].length; const { offset, limit, setPage, perPage, page, setPerPage } = usePagination("", {
initPerPage: getUserTablePreference("ResourceOverviewOrgsTable", PreferenceKey.PerPage, 10)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("ResourceOverviewOrgsTable", PreferenceKey.PerPage, newPerPage);
};
const { data, isPending } = useAdminGetOrganizations({
limit,
offset,
searchTerm: debouncedSearchTerm
});
const { organizations, totalCount = 0 } = data ?? {};
const isEmpty = !isPending && !totalCount;
useResetPageHelper({
totalCount,
offset,
setPage
});
const { mutateAsync: accessOrganization } = useServerAdminAccessOrg(); const { mutateAsync: accessOrganization } = useServerAdminAccessOrg();
@@ -499,125 +522,120 @@ const OrganizationsPanelTable = ({
<TBody> <TBody>
{isPending && <TableSkeleton columns={4} innerKey="organizations" />} {isPending && <TableSkeleton columns={4} innerKey="organizations" />}
{!isPending && {!isPending &&
data?.pages?.map((orgs) => organizations?.map((org) => {
orgs.map((org) => { const isMember = org.members.find((member) => member.user.id === user.id);
const isMember = org.members.find((member) => member.user.id === user.id);
return ( return (
<Tr key={`org-${org.id}`} className="w-full"> <Tr key={`org-${org.id}`} className="w-full">
<Td className="w-1/2 max-w-0"> <Td className="w-1/2 max-w-0">
<div className="flex items-center gap-x-1.5"> <div className="flex items-center gap-x-1.5">
{org.name ? ( {org.name ? (
<p className="truncate">{org.name}</p> <p className="truncate">{org.name}</p>
) : ( ) : (
<span className="text-mineshaft-400">Not Set</span> <span className="text-mineshaft-400">Not Set</span>
)} )}
</div> </div>
</Td> </Td>
<Td className="w-1/3"> <Td className="w-1/3">
<button <button
type="button" type="button"
onClick={() => handlePopUpOpen("viewMembers", { organization: org })} onClick={() => handlePopUpOpen("viewMembers", { organization: org })}
className="flex items-center hover:underline" className="flex items-center hover:underline"
> >
<Tooltip className="text-center" content="View Members"> <Tooltip className="text-center" content="View Members">
<FontAwesomeIcon <FontAwesomeIcon
icon={faEye} icon={faEye}
className="mr-1.5 text-mineshaft-300" className="mr-1.5 text-mineshaft-300"
size="sm" size="sm"
/> />
</Tooltip>
{org.members.length} {org.members.length === 1 ? "Member" : "Members"}
{!org.members.some(
(member) =>
member.role === OrgMembershipRole.Admin &&
member.status === OrgMembershipStatus.Accepted
) && (
<Tooltip content="No admins have accepted their invitations.">
<div className="ml-1.5">
<FontAwesomeIcon className="text-yellow" icon={faWarning} />
</div>
</Tooltip> </Tooltip>
{org.members.length} {org.members.length === 1 ? "Member" : "Members"} )}
{!org.members.some( </button>
(member) => </Td>
member.role === OrgMembershipRole.Admin && <Td className="w-1/3">
member.status === OrgMembershipStatus.Accepted {org.projects.length} {org.projects.length === 1 ? "Project" : "Projects"}
) && ( </Td>
<Tooltip content="No admins have accepted their invitations."> <Td>
<div className="ml-1.5"> <div className="flex justify-end gap-x-1">
<FontAwesomeIcon className="text-yellow" icon={faWarning} /> {isMember && (
</div> <Tooltip
</Tooltip> className="text-center"
)} content="You are a member of this organization"
</button> >
</Td> <div>
<Td className="w-1/3"> <FontAwesomeIcon
{org.projects.length} {org.projects.length === 1 ? "Project" : "Projects"} className="text-mineshaft-400"
</Td> icon={faUserCheck}
<Td> size="sm"
<div className="flex justify-end gap-x-1"> />
{isMember && ( </div>
<Tooltip </Tooltip>
className="text-center" )}
content="You are a member of this organization" <DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
> >
<div> <FontAwesomeIcon icon={faEllipsisV} />
<FontAwesomeIcon </IconButton>
className="text-mineshaft-400" </DropdownMenuTrigger>
icon={faUserCheck} <DropdownMenuContent sideOffset={2} align="end">
size="sm" {!isMember && (
/>
</div>
</Tooltip>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
{!isMember && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleAccessOrg(org.id);
}}
icon={<FontAwesomeIcon icon={faUserPlus} />}
>
Join Organization
</DropdownMenuItem>
)}
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handlePopUpOpen("deleteOrganization", { handleAccessOrg(org.id);
orgId: org.id,
orgName: org.name
});
}} }}
icon={<FontAwesomeIcon icon={faTrash} />} icon={<FontAwesomeIcon icon={faUserPlus} />}
> >
Delete Organization Join Organization
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> )}
</DropdownMenu> <DropdownMenuItem
</div> onClick={(e) => {
</Td> e.stopPropagation();
</Tr> handlePopUpOpen("deleteOrganization", {
); orgId: org.id,
}) orgName: org.name
)} });
}}
icon={<FontAwesomeIcon icon={faTrash} />}
>
Delete Organization
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
</Tr>
);
})}
</TBody> </TBody>
</Table> </Table>
{!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />} {!isPending && isEmpty && <EmptyState title="No organizations found" icon={faBuilding} />}
</TableContainer> </TableContainer>
{!isEmpty && ( {!isPending && totalCount > 0 && (
<Button <Pagination
className="mt-4 py-3 text-sm" count={totalCount}
isFullWidth page={page}
variant="outline_bg" perPage={perPage}
isLoading={isFetchingNextPage} onChangePage={(newPage) => setPage(newPage)}
isDisabled={isFetchingNextPage || !hasNextPage} onChangePerPage={handlePerPageChange}
onClick={() => fetchNextPage()} />
>
{hasNextPage ? "Load More" : "End of list"}
</Button>
)} )}
</div> </div>
<ViewMembersModal <ViewMembersModal
@@ -13,7 +13,6 @@ import {
faXmark faXmark
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { InfiniteData } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
@@ -31,6 +30,7 @@ import {
EmptyState, EmptyState,
IconButton, IconButton,
Input, Input,
Pagination,
Table, Table,
TableContainer, TableContainer,
TableSkeleton, TableSkeleton,
@@ -42,7 +42,12 @@ import {
Tr Tr
} from "@app/components/v2"; } from "@app/components/v2";
import { useSubscription, useUser } from "@app/context"; import { useSubscription, useUser } from "@app/context";
import { useDebounce, usePopUp } from "@app/hooks"; import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { useDebounce, usePagination, usePopUp } from "@app/hooks";
import { import {
useAdminBulkDeleteUsers, useAdminBulkDeleteUsers,
useAdminDeleteUser, useAdminDeleteUser,
@@ -58,17 +63,19 @@ const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions f
const UserPanelTable = ({ const UserPanelTable = ({
handlePopUpOpen, handlePopUpOpen,
users: usersPages, users,
isPending, isPending,
adminsOnly, adminsOnly,
searchUserFilter, searchUserFilter,
setSearchUserFilter, setSearchUserFilter,
setAdminsOnly, setAdminsOnly,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
selectedUsers, selectedUsers,
setSelectedUsers setSelectedUsers,
totalCount,
page,
perPage,
setPage,
handlePerPageChange
}: { }: {
handlePopUpOpen: ( handlePopUpOpen: (
popUpName: keyof UsePopUpState< popUpName: keyof UsePopUpState<
@@ -81,22 +88,22 @@ const UserPanelTable = ({
} }
) => void; ) => void;
isPending: boolean; isPending: boolean;
users: InfiniteData<User[], unknown> | undefined; users: User[] | undefined;
adminsOnly: boolean; adminsOnly: boolean;
setAdminsOnly: (adminsOnly: boolean) => void; setAdminsOnly: (adminsOnly: boolean) => void;
searchUserFilter: string; searchUserFilter: string;
setSearchUserFilter: (filter: string) => void; setSearchUserFilter: (filter: string) => void;
selectedUsers: User[]; selectedUsers: User[];
setSelectedUsers: Dispatch<SetStateAction<User[]>>; setSelectedUsers: Dispatch<SetStateAction<User[]>>;
isFetchingNextPage: boolean; totalCount: number;
fetchNextPage: () => void; page: number;
hasNextPage: boolean; perPage: number;
setPage: Dispatch<SetStateAction<number>>;
handlePerPageChange: (newPerPage: number) => void;
}) => { }) => {
const { subscription } = useSubscription(); const { subscription } = useSubscription();
const users = usersPages?.pages.flat(); const isEmpty = !isPending && totalCount === 0;
const isEmpty = !isPending && !users?.length;
const isTableFiltered = Boolean(adminsOnly); const isTableFiltered = Boolean(adminsOnly);
const selectedUserIds = selectedUsers.map((user) => user.id); const selectedUserIds = selectedUsers.map((user) => user.id);
@@ -302,16 +309,13 @@ const UserPanelTable = ({
{!isPending && isEmpty && <EmptyState title="No users found" icon={faUsers} />} {!isPending && isEmpty && <EmptyState title="No users found" icon={faUsers} />}
</TableContainer> </TableContainer>
{!isEmpty && ( {!isEmpty && (
<Button <Pagination
className="mt-4 py-3 text-sm" count={totalCount}
isFullWidth page={page}
variant="outline_bg" perPage={perPage}
isLoading={isFetchingNextPage} onChangePage={(newPage) => setPage(newPage)}
isDisabled={isFetchingNextPage || !hasNextPage} onChangePerPage={handlePerPageChange}
onClick={() => fetchNextPage()} />
>
{hasNextPage ? "Load More" : "End of List"}
</Button>
)} )}
</div> </div>
</> </>
@@ -341,18 +345,24 @@ export const UserIdentitiesTable = () => {
const [adminsOnly, setAdminsOnly] = useState(false); const [adminsOnly, setAdminsOnly] = useState(false);
const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500); const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500);
const { const { offset, limit, setPage, perPage, page, setPerPage } = usePagination("", {
data: users, initPerPage: getUserTablePreference("ResourceOverviewUsersTable", PreferenceKey.PerPage, 20)
isPending, });
isFetchingNextPage,
hasNextPage, const handlePerPageChange = (newPerPage: number) => {
fetchNextPage setPerPage(newPerPage);
} = useAdminGetUsers({ setUserTablePreference("ResourceOverviewUsersTable", PreferenceKey.PerPage, newPerPage);
limit: 20, };
const { data, isPending } = useAdminGetUsers({
limit,
offset,
searchTerm: debouncedSearchTerm, searchTerm: debouncedSearchTerm,
adminsOnly adminsOnly
}); });
const { users, totalCount = 0 } = data ?? {};
const handleRemoveUser = async () => { const handleRemoveUser = async () => {
const { id } = popUp?.removeUser?.data as { id: string; username: string }; const { id } = popUp?.removeUser?.data as { id: string; username: string };
@@ -479,9 +489,11 @@ export const UserIdentitiesTable = () => {
isPending={isPending} isPending={isPending}
adminsOnly={adminsOnly} adminsOnly={adminsOnly}
setAdminsOnly={setAdminsOnly} setAdminsOnly={setAdminsOnly}
isFetchingNextPage={isFetchingNextPage} page={page}
hasNextPage={hasNextPage} perPage={perPage}
fetchNextPage={fetchNextPage} setPage={setPage}
handlePerPageChange={handlePerPageChange}
totalCount={totalCount}
/> />
<DeleteActionModal <DeleteActionModal
isOpen={popUp.removeUser.isOpen} isOpen={popUp.removeUser.isOpen}