Merge pull request #3174 from Infisical/feat/addFolderDescription

Add descriptions to secret folders
This commit is contained in:
carlosmonastyrski
2025-03-04 14:56:43 -03:00
committed by GitHub
14 changed files with 168 additions and 43 deletions

View File

@@ -0,0 +1,23 @@
import { Knex } from "knex";
import { TableName } from "@app/db/schemas";
export async function up(knex: Knex): Promise<void> {
const hasProjectDescription = await knex.schema.hasColumn(TableName.SecretFolder, "description");
if (!hasProjectDescription) {
await knex.schema.alterTable(TableName.SecretFolder, (t) => {
t.string("description");
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasProjectDescription = await knex.schema.hasColumn(TableName.SecretFolder, "description");
if (hasProjectDescription) {
await knex.schema.alterTable(TableName.SecretFolder, (t) => {
t.dropColumn("description");
});
}
}

View File

@@ -15,7 +15,8 @@ export const SecretFoldersSchema = z.object({
updatedAt: z.date(),
envId: z.string().uuid(),
parentId: z.string().uuid().nullable().optional(),
isReserved: z.boolean().default(false).nullable().optional()
isReserved: z.boolean().default(false).nullable().optional(),
description: z.string().nullable().optional()
});
export type TSecretFolders = z.infer<typeof SecretFoldersSchema>;

View File

@@ -1142,6 +1142,7 @@ interface CreateFolderEvent {
folderId: string;
folderName: string;
folderPath: string;
description?: string;
};
}

View File

@@ -638,7 +638,8 @@ export const FOLDERS = {
environment: "The slug of the environment to create the folder in.",
name: "The name of the folder to create.",
path: "The path of the folder to create.",
directory: "The directory of the folder to create. (Deprecated in favor of path)"
directory: "The directory of the folder to create. (Deprecated in favor of path)",
description: "An optional description label for the folder."
},
UPDATE: {
folderId: "The ID of the folder to update.",
@@ -647,7 +648,8 @@ export const FOLDERS = {
path: "The path of the folder to update.",
directory: "The new directory of the folder to update. (Deprecated in favor of path)",
projectSlug: "The slug of the project where the folder is located.",
workspaceId: "The ID of the project where the folder is located."
workspaceId: "The ID of the project where the folder is located.",
description: "An optional description label for the folder."
},
DELETE: {
folderIdOrName: "The ID or name of the folder to delete.",

View File

@@ -47,7 +47,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.default("/")
.transform(prefixWithSlash)
.transform(removeTrailingSlash)
.describe(FOLDERS.CREATE.directory)
.describe(FOLDERS.CREATE.directory),
description: z.string().optional().nullable().describe(FOLDERS.CREATE.description)
}),
response: {
200: z.object({
@@ -65,7 +66,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
actorOrgId: req.permission.orgId,
...req.body,
projectId: req.body.workspaceId,
path
path,
description: req.body.description
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
@@ -76,7 +78,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
environment: req.body.environment,
folderId: folder.id,
folderName: folder.name,
folderPath: path
folderPath: path,
...(req.body.description ? { description: req.body.description } : {})
}
}
});
@@ -125,7 +128,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.default("/")
.transform(prefixWithSlash)
.transform(removeTrailingSlash)
.describe(FOLDERS.UPDATE.directory)
.describe(FOLDERS.UPDATE.directory),
description: z.string().optional().nullable().describe(FOLDERS.UPDATE.description)
}),
response: {
200: z.object({
@@ -196,7 +200,8 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) =>
.default("/")
.transform(prefixWithSlash)
.transform(removeTrailingSlash)
.describe(FOLDERS.UPDATE.path)
.describe(FOLDERS.UPDATE.path),
description: z.string().optional().nullable().describe(FOLDERS.UPDATE.description)
})
.array()
.min(1)

View File

@@ -50,7 +50,8 @@ export const secretFolderServiceFactory = ({
actorOrgId,
name,
environment,
path: secretPath
path: secretPath,
description
}: TCreateFolderDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
@@ -121,7 +122,10 @@ export const secretFolderServiceFactory = ({
}
}
const doc = await folderDAL.create({ name, envId: env.id, version: 1, parentId: parentFolderId }, tx);
const doc = await folderDAL.create(
{ name, envId: env.id, version: 1, parentId: parentFolderId, description },
tx
);
await folderVersionDAL.create(
{
name: doc.name,
@@ -170,7 +174,7 @@ export const secretFolderServiceFactory = ({
const result = await folderDAL.transaction(async (tx) =>
Promise.all(
folders.map(async (newFolder) => {
const { environment, path: secretPath, id, name } = newFolder;
const { environment, path: secretPath, id, name, description } = newFolder;
const parentFolder = await folderDAL.findBySecretPath(project.id, environment, secretPath);
if (!parentFolder) {
@@ -217,7 +221,7 @@ export const secretFolderServiceFactory = ({
const [doc] = await folderDAL.update(
{ envId: env.id, id: folder.id, parentId: parentFolder.id },
{ name },
{ name, description },
tx
);
await folderVersionDAL.create(
@@ -259,7 +263,8 @@ export const secretFolderServiceFactory = ({
name,
environment,
path: secretPath,
id
id,
description
}: TUpdateFolderDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
@@ -312,7 +317,7 @@ export const secretFolderServiceFactory = ({
const newFolder = await folderDAL.transaction(async (tx) => {
const [doc] = await folderDAL.update(
{ envId: env.id, id: folder.id, parentId: parentFolder.id, isReserved: false },
{ name },
{ name, description },
tx
);
await folderVersionDAL.create(

View File

@@ -9,6 +9,7 @@ export type TCreateFolderDTO = {
environment: string;
path: string;
name: string;
description?: string | null;
} & TProjectPermission;
export type TUpdateFolderDTO = {
@@ -16,6 +17,7 @@ export type TUpdateFolderDTO = {
path: string;
id: string;
name: string;
description?: string | null;
} & TProjectPermission;
export type TUpdateManyFoldersDTO = {
@@ -25,6 +27,7 @@ export type TUpdateManyFoldersDTO = {
path: string;
id: string;
name: string;
description?: string | null;
}[];
} & Omit<TProjectPermission, "projectId">;

View File

@@ -148,12 +148,13 @@ export const useUpdateFolder = () => {
const queryClient = useQueryClient();
return useMutation<object, object, TUpdateFolderDTO>({
mutationFn: async ({ path = "/", folderId, name, environment, projectId }) => {
mutationFn: async ({ path = "/", folderId, name, environment, projectId, description }) => {
const { data } = await apiRequest.patch(`/api/v1/folders/${folderId}`, {
name,
environment,
workspaceId: projectId,
path
path,
description
});
return data;
},

View File

@@ -5,6 +5,7 @@ export enum ReservedFolders {
export type TSecretFolder = {
id: string;
name: string;
description?: string;
};
export type TGetProjectFoldersDTO = {
@@ -24,6 +25,7 @@ export type TCreateFolderDTO = {
environment: string;
name: string;
path?: string;
description?: string | null;
};
export type TUpdateFolderDTO = {
@@ -32,6 +34,7 @@ export type TUpdateFolderDTO = {
name: string;
folderId: string;
path?: string;
description?: string | null;
};
export type TDeleteFolderDTO = {
@@ -49,5 +52,6 @@ export type TUpdateFolderBatchDTO = {
environment: string;
id: string;
path?: string;
description?: string | null;
}[];
};

View File

@@ -2,13 +2,22 @@ import { useCallback, useMemo } from "react";
import { DashboardProjectSecretsOverview } from "@app/hooks/api/dashboard/types";
type FolderNameAndDescription = {
name: string;
description?: string;
};
export const useFolderOverview = (folders: DashboardProjectSecretsOverview["folders"]) => {
const folderNames = useMemo(() => {
const names = new Set<string>();
const folderNamesAndDescriptions = useMemo(() => {
const namesAndDescriptions = new Map<string, FolderNameAndDescription>();
folders?.forEach((folder) => {
names.add(folder.name);
if (!namesAndDescriptions.has(folder.name)) {
namesAndDescriptions.set(folder.name, { name: folder.name, description: folder.description });
}
});
return [...names];
return Array.from(namesAndDescriptions.values());
}, [folders]);
const isFolderPresentInEnv = useCallback(
@@ -31,7 +40,7 @@ export const useFolderOverview = (folders: DashboardProjectSecretsOverview["fold
[folders]
);
return { folderNames, isFolderPresentInEnv, getFolderByNameAndEnv };
return { folderNamesAndDescriptions, isFolderPresentInEnv, getFolderByNameAndEnv };
};
export const useDynamicSecretOverview = (

View File

@@ -228,7 +228,7 @@ export const OverviewPage = () => {
setPage
});
const { folderNames, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders);
const { folderNamesAndDescriptions, getFolderByNameAndEnv, isFolderPresentInEnv } = useFolderOverview(folders);
const { dynamicSecretNames, isDynamicSecretPresentInEnv } =
useDynamicSecretOverview(dynamicSecrets);
@@ -251,14 +251,15 @@ export const OverviewPage = () => {
"updateFolder"
] as const);
const handleFolderCreate = async (folderName: string) => {
const handleFolderCreate = async (folderName: string, description: string | null) => {
const promises = userAvailableEnvs.map((env) => {
const environment = env.slug;
return createFolder({
name: folderName,
path: secretPath,
environment,
projectId: workspaceId
projectId: workspaceId,
description
});
});
@@ -279,7 +280,7 @@ export const OverviewPage = () => {
}
};
const handleFolderUpdate = async (newFolderName: string) => {
const handleFolderUpdate = async (newFolderName: string, description: string | null) => {
const { name: oldFolderName } = popUp.updateFolder.data as TSecretFolder;
const updatedFolders: TUpdateFolderBatchDTO["folders"] = [];
@@ -296,7 +297,8 @@ export const OverviewPage = () => {
environment: env.slug,
name: newFolderName,
id: folder.id,
path: secretPath
path: secretPath,
description
});
}
}
@@ -1027,7 +1029,7 @@ export const OverviewPage = () => {
)}
{!isOverviewLoading && visibleEnvs.length > 0 && (
<>
{folderNames.map((folderName, index) => (
{folderNamesAndDescriptions.map(({name: folderName, description}, index) => (
<SecretOverviewFolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
@@ -1039,7 +1041,7 @@ export const OverviewPage = () => {
key={`overview-${folderName}-${index + 1}`}
onClick={handleFolderClick}
onToggleFolderEdit={(name: string) =>
handlePopUpOpen("updateFolder", { name })
handlePopUpOpen("updateFolder", { name, description })
}
/>
))}
@@ -1159,7 +1161,9 @@ export const OverviewPage = () => {
<FolderForm
isEdit
defaultFolderName={(popUp.updateFolder?.data as Pick<TSecretFolder, "name">)?.name}
defaultDescription={(popUp.updateFolder?.data as Pick<TSecretFolder, "description">)?.description}
onUpdateFolder={handleFolderUpdate}
showDescriptionOverwriteWarning
/>
</ModalContent>
</Modal>

View File

@@ -128,13 +128,14 @@ export const ActionBar = ({
const { currentWorkspace } = useWorkspace();
const handleFolderCreate = async (folderName: string) => {
const handleFolderCreate = async (folderName: string, description: string | null) => {
try {
await createFolder({
name: folderName,
path: secretPath,
environment,
projectId: workspaceId
projectId: workspaceId,
description
});
handlePopUpClose("addFolder");
createNotification({

View File

@@ -1,16 +1,22 @@
import { useRef } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button, FormControl, Input, ModalClose } from "@app/components/v2";
import { TextArea } from "@app/components/v2/TextArea/TextArea";
type Props = {
onCreateFolder?: (folderName: string) => Promise<void>;
onUpdateFolder?: (folderName: string) => Promise<void>;
onCreateFolder?: (folderName: string, description: string | null) => Promise<void>;
onUpdateFolder?: (folderName: string, description: string | null) => Promise<void>;
isEdit?: boolean;
defaultFolderName?: string;
defaultDescription?: string;
showDescriptionOverwriteWarning?: boolean;
};
const descriptionOverwriteWarningMessage = "Warning: Any changes made here will overwrite any custom edits in individual environment folders."
const formSchema = z.object({
name: z
.string()
@@ -18,15 +24,20 @@ const formSchema = z.object({
.regex(
/^[a-zA-Z0-9-_]+$/,
"Folder name can only contain letters, numbers, dashes, and underscores"
)
),
description: z
.string()
.optional()
});
type TFormData = z.infer<typeof formSchema>;
export const FolderForm = ({
isEdit,
defaultFolderName,
defaultDescription,
onCreateFolder,
onUpdateFolder
onUpdateFolder,
showDescriptionOverwriteWarning = false
}: Props): JSX.Element => {
const {
control,
@@ -36,15 +47,32 @@ export const FolderForm = ({
} = useForm<TFormData>({
resolver: zodResolver(formSchema),
defaultValues: {
name: defaultFolderName
name: defaultFolderName,
description: defaultDescription || ""
}
});
const onSubmit = async ({ name }: TFormData) => {
const descriptionRef = useRef<HTMLTextAreaElement>(null);
const handleInput = () => {
const textarea = descriptionRef.current;
if (textarea) {
const lines = textarea.value.split("\n");
const maxDescriptionLines = 10;
if (lines.length > maxDescriptionLines) {
textarea.value = lines.slice(0, maxDescriptionLines).join("\n");
}
}
};
const onSubmit = async ({ name, description }: TFormData) => {
const descriptionShaped = description && description.trim() !== "" ? description : null;
if (isEdit) {
await onUpdateFolder?.(name);
await onUpdateFolder?.(name, descriptionShaped);
} else {
await onCreateFolder?.(name);
await onCreateFolder?.(name, descriptionShaped);
}
reset();
};
@@ -61,6 +89,31 @@ export const FolderForm = ({
</FormControl>
)}
/>
<Controller
control={control}
name="description"
defaultValue=""
render={({ field, fieldState: { error } }) => (
<FormControl
label="Folder Description"
isError={Boolean(error)}
tooltipText={showDescriptionOverwriteWarning ? descriptionOverwriteWarningMessage : undefined}
isOptional
errorText={error?.message}
className="flex-1"
>
<TextArea
placeholder="Folder description"
{...field}
rows={3}
ref={descriptionRef}
onInput={handleInput}
className="thin-scrollbar w-full !resize-none bg-mineshaft-900"
maxLength={255}
/>
</FormControl>
)}
/>
<div className="mt-8 flex items-center">
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
{isEdit ? "Save" : "Create"}

View File

@@ -1,5 +1,5 @@
import { subject } from "@casl/ability";
import { faClose, faFolder, faPencilSquare } from "@fortawesome/free-solid-svg-icons";
import { faClose, faFolder, faPencilSquare, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
@@ -11,6 +11,7 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
import { Tooltip } from "@app/components/v2/Tooltip/Tooltip";
import { FolderForm } from "../ActionBar/FolderForm";
@@ -42,7 +43,7 @@ export const FolderListView = ({
const { mutateAsync: updateFolder } = useUpdateFolder();
const { mutateAsync: deleteFolder } = useDeleteFolder();
const handleFolderUpdate = async (newFolderName: string) => {
const handleFolderUpdate = async (newFolderName: string, newFolderDescription: string | null) => {
try {
const { id: folderId } = popUp.updateFolder.data as TSecretFolder;
await updateFolder({
@@ -50,7 +51,8 @@ export const FolderListView = ({
name: newFolderName,
path: secretPath,
environment,
projectId: workspaceId
projectId: workspaceId,
description: newFolderDescription
});
handlePopUpClose("updateFolder");
createNotification({
@@ -98,7 +100,7 @@ export const FolderListView = ({
return (
<>
{folders.map(({ name, id }) => (
{folders.map(({ name, id, description }) => (
<div
key={id}
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
@@ -116,6 +118,16 @@ export const FolderListView = ({
onClick={() => handleFolderClick(name)}
>
{name}
{
description &&
<Tooltip
position="right"
className="flex items-center space-x-4 max-w-lg py-4 whitespace-pre-wrap"
content={description}
>
<FontAwesomeIcon icon={faInfoCircle} className="text-mineshaft-400 ml-1" />
</Tooltip>
}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
@@ -130,7 +142,7 @@ export const FolderListView = ({
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("updateFolder", { id, name })}
onClick={() => handlePopUpOpen("updateFolder", { id, name, description })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
@@ -167,6 +179,7 @@ export const FolderListView = ({
<FolderForm
isEdit
defaultFolderName={(popUp.updateFolder?.data as TSecretFolder)?.name}
defaultDescription={(popUp.updateFolder?.data as TSecretFolder)?.description}
onUpdateFolder={handleFolderUpdate}
/>
</ModalContent>