mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-18 04:58:28 +00:00
1264 lines
43 KiB
TypeScript
1264 lines
43 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { FormProvider, useFieldArray, useForm } from "react-hook-form";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useRouter } from "next/router";
|
|
import { subject } from "@casl/ability";
|
|
import {
|
|
closestCenter,
|
|
DndContext,
|
|
DragEndEvent,
|
|
KeyboardSensor,
|
|
MouseSensor,
|
|
TouchSensor,
|
|
useSensor,
|
|
useSensors
|
|
} from "@dnd-kit/core";
|
|
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
|
import { arrayMove } from "@dnd-kit/sortable";
|
|
import {
|
|
faAngleDown,
|
|
faArrowLeft,
|
|
faCheck,
|
|
faClockRotateLeft,
|
|
faCodeCommit,
|
|
faDownload,
|
|
faEye,
|
|
faEyeSlash,
|
|
faFileImport,
|
|
faFolderPlus,
|
|
faMagnifyingGlass,
|
|
faPlus
|
|
} from "@fortawesome/free-solid-svg-icons";
|
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
|
import { yupResolver } from "@hookform/resolvers/yup";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuTrigger
|
|
} from "@radix-ui/react-dropdown-menu";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
|
|
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
|
import NavHeader from "@app/components/navigation/NavHeader";
|
|
import { PermissionDeniedBanner, ProjectPermissionCan } from "@app/components/permissions";
|
|
import {
|
|
Button,
|
|
DeleteActionModal,
|
|
IconButton,
|
|
Input,
|
|
Modal,
|
|
ModalContent,
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
TableContainer,
|
|
Tag,
|
|
Tooltip,
|
|
UpgradePlanModal
|
|
} from "@app/components/v2";
|
|
import { leaveConfirmDefaultMessage } from "@app/const";
|
|
import {
|
|
ProjectPermissionActions,
|
|
ProjectPermissionSub,
|
|
useOrganization,
|
|
useProjectPermission,
|
|
useSubscription,
|
|
useWorkspace
|
|
} from "@app/context";
|
|
import { useLeaveConfirm, usePopUp, useToggle } from "@app/hooks";
|
|
import {
|
|
useBatchSecretsOp,
|
|
useCreateFolder,
|
|
useCreateSecretImport,
|
|
useCreateWsTag,
|
|
useDeleteFolder,
|
|
useDeleteSecretImport,
|
|
useGetImportedSecrets,
|
|
useGetProjectFolders,
|
|
useGetProjectSecrets,
|
|
useGetSecretImports,
|
|
useGetSecretVersion,
|
|
useGetSnapshotSecrets,
|
|
useGetUserAction,
|
|
useGetUserWsKey,
|
|
useGetWorkspaceSecretSnapshots,
|
|
useGetWsSnapshotCount,
|
|
useGetWsTags,
|
|
usePerformSecretRollback,
|
|
useRegisterUserAction,
|
|
useUpdateFolder,
|
|
useUpdateSecretImport
|
|
} from "@app/hooks/api";
|
|
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
|
|
|
import { CompareSecret } from "./components/CompareSecret";
|
|
import { CreateTagModal } from "./components/CreateTagModal";
|
|
import {
|
|
FolderForm,
|
|
FolderSection,
|
|
TDeleteFolderForm,
|
|
TEditFolderForm
|
|
} from "./components/FolderSection";
|
|
import { PitDrawer } from "./components/PitDrawer";
|
|
import { SecretDetailDrawer } from "./components/SecretDetailDrawer";
|
|
import { SecretDropzone } from "./components/SecretDropzone";
|
|
import { SecretImportForm } from "./components/SecretImportForm";
|
|
import { SecretImportSection } from "./components/SecretImportSection";
|
|
import { SecretInputRow } from "./components/SecretInputRow";
|
|
import { SecretTableHeader } from "./components/SecretTableHeader";
|
|
import {
|
|
DEFAULT_SECRET_VALUE,
|
|
downloadSecret,
|
|
FormData,
|
|
schema,
|
|
transformSecretsToBatchSecretReq,
|
|
TSecOverwriteOpt,
|
|
TSecretDetailsOpen
|
|
} from "./DashboardPage.utils";
|
|
|
|
const USER_ACTION_PUSH = "first_time_secrets_pushed";
|
|
type TDeleteSecretImport = { environment: string; secretPath: string };
|
|
/*
|
|
* Some imp aspects to consider. Here there are multiple stats changing
|
|
* Thus ideally we need to use a context. But instead we rely on react hook form
|
|
* React hook form provides context and high performance proxy based rendering
|
|
* It also handles error handling and transferring states between inputs
|
|
*
|
|
* Another thing is the purpose of overrideAction
|
|
* Before we would remove the value for personal secret when user toggle and user couldn't get it back
|
|
* They have to reload the browser or go back all over again
|
|
* Instead when user delete we raise a flag so if user decides to go back to toggle personal before saving
|
|
* They will get it back
|
|
*/
|
|
export const DashboardPage = () => {
|
|
const { subscription } = useSubscription();
|
|
const { t } = useTranslation();
|
|
const router = useRouter();
|
|
const { createNotification } = useNotificationContext();
|
|
const queryClient = useQueryClient();
|
|
const environment = router.query.env as string;
|
|
const permission = useProjectPermission();
|
|
|
|
const secretContainer = useRef<HTMLDivElement | null>(null);
|
|
const { popUp, handlePopUpOpen, handlePopUpToggle, handlePopUpClose } = usePopUp([
|
|
"secretDetails",
|
|
"addTag",
|
|
"secretSnapshots",
|
|
"uploadedSecOpts",
|
|
"compareSecrets",
|
|
"folderForm",
|
|
"deleteFolder",
|
|
"upgradePlan",
|
|
"addSecretImport",
|
|
"deleteSecretImport"
|
|
] as const);
|
|
const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true);
|
|
const [searchFilter, setSearchFilter] = useState("");
|
|
const [snapshotId, setSnaphotId] = useState<string | null>(null);
|
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
|
const deletedSecretIds = useRef<{ id: string; secretName: string }[]>([]);
|
|
const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false });
|
|
|
|
const folderId = router.query.folderId as string;
|
|
const isRollbackMode = Boolean(snapshotId);
|
|
|
|
const { currentWorkspace, isLoading } = useWorkspace();
|
|
const { currentOrg } = useOrganization();
|
|
const workspaceId = currentWorkspace?._id as string;
|
|
|
|
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
|
|
|
|
useEffect(() => {
|
|
if (!isLoading && !workspaceId && router.isReady) {
|
|
router.push(`/org/${currentOrg?._id}/overview`);
|
|
}
|
|
}, [isLoading, workspaceId, router.isReady]);
|
|
|
|
// fetching data
|
|
const { data: userAction } = useGetUserAction(USER_ACTION_PUSH);
|
|
const hasUserPushed = Boolean(userAction);
|
|
|
|
const { data: secretVersion } = useGetSecretVersion({
|
|
limit: 10,
|
|
offset: 0,
|
|
secretId: (popUp?.secretDetails?.data as TSecretDetailsOpen)?.id,
|
|
decryptFileKey: latestFileKey!
|
|
});
|
|
|
|
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
|
workspaceId,
|
|
env: environment,
|
|
decryptFileKey: latestFileKey!,
|
|
isPaused: Boolean(snapshotId),
|
|
folderId
|
|
});
|
|
|
|
const { data: folderData, isLoading: isFoldersLoading } = useGetProjectFolders({
|
|
workspaceId: workspaceId || "",
|
|
environment,
|
|
parentFolderId: folderId,
|
|
isPaused: isRollbackMode,
|
|
sortDir
|
|
});
|
|
|
|
const {
|
|
data: secretSnaphots,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetchingNextPage
|
|
} = useGetWorkspaceSecretSnapshots({
|
|
workspaceId,
|
|
environment,
|
|
folder: folderId,
|
|
limit: 10
|
|
});
|
|
|
|
const {
|
|
data: snapshotSecret,
|
|
isLoading: isSnapshotSecretsLoading,
|
|
isFetching: isSnapshotChanging
|
|
} = useGetSnapshotSecrets({
|
|
snapshotId: snapshotId || "",
|
|
env: environment,
|
|
decryptFileKey: latestFileKey!
|
|
});
|
|
|
|
const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = useGetWsSnapshotCount(
|
|
workspaceId,
|
|
environment,
|
|
folderId
|
|
);
|
|
|
|
const { data: wsTags } = useGetWsTags(workspaceId);
|
|
|
|
// mutation calls
|
|
const { mutateAsync: batchSecretOp } = useBatchSecretsOp();
|
|
const { mutateAsync: performSecretRollback } = usePerformSecretRollback();
|
|
const { mutateAsync: registerUserAction } = useRegisterUserAction();
|
|
const { mutateAsync: createWsTag } = useCreateWsTag();
|
|
const { mutateAsync: createFolder } = useCreateFolder();
|
|
const { mutateAsync: updateFolder } = useUpdateFolder(folderId);
|
|
const { mutateAsync: deleteFolder } = useDeleteFolder(folderId);
|
|
|
|
const { data: secretImportCfg, isFetching: isSecretImportCfgFetching } = useGetSecretImports(
|
|
workspaceId,
|
|
environment,
|
|
folderId
|
|
);
|
|
|
|
const { data: importedSecrets } = useGetImportedSecrets({
|
|
workspaceId,
|
|
decryptFileKey: latestFileKey!,
|
|
environment,
|
|
folderId
|
|
});
|
|
|
|
const secretPath = `/${(folderData?.dir || [])
|
|
?.filter(({ name }) => name !== "root")
|
|
?.map(({ name }) => name)
|
|
.join("/")}`;
|
|
|
|
const userAvailableEnvs = currentWorkspace?.environments || [];
|
|
|
|
// This is for dnd-kit. As react-query state mutation async
|
|
// This will act as a placeholder to avoid a glitching animation on dropping items
|
|
const [items, setItems] = useState<
|
|
Array<{ environment: string; secretPath: string; id: string }>
|
|
>([]);
|
|
|
|
useEffect(() => {
|
|
if (
|
|
!isSecretImportCfgFetching ||
|
|
// case in which u go to a folder and come back to fill in with cache data
|
|
(items.length === 0 && secretImportCfg?.imports?.length !== 0 && isSecretImportCfgFetching)
|
|
) {
|
|
setItems(
|
|
secretImportCfg?.imports?.map((el) => ({
|
|
...el,
|
|
id: `${el.environment}-${el.secretPath}`
|
|
})) || []
|
|
);
|
|
}
|
|
}, [isSecretImportCfgFetching]);
|
|
|
|
const { mutateAsync: createSecretImport } = useCreateSecretImport();
|
|
const { mutate: updateSecretImportSync } = useUpdateSecretImport();
|
|
const { mutateAsync: deleteSecretImport } = useDeleteSecretImport();
|
|
|
|
const sensors = useSensors(
|
|
useSensor(MouseSensor, {}),
|
|
useSensor(TouchSensor, {}),
|
|
useSensor(KeyboardSensor, {})
|
|
);
|
|
|
|
const method = useForm<FormData>({
|
|
// why any: well yup inferred ts expects other keys to defined as undefined
|
|
defaultValues: secrets as any,
|
|
values: secrets as any,
|
|
mode: "onBlur",
|
|
resolver: yupResolver(schema)
|
|
});
|
|
|
|
const {
|
|
register,
|
|
control,
|
|
handleSubmit,
|
|
getValues,
|
|
setValue,
|
|
formState: { isSubmitting, isDirty, errors },
|
|
reset
|
|
} = method;
|
|
const { fields, prepend, append, remove } = useFieldArray({ control, name: "secrets" });
|
|
|
|
const isReadOnly = isFoldersLoading
|
|
? true
|
|
: permission.can(
|
|
ProjectPermissionActions.Read,
|
|
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
|
) &&
|
|
permission.cannot(
|
|
ProjectPermissionActions.Edit,
|
|
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
|
) &&
|
|
permission.cannot(
|
|
ProjectPermissionActions.Create,
|
|
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
|
) &&
|
|
permission.cannot(
|
|
ProjectPermissionActions.Delete,
|
|
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
|
);
|
|
|
|
const canDoRollback = !isReadOnly;
|
|
const isSubmitDisabled =
|
|
isReadOnly ||
|
|
// not in rollback mode and no form has changed
|
|
(!isRollbackMode && !isDirty) ||
|
|
// in rollback mode and don't have permission to do it
|
|
(isRollbackMode &&
|
|
permission.cannot(ProjectPermissionActions.Create, ProjectPermissionSub.SecretRollback)) ||
|
|
isSubmitting;
|
|
|
|
useEffect(() => {
|
|
if (!isSnapshotChanging && Boolean(snapshotId)) {
|
|
reset({ secrets: snapshotSecret?.secrets, isSnapshotMode: true });
|
|
}
|
|
}, [isSnapshotChanging]);
|
|
|
|
useEffect(() => {
|
|
setHasUnsavedChanges(!isSubmitDisabled);
|
|
}, [isSubmitDisabled]);
|
|
|
|
const onSortSecrets = () => {
|
|
const dir = sortDir === "asc" ? "desc" : "asc";
|
|
const sec = getValues("secrets") || [];
|
|
const sortedSec = sec.sort((a, b) =>
|
|
dir === "asc" ? a?.key?.localeCompare(b?.key || "") : b?.key?.localeCompare(a?.key || "")
|
|
);
|
|
setValue("secrets", sortedSec);
|
|
setSortDir(dir);
|
|
};
|
|
|
|
const handleUploadedEnv = (uploadedSec: TSecOverwriteOpt["secrets"]) => {
|
|
const sec = getValues("secrets") || [];
|
|
const conflictingSec = sec.filter(({ key }) => Boolean(uploadedSec?.[key]));
|
|
const conflictingSecIds = conflictingSec.reduce<Record<string, boolean>>(
|
|
(prev, curr) => ({
|
|
...prev,
|
|
[curr.key]: true
|
|
}),
|
|
{}
|
|
);
|
|
// filter to get all conflicting ones
|
|
const conflictingUploadedSec = { ...uploadedSec };
|
|
// append non conflicting ones
|
|
Object.keys(uploadedSec).forEach((key) => {
|
|
if (!conflictingSecIds?.[key]) {
|
|
delete conflictingUploadedSec[key];
|
|
sec.push({
|
|
...DEFAULT_SECRET_VALUE,
|
|
key,
|
|
value: uploadedSec[key].value,
|
|
comment: uploadedSec[key].comments.join(",")
|
|
});
|
|
}
|
|
});
|
|
setValue("secrets", sec, { shouldDirty: true });
|
|
if (conflictingSec.length > 0) {
|
|
handlePopUpOpen("uploadedSecOpts", { secrets: conflictingUploadedSec });
|
|
}
|
|
};
|
|
|
|
const onOverwriteSecrets = () => {
|
|
const sec = getValues("secrets") || [];
|
|
const uploadedSec = (popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets;
|
|
const data: Array<{ key: string; index: number }> = [];
|
|
sec.forEach(({ key }, index) => {
|
|
if (uploadedSec?.[key]) data.push({ key, index });
|
|
});
|
|
data.forEach(({ key, index }) => {
|
|
const { value, comments } = uploadedSec[key];
|
|
const comment = comments.join(", ");
|
|
sec[index] = {
|
|
...DEFAULT_SECRET_VALUE,
|
|
key,
|
|
value,
|
|
comment,
|
|
tags: sec[index].tags
|
|
};
|
|
});
|
|
setValue("secrets", sec, { shouldDirty: true });
|
|
handlePopUpClose("uploadedSecOpts");
|
|
};
|
|
|
|
const onSecretRollback = async () => {
|
|
if (!snapshotSecret?.version) {
|
|
createNotification({
|
|
text: "Failed to find secret version",
|
|
type: "success"
|
|
});
|
|
return;
|
|
}
|
|
try {
|
|
await performSecretRollback({
|
|
workspaceId,
|
|
version: snapshotSecret.version,
|
|
environment,
|
|
folderId
|
|
});
|
|
setValue("isSnapshotMode", false);
|
|
setSnaphotId(null);
|
|
queryClient.invalidateQueries(secretKeys.getProjectSecret(workspaceId, environment));
|
|
createNotification({
|
|
text: "Successfully rollback secrets",
|
|
type: "success"
|
|
});
|
|
} catch (error) {
|
|
console.log(error);
|
|
createNotification({
|
|
text: "Failed to rollback secrets",
|
|
type: "error"
|
|
});
|
|
}
|
|
};
|
|
|
|
const onAppendSecret = () => {
|
|
setSearchFilter("");
|
|
append(DEFAULT_SECRET_VALUE);
|
|
};
|
|
|
|
const onSaveSecret = async ({ secrets: userSec = [], isSnapshotMode }: FormData) => {
|
|
if (isSnapshotMode) {
|
|
await onSecretRollback();
|
|
return;
|
|
}
|
|
// just closing this if save is triggered from drawer
|
|
handlePopUpClose("secretDetails");
|
|
// encrypt and format the secrets to batch api format
|
|
// requests = [ {method:"", secret:""} ]
|
|
const batchedSecret = transformSecretsToBatchSecretReq(
|
|
deletedSecretIds.current,
|
|
latestFileKey,
|
|
userSec,
|
|
secrets?.secrets
|
|
);
|
|
// type check
|
|
if (batchedSecret.length === 0) {
|
|
reset();
|
|
return;
|
|
}
|
|
try {
|
|
await batchSecretOp({
|
|
requests: batchedSecret,
|
|
workspaceId,
|
|
folderId,
|
|
environment
|
|
});
|
|
createNotification({
|
|
text: "Successfully saved changes",
|
|
type: "success"
|
|
});
|
|
deletedSecretIds.current = [];
|
|
if (!hasUserPushed) {
|
|
await registerUserAction(USER_ACTION_PUSH);
|
|
}
|
|
} catch (error) {
|
|
console.log(error);
|
|
createNotification({
|
|
text: "Failed to save changes",
|
|
type: "error"
|
|
});
|
|
}
|
|
};
|
|
|
|
const onDrawerOpen = useCallback((id: string | undefined, index: number) => {
|
|
handlePopUpOpen("secretDetails", { id, index } as TSecretDetailsOpen);
|
|
}, []);
|
|
|
|
const onEnvChange = (slug: string) => {
|
|
if (hasUnsavedChanges) {
|
|
// eslint-disable-next-line no-alert
|
|
if (!window.confirm(leaveConfirmDefaultMessage)) return;
|
|
}
|
|
|
|
const query: Record<string, string> = { ...router.query, env: slug };
|
|
delete query.folderId;
|
|
router.push({
|
|
pathname: router.pathname,
|
|
query
|
|
});
|
|
};
|
|
|
|
const handleDownloadSecret = () => {
|
|
const secretsFromImport: { key: string; value: string; comment: string }[] = [];
|
|
importedSecrets?.forEach(({ secrets: impSec }) => {
|
|
impSec.forEach((el) => {
|
|
secretsFromImport.push({ key: el.key, value: el.value, comment: el.comment });
|
|
});
|
|
});
|
|
downloadSecret(getValues("secrets"), secretsFromImport, environment);
|
|
};
|
|
|
|
// record all deleted ids
|
|
// This will make final deletion easier
|
|
const onSecretDelete = useCallback(
|
|
(index: number, secretName: string, id?: string, overrideId?: string) => {
|
|
if (id)
|
|
deletedSecretIds.current.push({
|
|
id,
|
|
secretName
|
|
});
|
|
if (overrideId)
|
|
deletedSecretIds.current.push({
|
|
id: overrideId,
|
|
secretName
|
|
});
|
|
remove(index);
|
|
// just the case if this is called from drawer
|
|
handlePopUpClose("secretDetails");
|
|
},
|
|
[]
|
|
);
|
|
|
|
const onCreateWsTag = useCallback(
|
|
async (tagName: string, tagColor: string) => {
|
|
try {
|
|
await createWsTag({
|
|
workspaceID: workspaceId,
|
|
tagName,
|
|
tagColor,
|
|
tagSlug: tagName.replace(" ", "_")
|
|
});
|
|
handlePopUpClose("addTag");
|
|
createNotification({
|
|
text: "Successfully created a tag",
|
|
type: "success"
|
|
});
|
|
} catch (error) {
|
|
console.error(error);
|
|
createNotification({
|
|
text: "Failed to create a tag",
|
|
type: "error"
|
|
});
|
|
}
|
|
},
|
|
[workspaceId]
|
|
);
|
|
|
|
const handleFolderOpen = useCallback(
|
|
(id: string) => {
|
|
setSearchFilter("");
|
|
router.push({
|
|
pathname: router.pathname,
|
|
query: {
|
|
id: workspaceId,
|
|
env: environment,
|
|
folderId: id
|
|
}
|
|
});
|
|
},
|
|
[environment, workspaceId]
|
|
);
|
|
|
|
const isEditFolder = Boolean(popUp?.folderForm?.data);
|
|
|
|
// FOLDER SECTION
|
|
const handleFolderCreate = async (name: string) => {
|
|
try {
|
|
await createFolder({
|
|
workspaceId,
|
|
environment,
|
|
folderName: name,
|
|
parentFolderId: folderId
|
|
});
|
|
createNotification({
|
|
type: "success",
|
|
text: "Successfully created folder"
|
|
});
|
|
handlePopUpClose("folderForm");
|
|
} catch (error) {
|
|
console.error(error);
|
|
createNotification({
|
|
text: "Failed to create folder",
|
|
type: "error"
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleFolderUpdate = useCallback(
|
|
async (name: string) => {
|
|
const { id } = popUp?.folderForm?.data as TDeleteFolderForm;
|
|
try {
|
|
await updateFolder({
|
|
folderId: id,
|
|
workspaceId,
|
|
environment,
|
|
name
|
|
});
|
|
createNotification({
|
|
type: "success",
|
|
text: "Successfully updated folder"
|
|
});
|
|
handlePopUpClose("folderForm");
|
|
} catch (error) {
|
|
console.error(error);
|
|
createNotification({
|
|
text: "Failed to update folder",
|
|
type: "error"
|
|
});
|
|
}
|
|
},
|
|
[environment, (popUp?.folderForm?.data as TDeleteFolderForm)?.id]
|
|
);
|
|
|
|
const handleFolderDelete = useCallback(async () => {
|
|
const { id } = popUp?.deleteFolder?.data as TDeleteFolderForm;
|
|
try {
|
|
deleteFolder({
|
|
workspaceId,
|
|
environment,
|
|
folderId: id
|
|
});
|
|
createNotification({
|
|
type: "success",
|
|
text: "Successfully removed folder"
|
|
});
|
|
handlePopUpClose("deleteFolder");
|
|
} catch (error) {
|
|
console.error(error);
|
|
createNotification({
|
|
text: "Failed to remove folder",
|
|
type: "error"
|
|
});
|
|
}
|
|
}, [(popUp?.deleteFolder?.data as TDeleteFolderForm)?.id]);
|
|
|
|
// SECRET IMPORT SECTION
|
|
const handleSecretImportCreate = async (env: string, secPath: string) => {
|
|
try {
|
|
await createSecretImport({
|
|
workspaceId,
|
|
environment,
|
|
folderId,
|
|
secretImport: {
|
|
environment: env,
|
|
secretPath: secPath
|
|
}
|
|
});
|
|
createNotification({
|
|
type: "success",
|
|
text: "Successfully create secret link"
|
|
});
|
|
handlePopUpClose("addSecretImport");
|
|
} catch (err) {
|
|
console.error(err);
|
|
createNotification({
|
|
text: "Failed to create secret link",
|
|
type: "error"
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleSecretImportDelete = async () => {
|
|
const { environment: importEnv, secretPath: impSecPath } = popUp.deleteSecretImport
|
|
?.data as TDeleteSecretImport;
|
|
try {
|
|
if (secretImportCfg?._id) {
|
|
await deleteSecretImport({
|
|
workspaceId,
|
|
environment,
|
|
folderId,
|
|
id: secretImportCfg?._id,
|
|
secretImportEnv: importEnv,
|
|
secretImportPath: impSecPath
|
|
});
|
|
handlePopUpClose("deleteSecretImport");
|
|
createNotification({
|
|
type: "success",
|
|
text: "Successfully removed secret link"
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
createNotification({
|
|
text: "Failed to remove secret link",
|
|
type: "error"
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleDragEnd = (evt: DragEndEvent) => {
|
|
const { active, over } = evt;
|
|
if (over?.id && active.id !== over.id) {
|
|
const oldIndex = items.findIndex(({ id }) => id === active.id);
|
|
const newIndex = items.findIndex(({ id }) => id === over.id);
|
|
const newImportOrder = arrayMove(items, oldIndex, newIndex);
|
|
setItems(newImportOrder);
|
|
updateSecretImportSync({
|
|
workspaceId,
|
|
environment,
|
|
folderId,
|
|
id: secretImportCfg?._id || "",
|
|
secretImports: newImportOrder.map((el) => ({
|
|
environment: el.environment,
|
|
secretPath: el.secretPath
|
|
}))
|
|
});
|
|
}
|
|
};
|
|
|
|
// OPTIMIZATION HOOKS PURELY FOR PERFORMANCE AND TO AVOID RE-RENDERING
|
|
const handleCreateTagModalOpen = useCallback(() => handlePopUpOpen("addTag"), []);
|
|
const handleFolderCreatePopUpOpen = useCallback(
|
|
(id: string, name: string) => handlePopUpOpen("folderForm", { id, name }),
|
|
[]
|
|
);
|
|
const handleFolderDeletePopUpOpen = useCallback(
|
|
(id: string, name: string) => handlePopUpOpen("deleteFolder", { id, name }),
|
|
[]
|
|
);
|
|
const handleSecretImportDelPopUpOpen = useCallback(
|
|
(impSecEnv: string, impSecPath: string) =>
|
|
handlePopUpOpen("deleteSecretImport", {
|
|
environment: impSecEnv,
|
|
secretPath: impSecPath
|
|
}),
|
|
[]
|
|
);
|
|
|
|
// when secrets is not loading and secrets list is empty
|
|
const isDashboardSecretEmpty = !isSecretsLoading && !fields?.length;
|
|
|
|
// folder list checks
|
|
const isFolderListLoading = isRollbackMode ? isSnapshotSecretsLoading : isFoldersLoading;
|
|
const folderList = isRollbackMode ? snapshotSecret?.folders : folderData?.folders;
|
|
|
|
// when using snapshot mode and snapshot is loading and snapshot list is empty
|
|
const isFoldersEmpty = !isFolderListLoading && !folderList?.length;
|
|
const isSnapshotSecretEmtpy =
|
|
isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length;
|
|
const isSecretEmpty = (!isRollbackMode && isDashboardSecretEmpty) || isSnapshotSecretEmtpy;
|
|
const isSecretImportEmpty = !secretImportCfg?.imports?.length;
|
|
const isEmptyPage = isFoldersEmpty && isSecretEmpty && isSecretImportEmpty;
|
|
|
|
if (isSecretsLoading) {
|
|
return (
|
|
<div className="container mx-auto flex h-1/2 w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
|
|
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto h-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
|
<form autoComplete="off" className="h-full flex flex-col">
|
|
{/* breadcrumb row */}
|
|
<div className="relative right-6 -top-2 mb-2 ml-6">
|
|
<NavHeader
|
|
pageName={t("dashboard.title")}
|
|
currentEnv={
|
|
userAvailableEnvs?.filter((envir) => envir.slug === environment)?.[0]?.name || ""
|
|
}
|
|
isFolderMode
|
|
folders={folderData?.dir}
|
|
isProjectRelated
|
|
userAvailableEnvs={userAvailableEnvs}
|
|
onEnvChange={onEnvChange}
|
|
/>
|
|
</div>
|
|
<div className="mb-4">
|
|
<h6 className="text-2xl">{isRollbackMode ? "Secret Snapshot" : ""}</h6>
|
|
{isRollbackMode && Boolean(snapshotSecret) && (
|
|
<Tag colorSchema="green">
|
|
{new Date(snapshotSecret?.createdAt || "").toLocaleString()}
|
|
</Tag>
|
|
)}
|
|
</div>
|
|
{/* Environment, search and other action row */}
|
|
<div className="flex items-center justify-between space-x-2">
|
|
<div className="flex max-w-lg flex-grow space-x-2">
|
|
<Input
|
|
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
|
placeholder="Search by folder name, key name, comment..."
|
|
value={searchFilter}
|
|
onChange={(e) => setSearchFilter(e.target.value)}
|
|
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<div>
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<IconButton ariaLabel="download" variant="outline_bg">
|
|
<FontAwesomeIcon icon={faDownload} />
|
|
</IconButton>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-1"
|
|
hideCloseBtn
|
|
>
|
|
<div className="flex flex-col space-y-2">
|
|
<Button
|
|
onClick={handleDownloadSecret}
|
|
colorSchema="primary"
|
|
variant="outline_bg"
|
|
className="h-8 bg-bunker-700"
|
|
>
|
|
Download as .env
|
|
</Button>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
<div>
|
|
<Tooltip content={isSecretValueHidden ? "Reveal Secrets" : "Hide secrets"}>
|
|
<IconButton
|
|
ariaLabel="reveal"
|
|
variant="outline_bg"
|
|
onClick={() => setIsSecretValueHidden.toggle()}
|
|
>
|
|
<FontAwesomeIcon icon={isSecretValueHidden ? faEye : faEyeSlash} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</div>
|
|
<ProjectPermissionCan
|
|
I={ProjectPermissionActions.Read}
|
|
a={ProjectPermissionSub.SecretRollback}
|
|
>
|
|
{(isAllowed) => (
|
|
<div className="block xl:hidden">
|
|
<Tooltip content="Point-in-time Recovery">
|
|
<IconButton
|
|
isDisabled={!isAllowed}
|
|
ariaLabel="recovery"
|
|
variant="outline_bg"
|
|
onClick={() => handlePopUpOpen("secretSnapshots")}
|
|
>
|
|
<FontAwesomeIcon icon={faCodeCommit} />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</div>
|
|
)}
|
|
</ProjectPermissionCan>
|
|
<ProjectPermissionCan
|
|
I={ProjectPermissionActions.Read}
|
|
a={ProjectPermissionSub.SecretRollback}
|
|
>
|
|
{(isAllowed) => (
|
|
<div className="hidden xl:block">
|
|
<Button
|
|
variant="outline_bg"
|
|
onClick={() => {
|
|
if (subscription && subscription.pitRecovery) {
|
|
handlePopUpOpen("secretSnapshots");
|
|
return;
|
|
}
|
|
|
|
handlePopUpOpen("upgradePlan");
|
|
}}
|
|
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
|
|
isLoading={isLoadingSnapshotCount}
|
|
isDisabled={!canDoRollback || !isAllowed}
|
|
className="h-10"
|
|
>
|
|
{snapshotCount} Commits
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</ProjectPermissionCan>
|
|
{!isReadOnly && !isRollbackMode && (
|
|
<div className="flex flex-row items-center justify-center">
|
|
<ProjectPermissionCan
|
|
I={ProjectPermissionActions.Create}
|
|
a={subject(ProjectPermissionSub.Secrets, {
|
|
secretPath,
|
|
environment
|
|
})}
|
|
>
|
|
{(isAllowed) => (
|
|
<button
|
|
type="button"
|
|
disabled={!isAllowed}
|
|
onClick={() => {
|
|
if (!(isReadOnly || isRollbackMode)) {
|
|
if (secretContainer.current) {
|
|
secretContainer.current.scroll({
|
|
top: 0,
|
|
behavior: "smooth"
|
|
});
|
|
}
|
|
prepend(DEFAULT_SECRET_VALUE, { shouldFocus: false });
|
|
setSearchFilter("");
|
|
}
|
|
}}
|
|
className="cursor-pointer rounded-l-md border border-mineshaft-500 bg-mineshaft-600 p-2 pr-4 text-sm font-semibold text-mineshaft-300 duration-200 hover:border-primary/40 hover:bg-primary/[0.1]"
|
|
>
|
|
<FontAwesomeIcon icon={faPlus} className="px-2" />
|
|
Add Secret
|
|
</button>
|
|
)}
|
|
</ProjectPermissionCan>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild className="data-[state=open]:bg-mineshaft-600">
|
|
<div className="cursor-pointer rounded-r-md border border-mineshaft-500 bg-mineshaft-600 p-2 text-sm text-mineshaft-300 duration-200 hover:border-primary/40 hover:bg-primary/[0.1]">
|
|
<FontAwesomeIcon icon={faAngleDown} className="pr-2 pl-1.5" />
|
|
</div>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="left-20 z-[60] mt-1 w-[10.8rem]">
|
|
<div className="rounded-md border border-mineshaft-600 bg-mineshaft-800 p-1">
|
|
<div className="w-full pb-1">
|
|
<ProjectPermissionCan
|
|
I={ProjectPermissionActions.Create}
|
|
a={ProjectPermissionSub.Secrets}
|
|
>
|
|
{(isAllowed) => (
|
|
<Button
|
|
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
|
|
onClick={() => handlePopUpOpen("folderForm")}
|
|
isDisabled={isReadOnly || isRollbackMode || !isAllowed}
|
|
variant="outline_bg"
|
|
className="h-10"
|
|
isFullWidth
|
|
>
|
|
Add Folder
|
|
</Button>
|
|
)}
|
|
</ProjectPermissionCan>
|
|
</div>
|
|
<div className="w-full">
|
|
<ProjectPermissionCan
|
|
I={ProjectPermissionActions.Edit}
|
|
a={subject(ProjectPermissionSub.Secrets, {
|
|
environment,
|
|
secretPath
|
|
})}
|
|
>
|
|
{(isAllowed) => (
|
|
<Button
|
|
leftIcon={<FontAwesomeIcon icon={faFileImport} />}
|
|
onClick={() => handlePopUpOpen("addSecretImport")}
|
|
isDisabled={isReadOnly || isRollbackMode || !isAllowed}
|
|
variant="outline_bg"
|
|
className="h-10"
|
|
isFullWidth
|
|
>
|
|
Add Import
|
|
</Button>
|
|
)}
|
|
</ProjectPermissionCan>
|
|
</div>
|
|
</div>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
)}
|
|
{isRollbackMode && (
|
|
<Button
|
|
variant="star"
|
|
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
|
|
onClick={() => {
|
|
setSnaphotId(null);
|
|
reset({ ...secrets, isSnapshotMode: false });
|
|
}}
|
|
className="h-10"
|
|
>
|
|
Go back
|
|
</Button>
|
|
)}
|
|
|
|
<Tooltip
|
|
isOpen={
|
|
!isSubmitting && isSubmitDisabled && isReadOnly && !isDirty ? undefined : false
|
|
}
|
|
content="Access restricted"
|
|
>
|
|
<Button
|
|
isDisabled={isSubmitDisabled}
|
|
isLoading={isSubmitting}
|
|
leftIcon={<FontAwesomeIcon icon={isRollbackMode ? faClockRotateLeft : faCheck} />}
|
|
onClick={handleSubmit(onSaveSecret)}
|
|
className="h-10 text-black"
|
|
color="primary"
|
|
variant="solid"
|
|
>
|
|
{isRollbackMode ? "Rollback" : "Save Changes"}
|
|
</Button>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={`${
|
|
isEmptyPage ? "flex flex-col flex-grow items-center justify-center" : ""
|
|
} no-scrollbar::-webkit-scrollbar mt-3 flex flex-col overflow-x-hidden overflow-y-scroll no-scrollbar`}
|
|
ref={secretContainer}
|
|
>
|
|
{!isEmptyPage && (
|
|
<DndContext
|
|
onDragEnd={handleDragEnd}
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
modifiers={[restrictToVerticalAxis]}
|
|
>
|
|
<TableContainer className="no-scrollbar::-webkit-scrollbar max-h-[calc(100%-120px)] no-scrollbar flex-grow">
|
|
<table className="secret-table relative">
|
|
<SecretTableHeader sortDir={sortDir} onSort={onSortSecrets} />
|
|
<tbody className="max-h-96 overflow-y-auto">
|
|
<SecretImportSection
|
|
environment={environment}
|
|
secretPath={secretPath}
|
|
onSecretImportDelete={handleSecretImportDelPopUpOpen}
|
|
secrets={secrets?.secrets}
|
|
importedSecrets={importedSecrets}
|
|
items={items}
|
|
searchTerm={searchFilter}
|
|
/>
|
|
<FolderSection
|
|
environment={environment}
|
|
secretPath={secretPath}
|
|
onFolderOpen={handleFolderOpen}
|
|
onFolderUpdate={handleFolderCreatePopUpOpen}
|
|
onFolderDelete={handleFolderDeletePopUpOpen}
|
|
folders={folderList}
|
|
search={searchFilter}
|
|
/>
|
|
{permission.can(
|
|
ProjectPermissionActions.Read,
|
|
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
|
) ? (
|
|
fields.map(({ id, _id }, index) => (
|
|
<SecretInputRow
|
|
key={id}
|
|
environment={environment}
|
|
secretPath={secretPath}
|
|
secUniqId={_id}
|
|
isReadOnly={isReadOnly}
|
|
isRollbackMode={isRollbackMode}
|
|
index={index}
|
|
searchTerm={searchFilter}
|
|
onSecretDelete={onSecretDelete}
|
|
isKeyError={Boolean(errors?.secrets?.[index]?.key?.message)}
|
|
keyError={errors?.secrets?.[index]?.key?.message}
|
|
onRowExpand={onDrawerOpen}
|
|
isSecretValueHidden={isSecretValueHidden}
|
|
wsTags={wsTags}
|
|
onCreateTagOpen={handleCreateTagModalOpen}
|
|
register={register}
|
|
control={control}
|
|
setValue={setValue}
|
|
autoCapitalization={currentWorkspace?.autoCapitalization}
|
|
/>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td colSpan={3}>
|
|
<PermissionDeniedBanner />
|
|
</td>
|
|
</tr>
|
|
)}
|
|
{!isReadOnly && !isRollbackMode && (
|
|
<tr>
|
|
<td colSpan={3} className="hover:bg-mineshaft-700">
|
|
<ProjectPermissionCan
|
|
I={ProjectPermissionActions.Create}
|
|
a={subject(ProjectPermissionSub.Secrets, {
|
|
environment,
|
|
secretPath
|
|
})}
|
|
>
|
|
{(isAllowed) => (
|
|
<button
|
|
type="button"
|
|
className="flex h-8 w-full cursor-default items-center justify-start pl-12 font-normal text-bunker-300"
|
|
onClick={onAppendSecret}
|
|
disabled={!isAllowed}
|
|
>
|
|
<FontAwesomeIcon icon={faPlus} />
|
|
<span className="ml-2 w-20">Add Secret</span>
|
|
</button>
|
|
)}
|
|
</ProjectPermissionCan>
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</TableContainer>
|
|
</DndContext>
|
|
)}
|
|
<FormProvider {...method}>
|
|
<PitDrawer
|
|
isDrawerOpen={popUp?.secretSnapshots?.isOpen}
|
|
onOpenChange={(isOpen) => handlePopUpToggle("secretSnapshots", isOpen)}
|
|
fetchNextPage={fetchNextPage}
|
|
hasNextPage={hasNextPage}
|
|
snapshotId={snapshotId}
|
|
isFetchingNextPage={isFetchingNextPage}
|
|
secretSnaphots={secretSnaphots}
|
|
onSelectSnapshot={setSnaphotId}
|
|
/>
|
|
<SecretDetailDrawer
|
|
environment={environment}
|
|
secretPath={secretPath}
|
|
onSave={handleSubmit(onSaveSecret)}
|
|
isReadOnly={isReadOnly || isRollbackMode}
|
|
onSecretDelete={onSecretDelete}
|
|
isDrawerOpen={popUp?.secretDetails?.isOpen}
|
|
onOpenChange={(isOpen) => handlePopUpToggle("secretDetails", isOpen)}
|
|
secretVersion={secretVersion}
|
|
index={(popUp?.secretDetails?.data as TSecretDetailsOpen)?.index}
|
|
onEnvCompare={(key) => handlePopUpOpen("compareSecrets", key)}
|
|
/>
|
|
</FormProvider>
|
|
<SecretDropzone
|
|
environment={environment}
|
|
secretPath={secretPath}
|
|
workspaceId={workspaceId}
|
|
isSmaller={!isEmptyPage}
|
|
onParsedEnv={handleUploadedEnv}
|
|
onAddNewSecret={onAppendSecret}
|
|
environments={userAvailableEnvs}
|
|
decryptFileKey={latestFileKey!}
|
|
/>
|
|
</div>
|
|
{/* secrets table and drawers, modals */}
|
|
</form>
|
|
{/* Create a new tag modal */}
|
|
<Modal
|
|
isOpen={popUp?.addTag?.isOpen}
|
|
onOpenChange={(open) => {
|
|
handlePopUpToggle("addTag", open);
|
|
}}
|
|
>
|
|
<ModalContent
|
|
title="Create tag"
|
|
subTitle="Specify your tag name, and the slug will be created automatically."
|
|
>
|
|
<CreateTagModal onCreateTag={onCreateWsTag} />
|
|
</ModalContent>
|
|
</Modal>
|
|
{/* Uploaded env override or not confirmation modal */}
|
|
<Modal
|
|
isOpen={popUp?.uploadedSecOpts?.isOpen}
|
|
onOpenChange={(open) => handlePopUpToggle("uploadedSecOpts", open)}
|
|
>
|
|
<ModalContent
|
|
title="Duplicate Secrets"
|
|
footerContent={[
|
|
<Button
|
|
key="keep-old-btn"
|
|
className="mr-4"
|
|
onClick={() => handlePopUpClose("uploadedSecOpts")}
|
|
>
|
|
Keep old
|
|
</Button>,
|
|
<Button colorSchema="danger" key="overwrite-btn" onClick={onOverwriteSecrets}>
|
|
Overwrite
|
|
</Button>
|
|
]}
|
|
>
|
|
<div className="flex flex-col space-y-2 text-gray-300">
|
|
<div>Your file contains following duplicate secrets</div>
|
|
<div className="text-sm text-gray-400">
|
|
{Object.keys((popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets || {})
|
|
?.map((key) => key)
|
|
.join(", ")}
|
|
</div>
|
|
<div>Are you sure you want to overwrite these secrets?</div>
|
|
</div>
|
|
</ModalContent>
|
|
</Modal>
|
|
<Modal
|
|
isOpen={popUp?.folderForm?.isOpen}
|
|
onOpenChange={(isOpen) => handlePopUpToggle("folderForm", isOpen)}
|
|
>
|
|
<ModalContent title={isEditFolder ? "Edit Folder" : "Create Folder"}>
|
|
<FolderForm
|
|
isEdit={isEditFolder}
|
|
onUpdateFolder={handleFolderUpdate}
|
|
onCreateFolder={handleFolderCreate}
|
|
defaultFolderName={(popUp?.folderForm?.data as TEditFolderForm)?.name}
|
|
/>
|
|
</ModalContent>
|
|
</Modal>
|
|
<Modal
|
|
isOpen={popUp?.addSecretImport?.isOpen}
|
|
onOpenChange={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)}
|
|
>
|
|
<ModalContent
|
|
title="Add Secret Link"
|
|
subTitle="To inherit secrets from another environment or folder"
|
|
>
|
|
<SecretImportForm
|
|
environments={currentWorkspace?.environments}
|
|
onCreate={handleSecretImportCreate}
|
|
/>
|
|
</ModalContent>
|
|
</Modal>
|
|
<DeleteActionModal
|
|
isOpen={popUp.deleteFolder.isOpen}
|
|
deleteKey={(popUp.deleteFolder?.data as TDeleteFolderForm)?.name}
|
|
title="Do you want to delete this folder?"
|
|
onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
|
|
onDeleteApproved={handleFolderDelete}
|
|
/>
|
|
<DeleteActionModal
|
|
isOpen={popUp.deleteSecretImport.isOpen}
|
|
deleteKey="unlink"
|
|
title="Do you want to remove this secret import?"
|
|
subTitle={`This will unlink secrets from environment ${
|
|
(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.environment
|
|
} of path ${(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.secretPath}?`}
|
|
onChange={(isOpen) => handlePopUpToggle("deleteSecretImport", isOpen)}
|
|
onDeleteApproved={handleSecretImportDelete}
|
|
/>
|
|
<Modal
|
|
isOpen={popUp?.compareSecrets?.isOpen}
|
|
onOpenChange={(open) => handlePopUpToggle("compareSecrets", open)}
|
|
>
|
|
<ModalContent
|
|
title={popUp?.compareSecrets?.data as string}
|
|
subTitle="Below is the comparison of secret values across available environments"
|
|
overlayClassName="z-[90]"
|
|
>
|
|
<CompareSecret
|
|
workspaceId={workspaceId}
|
|
envs={userAvailableEnvs || []}
|
|
secretKey={popUp?.compareSecrets?.data as string}
|
|
/>
|
|
</ModalContent>
|
|
</Modal>
|
|
{subscription && (
|
|
<UpgradePlanModal
|
|
isOpen={popUp.upgradePlan.isOpen}
|
|
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
|
text={
|
|
subscription.slug === null
|
|
? "You can perform point-in-time recovery under an Enterprise license"
|
|
: "You can perform point-in-time recovery if you switch to Infisical's Team plan"
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|