mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 07:26:45 +00:00
feat(project-members): Persist pagination setting and add role filtering
This commit is contained in:
@@ -78,7 +78,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
includeGroupMembers: z
|
includeGroupMembers: z
|
||||||
.enum(["true", "false"])
|
.enum(["true", "false"])
|
||||||
.default("false")
|
.default("false")
|
||||||
.transform((value) => value === "true")
|
.transform((value) => value === "true"),
|
||||||
|
roles: z.string().trim().transform(decodeURIComponent).optional()
|
||||||
}),
|
}),
|
||||||
params: z.object({
|
params: z.object({
|
||||||
workspaceId: z.string().trim()
|
workspaceId: z.string().trim()
|
||||||
@@ -117,13 +118,15 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => {
|
|||||||
},
|
},
|
||||||
onRequest: verifyAuth([AuthMode.JWT]),
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
handler: async (req) => {
|
handler: async (req) => {
|
||||||
|
const roles = req.query.roles?.split(",") ?? [];
|
||||||
const users = await server.services.projectMembership.getProjectMemberships({
|
const users = await server.services.projectMembership.getProjectMemberships({
|
||||||
actorId: req.permission.id,
|
actorId: req.permission.id,
|
||||||
actor: req.permission.type,
|
actor: req.permission.type,
|
||||||
actorAuthMethod: req.permission.authMethod,
|
actorAuthMethod: req.permission.authMethod,
|
||||||
includeGroupMembers: req.query.includeGroupMembers,
|
includeGroupMembers: req.query.includeGroupMembers,
|
||||||
projectId: req.params.workspaceId,
|
projectId: req.params.workspaceId,
|
||||||
actorOrgId: req.permission.orgId
|
actorOrgId: req.permission.orgId,
|
||||||
|
roles
|
||||||
});
|
});
|
||||||
|
|
||||||
return { users };
|
return { users };
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const projectMembershipDALFactory = (db: TDbClient) => {
|
|||||||
// special query
|
// special query
|
||||||
const findAllProjectMembers = async (
|
const findAllProjectMembers = async (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
filter: { usernames?: string[]; username?: string; id?: string } = {}
|
filter: { usernames?: string[]; username?: string; id?: string; roles?: string[] } = {}
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
const docs = await db
|
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;
|
return members;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new DatabaseError({ error, name: "Find all project members" });
|
throw new DatabaseError({ error, name: "Find all project members" });
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ export const projectMembershipServiceFactory = ({
|
|||||||
actorOrgId,
|
actorOrgId,
|
||||||
actorAuthMethod,
|
actorAuthMethod,
|
||||||
includeGroupMembers,
|
includeGroupMembers,
|
||||||
projectId
|
projectId,
|
||||||
|
roles
|
||||||
}: TGetProjectMembershipDTO) => {
|
}: TGetProjectMembershipDTO) => {
|
||||||
const { permission } = await permissionService.getProjectPermission({
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
actor,
|
actor,
|
||||||
@@ -91,7 +92,7 @@ export const projectMembershipServiceFactory = ({
|
|||||||
});
|
});
|
||||||
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member);
|
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionMemberActions.Read, ProjectPermissionSub.Member);
|
||||||
|
|
||||||
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId);
|
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId, { roles });
|
||||||
|
|
||||||
// projectMembers[0].project
|
// projectMembers[0].project
|
||||||
if (includeGroupMembers) {
|
if (includeGroupMembers) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TProjectPermission } from "@app/lib/types";
|
import { TProjectPermission } from "@app/lib/types";
|
||||||
|
|
||||||
export type TGetProjectMembershipDTO = { includeGroupMembers?: boolean } & TProjectPermission;
|
export type TGetProjectMembershipDTO = { includeGroupMembers?: boolean; roles?: string[] } & TProjectPermission;
|
||||||
export type TLeaveProjectDTO = Omit<TProjectPermission, "actorOrgId" | "actorAuthMethod">;
|
export type TLeaveProjectDTO = Omit<TProjectPermission, "actorOrgId" | "actorAuthMethod">;
|
||||||
export enum ProjectUserMembershipTemporaryMode {
|
export enum ProjectUserMembershipTemporaryMode {
|
||||||
Relative = "relative"
|
Relative = "relative"
|
||||||
|
|||||||
@@ -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({
|
return useQuery({
|
||||||
queryKey: workspaceKeys.getWorkspaceUsers(workspaceId),
|
queryKey: workspaceKeys.getWorkspaceUsers(workspaceId, includeGroupMembers, roles),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const {
|
const {
|
||||||
data: { users }
|
data: { users }
|
||||||
@@ -440,7 +444,8 @@ export const useGetWorkspaceUsers = (workspaceId: string, includeGroupMembers?:
|
|||||||
`/api/v1/workspace/${workspaceId}/users`,
|
`/api/v1/workspace/${workspaceId}/users`,
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
includeGroupMembers
|
includeGroupMembers,
|
||||||
|
roles: roles && roles.length > 0 ? encodeURIComponent(roles.join(",")) : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ export const workspaceKeys = {
|
|||||||
type ? ["workspaces", { type }] : (["workspaces"] as const),
|
type ? ["workspaces", { type }] : (["workspaces"] as const),
|
||||||
getWorkspaceAuditLogs: (workspaceId: string) =>
|
getWorkspaceAuditLogs: (workspaceId: string) =>
|
||||||
[{ workspaceId }, "workspace-audit-logs"] as const,
|
[{ 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) =>
|
getWorkspaceUserDetails: (workspaceId: string, membershipId: string) =>
|
||||||
[{ workspaceId, membershipId }, "workspace-user-details"] as const,
|
[{ workspaceId, membershipId }, "workspace-user-details"] as const,
|
||||||
getWorkspaceIdentityMemberships: (workspaceId: string) =>
|
getWorkspaceIdentityMemberships: (workspaceId: string) =>
|
||||||
|
|||||||
+109
-11
@@ -1,9 +1,12 @@
|
|||||||
import { useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
faArrowDown,
|
faArrowDown,
|
||||||
faArrowUp,
|
faArrowUp,
|
||||||
|
faCheckCircle,
|
||||||
|
faChevronRight,
|
||||||
faClock,
|
faClock,
|
||||||
faEllipsisV,
|
faEllipsisV,
|
||||||
|
faFilter,
|
||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
faSearch,
|
faSearch,
|
||||||
faTrash,
|
faTrash,
|
||||||
@@ -15,6 +18,14 @@ import { twMerge } from "tailwind-merge";
|
|||||||
|
|
||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
import {
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownSubMenu,
|
||||||
|
DropdownSubMenuContent,
|
||||||
|
DropdownSubMenuTrigger,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
HoverCard,
|
HoverCard,
|
||||||
HoverCardContent,
|
HoverCardContent,
|
||||||
@@ -40,7 +51,7 @@ import {
|
|||||||
useWorkspace
|
useWorkspace
|
||||||
} from "@app/context";
|
} from "@app/context";
|
||||||
import { usePagination, useResetPageHelper } from "@app/hooks";
|
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 { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||||
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
import { ProjectMembershipRole } from "@app/hooks/api/roles/types";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
@@ -65,13 +76,22 @@ enum MembersOrderBy {
|
|||||||
Email = "email"
|
Email = "email"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Filter = {
|
||||||
|
roles: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [filter, setFilter] = useState<Filter>({
|
||||||
|
roles: []
|
||||||
|
});
|
||||||
|
const filterRoles = useMemo(() => filter.roles, [filter.roles]);
|
||||||
|
|
||||||
const userId = user?.id || "";
|
const userId = user?.id || "";
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
|
const { data: projectRoles } = useGetProjectRoles(workspaceId);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
@@ -86,9 +106,20 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
setOrderBy,
|
setOrderBy,
|
||||||
setOrderDirection,
|
setOrderDirection,
|
||||||
toggleOrderDirection
|
toggleOrderDirection
|
||||||
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, { initPerPage: 20 });
|
} = usePagination<MembersOrderBy>(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(
|
const filteredUsers = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -142,14 +173,81 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
setOrderDirection(OrderByDirection.ASC);
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
value={search}
|
<DropdownMenu>
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
<DropdownMenuTrigger asChild>
|
||||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
<IconButton
|
||||||
placeholder="Search members..."
|
ariaLabel="Filter Users"
|
||||||
/>
|
variant="plain"
|
||||||
|
size="sm"
|
||||||
|
className={twMerge(
|
||||||
|
"flex h-10 w-11 items-center justify-center overflow-hidden border border-mineshaft-600 bg-mineshaft-800 p-0 transition-all hover:border-primary/60 hover:bg-primary/10",
|
||||||
|
isTableFiltered && "border-primary/50 text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faFilter} />
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" className="p-0">
|
||||||
|
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
|
||||||
|
<DropdownSubMenu>
|
||||||
|
<DropdownSubMenuTrigger
|
||||||
|
iconPos="right"
|
||||||
|
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
|
||||||
|
>
|
||||||
|
Roles
|
||||||
|
</DropdownSubMenuTrigger>
|
||||||
|
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
|
||||||
|
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||||
|
Apply Roles to Filter Users
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
{projectRoles?.map(({ id, slug, name }) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
handleRoleToggle(slug);
|
||||||
|
}}
|
||||||
|
key={id}
|
||||||
|
icon={filter.roles.includes(slug) && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div
|
||||||
|
className="mr-2 h-2 w-2 rounded-full"
|
||||||
|
style={{ background: "#bec2c8" }}
|
||||||
|
/>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownSubMenuContent>
|
||||||
|
</DropdownSubMenu>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
|
placeholder="Search members..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<TableContainer className="mt-4">
|
<TableContainer className="mt-4">
|
||||||
<Table>
|
<Table>
|
||||||
<THead>
|
<THead>
|
||||||
@@ -358,7 +456,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
page={page}
|
page={page}
|
||||||
perPage={perPage}
|
perPage={perPage}
|
||||||
onChangePage={setPage}
|
onChangePage={setPage}
|
||||||
onChangePerPage={setPerPage}
|
onChangePerPage={handlePerPageChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!isMembersLoading && !filteredUsers?.length && (
|
{!isMembersLoading && !filteredUsers?.length && (
|
||||||
|
|||||||
Reference in New Issue
Block a user