From a0029ab46926ffd03bf72906d8746bf7d7488587 Mon Sep 17 00:00:00 2001 From: carlosmonastyrski Date: Tue, 4 Mar 2025 10:11:20 -0300 Subject: [PATCH 1/4] Add descriptions to secret folders --- .../20250303213350_add-folder-description.ts | 25 +++++++ backend/src/db/schemas/secret-folders.ts | 3 +- backend/src/lib/api-docs/constants.ts | 6 +- .../server/routes/v1/secret-folder-router.ts | 17 +++-- .../secret-folder/secret-folder-service.ts | 14 ++-- .../secret-folder/secret-folder-types.ts | 3 + .../src/hooks/api/secretFolders/queries.tsx | 5 +- frontend/src/hooks/api/secretFolders/types.ts | 4 ++ frontend/src/hooks/utils/secrets-overview.tsx | 19 +++-- .../OverviewPage/OverviewPage.tsx | 18 +++-- .../components/ActionBar/ActionBar.tsx | 5 +- .../components/ActionBar/FolderForm.tsx | 69 ++++++++++++++++--- .../FolderListView/FolderListView.tsx | 21 ++++-- 13 files changed, 164 insertions(+), 45 deletions(-) create mode 100644 backend/src/db/migrations/20250303213350_add-folder-description.ts 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..963bdaca7 --- /dev/null +++ b/backend/src/db/migrations/20250303213350_add-folder-description.ts @@ -0,0 +1,25 @@ +import { Knex } from "knex"; + +import { TableName } from "../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/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..8566b9033 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().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, + 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().describe(FOLDERS.UPDATE.description), }), response: { 200: z.object({ @@ -144,7 +148,7 @@ export const registerSecretFolderRouter = async (server: FastifyZodProvider) => ...req.body, projectId: req.body.workspaceId, id: req.params.folderId, - path + path, }); await server.services.auditLog.createAuditLog({ ...req.auditLogInfo, @@ -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().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..291232dbd 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,7 @@ 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 +171,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 +218,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 +260,8 @@ export const secretFolderServiceFactory = ({ name, environment, path: secretPath, - id + id, + description }: TUpdateFolderDTO) => { const { permission } = await permissionService.getProjectPermission({ actor, @@ -312,7 +314,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..983cbb32d 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; } & TProjectPermission; export type TUpdateFolderDTO = { @@ -16,6 +17,7 @@ export type TUpdateFolderDTO = { path: string; id: string; name: string; + description?: string; } & TProjectPermission; export type TUpdateManyFoldersDTO = { @@ -25,6 +27,7 @@ export type TUpdateManyFoldersDTO = { path: string; id: string; name: string; + description?: string; }[]; } & 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..34b1fda57 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 }; export type TUpdateFolderDTO = { @@ -32,6 +34,7 @@ export type TUpdateFolderDTO = { name: string; folderId: string; path?: string; + description?: string }; export type TDeleteFolderDTO = { @@ -49,5 +52,6 @@ export type TUpdateFolderBatchDTO = { environment: string; id: string; path?: string; + description?: string; }[]; }; 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..31c5c8c3f 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 | undefined) => { 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 | undefined) => { 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..7b11f7a59 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 | undefined) => { 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..7c6aea890 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 | undefined) => Promise; + onUpdateFolder?: (folderName: string, description: string | undefined) => 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?.trim() === "" ? undefined : description; + 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 = ({ )} /> + ( + +