mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(folders): implemented ui for folders in dashboard
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
1
frontend/src/hooks/api/secretFolders/index.tsx
Normal file
1
frontend/src/hooks/api/secretFolders/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { useCreateFolder, useDeleteFolder, useGetProjectFolders, useUpdateFolder } from './queries';
|
||||
129
frontend/src/hooks/api/secretFolders/queries.tsx
Normal file
129
frontend/src/hooks/api/secretFolders/queries.tsx
Normal 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)
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
32
frontend/src/hooks/api/secretFolders/types.ts
Normal file
32
frontend/src/hooks/api/secretFolders/types.ts
Normal 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;
|
||||
};
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export { FolderForm } from './FolderForm';
|
||||
export { FolderSection } from './FolderSection';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,2 @@
|
||||
export type TEditFolderForm = { id: string; name: string };
|
||||
export type TDeleteFolderForm = { id: string; name: string };
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user