diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index 1b458175d..821f91ddc 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -459,7 +459,8 @@ export const PROJECTS = { 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." + autoCapitalization: "Disable or enable auto-capitalization for the project.", + slug: "An optional slug for the project. (must be unique within the server)" }, GET_KEY: { workspaceId: "The ID of the project to get the key from." diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 68d13842c..5209dadcf 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -307,7 +307,13 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { .max(256, { message: "Description must be 256 or fewer characters" }) .optional() .describe(PROJECTS.UPDATE.projectDescription), - autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization) + autoCapitalization: z.boolean().optional().describe(PROJECTS.UPDATE.autoCapitalization), + slug: z + .string() + .trim() + .max(64, { message: "Slug must be 64 characters or fewer" }) + .optional() + .describe(PROJECTS.UPDATE.slug) }), response: { 200: z.object({ @@ -325,7 +331,8 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { update: { name: req.body.name, description: req.body.description, - autoCapitalization: req.body.autoCapitalization + autoCapitalization: req.body.autoCapitalization, + slug: req.body.slug }, actorAuthMethod: req.permission.authMethod, actorId: req.permission.id, diff --git a/backend/src/services/project/project-service.ts b/backend/src/services/project/project-service.ts index e1653d371..4b110ebd2 100644 --- a/backend/src/services/project/project-service.ts +++ b/backend/src/services/project/project-service.ts @@ -563,11 +563,24 @@ export const projectServiceFactory = ({ }); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Settings); + if (update.slug) { + const existingProject = await projectDAL.findOne({ + slug: update.slug, + orgId: actorOrgId + }); + if (existingProject && existingProject.id !== project.id) { + throw new BadRequestError({ + message: `Failed to update project slug. The project "${existingProject.name}" with the slug "${existingProject.slug}" already exists in your organization. Please choose a unique slug for your project.` + }); + } + } + const updatedProject = await projectDAL.updateById(project.id, { name: update.name, description: update.description, autoCapitalization: update.autoCapitalization, - enforceCapitalization: update.autoCapitalization + enforceCapitalization: update.autoCapitalization, + slug: update.slug }); return updatedProject; diff --git a/backend/src/services/project/project-types.ts b/backend/src/services/project/project-types.ts index 83a59b6af..5ccf33d23 100644 --- a/backend/src/services/project/project-types.ts +++ b/backend/src/services/project/project-types.ts @@ -82,6 +82,7 @@ export type TUpdateProjectDTO = { name?: string; description?: string; autoCapitalization?: boolean; + slug?: string; }; } & Omit; diff --git a/frontend/src/pages/kms/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx b/frontend/src/components/project/ProjectOverviewChangeSection.tsx similarity index 58% rename from frontend/src/pages/kms/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx rename to frontend/src/components/project/ProjectOverviewChangeSection.tsx index d82415c0d..767f17334 100644 --- a/frontend/src/pages/kms/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx +++ b/frontend/src/components/project/ProjectOverviewChangeSection.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Controller, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; @@ -9,9 +9,7 @@ import { Button, FormControl, Input, TextArea } from "@app/components/v2"; import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; import { useUpdateProject } from "@app/hooks/api"; -import { CopyButton } from "./CopyButton"; - -const formSchema = z.object({ +const baseFormSchema = z.object({ name: z.string().min(1, "Required").max(64, "Too long, maximum length is 64 characters"), description: z .string() @@ -20,33 +18,59 @@ const formSchema = z.object({ .optional() }); -type FormData = z.infer; +const formSchemaWithSlug = baseFormSchema.extend({ + slug: z + .string() + .min(1, "Required") + .max(64, "Too long, maximum length is 64 characters") + .regex(/^[a-zA-Z0-9-]+$/, "Only letters, numbers and hyphens are allowed") +}); -export const ProjectOverviewChangeSection = () => { +type BaseFormData = z.infer; +type FormDataWithSlug = z.infer; + +type Props = { + showSlugField?: boolean; +}; + +export const ProjectOverviewChangeSection = ({ showSlugField = false }: Props) => { const { currentWorkspace } = useWorkspace(); + const [currentSlug, setCurrentSlug] = useState(currentWorkspace?.slug); const { mutateAsync, isPending } = useUpdateProject(); - const { handleSubmit, control, reset } = useForm({ resolver: zodResolver(formSchema) }); + const { handleSubmit, control, reset } = useForm({ + resolver: zodResolver(showSlugField ? formSchemaWithSlug : baseFormSchema) + }); useEffect(() => { if (currentWorkspace) { reset({ name: currentWorkspace.name, - description: currentWorkspace.description ?? "" + description: currentWorkspace.description ?? "", + ...(showSlugField && { slug: currentWorkspace.slug }) }); + setCurrentSlug(currentWorkspace.slug); } - }, [currentWorkspace]); + }, [currentWorkspace, showSlugField]); - const onFormSubmit = async ({ name, description }: FormData) => { + const onFormSubmit = async (data: BaseFormData | FormDataWithSlug) => { try { if (!currentWorkspace?.id) return; await mutateAsync({ projectID: currentWorkspace.id, - newProjectName: name, - newProjectDescription: description + newProjectName: data.name, + newProjectDescription: data.description, + ...(showSlugField && + "slug" in data && { + newSlug: data.slug !== currentWorkspace.slug ? data.slug : undefined + }) }); + if (showSlugField && "slug" in data) { + setCurrentSlug(data.slug); + } + createNotification({ text: "Successfully updated project overview", type: "success" @@ -65,20 +89,34 @@ export const ProjectOverviewChangeSection = () => {

Project Overview

- { + navigator.clipboard.writeText(currentSlug || ""); + createNotification({ + text: "Copied project slug to clipboard", + type: "success" + }); + }} + title="Click to copy project slug" > Copy Project Slug - - +
@@ -113,6 +151,38 @@ export const ProjectOverviewChangeSection = () => {
+ {showSlugField && ( +
+
+ + {(isAllowed) => ( + ( + + + + )} + control={control} + name="slug" + /> + )} + +
+
+ )}
{ const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ projectID, newProjectName, newProjectDescription }) => { + mutationFn: async ({ projectID, newProjectName, newProjectDescription, newSlug }) => { const { data } = await apiRequest.patch<{ workspace: Workspace }>( `/api/v1/workspace/${projectID}`, { name: newProjectName, - description: newProjectDescription + description: newProjectDescription, + slug: newSlug } ); return data.workspace; diff --git a/frontend/src/hooks/api/workspace/types.ts b/frontend/src/hooks/api/workspace/types.ts index 0510bdbe7..0980bc715 100644 --- a/frontend/src/hooks/api/workspace/types.ts +++ b/frontend/src/hooks/api/workspace/types.ts @@ -74,6 +74,7 @@ export type UpdateProjectDTO = { projectID: string; newProjectName: string; newProjectDescription?: string; + newSlug?: string; }; export type UpdatePitVersionLimitDTO = { projectSlug: string; pitVersionLimit: number }; diff --git a/frontend/src/pages/cert-manager/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx b/frontend/src/pages/cert-manager/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx index ef684a21b..41e8107e0 100644 --- a/frontend/src/pages/cert-manager/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx +++ b/frontend/src/pages/cert-manager/SettingsPage/components/ProjectGeneralTab/ProjectGeneralTab.tsx @@ -1,11 +1,12 @@ +import { ProjectOverviewChangeSection } from "@app/components/project/ProjectOverviewChangeSection"; + import { AuditLogsRetentionSection } from "../AuditLogsRetentionSection"; import { DeleteProjectSection } from "../DeleteProjectSection"; -import { ProjectOverviewChangeSection } from "../ProjectOverviewChangeSection"; export const ProjectGeneralTab = () => { return (
- +
diff --git a/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/CopyButton.tsx b/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/CopyButton.tsx deleted file mode 100644 index 34fe365ec..000000000 --- a/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/CopyButton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useCallback } from "react"; -import { faCheck, faCopy } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; - -import { createNotification } from "@app/components/notifications"; -import { Button } from "@app/components/v2"; -import { useToggle } from "@app/hooks"; - -type Props = { - value: string; - hoverText: string; - notificationText: string; - children: React.ReactNode; -}; - -export const CopyButton = ({ value, children, hoverText, notificationText }: Props) => { - const [isProjectIdCopied, setIsProjectIdCopied] = useToggle(false); - - const copyToClipboard = useCallback(() => { - if (isProjectIdCopied) { - return; - } - - setIsProjectIdCopied.on(); - navigator.clipboard.writeText(value); - - createNotification({ - text: notificationText, - type: "success" - }); - - const timer = setTimeout(() => setIsProjectIdCopied.off(), 2000); - - // eslint-disable-next-line consistent-return - return () => clearTimeout(timer); - }, [isProjectIdCopied]); - - return ( - - ); -}; diff --git a/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx b/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx deleted file mode 100644 index d82415c0d..000000000 --- a/frontend/src/pages/cert-manager/SettingsPage/components/ProjectOverviewChangeSection/ProjectOverviewChangeSection.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import { useEffect } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; - -import { createNotification } from "@app/components/notifications"; -import { ProjectPermissionCan } from "@app/components/permissions"; -import { Button, FormControl, Input, TextArea } from "@app/components/v2"; -import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context"; -import { useUpdateProject } from "@app/hooks/api"; - -import { CopyButton } from "./CopyButton"; - -const formSchema = z.object({ - name: z.string().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() -}); - -type FormData = z.infer; - -export const ProjectOverviewChangeSection = () => { - const { currentWorkspace } = useWorkspace(); - const { mutateAsync, isPending } = useUpdateProject(); - - const { handleSubmit, control, reset } = useForm({ resolver: zodResolver(formSchema) }); - - useEffect(() => { - if (currentWorkspace) { - reset({ - name: currentWorkspace.name, - description: currentWorkspace.description ?? "" - }); - } - }, [currentWorkspace]); - - const onFormSubmit = async ({ name, description }: FormData) => { - try { - if (!currentWorkspace?.id) return; - - await mutateAsync({ - projectID: currentWorkspace.id, - newProjectName: name, - newProjectDescription: description - }); - - createNotification({ - text: "Successfully updated project overview", - type: "success" - }); - } catch (err) { - console.error(err); - createNotification({ - text: "Failed to update project overview", - type: "error" - }); - } - }; - - return ( -
-
-

Project Overview

-
- - Copy Project Slug - - - Copy Project ID - -
-
-
-
-
-
- - {(isAllowed) => ( - ( - - - - )} - control={control} - name="name" - /> - )} - -
-
-
-
- - {(isAllowed) => ( - ( - -