diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 09ed85315..7df68f39a 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -1,3 +1,4 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { @@ -79,7 +80,17 @@ 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) + .refine((value) => { + if (!value) return true; + const slugs = value.split(","); + return slugs.every((slug) => slugify(slug.trim(), { lowercase: true }) === slug.trim()); + }) + .optional() }), params: z.object({ workspaceId: z.string().trim() @@ -118,13 +129,15 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, onRequest: verifyAuth([AuthMode.JWT]), handler: async (req) => { + const roles = (req.query.roles?.split(",") || []).filter(Boolean); 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..1e71f4605 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 @@ -31,6 +31,29 @@ export const projectMembershipDALFactory = (db: TDbClient) => { if (filter.id) { void qb.where(`${TableName.ProjectMembership}.id`, filter.id); } + if (filter.roles && filter.roles.length > 0) { + void qb.whereExists((subQuery) => { + void subQuery + .select("role") + .from(TableName.ProjectUserMembershipRole) + .leftJoin( + TableName.ProjectRoles, + `${TableName.ProjectRoles}.id`, + `${TableName.ProjectUserMembershipRole}.customRoleId` + ) + .whereRaw("??.?? = ??.??", [ + TableName.ProjectUserMembershipRole, + "projectMembershipId", + TableName.ProjectMembership, + "id" + ]) + .where((subQb) => { + void subQb + .whereIn(`${TableName.ProjectUserMembershipRole}.role`, filter.roles as string[]) + .orWhereIn(`${TableName.ProjectRoles}.slug`, filter.roles as string[]); + }); + }); + } }) .join( TableName.UserEncryptionKey, 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 8145e1904..0a2bf491b 100644 --- a/frontend/src/hooks/api/workspace/queries.tsx +++ b/frontend/src/hooks/api/workspace/queries.tsx @@ -431,9 +431,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 } @@ -441,7 +445,11 @@ export const useGetWorkspaceUsers = (workspaceId: string, includeGroupMembers?: `/api/v1/workspace/${workspaceId}/users`, { params: { - includeGroupMembers + includeGroupMembers, + roles: + roles && roles.length > 0 + ? roles.map((role) => encodeURIComponent(role)).join(",") + : undefined } } ); diff --git a/frontend/src/hooks/api/workspace/query-keys.tsx b/frontend/src/hooks/api/workspace/query-keys.tsx index b23ca6868..05e9c7588 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..fd303f653 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(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 && (