Merge pull request #3804 from Infisical/service-tokens-table-improvements

improvement(service-tokens): Improve Service Tokens Table
This commit is contained in:
Scott Wilson
2025-06-17 14:15:07 -07:00
committed by GitHub
4 changed files with 465 additions and 263 deletions

View File

@@ -70,7 +70,15 @@ export const Pagination = ({
key={`pagination-per-page-options-${perPageOption}`}
icon={perPage === perPageOption && <FontAwesomeIcon size="sm" icon={faCheck} />}
iconPos="right"
onClick={() => onChangePerPage(perPageOption)}
onClick={() => {
const totalPages = Math.ceil(count / perPageOption);
if (page > totalPages) {
onChangePage(totalPages);
}
onChangePerPage(perPageOption);
}}
>
{perPageOption} rows per page
</DropdownMenuItem>

View File

@@ -67,13 +67,12 @@ type Props = {
handlePopUpToggle: (popUpName: keyof UsePopUpState<["createAPIToken"]>, state?: boolean) => void;
};
export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
const ServiceTokenForm = () => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
const {
control,
reset,
handleSubmit,
formState: { isSubmitting }
} = useForm<FormData>({
@@ -152,13 +151,197 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
}
};
return !hasServiceToken ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label={t("section.token.add-dialog.name")}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your token name" />
</FormControl>
)}
/>
{tokenScopes.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`scopes.${index}.environment`}
defaultValue={currentWorkspace?.environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Environment" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{currentWorkspace?.environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.secretPath`}
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Secrets Path" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="can be /, /nested/**, /**/deep" />
</FormControl>
)}
/>
<IconButton
className="p-3"
ariaLabel="remove"
colorSchema="danger"
onClick={() => remove(index)}
>
<FontAwesomeIcon icon={faTrashCan} size="sm" />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() =>
append({
environment: currentWorkspace?.environments?.[0]?.slug || "",
secretPath: ""
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add Scope
</Button>
</div>
<Controller
control={control}
name="expiresIn"
defaultValue={String(apiTokenExpiry?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Expiration" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{apiTokenExpiry.map(({ label, value }) => (
<SelectItem value={String(value)} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
readValue: false,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error } }) => {
const options = [
{
label: "Read (default)",
value: "read"
},
{
label: "Write (optional)",
value: "write"
}
] as const;
return (
<FormControl label="Permissions" errorText={error?.message} isError={Boolean(error)}>
<>
{options.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={String(value[optionValue])}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === "read"}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
) : (
<div className="mb-3 mr-2 mt-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newToken}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
);
};
export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
const { t } = useTranslation();
const { currentWorkspace } = useWorkspace();
return (
<Modal
isOpen={popUp?.createAPIToken?.isOpen}
onOpenChange={(open) => {
handlePopUpToggle("createAPIToken", open);
reset();
setToken("");
}}
>
<ModalContent
@@ -169,194 +352,7 @@ export const AddServiceTokenModal = ({ popUp, handlePopUpToggle }: Props) => {
}
subTitle={t("section.token.add-dialog.description") as string}
>
{!hasServiceToken ? (
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label={t("section.token.add-dialog.name")}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="Type your token name" />
</FormControl>
)}
/>
{tokenScopes.map(({ id }, index) => (
<div className="mb-3 flex items-end space-x-2" key={id}>
<Controller
control={control}
name={`scopes.${index}.environment`}
defaultValue={currentWorkspace?.environments?.[0]?.slug}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl
className="mb-0"
label={index === 0 ? "Environment" : undefined}
errorText={error?.message}
isError={Boolean(error)}
>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{currentWorkspace?.environments.map(({ name, slug }) => (
<SelectItem value={slug} key={slug}>
{name}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name={`scopes.${index}.secretPath`}
defaultValue="/"
render={({ field, fieldState: { error } }) => (
<FormControl
className="mb-0 flex-grow"
label={index === 0 ? "Secrets Path" : undefined}
isError={Boolean(error)}
errorText={error?.message}
>
<Input {...field} placeholder="can be /, /nested/**, /**/deep" />
</FormControl>
)}
/>
<IconButton
className="p-3"
ariaLabel="remove"
colorSchema="danger"
onClick={() => remove(index)}
>
<FontAwesomeIcon icon={faTrashCan} size="sm" />
</IconButton>
</div>
))}
<div className="my-4 ml-1">
<Button
variant="outline_bg"
onClick={() =>
append({
environment: currentWorkspace?.environments?.[0]?.slug || "",
secretPath: ""
})
}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
size="xs"
>
Add Scope
</Button>
</div>
<Controller
control={control}
name="expiresIn"
defaultValue={String(apiTokenExpiry?.[0]?.value)}
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Expiration" errorText={error?.message} isError={Boolean(error)}>
<Select
defaultValue={field.value}
{...field}
onValueChange={(e) => onChange(e)}
className="w-full"
>
{apiTokenExpiry.map(({ label, value }) => (
<SelectItem value={String(value)} key={label}>
{label}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
<Controller
control={control}
name="permissions"
defaultValue={{
read: true,
readValue: false,
write: false
}}
render={({ field: { onChange, value }, fieldState: { error } }) => {
const options = [
{
label: "Read (default)",
value: "read"
},
{
label: "Write (optional)",
value: "write"
}
] as const;
return (
<FormControl
label="Permissions"
errorText={error?.message}
isError={Boolean(error)}
>
<>
{options.map(({ label, value: optionValue }) => {
return (
<Checkbox
id={String(value[optionValue])}
key={optionValue}
className="data-[state=checked]:bg-primary"
isChecked={value[optionValue]}
isDisabled={optionValue === "read"}
onCheckedChange={(state) => {
onChange({
...value,
[optionValue]: state
});
}}
>
{label}
</Checkbox>
);
})}
</>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center">
<Button
className="mr-4"
type="submit"
isDisabled={isSubmitting}
isLoading={isSubmitting}
>
Create
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
) : (
<div className="mb-3 mr-2 mt-2 flex items-center justify-end rounded-md bg-white/[0.07] p-2 text-base text-gray-400">
<p className="mr-4 break-all">{newToken}</p>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
className="group relative"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isTokenCopied ? faCheck : faCopy} />
<span className="absolute -left-8 -top-20 hidden w-28 translate-y-full rounded-md bg-bunker-800 py-2 pl-3 text-center text-sm text-gray-400 group-hover:flex group-hover:animate-fadeIn">
{t("common.click-to-copy")}
</span>
</IconButton>
</div>
)}
<ServiceTokenForm />
</ModalContent>
</Modal>
);

View File

@@ -1,5 +1,5 @@
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 { createNotification } from "@app/components/notifications";
@@ -48,8 +48,29 @@ export const ServiceTokenSection = withProjectPermission(
return (
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-2 flex justify-between">
<p className="text-xl font-semibold text-mineshaft-100">Service Tokens</p>
<div className="mb-2 flex items-center justify-between">
<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
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.ServiceTokens}
@@ -63,12 +84,11 @@ export const ServiceTokenSection = withProjectPermission(
}}
isDisabled={!isAllowed}
>
Create token
Create Token
</Button>
)}
</ProjectPermissionCan>
</div>
<p className="mb-8 text-gray-400">{t("section.token.service-tokens-description")}</p>
<ServiceTokenTable handlePopUpOpen={handlePopUpOpen} />
<AddServiceTokenModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal

View File

@@ -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 { format } from "date-fns";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Table,
TableContainer,
TableSkeleton,
@@ -12,10 +30,18 @@ import {
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
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 { OrderByDirection } from "@app/hooks/api/generic/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
type Props = {
@@ -31,78 +57,230 @@ type Props = {
) => void;
};
enum TokensOrderBy {
Name = "name",
Expiration = "expiration"
}
export const ServiceTokenTable = ({ handlePopUpOpen }: Props) => {
const { currentWorkspace } = useWorkspace();
const { data, isPending } = useGetUserWsServiceTokens({
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, scopes } = token;
const searchValue = search.trim().toLowerCase();
if (name.toLowerCase().includes(searchValue)) {
return true;
}
return scopes.some(
({ environment, secretPath }) =>
environment.toLowerCase().includes(searchValue) ||
secretPath.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 (
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Token Name</Th>
<Th>Environment - Secret Path</Th>
<Th>Valid Until</Th>
<Th aria-label="button" />
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
{!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 && (
<div>
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search service tokens by name, environment or secret path..."
className="flex-1"
containerClassName="mb-4 mt-2"
/>
<TableContainer>
<Table>
<THead>
<Tr>
<Td colSpan={4} className="bg-mineshaft-800 text-center text-bunker-400">
<EmptyState title="No service tokens found" icon={faKey} />
</Td>
<Th>
<div className="flex items-center">
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>
)}
</TBody>
</Table>
</TableContainer>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="project-service-tokens" />}
{!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>
);
};