diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 19423901b..393b10c1a 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -78,7 +78,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { includeGroupMembers: z .enum(["true", "false"]) .default("false") - .transform((value) => value === "true") + .transform((value) => value === "true"), + roles: z.string().trim().transform(decodeURIComponent).optional() }), params: z.object({ workspaceId: z.string().trim() @@ -117,13 +118,15 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { + const roles = req.query.roles?.split(",") ?? []; const users = await server.services.projectMembership.getProjectMemberships({ actorId: req.permission.id, actor: req.permission.type, actorAuthMethod: req.permission.authMethod, includeGroupMembers: req.query.includeGroupMembers, projectId: req.params.workspaceId, - actorOrgId: req.permission.orgId + actorOrgId: req.permission.orgId, + roles }); return { users }; diff --git a/backend/src/services/project-membership/project-membership-dal.ts b/backend/src/services/project-membership/project-membership-dal.ts index 61b703e70..8a9d41cba 100644 --- a/backend/src/services/project-membership/project-membership-dal.ts +++ b/backend/src/services/project-membership/project-membership-dal.ts @@ -13,7 +13,7 @@ export const projectMembershipDALFactory = (db: TDbClient) => { // special query const findAllProjectMembers = async ( projectId: string, - filter: { usernames?: string[]; username?: string; id?: string } = {} + filter: { usernames?: string[]; username?: string; id?: string; roles?: string[] } = {} ) => { try { const docs = await db @@ -126,6 +126,11 @@ export const projectMembershipDALFactory = (db: TDbClient) => { } ] }); + if (filter.roles && filter.roles.length > 0) { + return members.filter((member) => + member.roles.some((role) => filter.roles?.includes(role.role) || filter.roles?.includes(role.customRoleSlug)) + ); + } return members; } catch (error) { throw new DatabaseError({ error, name: "Find all project members" }); diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 1d6ba969a..1fe4961d2 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -79,7 +79,8 @@ export const projectMembershipServiceFactory = ({ actorOrgId, actorAuthMethod, includeGroupMembers, - projectId + projectId, + roles }: TGetProjectMembershipDTO) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -91,7 +92,7 @@ export const projectMembershipServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member); - const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles }); // projectMembers[0].project if (includeGroupMembers) { diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index 68819f5ae..9b8cf2ac3 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -1,6 +1,6 @@ import { TProjectPermission } from "@app/lib/types"; -export type TGetProjectMembershipDTO = { includeGroupMembers?: boolean } & TProjectPermission; +export type TGetProjectMembershipDTO = { includeGroupMembers?: boolean; roles?: string[] } & TProjectPermission; export type TLeaveProjectDTO = Omit; export enum ProjectUserMembershipTemporaryMode { Relative = "relative" diff --git a/frontend/src/hooks/api/workspace/queries.tsx b/frontend/src/hooks/api/workspace/queries.tsx index ca8feb6b6..a3f45626e 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -430,9 +430,13 @@ export const useDeleteWsEnvironment = () => { }); }; -export const useGetWorkspaceUsers = (workspaceId: string, includeGroupMembers?: boolean) => { +export const useGetWorkspaceUsers = ( + workspaceId: string, + includeGroupMembers?: boolean, + roles?: string[] +) => { return useQuery({ - queryKey: workspaceKeys.getWorkspaceUsers(workspaceId), + queryKey: workspaceKeys.getWorkspaceUsers(workspaceId, includeGroupMembers, roles), queryFn: async () => { const { data: { users } @@ -440,7 +444,8 @@ export const useGetWorkspaceUsers = (workspaceId: string, includeGroupMembers?: `/api/v1/workspace/${workspaceId}/users`, { params: { - includeGroupMembers + includeGroupMembers, + roles: roles && roles.length > 0 ? encodeURIComponent(roles.join(",")) : undefined } } ); diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index 539ed2ac7..ba6b87f77 100644 --- a/frontend/src/hooks/api/workspace/query-keys.tsx +++ b/frontend/src/hooks/api/workspace/query-keys.tsx @@ -15,7 +15,8 @@ export const workspaceKeys = { type ? ["workspaces", { type }] : (["workspaces"] as const), getWorkspaceAuditLogs: (workspaceId: string) => [{ workspaceId }, "workspace-audit-logs"] as const, - getWorkspaceUsers: (workspaceId: string) => [{ workspaceId }, "workspace-users"] as const, + getWorkspaceUsers: (workspaceId: string, includeGroupMembers?: boolean, roles?: string[]) => + [{ workspaceId, includeGroupMembers, roles }, "workspace-users"] as const, getWorkspaceUserDetails: (workspaceId: string, membershipId: string) => [{ workspaceId, membershipId }, "workspace-user-details"] as const, getWorkspaceIdentityMemberships: (workspaceId: string) => diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx index 7c93f9acf..433d1941e 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MembersTable.tsx @@ -1,9 +1,12 @@ -import { useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { faArrowDown, faArrowUp, + faCheckCircle, + faChevronRight, faClock, faEllipsisV, + faFilter, faMagnifyingGlass, faSearch, faTrash, @@ -15,6 +18,14 @@ import { twMerge } from "tailwind-merge"; import { ProjectPermissionCan } from "@app/components/permissions"; import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, + DropdownSubMenu, + DropdownSubMenuContent, + DropdownSubMenuTrigger, EmptyState, HoverCard, HoverCardContent, @@ -40,7 +51,7 @@ import { useWorkspace } from "@app/context"; import { usePagination, useResetPageHelper } from "@app/hooks"; -import { useGetWorkspaceUsers } from "@app/hooks/api"; +import { useGetProjectRoles, useGetWorkspaceUsers } from "@app/hooks/api"; import { OrderByDirection } from "@app/hooks/api/generic/types"; import { ProjectMembershipRole } from "@app/hooks/api/roles/types"; import { UsePopUpState } from "@app/hooks/usePopUp"; @@ -65,13 +76,22 @@ enum MembersOrderBy { Email = "email" } +type Filter = { + roles: string[]; +}; + export const MembersTable = ({ handlePopUpOpen }: Props) => { const { currentWorkspace } = useWorkspace(); const { user } = useUser(); const navigate = useNavigate(); + const [filter, setFilter] = useState({ + roles: [] + }); + const filterRoles = useMemo(() => filter.roles, [filter.roles]); const userId = user?.id || ""; const workspaceId = currentWorkspace?.id || ""; + const { data: projectRoles } = useGetProjectRoles(workspaceId); const { search, @@ -86,9 +106,20 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => { setOrderBy, setOrderDirection, toggleOrderDirection - } = usePagination(MembersOrderBy.Name, { initPerPage: 20 }); + } = usePagination(MembersOrderBy.Name, { + initPerPage: parseInt(localStorage.getItem("PROJECT_MEMBERS_TABLE_PER_PAGE") || "20", 10) + }); - const { data: members = [], isPending: isMembersLoading } = useGetWorkspaceUsers(workspaceId); + const handlePerPageChange = (newPerPage: number) => { + setPerPage(newPerPage); + localStorage.setItem("PROJECT_MEMBERS_TABLE_PER_PAGE", newPerPage.toString()); + }; + + const { data: members = [], isPending: isMembersLoading } = useGetWorkspaceUsers( + workspaceId, + undefined, + filterRoles + ); const filteredUsers = useMemo( () => @@ -142,14 +173,81 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => { setOrderDirection(OrderByDirection.ASC); }; + const isTableFiltered = Boolean(Object.values(filter.roles).length); + + const handleRoleToggle = useCallback( + (roleSlug: string) => + setFilter((state) => { + const roles = state.roles || []; + + if (roles.includes(roleSlug)) { + return { ...state, roles: roles.filter((role) => role !== roleSlug) }; + } + return { ...state, roles: [...roles, roleSlug] }; + }), + [] + ); + return (
- setSearch(e.target.value)} - leftIcon={} - placeholder="Search members..." - /> +
+ + + + + + + + Filter By + + } + > + Roles + + + + Apply Roles to Filter Users + + {projectRoles?.map(({ id, slug, name }) => ( + { + evt.preventDefault(); + handleRoleToggle(slug); + }} + key={id} + icon={filter.roles.includes(slug) && } + iconPos="right" + > +
+
+ {name} +
+ + ))} + + + + + setSearch(e.target.value)} + leftIcon={} + placeholder="Search members..." + /> +
@@ -358,7 +456,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => { page={page} perPage={perPage} onChangePage={setPage} - onChangePerPage={setPerPage} + onChangePerPage={handlePerPageChange} /> )} {!isMembersLoading && !filteredUsers?.length && (