improvement: add pagination/sort to org members table

This commit is contained in:
Scott Wilson
2024-11-27 19:23:54 -08:00
parent a04834c7c9
commit 588b3c77f9

View File

@@ -1,6 +1,13 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { faEllipsis, faMagnifyingGlass, faUsers } from "@fortawesome/free-solid-svg-icons"; import {
faArrowDown,
faArrowUp,
faEllipsis,
faMagnifyingGlass,
faSearch,
faUsers
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -14,7 +21,9 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
EmptyState, EmptyState,
IconButton,
Input, Input,
Pagination,
Select, Select,
SelectItem, SelectItem,
Table, Table,
@@ -33,6 +42,7 @@ import {
useSubscription, useSubscription,
useUser useUser
} from "@app/context"; } from "@app/context";
import { usePagination, useResetPageHelper } from "@app/hooks";
import { import {
useAddUsersToOrg, useAddUsersToOrg,
useFetchServerStatus, useFetchServerStatus,
@@ -40,6 +50,7 @@ import {
useGetOrgUsers, useGetOrgUsers,
useUpdateOrgMembership useUpdateOrgMembership
} from "@app/hooks/api"; } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp"; import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = { type Props = {
@@ -54,6 +65,11 @@ type Props = {
setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void; setCompleteInviteLinks: (links: Array<{ email: string; link: string }> | null) => void;
}; };
enum OrgMembersOrderBy {
Name = "firstName",
Email = "email"
}
export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Props) => { export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Props) => {
const router = useRouter(); const router = useRouter();
const { subscription } = useSubscription(); const { subscription } = useSubscription();
@@ -64,10 +80,8 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
const { data: roles, isLoading: isRolesLoading } = useGetOrgRoles(orgId); const { data: roles, isLoading: isRolesLoading } = useGetOrgRoles(orgId);
const [searchMemberFilter, setSearchMemberFilter] = useState("");
const { data: serverDetails } = useFetchServerStatus(); const { data: serverDetails } = useFetchServerStatus();
const { data: members, isLoading: isMembersLoading } = useGetOrgUsers(orgId); const { data: members = [], isLoading: isMembersLoading } = useGetOrgUsers(orgId);
const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg(); const { mutateAsync: addUsersMutateAsync } = useAddUsersToOrg();
const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership(); const { mutateAsync: updateOrgMembership } = useUpdateOrgMembership();
@@ -144,24 +158,75 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
[roles] [roles]
); );
const filterdUser = useMemo( const {
search,
setSearch,
setPage,
page,
perPage,
setPerPage,
offset,
orderDirection,
orderBy,
setOrderBy,
setOrderDirection,
toggleOrderDirection
} = usePagination<OrgMembersOrderBy>(OrgMembersOrderBy.Name);
const filteredUsers = useMemo(
() => () =>
members?.filter( members
({ user: u, inviteEmail }) => ?.filter(
u?.firstName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || ({ user: u, inviteEmail }) =>
u?.lastName?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || u?.firstName?.toLowerCase().includes(search.toLowerCase()) ||
u?.username?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || u?.lastName?.toLowerCase().includes(search.toLowerCase()) ||
u?.email?.toLowerCase().includes(searchMemberFilter.toLowerCase()) || u?.username?.toLowerCase().includes(search.toLowerCase()) ||
inviteEmail?.includes(searchMemberFilter.toLowerCase()) u?.email?.toLowerCase().includes(search.toLowerCase()) ||
), inviteEmail?.toLowerCase().includes(search.toLowerCase())
[members, searchMemberFilter] )
.sort((a, b) => {
const [memberOne, memberTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
let valueOne: string;
let valueTwo: string;
switch (orderBy) {
case OrgMembersOrderBy.Email:
valueOne = memberOne.user.email || memberOne.inviteEmail;
valueTwo = memberTwo.user.email || memberTwo.inviteEmail;
break;
case OrgMembersOrderBy.Name:
default:
valueOne = memberOne.user.firstName;
valueTwo = memberTwo.user.firstName;
}
return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase());
}),
[members, search, orderDirection, orderBy]
); );
const handleSort = (column: OrgMembersOrderBy) => {
if (column === orderBy) {
toggleOrderDirection();
return;
}
setOrderBy(column);
setOrderDirection(OrderByDirection.ASC);
};
useResetPageHelper({
totalCount: filteredUsers.length,
offset,
setPage
});
return ( return (
<div> <div>
<Input <Input
value={searchMemberFilter} value={search}
onChange={(e) => setSearchMemberFilter(e.target.value)} onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />} leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search members..." placeholder="Search members..."
/> />
@@ -169,8 +234,46 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
<Table> <Table>
<THead> <THead>
<Tr> <Tr>
<Th>Name</Th> <Th className="w-1/3">
<Th>Username</Th> <div className="flex items-center">
Name
<IconButton
variant="plain"
className={`ml-2 ${orderBy === OrgMembersOrderBy.Name ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(OrgMembersOrderBy.Name)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === OrgMembersOrderBy.Name
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th className="w-1/3">
<div className="flex items-center">
Email
<IconButton
variant="plain"
className={`ml-2 ${orderBy === OrgMembersOrderBy.Email ? "" : "opacity-30"}`}
ariaLabel="sort"
onClick={() => handleSort(OrgMembersOrderBy.Email)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === OrgMembersOrderBy.Email
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th>Role</Th> <Th>Role</Th>
<Th className="w-5" /> <Th className="w-5" />
</Tr> </Tr>
@@ -178,212 +281,231 @@ export const OrgMembersTable = ({ handlePopUpOpen, setCompleteInviteLinks }: Pro
<TBody> <TBody>
{isLoading && <TableSkeleton columns={5} innerKey="org-members" />} {isLoading && <TableSkeleton columns={5} innerKey="org-members" />}
{!isLoading && {!isLoading &&
filterdUser?.map( filteredUsers
({ user: u, inviteEmail, role, roleId, id: orgMembershipId, status, isActive }) => { .slice(offset, perPage * page)
const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-"; .map(
const email = u?.email || inviteEmail; ({
const username = u?.username ?? inviteEmail ?? "-"; user: u,
return ( inviteEmail,
<Tr role,
key={`org-membership-${orgMembershipId}`} roleId,
className="h-10 w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700" id: orgMembershipId,
onClick={() => router.push(`/org/${orgId}/memberships/${orgMembershipId}`)} status,
> isActive
<Td className={isActive ? "" : "text-mineshaft-400"}> }) => {
{name} const name = u && u.firstName ? `${u.firstName} ${u.lastName}` : "-";
{u.superAdmin && ( const email = u?.email || inviteEmail;
<Badge variant="primary" className="ml-2"> const username = u?.username ?? inviteEmail ?? "-";
Server Admin return (
</Badge> <Tr
)} key={`org-membership-${orgMembershipId}`}
</Td> className="h-10 w-full cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
<Td className={isActive ? "" : "text-mineshaft-400"}>{username}</Td> onClick={() => router.push(`/org/${orgId}/memberships/${orgMembershipId}`)}
<Td> >
<OrgPermissionCan <Td className={isActive ? "" : "text-mineshaft-400"}>
I={OrgPermissionActions.Edit} {name}
a={OrgPermissionSubjects.Member} {u.superAdmin && (
> <Badge variant="primary" className="ml-2">
{(isAllowed) => ( Server Admin
<> </Badge>
{!isActive && ( )}
<Button </Td>
isDisabled <Td className={isActive ? "" : "text-mineshaft-400"}>{username}</Td>
className="w-40" <Td>
colorSchema="primary" <OrgPermissionCan
variant="outline_bg" I={OrgPermissionActions.Edit}
onClick={() => {}} a={OrgPermissionSubjects.Member}
> >
Suspended {(isAllowed) => (
</Button> <>
)} {!isActive && (
{isActive && status === "accepted" && (
<Select
value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
isDisabled={userId === u?.id || !isAllowed}
className="w-48 bg-mineshaft-600"
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
onValueChange={(selectedRole) =>
onRoleChange(orgMembershipId, selectedRole)
}
>
{(roles || [])
.filter(({ slug }) =>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
)}
{isActive &&
(status === "invited" || status === "verified") &&
email &&
serverDetails?.emailConfigured && (
<Button <Button
isDisabled={!isAllowed} isDisabled
className="w-48" className="w-40"
colorSchema="primary" colorSchema="primary"
variant="outline_bg" variant="outline_bg"
onClick={() => onResendInvite(email)} onClick={() => {}}
> >
Resend invite Suspended
</Button> </Button>
)} )}
</> {isActive && status === "accepted" && (
)} <Select
</OrgPermissionCan> value={role === "custom" ? findRoleFromId(roleId)?.slug : role}
</Td> isDisabled={userId === u?.id || !isAllowed}
<Td> className="w-48 bg-mineshaft-600"
{userId !== u?.id && ( dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
<DropdownMenu> onValueChange={(selectedRole) =>
<DropdownMenuTrigger asChild className="rounded-lg"> onRoleChange(orgMembershipId, selectedRole)
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
router.push(`/org/${orgId}/memberships/${orgMembershipId}`);
}}
disabled={!isAllowed}
>
Edit User
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={
isActive
? twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)
: ""
} }
onClick={async (e) => {
e.stopPropagation();
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
if (!isActive) {
// activate user
await updateOrgMembership({
organizationId: orgId,
membershipId: orgMembershipId,
isActive: true
});
return;
}
// deactivate user
handlePopUpOpen("deactivateMember", {
orgMembershipId,
username
});
}}
disabled={!isAllowed}
> >
{`${isActive ? "Deactivate" : "Activate"} User`} {(roles || [])
</DropdownMenuItem> .filter(({ slug }) =>
slug === "owner" ? isIamOwner || role === "owner" : true
)
.map(({ slug, name: roleName }) => (
<SelectItem value={slug} key={`owner-option-${slug}`}>
{roleName}
</SelectItem>
))}
</Select>
)} )}
</OrgPermissionCan> {isActive &&
<OrgPermissionCan (status === "invited" || status === "verified") &&
I={OrgPermissionActions.Delete} email &&
a={OrgPermissionSubjects.Member} serverDetails?.emailConfigured && (
> <Button
{(isAllowed) => ( isDisabled={!isAllowed}
<DropdownMenuItem className="w-48"
className={twMerge( colorSchema="primary"
isAllowed variant="outline_bg"
? "hover:!bg-red-500 hover:!text-white" onClick={() => onResendInvite(email)}
: "pointer-events-none cursor-not-allowed opacity-50" >
)} Resend invite
onClick={(e) => { </Button>
e.stopPropagation(); )}
</>
if (currentOrg?.scimEnabled) { )}
createNotification({ </OrgPermissionCan>
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization", </Td>
type: "error" <Td>
}); {userId !== u?.id && (
return; <DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-1">
<OrgPermissionCan
I={OrgPermissionActions.Edit}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed &&
"pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
router.push(`/org/${orgId}/memberships/${orgMembershipId}`);
}}
disabled={!isAllowed}
>
Edit User
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={
isActive
? twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)
: ""
} }
onClick={async (e) => {
e.stopPropagation();
handlePopUpOpen("removeMember", { if (currentOrg?.scimEnabled) {
orgMembershipId, createNotification({
username text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
}); type: "error"
}} });
disabled={!isAllowed} return;
> }
Remove User
</DropdownMenuItem> if (!isActive) {
)} // activate user
</OrgPermissionCan> await updateOrgMembership({
</DropdownMenuContent> organizationId: orgId,
</DropdownMenu> membershipId: orgMembershipId,
)} isActive: true
</Td> });
</Tr>
); return;
} }
)}
// deactivate user
handlePopUpOpen("deactivateMember", {
orgMembershipId,
username
});
}}
disabled={!isAllowed}
>
{`${isActive ? "Deactivate" : "Activate"} User`}
</DropdownMenuItem>
)}
</OrgPermissionCan>
<OrgPermissionCan
I={OrgPermissionActions.Delete}
a={OrgPermissionSubjects.Member}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
if (currentOrg?.scimEnabled) {
createNotification({
text: "You cannot manage users from Infisical when org-level auth is enforced for your organization",
type: "error"
});
return;
}
handlePopUpOpen("removeMember", {
orgMembershipId,
username
});
}}
disabled={!isAllowed}
>
Remove User
</DropdownMenuItem>
)}
</OrgPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
)}
</Td>
</Tr>
);
}
)}
</TBody> </TBody>
</Table> </Table>
{!isLoading && filterdUser?.length === 0 && ( {Boolean(filteredUsers.length) && (
<Pagination
count={filteredUsers.length}
page={page}
perPage={perPage}
onChangePage={setPage}
onChangePerPage={setPerPage}
/>
)}
{!isMembersLoading && !filteredUsers?.length && (
<EmptyState <EmptyState
title={ title={
members?.length === 0 members.length
? "No organization members found" ? "No organization members match search..."
: "No organization members match search" : "No organization members found"
} }
icon={faUsers} icon={members.length ? faSearch : faUsers}
/> />
)} )}
</TableContainer> </TableContainer>