diff --git a/backend/src/db/migrations/20250303213350_add-folder-description.ts b/backend/src/db/migrations/20250303213350_add-folder-description.ts new file mode 100644 index 000000000..6a5d0a076 --- /dev/null +++ b/backend/src/db/migrations/20250303213350_add-folder-description.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "@app/db/schemas"; + +export async function up(knex: Knex): Promise { + 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 { + const hasProjectDescription = await knex.schema.hasColumn(TableName.SecretFolder, "description"); + + if (hasProjectDescription) { + await knex.schema.alterTable(TableName.SecretFolder, (t) => { + t.dropColumn("description"); + }); + } +} diff --git a/backend/src/db/schemas/secret-folders.ts b/backend/src/db/schemas/secret-folders.ts index ad43ed1ad..33b13b673 100644 --- a/backend/src/db/schemas/secret-folders.ts +++ b/backend/src/db/schemas/secret-folders.ts @@ -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; diff --git a/backend/src/ee/services/audit-log/audit-log-types.ts b/backend/src/ee/services/audit-log/audit-log-types.ts index bafeba120..85e48872e 100644 --- a/backend/src/ee/services/audit-log/audit-log-types.ts +++ b/backend/src/ee/services/audit-log/audit-log-types.ts @@ -1142,6 +1142,7 @@ interface CreateFolderEvent { folderId: string; folderName: string; folderPath: string; + description?: string; }; } diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index fb0bf84e3..1b458175d 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -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.", diff --git a/backend/src/server/routes/v1/secret-folder-router.ts b/backend/src/server/routes/v1/secret-folder-router.ts index 088afec47..b0a22548a 100644 --- a/backend/src/server/routes/v1/secret-folder-router.ts +++ b/backend/src/server/routes/v1/secret-folder-router.ts @@ -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) diff --git a/backend/src/services/secret-folder/secret-folder-service.ts b/backend/src/services/secret-folder/secret-folder-service.ts index 04eac4586..aabc35683 100644 --- a/backend/src/services/secret-folder/secret-folder-service.ts +++ b/backend/src/services/secret-folder/secret-folder-service.ts @@ -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( diff --git a/backend/src/services/secret-folder/secret-folder-types.ts b/backend/src/services/secret-folder/secret-folder-types.ts index eb98809cd..bbe4c7223 100644 --- a/backend/src/services/secret-folder/secret-folder-types.ts +++ b/backend/src/services/secret-folder/secret-folder-types.ts @@ -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; diff --git a/frontend/src/hooks/api/secretFolders/queries.tsx b/frontend/src/hooks/api/secretFolders/queries.tsx index b58dd3c05..10f835da8 100644 --- a/frontend/src/hooks/api/secretFolders/queries.tsx +++ b/frontend/src/hooks/api/secretFolders/queries.tsx @@ -148,12 +148,13 @@ export const useUpdateFolder = () => { const queryClient = useQueryClient(); return useMutation({ - 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; }, diff --git a/frontend/src/hooks/api/secretFolders/types.ts b/frontend/src/hooks/api/secretFolders/types.ts index 412f2686d..454e159e1 100644 --- a/frontend/src/hooks/api/secretFolders/types.ts +++ b/frontend/src/hooks/api/secretFolders/types.ts @@ -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; }[]; }; diff --git a/frontend/src/hooks/utils/secrets-overview.tsx b/frontend/src/hooks/utils/secrets-overview.tsx index b84fb3902..a3e5f8faf 100644 --- a/frontend/src/hooks/utils/secrets-overview.tsx +++ b/frontend/src/hooks/utils/secrets-overview.tsx @@ -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(); + const folderNamesAndDescriptions = useMemo(() => { + const namesAndDescriptions = new Map(); + 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 = ( diff --git a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx index 6f9d4c9b2..f96aabf53 100644 --- a/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx +++ b/frontend/src/pages/secret-manager/OverviewPage/OverviewPage.tsx @@ -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) => ( { key={`overview-${folderName}-${index + 1}`} onClick={handleFolderClick} onToggleFolderEdit={(name: string) => - handlePopUpOpen("updateFolder", { name }) + handlePopUpOpen("updateFolder", { name, description }) } /> ))} @@ -1159,7 +1161,9 @@ export const OverviewPage = () => { )?.name} + defaultDescription={(popUp.updateFolder?.data as Pick)?.description} onUpdateFolder={handleFolderUpdate} + showDescriptionOverwriteWarning /> diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx index e4de76198..03ff3dd57 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/ActionBar.tsx @@ -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({ diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx index bad6376c8..78d075b4f 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/ActionBar/FolderForm.tsx @@ -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; - onUpdateFolder?: (folderName: string) => Promise; + onCreateFolder?: (folderName: string, description: string | null) => Promise; + onUpdateFolder?: (folderName: string, description: string | null) => Promise; 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; export const FolderForm = ({ isEdit, defaultFolderName, + defaultDescription, onCreateFolder, - onUpdateFolder + onUpdateFolder, + showDescriptionOverwriteWarning = false }: Props): JSX.Element => { const { control, @@ -36,15 +47,32 @@ export const FolderForm = ({ } = useForm({ resolver: zodResolver(formSchema), defaultValues: { - name: defaultFolderName + name: defaultFolderName, + description: defaultDescription || "" } }); - const onSubmit = async ({ name }: TFormData) => { + const descriptionRef = useRef(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 = ({ )} /> + ( + +