Merge pull request #642 from akhilmhdh/feat/folder-env-overview

Folder support in secret overview page
This commit is contained in:
Maidul Islam
2023-06-16 13:19:16 -04:00
committed by GitHub
13 changed files with 432 additions and 228 deletions
@@ -11,6 +11,7 @@ import {
validateFolderName, validateFolderName,
generateFolderId, generateFolderId,
getParentFromFolderId, getParentFromFolderId,
getFolderByPath,
} from "../../services/FolderService"; } from "../../services/FolderService";
import { ADMIN, MEMBER } from "../../variables"; import { ADMIN, MEMBER } from "../../variables";
import { validateMembership } from "../../helpers/membership"; import { validateMembership } from "../../helpers/membership";
@@ -177,11 +178,13 @@ export const deleteFolder = async (req: Request, res: Response) => {
// TODO: validate workspace // TODO: validate workspace
export const getFolders = async (req: Request, res: Response) => { export const getFolders = async (req: Request, res: Response) => {
const { workspaceId, environment, parentFolderId } = req.query as { const { workspaceId, environment, parentFolderId, parentFolderPath } =
workspaceId: string; req.query as {
environment: string; workspaceId: string;
parentFolderId?: string; environment: string;
}; parentFolderId?: string;
parentFolderPath?: string;
};
const folders = await Folder.findOne({ workspace: workspaceId, environment }); const folders = await Folder.findOne({ workspace: workspaceId, environment });
if (!folders) { if (!folders) {
@@ -196,6 +199,20 @@ export const getFolders = async (req: Request, res: Response) => {
acceptedRoles: [ADMIN, MEMBER], acceptedRoles: [ADMIN, MEMBER],
}); });
// if instead of parentFolderId given a path like /folder1/folder2
if (parentFolderPath) {
const folder = getFolderByPath(folders.nodes, parentFolderPath);
if (!folder) {
res.send({ folders: [], dir: [] });
return;
}
// dir is not needed at present as this is only used in overview section of secrets
res.send({
folders: folder.children.map(({ id, name }) => ({ id, name })),
dir: [{ name: folder.name, id: folder.id }],
});
}
if (!parentFolderId) { if (!parentFolderId) {
const rootFolders = folders.nodes.children.map(({ id, name }) => ({ const rootFolders = folders.nodes.children.map(({ id, name }) => ({
id, id,
@@ -700,11 +700,15 @@ export const getSecrets = async (req: Request, res: Response) => {
(!folders && folderId && folderId !== "root") || (!folders && folderId && folderId !== "root") ||
(!folders && secretPath) (!folders && secretPath)
) { ) {
throw BadRequestError({ message: "Folder not found" }); res.send({ secrets: [] });
return;
} }
if (folders && folderId !== "root") { if (folders && folderId !== "root") {
const folder = searchByFolderId(folders.nodes, folderId as string); const folder = searchByFolderId(folders.nodes, folderId as string);
if (!folder) throw BadRequestError({ message: "Folder not found" }); if (!folder) {
res.send({ secrets: [] });
return;
}
} }
if (req.authData.authPayload instanceof ServiceTokenData) { if (req.authData.authPayload instanceof ServiceTokenData) {
@@ -720,10 +724,11 @@ export const getSecrets = async (req: Request, res: Response) => {
} }
if (folders && secretPath) { if (folders && secretPath) {
if (!folders) throw BadRequestError({ message: "Folder not found" }); // avoid throwing error and send empty list
const folder = getFolderByPath(folders.nodes, secretPath as string); const folder = getFolderByPath(folders.nodes, secretPath as string);
if (!folder) { if (!folder) {
throw BadRequestError({ message: "Secret path not found" }); res.send({ secrets: [] });
return;
} }
folderId = folder.id; folderId = folder.id;
} }
+1
View File
@@ -63,6 +63,7 @@ router.get(
query("workspaceId").exists().isString().trim(), query("workspaceId").exists().isString().trim(),
query("environment").exists().isString().trim(), query("environment").exists().isString().trim(),
query("parentFolderId").optional().isString().trim(), query("parentFolderId").optional().isString().trim(),
query("parentFolderPath").optional().isString().trim(),
validateRequest, validateRequest,
getFolders getFolders
); );
@@ -1 +1,7 @@
export { useCreateFolder, useDeleteFolder, useGetProjectFolders, useUpdateFolder } from './queries'; export {
useCreateFolder,
useDeleteFolder,
useGetProjectFolders,
useGetProjectFoldersBatch,
useUpdateFolder
} from './queries';
@@ -1,5 +1,5 @@
import { useCallback } from 'react'; 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'; import { apiRequest } from '@app/config/request';
@@ -7,6 +7,7 @@ import { secretSnapshotKeys } from '../secretSnapshots/queries';
import { import {
CreateFolderDTO, CreateFolderDTO,
DeleteFolderDTO, DeleteFolderDTO,
GetProjectFoldersBatchDTO,
GetProjectFoldersDTO, GetProjectFoldersDTO,
TSecretFolder, TSecretFolder,
UpdateFolderDTO UpdateFolderDTO
@@ -17,6 +18,26 @@ const queryKeys = {
['secret-folders', { workspaceId, environment, parentFolderId }] as const ['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 = ({ export const useGetProjectFolders = ({
workspaceId, workspaceId,
parentFolderId, parentFolderId,
@@ -27,19 +48,7 @@ export const useGetProjectFolders = ({
useQuery({ useQuery({
queryKey: queryKeys.getSecretFolders(workspaceId, environment, parentFolderId), queryKey: queryKeys.getSecretFolders(workspaceId, environment, parentFolderId),
enabled: Boolean(workspaceId) && Boolean(environment) && !isPaused, enabled: Boolean(workspaceId) && Boolean(environment) && !isPaused,
queryFn: async () => { queryFn: async () => fetchProjectFolders(workspaceId, environment, parentFolderId),
const { data } = await apiRequest.get<{ folders: TSecretFolder[]; dir: TSecretFolder[] }>(
'/api/v1/folders',
{
params: {
workspaceId,
environment,
parentFolderId
}
}
);
return data;
},
select: useCallback( select: useCallback(
({ folders, dir }: { folders: TSecretFolder[]; dir: TSecretFolder[] }) => ({ ({ folders, dir }: { folders: TSecretFolder[]; dir: TSecretFolder[] }) => ({
dir, 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 = () => { export const useCreateFolder = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -11,6 +11,12 @@ export type GetProjectFoldersDTO = {
sortDir?: 'asc' | 'desc'; sortDir?: 'asc' | 'desc';
}; };
export type GetProjectFoldersBatchDTO = {
folders: Omit<GetProjectFoldersDTO, 'isPaused' | 'sortDir'>[];
isPaused?: boolean;
parentFolderPath?: string;
};
export type CreateFolderDTO = { export type CreateFolderDTO = {
workspaceId: string; workspaceId: string;
environment: string; environment: string;
+19 -12
View File
@@ -1,4 +1,5 @@
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import { useCallback } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { import {
@@ -29,14 +30,16 @@ export const secretKeys = {
const fetchProjectEncryptedSecrets = async ( const fetchProjectEncryptedSecrets = async (
workspaceId: string, workspaceId: string,
env: string | string[], env: string | string[],
folderId?: string folderId?: string,
secretPath?: string
) => { ) => {
if (typeof env === 'string') { if (typeof env === 'string') {
const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>('/api/v2/secrets', { const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>('/api/v2/secrets', {
params: { params: {
environment: env, environment: env,
workspaceId, workspaceId,
folderId: folderId || undefined folderId: folderId || undefined,
secretPath
} }
}); });
return data.secrets; return data.secrets;
@@ -52,7 +55,8 @@ const fetchProjectEncryptedSecrets = async (
params: { params: {
environment: envPoint, environment: envPoint,
workspaceId, workspaceId,
folderId folderId,
secretPath
} }
}); });
allEnvData = allEnvData.concat(data.secrets); allEnvData = allEnvData.concat(data.secrets);
@@ -77,7 +81,7 @@ export const useGetProjectSecrets = ({
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused, enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId), queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId),
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId), queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId),
select: (data) => { select: useCallback((data: EncryptedSecret[]) => {
const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string; const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string;
const latestKey = decryptFileKey; const latestKey = decryptFileKey;
const key = decryptAssymmetric({ const key = decryptAssymmetric({
@@ -146,21 +150,24 @@ export const useGetProjectSecrets = ({
} }
}); });
return { secrets: sharedSecrets }; return { secrets: sharedSecrets };
} }, [decryptFileKey])
}); });
export const useGetProjectSecretsByKey = ({ export const useGetProjectSecretsByKey = ({
workspaceId, workspaceId,
env, env,
decryptFileKey, decryptFileKey,
isPaused isPaused,
folderId,
secretPath
}: GetProjectSecretsDTO) => }: GetProjectSecretsDTO) =>
useQuery({ useQuery({
// wait for all values to be available // wait for all values to be available
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused, enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
queryKey: secretKeys.getProjectSecret(workspaceId, env), // right now secretpath is passed as folderid as only this is used in overview
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env), queryKey: secretKeys.getProjectSecret(workspaceId, env, secretPath),
select: (data) => { queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath),
select: useCallback((data: EncryptedSecret[]) => {
const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string; const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string;
const latestKey = decryptFileKey; const latestKey = decryptFileKey;
const key = decryptAssymmetric({ const key = decryptAssymmetric({
@@ -235,7 +242,7 @@ export const useGetProjectSecretsByKey = ({
}); });
return { secrets: sharedSecrets, uniqueSecCount: Object.keys(uniqSecKeys).length }; return { secrets: sharedSecrets, uniqueSecCount: Object.keys(uniqSecKeys).length };
} }, [decryptFileKey])
}); });
const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => { const fetchEncryptedSecretVersion = async (secretId: string, offset: number, limit: number) => {
@@ -256,7 +263,7 @@ export const useGetSecretVersion = (dto: GetSecretVersionsDTO) =>
enabled: Boolean(dto.secretId && dto.decryptFileKey), enabled: Boolean(dto.secretId && dto.decryptFileKey),
queryKey: secretKeys.getSecretVersion(dto.secretId), queryKey: secretKeys.getSecretVersion(dto.secretId),
queryFn: () => fetchEncryptedSecretVersion(dto.secretId, dto.offset, dto.limit), queryFn: () => fetchEncryptedSecretVersion(dto.secretId, dto.offset, dto.limit),
select: (data) => { select: useCallback((data: EncryptedSecretVersion[]) => {
const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string; const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string;
const latestKey = dto.decryptFileKey; const latestKey = dto.decryptFileKey;
const key = decryptAssymmetric({ const key = decryptAssymmetric({
@@ -278,7 +285,7 @@ export const useGetSecretVersion = (dto: GetSecretVersionsDTO) =>
}) })
})) }))
.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); .sort((a, b) => b.createdAt.localeCompare(a.createdAt));
} }, [])
}); });
export const useBatchSecretsOp = () => { export const useBatchSecretsOp = () => {
+1
View File
@@ -96,6 +96,7 @@ export type GetProjectSecretsDTO = {
env: string | string[]; env: string | string[];
decryptFileKey: UserWsKeyPair; decryptFileKey: UserWsKeyPair;
folderId?: string; folderId?: string;
secretPath?: string;
isPaused?: boolean; isPaused?: boolean;
onSuccess?: (data: DecryptedSecret[]) => void; onSuccess?: (data: DecryptedSecret[]) => void;
}; };
+1 -12
View File
@@ -12,13 +12,6 @@ const Dashboard = () => {
const queryEnv = router.query.env as string; const queryEnv = router.query.env as string;
const isOverviewMode = !queryEnv; const isOverviewMode = !queryEnv;
const onExploreEnv = (slug: string) => {
router.push({
pathname: router.pathname,
query: { ...router.query, env: slug }
});
};
return ( return (
<> <>
<Head> <Head>
@@ -29,11 +22,7 @@ const Dashboard = () => {
<meta name="og:description" content={String(t('dashboard.og-description'))} /> <meta name="og:description" content={String(t('dashboard.og-description'))} />
</Head> </Head>
<div className="h-full"> <div className="h-full">
{isOverviewMode ? ( {isOverviewMode ? <DashboardEnvOverview /> : <DashboardPage envFromTop={queryEnv} />}
<DashboardEnvOverview onEnvChange={onExploreEnv} />
) : (
<DashboardPage envFromTop={queryEnv} />
)}
</div> </div>
</> </>
); );
+36 -4
View File
@@ -4,20 +4,20 @@
@layer utilities { @layer utilities {
.flex-0 { .flex-0 {
flex:0; flex: 0;
} }
.flex-2 { .flex-2 {
flex-grow: 2; flex-grow: 2;
} }
.flex-3 { .flex-3 {
flex-grow: 3; flex-grow: 3;
} }
} }
@layer components { @layer components {
.secret-table { .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 */ /* padding except for comment column */
@@ -29,13 +29,45 @@
@apply py-1 px-1 pr-2 text-sm; @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; @apply border-r border-mineshaft-600;
} }
.secret-table tr { .secret-table tr {
@apply border-b border-mineshaft-600; @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:hover::before {
@apply bg-mineshaft-600;
}
.breadcrumb:hover::after {
@apply bg-mineshaft-600;
}
.breadcrumb::after {
left: 5px;
bottom: -3px;
transform: skew(-30deg);
}
.breadcrumb::before {
left: 5px;
top: -3px;
transform: skew(30deg);
}
} }
@import '@fontsource/inter/400.css'; @import '@fontsource/inter/400.css';
@@ -1,35 +1,31 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useRouter } from 'next/router'; 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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { yupResolver } from '@hookform/resolvers/yup';
import NavHeader from '@app/components/navigation/NavHeader'; import NavHeader from '@app/components/navigation/NavHeader';
import { Button, Input, TableContainer, Tooltip } from '@app/components/v2'; import { Button, Input, TableContainer, Tooltip } from '@app/components/v2';
import { useWorkspace } from '@app/context'; import { useWorkspace } from '@app/context';
import { import {
useGetProjectFoldersBatch,
useGetProjectSecretsByKey, useGetProjectSecretsByKey,
useGetUserWsEnvironments, useGetUserWsEnvironments,
useGetUserWsKey useGetUserWsKey
} from '@app/hooks/api'; } from '@app/hooks/api';
import { WorkspaceEnv } from '@app/hooks/api/types';
import { EnvComparisonRow } from './components/EnvComparisonRow'; 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 { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const [selectedEnv, setSelectedEnv] = useState<WorkspaceEnv | null>(null);
const { currentWorkspace, isLoading } = useWorkspace(); const { currentWorkspace, isLoading } = useWorkspace();
const workspaceId = currentWorkspace?._id as string; const workspaceId = currentWorkspace?._id as string;
const { data: latestFileKey } = useGetUserWsKey(workspaceId); const { data: latestFileKey } = useGetUserWsKey(workspaceId);
const [searchFilter, setSearchFilter] = useState(''); const [searchFilter, setSearchFilter] = useState('');
const secretPath = router.query?.secretPath as string;
useEffect(() => { useEffect(() => {
if (!isLoading && !workspaceId && router.isReady) { if (!isLoading && !workspaceId && router.isReady) {
@@ -38,14 +34,7 @@ export const DashboardEnvOverview = ({ onEnvChange }: { onEnvChange: any }) => {
}, [isLoading, workspaceId, router.isReady]); }, [isLoading, workspaceId, router.isReady]);
const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({ const { data: wsEnv, isLoading: isEnvListLoading } = useGetUserWsEnvironments({
workspaceId, workspaceId
onSuccess: (data) => {
// get an env with one of the access available
const env = data.find(({ isReadDenied }) => !isReadDenied);
if (env) {
setSelectedEnv(env);
}
}
}); });
const userAvailableEnvs = wsEnv?.filter(({ isReadDenied }) => !isReadDenied); const userAvailableEnvs = wsEnv?.filter(({ isReadDenied }) => !isReadDenied);
@@ -54,17 +43,32 @@ export const DashboardEnvOverview = ({ onEnvChange }: { onEnvChange: any }) => {
workspaceId, workspaceId,
env: userAvailableEnvs?.map((env) => env.slug) ?? [], env: userAvailableEnvs?.map((env) => env.slug) ?? [],
decryptFileKey: latestFileKey!, decryptFileKey: latestFileKey!,
isPaused: false isPaused: false,
secretPath
}); });
const method = useForm<FormData>({ const folders = useGetProjectFoldersBatch({
// why any: well yup inferred ts expects other keys to defined as undefined folders:
defaultValues: secrets as any, userAvailableEnvs?.map((env) => ({
values: secrets as any, environment: env.slug,
mode: 'onBlur', workspaceId
resolver: yupResolver(schema) })) ?? [],
parentFolderPath: secretPath
}); });
const foldersGroupedByEnv = useMemo(() => {
const res: Record<string, Record<string, boolean>> = {};
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(() => { const numSecretsMissingPerEnv = useMemo(() => {
// first get all sec in the env then subtract with total to get missing ones // first get all sec in the env then subtract with total to get missing ones
const secPerEnvMissing: Record<string, number> = Object.fromEntries( const secPerEnvMissing: Record<string, number> = Object.fromEntries(
@@ -81,7 +85,43 @@ export const DashboardEnvOverview = ({ onEnvChange }: { onEnvChange: any }) => {
return secPerEnvMissing; return secPerEnvMissing;
}, [secrets, userAvailableEnvs]); }, [secrets, userAvailableEnvs]);
const isReadOnly = selectedEnv?.isWriteDenied; const onExploreEnv = (slug: string) => {
const query: Record<string, string> = { ...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<string, string>;
// root condition
if (index === 0) delete query.secretPath;
router.push({
pathname: router.pathname,
query
});
};
if (isSecretsLoading || isEnvListLoading) { if (isSecretsLoading || isEnvListLoading) {
return ( return (
@@ -91,165 +131,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 // 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 ( return (
<div className="container mx-auto max-w-full px-6 text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto max-w-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<FormProvider {...method}> <div className="relative right-5">
<form autoComplete="off"> <NavHeader pageName={t('dashboard.title')} isProjectRelated />
{/* breadcrumb row */} </div>
<div className="relative right-5"> <div className="mt-6">
<NavHeader pageName={t('dashboard.title')} isProjectRelated /> <p className="text-3xl font-semibold text-bunker-100">Secrets Overview</p>
<p className="text-md text-bunker-300">
Inject your secrets using
<a
className="mx-1 text-primary/80 hover:text-primary"
href="https://infisical.com/docs/cli/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical CLI
</a>
or
<a
className="mx-1 text-primary/80 hover:text-primary"
href="https://infisical.com/docs/sdks/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical SDKs
</a>
</p>
</div>
<div className="mt-8 flex items-center justify-between">
<div className="flex items-center space-x-2">
<div
className="breadcrumb relative z-20 border-solid border-mineshaft-600 bg-mineshaft-800 hover:bg-mineshaft-600 py-1 pl-5 pr-2 text-sm"
onClick={() => onFolderCrumbClick(0)}
onKeyDown={() => null}
role="button"
tabIndex={0}
>
<FontAwesomeIcon icon={faFolderOpen} className="text-primary" />
</div> </div>
<div className="mt-6"> {(secretPath || '')
<p className="text-3xl font-semibold text-bunker-100">Secrets Overview</p> .split('/')
<p className="text-md text-bunker-300"> .filter(Boolean)
Inject your secrets using .map((path, index, arr) => (
<a <div
className="mx-1 text-primary/80 hover:text-primary" key={`secret-path-${index + 1}`}
href="https://infisical.com/docs/cli/overview" className={`breadcrumb relative z-20 ${
target="_blank" index + 1 === arr.length ? 'cursor-default' : 'cursor-pointer'
rel="noopener noreferrer" } border-solid border-mineshaft-600 py-1 pl-5 pr-2 text-sm text-mineshaft-200`}
onClick={() => onFolderCrumbClick(index + 1)}
onKeyDown={() => null}
role="button"
tabIndex={0}
> >
Infisical CLI {path}
</a>
or
<a
className="mx-1 text-primary/80 hover:text-primary"
href="https://infisical.com/docs/sdks/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical SDKs
</a>
</p>
</div>
<div className="absolute top-[11.1rem] right-6 flex w-full max-w-sm 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 secret name..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
/>
</div>
<div className="overflow-y-auto">
<div className="sticky top-0 mt-8 flex h-10 min-w-[60.3rem] flex-row rounded-md border border-mineshaft-600 bg-mineshaft-800">
<div className="sticky top-0 flex w-10 items-center justify-center border-none px-4">
<div className="w-10 text-center text-xs text-transparent">{0}</div>
</div> </div>
<div className="sticky top-0 border-none"> ))}
<div className="relative flex h-full w-full min-w-[200px] items-center justify-start lg:min-w-[220px] xl:min-w-[250px]"> </div>
<div className="text-sm font-medium ">Secret</div> <div className="w-80">
</div> <Input
</div> className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
{numSecretsMissingPerEnv && placeholder="Search by secret/folder name..."
userAvailableEnvs?.map((env) => { value={searchFilter}
return ( onChange={(e) => setSearchFilter(e.target.value)}
<div leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
key={`header-${env.slug}`} />
className="flex w-full min-w-[11rem] flex-row items-center rounded-md border-none bg-mineshaft-800" </div>
> </div>
<div className="flex w-full flex-row justify-center text-center text-sm font-medium text-bunker-200/[.99]"> <div className="overflow-y-auto">
{env.name} <div className="sticky top-0 mt-3 flex h-10 min-w-[60.3rem] flex-row rounded-md border border-mineshaft-600 bg-mineshaft-800">
{numSecretsMissingPerEnv[env.slug] > 0 && ( <div className="sticky top-0 flex w-10 items-center justify-center border-none px-4">
<div className="mt-0.5 ml-2.5 flex h-[1.1rem] w-[1.1rem] cursor-default items-center justify-center rounded-sm border border-red-400 bg-red text-xs text-bunker-100"> <div className="w-10 text-center text-xs text-transparent">{0}</div>
<Tooltip </div>
content={`${ <div className="sticky top-0 border-none">
numSecretsMissingPerEnv[env.slug] <div className="relative flex h-full w-full min-w-[200px] items-center justify-start lg:min-w-[220px] xl:min-w-[250px]">
} secrets missing compared to other environments`} <div className="text-sm font-medium ">Secret</div>
> </div>
<span className="text-bunker-100"> </div>
{numSecretsMissingPerEnv[env.slug]} {numSecretsMissingPerEnv &&
</span> userAvailableEnvs?.map((env) => {
</Tooltip> return (
</div> <div
)} key={`header-${env.slug}`}
className="flex w-full min-w-[11rem] flex-row items-center rounded-md border-none bg-mineshaft-800"
>
<div className="flex w-full flex-row justify-center text-center text-sm font-medium text-bunker-200/[.99]">
{env.name}
{numSecretsMissingPerEnv[env.slug] > 0 && (
<div className="mt-0.5 ml-2.5 flex h-[1.1rem] w-[1.1rem] cursor-default items-center justify-center rounded-sm border border-red-400 bg-red text-xs text-bunker-100">
<Tooltip
content={`${
numSecretsMissingPerEnv[env.slug]
} secrets missing compared to other environments`}
>
<span className="text-bunker-100">
{numSecretsMissingPerEnv[env.slug]}
</span>
</Tooltip>
</div> </div>
</div> )}
);
})}
</div>
<div
className={`${
isDashboardSecretEmpty ? '' : ''
} no-scrollbar::-webkit-scrollbar mt-3 flex h-full max-h-[calc(100vh-370px)] w-full min-w-[60.3rem] flex-grow flex-row items-start justify-center overflow-x-hidden rounded-md border border-mineshaft-600 no-scrollbar`}
>
{!isDashboardSecretEmpty && (
<TableContainer className="border-none">
<table className="secret-table relative w-full bg-mineshaft-900">
<tbody className="max-h-screen overflow-y-auto">
{Object.keys(secrets?.secrets || {})?.filter((secret: any) => secret.toUpperCase().includes(searchFilter.toUpperCase())).map((key, index) => (
<EnvComparisonRow
key={`row-${key}`}
secrets={secrets?.secrets?.[key]}
isReadOnly={isReadOnly}
index={index}
isSecretValueHidden
userAvailableEnvs={userAvailableEnvs}
/>
))}
</tbody>
</table>
</TableContainer>
)}
{isDashboardSecretEmpty && (
<div className="flex h-40 w-full flex-row rounded-md">
<div className="flex w-full min-w-[11rem] flex-col items-center justify-center rounded-md border-none bg-mineshaft-800 text-bunker-300">
<FontAwesomeIcon icon={faKey} className="text-4xl mb-4" />
<span className="mb-1">No secrets found.</span>
<span>To add more secrets you can explore any environment.</span>
</div> </div>
</div> </div>
)} );
{/* In future, we should add an option to add environments here })}
<div className="flex items-start justify-center h-full ml-10"> </div>
<Button <div
leftIcon={<FontAwesomeIcon icon={faPlus}/>} className={`${
onClick={() => prepend(DEFAULT_SECRET_VALUE, { shouldFocus: false })} isDashboardEmpty ? '' : ''
variant="outline_bg" } no-scrollbar::-webkit-scrollbar mt-3 flex h-full max-h-[calc(100vh-370px)] w-full min-w-[60.3rem] flex-grow flex-row items-start justify-center overflow-x-hidden rounded-md border border-mineshaft-600 no-scrollbar`}
colorSchema="primary" >
isFullWidth {!isDashboardEmpty && (
className="h-10" <TableContainer className="border-none">
> <table className="secret-table relative w-full bg-mineshaft-900">
Add Environment <tbody className="max-h-screen overflow-y-auto">
</Button> {Object.keys(foldersGroupedByEnv || {}).map((folderName, index) => (
</div> */} <FolderComparisonRow
</div> key={`${folderName}-${index + 1}`}
<div className="group mt-4 flex min-w-[60.3rem] flex-row items-center"> folderName={folderName}
<div className="flex h-10 w-10 items-center justify-center border-none px-4"> userAvailableEnvs={userAvailableEnvs}
<div className="w-10 text-center text-xs text-transparent">0</div> folderInEnv={foldersGroupedByEnv[folderName]}
onClick={onFolderClick}
/>
))}
{Object.keys(secrets?.secrets || {})
?.filter((secret: any) =>
secret.toUpperCase().includes(searchFilter.toUpperCase())
)
.map((key) => (
<EnvComparisonRow
key={`row-${key}`}
secrets={secrets?.secrets?.[key]}
isReadOnly
isSecretValueHidden
userAvailableEnvs={userAvailableEnvs}
/>
))}
</tbody>
</table>
</TableContainer>
)}
{isDashboardEmpty && (
<div className="flex h-40 w-full flex-row rounded-md">
<div className="flex w-full min-w-[11rem] flex-col items-center justify-center rounded-md border-none bg-mineshaft-800 text-bunker-300">
<FontAwesomeIcon icon={faKey} className="mb-4 text-4xl" />
<span className="mb-1">No secrets/folders found.</span>
<span>To add more secrets you can explore any environment.</span>
</div> </div>
<div className="flex min-w-[200px] flex-row items-center justify-between lg:min-w-[220px] xl:min-w-[250px]">
<span className="text-transparent">0</span>
<button type="button" className="mr-2 text-transparent">
1
</button>
</div>
{userAvailableEnvs?.map((env) => {
return (
<div
key={`button-${env.slug}`}
className="mx-2 mb-1 flex h-10 w-full min-w-[11rem] flex-row items-center justify-center border-none"
>
<Button
onClick={() => onEnvChange(env.slug)}
// router.push(`${router.asPath }?env=${env.slug}`)
variant="outline_bg"
colorSchema="primary"
isFullWidth
className="h-10"
>
Explore {env.name}
</Button>
</div>
);
})}
</div> </div>
)}
</div>
<div className="group mt-4 flex min-w-[60.3rem] flex-row items-center">
<div className="flex h-10 w-10 items-center justify-center border-none px-4">
<div className="w-10 text-center text-xs text-transparent">0</div>
</div> </div>
</form> <div className="flex min-w-[200px] flex-row items-center justify-between lg:min-w-[220px] xl:min-w-[250px]">
</FormProvider> <span className="text-transparent">0</span>
<button type="button" className="mr-2 text-transparent">
1
</button>
</div>
{userAvailableEnvs?.map((env) => {
return (
<div
key={`button-${env.slug}`}
className="mx-2 mb-1 flex h-10 w-full min-w-[11rem] flex-row items-center justify-center border-none"
>
<Button
onClick={() => onExploreEnv(env.slug)}
variant="outline_bg"
colorSchema="primary"
isFullWidth
className="h-10"
>
Explore {env.name}
</Button>
</div>
);
})}
</div>
</div>
</div> </div>
); );
}; };
@@ -1,11 +1,10 @@
/* eslint-disable react/jsx-no-useless-fragment */ /* eslint-disable react/jsx-no-useless-fragment */
import { useCallback, useState } from 'react'; 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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { twMerge } from 'tailwind-merge'; import { twMerge } from 'tailwind-merge';
type Props = { type Props = {
index: number;
secrets: any[] | undefined; secrets: any[] | undefined;
// permission and external state's that decided to hide or show // permission and external state's that decided to hide or show
isReadOnly?: boolean; isReadOnly?: boolean;
@@ -30,7 +29,7 @@ const DashboardInput = ({
if (val === undefined) if (val === undefined)
return ( return (
<span className="cursor-default font-sans text-xs italic text-red-500/80"> <span className="cursor-default font-sans text-xs italic text-red-500/80">
<FontAwesomeIcon icon={faMinus} className="mt-1" /> <FontAwesomeIcon icon={faMinus} className="mt-1" />
</span> </span>
); );
if (val?.length === 0) if (val?.length === 0)
@@ -110,7 +109,6 @@ const DashboardInput = ({
}; };
export const EnvComparisonRow = ({ export const EnvComparisonRow = ({
index,
secrets, secrets,
isSecretValueHidden, isSecretValueHidden,
isReadOnly, isReadOnly,
@@ -126,7 +124,9 @@ export const EnvComparisonRow = ({
return ( return (
<tr className="group flex min-w-full flex-row items-center hover:bg-mineshaft-800"> <tr className="group flex min-w-full flex-row items-center hover:bg-mineshaft-800">
<td className="flex h-10 w-10 items-center justify-center border-none px-4"> <td className="flex h-10 w-10 items-center justify-center border-none px-4">
<div className="w-10 text-center text-xs text-bunker-400">{index + 1}</div> <div className="w-10 text-center text-xs text-bunker-400">
<FontAwesomeIcon icon={faKey} />
</div>
</td> </td>
<td className="flex h-full min-w-[200px] flex-row items-center justify-between lg:min-w-[220px] xl:min-w-[250px]"> <td className="flex h-full min-w-[200px] flex-row items-center justify-between lg:min-w-[220px] xl:min-w-[250px]">
<div className="flex h-8 cursor-default flex-row items-center truncate"> <div className="flex h-8 cursor-default flex-row items-center truncate">
@@ -0,0 +1,42 @@
import { faCheck, faFolder, faXmark } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
type Props = {
folderInEnv: Record<string, boolean>;
userAvailableEnvs?: Array<{ slug: string; name: string }>;
folderName: string;
onClick: (folderName: string) => void;
};
export const FolderComparisonRow = ({
folderInEnv = {},
userAvailableEnvs = [],
folderName,
onClick
}: Props) => (
<tr
className="group flex min-w-full cursor-pointer flex-row items-center hover:bg-mineshaft-800"
onClick={() => onClick(folderName)}
>
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
<div className="w-10 text-center text-xs text-bunker-400">
<FontAwesomeIcon icon={faFolder} className="text-primary-700" />
</div>
</td>
<td className="flex h-full min-w-[200px] flex-row items-center justify-between lg:min-w-[200px] xl:min-w-[250px]">
<div className="flex h-8 flex-row items-center truncate">{folderName}</div>
</td>
{userAvailableEnvs?.map(({ slug }) => (
<td
className={`flex h-10 w-full cursor-default flex-row items-center justify-center ${
folderInEnv[slug]
? 'bg-mineshaft-900/30 text-green-500/80'
: 'bg-red-800/10 text-red-500/80'
}`}
key={`${folderName}-${slug}`}
>
<FontAwesomeIcon icon={folderInEnv[slug] ? faCheck : faXmark} />
</td>
))}
</tr>
);