diff --git a/backend/src/ee/routes/v1/group-router.ts b/backend/src/ee/routes/v1/group-router.ts index 84cffd890..beb0d8345 100644 --- a/backend/src/ee/routes/v1/group-router.ts +++ b/backend/src/ee/routes/v1/group-router.ts @@ -115,31 +115,40 @@ export const registerGroupRouter = async (server: FastifyZodProvider) => { params: z.object({ slug: z.string().trim().describe(GROUPS.LIST_USERS.slug) }), + querystring: z.object({ + offset: z.coerce.number().min(0).max(100).default(0).describe(GROUPS.LIST_USERS.offset), + limit: z.coerce.number().min(1).max(100).default(10).describe(GROUPS.LIST_USERS.limit), + username: z.string().optional().describe(GROUPS.LIST_USERS.username) + }), response: { - 200: UsersSchema.pick({ - email: true, - username: true, - firstName: true, - lastName: true, - id: true + 200: z.object({ + users: UsersSchema.pick({ + email: true, + username: true, + firstName: true, + lastName: true, + id: true + }) + .merge( + z.object({ + isPartOfGroup: z.boolean() + }) + ) + .array(), + totalCount: z.number() }) - .merge( - z.object({ - isPartOfGroup: z.boolean() - }) - ) - .array() } }, handler: async (req) => { - const users = await server.services.group.listGroupUsers({ + const { users, totalCount } = await server.services.group.listGroupUsers({ groupSlug: req.params.slug, actor: req.permission.type, actorId: req.permission.id, actorAuthMethod: req.permission.authMethod, - actorOrgId: req.permission.orgId + actorOrgId: req.permission.orgId, + ...req.query }); - return users; + return { users, totalCount }; } }); diff --git a/backend/src/ee/services/group/group-dal.ts b/backend/src/ee/services/group/group-dal.ts index 66b1d1d48..240500254 100644 --- a/backend/src/ee/services/group/group-dal.ts +++ b/backend/src/ee/services/group/group-dal.ts @@ -59,10 +59,48 @@ export const groupDALFactory = (db: TDbClient) => { } }; - // special query - const findAllGroupMembers = async (orgId: string, groupId: string) => { + const countAllGroupMembers = async ({ orgId, groupId }: { orgId: string; groupId: string }) => { try { - const members = await db(TableName.OrgMembership) + interface CountResult { + count: string; + } + + const doc = await db(TableName.OrgMembership) + .where(`${TableName.OrgMembership}.orgId`, orgId) + .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) + .leftJoin(TableName.UserGroupMembership, function () { + this.on(`${TableName.UserGroupMembership}.userId`, "=", `${TableName.Users}.id`).andOn( + `${TableName.UserGroupMembership}.groupId`, + "=", + db.raw("?", [groupId]) + ); + }) + .where({ isGhost: false }) + .count(`${TableName.Users}.id`) + .first(); + + return parseInt((doc?.count as string) || "0", 10); + } catch (err) { + throw new DatabaseError({ error: err, name: "Count all group members" }); + } + }; + + // special query + const findAllGroupMembers = async ({ + orgId, + groupId, + offset = 0, + limit, + username + }: { + orgId: string; + groupId: string; + offset?: number; + limit?: number; + username?: string; + }) => { + try { + let query = db(TableName.OrgMembership) .where(`${TableName.OrgMembership}.orgId`, orgId) .join(TableName.Users, `${TableName.OrgMembership}.userId`, `${TableName.Users}.id`) .leftJoin(TableName.UserGroupMembership, function () { @@ -82,16 +120,29 @@ export const groupDALFactory = (db: TDbClient) => { db.ref("id").withSchema(TableName.Users).as("userId") // db.raw(`CASE WHEN "${TableName.UserGroupMembership}"."groupId" IS NOT NULL THEN TRUE ELSE FALSE END as isPartOfGroup`) ) - .where({ isGhost: false }); // MAKE SURE USER IS NOT A GHOST USER + .where({ isGhost: false }) // MAKE SURE USER IS NOT A GHOST USER + .offset(offset); - return members.map(({ email, username, firstName, lastName, userId, groupId: memberGroupId }) => ({ - id: userId, - email, - username, - firstName, - lastName, - isPartOfGroup: !!memberGroupId - })); + if (limit) { + query = query.limit(limit); + } + + if (username) { + query = query.andWhere(`${TableName.Users}.username`, "ilike", `%${username}%`); + } + + const members = await query; + + return members.map( + ({ email, username: memberUsername, firstName, lastName, userId, groupId: memberGroupId }) => ({ + id: userId, + email, + username: memberUsername, + firstName, + lastName, + isPartOfGroup: !!memberGroupId + }) + ); } catch (error) { throw new DatabaseError({ error, name: "Find all org members" }); } @@ -100,6 +151,7 @@ export const groupDALFactory = (db: TDbClient) => { return { findGroups, findByOrgId, + countAllGroupMembers, findAllGroupMembers, ...groupOrm }; diff --git a/backend/src/ee/services/group/group-service.ts b/backend/src/ee/services/group/group-service.ts index 55160e992..285403bcd 100644 --- a/backend/src/ee/services/group/group-service.ts +++ b/backend/src/ee/services/group/group-service.ts @@ -29,7 +29,10 @@ import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal"; type TGroupServiceFactoryDep = { userDAL: Pick; - groupDAL: Pick; + groupDAL: Pick< + TGroupDALFactory, + "create" | "findOne" | "update" | "delete" | "findAllGroupMembers" | "countAllGroupMembers" + >; groupProjectDAL: Pick; orgDAL: Pick; userGroupMembershipDAL: Pick< @@ -185,7 +188,16 @@ export const groupServiceFactory = ({ return group; }; - const listGroupUsers = async ({ groupSlug, actor, actorId, actorAuthMethod, actorOrgId }: TListGroupUsersDTO) => { + const listGroupUsers = async ({ + groupSlug, + offset, + limit, + username, + actor, + actorId, + actorAuthMethod, + actorOrgId + }: TListGroupUsersDTO) => { if (!actorOrgId) throw new BadRequestError({ message: "Failed to create group without organization" }); const { permission } = await permissionService.getOrgPermission( @@ -207,8 +219,20 @@ export const groupServiceFactory = ({ message: `Failed to find group with slug ${groupSlug}` }); - const users = await groupDAL.findAllGroupMembers(group.orgId, group.id); - return users; + const users = await groupDAL.findAllGroupMembers({ + orgId: group.orgId, + groupId: group.id, + offset, + limit, + username + }); + + const totalCount = await groupDAL.countAllGroupMembers({ + orgId: group.orgId, + groupId: group.id + }); + + return { users, totalCount }; }; const addUserToGroup = async ({ diff --git a/backend/src/ee/services/group/group-types.ts b/backend/src/ee/services/group/group-types.ts index 4e36e447e..e2fbbe63e 100644 --- a/backend/src/ee/services/group/group-types.ts +++ b/backend/src/ee/services/group/group-types.ts @@ -21,6 +21,9 @@ export type TDeleteGroupDTO = { export type TListGroupUsersDTO = { groupSlug: string; + offset: number; + limit: number; + username?: string; } & TGenericPermission; export type TAddUserToGroupDTO = { diff --git a/backend/src/ee/services/scim/scim-service.ts b/backend/src/ee/services/scim/scim-service.ts index b8994d5d0..15ca67a10 100644 --- a/backend/src/ee/services/scim/scim-service.ts +++ b/backend/src/ee/services/scim/scim-service.ts @@ -536,7 +536,10 @@ export const scimServiceFactory = ({ }); } - const users = await groupDAL.findAllGroupMembers(group.orgId, group.id); + const users = await groupDAL.findAllGroupMembers({ + orgId: group.orgId, + groupId: group.id + }); return buildScimGroup({ groupId: group.id, diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index cbb217bbf..42123f896 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -14,7 +14,10 @@ export const GROUPS = { slug: "The slug of the group to delete" }, LIST_USERS: { - slug: "The slug of the group to list users for" + slug: "The slug of the group to list users for", + offset: "The offset to start from. If you enter 10, it will start from the 10th user.", + limit: "The number of users to return.", + username: "The username to search for." }, ADD_USER: { slug: "The slug of the group to add the user to.", diff --git a/frontend/src/hooks/api/groups/mutations.tsx b/frontend/src/hooks/api/groups/mutations.tsx index e3d1ffedc..32d718e37 100644 --- a/frontend/src/hooks/api/groups/mutations.tsx +++ b/frontend/src/hooks/api/groups/mutations.tsx @@ -102,7 +102,7 @@ export const useAddUserToGroup = () => { return data; }, onSuccess: (_, { slug }) => { - queryClient.invalidateQueries(groupKeys.getGroupUserMembership(slug)); + queryClient.invalidateQueries(groupKeys.forGroupUserMemberships(slug)); } }); }; @@ -124,7 +124,7 @@ export const useRemoveUserFromGroup = () => { return data; }, onSuccess: (_, { slug }) => { - queryClient.invalidateQueries(groupKeys.getGroupUserMembership(slug)); + queryClient.invalidateQueries(groupKeys.forGroupUserMemberships(slug)); } }); }; \ No newline at end of file diff --git a/frontend/src/hooks/api/groups/queries.tsx b/frontend/src/hooks/api/groups/queries.tsx index a6ddb568c..ba0543135 100644 --- a/frontend/src/hooks/api/groups/queries.tsx +++ b/frontend/src/hooks/api/groups/queries.tsx @@ -3,7 +3,19 @@ import { useQuery } from "@tanstack/react-query"; import { apiRequest } from "@app/config/request"; export const groupKeys = { - getGroupUserMembership: (slug: string) => [{ slug }, "group-user-memberships"] as const + allGroupUserMemberships: () => ["group-user-memberships"] as const, + forGroupUserMemberships: (slug: string) => [...groupKeys.allGroupUserMemberships(), slug] as const, + specificGroupUserMemberships: ({ + slug, + offset, + limit, + username + }: { + slug: string; + offset: number; + limit: number; + username: string; + }) => [...groupKeys.forGroupUserMemberships(slug), { offset, limit, username }] as const }; type TUser = { @@ -15,14 +27,39 @@ type TUser = { isPartOfGroup: boolean; }; -export const useListGroupUsers = (groupSlug: string) => { +export const useListGroupUsers = ({ + groupSlug, + offset = 0, + limit = 10, + username +}: { + groupSlug: string; + offset: number; + limit: number; + username: string; +}) => { return useQuery({ - queryKey: groupKeys.getGroupUserMembership(groupSlug), + queryKey: groupKeys.specificGroupUserMemberships({ + slug: groupSlug, + offset, + limit, + username + }), enabled: Boolean(groupSlug), queryFn: async () => { - const { data: users } = await apiRequest.get(`/api/v1/groups/${groupSlug}/users`); - - return users; - } + const params = new URLSearchParams({ + offset: String(offset), + limit: String(limit), + username + }); + + const { data } = await apiRequest.get<{ users: TUser[]; totalCount: number; }>( + `/api/v1/groups/${groupSlug}/users`, { + params + } + ); + + return data; + }, }); }; diff --git a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx index e45098ded..8c15643b7 100644 --- a/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx +++ b/frontend/src/views/Org/MembersPage/components/OrgGroupsTab/components/OrgGroupsSection/OrgGroupMembersModal.tsx @@ -1,4 +1,4 @@ -import { useMemo,useState } from "react"; +import { useState } from "react"; import { faMagnifyingGlass,faUsers } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -10,6 +10,7 @@ import { Input, Modal, ModalContent, + Pagination, Table, TableContainer, TableSkeleton, @@ -17,8 +18,7 @@ import { Td, Th, THead, - Tr -} from "@app/components/v2"; + Tr} from "@app/components/v2"; import { OrgPermissionActions, OrgPermissionSubjects @@ -38,13 +38,21 @@ export const OrgGroupMembersModal = ({ popUp, handlePopUpToggle }: Props) => { + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(10); const [searchMemberFilter, setSearchMemberFilter] = useState(""); const popUpData = popUp?.groupMembers?.data as { slug: string; }; - const { data: users, isLoading } = useListGroupUsers(popUpData?.slug ?? ""); + const { data, isLoading } = useListGroupUsers({ + groupSlug: popUpData?.slug, + offset: (page - 1) * perPage, + limit: perPage, + username: searchMemberFilter + }); + const { mutateAsync: assignMutateAsync } = useAddUserToGroup(); const { mutateAsync: unassignMutateAsync } = useRemoveUserFromGroup(); @@ -76,17 +84,6 @@ export const OrgGroupMembersModal = ({ } } - const filterdUser = useMemo( - () => - users?.filter( - ({ firstName, lastName, username }) => - firstName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || - lastName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || - username?.toLowerCase().includes(searchMemberFilter.toLowerCase()) - ), - [users, searchMemberFilter] - ); - return ( {isLoading && } - {!isLoading && filterdUser?.map(({ + {!isLoading && data?.users?.map(({ id, firstName, lastName, @@ -150,7 +147,16 @@ export const OrgGroupMembersModal = ({ })} - {!isLoading && !filterdUser?.length && ( + {!isLoading && data?.totalCount !== undefined && ( + setPage(newPage)} + onChangePerPage={(newPerPage) => setPerPage(newPerPage)} + /> + )} + {!isLoading && !data?.users?.length && (