From 32430a6a1655f6082f6946e7534249f924d32ff3 Mon Sep 17 00:00:00 2001 From: McPizza Date: Mon, 25 Nov 2024 21:59:14 +0100 Subject: [PATCH] feat: Add Project Descriptions (#2774) * feat: :sparkles: initial backend project description --- .../20241119143026_add-project-descripton.ts | 23 ++ backend/src/db/schemas/kms-root-config.ts | 2 +- backend/src/db/schemas/projects.ts | 3 +- backend/src/lib/api-docs/constants.ts | 2 + backend/src/server/routes/sanitizedSchemas.ts | 1 + .../src/server/routes/v1/project-router.ts | 7 + .../src/server/routes/v2/project-router.ts | 8 +- .../src/services/project/project-service.ts | 3 + backend/src/services/project/project-types.ts | 2 + .../v2/projects/NewProjectModal.tsx | 328 ++++++++++++++++++ frontend/src/components/v2/projects/index.tsx | 1 + frontend/src/hooks/api/types.ts | 2 +- frontend/src/hooks/api/workspace/index.tsx | 5 +- frontend/src/hooks/api/workspace/queries.tsx | 24 +- frontend/src/hooks/api/workspace/types.ts | 10 +- frontend/src/layouts/AppLayout/AppLayout.tsx | 279 +-------------- .../src/pages/org/[id]/overview/index.tsx | 321 ++--------------- .../ProjectGeneralTab/ProjectGeneralTab.tsx | 4 +- .../ProjectNameChangeSection.tsx | 119 ------- .../ProjectNameChangeSection/index.tsx | 1 - .../CopyButton.tsx | 0 .../ProjectOverviewChangeSection.tsx | 168 +++++++++ .../ProjectOverviewChangeSection/index.tsx | 1 + .../ProjectSettingsPage/components/index.tsx | 2 +- 24 files changed, 615 insertions(+), 701 deletions(-) create mode 100644 backend/src/db/migrations/20241119143026_add-project-descripton.ts create mode 100644 frontend/src/components/v2/projects/NewProjectModal.tsx create mode 100644 frontend/src/components/v2/projects/index.tsx delete mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/ProjectNameChangeSection.tsx delete mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/ProjectNameChangeSection/index.tsx rename frontend/src/views/Settings/ProjectSettingsPage/components/{ProjectNameChangeSection => ProjectOverviewChangeSection}/CopyButton.tsx (100%) create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx create mode 100644 frontend/src/views/Settings/ProjectSettingsPage/components/ProjectOverviewChangeSection/index.tsx diff --git a/backend/src/db/migrations/20241119143026_add-project-descripton.ts b/backend/src/db/migrations/20241119143026_add-project-descripton.ts new file mode 100644 index 000000000..3c78c99e2 --- /dev/null +++ b/backend/src/db/migrations/20241119143026_add-project-descripton.ts @@ -0,0 +1,23 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasProjectDescription = await knex.schema.hasColumn(TableName.Project, "description"); + + if (!hasProjectDescription) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.string("description"); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasProjectDescription = await knex.schema.hasColumn(TableName.Project, "description"); + + if (hasProjectDescription) { + await knex.schema.alterTable(TableName.Project, (t) => { + t.dropColumn("description"); + }); + } +} diff --git a/backend/src/db/schemas/kms-root-config.ts b/backend/src/db/schemas/kms-root-config.ts index d15e1dff8..c9c1ebda5 100644 --- a/backend/src/db/schemas/kms-root-config.ts +++ b/backend/src/db/schemas/kms-root-config.ts @@ -12,7 +12,7 @@ import { TImmutableDBKeys } from "./models"; export const KmsRootConfigSchema = z.object({ id: z.string().uuid(), encryptedRootKey: zodBuffer, - encryptionStrategy: z.string(), + encryptionStrategy: z.string().default("SOFTWARE").nullable().optional(), createdAt: z.date(), updatedAt: z.date() }); diff --git a/backend/src/db/schemas/projects.ts b/backend/src/db/schemas/projects.ts index deba51b9a..5c5f9774b 100644 --- a/backend/src/db/schemas/projects.ts +++ b/backend/src/db/schemas/projects.ts @@ -23,7 +23,8 @@ export const ProjectsSchema = z.object({ kmsCertificateKeyId: z.string().uuid().nullable().optional(), auditLogsRetentionDays: z.number().nullable().optional(), kmsSecretManagerKeyId: z.string().uuid().nullable().optional(), - kmsSecretManagerEncryptedDataKey: zodBuffer.nullable().optional() + kmsSecretManagerEncryptedDataKey: zodBuffer.nullable().optional(), + description: z.string().nullable().optional() }); export type TProjects = z.infer; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 136a4db29..9d0382b5c 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -391,6 +391,7 @@ export const PROJECTS = { CREATE: { organizationSlug: "The slug of the organization to create the project in.", projectName: "The name of the project to create.", + projectDescription: "An optional description label for the project.", slug: "An optional slug for the project.", template: "The name of the project template, if specified, to apply to this project." }, @@ -403,6 +404,7 @@ export const PROJECTS = { UPDATE: { workspaceId: "The ID of the project to update.", name: "The new name of the project.", + projectDescription: "An optional description label for the project.", autoCapitalization: "Disable or enable auto-capitalization for the project." }, GET_KEY: { diff --git a/backend/src/server/routes/sanitizedSchemas.ts b/backend/src/server/routes/sanitizedSchemas.ts index bbbe57631..3fbbc60e3 100644 --- a/backend/src/server/routes/sanitizedSchemas.ts +++ b/backend/src/server/routes/sanitizedSchemas.ts @@ -212,6 +212,7 @@ export const SanitizedAuditLogStreamSchema = z.object({ export const SanitizedProjectSchema = ProjectsSchema.pick({ id: true, name: true, + description: true, slug: true, autoCapitalization: true, orgId: true, diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index e5e2f636c..f27462d02 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -296,6 +296,12 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .max(64, { message: "Name must be 64 or fewer characters" }) .optional() .describe(PROJECTS.UPDATE.name), + description: z + .string() + .trim() + .max(256, { message: "Description must be 256 or fewer characters" }) + .optional() + .describe(PROJECTS.UPDATE.projectDescription), autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization) }), response: { @@ -313,6 +319,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, update: { name: req.body.name, + description: req.body.description, autoCapitalization: req.body.autoCapitalization }, actorAuthMethod: req.permission.authMethod, diff --git a/backend/src/server/routes/v2/project-router.ts b/backend/src/server/routes/v2/project-router.ts index c2aa446b4..0e271eb0e 100644 --- a/backend/src/server/routes/v2/project-router.ts +++ b/backend/src/server/routes/v2/project-router.ts @@ -161,6 +161,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { ], body: z.object({ projectName: z.string().trim().describe(PROJECTS.CREATE.projectName), + projectDescription: z.string().trim().optional().describe(PROJECTS.CREATE.projectDescription), slug: z .string() .min(5) @@ -194,6 +195,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, workspaceName: req.body.projectName, + workspaceDescription: req.body.projectDescription, slug: req.body.slug, kmsKeyId: req.body.kmsKeyId, template: req.body.template @@ -312,8 +314,9 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { slug: slugSchema.describe("The slug of the project to update.") }), body: z.object({ - name: z.string().trim().optional().describe("The new name of the project."), - autoCapitalization: z.boolean().optional().describe("The new auto-capitalization setting.") + name: z.string().trim().optional().describe(PROJECTS.UPDATE.name), + description: z.string().trim().optional().describe(PROJECTS.UPDATE.projectDescription), + autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization) }), response: { 200: SanitizedProjectSchema @@ -330,6 +333,7 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { }, update: { name: req.body.name, + description: req.body.description, autoCapitalization: req.body.autoCapitalization }, actorId: req.permission.id, diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index dfe2ce3ec..53e934716 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -149,6 +149,7 @@ export const projectServiceFactory = ({ actorOrgId, actorAuthMethod, workspaceName, + workspaceDescription, slug: projectSlug, kmsKeyId, tx: trx, @@ -206,6 +207,7 @@ export const projectServiceFactory = ({ const project = await projectDAL.create( { name: workspaceName, + description: workspaceDescription, orgId: organization.id, slug: projectSlug || slugify(`${workspaceName}-${alphaNumericNanoId(4)}`), kmsSecretManagerKeyId: kmsKeyId, @@ -496,6 +498,7 @@ export const projectServiceFactory = ({ const updatedProject = await projectDAL.updateById(project.id, { name: update.name, + description: update.description, autoCapitalization: update.autoCapitalization }); return updatedProject; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 28cda2d95..b826f2a6a 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -29,6 +29,7 @@ export type TCreateProjectDTO = { actorId: string; actorOrgId?: string; workspaceName: string; + workspaceDescription?: string; slug?: string; kmsKeyId?: string; createDefaultEnvs?: boolean; @@ -69,6 +70,7 @@ export type TUpdateProjectDTO = { filter: Filter; update: { name?: string; + description?: string; autoCapitalization?: boolean; }; } & Omit; diff --git a/frontend/src/components/v2/projects/NewProjectModal.tsx b/frontend/src/components/v2/projects/NewProjectModal.tsx new file mode 100644 index 000000000..8f2cf79e8 --- /dev/null +++ b/frontend/src/components/v2/projects/NewProjectModal.tsx @@ -0,0 +1,328 @@ +import { FC, useEffect } from "react"; +import { Controller, useForm } from "react-hook-form"; +import { useRouter } from "next/router"; +import { faInfoCircle } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import z from "zod"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, + Button, + Checkbox, + FormControl, + Input, + Modal, + ModalClose, + ModalContent, + Select, + SelectItem, + TextArea +} from "@app/components/v2"; +import { + OrgPermissionActions, + OrgPermissionSubjects, + useOrganization, + useOrgPermission, + useSubscription, + useUser +} from "@app/context"; +import { + fetchOrgUsers, + useAddUserToWsNonE2EE, + useCreateWorkspace, + useGetExternalKmsList +} from "@app/hooks/api"; +import { INTERNAL_KMS_KEY_ID } from "@app/hooks/api/kms/types"; +import { InfisicalProjectTemplate, useListProjectTemplates } from "@app/hooks/api/projectTemplates"; + +const formSchema = z.object({ + name: z.string().trim().min(1, "Required").max(64, "Too long, maximum length is 64 characters"), + description: z + .string() + .trim() + .max(256, "Description too long, max length is 256 characters") + .optional(), + addMembers: z.boolean(), + kmsKeyId: z.string(), + template: z.string() +}); + +type TAddProjectFormData = z.infer; + +interface NewProjectModalProps { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; +} + +type NewProjectFormProps = Pick; + +const NewProjectForm = ({ onOpenChange }: NewProjectFormProps) => { + const router = useRouter(); + const { currentOrg } = useOrganization(); + const { permission } = useOrgPermission(); + const { user } = useUser(); + const createWs = useCreateWorkspace(); + const addUsersToProject = useAddUserToWsNonE2EE(); + const { subscription } = useSubscription(); + + const canReadProjectTemplates = permission.can( + OrgPermissionActions.Read, + OrgPermissionSubjects.ProjectTemplates + ); + + const { data: projectTemplates = [] } = useListProjectTemplates({ + enabled: Boolean(canReadProjectTemplates && subscription?.projectTemplates) + }); + + const { data: externalKmsList } = useGetExternalKmsList(currentOrg?.id!, { + enabled: permission.can(OrgPermissionActions.Read, OrgPermissionSubjects.Kms) + }); + + const { + control, + handleSubmit, + reset, + formState: { isSubmitting, errors } + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + kmsKeyId: INTERNAL_KMS_KEY_ID, + template: InfisicalProjectTemplate.Default + } + }); + + useEffect(() => { + if (Object.keys(errors).length > 0) { + console.log("Current form errors:", errors); + } + }, [errors]); + + const onCreateProject = async ({ + name, + description, + addMembers, + kmsKeyId, + template + }: TAddProjectFormData) => { + // type check + if (!currentOrg) return; + if (!user) return; + try { + const { + data: { + project: { id: newProjectId } + } + } = await createWs.mutateAsync({ + projectName: name, + projectDescription: description, + kmsKeyId: kmsKeyId !== INTERNAL_KMS_KEY_ID ? kmsKeyId : undefined, + template + }); + + if (addMembers) { + const orgUsers = await fetchOrgUsers(currentOrg.id); + await addUsersToProject.mutateAsync({ + usernames: orgUsers + .filter( + (member) => member.user.username !== user.username && member.status === "accepted" + ) + .map((member) => member.user.username), + projectId: newProjectId, + orgId: currentOrg.id + }); + } + // eslint-disable-next-line no-promise-executor-return -- We do this because the function returns too fast, which sometimes causes an error when the user is redirected. + await new Promise((resolve) => setTimeout(resolve, 2_000)); + + createNotification({ text: "Project created", type: "success" }); + reset(); + onOpenChange(false); + router.push(`/project/${newProjectId}/secrets/overview`); + } catch (err) { + console.error(err); + createNotification({ text: "Failed to create project", type: "error" }); + } + }; + const onSubmit = handleSubmit((data) => { + return onCreateProject(data); + }); + return ( +
+
+ ( + + + + )} + /> + ( + +