feat(folders): implemented ui for folders in dashboard

This commit is contained in:
akhilmhdh
2023-05-29 22:23:22 +05:30
parent deaa85cbe7
commit 579516bd38
19 changed files with 708 additions and 128 deletions

View File

@@ -1,12 +1,23 @@
import Link from 'next/link';
import { useRouter } from 'next/router';
import { faAngleRight } from '@fortawesome/free-solid-svg-icons';
import { faAngleRight, faHome } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useOrganization, useWorkspace } from '@app/context';
import { Select, SelectItem, Tooltip } from '../v2';
type Props = {
pageName: string;
isProjectRelated?: boolean;
isOrganizationRelated?: boolean;
currentEnv?: string;
userAvailableEnvs?: any[];
onEnvChange?: (slug: string) => void;
folders?: Array<{ id: string; name: string }>;
isFolderMode?: boolean;
};
// TODO: make links clickable and clean up
/**
@@ -29,15 +40,10 @@ export default function NavHeader({
isOrganizationRelated,
currentEnv,
userAvailableEnvs,
onEnvChange
}: {
pageName: string;
isProjectRelated?: boolean;
isOrganizationRelated?: boolean;
currentEnv?: string;
userAvailableEnvs?: any[];
onEnvChange?: (slug: string) => void;
}): JSX.Element {
onEnvChange,
folders,
isFolderMode
}: Props): JSX.Element {
const { currentWorkspace } = useWorkspace();
const { currentOrg } = useOrganization();
const router = useRouter();
@@ -95,6 +101,26 @@ export default function NavHeader({
</div>
</>
)}
{isFolderMode &&
folders?.map(({ id, name }, index) => {
const query = { ...router.query };
if (name !== 'root') query.folderId = id;
else delete query.folderId;
return (
<div className="flex items-center space-x-3" key={`breadcrumb-folder-${id}`}>
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
{index + 1 === folders?.length ? (
<span className="text-sm font-semibold text-bunker-300">{name}</span>
) : (
<Link passHref legacyBehavior href={{ pathname: '/dashboard/[id]', query }}>
<a className="text-sm font-semibold text-primary/80 hover:text-primary">
{name === 'root' ? <FontAwesomeIcon icon={faHome} /> : name}
</a>
</Link>
)}
</div>
);
})}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import { SizeProp } from '@fortawesome/fontawesome-svg-core';
import { faCubesStacked, IconDefinition } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { twMerge } from 'tailwind-merge';
@@ -8,13 +9,25 @@ type Props = {
className?: string;
children?: ReactNode;
icon?: IconDefinition;
iconSize?: SizeProp;
};
export const EmptyState = ({ title, className, children, icon = faCubesStacked }: Props) => (
<div className={twMerge('flex w-full bg-bunker-700 flex-col items-center px-2 pt-6 text-bunker-300', className)}>
<FontAwesomeIcon icon={icon} size="2x" className='mr-4' />
<div className='flex flex-row items-center py-4'>
<div className="text-bunker-300 text-sm">{title}</div>
export const EmptyState = ({
title,
className,
children,
icon = faCubesStacked,
iconSize = '2x'
}: Props) => (
<div
className={twMerge(
'flex w-full flex-col items-center bg-bunker-700 px-2 pt-6 text-bunker-300',
className
)}
>
<FontAwesomeIcon icon={icon} size={iconSize} className="mr-4" />
<div className="flex flex-row items-center py-4">
<div className="text-sm text-bunker-300">{title}</div>
<div>{children}</div>
</div>
</div>

View File

@@ -35,7 +35,7 @@ export const Tooltip = ({
sideOffset={5}
{...props}
className={twMerge(
`z-20 select-none max-w-[15rem] rounded-md bg-mineshaft-800 border border-mineshaft-600 py-2 px-4 font-light text-sm text-bunker-200 shadow-md
`z-50 max-w-[15rem] select-none rounded-md border border-mineshaft-600 bg-mineshaft-800 py-2 px-4 text-sm font-light text-bunker-200 shadow-md
data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade
data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade
data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade

View File

@@ -2,6 +2,7 @@ export * from './auth';
export * from './incidentContacts';
export * from './keys';
export * from './organization';
export * from './secretFolders';
export * from './secrets';
export * from './secretSnapshots';
export * from './serviceAccounts';

View File

@@ -0,0 +1 @@
export { useCreateFolder, useDeleteFolder, useGetProjectFolders, useUpdateFolder } from './queries';

View File

@@ -0,0 +1,129 @@
import { useCallback } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { apiRequest } from '@app/config/request';
import { secretSnapshotKeys } from '../secretSnapshots/queries';
import {
CreateFolderDTO,
DeleteFolderDTO,
GetProjectFoldersDTO,
TSecretFolder,
UpdateFolderDTO
} from './types';
const queryKeys = {
getSecretFolders: (workspaceId: string, environment: string, parentFolderId?: string) =>
['secret-folders', { workspaceId, environment, parentFolderId }] as const
};
export const useGetProjectFolders = ({
workspaceId,
parentFolderId,
environment,
isPaused,
sortDir
}: GetProjectFoldersDTO) =>
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;
},
select: useCallback(
({ folders, dir }: { folders: TSecretFolder[]; dir: TSecretFolder[] }) => ({
dir,
folders: folders.sort((a, b) =>
sortDir === 'asc'
? a?.name?.localeCompare(b?.name || '')
: b?.name?.localeCompare(a?.name || '')
)
}),
[sortDir]
)
});
export const useCreateFolder = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, CreateFolderDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.post('/api/v1/folders', dto);
return data;
},
onSuccess: (_, { workspaceId, environment, parentFolderId }) => {
queryClient.invalidateQueries(
queryKeys.getSecretFolders(workspaceId, environment, parentFolderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.count(workspaceId, environment, parentFolderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.list(workspaceId, environment, parentFolderId)
);
}
});
};
export const useUpdateFolder = (parentFolderId: string) => {
const queryClient = useQueryClient();
return useMutation<{}, {}, UpdateFolderDTO>({
mutationFn: async ({ folderId, name, environment, workspaceId }) => {
const { data } = await apiRequest.patch(`/api/v1/folders/${folderId}`, {
name,
environment,
workspaceId
});
return data;
},
onSuccess: (_, { workspaceId, environment }) => {
queryClient.invalidateQueries(
queryKeys.getSecretFolders(workspaceId, environment, parentFolderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.count(workspaceId, environment, parentFolderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.list(workspaceId, environment, parentFolderId)
);
}
});
};
export const useDeleteFolder = (parentFolderId: string) => {
const queryClient = useQueryClient();
return useMutation<{}, {}, DeleteFolderDTO>({
mutationFn: async ({ folderId, environment, workspaceId }) => {
const { data } = await apiRequest.delete(`/api/v1/folders/${folderId}`, {
data: {
environment,
workspaceId
}
});
return data;
},
onSuccess: (_, { workspaceId, environment }) => {
queryClient.invalidateQueries(
queryKeys.getSecretFolders(workspaceId, environment, parentFolderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.count(workspaceId, environment, parentFolderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.list(workspaceId, environment, parentFolderId)
);
}
});
};

View File

@@ -0,0 +1,32 @@
export type TSecretFolder = {
id: string;
name: string;
};
export type GetProjectFoldersDTO = {
workspaceId: string;
environment: string;
parentFolderId?: string;
isPaused?: boolean;
sortDir?: 'asc' | 'desc';
};
export type CreateFolderDTO = {
workspaceId: string;
environment: string;
folderName: string;
parentFolderId?: string;
};
export type UpdateFolderDTO = {
workspaceId: string;
environment: string;
name: string;
folderId: string;
};
export type DeleteFolderDTO = {
workspaceId: string;
environment: string;
folderId: string;
};

View File

@@ -17,18 +17,31 @@ import {
} from './types';
export const secretSnapshotKeys = {
list: (workspaceId: string) => [{ workspaceId }, 'secret-snapshot'] as const,
list: (workspaceId: string, env: string, folderId?: string) =>
[{ workspaceId, env, folderId }, 'secret-snapshot'] as const,
snapshotSecrets: (snapshotId: string) => [{ snapshotId }, 'secret-snapshot'] as const,
count: (workspaceId: string) => [{ workspaceId }, 'count', 'secret-snapshot']
count: (workspaceId: string, env: string, folderId?: string) => [
{ workspaceId, env, folderId },
'count',
'secret-snapshot'
]
};
const fetchWorkspaceSecretSnaphots = async (workspaceId: string, limit = 10, offset = 0) => {
const fetchWorkspaceSecretSnaphots = async (
workspaceId: string,
environment: string,
folderId?: string,
limit = 10,
offset = 0
) => {
const res = await apiRequest.get<{ secretSnapshots: TWorkspaceSecretSnapshot[] }>(
`/api/v1/workspace/${workspaceId}/secret-snapshots`,
{
params: {
limit,
offset
offset,
environment,
folderId
}
}
);
@@ -38,9 +51,16 @@ const fetchWorkspaceSecretSnaphots = async (workspaceId: string, limit = 10, off
export const useGetWorkspaceSecretSnapshots = (dto: GetWorkspaceSecretSnapshotsDTO) =>
useInfiniteQuery({
enabled: Boolean(dto.workspaceId),
queryKey: secretSnapshotKeys.list(dto.workspaceId),
queryFn: ({ pageParam }) => fetchWorkspaceSecretSnaphots(dto.workspaceId, dto.limit, pageParam),
enabled: Boolean(dto.workspaceId && dto.environment),
queryKey: secretSnapshotKeys.list(dto.workspaceId, dto.environment, dto?.folder),
queryFn: ({ pageParam }) =>
fetchWorkspaceSecretSnaphots(
dto.workspaceId,
dto.environment,
dto?.folder,
dto.limit,
pageParam
),
getNextPageParam: (lastPage, pages) =>
lastPage.length !== 0 ? pages.length * dto.limit : undefined
});
@@ -115,40 +135,53 @@ export const useGetSnapshotSecrets = ({ decryptFileKey, env, snapshotId }: TSnap
}
});
return { version: data.version, secrets: sharedSecrets, createdAt: data.createdAt };
return {
version: data.version,
secrets: sharedSecrets,
createdAt: data.createdAt,
folders: data.folderVersion
};
}
});
const fetchWorkspaceSecretSnaphotCount = async (workspaceId: string) => {
const fetchWorkspaceSecretSnaphotCount = async (
workspaceId: string,
environment: string,
folderId?: string
) => {
const res = await apiRequest.get<{ count: number }>(
`/api/v1/workspace/${workspaceId}/secret-snapshots/count`
`/api/v1/workspace/${workspaceId}/secret-snapshots/count`,
{
params: {
environment,
folderId
}
}
);
return res.data.count;
};
export const useGetWsSnapshotCount = (workspaceId: string) =>
export const useGetWsSnapshotCount = (workspaceId: string, env: string, folderId?: string) =>
useQuery({
enabled: Boolean(workspaceId),
queryKey: secretSnapshotKeys.count(workspaceId),
queryFn: () => fetchWorkspaceSecretSnaphotCount(workspaceId)
enabled: Boolean(workspaceId && env),
queryKey: secretSnapshotKeys.count(workspaceId, env, folderId),
queryFn: () => fetchWorkspaceSecretSnaphotCount(workspaceId, env, folderId)
});
export const usePerformSecretRollback = () => {
const queryClient = useQueryClient();
return useMutation<{}, {}, TSecretRollbackDTO>({
mutationFn: async (dto) => {
mutationFn: async ({ workspaceId, ...dto }) => {
const { data } = await apiRequest.post(
`/api/v1/workspace/${dto.workspaceId}/secret-snapshots/rollback`,
{
version: dto.version
}
`/api/v1/workspace/${workspaceId}/secret-snapshots/rollback`,
dto
);
return data;
},
onSuccess: (_, dto) => {
queryClient.invalidateQueries(secretSnapshotKeys.list(dto.workspaceId));
queryClient.invalidateQueries(secretSnapshotKeys.count(dto.workspaceId));
onSuccess: (_, { workspaceId, environment, folderId }) => {
queryClient.invalidateQueries(secretSnapshotKeys.list(workspaceId, environment, folderId));
queryClient.invalidateQueries(secretSnapshotKeys.count(workspaceId, environment, folderId));
}
});
};

View File

@@ -13,6 +13,7 @@ export type TWorkspaceSecretSnapshot = {
export type TSnapshotSecret = Omit<TWorkspaceSecretSnapshot, 'secretVersions'> & {
secretVersions: EncryptedSecretVersion[];
folderVersion: Array<{ name: string; id: string }>;
};
export type TSnapshotSecretProps = {
@@ -24,9 +25,13 @@ export type TSnapshotSecretProps = {
export type GetWorkspaceSecretSnapshotsDTO = {
workspaceId: string;
limit: number;
environment: string;
folder?: string;
};
export type TSecretRollbackDTO = {
workspaceId: string;
version: number;
environment: string;
folderId?: string;
};

View File

@@ -19,19 +19,24 @@ import {
export const secretKeys = {
// this is also used in secretSnapshot part
getProjectSecret: (workspaceId: string, env: string | string[]) => [
{ workspaceId, env },
getProjectSecret: (workspaceId: string, env: string | string[], folderId?: string) => [
{ workspaceId, env, folderId },
'secrets'
],
getSecretVersion: (secretId: string) => [{ secretId }, 'secret-versions']
};
const fetchProjectEncryptedSecrets = async (workspaceId: string, env: string | string[]) => {
const fetchProjectEncryptedSecrets = async (
workspaceId: string,
env: string | string[],
folderId?: string
) => {
if (typeof env === 'string') {
const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>('/api/v2/secrets', {
params: {
environment: env,
workspaceId
workspaceId,
folderId: folderId || undefined
}
});
return data.secrets;
@@ -46,7 +51,8 @@ const fetchProjectEncryptedSecrets = async (workspaceId: string, env: string | s
const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>('/api/v2/secrets', {
params: {
environment: envPoint,
workspaceId
workspaceId,
folderId
}
});
allEnvData = allEnvData.concat(data.secrets);
@@ -63,13 +69,14 @@ export const useGetProjectSecrets = ({
workspaceId,
env,
decryptFileKey,
isPaused
isPaused,
folderId
}: GetProjectSecretsDTO) =>
useQuery({
// wait for all values to be available
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
queryKey: secretKeys.getProjectSecret(workspaceId, env),
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env),
queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId),
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId),
select: (data) => {
const PRIVATE_KEY = localStorage.getItem('PRIVATE_KEY') as string;
const latestKey = decryptFileKey;
@@ -283,9 +290,15 @@ export const useBatchSecretsOp = () => {
return data;
},
onSuccess: (_, dto) => {
queryClient.invalidateQueries(secretKeys.getProjectSecret(dto.workspaceId, dto.environment));
queryClient.invalidateQueries(secretSnapshotKeys.list(dto.workspaceId));
queryClient.invalidateQueries(secretSnapshotKeys.count(dto.workspaceId));
queryClient.invalidateQueries(
secretKeys.getProjectSecret(dto.workspaceId, dto.environment, dto.folderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.list(dto.workspaceId, dto.environment, dto?.folderId)
);
queryClient.invalidateQueries(
secretSnapshotKeys.count(dto.workspaceId, dto.environment, dto?.folderId)
);
}
});
};

View File

@@ -61,6 +61,7 @@ type SecretTagArg = { _id: string; name: string; slug: string };
export type UpdateSecretArg = {
_id: string;
folderId?: string;
type: 'shared' | 'personal';
secretName: string;
secretKeyCiphertext: string;
@@ -81,6 +82,7 @@ export type DeleteSecretArg = { _id: string };
export type BatchSecretDTO = {
workspaceId: string;
folderId: string;
environment: string;
requests: Array<
| { method: 'POST'; secret: CreateSecretArg }
@@ -93,6 +95,7 @@ export type GetProjectSecretsDTO = {
workspaceId: string;
env: string | string[];
decryptFileKey: UserWsKeyPair;
folderId?: string;
isPaused?: boolean;
onSuccess?: (data: DecryptedSecret[]) => void;
};

View File

@@ -10,6 +10,7 @@ import {
faDownload,
faEye,
faEyeSlash,
faFolderPlus,
faMagnifyingGlass,
faPlus
} from '@fortawesome/free-solid-svg-icons';
@@ -21,6 +22,7 @@ import { useNotificationContext } from '@app/components/context/Notifications/No
import NavHeader from '@app/components/navigation/NavHeader';
import {
Button,
DeleteActionModal,
IconButton,
Input,
Modal,
@@ -37,7 +39,10 @@ import { useWorkspace } from '@app/context';
import { useLeaveConfirm, usePopUp, useToggle } from '@app/hooks';
import {
useBatchSecretsOp,
useCreateFolder,
useCreateWsTag,
useDeleteFolder,
useGetProjectFolders,
useGetProjectSecrets,
useGetSecretVersion,
useGetSnapshotSecrets,
@@ -48,13 +53,20 @@ import {
useGetWsSnapshotCount,
useGetWsTags,
usePerformSecretRollback,
useRegisterUserAction
useRegisterUserAction,
useUpdateFolder
} from '@app/hooks/api';
import { secretKeys } from '@app/hooks/api/secrets/queries';
import { WorkspaceEnv } from '@app/hooks/api/types';
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';
@@ -96,7 +108,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
'addTag',
'secretSnapshots',
'uploadedSecOpts',
'compareSecrets'
'compareSecrets',
'folderForm',
'deleteFolder'
] as const);
const [isSecretValueHidden, setIsSecretValueHidden] = useToggle(true);
const [searchFilter, setSearchFilter] = useState('');
@@ -106,6 +120,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
const deletedSecretIds = useRef<string[]>([]);
const { hasUnsavedChanges, setHasUnsavedChanges } = useLeaveConfirm({ initialValue: false });
const folderId = router.query.folderId as string;
const isRollbackMode = Boolean(snapshotId);
const { currentWorkspace, isLoading } = useWorkspace();
const workspaceId = currentWorkspace?._id as string;
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
@@ -142,7 +159,16 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
workspaceId,
env: selectedEnv?.slug || '',
decryptFileKey: latestFileKey!,
isPaused: Boolean(snapshotId)
isPaused: Boolean(snapshotId),
folderId
});
const { data: folderData, isLoading: isFoldersLoading } = useGetProjectFolders({
workspaceId: workspaceId || '',
environment: selectedEnv?.slug || '',
parentFolderId: folderId,
isPaused: isRollbackMode,
sortDir
});
const {
@@ -152,6 +178,8 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
isFetchingNextPage
} = useGetWorkspaceSecretSnapshots({
workspaceId,
environment: selectedEnv?.slug || '',
folder: folderId,
limit: 10
});
@@ -165,8 +193,11 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
decryptFileKey: latestFileKey!
});
const { data: snapshotCount, isLoading: isLoadingSnapshotCount } =
useGetWsSnapshotCount(workspaceId);
const { data: snapshotCount, isLoading: isLoadingSnapshotCount } = useGetWsSnapshotCount(
workspaceId,
selectedEnv?.slug || '',
folderId
);
const { data: wsTags } = useGetWsTags(workspaceId);
// mutation calls
@@ -174,6 +205,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
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 method = useForm<FormData>({
// why any: well yup inferred ts expects other keys to defined as undefined
@@ -192,7 +226,6 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
reset
} = method;
const { fields, prepend, append, remove } = useFieldArray({ control, name: 'secrets' });
const isRollbackMode = Boolean(snapshotId);
const isReadOnly = selectedEnv?.isWriteDenied;
const isAddOnly = selectedEnv?.isReadDenied && !selectedEnv?.isWriteDenied;
const canDoRollback = !isReadOnly && !isAddOnly;
@@ -281,7 +314,9 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
try {
await performSecretRollback({
workspaceId,
version: snapshotSecret.version
version: snapshotSecret.version,
environment: selectedEnv?.slug || '',
folderId
});
setValue('isSnapshotMode', false);
setSnaphotId(null);
@@ -326,6 +361,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
await batchSecretOp({
requests: batchedSecret,
workspaceId,
folderId,
environment: selectedEnv?.slug
});
createNotification({
@@ -393,7 +429,99 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
}
};
if (isSecretsLoading || isEnvListLoading) {
const handleFolderOpen = (id: string) => {
setSearchFilter('');
router.push({
pathname: router.pathname,
query: {
...router.query,
folderId: id
}
});
};
const isEditFolder = Boolean(popUp?.folderForm?.data);
const handleFolderCreate = async (name: string) => {
try {
await createFolder({
workspaceId,
environment: selectedEnv?.slug || '',
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 = async (name: string) => {
const { id } = popUp?.folderForm?.data as TDeleteFolderForm;
try {
await updateFolder({
folderId: id,
workspaceId,
environment: selectedEnv?.slug || '',
name
});
createNotification({
type: 'success',
text: 'Successfully updated folder'
});
handlePopUpClose('folderForm');
} catch (error) {
console.error(error);
createNotification({
text: 'Failed to update folder',
type: 'error'
});
}
};
const handleFolderDelete = async () => {
const { id } = popUp?.deleteFolder?.data as TDeleteFolderForm;
try {
deleteFolder({
workspaceId,
environment: selectedEnv?.slug || '',
folderId: id
});
createNotification({
type: 'success',
text: 'Successfully removed folder'
});
handlePopUpClose('deleteFolder');
} catch (error) {
console.error(error);
createNotification({
text: 'Failed to remove folder',
type: 'error'
});
}
};
// 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 isSnapshotSecretEmtpy =
isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length;
const isSecretEmpty = (!isRollbackMode && isDashboardSecretEmpty) || isSnapshotSecretEmtpy;
if (isSecretsLoading || isEnvListLoading || isFolderListLoading) {
return (
<div className="container mx-auto flex h-1/2 w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
@@ -401,64 +529,42 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
);
}
// when secrets is not loading and secrets list is empty
const isDashboardSecretEmpty = !isSecretsLoading && false;
// when using snapshot mode and snapshot is loading and snapshot list is empty
const isSnapshotSecretEmtpy =
isRollbackMode && !isSnapshotSecretsLoading && !snapshotSecret?.secrets?.length;
const isSecretEmpty = (!isRollbackMode && isDashboardSecretEmpty) || isSnapshotSecretEmtpy;
const userAvailableEnvs = wsEnv?.filter(
({ isReadDenied, isWriteDenied }) => !isReadDenied || !isWriteDenied
);
return (
<div className="mr-auto container px-6 text-mineshaft-50 dark:[color-scheme:dark] h-full">
<div className="container mx-auto h-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
<FormProvider {...method}>
<form autoComplete="off">
<form autoComplete="off" className="h-full">
{/* breadcrumb row */}
<div className="relative right-6 mb-6 -top-2">
<div className="relative right-6 -top-2 mb-2">
<NavHeader
pageName={t('dashboard.title')}
currentEnv={
userAvailableEnvs?.filter((envir) => envir.slug === envFromTop)[0].name || ''
}
isFolderMode
folders={folderData?.dir}
isProjectRelated
userAvailableEnvs={userAvailableEnvs}
onEnvChange={onEnvChange}
/>
</div>
{/* This is only for rollbacks */}
{isRollbackMode &&
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<h1 className="text-3xl font-semibold">Secret Snapshot</h1>
{isRollbackMode && Boolean(snapshotSecret) && (
<Tag colorSchema="green">
{new Date(snapshotSecret?.createdAt || '').toLocaleString()}
</Tag>
)}
</div>
<div className="flex items-center space-x-2">
<Button
variant="star"
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
onClick={() => {
setSnaphotId(null);
reset({ ...secrets, isSnapshotMode: false });
}}
className="h-10"
>
Go back
</Button>
</div>
</div>}
<div className="mb-4">
<h6 className="text-2xl">{isRollbackMode ? 'Secret Snapshot' : 'Secrets'}</h6>
{isRollbackMode && Boolean(snapshotSecret) && (
<Tag colorSchema="green">
{new Date(snapshotSecret?.createdAt || '').toLocaleString()}
</Tag>
)}
</div>
{/* Environment, search and other action row */}
<div className="mt-2 flex items-center space-x-2 justify-between">
<div className="flex-grow max-w-sm">
<div className="flex items-center justify-between space-x-2">
<div className="flex max-w-lg flex-grow space-x-2">
<Input
className="h-[2.3rem] bg-mineshaft-800 focus:bg-mineshaft-700/80 duration-200 placeholder-mineshaft-50"
placeholder="Search keys..."
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
placeholder="Search by folder name, key name, comment..."
value={searchFilter}
onChange={(e) => setSearchFilter(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
@@ -500,8 +606,8 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</IconButton>
</Tooltip>
</div>
<div className='block xl:hidden'>
<Tooltip content='Point-in-time Recovery'>
<div className="block xl:hidden">
<Tooltip content="Point-in-time Recovery">
<IconButton
ariaLabel="recovery"
variant="outline_bg"
@@ -511,7 +617,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</IconButton>
</Tooltip>
</div>
<div className='hidden xl:block'>
<div className="hidden xl:block">
<Button
variant="outline_bg"
onClick={() => handlePopUpOpen('secretSnapshots')}
@@ -525,8 +631,20 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</div>
{!isReadOnly && !isRollbackMode && (
<>
<div className='block lg:hidden'>
<Tooltip content='Point-in-time Recovery'>
<div className="block lg:hidden">
<Tooltip content="Add Folder">
<IconButton
ariaLabel="recovery"
variant="outline_bg"
onClick={() => handlePopUpOpen('folderForm')}
>
<FontAwesomeIcon icon={faFolderPlus} />
</IconButton>
</Tooltip>
</div>
<div className="block lg:hidden">
<Tooltip content="Point-in-time Recovery">
<IconButton
ariaLabel="recovery"
variant="outline_bg"
@@ -544,7 +662,19 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</IconButton>
</Tooltip>
</div>
<div className='hidden lg:block'>
<div className="hidden lg:block">
<Button
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
onClick={() => handlePopUpOpen('folderForm')}
isDisabled={isReadOnly || isRollbackMode}
variant="outline_bg"
className="h-10"
>
Add Folder
</Button>
</div>
<div className="hidden lg:block">
<Button
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => {
@@ -565,6 +695,19 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</div>
</>
)}
{isRollbackMode && (
<Button
variant="star"
leftIcon={<FontAwesomeIcon icon={faArrowLeft} />}
onClick={() => {
setSnaphotId(null);
reset({ ...secrets, isSnapshotMode: false });
}}
className="h-10"
>
Go back
</Button>
)}
<Button
isDisabled={isSubmitDisabled}
isLoading={isSubmitting}
@@ -581,14 +724,21 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
<div
className={`${
isSecretEmpty ? 'flex flex-col items-center justify-center' : ''
} no-scrollbar::-webkit-scrollbar mt-3 h-[calc(100vh-220px)] overflow-x-hidden overflow-y-scroll no-scrollbar`}
} no-scrollbar::-webkit-scrollbar mt-3 h-3/4 overflow-x-hidden overflow-y-scroll no-scrollbar`}
ref={secretContainer}
>
{!isSecretEmpty && (
<TableContainer className="max-h-[calc(100%-40px)] no-scrollbar no-scrollbar::-webkit-scrollbar">
<TableContainer className="no-scrollbar::-webkit-scrollbar max-h-[calc(100%-120px)] no-scrollbar">
<table className="secret-table relative">
<SecretTableHeader sortDir={sortDir} onSort={onSortSecrets} />
<tbody className="max-h-96 overflow-y-auto">
<FolderSection
onFolderOpen={handleFolderOpen}
onFolderUpdate={(id, name) => handlePopUpOpen('folderForm', { id, name })}
onFolderDelete={(id, name) => handlePopUpOpen('deleteFolder', { id, name })}
folders={folderList}
search={searchFilter}
/>
{fields.map(({ id, _id }, index) => (
<SecretInputRow
key={id}
@@ -609,7 +759,7 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
<td colSpan={3} className="hover:bg-mineshaft-700">
<button
type="button"
className="pl-12 cursor-default w-full flex h-8 items-center justify-start font-normal text-bunker-300"
className="flex h-8 w-full cursor-default items-center justify-start pl-12 font-normal text-bunker-300"
onClick={onAppendSecret}
>
<FontAwesomeIcon icon={faPlus} />
@@ -695,6 +845,26 @@ export const DashboardPage = ({ envFromTop }: { envFromTop: string }) => {
</div>
</ModalContent>
</Modal>
<Modal
isOpen={popUp?.folderForm?.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle('folderForm', isOpen)}
>
<ModalContent title={isEditFolder ? 'Edit Folder' : 'Create Folder'}>
<FolderForm
isEdit={isEditFolder}
onUpdateFolder={handleFolderUpdate}
onCreateFolder={handleFolderCreate}
defaultFolderName={(popUp?.folderForm?.data as TEditFolderForm)?.name}
/>
</ModalContent>
</Modal>
<DeleteActionModal
isOpen={popUp.deleteFolder.isOpen}
deleteKey={(popUp.deleteFolder?.data as TDeleteFolderForm)?.name}
title="Do you want to delete this folder?"
onChange={(isOpen) => handlePopUpToggle('deleteFolder', isOpen)}
onDeleteApproved={handleFolderDelete}
/>
<Modal
isOpen={popUp?.compareSecrets?.isOpen}
onOpenChange={(open) => handlePopUpToggle('compareSecrets', open)}

View File

@@ -0,0 +1,75 @@
import { Controller, useForm } from 'react-hook-form';
import { yupResolver } from '@hookform/resolvers/yup';
import * as yup from 'yup';
import { Button, FormControl, Input, ModalClose } from '@app/components/v2';
type Props = {
onCreateFolder: (folderName: string) => Promise<void>;
onUpdateFolder: (folderName: string) => Promise<void>;
isEdit?: boolean;
defaultFolderName?: string;
};
const formSchema = yup.object({
name: yup
.string()
.required()
.trim()
.matches(/^[a-zA-Z0-9-_]+$/, 'Folder name cannot contain spaces. Only underscore and dashes')
.label('Tag Name')
});
type TFormData = yup.InferType<typeof formSchema>;
export const FolderForm = ({
isEdit,
onCreateFolder,
defaultFolderName,
onUpdateFolder
}: Props): JSX.Element => {
const {
control,
reset,
formState: { isSubmitting },
handleSubmit
} = useForm<TFormData>({
resolver: yupResolver(formSchema),
defaultValues: {
name: defaultFolderName
}
});
const onSubmit = async ({ name }: TFormData) => {
if (isEdit) {
await onUpdateFolder(name);
} else {
await onCreateFolder(name);
}
reset();
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Controller
control={control}
name="name"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl label="Folder Name" isError={Boolean(error)} errorText={error?.message}>
<Input {...field} placeholder="Type your folder name" />
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
{isEdit ? 'Save' : 'Create'}
</Button>
<ModalClose asChild>
<Button variant="plain" colorSchema="secondary">
Cancel
</Button>
</ModalClose>
</div>
</form>
);
};

View File

@@ -0,0 +1,77 @@
import { faEdit, faFolder, faTrash } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconButton, Tooltip } from '@app/components/v2';
type Props = {
folders?: Array<{ id: string; name: string }>;
search?: string;
onFolderUpdate: (folderId: string, name: string) => void;
onFolderDelete: (folderId: string, name: string) => void;
onFolderOpen: (folderId: string) => void;
};
export const FolderSection = ({
onFolderUpdate: handleFolderUpdate,
onFolderDelete: handleFolderDelete,
onFolderOpen: handleFolderOpen,
search = '',
folders = []
}: Props) => {
return (
<>
{folders
.filter(({ name }) => name.toLowerCase().includes(search.toLowerCase()))
.map(({ id, name }) => (
<tr key={id} className="group flex flex-row items-center">
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
<FontAwesomeIcon icon={faFolder} className="text-primary-700" />
</td>
<td
colSpan={2}
className="relative flex w-full min-w-[220px] items-center justify-between overflow-hidden text-ellipsis uppercase lg:min-w-[240px] xl:min-w-[280px]"
style={{ paddingTop: '0', paddingBottom: '0' }}
>
<div
className="flex-grow cursor-pointer p-2 pl-3"
onKeyDown={() => null}
tabIndex={0}
role="button"
onClick={() => handleFolderOpen(id)}
>
{name}
</div>
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Settings">
<IconButton
size="lg"
colorSchema="primary"
variant="plain"
onClick={() => handleFolderUpdate(id, name)}
ariaLabel="expand"
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
</Tooltip>
</div>
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Delete">
<IconButton
size="md"
variant="plain"
colorSchema="danger"
ariaLabel="delete"
onClick={() => handleFolderDelete(id, name)}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div>
</div>
</td>
</tr>
))}
</>
);
};

View File

@@ -0,0 +1,3 @@
export { FolderForm } from './FolderForm';
export { FolderSection } from './FolderSection';
export * from './types';

View File

@@ -0,0 +1,2 @@
export type TEditFolderForm = { id: string; name: string };
export type TDeleteFolderForm = { id: string; name: string };

View File

@@ -24,9 +24,9 @@ export const MaskedInput = ({ isReadOnly, isSecretValueHidden, index, isOverridd
const syntaxHighlight = useCallback((val: string) => {
if (val?.length === 0) return <span className="font-sans text-bunker-400/80">EMPTY</span>;
return val?.split(REGEX).map((word) =>
return val?.split(REGEX).map((word, i) =>
word.match(REGEX) !== null ? (
<span className="ph-no-capture text-yellow" key={`${val}-${index + 1}`}>
<span className="ph-no-capture text-yellow" key={`${val}-${i + 1}`}>
{word.slice(0, 2)}
<span className="ph-no-capture text-yellow-200/80">{word.slice(2, word.length - 1)}</span>
{word.slice(word.length - 1, word.length) === '}' ? (
@@ -40,7 +40,7 @@ export const MaskedInput = ({ isReadOnly, isSecretValueHidden, index, isOverridd
)}
</span>
) : (
<span key={`${word}_${index + 1}`} className="ph-no-capture">
<span key={`${word}_${i + 1}`} className="ph-no-capture">
{word}
</span>
)

View File

@@ -6,7 +6,6 @@ import {
faComment,
faEllipsis,
faInfoCircle,
faKey,
faPlus,
faTags,
faXmark
@@ -159,9 +158,8 @@ export const SecretInputRow = memo(
return (
<tr className="group flex flex-row items-center" key={index}>
<td className="flex h-10 w-10 items-center justify-center px-4 border-none">
{/* <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} className="w-4 h-4 text-bunker-400/60 pl-2.5 pt-0.5" /></div>
<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>
</td>
<Controller
control={control}
@@ -200,7 +198,7 @@ export const SecretInputRow = memo(
</HoverCard>
)}
/>
<td className="flex h-10 border-none w-full flex-grow flex-row items-center justify-center border-r border-red">
<td className="flex h-10 w-full flex-grow flex-row items-center justify-center border-r border-none border-red">
<MaskedInput
isReadOnly={
isReadOnly || isRollbackMode || (isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
@@ -226,10 +224,10 @@ export const SecretInputRow = memo(
</Tag>
))}
{!(isReadOnly || isAddOnly || isRollbackMode) && (
<div className="overflow-hidden duration-0 ml-1">
<div className="duration-0 ml-1 overflow-hidden">
<Popover>
<PopoverTrigger asChild>
<div className="w-0 data-[state=open]:w-6 group-hover:w-6">
<div className="w-0 group-hover:w-6 data-[state=open]:w-6">
<Tooltip content="Add tags">
<IconButton
variant="plain"
@@ -291,7 +289,7 @@ export const SecretInputRow = memo(
</div>
)}
</div>
<div className="flex flex-row items-center h-full pr-2">
<div className="flex h-full flex-row items-center pr-2">
{!isAddOnly && (
<div>
<Tooltip content="Override with a personal value">
@@ -314,16 +312,14 @@ export const SecretInputRow = memo(
</div>
)}
<Tooltip content="Comment">
<div
className={`mt-0.5 overflow-hidden `}
>
<div className={`mt-0.5 overflow-hidden `}>
<Popover>
<PopoverTrigger asChild>
<IconButton
className={twMerge(
'overflow-hidden p-0 w-7',
'data-[state=open]:w-7 group-hover:w-7 w-0',
hasComment ? 'text-primary w-7' : 'group-hover:w-7'
'w-7 overflow-hidden p-0',
'w-0 group-hover:w-7 data-[state=open]:w-7',
hasComment ? 'w-7 text-primary' : 'group-hover:w-7'
)}
variant="plain"
size="md"
@@ -332,12 +328,13 @@ export const SecretInputRow = memo(
<FontAwesomeIcon icon={faComment} />
</IconButton>
</PopoverTrigger>
<PopoverContent className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl" sticky="always">
<PopoverContent
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl"
sticky="always"
>
<FormControl label="Comment" className="mb-0">
<TextArea
isDisabled={
isReadOnly || isRollbackMode || shouldBeBlockedInAddOnly
}
isDisabled={isReadOnly || isRollbackMode || shouldBeBlockedInAddOnly}
className="border border-mineshaft-600 text-sm"
{...register(`secrets.${index}.comment`)}
rows={8}
@@ -349,7 +346,7 @@ export const SecretInputRow = memo(
</div>
</Tooltip>
</div>
<div className="duration-0 w-0 flex items-center justify-end space-x-2.5 overflow-hidden transition-all w-16 border-l border-mineshaft-600 h-10">
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
{!isAddOnly && (
<div className="opacity-0 group-hover:opacity-100">
<Tooltip content="Settings">

View File

@@ -11,7 +11,7 @@ type Props = {
export const SecretTableHeader = ({ sortDir, onSort }: Props): JSX.Element => (
<thead className="sticky top-0 z-50 bg-mineshaft-800">
<tr className="top-0 flex flex-row">
<td className="flex w-10 items-center justify-center px-4 border-none">
<td className="flex w-10 items-center justify-center border-none px-4">
<div className="w-10 text-center text-xs text-transparent">{0}</div>
</td>
<td className="flex items-center">