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(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(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({ // 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>( (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 = { ...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 (
loading animation
); } return (
{/* breadcrumb row */}
envir.slug === environment)?.[0]?.name || "" } isFolderMode folders={folderData?.dir} isProjectRelated userAvailableEnvs={userAvailableEnvs} onEnvChange={onEnvChange} />
{isRollbackMode ? "Secret Snapshot" : ""}
{isRollbackMode && Boolean(snapshotSecret) && ( {new Date(snapshotSecret?.createdAt || "").toLocaleString()} )}
{/* Environment, search and other action row */}
setSearchFilter(e.target.value)} leftIcon={} />
setIsSecretValueHidden.toggle()} >
{(isAllowed) => (
handlePopUpOpen("secretSnapshots")} >
)}
{(isAllowed) => (
)}
{!isReadOnly && !isRollbackMode && (
{(isAllowed) => ( )}
{(isAllowed) => ( )}
{(isAllowed) => ( )}
)} {isRollbackMode && ( )}
{!isEmptyPage && ( {permission.can( ProjectPermissionActions.Read, subject(ProjectPermissionSub.Secrets, { environment, secretPath }) ) ? ( fields.map(({ id, _id }, index) => ( )) ) : ( )} {!isReadOnly && !isRollbackMode && ( )}
{(isAllowed) => ( )}
)} handlePopUpToggle("secretSnapshots", isOpen)} fetchNextPage={fetchNextPage} hasNextPage={hasNextPage} snapshotId={snapshotId} isFetchingNextPage={isFetchingNextPage} secretSnaphots={secretSnaphots} onSelectSnapshot={setSnaphotId} /> handlePopUpToggle("secretDetails", isOpen)} secretVersion={secretVersion} index={(popUp?.secretDetails?.data as TSecretDetailsOpen)?.index} onEnvCompare={(key) => handlePopUpOpen("compareSecrets", key)} />
{/* secrets table and drawers, modals */}
{/* Create a new tag modal */} { handlePopUpToggle("addTag", open); }} > {/* Uploaded env override or not confirmation modal */} handlePopUpToggle("uploadedSecOpts", open)} > handlePopUpClose("uploadedSecOpts")} > Keep old , ]} >
Your file contains following duplicate secrets
{Object.keys((popUp?.uploadedSecOpts?.data as TSecOverwriteOpt)?.secrets || {}) ?.map((key) => key) .join(", ")}
Are you sure you want to overwrite these secrets?
handlePopUpToggle("folderForm", isOpen)} > handlePopUpToggle("addSecretImport", isOpen)} > handlePopUpToggle("deleteFolder", isOpen)} onDeleteApproved={handleFolderDelete} /> handlePopUpToggle("deleteSecretImport", isOpen)} onDeleteApproved={handleSecretImportDelete} /> handlePopUpToggle("compareSecrets", open)} > {subscription && ( 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" } /> )}
); };