diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cb3360485..5853ff650 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -5,6 +5,9 @@ "packages": { "": { "dependencies": { + "@dnd-kit/core": "^6.0.8", + "@dnd-kit/modifiers": "^6.0.1", + "@dnd-kit/sortable": "^7.0.2", "@emotion/css": "^11.10.0", "@emotion/server": "^11.10.0", "@fontsource/inter": "^4.5.15", @@ -2468,6 +2471,68 @@ "node": ">=10.0.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz", + "integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz", + "integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==", + "dependencies": { + "@dnd-kit/accessibility": "^3.0.0", + "@dnd-kit/utilities": "^3.2.1", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/modifiers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz", + "integrity": "sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.1", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.0.6", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", + "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.0", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.0.7", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz", + "integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@emotion/babel-plugin": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", @@ -24491,6 +24556,50 @@ "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", "dev": true }, + "@dnd-kit/accessibility": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.0.1.tgz", + "integrity": "sha512-HXRrwS9YUYQO9lFRc/49uO/VICbM+O+ZRpFDe9Pd1rwVv2PCNkRiTZRdxrDgng/UkvdC3Re9r2vwPpXXrWeFzg==", + "requires": { + "tslib": "^2.0.0" + } + }, + "@dnd-kit/core": { + "version": "6.0.8", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.0.8.tgz", + "integrity": "sha512-lYaoP8yHTQSLlZe6Rr9qogouGUz9oRUj4AHhDQGQzq/hqaJRpFo65X+JKsdHf8oUFBzx5A+SJPUvxAwTF2OabA==", + "requires": { + "@dnd-kit/accessibility": "^3.0.0", + "@dnd-kit/utilities": "^3.2.1", + "tslib": "^2.0.0" + } + }, + "@dnd-kit/modifiers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-6.0.1.tgz", + "integrity": "sha512-rbxcsg3HhzlcMHVHWDuh9LCjpOVAgqbV78wLGI8tziXY3+qcMQ61qVXIvNKQFuhj75dSfD+o+PYZQ/NUk2A23A==", + "requires": { + "@dnd-kit/utilities": "^3.2.1", + "tslib": "^2.0.0" + } + }, + "@dnd-kit/sortable": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-7.0.2.tgz", + "integrity": "sha512-wDkBHHf9iCi1veM834Gbk1429bd4lHX4RpAwT0y2cHLf246GAvU2sVw/oxWNpPKQNQRQaeGXhAVgrOl1IT+iyA==", + "requires": { + "@dnd-kit/utilities": "^3.2.0", + "tslib": "^2.0.0" + } + }, + "@dnd-kit/utilities": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.1.tgz", + "integrity": "sha512-OOXqISfvBw/1REtkSK2N3Fi2EQiLMlWUlqnOK/UpOISqBZPWpE6TqL+jcPtMOkE8TqYGiURvRdPSI9hltNUjEA==", + "requires": { + "tslib": "^2.0.0" + } + }, "@emotion/babel-plugin": { "version": "11.11.0", "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.11.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index f20477e6f..0430844e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,9 @@ "build-storybook": "storybook build" }, "dependencies": { + "@dnd-kit/core": "^6.0.8", + "@dnd-kit/modifiers": "^6.0.1", + "@dnd-kit/sortable": "^7.0.2", "@emotion/css": "^11.10.0", "@emotion/server": "^11.10.0", "@fontsource/inter": "^4.5.15", diff --git a/frontend/src/hooks/api/index.tsx b/frontend/src/hooks/api/index.tsx index 299629416..18e5b59fb 100644 --- a/frontend/src/hooks/api/index.tsx +++ b/frontend/src/hooks/api/index.tsx @@ -6,6 +6,7 @@ export * from "./integrations"; export * from "./keys"; export * from "./organization"; export * from "./secretFolders"; +export * from "./secretImports"; export * from "./secrets"; export * from "./secretSnapshots"; export * from "./serviceAccounts"; diff --git a/frontend/src/hooks/api/secretImports/index.ts b/frontend/src/hooks/api/secretImports/index.ts new file mode 100644 index 000000000..2e1150cb8 --- /dev/null +++ b/frontend/src/hooks/api/secretImports/index.ts @@ -0,0 +1,2 @@ +export { useCreateSecretImport, useDeleteSecretImport, useUpdateSecretImport } from "./mutation"; +export { useGetImportedSecrets, useGetSecretImports } from "./queries"; diff --git a/frontend/src/hooks/api/secretImports/mutation.tsx b/frontend/src/hooks/api/secretImports/mutation.tsx new file mode 100644 index 000000000..bb01528ea --- /dev/null +++ b/frontend/src/hooks/api/secretImports/mutation.tsx @@ -0,0 +1,78 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { secretImportKeys } from "./queries"; +import { TCreateSecretImportDTO, TDeleteSecretImportDTO, TUpdateSecretImportDTO } from "./types"; + +export const useCreateSecretImport = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TCreateSecretImportDTO>({ + mutationFn: async ({ secretImport, environment, workspaceId, folderId }) => { + const { data } = await apiRequest.post("/api/v1/secret-imports", { + secretImport, + environment, + workspaceId, + folderId + }); + return data; + }, + onSuccess: (_, { workspaceId, environment, folderId }) => { + queryClient.invalidateQueries( + secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId) + ); + queryClient.invalidateQueries( + secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId) + ); + } + }); +}; + +export const useUpdateSecretImport = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateSecretImportDTO>({ + mutationFn: async ({ environment, workspaceId, folderId, secretImports, id }) => { + const { data } = await apiRequest.put(`/api/v1/secret-imports/${id}`, { + secretImports, + environment, + workspaceId, + folderId + }); + return data; + }, + onSuccess: (_, { workspaceId, environment, folderId }) => { + queryClient.invalidateQueries( + secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId) + ); + queryClient.invalidateQueries( + secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId) + ); + } + }); +}; + +export const useDeleteSecretImport = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteSecretImportDTO>({ + mutationFn: async ({ id, secretImportEnv, secretImportPath }) => { + const { data } = await apiRequest.delete(`/api/v1/secret-imports/${id}`, { + data: { + secretImportPath, + secretImportEnv + } + }); + return data; + }, + onSuccess: (_, { workspaceId, environment, folderId }) => { + queryClient.invalidateQueries( + secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId) + ); + queryClient.invalidateQueries( + secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId) + ); + } + }); +}; diff --git a/frontend/src/hooks/api/secretImports/queries.tsx b/frontend/src/hooks/api/secretImports/queries.tsx new file mode 100644 index 000000000..7bb07488c --- /dev/null +++ b/frontend/src/hooks/api/secretImports/queries.tsx @@ -0,0 +1,124 @@ +import { useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { + decryptAssymmetric, + decryptSymmetric +} from "@app/components/utilities/cryptography/crypto"; +import { apiRequest } from "@app/config/request"; + +import { TGetImportedSecrets, TImportedSecrets, TSecretImports } from "./types"; + +export const secretImportKeys = { + getProjectSecretImports: (workspaceId: string, env: string | string[], folderId?: string) => [ + { workspaceId, env, folderId }, + "secrets-imports" + ], + getSecretImportSecrets: (workspaceId: string, env: string | string[], folderId?: string) => [ + { workspaceId, env, folderId }, + "secrets-import-sec" + ] +}; + +const fetchSecretImport = async (workspaceId: string, environment: string, folderId?: string) => { + const { data } = await apiRequest.get<{ secretImport: TSecretImports }>( + "/api/v1/secret-imports", + { + params: { + workspaceId, + environment, + folderId + } + } + ); + return data.secretImport; +}; + +export const useGetSecretImports = (workspaceId: string, env: string, folderId?: string) => + useQuery({ + enabled: Boolean(workspaceId) && Boolean(env), + queryKey: secretImportKeys.getProjectSecretImports(workspaceId, env, folderId), + queryFn: () => fetchSecretImport(workspaceId, env, folderId) + }); + +const fetchImportedSecrets = async ( + workspaceId: string, + environment: string, + folderId?: string +) => { + const { data } = await apiRequest.get<{ secrets: TImportedSecrets }>( + "/api/v1/secret-imports/secrets", + { + params: { + workspaceId, + environment, + folderId + } + } + ); + return data.secrets; +}; + +export const useGetImportedSecrets = ({ + workspaceId, + environment, + folderId, + decryptFileKey +}: TGetImportedSecrets) => + useQuery({ + enabled: Boolean(workspaceId) && Boolean(environment) && Boolean(decryptFileKey), + queryKey: secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId), + queryFn: () => fetchImportedSecrets(workspaceId, environment, folderId), + select: useCallback( + (data: TImportedSecrets) => { + const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string; + const latestKey = decryptFileKey; + const key = decryptAssymmetric({ + ciphertext: latestKey.encryptedKey, + nonce: latestKey.nonce, + publicKey: latestKey.sender.publicKey, + privateKey: PRIVATE_KEY + }); + + return data.map((el) => ({ + environment: el.environment, + secretPath: el.secretPath, + folderId: el.folderId, + secrets: el.secrets.map((encSecret) => { + const secretKey = decryptSymmetric({ + ciphertext: encSecret.secretKeyCiphertext, + iv: encSecret.secretKeyIV, + tag: encSecret.secretKeyTag, + key + }); + + const secretValue = decryptSymmetric({ + ciphertext: encSecret.secretValueCiphertext, + iv: encSecret.secretValueIV, + tag: encSecret.secretValueTag, + key + }); + + const secretComment = decryptSymmetric({ + ciphertext: encSecret.secretCommentCiphertext, + iv: encSecret.secretCommentIV, + tag: encSecret.secretCommentTag, + key + }); + + return { + _id: encSecret._id, + env: encSecret.environment, + key: secretKey, + value: secretValue, + tags: encSecret.tags, + comment: secretComment, + createdAt: encSecret.createdAt, + updatedAt: encSecret.updatedAt + }; + }) + })); + }, + [decryptFileKey] + ) + }); diff --git a/frontend/src/hooks/api/secretImports/types.ts b/frontend/src/hooks/api/secretImports/types.ts new file mode 100644 index 000000000..50884225f --- /dev/null +++ b/frontend/src/hooks/api/secretImports/types.ts @@ -0,0 +1,56 @@ +import { EncryptedSecret } from "../secrets/types"; +import { UserWsKeyPair } from "../types"; + +export type TSecretImports = { + _id: string; + workspaceId: string; + environment: string; + folderId: string; + imports: Array<{ environment: string; secretPath: string }>; + createdAt: string; + updatedAt: string; +}; + +export type TImportedSecrets = { + environment: string; + secretPath: string; + folderId: string; + secrets: EncryptedSecret[]; +}[]; + +export type TGetImportedSecrets = { + workspaceId: string; + environment: string; + folderId?: string; + decryptFileKey: UserWsKeyPair; +}; + +export type TCreateSecretImportDTO = { + workspaceId: string; + environment: string; + folderId?: string; + secretImport: { + environment: string; + secretPath: string; + }; +}; + +export type TUpdateSecretImportDTO = { + id: string; + workspaceId: string; + environment: string; + folderId?: string; + secretImports: Array<{ + environment: string; + secretPath: string; + }>; +}; + +export type TDeleteSecretImportDTO = { + id: string; + workspaceId: string; + environment: string; + folderId?: string; + secretImportPath: string; + secretImportEnv: string; +}; diff --git a/frontend/src/hooks/api/secrets/mutation.tsx b/frontend/src/hooks/api/secrets/mutation.tsx new file mode 100644 index 000000000..0047d8435 --- /dev/null +++ b/frontend/src/hooks/api/secrets/mutation.tsx @@ -0,0 +1,69 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { apiRequest } from "@app/config/request"; + +import { secretKeys } from "./queries"; +import { TCreateSecretImportDTO, TDeleteSecretImportDTO, TUpdateSecretImportDTO } from "./types"; + +export const useCreateSecretImport = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TCreateSecretImportDTO>({ + mutationFn: async ({ secretImport, environment, workspaceId, folderId }) => { + const { data } = await apiRequest.post("/api/v1/secret-imports", { + secretImport, + environment, + workspaceId, + folderId + }); + return data; + }, + onSuccess: (_, { workspaceId, environment, folderId }) => { + queryClient.invalidateQueries( + secretKeys.getProjectSecretImports(workspaceId, environment, folderId) + ); + } + }); +}; + +export const useUpdateSecretImport = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TUpdateSecretImportDTO>({ + mutationFn: async ({ environment, workspaceId, folderId, secretImports, id }) => { + const { data } = await apiRequest.put(`/api/v1/secret-imports/${id}`, { + secretImports, + environment, + workspaceId, + folderId + }); + return data; + }, + onSuccess: (_, { workspaceId, environment, folderId }) => { + queryClient.invalidateQueries( + secretKeys.getProjectSecretImports(workspaceId, environment, folderId) + ); + } + }); +}; + +export const useDeleteSecretImport = () => { + const queryClient = useQueryClient(); + + return useMutation<{}, {}, TDeleteSecretImportDTO>({ + mutationFn: async ({ id, secretImportEnv, secretImportPath }) => { + const { data } = await apiRequest.delete(`/api/v1/secret-imports/${id}`, { + data: { + secretImportPath, + secretImportEnv + } + }); + return data; + }, + onSuccess: (_, { workspaceId, environment, folderId }) => { + queryClient.invalidateQueries( + secretKeys.getProjectSecretImports(workspaceId, environment, folderId) + ); + } + }); +}; diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 397f68aa7..1541ff753 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -24,6 +24,10 @@ export const secretKeys = { { workspaceId, env, folderId }, "secrets" ], + getProjectSecretImports: (workspaceId: string, env: string | string[], folderId?: string) => [ + { workspaceId, env, folderId }, + "secrets-imports" + ], getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"] }; diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index a622b25eb..b36eb47f7 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -34,6 +34,7 @@ export type DecryptedSecret = { valueOverride?: string; idOverride?: string; overrideAction?: string; + folderId?: string; }; export type EncryptedSecretVersion = { @@ -98,6 +99,7 @@ export type GetProjectSecretsDTO = { folderId?: string; secretPath?: string; isPaused?: boolean; + include_imports?: boolean; onSuccess?: (data: DecryptedSecret[]) => void; }; diff --git a/frontend/src/views/DashboardPage/DashboardPage.tsx b/frontend/src/views/DashboardPage/DashboardPage.tsx index 1cb686414..3b0effb1f 100644 --- a/frontend/src/views/DashboardPage/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage/DashboardPage.tsx @@ -2,6 +2,18 @@ import { useEffect, useRef, useState } from "react"; import { FormProvider, useFieldArray, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { useRouter } from "next/router"; +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 { faArrowLeft, faCheck, @@ -10,6 +22,7 @@ import { faDownload, faEye, faEyeSlash, + faFileImport, faFolderPlus, faMagnifyingGlass, faPlus @@ -41,10 +54,14 @@ import { useLeaveConfirm, usePopUp, useToggle } from "@app/hooks"; import { useBatchSecretsOp, useCreateFolder, + useCreateSecretImport, useCreateWsTag, useDeleteFolder, + useDeleteSecretImport, + useGetImportedSecrets, useGetProjectFolders, useGetProjectSecrets, + useGetSecretImports, useGetSecretVersion, useGetSnapshotSecrets, useGetUserAction, @@ -55,7 +72,8 @@ import { useGetWsTags, usePerformSecretRollback, useRegisterUserAction, - useUpdateFolder + useUpdateFolder, + useUpdateSecretImport } from "@app/hooks/api"; import { secretKeys } from "@app/hooks/api/secrets/queries"; import { WorkspaceEnv } from "@app/hooks/api/types"; @@ -71,6 +89,8 @@ import { 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 { @@ -84,7 +104,7 @@ import { } 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 @@ -113,7 +133,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { "compareSecrets", "folderForm", "deleteFolder", - "upgradePlan" + "upgradePlan", + "addSecretImport", + "deleteSecretImport" ] as const); const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true); const [searchFilter, setSearchFilter] = useState(""); @@ -129,6 +151,8 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { const { currentWorkspace, isLoading } = useWorkspace(); const { currentOrg } = useOrganization(); const workspaceId = currentWorkspace?._id as string; + const selectedEnvSlug = selectedEnv?.slug || ""; + const { data: latestFileKey } = useGetUserWsKey(workspaceId); useEffect(() => { @@ -161,7 +185,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({ workspaceId, - env: selectedEnv?.slug || "", + env: selectedEnvSlug, decryptFileKey: latestFileKey!, isPaused: Boolean(snapshotId), folderId @@ -169,7 +193,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { const { data: folderData, isLoading: isFoldersLoading } = useGetProjectFolders({ workspaceId: workspaceId || "", - environment: selectedEnv?.slug || "", + environment: selectedEnvSlug, parentFolderId: folderId, isPaused: isRollbackMode, sortDir @@ -182,7 +206,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { isFetchingNextPage } = useGetWorkspaceSecretSnapshots({ workspaceId, - environment: selectedEnv?.slug || "", + environment: selectedEnvSlug, folder: folderId, limit: 10 }); @@ -193,17 +217,18 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { isFetching: isSnapshotChanging } = useGetSnapshotSecrets({ snapshotId: snapshotId || "", - env: selectedEnv?.slug || "", + env: selectedEnvSlug, decryptFileKey: latestFileKey! }); const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = useGetWsSnapshotCount( workspaceId, - selectedEnv?.slug || "", + selectedEnvSlug, folderId ); const { data: wsTags } = useGetWsTags(workspaceId); + // mutation calls const { mutateAsync: batchSecretOp } = useBatchSecretsOp(); const { mutateAsync: performSecretRollback } = usePerformSecretRollback(); @@ -213,6 +238,50 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { const { mutateAsync: updateFolder } = useUpdateFolder(folderId); const { mutateAsync: deleteFolder } = useDeleteFolder(folderId); + const { data: secretImportCfg, isFetching: isSecretImportCfgFetching } = useGetSecretImports( + workspaceId, + selectedEnvSlug, + folderId + ); + + const { data: importedSecrets } = useGetImportedSecrets({ + workspaceId, + decryptFileKey: latestFileKey!, + environment: selectedEnvSlug, + folderId + }); + + // 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, @@ -319,14 +388,12 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { await performSecretRollback({ workspaceId, version: snapshotSecret.version, - environment: selectedEnv?.slug || "", + environment: selectedEnvSlug, folderId }); setValue("isSnapshotMode", false); setSnaphotId(null); - queryClient.invalidateQueries( - secretKeys.getProjectSecret(workspaceId, selectedEnv?.slug || "") - ); + queryClient.invalidateQueries(secretKeys.getProjectSecret(workspaceId, selectedEnvSlug)); createNotification({ text: "Successfully rollback secrets", type: "success" @@ -522,6 +589,79 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { } }; + const handleSecretImportCreate = async (env: string, secretPath: string) => { + try { + await createSecretImport({ + workspaceId, + environment: selectedEnv?.slug || "", + folderId, + secretImport: { + environment: env, + secretPath + } + }); + 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: selectedEnvSlug, + 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: selectedEnvSlug, + folderId, + id: secretImportCfg?._id || "", + secretImports: newImportOrder.map((el) => ({ + environment: el.environment, + secretPath: el.secretPath + })) + }); + } + }; + // when secrets is not loading and secrets list is empty const isDashboardSecretEmpty = !isSecretsLoading && !fields?.length; @@ -664,7 +804,6 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => { -
{
+
+ +
-
+ + + )} + + + + )} { /> + handlePopUpToggle("addSecretImport", isOpen)} + > + + + + { onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)} onDeleteApproved={handleFolderDelete} /> + handlePopUpToggle("deleteSecretImport", isOpen)} + onDeleteApproved={handleSecretImportDelete} + /> handlePopUpToggle("compareSecrets", open)} diff --git a/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx b/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx index 482324246..dbdf57fe0 100644 --- a/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx +++ b/frontend/src/views/DashboardPage/components/FolderSection/FolderSection.tsx @@ -67,7 +67,7 @@ export const FolderSection = ({ ariaLabel="delete" onClick={() => handleFolderDelete(id, name)} > - +
diff --git a/frontend/src/views/DashboardPage/components/SecretImportForm/SecretImportForm.tsx b/frontend/src/views/DashboardPage/components/SecretImportForm/SecretImportForm.tsx new file mode 100644 index 000000000..27adbf24f --- /dev/null +++ b/frontend/src/views/DashboardPage/components/SecretImportForm/SecretImportForm.tsx @@ -0,0 +1,86 @@ +import { Controller, useForm } from "react-hook-form"; +import { yupResolver } from "@hookform/resolvers/yup"; +import * as yup from "yup"; + +import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2"; + +type Props = { + onCreate: (environment: string, secretPath: string) => Promise; + environments?: Array<{ slug: string; name: string }>; +}; + +const formSchema = yup.object({ + environment: yup.string().required().label("Environment").trim(), + secretPath: yup + .string() + .required() + .label("Secret Path") + .trim() + .transform((val) => + typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val + ) +}); + +type TFormData = yup.InferType; + +export const SecretImportForm = ({ onCreate, environments = [] }: Props): JSX.Element => { + const { + control, + reset, + formState: { isSubmitting }, + handleSubmit + } = useForm({ + resolver: yupResolver(formSchema) + }); + + const onSubmit = async ({ environment, secretPath }: TFormData) => { + await onCreate(environment, secretPath); + reset(); + }; + + return ( +
+ ( + + + + )} + /> + ( + + + + )} + /> +
+ + + + +
+ + ); +}; diff --git a/frontend/src/views/DashboardPage/components/SecretImportForm/index.tsx b/frontend/src/views/DashboardPage/components/SecretImportForm/index.tsx new file mode 100644 index 000000000..c5be50814 --- /dev/null +++ b/frontend/src/views/DashboardPage/components/SecretImportForm/index.tsx @@ -0,0 +1 @@ +export { SecretImportForm } from "./SecretImportForm"; diff --git a/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx new file mode 100644 index 000000000..6e424fc1a --- /dev/null +++ b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportItem.tsx @@ -0,0 +1,144 @@ +import { useEffect } from "react"; +import { useSortable } from "@dnd-kit/sortable"; +import { faFileImport, faFolder, faUpDown, faXmark } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +import { IconButton, TableContainer, Tooltip } from "@app/components/v2"; +import { useWorkspace } from "@app/context"; +import { useToggle } from "@app/hooks/useToggle"; + +type Props = { + onDelete: (environment: string, secretPath: string) => void; + importedEnv: string; + importedSecPath: string; + importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[]; +}; + +// to show the environment and folder icon +export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: string }) => ( +
+
{env || "-"}
+ {secretPath && ( +
+ + {secretPath} +
+ )} +
+); + +export const SecretImportItem = ({ + importedEnv, + importedSecPath, + onDelete, + importedSecrets = [] +}: Props) => { + const [isExpanded, setIsExpanded] = useToggle(); + const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({ + id: `${importedEnv}-${importedSecPath}` + }); + const { currentWorkspace } = useWorkspace(); + const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv); + + useEffect(() => { + if (isDragging) { + setIsExpanded.off(); + } + }, [isDragging]); + + const style = { + transform: transform ? `translateY(${transform.y ? Math.round(transform.y) : 0}px)` : "", + transition + }; + + return ( + <> + setIsExpanded.toggle()} + > + + + + +
+ +
+
+
+ + + + + +
+
+ + { + evt.stopPropagation(); + onDelete(importedEnv, importedSecPath); + }} + > + + + +
+
+ + + + {isExpanded && !isDragging && ( + +
+
Secrets Imported
+ + + + + + + + + + + {importedSecrets.map(({ key, value, overriden }, index) => ( + + + + + + ))} + +
KeyValueOverride
+ {key} + + {value} + + +
+
+
+ + )} + + + ); +}; diff --git a/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx new file mode 100644 index 000000000..6998b6ae3 --- /dev/null +++ b/frontend/src/views/DashboardPage/components/SecretImportSection/SecretImportSection.tsx @@ -0,0 +1,93 @@ +import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; + +import { useWorkspace } from "@app/context"; +import { DecryptedSecret } from "@app/hooks/api/secrets/types"; + +import { SecretImportItem } from "./SecretImportItem"; + +type TImportedSecrets = Array<{ + environment: string; + secretPath: string; + folderId: string; + secrets: DecryptedSecret[]; +}>; + +const SECRET_IN_DASHBOARD = "Present In Dashboard"; + +export const computeImportedSecretRows = ( + importedSecEnv: string, + importedSecPath: string, + importSecrets: TImportedSecrets = [], + secrets: DecryptedSecret[] = [], + environments: { name: string; slug: string }[] = [] +) => { + const importedSecIndex = importSecrets.findIndex( + ({ secretPath, environment }) => + secretPath === importedSecPath && importedSecEnv === environment + ); + if (importedSecIndex === -1) return []; + + const importedSec = importSecrets[importedSecIndex]; + + const overridenSec: Record = {}; + const envSlug2Name: Record = {}; + environments.forEach((el) => { + envSlug2Name[el.slug] = el.name; + }); + + for (let i = importedSecIndex + 1; i < importSecrets.length; i += 1) { + importSecrets[i].secrets.forEach((el) => { + overridenSec[el.key] = { + env: envSlug2Name?.[importSecrets[i].environment] || "unknown", + secretPath: importSecrets[i].secretPath + }; + }); + } + + secrets.forEach((el) => { + overridenSec[el.key] = { env: SECRET_IN_DASHBOARD, secretPath: "" }; + }); + + return importedSec.secrets.map(({ key, value }) => ({ + key, + value, + overriden: overridenSec?.[key] + })); +}; + +type Props = { + secrets?: DecryptedSecret[]; + importedSecrets?: TImportedSecrets; + onSecretImportDelete: (env: string, secPath: string) => void; + items: { id: string; environment: string; secretPath: string }[]; +}; + +export const SecretImportSection = ({ + secrets = [], + importedSecrets = [], + onSecretImportDelete, + items = [] +}: Props) => { + const { currentWorkspace } = useWorkspace(); + const environments = currentWorkspace?.environments || []; + + return ( + + {items.map(({ secretPath: impSecPath, environment: importSecEnv, id }) => ( + + ))} + + ); +}; diff --git a/frontend/src/views/DashboardPage/components/SecretImportSection/index.tsx b/frontend/src/views/DashboardPage/components/SecretImportSection/index.tsx new file mode 100644 index 000000000..837c9ce6d --- /dev/null +++ b/frontend/src/views/DashboardPage/components/SecretImportSection/index.tsx @@ -0,0 +1 @@ +export { SecretImportSection } from "./SecretImportSection"; diff --git a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx index fd79785d3..2a99400fb 100644 --- a/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx +++ b/frontend/src/views/DashboardPage/components/SecretInputRow/SecretInputRow.tsx @@ -407,7 +407,7 @@ export const SecretInputRow = memo(