From a6cf7107b9ea487e970ea208fc843565ddf26ac6 Mon Sep 17 00:00:00 2001 From: akhilmhdh Date: Tue, 13 Jun 2023 19:25:50 +0530 Subject: [PATCH] feat(folder-sec-overview): implemented folder based ui for sec overview --- .../src/hooks/api/secretFolders/index.tsx | 8 +- .../src/hooks/api/secretFolders/queries.tsx | 56 ++- frontend/src/hooks/api/secretFolders/types.ts | 6 + frontend/src/hooks/api/secrets/queries.tsx | 31 +- frontend/src/hooks/api/secrets/types.ts | 1 + frontend/src/pages/dashboard/[id].tsx | 13 +- frontend/src/styles/globals.css | 32 +- .../DashboardPage/DashboardEnvOverview.tsx | 411 ++++++++++-------- .../EnvComparisonRow/EnvComparisonRow.tsx | 10 +- .../EnvComparisonRow/FolderComparisonRow.tsx | 42 ++ 10 files changed, 392 insertions(+), 218 deletions(-) create mode 100644 frontend/src/views/DashboardPage/components/EnvComparisonRow/FolderComparisonRow.tsx diff --git a/frontend/src/hooks/api/secretFolders/index.tsx b/frontend/src/hooks/api/secretFolders/index.tsx index c1cc056d1b..8b48f3e204 100644 --- a/frontend/src/hooks/api/secretFolders/index.tsx +++ b/frontend/src/hooks/api/secretFolders/index.tsx @@ -1 +1,7 @@ -export { useCreateFolder, useDeleteFolder, useGetProjectFolders, useUpdateFolder } from './queries'; +export { + useCreateFolder, + useDeleteFolder, + useGetProjectFolders, + useGetProjectFoldersBatch, + useUpdateFolder +} from './queries'; diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index 008d6f26f5..57b58afea4 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiRequest } from '@app/config/request'; @@ -7,6 +7,7 @@ import { secretSnapshotKeys } from '../secretSnapshots/queries'; import { CreateFolderDTO, DeleteFolderDTO, + GetProjectFoldersBatchDTO, GetProjectFoldersDTO, TSecretFolder, UpdateFolderDTO @@ -17,6 +18,26 @@ const queryKeys = { ['secret-folders', { workspaceId, environment, parentFolderId }] as const }; +const fetchProjectFolders = async ( + workspaceId: string, + environment: string, + parentFolderId?: string, + parentFolderPath?: string +) => { + const { data } = await apiRequest.get<{ folders: TSecretFolder[]; dir: TSecretFolder[] }>( + '/api/v1/folders', + { + params: { + workspaceId, + environment, + parentFolderId, + parentFolderPath + } + } + ); + return data; +}; + export const useGetProjectFolders = ({ workspaceId, parentFolderId, @@ -27,19 +48,7 @@ export const useGetProjectFolders = ({ useQuery({ queryKey: queryKeys.getSecretFolders(workspaceId, environment, parentFolderId), enabled: Boolean(workspaceId) && Boolean(environment) && !isPaused, - queryFn: async () => { - const { data } = await apiRequest.get<{ folders: TSecretFolder[]; dir: TSecretFolder[] }>( - '/api/v1/folders', - { - params: { - workspaceId, - environment, - parentFolderId - } - } - ); - return data; - }, + queryFn: async () => fetchProjectFolders(workspaceId, environment, parentFolderId), select: useCallback( ({ folders, dir }: { folders: TSecretFolder[]; dir: TSecretFolder[] }) => ({ dir, @@ -53,6 +62,25 @@ export const useGetProjectFolders = ({ ) }); +export const useGetProjectFoldersBatch = ({ + folders = [], + isPaused, + parentFolderPath +}: GetProjectFoldersBatchDTO) => + useQueries({ + queries: folders.map(({ workspaceId, environment, parentFolderId }) => ({ + queryKey: queryKeys.getSecretFolders(workspaceId, environment, parentFolderPath), + queryFn: async () => + fetchProjectFolders(workspaceId, environment, parentFolderId, parentFolderPath), + enabled: Boolean(workspaceId) && Boolean(environment) && !isPaused, + select: (data: { folders: TSecretFolder[]; dir: TSecretFolder[] }) => ({ + environment, + folders: data.folders, + dir: data.dir + }) + })) + }); + export const useCreateFolder = () => { const queryClient = useQueryClient(); diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index a9ca176411..44a6a0859d 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -11,6 +11,12 @@ export type GetProjectFoldersDTO = { sortDir?: 'asc' | 'desc'; }; +export type GetProjectFoldersBatchDTO = { + folders: Omit[]; + isPaused?: boolean; + parentFolderPath?: string; +}; + export type CreateFolderDTO = { workspaceId: string; environment: string; diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index e4ad89dd6f..89137b03c4 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -1,4 +1,5 @@ /* eslint-disable no-param-reassign */ +import { useCallback } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { @@ -29,14 +30,16 @@ export const secretKeys = { const fetchProjectEncryptedSecrets = async ( workspaceId: string, env: string | string[], - folderId?: string + folderId?: string, + secretPath?: string ) => { if (typeof env === 'string') { const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>('/api/v2/secrets', { params: { environment: env, workspaceId, - folderId: folderId || undefined + folderId: folderId || undefined, + secretPath } }); return data.secrets; @@ -52,7 +55,8 @@ const fetchProjectEncryptedSecrets = async ( params: { environment: envPoint, workspaceId, - folderId + folderId, + secretPath } }); allEnvData = allEnvData.concat(data.secrets); @@ -77,7 +81,7 @@ export const useGetProjectSecrets = ({ enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused, queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId), queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId), - select: (data) => { + select: useCallback((data: EncryptedSecret[]) => { const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string; const latestKey = decryptFileKey; const key = decryptAssymmetric({ @@ -146,21 +150,24 @@ export const useGetProjectSecrets = ({ } }); return { secrets: sharedSecrets }; - } + }, []) }); export const useGetProjectSecretsByKey = ({ workspaceId, env, decryptFileKey, - isPaused + isPaused, + folderId, + secretPath }: GetProjectSecretsDTO) => useQuery({ // wait for all values to be available enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused, - queryKey: secretKeys.getProjectSecret(workspaceId, env), - queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env), - select: (data) => { + // right now secretpath is passed as folderid as only this is used in overview + queryKey: secretKeys.getProjectSecret(workspaceId, env, secretPath), + queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath), + select: useCallback((data: EncryptedSecret[]) => { const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string; const latestKey = decryptFileKey; const key = decryptAssymmetric({ @@ -235,7 +242,7 @@ export const useGetProjectSecretsByKey = ({ }); return { secrets: sharedSecrets, uniqueSecCount: Object.keys(uniqSecKeys).length }; - } + }, []) }); const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => { @@ -256,7 +263,7 @@ export const useGetSecretVersion = (dto: GetSecretVersionsDTO) => enabled: Boolean(dto.secretId && dto.decryptFileKey), queryKey: secretKeys.getSecretVersion(dto.secretId), queryFn: () => fetchEncryptedSecretVersion(dto.secretId, dto.offset, dto.limit), - select: (data) => { + select: useCallback((data: EncryptedSecretVersion[]) => { const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string; const latestKey = dto.decryptFileKey; const key = decryptAssymmetric({ @@ -278,7 +285,7 @@ export const useGetSecretVersion = (dto: GetSecretVersionsDTO) => }) })) .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); - } + }, []) }); export const useBatchSecretsOp = () => { diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 2a5938c681..5bde496e26 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -96,6 +96,7 @@ export type GetProjectSecretsDTO = { env: string | string[]; decryptFileKey: UserWsKeyPair; folderId?: string; + secretPath?: string; isPaused?: boolean; onSuccess?: (data: DecryptedSecret[]) => void; }; diff --git a/frontend/src/pages/dashboard/[id].tsx b/frontend/src/pages/dashboard/[id].tsx index 8d31b42917..3c6fdf9951 100644 --- a/frontend/src/pages/dashboard/[id].tsx +++ b/frontend/src/pages/dashboard/[id].tsx @@ -12,13 +12,6 @@ const Dashboard = () => { const queryEnv = router.query.env as string; const isOverviewMode = !queryEnv; - const onExploreEnv = (slug: string) => { - router.push({ - pathname: router.pathname, - query: { ...router.query, env: slug } - }); - }; - return ( <> @@ -29,11 +22,7 @@ const Dashboard = () => {
- {isOverviewMode ? ( - - ) : ( - - )} + {isOverviewMode ? : }
); diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index feca862f66..693da7ce69 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -4,20 +4,20 @@ @layer utilities { .flex-0 { - flex:0; + flex: 0; } .flex-2 { flex-grow: 2; } .flex-3 { - flex-grow: 3; + flex-grow: 3; } } @layer components { .secret-table { - @apply bg-mineshaft-800 text-left text-bunker-300 w-full; + @apply w-full bg-mineshaft-800 text-left text-bunker-300; } /* padding except for comment column */ @@ -29,13 +29,37 @@ @apply py-1 px-1 pr-2 text-sm; } - .secret-table th:not(:last-child),.secret-table td:not(:last-child) { + .secret-table th:not(:last-child), + .secret-table td:not(:last-child) { @apply border-r border-mineshaft-600; } .secret-table tr { @apply border-b border-mineshaft-600; } + + .breadcrumb::after, + .breadcrumb::before { + content: ''; + height: 60%; + width: 100%; + z-index: -1; + display: block; + position: absolute; + @apply bg-mineshaft-800; + } + + .breadcrumb::after { + left: 5px; + bottom: -3px; + transform: skew(-30deg); + } + + .breadcrumb::before { + left: 5px; + top: -3px; + transform: skew(30deg); + } } @import '@fontsource/inter/400.css'; diff --git a/frontend/src/views/DashboardPage/DashboardEnvOverview.tsx b/frontend/src/views/DashboardPage/DashboardEnvOverview.tsx index 7427bbc3e2..c95a795db4 100644 --- a/frontend/src/views/DashboardPage/DashboardEnvOverview.tsx +++ b/frontend/src/views/DashboardPage/DashboardEnvOverview.tsx @@ -1,35 +1,32 @@ import { useEffect, useMemo, useState } from 'react'; -import { FormProvider, useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useRouter } from 'next/router'; -import { faKey, faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'; +import { faFolderOpen, faKey, faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { yupResolver } from '@hookform/resolvers/yup'; import NavHeader from '@app/components/navigation/NavHeader'; import { Button, Input, TableContainer, Tooltip } from '@app/components/v2'; import { useWorkspace } from '@app/context'; import { + useGetProjectFoldersBatch, useGetProjectSecretsByKey, useGetUserWsEnvironments, useGetUserWsKey } from '@app/hooks/api'; -import { WorkspaceEnv } from '@app/hooks/api/types'; import { EnvComparisonRow } from './components/EnvComparisonRow'; -import { FormData, schema } from './DashboardPage.utils'; +import { FolderComparisonRow } from './components/EnvComparisonRow/FolderComparisonRow'; -export const DashboardEnvOverview = ({ onEnvChange }: { onEnvChange: any }) => { +export const DashboardEnvOverview = () => { const { t } = useTranslation(); const router = useRouter(); - const [selectedEnv, setSelectedEnv] = useState(null); - const { currentWorkspace, isLoading } = useWorkspace(); const workspaceId = currentWorkspace?._id as string; const { data: latestFileKey } = useGetUserWsKey(workspaceId); const [searchFilter, setSearchFilter] = useState(''); + const secretPath = router.query?.secretPath as string; useEffect(() => { if (!isLoading && !workspaceId && router.isReady) { @@ -38,14 +35,7 @@ export const DashboardEnvOverview = ({ onEnvChange }: { onEnvChange: any }) => { }, [isLoading, workspaceId, router.isReady]); const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({ - workspaceId, - onSuccess: (data) => { - // get an env with one of the access available - const env = data.find(({ isReadDenied }) => !isReadDenied); - if (env) { - setSelectedEnv(env); - } - } + workspaceId }); const userAvailableEnvs = wsEnv?.filter(({ isReadDenied }) => !isReadDenied); @@ -54,17 +44,32 @@ export const DashboardEnvOverview = ({ onEnvChange }: { onEnvChange: any }) => { workspaceId, env: userAvailableEnvs?.map((env) => env.slug) ?? [], decryptFileKey: latestFileKey!, - isPaused: false + isPaused: false, + secretPath }); - 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 folders = useGetProjectFoldersBatch({ + folders: + userAvailableEnvs?.map((env) => ({ + environment: env.slug, + workspaceId + })) ?? [], + parentFolderPath: secretPath }); + const foldersGroupedByEnv = useMemo(() => { + const res: Record> = {}; + folders.forEach(({ data }) => { + data?.folders + ?.filter(({ name }) => name.toLowerCase().includes(searchFilter)) + ?.forEach((folder) => { + if (!res?.[folder.name]) res[folder.name] = {}; + res[folder.name][data.environment] = true; + }); + }); + return res; + }, [folders, userAvailableEnvs, searchFilter]); + const numSecretsMissingPerEnv = useMemo(() => { // first get all sec in the env then subtract with total to get missing ones const secPerEnvMissing: Record = Object.fromEntries( @@ -81,7 +86,43 @@ export const DashboardEnvOverview = ({ onEnvChange }: { onEnvChange: any }) => { return secPerEnvMissing; }, [secrets, userAvailableEnvs]); - const isReadOnly = selectedEnv?.isWriteDenied; + const onExploreEnv = (slug: string) => { + const query: Record = { ...router.query, env: slug }; + delete query.secretPath; + // the dir return will have the present directory folder id + // use that when clicking on explore to redirect user to there + const envFolder = folders.find(({ data }) => slug === data?.environment); + const dir = envFolder?.data?.dir?.pop(); + if (dir) { + query.folderId = dir.id; + } + + router.push({ + pathname: router.pathname, + query + }); + }; + + const onFolderClick = (path: string) => { + router.push({ + pathname: router.pathname, + query: { + ...router.query, + secretPath: `${router.query?.secretPath || ''}/${path}` + } + }); + }; + + const onFolderCrumbClick = (index: number) => { + const newSecPath = secretPath.split('/').filter(Boolean).slice(0, index).join('/'); + const query = { ...router.query, secretPath: `/${newSecPath}` } as Record; + // root condition + if (index === 0) delete query.secretPath; + router.push({ + pathname: router.pathname, + query + }); + }; if (isSecretsLoading || isEnvListLoading) { return ( @@ -91,165 +132,195 @@ export const DashboardEnvOverview = ({ onEnvChange }: { onEnvChange: any }) => { ); } + const filteredSecrets = Object.keys(secrets?.secrets || {})?.filter((secret: any) => + secret.toUpperCase().includes(searchFilter.toUpperCase()) + ); // when secrets is not loading and secrets list is empty - const isDashboardSecretEmpty = !isSecretsLoading && !Object.keys(secrets?.secrets || {})?.filter((secret: any) => secret.toUpperCase().includes(searchFilter.toUpperCase()))?.length; + const isDashboardSecretEmpty = !isSecretsLoading && !filteredSecrets?.length; + const isFoldersEmtpy = + !folders.some(({ isLoading: isFolderLoading }) => isFolderLoading) && + !Object.keys(foldersGroupedByEnv).length; + const isDashboardEmpty = isFoldersEmtpy && isDashboardSecretEmpty; return (
- -
- {/* breadcrumb row */} -
- +
+ +
+
+

Secrets Overview

+

+ Inject your secrets using + + Infisical CLI + + or + + Infisical SDKs + +

+
+
+
+
onFolderCrumbClick(0)} + onKeyDown={() => null} + role="button" + tabIndex={0} + > +
-
-

Secrets Overview

-

- Inject your secrets using - ( +

-
- setSearchFilter(e.target.value)} - leftIcon={} - /> -
-
-
-
-
{0}
+ {path}
-
-
-
Secret
-
-
- {numSecretsMissingPerEnv && - userAvailableEnvs?.map((env) => { - return ( -
-
- {env.name} - {numSecretsMissingPerEnv[env.slug] > 0 && ( -
- - - {numSecretsMissingPerEnv[env.slug]} - - -
- )} + ))} +
+
+ setSearchFilter(e.target.value)} + leftIcon={} + /> +
+
+
+
+
+
{0}
+
+
+
+
Secret
+
+
+ {numSecretsMissingPerEnv && + userAvailableEnvs?.map((env) => { + return ( +
+
+ {env.name} + {numSecretsMissingPerEnv[env.slug] > 0 && ( +
+ + + {numSecretsMissingPerEnv[env.slug]} + +
-
- ); - })} -
-
- {!isDashboardSecretEmpty && ( - - - - {Object.keys(secrets?.secrets || {})?.filter((secret: any) => secret.toUpperCase().includes(searchFilter.toUpperCase())).map((key, index) => ( - - ))} - -
-
- )} - {isDashboardSecretEmpty && ( -
-
- - No secrets found. - To add more secrets you can explore any environment. + )}
- )} - {/* In future, we should add an option to add environments here -
- -
*/} -
-
-
-
0
+ ); + })} +
+
+ {!isDashboardEmpty && ( + + + + {Object.keys(foldersGroupedByEnv || {}).map((folderName, index) => ( + + ))} + {Object.keys(secrets?.secrets || {}) + ?.filter((secret: any) => + secret.toUpperCase().includes(searchFilter.toUpperCase()) + ) + .map((key) => ( + + ))} + +
+
+ )} + {isDashboardEmpty && ( +
+
+ + No secrets/folders found. + To add more secrets you can explore any environment.
-
- 0 - -
- {userAvailableEnvs?.map((env) => { - return ( -
- -
- ); - })}
+ )} +
+
+
+
0
- - +
+ 0 + +
+ {userAvailableEnvs?.map((env) => { + return ( +
+ +
+ ); + })} +
+
); }; diff --git a/frontend/src/views/DashboardPage/components/EnvComparisonRow/EnvComparisonRow.tsx b/frontend/src/views/DashboardPage/components/EnvComparisonRow/EnvComparisonRow.tsx index cb12a3d9ad..6ef1eb45a0 100644 --- a/frontend/src/views/DashboardPage/components/EnvComparisonRow/EnvComparisonRow.tsx +++ b/frontend/src/views/DashboardPage/components/EnvComparisonRow/EnvComparisonRow.tsx @@ -1,11 +1,10 @@ /* eslint-disable react/jsx-no-useless-fragment */ import { useCallback, useState } from 'react'; -import { faCircle, faEye, faEyeSlash, faMinus } from '@fortawesome/free-solid-svg-icons'; +import { faCircle, faEye, faEyeSlash, faKey, faMinus } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { twMerge } from 'tailwind-merge'; type Props = { - index: number; secrets: any[] | undefined; // permission and external state's that decided to hide or show isReadOnly?: boolean; @@ -30,7 +29,7 @@ const DashboardInput = ({ if (val === undefined) return ( - + ); if (val?.length === 0) @@ -110,7 +109,6 @@ const DashboardInput = ({ }; export const EnvComparisonRow = ({ - index, secrets, isSecretValueHidden, isReadOnly, @@ -126,7 +124,9 @@ export const EnvComparisonRow = ({ return ( -
{index + 1}
+
+ +
diff --git a/frontend/src/views/DashboardPage/components/EnvComparisonRow/FolderComparisonRow.tsx b/frontend/src/views/DashboardPage/components/EnvComparisonRow/FolderComparisonRow.tsx new file mode 100644 index 0000000000..b63c05dc2c --- /dev/null +++ b/frontend/src/views/DashboardPage/components/EnvComparisonRow/FolderComparisonRow.tsx @@ -0,0 +1,42 @@ +import { faCheck, faFolder, faX } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +type Props = { + folderInEnv: Record; + userAvailableEnvs?: Array<{ slug: string; name: string }>; + folderName: string; + onClick: (folderName: string) => void; +}; + +export const FolderComparisonRow = ({ + folderInEnv = {}, + userAvailableEnvs = [], + folderName, + onClick +}: Props) => ( + onClick(folderName)} + > + +
+ +
+ + +
{folderName}
+ + {userAvailableEnvs?.map(({ slug }) => ( + + + + ))} + +);