improvement: finish address changes

This commit is contained in:
Scott Wilson
2024-10-02 16:35:53 -07:00
parent fd254fbeec
commit e667c7c988
82 changed files with 2708 additions and 223 deletions
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useRouter } from "next/router";
import {
faArrowDown,
@@ -34,7 +34,7 @@ import {
Tr
} from "@app/components/v2";
import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context";
import { useDebounce } from "@app/hooks";
import { usePagination } from "@app/hooks";
import { useGetIdentityMembershipOrgs, useGetOrgRoles, useUpdateIdentity } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { OrgIdentityOrderBy } from "@app/hooks/api/organization/types";
@@ -50,28 +50,35 @@ type Props = {
) => void;
};
const INIT_PER_PAGE = 20;
export const IdentityTable = ({ handlePopUpOpen }: Props) => {
const router = useRouter();
const { currentOrg } = useOrganization();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC);
const [orderBy, setOrderBy] = useState(OrgIdentityOrderBy.Name);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search);
const {
offset,
limit,
orderBy,
setOrderBy,
orderDirection,
setOrderDirection,
search,
debouncedSearch,
setPage,
setSearch,
perPage,
page,
setPerPage
} = usePagination<OrgIdentityOrderBy>(OrgIdentityOrderBy.Name);
const organizationId = currentOrg?.id || "";
const { mutateAsync: updateMutateAsync } = useUpdateIdentity();
const offset = (page - 1) * perPage;
const { data, isLoading, isFetching } = useGetIdentityMembershipOrgs(
{
organizationId,
offset,
limit: perPage,
limit,
orderDirection,
orderBy,
search: debouncedSearch
@@ -79,10 +86,11 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
{ keepPreviousData: true }
);
const { totalCount = 0 } = data ?? {};
useEffect(() => {
// reset page if no longer valid
if (data && data.totalCount < offset) setPage(1);
}, [data?.totalCount]);
if (totalCount <= offset) setPage(1);
}, [totalCount]);
const { data: roles } = useGetOrgRoles(organizationId);
@@ -155,7 +163,8 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
</IconButton>
</div>
</Th>
<Th>
<Th>Role</Th>
{/* <Th>
<div className="flex items-center">
Role
<IconButton
@@ -174,7 +183,7 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
/>
</IconButton>
</div>
</Th>
</Th> */}
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
</Tr>
</THead>
@@ -277,9 +286,9 @@ export const IdentityTable = ({ handlePopUpOpen }: Props) => {
})}
</TBody>
</Table>
{!isLoading && data && data.totalCount > 0 && (
{!isLoading && data && totalCount > 0 && (
<Pagination
count={data.totalCount}
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
@@ -2,7 +2,7 @@ import { Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { CaTab, CertificatesTab,PkiAlertsTab } from "./components";
import { CaTab, CertificatesTab, PkiAlertsTab } from "./components";
enum TabSections {
Ca = "certificate-authorities",
@@ -36,5 +36,5 @@ export const CertificatesPage = withProjectPermission(
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.AuditLogs }
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Certificates }
);
@@ -0,0 +1,169 @@
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { faCheckCircle, faCopy, faInfoCircle, faLockOpen } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { decodeBase64 } from "tweetnacl-util";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Modal,
ModalClose,
ModalContent,
Switch,
TextArea,
Tooltip
} from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import { TCmek, useCmekDecrypt } from "@app/hooks/api/cmeks";
const formSchema = z.object({
ciphertext: z.string()
});
export type FormData = z.infer<typeof formSchema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
cmek: TCmek;
};
type FormProps = Pick<Props, "cmek">;
const DecryptForm = ({ cmek }: FormProps) => {
const cmekDecrypt = useCmekDecrypt();
const [shouldDecode, setShouldDecode] = useState(false);
const [plaintext, setPlaintext] = useState("");
const {
handleSubmit,
register,
formState: { isSubmitting, errors }
} = useForm<FormData>({
resolver: zodResolver(formSchema)
});
const [copyCiphertext, isCopyingCiphertext, setCopyCipherText] = useTimedReset<string>({
initialState: "Copy to Clipboard"
});
const handleDecryptData = async (formData: FormData) => {
try {
const data = await cmekDecrypt.mutateAsync({ ...formData, keyId: cmek.id });
createNotification({
text: "Successfully decrypted data",
type: "success"
});
setPlaintext(
shouldDecode ? Buffer.from(decodeBase64(data.plaintext)).toString("utf8") : data.plaintext
);
} catch (err) {
console.error(err);
createNotification({
text: "Failed to decrypt data",
type: "error"
});
}
};
useEffect(() => {
const text = cmekDecrypt.data?.plaintext;
if (!text) return;
setPlaintext(shouldDecode ? Buffer.from(decodeBase64(text)).toString("utf8") : text);
}, [shouldDecode]);
const handleCopyToClipboard = () => {
navigator.clipboard.writeText(plaintext ?? "");
setCopyCipherText("Copied to Clipboard");
};
return (
<form onSubmit={handleSubmit(handleDecryptData)}>
{plaintext ? (
<FormControl label="Decrypted Data (plaintext)">
<TextArea
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
isDisabled
value={plaintext}
/>
</FormControl>
) : (
<FormControl
label="Encrypted Data (ciphertext)"
errorText={errors.ciphertext?.message}
isError={Boolean(errors.ciphertext)}
>
<TextArea
{...register("ciphertext")}
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
/>
</FormControl>
)}
<Switch
id="decode-base-64"
isChecked={shouldDecode}
onCheckedChange={setShouldDecode}
containerClassName="mb-6 ml-0.5 -mt-2.5"
>
Decode Base64{" "}
<Tooltip content="Toggle this switch on if your data was originally plain text.">
<FontAwesomeIcon icon={faInfoCircle} className=" text-mineshaft-400" />
</Tooltip>
</Switch>
<div className="flex items-center">
<Button
className={`mr-4 ${plaintext ? "w-44" : ""}`}
size="sm"
leftIcon={
// eslint-disable-next-line no-nested-ternary
plaintext ? (
isCopyingCiphertext ? (
<FontAwesomeIcon icon={faCheckCircle} />
) : (
<FontAwesomeIcon icon={faCopy} />
)
) : (
<FontAwesomeIcon icon={faLockOpen} />
)
}
onClick={plaintext ? handleCopyToClipboard : undefined}
type={plaintext ? "button" : "submit"}
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{plaintext ? copyCiphertext : "Decrypt"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
{plaintext ? "Close" : "Cancel"}
</Button>
</ModalClose>
</div>
</form>
);
};
export const CmekDecryptModal = ({ isOpen, onOpenChange, cmek }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
subTitle={
<>
Decrypt ciphertext using <span className="font-bold">{cmek?.name}</span>. Returns Base64
encoded plaintext.
</>
}
title="Decrypt Data"
>
<DecryptForm cmek={cmek} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,169 @@
import { Controller, useForm } from "react-hook-form";
import { faCheckCircle, faCopy, faInfoCircle, faLock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Modal,
ModalClose,
ModalContent,
Switch,
TextArea,
Tooltip
} from "@app/components/v2";
import { useTimedReset } from "@app/hooks";
import { TCmek, useCmekEncrypt } from "@app/hooks/api/cmeks";
const formSchema = z.object({
plaintext: z.string(),
isBase64Encoded: z.boolean()
});
export type FormData = z.infer<typeof formSchema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
cmek: TCmek;
};
type FormProps = Pick<Props, "cmek">;
const EncryptForm = ({ cmek }: FormProps) => {
const cmekEncrypt = useCmekEncrypt();
const {
handleSubmit,
register,
control,
formState: { isSubmitting, errors }
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
isBase64Encoded: false
}
});
const [copyCiphertext, isCopyingCiphertext, setCopyCipherText] = useTimedReset<string>({
initialState: "Copy to Clipboard"
});
const handleEncryptData = async (formData: FormData) => {
try {
await cmekEncrypt.mutateAsync({ ...formData, keyId: cmek.id });
createNotification({
text: "Successfully encrypted data",
type: "success"
});
} catch (err) {
console.error(err);
createNotification({
text: "Failed to encrypt data",
type: "error"
});
}
};
const ciphertext = cmekEncrypt.data?.ciphertext;
const handleCopyToClipboard = () => {
navigator.clipboard.writeText(ciphertext ?? "");
setCopyCipherText("Copied to Clipboard");
};
return (
<form onSubmit={handleSubmit(handleEncryptData)}>
{ciphertext ? (
<FormControl label="Encrypted Data (Ciphertext)">
<TextArea
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
isDisabled
value={cmekEncrypt.data?.ciphertext}
/>
</FormControl>
) : (
<>
<FormControl
label="Data (Plaintext)"
errorText={errors.plaintext?.message}
isError={Boolean(errors.plaintext)}
>
<TextArea
{...register("plaintext")}
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
/>
</FormControl>
<Controller
control={control}
name="isBase64Encoded"
render={({ field: { onChange, value } }) => (
<Switch
id="encode-base-64"
isChecked={value}
onCheckedChange={onChange}
containerClassName="mb-6 ml-0.5 -mt-2.5"
>
Data is Base64 encoded{" "}
<Tooltip content="Toggle this switch on if your data is already Base64 encoded to avoid redundant encoding.">
<FontAwesomeIcon icon={faInfoCircle} className=" text-mineshaft-400" />
</Tooltip>
</Switch>
)}
/>
</>
)}
<div className="flex items-center">
<Button
className={`mr-4 ${ciphertext ? "w-44" : ""}`}
size="sm"
leftIcon={
// eslint-disable-next-line no-nested-ternary
ciphertext ? (
isCopyingCiphertext ? (
<FontAwesomeIcon icon={faCheckCircle} />
) : (
<FontAwesomeIcon icon={faCopy} />
)
) : (
<FontAwesomeIcon icon={faLock} />
)
}
onClick={ciphertext ? handleCopyToClipboard : undefined}
type={ciphertext ? "button" : "submit"}
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{ciphertext ? copyCiphertext : "Encrypt"}
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
{ciphertext ? "Close" : "Cancel"}
</Button>
</ModalClose>
</div>
</form>
);
};
export const CmekEncryptModal = ({ isOpen, onOpenChange, cmek }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Encrypt Data"
subTitle={
<>
Encrypt data using <span className="font-bold">{cmek?.name}</span>. Returns Base64
encoded ciphertext.
</>
}
>
<EncryptForm cmek={cmek} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,158 @@
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import slugify from "@sindresorhus/slugify";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FormControl,
Input,
Modal,
ModalClose,
ModalContent,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { EncryptionAlgorithm, TCmek, useCreateCmek, useUpdateCmek } from "@app/hooks/api/cmeks";
const formSchema = z.object({
name: z
.string()
.min(1)
.toLowerCase()
.max(32)
.refine((v) => slugify(v) === v, {
message: "Name must be in slug format"
}),
description: z.string().max(500).optional(),
encryptionAlgorithm: z.nativeEnum(EncryptionAlgorithm)
});
export type FormData = z.infer<typeof formSchema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
cmek?: TCmek | null;
};
type FormProps = Pick<Props, "cmek"> & {
onComplete: () => void;
};
const CmekForm = ({ onComplete, cmek }: FormProps) => {
const createCmek = useCreateCmek();
const updateCmek = useUpdateCmek();
const { currentWorkspace } = useWorkspace();
const projectId = currentWorkspace?.id!;
const isUpdate = !!cmek;
const {
control,
handleSubmit,
register,
formState: { isSubmitting, errors }
} = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: cmek?.name,
description: cmek?.description,
encryptionAlgorithm: EncryptionAlgorithm.AES_GCM_256
}
});
const handleCreateCmek = async ({ encryptionAlgorithm, name, description }: FormData) => {
const mutation = isUpdate
? updateCmek.mutateAsync({ keyId: cmek.id, projectId, name, description })
: createCmek.mutateAsync({
projectId,
encryptionAlgorithm,
name,
description
});
try {
await mutation;
createNotification({
text: `Successfully ${isUpdate ? "updated" : "added"} key`,
type: "success"
});
onComplete();
} catch (err) {
console.error(err);
createNotification({
text: `Failed to ${isUpdate ? "update" : "add"} key`,
type: "error"
});
}
};
return (
<form onSubmit={handleSubmit(handleCreateCmek)}>
<FormControl
helperText="Name must be slug-friendly"
errorText={errors.name?.message}
isError={Boolean(errors.name?.message)}
label="Name"
>
<Input autoFocus placeholder="my-secret-key" {...register("name")} />
</FormControl>
{!isUpdate && (
<Controller
control={control}
name="encryptionAlgorithm"
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
<FormControl label="Algorithm" errorText={error?.message} isError={Boolean(error)}>
<Select defaultValue={field.value} onValueChange={onChange} className="w-full">
{Object.entries(EncryptionAlgorithm)?.map(([key, value]) => (
<SelectItem value={value} key={`source-environment-${key}`}>
{key.replaceAll("_", "-")}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
)}
<FormControl
label="Description (optional)"
errorText={errors.description?.message}
isError={Boolean(errors.description?.message)}
>
<TextArea
className="max-h-[20rem] min-h-[10rem] min-w-full max-w-full"
{...register("description")}
/>
</FormControl>
<div className="flex items-center">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting}
isDisabled={isSubmitting}
>
{isUpdate ? "Update" : "Add"} Key
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</form>
);
};
export const CmekModal = ({ isOpen, onOpenChange, cmek }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent title={`${cmek ? "Update" : "Add"} Key`}>
<CmekForm onComplete={() => onOpenChange(false)} cmek={cmek} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,427 @@
import { useEffect } from "react";
import Link from "next/link";
import {
faArrowDown,
faArrowUp,
faArrowUpRightFromSquare,
faCancel,
faCheckCircle,
faEdit,
faEllipsis,
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,
useProjectPermission,
useWorkspace
} from "@app/context";
import { usePagination, usePopUp } from "@app/hooks";
import { useGetCmeksByProjectId, useUpdateCmek } from "@app/hooks/api/cmeks";
import { CmekOrderBy, 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 { 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 { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission();
const projectId = currentWorkspace?.id ?? "";
const {
offset,
limit,
orderBy,
orderDirection,
setOrderDirection,
search,
debouncedSearch,
setPage,
setSearch,
perPage,
page,
setPerPage
} = usePagination(CmekOrderBy.Name);
const { data, isLoading, isFetching } = useGetCmeksByProjectId({
projectId,
offset,
limit,
search: debouncedSearch,
orderBy,
orderDirection
});
const { keys = [], totalCount = 0 } = data ?? {};
useEffect(() => {
// reset page if no longer valid
if (totalCount <= offset) setPage(1);
}, [totalCount]);
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp([
"upsertKey",
"deleteKey",
"encryptData",
"decryptData"
] 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
);
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="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<p className="whitespace-nowrap text-xl font-semibold text-mineshaft-100">Keys</p>
<div className="flex w-full justify-end pr-4">
<Link href="https://infisical.com/docs/documentation/platform/kms/infisical-kms">
<span className="w-max cursor-pointer rounded-md border border-mineshaft-500 bg-mineshaft-600 px-4 py-2 text-mineshaft-200 duration-200 hover:border-primary/40 hover:bg-primary/10 hover:text-white">
Documentation{" "}
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.06rem] ml-1 text-xs"
/>
</span>
</Link>
</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 className="w-1/3">
<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>Algorithm</Th>
<Th>Status</Th>
<Th>Version</Th>
<Th className="w-16">{isFetching ? <Spinner size="xs" /> : null}</Th>
</Tr>
</THead>
<TBody>
{isLoading && <TableSkeleton columns={4} innerKey="project-keys" />}
{!isLoading &&
keys.length > 0 &&
keys.map((cmek) => {
const { name, id, version, description, encryptionAlgorithm, isDisabled } = cmek;
const { variant, label } = getStatusBadgeProps(isDisabled);
return (
<Tr className="group h-10 hover:bg-mineshaft-700" key={`st-v3-${id}`}>
<Td>
<div className="flex items-center gap-2">
{name}
{description && (
<Tooltip content={description}>
<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 className="flex justify-end">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
variant="plain"
colorSchema="primary"
className="ml-4 p-0 data-[state=open]:text-primary-400"
ariaLabel="More options"
>
<FontAwesomeIcon size="lg" icon={faEllipsis} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-[160px]">
<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>
<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>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isLoading && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={(newPerPage) => setPerPage(newPerPage)}
/>
)}
{!isLoading && 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}
/>
</div>
</motion.div>
);
};
@@ -0,0 +1,52 @@
import { createNotification } from "@app/components/notifications";
import { DeleteActionModal } from "@app/components/v2";
import { TCmek, useDeleteCmek } from "@app/hooks/api/cmeks";
type Props = {
cmek: TCmek;
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
export const DeleteCmekModal = ({ isOpen, onOpenChange, cmek }: Props) => {
const deleteCmek = useDeleteCmek();
if (!cmek) return null;
const { id: keyId, projectId, name } = cmek;
const handleDeleteCmek = async () => {
try {
await deleteCmek.mutateAsync({
keyId,
projectId
});
createNotification({
text: "Key successfully deleted",
type: "success"
});
onOpenChange(false);
} catch (err) {
console.error(err);
const error = err as any;
const text = error?.response?.data?.message ?? "Failed to delete key";
createNotification({
text,
type: "error"
});
}
};
return (
<DeleteActionModal
isOpen={isOpen}
title={`Are you sure want to delete ${name}?`}
onChange={onOpenChange}
deleteKey="confirm"
onDeleteApproved={handleDeleteCmek}
/>
);
};
@@ -0,0 +1 @@
export * from "./CmekTable";
@@ -0,0 +1,24 @@
import { ProjectPermissionCmekActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { CmekTable } from "./components";
export const KmsPage = withProjectPermission(
() => {
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl py-6 px-6">
<p className="mr-4 text-3xl font-semibold text-white">Key Management System</p>
<p className="text-md mb-4 text-bunker-300">
Manage keys and perform cryptographic operations.
</p>
<CmekTable />
</div>
</div>
);
},
{
action: ProjectPermissionCmekActions.Read,
subject: ProjectPermissionSub.Cmek
}
);
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect } from "react";
import Link from "next/link";
import {
faArrowDown,
@@ -44,7 +44,7 @@ import {
} from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
import { withProjectPermission } from "@app/hoc";
import { useDebounce } from "@app/hooks";
import { usePagination } from "@app/hooks";
import { useDeleteIdentityFromWorkspace, useGetWorkspaceIdentityMemberships } from "@app/hooks/api";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { IdentityMembership } from "@app/hooks/api/identities/types";
@@ -56,7 +56,7 @@ import { IdentityModal } from "./components/IdentityModal";
import { IdentityRoleForm } from "./components/IdentityRoleForm";
const MAX_ROLES_TO_BE_SHOWN_IN_TABLE = 2;
const INIT_PER_PAGE = 20;
const formatRoleName = (role: string, customRoleName?: string) => {
if (role === ProjectMembershipRole.Custom) return customRoleName;
if (role === ProjectMembershipRole.Member) return "Developer";
@@ -67,27 +67,43 @@ export const IdentityTab = withProjectPermission(
() => {
const { currentWorkspace } = useWorkspace();
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
const [orderDirection, setOrderDirection] = useState(OrderByDirection.ASC);
const [orderBy, setOrderBy] = useState(ProjectIdentityOrderBy.Name);
const [search, setSearch] = useState("");
const [debouncedSearch] = useDebounce(search);
const {
offset,
limit,
orderBy,
setOrderBy,
orderDirection,
setOrderDirection,
search,
debouncedSearch,
setPage,
setSearch,
perPage,
page,
setPerPage
} = usePagination(ProjectIdentityOrderBy.Name);
const workspaceId = currentWorkspace?.id ?? "";
const offset = (page - 1) * perPage;
const { data, isLoading, isFetching } = useGetWorkspaceIdentityMemberships(
{
workspaceId: currentWorkspace?.id || "",
offset,
limit: perPage,
limit,
orderDirection,
orderBy,
search: debouncedSearch
},
{ keepPreviousData: true }
);
const { totalCount = 0 } = data ?? {};
useEffect(() => {
// reset page if no longer valid
if (totalCount <= offset) setPage(1);
}, [totalCount]);
const { mutateAsync: deleteMutateAsync } = useDeleteIdentityFromWorkspace();
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
@@ -122,11 +138,6 @@ export const IdentityTab = withProjectPermission(
}
};
useEffect(() => {
// reset page if no longer valid
if (data && data.totalCount < offset) setPage(1);
}, [data?.totalCount]);
const handleSort = (column: ProjectIdentityOrderBy) => {
if (column === orderBy) {
setOrderDirection((prev) =>
@@ -369,9 +380,9 @@ export const IdentityTab = withProjectPermission(
})}
</TBody>
</Table>
{!isLoading && data && data.totalCount > 0 && (
{!isLoading && data && totalCount > 0 && (
<Pagination
count={data.totalCount}
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
@@ -1,6 +1,10 @@
import { z } from "zod";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import {
ProjectPermissionActions,
ProjectPermissionCmekActions,
ProjectPermissionSub
} from "@app/context";
import {
PermissionConditionOperators,
TPermissionCondition,
@@ -15,6 +19,15 @@ const GeneralPolicyActionSchema = z.object({
create: z.boolean().optional()
});
const CmekPolicyActionSchema = z.object({
read: z.boolean().optional(),
edit: z.boolean().optional(),
delete: z.boolean().optional(),
create: z.boolean().optional(),
encrypt: z.boolean().optional(),
decrypt: z.boolean().optional()
});
const SecretFolderPolicyActionSchema = z.object({
read: z.boolean().optional()
});
@@ -88,7 +101,8 @@ export const formSchema = z.object({
[ProjectPermissionSub.Workspace]: WorkspacePolicyActionSchema.array().default([]),
[ProjectPermissionSub.Tags]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.SecretRotation]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([])
[ProjectPermissionSub.Kms]: GeneralPolicyActionSchema.array().default([]),
[ProjectPermissionSub.Cmek]: CmekPolicyActionSchema.array().default([])
})
.partial()
.optional()
@@ -119,7 +133,7 @@ const convertCaslConditionToFormOperator = (caslConditions: TPermissionCondition
return formConditions;
};
// convert role permission to form compatiable data structure
// convert role permission to form compatible data structure
export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
const formVal: Partial<TFormSchema["permissions"]> = {};
@@ -198,6 +212,23 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
// from above statement we are sure it won't be undefined
if (canRead) formVal[subject as ProjectPermissionSub.Member]![0].read = true;
} else if (subject === ProjectPermissionSub.Cmek) {
const canRead = action.includes(ProjectPermissionCmekActions.Read);
const canEdit = action.includes(ProjectPermissionCmekActions.Edit);
const canDelete = action.includes(ProjectPermissionCmekActions.Delete);
const canCreate = action.includes(ProjectPermissionCmekActions.Create);
const canEncrypt = action.includes(ProjectPermissionCmekActions.Encrypt);
const canDecrypt = action.includes(ProjectPermissionCmekActions.Decrypt);
if (!formVal[subject]) formVal[subject] = [{}];
// from above statement we are sure it won't be undefined
if (canRead) formVal[subject]![0].read = true;
if (canEdit) formVal[subject]![0].edit = true;
if (canCreate) formVal[subject]![0].create = true;
if (canDelete) formVal[subject]![0].delete = true;
if (canEncrypt) formVal[subject]![0].encrypt = true;
if (canDecrypt) formVal[subject]![0].decrypt = true;
}
});
return formVal;
@@ -277,8 +308,19 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
title: "Secret Folders",
actions: [{ label: "Read Only", value: "read" }]
},
[ProjectPermissionSub.Kms]: {
[ProjectPermissionSub.Cmek]: {
title: "KMS",
actions: [
{ label: "Read", value: "read" },
{ label: "Create", value: "create" },
{ label: "Modify", value: "edit" },
{ label: "Remove", value: "delete" },
{ label: "Encrypt", value: "encrypt" },
{ label: "Decrypt", value: "decrypt" }
]
},
[ProjectPermissionSub.Kms]: {
title: "Project KMS Configuration",
actions: [{ label: "Modify", value: "edit" }]
},
[ProjectPermissionSub.Integrations]: {
@@ -16,7 +16,7 @@ import {
useProjectPermission,
useWorkspace
} from "@app/context";
import { useDebounce, usePopUp } from "@app/hooks";
import { useDebounce, usePagination, usePopUp } from "@app/hooks";
import {
useGetImportedSecretsSingleEnv,
useGetSecretApprovalPolicyOfABoard,
@@ -25,6 +25,7 @@ import {
useGetWsTags
} from "@app/hooks/api";
import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard";
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { DynamicSecretListView } from "@app/views/SecretMainPage/components/DynamicSecretListView";
import { FolderListView } from "@app/views/SecretMainPage/components/FolderListView";
@@ -47,7 +48,6 @@ const LOADER_TEXT = [
"Getting secret import links..."
];
const INIT_PER_PAGE = 20;
export const SecretMainPage = () => {
const { t } = useTranslation();
const { currentWorkspace, isLoading: isWorkspaceLoading } = useWorkspace();
@@ -55,11 +55,18 @@ export const SecretMainPage = () => {
const { permission } = useProjectPermission();
const [isVisible, setIsVisible] = useState(false);
const [orderDirection, setOrderDirection] = useState<OrderByDirection>(OrderByDirection.ASC);
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
const paginationOffset = (page - 1) * perPage;
const {
offset,
limit,
orderDirection,
setOrderDirection,
setPage,
perPage,
page,
setPerPage,
orderBy
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name);
const [snapshotId, setSnapshotId] = useState<string | null>(null);
const isRollbackMode = Boolean(snapshotId);
@@ -129,8 +136,9 @@ export const SecretMainPage = () => {
environment,
projectId: workspaceId,
secretPath,
offset: paginationOffset,
limit: perPage,
offset,
limit,
orderBy,
search: debouncedSearchFilter,
orderDirection,
includeImports: canReadSecret && filter.include.import,
@@ -152,6 +160,11 @@ export const SecretMainPage = () => {
totalCount = 0
} = data ?? {};
useEffect(() => {
// reset page if no longer valid
if (totalCount <= offset) setPage(1);
}, [totalCount]);
// fetch imported secrets to show user the overriden ones
const { data: importedSecrets } = useGetImportedSecretsSingleEnv({
projectId: workspaceId,
@@ -253,11 +266,6 @@ export const SecretMainPage = () => {
handlePopUpClose("snapshots");
}, []);
useEffect(() => {
// reset page if no longer valid
if (totalCount < paginationOffset) setPage(1);
}, [totalCount]);
useEffect(() => {
// restore filters for path if set
const restore = filterHistory.get(secretPath);
@@ -54,7 +54,7 @@ import {
useProjectPermission,
useWorkspace
} from "@app/context";
import { useDebounce, usePopUp } from "@app/hooks";
import { useDebounce, usePagination, usePopUp } from "@app/hooks";
import {
useCreateFolder,
useCreateSecretV3,
@@ -95,7 +95,6 @@ enum RowType {
type Filter = {
[key in RowType]: boolean;
};
const INIT_PER_PAGE = 20;
const DEFAULT_FILTER_STATE = {
[RowType.Folder]: true,
@@ -111,7 +110,6 @@ export const SecretOverviewPage = () => {
// coz when overflow the table goes to the right
const parentTableRef = useRef<HTMLTableElement>(null);
const [expandableTableWidth, setExpandableTableWidth] = useState(0);
const [orderDirection, setOrderDirection] = useState<OrderByDirection>(OrderByDirection.ASC);
const { permission } = useProjectPermission();
useEffect(() => {
@@ -142,8 +140,17 @@ export const SecretOverviewPage = () => {
[EntryType.SECRET]: {}
});
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(INIT_PER_PAGE);
const {
offset,
limit,
orderDirection,
setOrderDirection,
setPage,
perPage,
page,
setPerPage,
orderBy
} = usePagination<DashboardSecretsOrderBy>(DashboardSecretsOrderBy.Name);
const toggleSelectedEntry = useCallback(
(type: EntryType, key: string) => {
@@ -208,21 +215,19 @@ export const SecretOverviewPage = () => {
environments: userAvailableEnvs.map(({ slug }) => slug)
});
const paginationOffset = (page - 1) * perPage;
const { isLoading: isOverviewLoading, data: overview } = useGetProjectSecretsOverview(
{
projectId: workspaceId,
environments: visibleEnvs.map((env) => env.slug),
secretPath,
orderDirection,
orderBy: DashboardSecretsOrderBy.Name,
orderBy,
includeFolders: filter.folder,
includeDynamicSecrets: filter.dynamic,
includeSecrets: filter.secret,
search: debouncedSearchFilter,
limit: perPage,
offset: paginationOffset
limit,
offset
},
{ enabled: isProjectV3 }
);
@@ -231,12 +236,17 @@ export const SecretOverviewPage = () => {
secrets,
folders,
dynamicSecrets,
totalCount = 0,
totalFolderCount,
totalSecretCount,
totalDynamicSecretCount
totalDynamicSecretCount,
totalCount = 0
} = overview ?? {};
useEffect(() => {
// reset page if no longer valid
if (totalCount <= offset) setPage(1);
}, [totalCount]);
const { folderNames, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders);
const { dynamicSecretNames, isDynamicSecretPresentInEnv } =
@@ -530,11 +540,6 @@ export const SecretOverviewPage = () => {
}
};
useEffect(() => {
// reset page if no longer valid
if (totalCount < paginationOffset) setPage(1);
}, [totalCount]);
const handleToggleRowType = useCallback(
(rowType: RowType) =>
setFilter((state) => {
@@ -61,7 +61,7 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
} = useForm<AddExternalKmsType>({
resolver: zodResolver(AddExternalKmsSchema),
defaultValues: {
slug: kms?.slug,
name: kms?.name,
description: kms?.description ?? "",
provider: {
type: ExternalKmsProvider.AWS,
@@ -89,12 +89,12 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
const selectedAwsAuthType = watch("provider.inputs.credential.type");
const handleAddAwsKms = async (data: AddExternalKmsType) => {
const { slug, description, provider } = data;
const { name, description, provider } = data;
try {
if (kms) {
await updateAwsExternalKms({
kmsId: kms.id,
slug,
name,
description,
provider
});
@@ -105,7 +105,7 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
});
} else {
await addAwsExternalKms({
slug,
name,
description,
provider
});
@@ -126,7 +126,7 @@ export const AwsKmsForm = ({ onCompleted, onCancel, kms }: Props) => {
<form onSubmit={handleSubmit(handleAddAwsKms)} autoComplete="off">
<Controller
control={control}
name="slug"
name="name"
render={({ field, fieldState: { error } }) => (
<FormControl label="Alias" errorText={error?.message} isError={Boolean(error)}>
<Input placeholder="" {...field} />
@@ -122,7 +122,7 @@ export const OrgEncryptionTab = withPermission(
)}
<div className="ml-2">{kms.externalKms.provider.toUpperCase()}</div>
</Td>
<Td>{kms.slug}</Td>
<Td>{kms.name}</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="rounded-lg">
@@ -170,7 +170,7 @@ export const OrgEncryptionTab = withPermission(
onClick={(e) => {
e.stopPropagation();
handlePopUpOpen("removeExternalKms", {
slug: kms.slug,
name: kms.name,
kmsId: kms.id,
provider: kms.externalKms.provider
});
@@ -307,7 +307,7 @@ export const EncryptionTab = () => {
</SelectItem>
{externalKmsList?.map((kms) => (
<SelectItem value={kms.id} key={`kms-${kms.id}`}>
{kms.slug}
{kms.name}
</SelectItem>
))}
</Select>