Files
infisical/frontend/src/pages/kms/OverviewPage/components/CmekTable.tsx
T

571 lines
21 KiB
TypeScript

import {
faArrowDown,
faArrowUp,
faArrowUpRightFromSquare,
faCancel,
faCheck,
faCheckCircle,
faCopy,
faEdit,
faEllipsis,
faFileSignature,
faInfoCircle,
faKey,
faLock,
faLockOpen,
faMagnifyingGlass,
faPlus,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
EmptyState,
IconButton,
Input,
Pagination,
Spinner,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tooltip,
Tr
} from "@app/components/v2";
import { BadgeProps } from "@app/components/v2/Badge/Badge";
import {
ProjectPermissionActions,
ProjectPermissionCmekActions,
ProjectPermissionSub,
useProject,
useProjectPermission
} from "@app/context";
import { kmsKeyUsageOptions } from "@app/helpers/kms";
import {
getUserTablePreference,
PreferenceKey,
setUserTablePreference
} from "@app/helpers/userTablePreferences";
import { usePagination, usePopUp, useResetPageHelper, useTimedReset } from "@app/hooks";
import { useGetCmeksByProjectId, useUpdateCmek } from "@app/hooks/api/cmeks";
import { CmekOrderBy, KmsKeyUsage, TCmek } from "@app/hooks/api/cmeks/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { CmekDecryptModal } from "./CmekDecryptModal";
import { CmekEncryptModal } from "./CmekEncryptModal";
import { CmekModal } from "./CmekModal";
import { CmekSignModal } from "./CmekSignModal";
import { CmekVerifyModal } from "./CmekVerifyModal";
import { DeleteCmekModal } from "./DeleteCmekModal";
const getStatusBadgeProps = (
isDisabled: boolean
): { variant: BadgeProps["variant"]; label: string } => {
if (isDisabled) {
return {
variant: "danger",
label: "Disabled"
};
}
return {
variant: "success",
label: "Active"
};
};
export const CmekTable = () => {
const { currentProject } = useProject();
const { permission } = useProjectPermission();
const projectId = currentProject?.id ?? "";
const {
offset,
limit,
orderBy,
orderDirection,
setOrderDirection,
search,
debouncedSearch,
setPage,
setSearch,
perPage,
page,
setPerPage
} = usePagination(CmekOrderBy.Name, {
initPerPage: getUserTablePreference("cmekClientTable", PreferenceKey.PerPage, 20)
});
const handlePerPageChange = (newPerPage: number) => {
setPerPage(newPerPage);
setUserTablePreference("cmekClientTable", PreferenceKey.PerPage, newPerPage);
};
const { data, isPending, isFetching } = useGetCmeksByProjectId({
projectId,
offset,
limit,
search: debouncedSearch,
orderBy,
orderDirection
});
const { keys = [], totalCount = 0 } = data ?? {};
useResetPageHelper({
totalCount,
offset,
setPage
});
const [, isCopyingCiphertext, setCopyCipherText] = useTimedReset<string>({
initialState: "",
delay: 1000
});
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"upsertKey",
"deleteKey",
"encryptData",
"decryptData",
"signData",
"verifyData"
] as const);
const handleSort = () => {
setOrderDirection((prev) =>
prev === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
);
};
const updateCmek = useUpdateCmek();
const handleDisableCmek = async ({ id: keyId, isDisabled }: TCmek) => {
try {
await updateCmek.mutateAsync({
keyId,
projectId,
isDisabled: !isDisabled
});
createNotification({
text: `Key successfully ${isDisabled ? "enabled" : "disabled"}`,
type: "success"
});
} catch (err) {
console.error(err);
const error = err as any;
const text =
error?.response?.data?.message ?? `Failed to ${isDisabled ? "enable" : "disable"} key`;
createNotification({
text,
type: "error"
});
}
};
const cannotEditKey = permission.cannot(
ProjectPermissionCmekActions.Edit,
ProjectPermissionSub.Cmek
);
const cannotDeleteKey = permission.cannot(
ProjectPermissionCmekActions.Delete,
ProjectPermissionSub.Cmek
);
const cannotEncryptData = permission.cannot(
ProjectPermissionCmekActions.Encrypt,
ProjectPermissionSub.Cmek
);
const cannotDecryptData = permission.cannot(
ProjectPermissionCmekActions.Decrypt,
ProjectPermissionSub.Cmek
);
const cannotSignData = permission.cannot(
ProjectPermissionCmekActions.Sign,
ProjectPermissionSub.Cmek
);
const cannotVerifyData = permission.cannot(
ProjectPermissionCmekActions.Verify,
ProjectPermissionSub.Cmek
);
return (
<motion.div
key="kms-keys-tab"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<div className="border-mineshaft-600 bg-mineshaft-900 mb-6 rounded-lg border p-4">
<div className="mb-4 flex items-center justify-between">
<p className="text-mineshaft-100 whitespace-nowrap text-xl font-medium">Keys</p>
<div className="flex w-full justify-end pr-4">
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/kms"
>
<span className="border-mineshaft-500 bg-mineshaft-600 text-mineshaft-200 hover:border-primary/40 hover:bg-primary/10 flex w-max cursor-pointer items-center rounded-md border px-4 py-2 duration-200 hover:text-white">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</a>
</div>
<ProjectPermissionCan I={ProjectPermissionActions.Create} a={ProjectPermissionSub.Cmek}>
{(isAllowed) => (
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("upsertKey", null)}
isDisabled={!isAllowed}
>
Add Key
</Button>
)}
</ProjectPermissionCan>
</div>
<Input
containerClassName="mb-4"
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search keys by name..."
/>
<TableContainer>
<Table>
<THead>
<Tr className="h-14">
<Th>
<div className="flex items-center">
Name
<IconButton
variant="plain"
className="ml-2"
ariaLabel="sort"
onClick={handleSort}
>
<FontAwesomeIcon
icon={orderDirection === OrderByDirection.DESC ? faArrowUp : faArrowDown}
/>
</IconButton>
</div>
</Th>
<Th>Key ID</Th>
<Th>Key Usage</Th>
<Th>Algorithm</Th>
<Th>Status</Th>
<Th>Version</Th>
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={5} innerKey="project-keys" />}
{!isPending &&
keys.length > 0 &&
keys.map((cmek) => {
const {
name,
id,
version,
description,
encryptionAlgorithm,
isDisabled,
keyUsage
} = cmek;
const { variant, label } = getStatusBadgeProps(isDisabled);
return (
<Tr
className="hover:bg-mineshaft-700 group h-10"
key={`st-v3-${id}`}
onMouseLeave={() => {
setCopyCipherText("");
}}
>
<Td>
<div className="flex items-center gap-2">
{name}
{description && (
<Tooltip content={description}>
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
)}
</div>
</Td>
<Td>
<div>
<span> {id}</span>
<IconButton
ariaLabel="copy icon"
colorSchema="secondary"
size="xs"
className="group/copy duration:0 invisible relative ml-3 rounded-md group-hover:visible"
onClick={() => {
navigator.clipboard.writeText(id);
setCopyCipherText("Copied");
}}
>
<FontAwesomeIcon icon={isCopyingCiphertext ? faCheck : faCopy} />
</IconButton>
</div>
</Td>
<Td>
<div className="flex items-center gap-2">
{kmsKeyUsageOptions[keyUsage].label}
<Tooltip content={kmsKeyUsageOptions[keyUsage].tooltip}>
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400" />
</Tooltip>
</div>
</Td>
<Td className="uppercase">{encryptionAlgorithm}</Td>
<Td>
<Badge variant={variant}>{label}</Badge>
</Td>
<Td>{version}</Td>
<Td>
<div className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
variant="plain"
colorSchema="primary"
className="data-[state=open]:text-primary-400 ml-4 p-0"
ariaLabel="More options"
>
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[160px]">
{keyUsage === KmsKeyUsage.ENCRYPT_DECRYPT && (
<>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotEncryptData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("encryptData", cmek)}
icon={<FontAwesomeIcon icon={faLock} />}
iconPos="left"
isDisabled={cannotEncryptData || isDisabled}
>
Encrypt Data
</DropdownMenuItem>
</div>
</Tooltip>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotDecryptData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("decryptData", cmek)}
icon={<FontAwesomeIcon icon={faLockOpen} />}
iconPos="left"
isDisabled={cannotDecryptData || isDisabled}
>
Decrypt Data
</DropdownMenuItem>
</div>
</Tooltip>
</>
)}
{keyUsage === KmsKeyUsage.SIGN_VERIFY && (
<>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotSignData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("signData", cmek)}
icon={<FontAwesomeIcon icon={faFileSignature} />}
iconPos="left"
isDisabled={cannotSignData || isDisabled}
>
Sign Data
</DropdownMenuItem>
</div>
</Tooltip>
<Tooltip
content={
// eslint-disable-next-line no-nested-ternary
cannotVerifyData
? "Access Restricted"
: isDisabled
? "Key Disabled"
: ""
}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("verifyData", cmek)}
icon={<FontAwesomeIcon icon={faCheckCircle} />}
iconPos="left"
isDisabled={cannotVerifyData || isDisabled}
>
Verify Data
</DropdownMenuItem>
</div>
</Tooltip>
</>
)}
<Tooltip
content={cannotEditKey ? "Access Restricted" : ""}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("upsertKey", cmek)}
icon={<FontAwesomeIcon icon={faEdit} />}
iconPos="left"
isDisabled={cannotEditKey}
>
Edit Key
</DropdownMenuItem>
</div>
</Tooltip>
<Tooltip
content={cannotEditKey ? "Access Restricted" : ""}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handleDisableCmek(cmek)}
icon={
<FontAwesomeIcon
icon={isDisabled ? faCheckCircle : faCancel}
/>
}
iconPos="left"
isDisabled={cannotEditKey}
>
{isDisabled ? "Enable" : "Disable"} Key
</DropdownMenuItem>
</div>
</Tooltip>
<Tooltip
content={cannotDeleteKey ? "Access Restricted" : ""}
position="left"
>
<div>
<DropdownMenuItem
onClick={() => handlePopUpOpen("deleteKey", cmek)}
icon={<FontAwesomeIcon icon={faTrash} />}
iconPos="left"
isDisabled={cannotDeleteKey}
>
Delete Key
</DropdownMenuItem>
</div>
</Tooltip>
</DropdownMenuContent>
</DropdownMenu>
</div>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && keys.length === 0 && (
<EmptyState
title={
debouncedSearch.trim().length > 0
? "No keys match search filter"
: "No keys have been added to this project"
}
icon={faKey}
/>
)}
</TableContainer>
<DeleteCmekModal
isOpen={popUp.deleteKey.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("deleteKey", isOpen)}
cmek={popUp.deleteKey.data as TCmek}
/>
<CmekModal
isOpen={popUp.upsertKey.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("upsertKey", isOpen)}
cmek={popUp.upsertKey.data as TCmek | null}
/>
<CmekEncryptModal
isOpen={popUp.encryptData.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("encryptData", isOpen)}
cmek={popUp.encryptData.data as TCmek}
/>
<CmekDecryptModal
isOpen={popUp.decryptData.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("decryptData", isOpen)}
cmek={popUp.decryptData.data as TCmek}
/>
<CmekSignModal
isOpen={popUp.signData.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("signData", isOpen)}
cmek={popUp.signData.data as TCmek}
/>
<CmekVerifyModal
isOpen={popUp.verifyData.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("verifyData", isOpen)}
cmek={popUp.verifyData.data as TCmek}
/>
</div>
</motion.div>
);
};