From c91b803933f162fb7f222ced4143ad06379b4387 Mon Sep 17 00:00:00 2001 From: Piyush Gupta Date: Wed, 8 Oct 2025 23:10:22 +0530 Subject: [PATCH] feat: adds pagination in users table --- backend/src/server/routes/v1/admin-router.ts | 19 +++- .../super-admin/super-admin-service.ts | 11 +++ .../services/super-admin/super-admin-types.ts | 5 ++ backend/src/services/user/user-dal.ts | 30 +++++++ frontend/src/hooks/api/admin/queries.ts | 40 ++------- frontend/src/hooks/api/admin/types.ts | 18 +++- frontend/src/hooks/api/users/types.ts | 44 +++++----- .../components/AddServerAdminModal.tsx | 22 ++--- .../components/ServerAdminsTable.tsx | 86 +++++++++++-------- .../components/AddOrganizationModal.tsx | 21 ++--- .../components/UserIdentitiesTable.tsx | 86 +++++++++++-------- 11 files changed, 225 insertions(+), 157 deletions(-) diff --git a/backend/src/server/routes/v1/admin-router.ts b/backend/src/server/routes/v1/admin-router.ts index 7b31326e4..eee4694e8 100644 --- a/backend/src/server/routes/v1/admin-router.ts +++ b/backend/src/server/routes/v1/admin-router.ts @@ -172,7 +172,10 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { email: true, id: true, superAdmin: true - }).array() + }).array(), + meta: z.object({ + total: z.number() + }) }) } }, @@ -186,8 +189,16 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { ...req.query }); + const count = await server.services.superAdmin.countUsers({ + searchTerm: req.query.searchTerm, + adminsOnly: req.query.adminsOnly + }); + return { - users + users, + meta: { + total: count + } }; } }); @@ -247,7 +258,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { ...req.query }); const count = await server.services.superAdmin.countOrganizations({ - ...req.query + searchTerm: req.query.searchTerm }); return { @@ -364,7 +375,7 @@ export const registerAdminRouter = async (server: FastifyZodProvider) => { }); const count = await server.services.superAdmin.countIdentities({ - ...req.query + searchTerm: req.query.searchTerm }); return { diff --git a/backend/src/services/super-admin/super-admin-service.ts b/backend/src/services/super-admin/super-admin-service.ts index d056986e2..6f26b40e9 100644 --- a/backend/src/services/super-admin/super-admin-service.ts +++ b/backend/src/services/super-admin/super-admin-service.ts @@ -57,6 +57,7 @@ import { TAdminSignUpDTO, TCountIdentitiesDTO, TCountOrganizationsDTO, + TCountUsersDTO, TCreateOrganizationDTO, TGetOrganizationsDTO, TResendOrgInviteDTO @@ -670,6 +671,15 @@ export const superAdminServiceFactory = ({ }); }; + const countUsers = async ({ searchTerm, adminsOnly }: TCountUsersDTO) => { + const count = await userDAL.countUsersByFilter({ + searchTerm, + adminsOnly + }); + + return count; + }; + const deleteUser = async (userId: string) => { const superAdmins = await userDAL.find({ superAdmin: true @@ -1151,6 +1161,7 @@ export const superAdminServiceFactory = ({ adminSignUp, bootstrapInstance, getUsers, + countUsers, deleteUser, getIdentities, countIdentities, diff --git a/backend/src/services/super-admin/super-admin-types.ts b/backend/src/services/super-admin/super-admin-types.ts index ff606c1ed..467b6d808 100644 --- a/backend/src/services/super-admin/super-admin-types.ts +++ b/backend/src/services/super-admin/super-admin-types.ts @@ -42,6 +42,11 @@ export type TCountIdentitiesDTO = { searchTerm: string; }; +export type TCountUsersDTO = { + searchTerm: string; + adminsOnly: boolean; +}; + export type TCreateOrganizationDTO = { name: string; inviteAdminEmails: string[]; diff --git a/backend/src/services/user/user-dal.ts b/backend/src/services/user/user-dal.ts index 4267d13ee..80f9fa510 100644 --- a/backend/src/services/user/user-dal.ts +++ b/backend/src/services/user/user-dal.ts @@ -70,6 +70,35 @@ export const userDALFactory = (db: TDbClient) => { } }; + const countUsersByFilter = async ({ searchTerm, adminsOnly }: { searchTerm: string; adminsOnly: boolean }) => { + interface CountResult { + count: string; + } + try { + const count = await db + .replicaNode()(TableName.Users) + .where("isGhost", "=", false) + .where((qb) => { + if (searchTerm) { + void qb + .whereILike("email", `%${searchTerm}%`) + .orWhereILike("firstName", `%${searchTerm}%`) + .orWhereILike("lastName", `%${searchTerm}%`) + .orWhereRaw('lower("username") like ?', `%${searchTerm}%`); + } + if (adminsOnly) { + void qb.where("superAdmin", true); + } + }) + .count("*") + .first(); + + return parseInt((count as unknown as CountResult).count || "0", 10); + } catch (error) { + throw new DatabaseError({ error, name: "Count Users by filter" }); + } + }; + // USER ENCRYPTION FUNCTIONS // ------------------------- const findUserEncKeyByUsername = async ({ username }: { username: string }) => { @@ -243,6 +272,7 @@ export const userDALFactory = (db: TDbClient) => { findOneUserAction, createUserAction, getUsersByFilter, + countUsersByFilter, findAllMyAccounts, findUserByEmail }; diff --git a/frontend/src/hooks/api/admin/queries.ts b/frontend/src/hooks/api/admin/queries.ts index c16252fa3..e52931d73 100644 --- a/frontend/src/hooks/api/admin/queries.ts +++ b/frontend/src/hooks/api/admin/queries.ts @@ -1,15 +1,7 @@ -import { - DefaultError, - InfiniteData, - UndefinedInitialDataInfiniteOptions, - useInfiniteQuery, - useQuery, - UseQueryOptions -} from "@tanstack/react-query"; +import { useQuery, UseQueryOptions } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; -import { User } from "../types"; import { AdminGetIdentitiesFilters, AdminGetOrganizationsFilters, @@ -20,6 +12,7 @@ import { TGetInvalidatingCacheStatus, TGetOrganizationsResponse, TGetServerRootKmsEncryptionDetails, + TGetUsersResponse, TServerConfig } from "./types"; @@ -87,37 +80,22 @@ export const useGetServerConfig = ({ enabled: options?.enabled ?? true }); -export const useAdminGetUsers = ( - filters: AdminGetUsersFilters, - options?: Partial< - UndefinedInitialDataInfiniteOptions< - User[], - DefaultError, - InfiniteData, - ReturnType, - number - > - > -) => { - return useInfiniteQuery({ - initialPageParam: 0, +export const useAdminGetUsers = (filters: AdminGetUsersFilters) => { + return useQuery({ queryKey: adminQueryKeys.getUsers(filters), - queryFn: async ({ pageParam }) => { - const { data } = await apiRequest.get<{ users: User[] }>( + queryFn: async () => { + const { data } = await apiRequest.get( "/api/v1/admin/user-management/users", { params: { - ...filters, - offset: pageParam + ...filters } } ); - return data.users; + return { users: data.users, totalCount: data.meta.total }; }, - getNextPageParam: (lastPage, pages) => - lastPage.length !== 0 ? pages.length * filters.limit : undefined, - ...options + placeholderData: (previousData) => previousData }); }; diff --git a/frontend/src/hooks/api/admin/types.ts b/frontend/src/hooks/api/admin/types.ts index f8c8fbd2d..1a9a65994 100644 --- a/frontend/src/hooks/api/admin/types.ts +++ b/frontend/src/hooks/api/admin/types.ts @@ -1,7 +1,7 @@ import { Identity } from "@app/hooks/api/identities/types"; import { OrgMembershipStatus } from "@app/hooks/api/organization/types"; -import { Organization } from "../types"; +import { Organization, User } from "../types"; export enum LoginMethod { EMAIL = "email", @@ -49,6 +49,11 @@ export type TGetIdentitiesResponse = { meta: PaginatedDataMeta; }; +export type TGetUsersResponse = { + users: User[]; + meta: PaginatedDataMeta; +}; + export type TServerConfig = { initialized: boolean; allowSignUp: boolean; @@ -102,9 +107,10 @@ export type AdminGetOrganizationsFilters = { }; export type AdminGetUsersFilters = { - limit: number; - searchTerm: string; - adminsOnly: boolean; + limit?: number; + offset?: number; + searchTerm?: string; + adminsOnly?: boolean; }; export type AdminGetIdentitiesFilters = { @@ -187,3 +193,7 @@ export enum AdminOrganizationsOrderBy { export enum AdminIdentitiesOrderBy { Name = "name" } + +export enum AdminUsersOrderBy { + Username = "username" +} diff --git a/frontend/src/hooks/api/users/types.ts b/frontend/src/hooks/api/users/types.ts index a59965dfc..f442b4c1f 100644 --- a/frontend/src/hooks/api/users/types.ts +++ b/frontend/src/hooks/api/users/types.ts @@ -103,29 +103,29 @@ export type TWorkspaceUser = { organization: string; roles: ( | { - id: string; - role: "owner" | "admin" | "member" | "no-access" | "custom"; - customRoleId: string; - customRoleName: string; - customRoleSlug: string; - isTemporary: false; - temporaryRange: null; - temporaryMode: null; - temporaryAccessEndTime: null; - temporaryAccessStartTime: null; - } + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + isTemporary: false; + temporaryRange: null; + temporaryMode: null; + temporaryAccessEndTime: null; + temporaryAccessStartTime: null; + } | { - id: string; - role: "owner" | "admin" | "member" | "no-access" | "custom"; - customRoleId: string; - customRoleName: string; - customRoleSlug: string; - isTemporary: true; - temporaryRange: string; - temporaryMode: ProjectUserMembershipTemporaryMode; - temporaryAccessEndTime: string; - temporaryAccessStartTime: string; - } + id: string; + role: "owner" | "admin" | "member" | "no-access" | "custom"; + customRoleId: string; + customRoleName: string; + customRoleSlug: string; + isTemporary: true; + temporaryRange: string; + temporaryMode: ProjectUserMembershipTemporaryMode; + temporaryAccessEndTime: string; + temporaryAccessStartTime: string; + } )[]; status: "invited" | "accepted" | "verified" | "completed"; deniedPermissions: any[]; diff --git a/frontend/src/pages/admin/AccessManagementPage/components/AddServerAdminModal.tsx b/frontend/src/pages/admin/AccessManagementPage/components/AddServerAdminModal.tsx index aa02d40e8..3e7638627 100644 --- a/frontend/src/pages/admin/AccessManagementPage/components/AddServerAdminModal.tsx +++ b/frontend/src/pages/admin/AccessManagementPage/components/AddServerAdminModal.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -55,18 +55,14 @@ const Content = ({ onClose }: ContentProps) => { const [searchUserFilter, setSearchUserFilter] = useState(""); const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500); - const { data, isFetching } = useAdminGetUsers( - { - limit: 20, - searchTerm: debouncedSearchTerm, - adminsOnly: false - }, - { - placeholderData: (prev) => prev - } - ); + const { data, isPending } = useAdminGetUsers({ + limit: 20, + searchTerm: debouncedSearchTerm, + adminsOnly: false + }); - 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) => { try { @@ -91,7 +87,7 @@ const Content = ({ onClose }: ContentProps) => { render={({ field, fieldState: { error } }) => ( } {!isEmpty && ( - + setPage(newPage)} + onChangePerPage={handlePerPageChange} + /> )} @@ -278,18 +283,27 @@ export const ServerAdminsTable = () => { const [searchUserFilter, setSearchUserFilter] = useState(""); const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500); - const { - data: users, - isPending, - isFetchingNextPage, - hasNextPage, - fetchNextPage - } = useAdminGetUsers({ - limit: 20, + const { offset, limit, setPage, perPage, page, setPerPage } = usePagination( + AdminUsersOrderBy.Username, + { + initPerPage: getUserTablePreference("ServerAdminUsersTable", PreferenceKey.PerPage, 20) + } + ); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("ServerAdminUsersTable", PreferenceKey.PerPage, newPerPage); + }; + + const { data, isPending } = useAdminGetUsers({ + limit, + offset, searchTerm: debouncedSearchTerm, adminsOnly: true }); + const { users = [], totalCount = 0 } = data ?? {}; + const handleRemoveUser = async () => { const { id } = popUp?.removeUser?.data as { id: string; username: string }; @@ -389,9 +403,11 @@ export const ServerAdminsTable = () => { searchUserFilter={searchUserFilter} setSearchUserFilter={setSearchUserFilter} isPending={isPending} - isFetchingNextPage={isFetchingNextPage} - hasNextPage={hasNextPage} - fetchNextPage={fetchNextPage} + page={page} + perPage={perPage} + setPage={setPage} + handlePerPageChange={handlePerPageChange} + totalCount={totalCount} /> { const [searchUserFilter, setSearchUserFilter] = useState(""); const [debouncedSearchTerm, setDebouncedSearchTerm] = useDebounce(searchUserFilter, 500); - const { data, isFetching } = useAdminGetUsers( - { - limit: 20, - searchTerm: debouncedSearchTerm, - adminsOnly: false - }, - { - placeholderData: (prev) => prev - } - ); + const { data, isPending } = useAdminGetUsers({ + limit: 20, + searchTerm: debouncedSearchTerm, + adminsOnly: false + }); - const users = useMemo(() => data?.pages.flat() ?? [], [data]); + const { users = [] } = data ?? {}; const onSubmit = async ({ name, invitees }: FormData) => { try { @@ -155,7 +150,7 @@ const Content = ({ onClose }: ContentProps) => { }) .includes(input) } - isLoading={searchUserFilter !== debouncedSearchTerm || isFetching} + isLoading={searchUserFilter !== debouncedSearchTerm || isPending} className="w-full" placeholder="Search users or invite new ones..." isMulti diff --git a/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx b/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx index 9856ee497..7ce5598ac 100644 --- a/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx +++ b/frontend/src/pages/admin/ResourceOverviewPage/components/UserIdentitiesTable.tsx @@ -13,7 +13,6 @@ import { faXmark } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { InfiniteData } from "@tanstack/react-query"; import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; @@ -31,6 +30,7 @@ import { EmptyState, IconButton, Input, + Pagination, Table, TableContainer, TableSkeleton, @@ -42,7 +42,12 @@ import { Tr } from "@app/components/v2"; 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 { useAdminBulkDeleteUsers, useAdminDeleteUser, @@ -50,6 +55,7 @@ import { useAdminGrantServerAdminAccess, useRemoveUserServerAdminAccess } from "@app/hooks/api"; +import { AdminUsersOrderBy } from "@app/hooks/api/admin/types"; import { User } from "@app/hooks/api/users/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -58,17 +64,19 @@ const removeServerAdminUpgradePlanMessage = "Removing Server Admin permissions f const UserPanelTable = ({ handlePopUpOpen, - users: usersPages, + users, isPending, adminsOnly, searchUserFilter, setSearchUserFilter, setAdminsOnly, - isFetchingNextPage, - fetchNextPage, - hasNextPage, selectedUsers, - setSelectedUsers + setSelectedUsers, + totalCount, + page, + perPage, + setPage, + handlePerPageChange }: { handlePopUpOpen: ( popUpName: keyof UsePopUpState< @@ -81,22 +89,22 @@ const UserPanelTable = ({ } ) => void; isPending: boolean; - users: InfiniteData | undefined; + users: User[] | undefined; adminsOnly: boolean; setAdminsOnly: (adminsOnly: boolean) => void; searchUserFilter: string; setSearchUserFilter: (filter: string) => void; selectedUsers: User[]; setSelectedUsers: Dispatch>; - isFetchingNextPage: boolean; - fetchNextPage: () => void; - hasNextPage: boolean; + totalCount: number; + page: number; + perPage: number; + setPage: Dispatch>; + handlePerPageChange: (newPerPage: number) => void; }) => { const { subscription } = useSubscription(); - const users = usersPages?.pages.flat(); - - const isEmpty = !isPending && !users?.length; + const isEmpty = !isPending && totalCount === 0; const isTableFiltered = Boolean(adminsOnly); const selectedUserIds = selectedUsers.map((user) => user.id); @@ -302,16 +310,13 @@ const UserPanelTable = ({ {!isPending && isEmpty && } {!isEmpty && ( - + setPage(newPage)} + onChangePerPage={handlePerPageChange} + /> )} @@ -341,18 +346,27 @@ export const UserIdentitiesTable = () => { const [adminsOnly, setAdminsOnly] = useState(false); const [debouncedSearchTerm] = useDebounce(searchUserFilter, 500); - const { - data: users, - isPending, - isFetchingNextPage, - hasNextPage, - fetchNextPage - } = useAdminGetUsers({ - limit: 20, + const { offset, limit, setPage, perPage, page, setPerPage } = usePagination( + AdminUsersOrderBy.Username, + { + initPerPage: getUserTablePreference("ResourceOverviewUsersTable", PreferenceKey.PerPage, 20) + } + ); + + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + setUserTablePreference("ResourceOverviewUsersTable", PreferenceKey.PerPage, newPerPage); + }; + + const { data, isPending } = useAdminGetUsers({ + limit, + offset, searchTerm: debouncedSearchTerm, adminsOnly }); + const { users, totalCount = 0 } = data ?? {}; + const handleRemoveUser = async () => { const { id } = popUp?.removeUser?.data as { id: string; username: string }; @@ -479,9 +493,11 @@ export const UserIdentitiesTable = () => { isPending={isPending} adminsOnly={adminsOnly} setAdminsOnly={setAdminsOnly} - isFetchingNextPage={isFetchingNextPage} - hasNextPage={hasNextPage} - fetchNextPage={fetchNextPage} + page={page} + perPage={perPage} + setPage={setPage} + handlePerPageChange={handlePerPageChange} + totalCount={totalCount} />