mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-08 22:28:15 +00:00
improvement: add pagination, search and column sort to service token table and improve table rows
This commit is contained in:
@@ -70,7 +70,15 @@ export const Pagination = ({
|
|||||||
key={`pagination-per-page-options-${perPageOption}`}
|
key={`pagination-per-page-options-${perPageOption}`}
|
||||||
icon={perPage === perPageOption && <FontAwesomeIcon size="sm" icon={faCheck} />}
|
icon={perPage === perPageOption && <FontAwesomeIcon size="sm" icon={faCheck} />}
|
||||||
iconPos="right"
|
iconPos="right"
|
||||||
onClick={() => onChangePerPage(perPageOption)}
|
onClick={() => {
|
||||||
|
const totalPages = Math.ceil(count / perPageOption);
|
||||||
|
|
||||||
|
if (page > totalPages) {
|
||||||
|
onChangePage(totalPages);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangePerPage(perPageOption);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{perPageOption} rows per page
|
{perPageOption} rows per page
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
+25
-5
@@ -1,5 +1,5 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
import { faArrowUpRightFromSquare, faBookOpen, faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
@@ -48,8 +48,29 @@ export const ServiceTokenSection = withProjectPermission(
|
|||||||
|
|
||||||
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-2 flex justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<p className="text-xl font-semibold text-mineshaft-100">Service Tokens</p>
|
<div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<p className="text-xl font-semibold text-mineshaft-100">Service Tokens</p>
|
||||||
|
<a
|
||||||
|
href="https://infisical.com/docs/documentation/platform/token"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<div className="ml-1 mt-[0.16rem] inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
|
||||||
|
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
|
||||||
|
<span>Docs</span>
|
||||||
|
<FontAwesomeIcon
|
||||||
|
icon={faArrowUpRightFromSquare}
|
||||||
|
className="mb-[0.07rem] ml-1.5 text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-bunker-300">
|
||||||
|
{t("section.token.service-tokens-description")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<ProjectPermissionCan
|
<ProjectPermissionCan
|
||||||
I={ProjectPermissionActions.Create}
|
I={ProjectPermissionActions.Create}
|
||||||
a={ProjectPermissionSub.ServiceTokens}
|
a={ProjectPermissionSub.ServiceTokens}
|
||||||
@@ -63,12 +84,11 @@ export const ServiceTokenSection = withProjectPermission(
|
|||||||
}}
|
}}
|
||||||
isDisabled={!isAllowed}
|
isDisabled={!isAllowed}
|
||||||
>
|
>
|
||||||
Create token
|
Create Token
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
</div>
|
</div>
|
||||||
<p className="mb-8 text-gray-400">{t("section.token.service-tokens-description")}</p>
|
|
||||||
<ServiceTokenTable handlePopUpOpen={handlePopUpOpen} />
|
<ServiceTokenTable handlePopUpOpen={handlePopUpOpen} />
|
||||||
<AddServiceTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
<AddServiceTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
|
|||||||
+235
-65
@@ -1,10 +1,28 @@
|
|||||||
import { faFolder, faKey, faTrashCan } from "@fortawesome/free-solid-svg-icons";
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
faArrowDown,
|
||||||
|
faArrowUp,
|
||||||
|
faEllipsisV,
|
||||||
|
faFolder,
|
||||||
|
faKey,
|
||||||
|
faMagnifyingGlass,
|
||||||
|
faSearch,
|
||||||
|
faTrash
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
import {
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
EmptyState,
|
EmptyState,
|
||||||
IconButton,
|
IconButton,
|
||||||
|
Input,
|
||||||
|
Pagination,
|
||||||
Table,
|
Table,
|
||||||
TableContainer,
|
TableContainer,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
@@ -12,10 +30,18 @@ import {
|
|||||||
Td,
|
Td,
|
||||||
Th,
|
Th,
|
||||||
THead,
|
THead,
|
||||||
|
Tooltip,
|
||||||
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 {
|
||||||
|
getUserTablePreference,
|
||||||
|
PreferenceKey,
|
||||||
|
setUserTablePreference
|
||||||
|
} from "@app/helpers/userTablePreferences";
|
||||||
|
import { usePagination, useResetPageHelper } from "@app/hooks";
|
||||||
import { useGetUserWsServiceTokens } from "@app/hooks/api";
|
import { useGetUserWsServiceTokens } 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 = {
|
||||||
@@ -31,78 +57,222 @@ type Props = {
|
|||||||
) => void;
|
) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
enum TokensOrderBy {
|
||||||
|
Name = "name",
|
||||||
|
Expiration = "expiration"
|
||||||
|
}
|
||||||
|
|
||||||
export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => {
|
export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => {
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const { data, isPending } = useGetUserWsServiceTokens({
|
const { data, isPending } = useGetUserWsServiceTokens({
|
||||||
workspaceID: currentWorkspace?.id || ""
|
workspaceID: currentWorkspace?.id || ""
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
setSearch,
|
||||||
|
setPage,
|
||||||
|
page,
|
||||||
|
perPage,
|
||||||
|
setPerPage,
|
||||||
|
offset,
|
||||||
|
orderDirection,
|
||||||
|
toggleOrderDirection,
|
||||||
|
orderBy,
|
||||||
|
setOrderDirection,
|
||||||
|
setOrderBy
|
||||||
|
} = usePagination<TokensOrderBy>(TokensOrderBy.Name, {
|
||||||
|
initPerPage: getUserTablePreference("projectServiceTokens", PreferenceKey.PerPage, 20)
|
||||||
|
});
|
||||||
|
|
||||||
|
const handlePerPageChange = (newPerPage: number) => {
|
||||||
|
setPerPage(newPerPage);
|
||||||
|
setUserTablePreference("projectServiceTokens", PreferenceKey.PerPage, newPerPage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredTokens = useMemo(
|
||||||
|
() =>
|
||||||
|
data
|
||||||
|
?.filter((token) => {
|
||||||
|
const { name } = token;
|
||||||
|
|
||||||
|
const searchValue = search.trim().toLowerCase();
|
||||||
|
|
||||||
|
return name.toLowerCase().includes(searchValue);
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const [tokenOne, tokenTwo] = orderDirection === OrderByDirection.ASC ? [a, b] : [b, a];
|
||||||
|
|
||||||
|
switch (orderBy) {
|
||||||
|
case TokensOrderBy.Expiration:
|
||||||
|
if (!tokenOne.expiresAt && !tokenTwo.expiresAt) return 0;
|
||||||
|
if (!tokenOne.expiresAt) return 1;
|
||||||
|
if (!tokenTwo.expiresAt) return -1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
new Date(tokenOne.expiresAt).getTime() - new Date(tokenTwo.expiresAt).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
case TokensOrderBy.Name:
|
||||||
|
default:
|
||||||
|
return tokenOne.name.toLowerCase().localeCompare(tokenTwo.name.toLowerCase());
|
||||||
|
}
|
||||||
|
}) ?? [],
|
||||||
|
[data, orderDirection, search, orderBy]
|
||||||
|
);
|
||||||
|
|
||||||
|
useResetPageHelper({
|
||||||
|
totalCount: filteredTokens.length,
|
||||||
|
offset,
|
||||||
|
setPage
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSort = (column: TokensOrderBy) => {
|
||||||
|
if (column === orderBy) {
|
||||||
|
toggleOrderDirection();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOrderBy(column);
|
||||||
|
setOrderDirection(OrderByDirection.ASC);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getClassName = (col: TokensOrderBy) => twMerge("ml-2", orderBy === col ? "" : "opacity-30");
|
||||||
|
|
||||||
|
const getColSortIcon = (col: TokensOrderBy) =>
|
||||||
|
orderDirection === OrderByDirection.DESC && orderBy === col ? faArrowUp : faArrowDown;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableContainer>
|
<div>
|
||||||
<Table>
|
<Input
|
||||||
<THead>
|
value={search}
|
||||||
<Tr>
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
<Th>Token Name</Th>
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||||
<Th>Environment - Secret Path</Th>
|
placeholder="Search service tokens..."
|
||||||
<Th>Valid Until</Th>
|
className="flex-1"
|
||||||
<Th aria-label="button" />
|
containerClassName="mb-4 mt-2"
|
||||||
</Tr>
|
/>
|
||||||
</THead>
|
<TableContainer>
|
||||||
<TBody>
|
<Table>
|
||||||
{isPending && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
|
<THead>
|
||||||
{!isPending &&
|
|
||||||
data &&
|
|
||||||
data.map((row) => (
|
|
||||||
<Tr key={row.id}>
|
|
||||||
<Td>{row.name}</Td>
|
|
||||||
<Td>
|
|
||||||
<div className="mb-2 flex flex-col flex-wrap space-y-1">
|
|
||||||
{row?.scopes.map(({ secretPath, environment }) => (
|
|
||||||
<div
|
|
||||||
key={`${row.id}-${environment}-${secretPath}`}
|
|
||||||
className="inline-flex items-center space-x-1 rounded-md border border-mineshaft-600 p-1 px-2"
|
|
||||||
>
|
|
||||||
<div className="mr-2 border-r border-mineshaft-600 pr-2">{environment}</div>
|
|
||||||
<FontAwesomeIcon icon={faFolder} size="sm" />
|
|
||||||
<span className="pl-2">{secretPath}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Td>
|
|
||||||
<Td>{row.expiresAt && new Date(row.expiresAt).toUTCString()}</Td>
|
|
||||||
<Td>
|
|
||||||
<ProjectPermissionCan
|
|
||||||
I={ProjectPermissionActions.Delete}
|
|
||||||
a={ProjectPermissionSub.ServiceTokens}
|
|
||||||
>
|
|
||||||
{(isAllowed) => (
|
|
||||||
<IconButton
|
|
||||||
onClick={() =>
|
|
||||||
handlePopUpOpen("deleteAPITokenConfirmation", {
|
|
||||||
name: row.name,
|
|
||||||
id: row.id
|
|
||||||
})
|
|
||||||
}
|
|
||||||
colorSchema="danger"
|
|
||||||
ariaLabel="delete"
|
|
||||||
isDisabled={!isAllowed}
|
|
||||||
>
|
|
||||||
<FontAwesomeIcon icon={faTrashCan} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</ProjectPermissionCan>
|
|
||||||
</Td>
|
|
||||||
</Tr>
|
|
||||||
))}
|
|
||||||
{!isPending && data && data?.length === 0 && (
|
|
||||||
<Tr>
|
<Tr>
|
||||||
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
|
<Th>
|
||||||
<EmptyState title="No service tokens found" icon={faKey} />
|
<div className="flex items-center">
|
||||||
</Td>
|
Name
|
||||||
|
<IconButton
|
||||||
|
variant="plain"
|
||||||
|
className={getClassName(TokensOrderBy.Name)}
|
||||||
|
ariaLabel="sort"
|
||||||
|
onClick={() => handleSort(TokensOrderBy.Name)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={getColSortIcon(TokensOrderBy.Name)} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</Th>
|
||||||
|
<Th>Environment / Secret Path</Th>
|
||||||
|
<Th>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Valid Until
|
||||||
|
<IconButton
|
||||||
|
variant="plain"
|
||||||
|
className={getClassName(TokensOrderBy.Expiration)}
|
||||||
|
ariaLabel="sort"
|
||||||
|
onClick={() => handleSort(TokensOrderBy.Expiration)}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={getColSortIcon(TokensOrderBy.Expiration)} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
</Th>
|
||||||
|
<Th className="w-5" aria-label="button" />
|
||||||
</Tr>
|
</Tr>
|
||||||
)}
|
</THead>
|
||||||
</TBody>
|
<TBody>
|
||||||
</Table>
|
{isPending && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
|
||||||
</TableContainer>
|
{!isPending &&
|
||||||
|
filteredTokens.slice(offset, perPage * page).map((row) => (
|
||||||
|
<Tr key={row.id}>
|
||||||
|
<Td>{row.name}</Td>
|
||||||
|
<Td>
|
||||||
|
<div className="flex flex-row flex-wrap gap-1">
|
||||||
|
{row?.scopes.map(({ secretPath, environment }) => (
|
||||||
|
<div
|
||||||
|
key={`${row.id}-${environment}-${secretPath}`}
|
||||||
|
className="inline-flex items-center space-x-1 rounded-md border border-mineshaft-500 p-1 px-2"
|
||||||
|
>
|
||||||
|
<div className="mr-1 border-r border-mineshaft-500 pr-2">
|
||||||
|
{environment}
|
||||||
|
</div>
|
||||||
|
<FontAwesomeIcon icon={faFolder} size="sm" className="text-yellow" />
|
||||||
|
<span className="pl-1">{secretPath}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
{row.expiresAt ? (
|
||||||
|
format(row.expiresAt, "MM/dd/yyyy h:mm:ss aa")
|
||||||
|
) : (
|
||||||
|
<span className="text-mineshaft-400">N/A</span>
|
||||||
|
)}
|
||||||
|
</Td>
|
||||||
|
<Td>
|
||||||
|
<Tooltip className="max-w-sm text-center" content="Options">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="Options"
|
||||||
|
colorSchema="secondary"
|
||||||
|
className="w-6"
|
||||||
|
variant="plain"
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faEllipsisV} />
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="min-w-[12rem]" sideOffset={2} align="end">
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Delete}
|
||||||
|
a={ProjectPermissionSub.ServiceTokens}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePopUpOpen("deleteAPITokenConfirmation", {
|
||||||
|
name: row.name,
|
||||||
|
id: row.id
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete Token
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</Tooltip>
|
||||||
|
</Td>
|
||||||
|
</Tr>
|
||||||
|
))}
|
||||||
|
</TBody>
|
||||||
|
</Table>
|
||||||
|
{Boolean(filteredTokens.length) && (
|
||||||
|
<Pagination
|
||||||
|
count={filteredTokens.length}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
onChangePage={setPage}
|
||||||
|
onChangePerPage={handlePerPageChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!isPending && !filteredTokens?.length && (
|
||||||
|
<EmptyState
|
||||||
|
title={data?.length ? "No service tokens match search..." : "No service tokens found"}
|
||||||
|
icon={data?.length ? faSearch : faKey}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TableContainer>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user