mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-04 04:26:11 +00:00
Merge pull request #2804 from Infisical/users-projects-table-pagination
Improvement: Users, Groups and Projects Table Pagination
This commit is contained in:
+1
-1
@@ -62,7 +62,7 @@ export const IdentityProjectRow = ({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Td>{project.name}</Td>
|
<Td className="max-w-0 truncate">{project.name}</Td>
|
||||||
<Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${
|
<Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${
|
||||||
roles.length > 1 ? ` (+${roles.length - 1})` : ""
|
roles.length > 1 ? ` (+${roles.length - 1})` : ""
|
||||||
}`}</Td>
|
}`}</Td>
|
||||||
|
|||||||
+122
-30
@@ -1,7 +1,18 @@
|
|||||||
import { faFolder } from "@fortawesome/free-solid-svg-icons";
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
faArrowDown,
|
||||||
|
faArrowUp,
|
||||||
|
faFolder,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faSearch
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EmptyState,
|
EmptyState,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Pagination,
|
||||||
Table,
|
Table,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
@@ -10,7 +21,9 @@ import {
|
|||||||
THead,
|
THead,
|
||||||
Tr
|
Tr
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
|
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||||
import { useGetIdentityProjectMemberships } from "@app/hooks/api";
|
import { useGetIdentityProjectMemberships } from "@app/hooks/api";
|
||||||
|
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
import { IdentityProjectRow } from "./IdentityProjectRow";
|
import { IdentityProjectRow } from "./IdentityProjectRow";
|
||||||
@@ -23,36 +36,115 @@ type Props = {
|
|||||||
) => void;
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum IdentityProjectsOrderBy {
|
||||||
|
Name = "name"
|
||||||
|
}
|
||||||
|
|
||||||
export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => {
|
export const IdentityProjectsTable = ({ identityId, handlePopUpOpen }: Props) => {
|
||||||
const { data: projectMemberships, isLoading } = useGetIdentityProjectMemberships(identityId);
|
const { data: projectMemberships = [], isLoading } = useGetIdentityProjectMemberships(identityId);
|
||||||
|
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
setPage,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
setPerPage,
|
||||||
|
offset,
|
||||||
|
orderDirection,
|
||||||
|
toggleOrderDirection
|
||||||
|
} = usePagination(IdentityProjectsOrderBy.Name, { initPerPage: 10 });
|
||||||
|
|
||||||
|
const filteredProjectMemberships = useMemo(
|
||||||
|
() =>
|
||||||
|
projectMemberships
|
||||||
|
?.filter((membership) =>
|
||||||
|
membership.project.name.toLowerCase().includes(search.trim().toLowerCase())
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const [membershipOne, membershipTwo] =
|
||||||
|
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||||
|
|
||||||
|
return membershipOne.project.name
|
||||||
|
.toLowerCase()
|
||||||
|
.localeCompare(membershipTwo.project.name.toLowerCase());
|
||||||
|
}),
|
||||||
|
[projectMemberships, orderDirection, search]
|
||||||
|
);
|
||||||
|
|
||||||
|
useResetPageHelper({
|
||||||
|
totalCount: filteredProjectMemberships.length,
|
||||||
|
offset,
|
||||||
|
setPage
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer>
|
<div>
|
||||||
<Table>
|
<Input
|
||||||
<THead>
|
value={search}
|
||||||
<Tr>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<Th>Name</Th>
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
<Th>Role</Th>
|
placeholder="Search projects..."
|
||||||
<Th>Added On</Th>
|
/>
|
||||||
<Th className="w-5" />
|
<TableContainer className="mt-4">
|
||||||
</Tr>
|
<Table>
|
||||||
</THead>
|
<THead>
|
||||||
<TBody>
|
<Tr>
|
||||||
{isLoading && <TableSkeleton columns={4} innerKey="identity-project-memberships" />}
|
<Th className="w-2/3">
|
||||||
{!isLoading &&
|
<div className="flex items-center">
|
||||||
projectMemberships?.map((membership) => {
|
Name
|
||||||
return (
|
<IconButton
|
||||||
<IdentityProjectRow
|
variant="plain"
|
||||||
key={`identity-project-membership-${membership.id}`}
|
className="ml-2"
|
||||||
membership={membership}
|
ariaLabel="sort"
|
||||||
handlePopUpOpen={handlePopUpOpen}
|
onClick={toggleOrderDirection}
|
||||||
/>
|
>
|
||||||
);
|
<FontAwesomeIcon
|
||||||
})}
|
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
|
||||||
</TBody>
|
/>
|
||||||
</Table>
|
</IconButton>
|
||||||
{!isLoading && !projectMemberships?.length && (
|
</div>
|
||||||
<EmptyState title="This identity has not been assigned to any projects" icon={faFolder} />
|
</Th>
|
||||||
)}
|
|
||||||
</TableContainer>
|
<Th>Role</Th>
|
||||||
|
<Th>Added On</Th>
|
||||||
|
<Th className="w-5" />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isLoading && <TableSkeleton columns={4} innerKey="identity-project-memberships" />}
|
||||||
|
{!isLoading &&
|
||||||
|
filteredProjectMemberships.slice(offset, perPage * page).map((membership) => {
|
||||||
|
return (
|
||||||
|
<IdentityProjectRow
|
||||||
|
key={`identity-project-membership-${membership.id}`}
|
||||||
|
membership={membership}
|
||||||
|
handlePopUpOpen={handlePopUpOpen}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
{Boolean(filteredProjectMemberships.length) && (
|
||||||
|
<Pagination
|
||||||
|
count={filteredProjectMemberships.length}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
onChangePage={setPage}
|
||||||
|
onChangePerPage={setPerPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isLoading && !filteredProjectMemberships?.length && (
|
||||||
|
<EmptyState
|
||||||
|
title={
|
||||||
|
projectMemberships.length
|
||||||
|
? "No projects match search..."
|
||||||
|
: "This identity has not been assigned to any projects"
|
||||||
|
}
|
||||||
|
icon={projectMemberships.length ? faSearch : faFolder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableContainer>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { OrgPermissionActions, OrgPermissionSubjects } from "@app/context";
|
|||||||
import { withPermission } from "@app/hoc";
|
import { withPermission } from "@app/hoc";
|
||||||
import { isTabSection, TabSections } from "@app/views/Org/Types";
|
import { isTabSection, TabSections } from "@app/views/Org/Types";
|
||||||
|
|
||||||
import { OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components";
|
import { OrgGroupsTab, OrgIdentityTab, OrgMembersTab, OrgRoleTabSection } from "./components";
|
||||||
|
|
||||||
export const MembersPage = withPermission(
|
export const MembersPage = withPermission(
|
||||||
() => {
|
() => {
|
||||||
@@ -25,9 +25,9 @@ export const MembersPage = withPermission(
|
|||||||
const updateSelectedTab = (tab: string) => {
|
const updateSelectedTab = (tab: string) => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: router.pathname,
|
pathname: router.pathname,
|
||||||
query: { ...router.query, selectedTab: tab },
|
query: { ...router.query, selectedTab: tab }
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||||
@@ -36,16 +36,20 @@ export const MembersPage = withPermission(
|
|||||||
<Tabs value={activeTab} onValueChange={updateSelectedTab}>
|
<Tabs value={activeTab} onValueChange={updateSelectedTab}>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value={TabSections.Member}>Users</Tab>
|
<Tab value={TabSections.Member}>Users</Tab>
|
||||||
|
<Tab value={TabSections.Groups}>Groups</Tab>
|
||||||
<Tab value={TabSections.Identities}>
|
<Tab value={TabSections.Identities}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<p>Machine Identities</p>
|
<p>Machine Identities</p>
|
||||||
</div>
|
</div>
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab value={TabSections.Roles}>Organization Roles</Tab>
|
<Tab value={TabSections.Roles}>Organization Roles</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanel value={TabSections.Member}>
|
<TabPanel value={TabSections.Member}>
|
||||||
<OrgMembersTab />
|
<OrgMembersTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
<TabPanel value={TabSections.Groups}>
|
||||||
|
<OrgGroupsTab />
|
||||||
|
</TabPanel>
|
||||||
<TabPanel value={TabSections.Identities}>
|
<TabPanel value={TabSections.Identities}>
|
||||||
<OrgIdentityTab />
|
<OrgIdentityTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|||||||
+1
-1
@@ -57,7 +57,7 @@ export const OrgGroupsSection = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
|
||||||
<div className="mb-4 flex justify-between">
|
<div className="mb-4 flex justify-between">
|
||||||
<p className="text-xl font-semibold text-mineshaft-100">User Groups</p>
|
<p className="text-xl font-semibold text-mineshaft-100">Groups</p>
|
||||||
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Groups}>
|
<OrgPermissionCan I={OrgPermissionActions.Create} a={OrgPermissionSubjects.Groups}>
|
||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
+183
-149
@@ -1,9 +1,10 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
faArrowDown,
|
faArrowDown,
|
||||||
faArrowUp,
|
faArrowUp,
|
||||||
faEllipsis,
|
faEllipsis,
|
||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
|
faSearch,
|
||||||
faUsers
|
faUsers
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
@@ -19,6 +20,7 @@ import {
|
|||||||
EmptyState,
|
EmptyState,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
|
Pagination,
|
||||||
Select,
|
Select,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
Table,
|
Table,
|
||||||
@@ -31,7 +33,7 @@ import {
|
|||||||
Tr
|
Tr
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
|
||||||
import { useDebounce } from "@app/hooks";
|
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||||
import { useGetOrganizationGroups, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
|
import { useGetOrganizationGroups, useGetOrgRoles, useUpdateGroup } from "@app/hooks/api";
|
||||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
@@ -59,14 +61,10 @@ enum GroupsOrderBy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
||||||
const [searchGroupsFilter, setSearchGroupsFilter] = useState("");
|
|
||||||
const [debouncedSearch] = useDebounce(searchGroupsFilter.trim());
|
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
const orgId = currentOrg?.id || "";
|
const orgId = currentOrg?.id || "";
|
||||||
const { isLoading, data: groups } = useGetOrganizationGroups(orgId);
|
const { isLoading, data: groups = [] } = useGetOrganizationGroups(orgId);
|
||||||
const { mutateAsync: updateMutateAsync } = useUpdateGroup();
|
const { mutateAsync: updateMutateAsync } = useUpdateGroup();
|
||||||
const [orderBy, setOrderBy] = useState(GroupsOrderBy.Name);
|
|
||||||
const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC);
|
|
||||||
|
|
||||||
const { data: roles } = useGetOrgRoles(orgId);
|
const { data: roles } = useGetOrgRoles(orgId);
|
||||||
|
|
||||||
@@ -90,12 +88,27 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
setPage,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
setPerPage,
|
||||||
|
offset,
|
||||||
|
orderDirection,
|
||||||
|
orderBy,
|
||||||
|
setOrderBy,
|
||||||
|
setOrderDirection,
|
||||||
|
toggleOrderDirection
|
||||||
|
} = usePagination<GroupsOrderBy>(GroupsOrderBy.Name, { initPerPage: 20 });
|
||||||
|
|
||||||
const filteredGroups = useMemo(() => {
|
const filteredGroups = useMemo(() => {
|
||||||
const filtered = debouncedSearch
|
const filtered = search
|
||||||
? groups?.filter(
|
? groups?.filter(
|
||||||
({ name, slug }) =>
|
({ name, slug }) =>
|
||||||
name.toLowerCase().includes(debouncedSearch.toLowerCase()) ||
|
name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
slug.toLowerCase().includes(debouncedSearch.toLowerCase())
|
slug.toLowerCase().includes(search.toLowerCase())
|
||||||
)
|
)
|
||||||
: groups;
|
: groups;
|
||||||
|
|
||||||
@@ -113,13 +126,11 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return orderDirection === OrderByDirection.ASC ? ordered : ordered?.reverse();
|
return orderDirection === OrderByDirection.ASC ? ordered : ordered?.reverse();
|
||||||
}, [debouncedSearch, groups, orderBy, orderDirection]);
|
}, [search, groups, orderBy, orderDirection]);
|
||||||
|
|
||||||
const handleSort = (column: GroupsOrderBy) => {
|
const handleSort = (column: GroupsOrderBy) => {
|
||||||
if (column === orderBy) {
|
if (column === orderBy) {
|
||||||
setOrderDirection((prev) =>
|
toggleOrderDirection();
|
||||||
prev === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,11 +138,17 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
setOrderDirection(OrderByDirection.ASC);
|
setOrderDirection(OrderByDirection.ASC);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useResetPageHelper({
|
||||||
|
totalCount: filteredGroups.length,
|
||||||
|
offset,
|
||||||
|
setPage
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
value={searchGroupsFilter}
|
value={search}
|
||||||
onChange={(e) => setSearchGroupsFilter(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
placeholder="Search groups..."
|
placeholder="Search groups..."
|
||||||
/>
|
/>
|
||||||
@@ -202,143 +219,160 @@ export const OrgGroupsTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
<TBody>
|
<TBody>
|
||||||
{isLoading && <TableSkeleton columns={4} innerKey="org-groups" />}
|
{isLoading && <TableSkeleton columns={4} innerKey="org-groups" />}
|
||||||
{!isLoading &&
|
{!isLoading &&
|
||||||
filteredGroups?.map(({ id, name, slug, role, customRole }) => {
|
filteredGroups
|
||||||
return (
|
.slice(offset, perPage * page)
|
||||||
<Tr className="h-10" key={`org-group-${id}`}>
|
.map(({ id, name, slug, role, customRole }) => {
|
||||||
<Td>{name}</Td>
|
return (
|
||||||
<Td>{slug}</Td>
|
<Tr className="h-10" key={`org-group-${id}`}>
|
||||||
<Td>
|
<Td>{name}</Td>
|
||||||
<OrgPermissionCan
|
<Td>{slug}</Td>
|
||||||
I={OrgPermissionActions.Edit}
|
<Td>
|
||||||
a={OrgPermissionSubjects.Groups}
|
<OrgPermissionCan
|
||||||
>
|
I={OrgPermissionActions.Edit}
|
||||||
{(isAllowed) => {
|
a={OrgPermissionSubjects.Groups}
|
||||||
return (
|
>
|
||||||
<Select
|
{(isAllowed) => {
|
||||||
value={role === "custom" ? (customRole?.slug as string) : role}
|
return (
|
||||||
isDisabled={!isAllowed}
|
<Select
|
||||||
className="w-48 bg-mineshaft-600"
|
value={role === "custom" ? (customRole?.slug as string) : role}
|
||||||
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
isDisabled={!isAllowed}
|
||||||
onValueChange={(selectedRole) =>
|
className="w-48 bg-mineshaft-600"
|
||||||
handleChangeRole({
|
dropdownContainerClassName="border border-mineshaft-600 bg-mineshaft-800"
|
||||||
id,
|
onValueChange={(selectedRole) =>
|
||||||
role: selectedRole
|
handleChangeRole({
|
||||||
})
|
id,
|
||||||
}
|
role: selectedRole
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(roles || []).map(({ slug: roleSlug, name: roleName }) => (
|
||||||
|
<SelectItem value={roleSlug} key={`role-option-${roleSlug}`}>
|
||||||
|
{roleName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</OrgPermissionCan>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<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">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
createNotification({
|
||||||
|
text: "Copied group ID to clipboard",
|
||||||
|
type: "info"
|
||||||
|
});
|
||||||
|
navigator.clipboard.writeText(id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{(roles || []).map(({ slug: roleSlug, name: roleName }) => (
|
Copy Group ID
|
||||||
<SelectItem value={roleSlug} key={`role-option-${roleSlug}`}>
|
</DropdownMenuItem>
|
||||||
{roleName}
|
<OrgPermissionCan
|
||||||
</SelectItem>
|
I={OrgPermissionActions.Edit}
|
||||||
))}
|
a={OrgPermissionSubjects.Identity}
|
||||||
</Select>
|
>
|
||||||
);
|
{(isAllowed) => (
|
||||||
}}
|
<DropdownMenuItem
|
||||||
</OrgPermissionCan>
|
className={twMerge(
|
||||||
</Td>
|
!isAllowed &&
|
||||||
<Td>
|
"pointer-events-none cursor-not-allowed opacity-50"
|
||||||
<DropdownMenu>
|
)}
|
||||||
<DropdownMenuTrigger asChild className="rounded-lg">
|
onClick={(e) => {
|
||||||
<div className="hover:text-primary-400 data-[state=open]:text-primary-400">
|
e.stopPropagation();
|
||||||
<FontAwesomeIcon size="sm" icon={faEllipsis} />
|
handlePopUpOpen("groupMembers", {
|
||||||
</div>
|
groupId: id,
|
||||||
</DropdownMenuTrigger>
|
slug
|
||||||
<DropdownMenuContent align="start" className="p-1">
|
});
|
||||||
<DropdownMenuItem
|
}}
|
||||||
onClick={(e) => {
|
disabled={!isAllowed}
|
||||||
e.stopPropagation();
|
>
|
||||||
createNotification({
|
Manage Users
|
||||||
text: "Copied group ID to clipboard",
|
</DropdownMenuItem>
|
||||||
type: "info"
|
)}
|
||||||
});
|
</OrgPermissionCan>
|
||||||
navigator.clipboard.writeText(id);
|
<OrgPermissionCan
|
||||||
}}
|
I={OrgPermissionActions.Edit}
|
||||||
>
|
a={OrgPermissionSubjects.Identity}
|
||||||
Copy Group ID
|
>
|
||||||
</DropdownMenuItem>
|
{(isAllowed) => (
|
||||||
<OrgPermissionCan
|
<DropdownMenuItem
|
||||||
I={OrgPermissionActions.Edit}
|
className={twMerge(
|
||||||
a={OrgPermissionSubjects.Identity}
|
!isAllowed &&
|
||||||
>
|
"pointer-events-none cursor-not-allowed opacity-50"
|
||||||
{(isAllowed) => (
|
)}
|
||||||
<DropdownMenuItem
|
onClick={(e) => {
|
||||||
className={twMerge(
|
e.stopPropagation();
|
||||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
handlePopUpOpen("group", {
|
||||||
)}
|
groupId: id,
|
||||||
onClick={(e) => {
|
name,
|
||||||
e.stopPropagation();
|
slug,
|
||||||
handlePopUpOpen("groupMembers", {
|
role,
|
||||||
groupId: id,
|
customRole
|
||||||
slug
|
});
|
||||||
});
|
}}
|
||||||
}}
|
disabled={!isAllowed}
|
||||||
disabled={!isAllowed}
|
>
|
||||||
>
|
Edit Group
|
||||||
Manage Users
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
)}
|
||||||
)}
|
</OrgPermissionCan>
|
||||||
</OrgPermissionCan>
|
<OrgPermissionCan
|
||||||
<OrgPermissionCan
|
I={OrgPermissionActions.Delete}
|
||||||
I={OrgPermissionActions.Edit}
|
a={OrgPermissionSubjects.Groups}
|
||||||
a={OrgPermissionSubjects.Identity}
|
>
|
||||||
>
|
{(isAllowed) => (
|
||||||
{(isAllowed) => (
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem
|
className={twMerge(
|
||||||
className={twMerge(
|
isAllowed
|
||||||
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
|
? "hover:!bg-red-500 hover:!text-white"
|
||||||
)}
|
: "pointer-events-none cursor-not-allowed opacity-50"
|
||||||
onClick={(e) => {
|
)}
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
handlePopUpOpen("group", {
|
e.stopPropagation();
|
||||||
groupId: id,
|
handlePopUpOpen("deleteGroup", {
|
||||||
name,
|
groupId: id,
|
||||||
slug,
|
name
|
||||||
role,
|
});
|
||||||
customRole
|
}}
|
||||||
});
|
disabled={!isAllowed}
|
||||||
}}
|
>
|
||||||
disabled={!isAllowed}
|
Delete Group
|
||||||
>
|
</DropdownMenuItem>
|
||||||
Edit Group
|
)}
|
||||||
</DropdownMenuItem>
|
</OrgPermissionCan>
|
||||||
)}
|
</DropdownMenuContent>
|
||||||
</OrgPermissionCan>
|
</DropdownMenu>
|
||||||
<OrgPermissionCan
|
</Td>
|
||||||
I={OrgPermissionActions.Delete}
|
</Tr>
|
||||||
a={OrgPermissionSubjects.Groups}
|
);
|
||||||
>
|
})}
|
||||||
{(isAllowed) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
className={twMerge(
|
|
||||||
isAllowed
|
|
||||||
? "hover:!bg-red-500 hover:!text-white"
|
|
||||||
: "pointer-events-none cursor-not-allowed opacity-50"
|
|
||||||
)}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
handlePopUpOpen("deleteGroup", {
|
|
||||||
groupId: id,
|
|
||||||
name
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={!isAllowed}
|
|
||||||
>
|
|
||||||
Delete Group
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</OrgPermissionCan>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TBody>
|
</TBody>
|
||||||
</Table>
|
</Table>
|
||||||
{filteredGroups?.length === 0 && (
|
{Boolean(filteredGroups.length) && (
|
||||||
|
<Pagination
|
||||||
|
count={filteredGroups.length}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
onChangePage={setPage}
|
||||||
|
onChangePerPage={setPerPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isLoading && !filteredGroups?.length && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={groups?.length === 0 ? "No groups found" : "No groups match search"}
|
title={
|
||||||
icon={faUsers}
|
groups.length
|
||||||
|
? "No organization groups match search..."
|
||||||
|
: "No organization groups found"
|
||||||
|
}
|
||||||
|
icon={groups.length ? faSearch : faUsers}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
import { OrgGroupsSection } from "../OrgGroupsTab/components";
|
|
||||||
import { OrgMembersSection } from "./components";
|
import { OrgMembersSection } from "./components";
|
||||||
|
|
||||||
export const OrgMembersTab = () => {
|
export const OrgMembersTab = () => {
|
||||||
@@ -13,7 +12,6 @@ export const OrgMembersTab = () => {
|
|||||||
exit={{ opacity: 0, translateX: 30 }}
|
exit={{ opacity: 0, translateX: 30 }}
|
||||||
>
|
>
|
||||||
<OrgMembersSection />
|
<OrgMembersSection />
|
||||||
<OrgGroupsSection />
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+334
-209
@@ -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,78 @@ 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, { initPerPage: 20 });
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valueOne) return 1;
|
||||||
|
if (!valueTwo) return -1;
|
||||||
|
|
||||||
|
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 +237,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 +284,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>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
export enum TabSections {
|
export enum TabSections {
|
||||||
Member = "members",
|
Member = "members",
|
||||||
Roles = "roles",
|
Groups = "groups",
|
||||||
Identities = "identities"
|
Roles = "roles",
|
||||||
}
|
Identities = "identities"
|
||||||
|
}
|
||||||
export const isTabSection = (value: string): value is TabSections => {
|
|
||||||
return (Object.values(TabSections) as string[]).includes(value);
|
export const isTabSection = (value: string): value is TabSections => {
|
||||||
}
|
return (Object.values(TabSections) as string[]).includes(value);
|
||||||
|
};
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export const UserProjectRow = ({
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Td>{project.name}</Td>
|
<Td className="max-w-0 truncate">{project.name}</Td>
|
||||||
<Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${
|
<Td>{`${formatRoleName(roles[0].role, roles[0].customRoleName)}${
|
||||||
roles.length > 1 ? ` (+${roles.length - 1})` : ""
|
roles.length > 1 ? ` (+${roles.length - 1})` : ""
|
||||||
}`}</Td>
|
}`}</Td>
|
||||||
|
|||||||
+118
-29
@@ -1,7 +1,18 @@
|
|||||||
import { faFolder } from "@fortawesome/free-solid-svg-icons";
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
faArrowDown,
|
||||||
|
faArrowUp,
|
||||||
|
faFolder,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faSearch
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EmptyState,
|
EmptyState,
|
||||||
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Pagination,
|
||||||
Table,
|
Table,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
@@ -11,7 +22,9 @@ import {
|
|||||||
Tr
|
Tr
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { useOrganization } from "@app/context";
|
import { useOrganization } from "@app/context";
|
||||||
|
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||||
import { useGetOrgMembershipProjectMemberships } from "@app/hooks/api";
|
import { useGetOrgMembershipProjectMemberships } from "@app/hooks/api";
|
||||||
|
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
import { UserProjectRow } from "./UserProjectRow";
|
import { UserProjectRow } from "./UserProjectRow";
|
||||||
@@ -21,42 +34,118 @@ type Props = {
|
|||||||
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromProject"]>, data?: {}) => void;
|
handlePopUpOpen: (popUpName: keyof UsePopUpState<["removeUserFromProject"]>, data?: {}) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum UserProjectsOrderBy {
|
||||||
|
Name = "Name"
|
||||||
|
}
|
||||||
|
|
||||||
export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => {
|
export const UserProjectsTable = ({ membershipId, handlePopUpOpen }: Props) => {
|
||||||
const { currentOrg } = useOrganization();
|
const { currentOrg } = useOrganization();
|
||||||
const orgId = currentOrg?.id || "";
|
const orgId = currentOrg?.id || "";
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
setPage,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
setPerPage,
|
||||||
|
offset,
|
||||||
|
orderDirection,
|
||||||
|
toggleOrderDirection
|
||||||
|
} = usePagination(UserProjectsOrderBy.Name, { initPerPage: 10 });
|
||||||
|
|
||||||
const { data: projectMemberships, isLoading } = useGetOrgMembershipProjectMemberships(
|
const { data: projectMemberships = [], isLoading } = useGetOrgMembershipProjectMemberships(
|
||||||
orgId,
|
orgId,
|
||||||
membershipId
|
membershipId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const filteredProjectMemberships = useMemo(
|
||||||
|
() =>
|
||||||
|
projectMemberships
|
||||||
|
?.filter((membership) =>
|
||||||
|
membership.project.name.toLowerCase().includes(search.trim().toLowerCase())
|
||||||
|
)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const [membershipOne, membershipTwo] =
|
||||||
|
orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||||
|
|
||||||
|
return membershipOne.project.name
|
||||||
|
.toLowerCase()
|
||||||
|
.localeCompare(membershipTwo.project.name.toLowerCase());
|
||||||
|
}),
|
||||||
|
[projectMemberships, orderDirection, search]
|
||||||
|
);
|
||||||
|
|
||||||
|
useResetPageHelper({
|
||||||
|
totalCount: filteredProjectMemberships.length,
|
||||||
|
offset,
|
||||||
|
setPage
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer>
|
<div>
|
||||||
<Table>
|
<Input
|
||||||
<THead>
|
value={search}
|
||||||
<Tr>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<Th>Name</Th>
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
<Th>Role</Th>
|
placeholder="Search projects..."
|
||||||
<Th className="w-5" />
|
/>
|
||||||
</Tr>
|
<TableContainer className="mt-4">
|
||||||
</THead>
|
<Table>
|
||||||
<TBody>
|
<THead>
|
||||||
{isLoading && <TableSkeleton columns={3} innerKey="user-project-memberships" />}
|
<Tr>
|
||||||
{!isLoading &&
|
<Th className="w-2/3">
|
||||||
projectMemberships?.map((membership) => {
|
<div className="flex items-center">
|
||||||
return (
|
Name
|
||||||
<UserProjectRow
|
<IconButton
|
||||||
key={`user-project-membership-${membership.id}`}
|
variant="plain"
|
||||||
membership={membership}
|
className="ml-2"
|
||||||
handlePopUpOpen={handlePopUpOpen}
|
ariaLabel="sort"
|
||||||
/>
|
onClick={toggleOrderDirection}
|
||||||
);
|
>
|
||||||
})}
|
<FontAwesomeIcon
|
||||||
</TBody>
|
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
|
||||||
</Table>
|
/>
|
||||||
{!isLoading && !projectMemberships?.length && (
|
</IconButton>
|
||||||
<EmptyState title="This user has not been assigned to any projects" icon={faFolder} />
|
</div>
|
||||||
)}
|
</Th>
|
||||||
</TableContainer>
|
<Th>Role</Th>
|
||||||
|
<Th className="w-5" />
|
||||||
|
</Tr>
|
||||||
|
</THead>
|
||||||
|
<TBody>
|
||||||
|
{isLoading && <TableSkeleton columns={3} innerKey="user-project-memberships" />}
|
||||||
|
{!isLoading &&
|
||||||
|
filteredProjectMemberships.slice(offset, perPage * page).map((membership) => {
|
||||||
|
return (
|
||||||
|
<UserProjectRow
|
||||||
|
key={`user-project-membership-${membership.id}`}
|
||||||
|
membership={membership}
|
||||||
|
handlePopUpOpen={handlePopUpOpen}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
{Boolean(filteredProjectMemberships.length) && (
|
||||||
|
<Pagination
|
||||||
|
count={filteredProjectMemberships.length}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
onChangePage={setPage}
|
||||||
|
onChangePerPage={setPerPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isLoading && !filteredProjectMemberships?.length && (
|
||||||
|
<EmptyState
|
||||||
|
title={
|
||||||
|
projectMemberships.length
|
||||||
|
? "No projects match search..."
|
||||||
|
: "This user has not been assigned to any projects"
|
||||||
|
}
|
||||||
|
icon={projectMemberships.length ? faSearch : faFolder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableContainer>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
|
|||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||||
import { withProjectPermission } from "@app/hoc";
|
import { withProjectPermission } from "@app/hoc";
|
||||||
|
|
||||||
import { isTabSection,TabSections } from "../Types";
|
import { isTabSection, TabSections } from "../Types";
|
||||||
import { IdentityTab, MembersTab,ProjectRoleListTab, ServiceTokenTab } from "./components";
|
import {
|
||||||
|
GroupsTab,
|
||||||
|
IdentityTab,
|
||||||
|
MembersTab,
|
||||||
|
ProjectRoleListTab,
|
||||||
|
ServiceTokenTab
|
||||||
|
} from "./components";
|
||||||
|
|
||||||
export const MembersPage = withProjectPermission(
|
export const MembersPage = withProjectPermission(
|
||||||
() => {
|
() => {
|
||||||
@@ -26,9 +31,9 @@ export const MembersPage = withProjectPermission(
|
|||||||
const updateSelectedTab = (tab: string) => {
|
const updateSelectedTab = (tab: string) => {
|
||||||
router.push({
|
router.push({
|
||||||
pathname: router.pathname,
|
pathname: router.pathname,
|
||||||
query: { ...router.query, selectedTab: tab },
|
query: { ...router.query, selectedTab: tab }
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||||
@@ -37,6 +42,7 @@ export const MembersPage = withProjectPermission(
|
|||||||
<Tabs value={activeTab} onValueChange={updateSelectedTab}>
|
<Tabs value={activeTab} onValueChange={updateSelectedTab}>
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value={TabSections.Member}>Users</Tab>
|
<Tab value={TabSections.Member}>Users</Tab>
|
||||||
|
<Tab value={TabSections.Groups}>Groups</Tab>
|
||||||
<Tab value={TabSections.Identities}>
|
<Tab value={TabSections.Identities}>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<p>Machine Identities</p>
|
<p>Machine Identities</p>
|
||||||
@@ -48,6 +54,9 @@ export const MembersPage = withProjectPermission(
|
|||||||
<TabPanel value={TabSections.Member}>
|
<TabPanel value={TabSections.Member}>
|
||||||
<MembersTab />
|
<MembersTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
<TabPanel value={TabSections.Groups}>
|
||||||
|
<GroupsTab />
|
||||||
|
</TabPanel>
|
||||||
<TabPanel value={TabSections.Identities}>
|
<TabPanel value={TabSections.Identities}>
|
||||||
<IdentityTab />
|
<IdentityTab />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|||||||
+164
-69
@@ -1,4 +1,12 @@
|
|||||||
import { faServer, faTrash } from "@fortawesome/free-solid-svg-icons";
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
faArrowDown,
|
||||||
|
faArrowUp,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faSearch,
|
||||||
|
faTrash,
|
||||||
|
faUsers
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
|
||||||
@@ -6,6 +14,8 @@ import { ProjectPermissionCan } from "@app/components/permissions";
|
|||||||
import {
|
import {
|
||||||
EmptyState,
|
EmptyState,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Pagination,
|
||||||
Table,
|
Table,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
@@ -17,7 +27,9 @@ import {
|
|||||||
Tr
|
Tr
|
||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||||
|
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||||
import { useListWorkspaceGroups } from "@app/hooks/api";
|
import { useListWorkspaceGroups } from "@app/hooks/api";
|
||||||
|
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||||
import { UsePopUpState } from "@app/hooks/usePopUp";
|
import { UsePopUpState } from "@app/hooks/usePopUp";
|
||||||
|
|
||||||
import { GroupRoles } from "./GroupRoles";
|
import { GroupRoles } from "./GroupRoles";
|
||||||
@@ -32,76 +44,159 @@ type Props = {
|
|||||||
) => void;
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum GroupsOrderBy {
|
||||||
|
Name = "name"
|
||||||
|
}
|
||||||
|
|
||||||
export const GroupTable = ({ handlePopUpOpen }: Props) => {
|
export const GroupTable = ({ handlePopUpOpen }: Props) => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const { data, isLoading } = useListWorkspaceGroups(currentWorkspace?.id || "");
|
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
setPage,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
setPerPage,
|
||||||
|
offset,
|
||||||
|
orderDirection,
|
||||||
|
orderBy,
|
||||||
|
toggleOrderDirection
|
||||||
|
} = usePagination(GroupsOrderBy.Name, { initPerPage: 20 });
|
||||||
|
|
||||||
|
const { data: groupMemberships = [], isLoading } = useListWorkspaceGroups(
|
||||||
|
currentWorkspace?.id || ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredGroupMemberships = useMemo(() => {
|
||||||
|
const filtered = search
|
||||||
|
? groupMemberships?.filter(
|
||||||
|
({ group: { name, slug } }) =>
|
||||||
|
name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
slug.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: groupMemberships;
|
||||||
|
|
||||||
|
const ordered = filtered?.sort((a, b) =>
|
||||||
|
a.group.name.toLowerCase().localeCompare(b.group.name.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return orderDirection === OrderByDirection.ASC ? ordered : ordered?.reverse();
|
||||||
|
}, [search, groupMemberships, orderBy, orderDirection]);
|
||||||
|
|
||||||
|
useResetPageHelper({
|
||||||
|
totalCount: filteredGroupMemberships.length,
|
||||||
|
offset,
|
||||||
|
setPage
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer>
|
<div>
|
||||||
<Table>
|
<Input
|
||||||
<THead>
|
value={search}
|
||||||
<Tr>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<Th>Name</Th>
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
<Th>Role</Th>
|
placeholder="Search members..."
|
||||||
<Th>Added on</Th>
|
/>
|
||||||
<Th className="w-5" />
|
<TableContainer className="mt-4">
|
||||||
</Tr>
|
<Table>
|
||||||
</THead>
|
<THead>
|
||||||
<TBody>
|
<Tr>
|
||||||
{isLoading && <TableSkeleton columns={4} innerKey="project-groups" />}
|
<Th className="w-1/3">
|
||||||
{!isLoading &&
|
<div className="flex items-center">
|
||||||
data &&
|
Name
|
||||||
data.length > 0 &&
|
<IconButton
|
||||||
data.map(({ group: { id, name }, roles, createdAt }) => {
|
variant="plain"
|
||||||
return (
|
className="ml-2"
|
||||||
<Tr className="group h-10" key={`st-v3-${id}`}>
|
ariaLabel="sort"
|
||||||
<Td>{name}</Td>
|
onClick={toggleOrderDirection}
|
||||||
<Td>
|
>
|
||||||
<ProjectPermissionCan
|
<FontAwesomeIcon
|
||||||
I={ProjectPermissionActions.Edit}
|
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
|
||||||
a={ProjectPermissionSub.Groups}
|
/>
|
||||||
>
|
</IconButton>
|
||||||
{(isAllowed) => (
|
</div>
|
||||||
<GroupRoles roles={roles} disableEdit={!isAllowed} groupId={id} />
|
</Th>
|
||||||
)}
|
<Th>Role</Th>
|
||||||
</ProjectPermissionCan>
|
<Th>Added on</Th>
|
||||||
</Td>
|
<Th className="w-5" />
|
||||||
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
|
</Tr>
|
||||||
<Td className="flex justify-end">
|
</THead>
|
||||||
<ProjectPermissionCan
|
<TBody>
|
||||||
I={ProjectPermissionActions.Delete}
|
{isLoading && <TableSkeleton columns={4} innerKey="project-groups" />}
|
||||||
a={ProjectPermissionSub.Groups}
|
{!isLoading &&
|
||||||
>
|
filteredGroupMemberships &&
|
||||||
{(isAllowed) => (
|
filteredGroupMemberships.length > 0 &&
|
||||||
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
filteredGroupMemberships
|
||||||
<Tooltip content="Remove">
|
.slice(offset, perPage * page)
|
||||||
<IconButton
|
.map(({ group: { id, name }, roles, createdAt }) => {
|
||||||
onClick={() => {
|
return (
|
||||||
handlePopUpOpen("deleteGroup", {
|
<Tr className="group h-10" key={`st-v3-${id}`}>
|
||||||
id,
|
<Td>{name}</Td>
|
||||||
name
|
<Td>
|
||||||
});
|
<ProjectPermissionCan
|
||||||
}}
|
I={ProjectPermissionActions.Edit}
|
||||||
colorSchema="danger"
|
a={ProjectPermissionSub.Groups}
|
||||||
variant="plain"
|
>
|
||||||
ariaLabel="update"
|
{(isAllowed) => (
|
||||||
className="ml-4"
|
<GroupRoles roles={roles} disableEdit={!isAllowed} groupId={id} />
|
||||||
isDisabled={!isAllowed}
|
)}
|
||||||
>
|
</ProjectPermissionCan>
|
||||||
<FontAwesomeIcon icon={faTrash} />
|
</Td>
|
||||||
</IconButton>
|
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
|
||||||
</Tooltip>
|
<Td className="flex justify-end">
|
||||||
</div>
|
<ProjectPermissionCan
|
||||||
)}
|
I={ProjectPermissionActions.Delete}
|
||||||
</ProjectPermissionCan>
|
a={ProjectPermissionSub.Groups}
|
||||||
</Td>
|
>
|
||||||
</Tr>
|
{(isAllowed) => (
|
||||||
);
|
<div className="opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||||
})}
|
<Tooltip content="Remove">
|
||||||
</TBody>
|
<IconButton
|
||||||
</Table>
|
onClick={() => {
|
||||||
{!isLoading && data?.length === 0 && (
|
handlePopUpOpen("deleteGroup", {
|
||||||
<EmptyState title="No groups have been added to this project" icon={faServer} />
|
id,
|
||||||
)}
|
name
|
||||||
</TableContainer>
|
});
|
||||||
|
}}
|
||||||
|
colorSchema="danger"
|
||||||
|
variant="plain"
|
||||||
|
ariaLabel="update"
|
||||||
|
className="ml-4"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
{Boolean(filteredGroupMemberships.length) && (
|
||||||
|
<Pagination
|
||||||
|
count={filteredGroupMemberships.length}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
onChangePage={setPage}
|
||||||
|
onChangePerPage={setPerPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isLoading && !filteredGroupMemberships?.length && (
|
||||||
|
<EmptyState
|
||||||
|
title={
|
||||||
|
groupMemberships.length
|
||||||
|
? "No project groups match search..."
|
||||||
|
: "No project groups found"
|
||||||
|
}
|
||||||
|
icon={groupMemberships.length ? faSearch : faUsers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableContainer>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
import { useWorkspace } from "@app/context";
|
|
||||||
|
|
||||||
import { GroupsSection } from "../GroupsTab/components";
|
|
||||||
import { MembersSection } from "./components";
|
import { MembersSection } from "./components";
|
||||||
|
|
||||||
export const MembersTab = () => {
|
export const MembersTab = () => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="panel-project-members"
|
key="panel-project-members"
|
||||||
@@ -16,7 +12,6 @@ export const MembersTab = () => {
|
|||||||
exit={{ opacity: 0, translateX: 30 }}
|
exit={{ opacity: 0, translateX: 30 }}
|
||||||
>
|
>
|
||||||
<MembersSection />
|
<MembersSection />
|
||||||
{currentWorkspace?.version && currentWorkspace.version > 1 && <GroupsSection />}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-3
@@ -121,9 +121,12 @@ export const AddMemberModal = ({ popUp, handlePopUpToggle }: Props) => {
|
|||||||
});
|
});
|
||||||
return (orgUsers || [])
|
return (orgUsers || [])
|
||||||
.filter(({ user: u }) => !wsUserUsernames.has(u.username))
|
.filter(({ user: u }) => !wsUserUsernames.has(u.username))
|
||||||
.map((member) => ({
|
.map(({ id, inviteEmail, user: { firstName, lastName, email } }) => ({
|
||||||
value: member.id,
|
value: id,
|
||||||
label: `${member.user.firstName} ${member.user.lastName}`
|
label:
|
||||||
|
firstName && lastName
|
||||||
|
? `${firstName} ${lastName}`
|
||||||
|
: firstName || lastName || email || inviteEmail
|
||||||
}));
|
}));
|
||||||
}, [orgUsers, members]);
|
}, [orgUsers, members]);
|
||||||
|
|
||||||
|
|||||||
+134
-21
@@ -1,9 +1,12 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import {
|
import {
|
||||||
|
faArrowDown,
|
||||||
|
faArrowUp,
|
||||||
faClock,
|
faClock,
|
||||||
faEllipsisV,
|
faEllipsisV,
|
||||||
faMagnifyingGlass,
|
faMagnifyingGlass,
|
||||||
|
faSearch,
|
||||||
faTrash,
|
faTrash,
|
||||||
faUsers
|
faUsers
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
@@ -18,6 +21,7 @@ import {
|
|||||||
HoverCardTrigger,
|
HoverCardTrigger,
|
||||||
IconButton,
|
IconButton,
|
||||||
Input,
|
Input,
|
||||||
|
Pagination,
|
||||||
Table,
|
Table,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
@@ -35,7 +39,9 @@ import {
|
|||||||
useUser,
|
useUser,
|
||||||
useWorkspace
|
useWorkspace
|
||||||
} from "@app/context";
|
} from "@app/context";
|
||||||
|
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||||
import { useGetWorkspaceUsers } from "@app/hooks/api";
|
import { useGetWorkspaceUsers } from "@app/hooks/api";
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -54,9 +60,12 @@ type Props = {
|
|||||||
) => void;
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
enum MembersOrderBy {
|
||||||
const [searchMemberFilter, setSearchMemberFilter] = useState("");
|
Name = "firstName",
|
||||||
|
Email = "email"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const { user } = useUser();
|
const { user } = useUser();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -64,26 +73,80 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
const userId = user?.id || "";
|
const userId = user?.id || "";
|
||||||
const workspaceId = currentWorkspace?.id || "";
|
const workspaceId = currentWorkspace?.id || "";
|
||||||
|
|
||||||
const { data: members, isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
|
const {
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
setPage,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
setPerPage,
|
||||||
|
offset,
|
||||||
|
orderDirection,
|
||||||
|
orderBy,
|
||||||
|
setOrderBy,
|
||||||
|
setOrderDirection,
|
||||||
|
toggleOrderDirection
|
||||||
|
} = usePagination<MembersOrderBy>(MembersOrderBy.Name, { initPerPage: 20 });
|
||||||
|
|
||||||
const filterdUsers = useMemo(
|
const { data: members = [], isLoading: isMembersLoading } = useGetWorkspaceUsers(workspaceId);
|
||||||
|
|
||||||
|
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 MembersOrderBy.Email:
|
||||||
|
valueOne = memberOne.user.email || memberOne.inviteEmail;
|
||||||
|
valueTwo = memberTwo.user.email || memberTwo.inviteEmail;
|
||||||
|
break;
|
||||||
|
case MembersOrderBy.Name:
|
||||||
|
default:
|
||||||
|
valueOne = memberOne.user.firstName;
|
||||||
|
valueTwo = memberTwo.user.firstName;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!valueOne) return 1;
|
||||||
|
if (!valueTwo) return -1;
|
||||||
|
|
||||||
|
return valueOne.toLowerCase().localeCompare(valueTwo.toLowerCase());
|
||||||
|
}),
|
||||||
|
[members, search, orderDirection, orderBy]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useResetPageHelper({
|
||||||
|
totalCount: filteredUsers.length,
|
||||||
|
offset,
|
||||||
|
setPage
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSort = (column: MembersOrderBy) => {
|
||||||
|
if (column === orderBy) {
|
||||||
|
toggleOrderDirection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderBy(column);
|
||||||
|
setOrderDirection(OrderByDirection.ASC);
|
||||||
|
};
|
||||||
|
|
||||||
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..."
|
||||||
/>
|
/>
|
||||||
@@ -91,8 +154,44 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
<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 === MembersOrderBy.Name ? "" : "opacity-30"}`}
|
||||||
|
ariaLabel="sort"
|
||||||
|
onClick={() => handleSort(MembersOrderBy.Name)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={
|
||||||
|
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Name
|
||||||
|
? faArrowUp
|
||||||
|
: faArrowDown
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</Th>
|
||||||
|
<Th>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Email
|
||||||
|
<IconButton
|
||||||
|
variant="plain"
|
||||||
|
className={`ml-2 ${orderBy === MembersOrderBy.Email ? "" : "opacity-30"}`}
|
||||||
|
ariaLabel="sort"
|
||||||
|
onClick={() => handleSort(MembersOrderBy.Email)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={
|
||||||
|
orderDirection === OrderByDirection.DESC && orderBy === MembersOrderBy.Email
|
||||||
|
? faArrowUp
|
||||||
|
: faArrowDown
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</Th>
|
||||||
<Th>Role</Th>
|
<Th>Role</Th>
|
||||||
<Th className="w-5" />
|
<Th className="w-5" />
|
||||||
</Tr>
|
</Tr>
|
||||||
@@ -100,7 +199,7 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
<TBody>
|
<TBody>
|
||||||
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
|
{isMembersLoading && <TableSkeleton columns={4} innerKey="project-members" />}
|
||||||
{!isMembersLoading &&
|
{!isMembersLoading &&
|
||||||
filterdUsers?.map((projectMember) => {
|
filteredUsers.slice(offset, perPage * page).map((projectMember) => {
|
||||||
const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
|
const { user: u, inviteEmail, id: membershipId, roles } = projectMember;
|
||||||
const name = u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : "-";
|
const name = u.firstName || u.lastName ? `${u.firstName} ${u.lastName || ""}` : "-";
|
||||||
const email = u?.email || inviteEmail;
|
const email = u?.email || inviteEmail;
|
||||||
@@ -239,8 +338,22 @@ export const MembersTable = ({ handlePopUpOpen }: Props) => {
|
|||||||
})}
|
})}
|
||||||
</TBody>
|
</TBody>
|
||||||
</Table>
|
</Table>
|
||||||
{!isMembersLoading && filterdUsers?.length === 0 && (
|
{Boolean(filteredUsers.length) && (
|
||||||
<EmptyState title="No project members found" icon={faUsers} />
|
<Pagination
|
||||||
|
count={filteredUsers.length}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
onChangePage={setPage}
|
||||||
|
onChangePerPage={setPerPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isMembersLoading && !filteredUsers?.length && (
|
||||||
|
<EmptyState
|
||||||
|
title={
|
||||||
|
members.length ? "No project members match search..." : "No project members found"
|
||||||
|
}
|
||||||
|
icon={members.length ? faSearch : faUsers}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</TableContainer>
|
</TableContainer>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user