diff --git a/backend/src/server/routes/v1/project-env-router.ts b/backend/src/server/routes/v1/project-env-router.ts index ef51f865c6..341b8a184e 100644 --- a/backend/src/server/routes/v1/project-env-router.ts +++ b/backend/src/server/routes/v1/project-env-router.ts @@ -1,3 +1,4 @@ +import slugify from "@sindresorhus/slugify"; import { z } from "zod"; import { ProjectEnvironmentsSchema } from "@app/db/schemas"; @@ -26,7 +27,13 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { }), body: z.object({ name: z.string().trim().describe(ENVIRONMENTS.CREATE.name), - slug: z.string().trim().describe(ENVIRONMENTS.CREATE.slug) + slug: z + .string() + .trim() + .refine((v) => slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(ENVIRONMENTS.CREATE.slug) }), response: { 200: z.object({ @@ -84,7 +91,14 @@ export const registerProjectEnvRouter = async (server: FastifyZodProvider) => { id: z.string().trim().describe(ENVIRONMENTS.UPDATE.id) }), body: z.object({ - slug: z.string().trim().optional().describe(ENVIRONMENTS.UPDATE.slug), + slug: z + .string() + .trim() + .optional() + .refine((v) => !v || slugify(v) === v, { + message: "Slug must be a valid slug" + }) + .describe(ENVIRONMENTS.UPDATE.slug), name: z.string().trim().optional().describe(ENVIRONMENTS.UPDATE.name), position: z.number().optional().describe(ENVIRONMENTS.UPDATE.position) }), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index fbebc08848..e7c587f139 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,6 +38,7 @@ "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "@reduxjs/toolkit": "^1.8.3", + "@sindresorhus/slugify": "^2.2.1", "@stripe/react-stripe-js": "^1.16.3", "@stripe/stripe-js": "^1.46.0", "@tanstack/react-query": "^4.23.0", @@ -5776,6 +5777,57 @@ "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", "dev": true }, + "node_modules/@sindresorhus/slugify": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-2.2.1.tgz", + "integrity": "sha512-MkngSCRZ8JdSOCHRaYd+D01XhvU3Hjy6MGl06zhOk614hp9EOAp5gIkBeQg7wtmxpitU6eAL4kdiRMcJa2dlrw==", + "dependencies": { + "@sindresorhus/transliterate": "^1.0.0", + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/slugify/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-1.6.0.tgz", + "integrity": "sha512-doH1gimEu3A46VX6aVxpHTeHrytJAG6HgdxntYnCFiIFHEM/ZGpG8KiZGBChchjQmG0XFIBL552kBTjVcMZXwQ==", + "dependencies": { + "escape-string-regexp": "^5.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@sindresorhus/transliterate/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@storybook/addon-actions": { "version": "7.6.8", "resolved": "https://registry.npmjs.org/@storybook/addon-actions/-/addon-actions-7.6.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 0dacef6b30..e01ef945e6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -46,6 +46,7 @@ "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "@reduxjs/toolkit": "^1.8.3", + "@sindresorhus/slugify": "^2.2.1", "@stripe/react-stripe-js": "^1.16.3", "@stripe/stripe-js": "^1.46.0", "@tanstack/react-query": "^4.23.0", diff --git a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx new file mode 100644 index 0000000000..2added8783 --- /dev/null +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -0,0 +1,375 @@ +import { TextareaHTMLAttributes, useEffect, useRef, useState } from "react"; +import { faCircle, faFolder, faKey } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import * as Popover from "@radix-ui/react-popover"; +import { twMerge } from "tailwind-merge"; + +import { useWorkspace } from "@app/context"; +import { useDebounce } from "@app/hooks"; +import { useGetFoldersByEnv, useGetProjectSecrets, useGetUserWsKey } from "@app/hooks/api"; + +import { SecretInput } from "../SecretInput"; + +const REGEX_UNCLOSED_SECRET_REFERENCE = /\${(?![^{}]*\})/g; +const REGEX_OPEN_SECRET_REFERENCE = /\${/g; + +export enum ReferenceType { + ENVIRONMENT = "environment", + FOLDER = "folder", + SECRET = "secret" +} + +type Props = TextareaHTMLAttributes & { + value?: string | null; + isImport?: boolean; + isVisible?: boolean; + isReadOnly?: boolean; + isDisabled?: boolean; + secretPath?: string; + environment?: string; + containerClassName?: string; +}; + +type ReferenceItem = { + name: string; + type: ReferenceType; + slug?: string; +}; + +export const InfisicalSecretInput = ({ + value: propValue, + isVisible, + containerClassName, + onBlur, + isDisabled, + isImport, + isReadOnly, + secretPath: propSecretPath, + environment: propEnvironment, + onChange, + ...props +}: Props) => { + const [inputValue, setInputValue] = useState(propValue ?? ""); + const [isSuggestionsOpen, setIsSuggestionsOpen] = useState(false); + const [currentCursorPosition, setCurrentCursorPosition] = useState(0); + const [currentReference, setCurrentReference] = useState(""); + const [secretPath, setSecretPath] = useState(propSecretPath || "/"); + const [environment, setEnvironment] = useState(propEnvironment); + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const { data: decryptFileKey } = useGetUserWsKey(workspaceId); + const { data: secrets } = useGetProjectSecrets({ + decryptFileKey: decryptFileKey!, + environment: environment || currentWorkspace?.environments?.[0].slug!, + secretPath, + workspaceId + }); + const { folderNames: folders } = useGetFoldersByEnv({ + path: secretPath, + environments: [environment || currentWorkspace?.environments?.[0].slug!], + projectId: workspaceId + }); + + const debouncedCurrentReference = useDebounce(currentReference, 100); + + const [listReference, setListReference] = useState([]); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const inputRef = useRef(null); + + useEffect(() => { + setInputValue(propValue ?? ""); + }, [propValue]); + + useEffect(() => { + let currentEnvironment = propEnvironment; + let currentSecretPath = propSecretPath || "/"; + + if (!currentReference) { + setSecretPath(currentSecretPath); + setEnvironment(currentEnvironment); + return; + } + + const isNested = currentReference.includes("."); + + if (isNested) { + const [envSlug, ...folderPaths] = currentReference.split("."); + const isValidEnvSlug = currentWorkspace?.environments.find((e) => e.slug === envSlug); + currentEnvironment = isValidEnvSlug ? envSlug : undefined; + + // should be based on the last valid section (with .) + folderPaths.pop(); + currentSecretPath = `/${folderPaths?.join("/")}`; + } + + setSecretPath(currentSecretPath); + setEnvironment(currentEnvironment); + }, [debouncedCurrentReference]); + + useEffect(() => { + const currentListReference: ReferenceItem[] = []; + const isNested = currentReference?.includes("."); + + if (!currentReference) { + setListReference(currentListReference); + return; + } + + if (!environment) { + currentWorkspace?.environments.forEach((env) => { + currentListReference.unshift({ + name: env.slug, + type: ReferenceType.ENVIRONMENT + }); + }); + } else if (isNested) { + folders?.forEach((folder) => { + currentListReference.unshift({ name: folder, type: ReferenceType.FOLDER }); + }); + } else if (environment) { + currentWorkspace?.environments.forEach((env) => { + currentListReference.unshift({ + name: env.slug, + type: ReferenceType.ENVIRONMENT + }); + }); + } + + secrets?.forEach((secret) => { + currentListReference.unshift({ name: secret.key, type: ReferenceType.SECRET }); + }); + + // Get fragment inside currentReference + const searchFragment = isNested ? currentReference.split(".").pop() || "" : currentReference; + const filteredListRef = currentListReference + .filter((suggestionEntry) => + suggestionEntry.name.toUpperCase().startsWith(searchFragment.toUpperCase()) + ) + .sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + + setListReference(filteredListRef); + }, [secrets, environment, debouncedCurrentReference]); + + const getIndexOfUnclosedRefToTheLeft = (pos: number) => { + // take substring up to pos in order to consider edits for closed references + const unclosedReferenceIndexMatches = [ + ...inputValue.substring(0, pos).matchAll(REGEX_UNCLOSED_SECRET_REFERENCE) + ].map((match) => match.index); + + // find unclosed reference index less than the current cursor position + let indexIter = -1; + unclosedReferenceIndexMatches.forEach((index) => { + if (index !== undefined && index > indexIter && index < pos) { + indexIter = index; + } + }); + + return indexIter; + }; + + const getIndexOfUnclosedRefToTheRight = (pos: number) => { + const unclosedReferenceIndexMatches = [...inputValue.matchAll(REGEX_OPEN_SECRET_REFERENCE)].map( + (match) => match.index + ); + + // find the next unclosed reference index to the right of the current cursor position + // this is so that we know the limitation for slicing references + let indexIter = Infinity; + unclosedReferenceIndexMatches.forEach((index) => { + if (index !== undefined && index > pos && index < indexIter) { + indexIter = index; + } + }); + + return indexIter; + }; + + const handleKeyUp = (e: React.KeyboardEvent) => { + // open suggestions if current position is to the right of an unclosed secret reference + const indexIter = getIndexOfUnclosedRefToTheLeft(currentCursorPosition); + if (indexIter === -1) { + return; + } + + setIsSuggestionsOpen(true); + + if (e.key !== "Enter") { + // current reference is then going to be based on the text from the closest ${ to the right + // until the current cursor position + const openReferenceValue = inputValue.slice(indexIter + 2, currentCursorPosition); + setCurrentReference(openReferenceValue); + } + }; + + const handleSuggestionSelect = (selectedIndex?: number) => { + const selectedSuggestion = listReference[selectedIndex ?? highlightedIndex]; + + if (!selectedSuggestion) { + return; + } + + const leftIndexIter = getIndexOfUnclosedRefToTheLeft(currentCursorPosition); + const rightIndexLimit = getIndexOfUnclosedRefToTheRight(currentCursorPosition); + + if (leftIndexIter === -1) { + return; + } + + let newValue = ""; + const currentOpenRef = inputValue.slice(leftIndexIter + 2, currentCursorPosition); + if (currentOpenRef.includes(".")) { + // append suggestion after last DOT (.) + const lastDotIndex = currentReference.lastIndexOf("."); + const existingPath = currentReference.slice(0, lastDotIndex); + const refEndAfterAppending = Math.min( + leftIndexIter + + 3 + + existingPath.length + + selectedSuggestion.name.length + + Number(selectedSuggestion.type !== ReferenceType.SECRET), + rightIndexLimit - 1 + ); + + newValue = `${inputValue.slice(0, leftIndexIter + 2)}${existingPath}.${ + selectedSuggestion.name + }${selectedSuggestion.type !== ReferenceType.SECRET ? "." : "}"}${inputValue.slice( + refEndAfterAppending + )}`; + const openReferenceValue = newValue.slice(leftIndexIter + 2, refEndAfterAppending); + setCurrentReference(openReferenceValue); + + // add 1 in order to prevent referenceOpen from being triggered by handleKeyUp + setCurrentCursorPosition(refEndAfterAppending + 1); + } else { + // append selectedSuggestion at position after unclosed ${ + const refEndAfterAppending = Math.min( + selectedSuggestion.name.length + + leftIndexIter + + 2 + + Number(selectedSuggestion.type !== ReferenceType.SECRET), + rightIndexLimit - 1 + ); + + newValue = `${inputValue.slice(0, leftIndexIter + 2)}${selectedSuggestion.name}${ + selectedSuggestion.type !== ReferenceType.SECRET ? "." : "}" + }${inputValue.slice(refEndAfterAppending)}`; + + const openReferenceValue = newValue.slice(leftIndexIter + 2, refEndAfterAppending); + setCurrentReference(openReferenceValue); + setCurrentCursorPosition(refEndAfterAppending); + } + + onChange?.({ target: { value: newValue } } as any); + setInputValue(newValue); + setHighlightedIndex(-1); + setIsSuggestionsOpen(false); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const mod = (n: number, m: number) => ((n % m) + m) % m; + if (e.key === "ArrowDown") { + setHighlightedIndex((prevIndex) => mod(prevIndex + 1, listReference.length)); + } else if (e.key === "ArrowUp") { + setHighlightedIndex((prevIndex) => mod(prevIndex - 1, listReference.length)); + } else if (e.key === "Enter" && highlightedIndex >= 0) { + handleSuggestionSelect(); + } + if (["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) { + e.preventDefault(); + } + }; + + const setIsOpen = (isOpen: boolean) => { + setHighlightedIndex(-1); + + if (isSuggestionsOpen) { + setIsSuggestionsOpen(isOpen); + } + }; + + const handleSecretChange = (e: any) => { + // propagate event to react-hook-form onChange + if (onChange) { + onChange(e); + } + + setCurrentCursorPosition(inputRef.current?.selectionStart || 0); + setInputValue(e.target.value); + }; + + return ( + 0 && currentReference.length > 0} + onOpenChange={setIsOpen} + > + + + + e.preventDefault()} + className={twMerge( + "relative top-2 z-[100] overflow-hidden rounded-md border border-mineshaft-600 bg-mineshaft-900 font-inter text-bunker-100 shadow-md" + )} + style={{ + width: "var(--radix-popover-trigger-width)", + maxHeight: "var(--radix-select-content-available-height)" + }} + > +
+ {listReference.map((item, i) => { + let entryIcon; + if (item.type === ReferenceType.SECRET) { + entryIcon = faKey; + } else if (item.type === ReferenceType.ENVIRONMENT) { + entryIcon = faCircle; + } else { + entryIcon = faFolder; + } + + return ( +
{ + e.preventDefault(); + setHighlightedIndex(i); + handleSuggestionSelect(i); + }} + style={{ pointerEvents: "auto" }} + className="flex items-center justify-between border-mineshaft-600 text-left" + key={`secret-reference-secret-${i + 1}`} + > +
+
+
+ +
+
{item.name}
+
+
+
+ ); + })} +
+
+
+ ); +}; + +InfisicalSecretInput.displayName = "InfisicalSecretInput"; diff --git a/frontend/src/components/v2/InfisicalSecretInput/index.tsx b/frontend/src/components/v2/InfisicalSecretInput/index.tsx new file mode 100644 index 0000000000..bf5be94524 --- /dev/null +++ b/frontend/src/components/v2/InfisicalSecretInput/index.tsx @@ -0,0 +1 @@ +export { InfisicalSecretInput } from "./InfisicalSecretInput"; diff --git a/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx index 23f8089d1d..4efd6bdc30 100644 --- a/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/views/SecretMainPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -3,7 +3,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { Button, FormControl, Input, Modal, ModalContent, SecretInput } from "@app/components/v2"; +import { Button, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { useCreateSecretV3 } from "@app/hooks/api"; import { UserWsKeyPair } from "@app/hooks/api/types"; @@ -44,8 +45,6 @@ export const CreateSecretForm = ({ const { isOpen } = usePopUpState(PopUpNames.CreateSecretForm); const { closePopUp, togglePopUp } = usePopUpAction(); - - const { mutateAsync: createSecretV3 } = useCreateSecretV3(); const handleFormSubmit = async ({ key, value }: TFormSchema) => { @@ -103,8 +102,10 @@ export const CreateSecretForm = ({ isError={Boolean(errors?.value)} errorText={errors?.value?.message} > - diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx index 8f23a6ae75..28e2fed156 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretDetaiSidebar.tsx @@ -27,12 +27,12 @@ import { FormControl, IconButton, Input, - SecretInput, Switch, Tag, TextArea, Tooltip } from "@app/components/v2"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context"; import { useToggle } from "@app/hooks"; import { useGetSecretVersion } from "@app/hooks/api"; @@ -71,7 +71,6 @@ export const SecretDetailSidebar = ({ environment, secretPath }: Props) => { - const { register, control, @@ -204,8 +203,10 @@ export const SecretDetailSidebar = ({ control={control} render={({ field }) => ( - ( - diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx index 7996d932cd..455b7bbe6e 100644 --- a/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretListView/SecretItem.tsx @@ -14,7 +14,6 @@ import { Popover, PopoverContent, PopoverTrigger, - SecretInput, Spinner, TextArea, Tooltip @@ -49,6 +48,7 @@ import { memo, useEffect } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; import { twMerge } from "tailwind-merge"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { CreateReminderForm } from "./CreateReminderForm"; import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils"; @@ -263,10 +263,12 @@ export const SecretItem = memo( key="value-overriden" control={control} render={({ field }) => ( - @@ -278,10 +280,12 @@ export const SecretItem = memo( key="secret-value" control={control} render={({ field }) => ( - diff --git a/frontend/src/views/SecretMainPage/components/SnapshotView/SecretItem.tsx b/frontend/src/views/SecretMainPage/components/SnapshotView/SecretItem.tsx index 2497276555..a571ca5d33 100644 --- a/frontend/src/views/SecretMainPage/components/SnapshotView/SecretItem.tsx +++ b/frontend/src/views/SecretMainPage/components/SnapshotView/SecretItem.tsx @@ -120,7 +120,9 @@ export const SecretItem = ({ mode, preSecret, postSecret }: Props) => { Value {isModified && ( - + )} diff --git a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx index f7605aa93b..85d3125d5c 100644 --- a/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx +++ b/frontend/src/views/SecretOverviewPage/components/CreateSecretForm/CreateSecretForm.tsx @@ -13,9 +13,9 @@ import { Input, Modal, ModalContent, - SecretInput, Tooltip } from "@app/components/v2"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { useWorkspace } from "@app/context"; import { useCreateFolder, useCreateSecretV3, useUpdateSecretV3 } from "@app/hooks/api"; import { DecryptedSecret, UserWsKeyPair } from "@app/hooks/api/types"; @@ -64,8 +64,6 @@ export const CreateSecretForm = ({ const workspaceId = currentWorkspace?.id || ""; const environments = currentWorkspace?.environments || []; - - const { mutateAsync: createSecretV3 } = useCreateSecretV3(); const { mutateAsync: updateSecretV3 } = useUpdateSecretV3(); const { mutateAsync: createFolder } = useCreateFolder(); @@ -163,7 +161,7 @@ export const CreateSecretForm = ({ isError={Boolean(errors?.value)} errorText={errors?.value?.message} > - diff --git a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx index e06a6d5002..71529c95cf 100644 --- a/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx +++ b/frontend/src/views/SecretOverviewPage/components/SecretOverviewTableRow/SecretEditRow.tsx @@ -6,7 +6,8 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; -import { IconButton, SecretInput, Tooltip } from "@app/components/v2"; +import { IconButton, Tooltip } from "@app/components/v2"; +import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { useToggle } from "@app/hooks"; @@ -49,7 +50,6 @@ export const SecretEditRow = ({ } }); const [isDeleting, setIsDeleting] = useToggle(); - const handleFormReset = () => { reset(); @@ -97,10 +97,13 @@ export const SecretEditRow = ({ control={control} name="value" render={({ field }) => ( - )} diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx index 658faea0e3..68bbeb8406 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/AddEnvironmentModal.tsx @@ -1,5 +1,6 @@ import { Controller, useForm } from "react-hook-form"; import { yupResolver } from "@hookform/resolvers/yup"; +import slugify from "@sindresorhus/slugify"; import * as yup from "yup"; import { createNotification } from "@app/components/notifications"; @@ -16,7 +17,14 @@ type Props = { const schema = yup.object({ environmentName: yup.string().label("Environment Name").required(), - environmentSlug: yup.string().label("Environment Slug").required() + environmentSlug: yup + .string() + .label("Environment Slug") + .test({ + test: (slug) => slugify(slug as string) === slug, + message: "Slug must be a valid slug" + }) + .required() }); export type FormData = yup.InferType; diff --git a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx index 0acd9fbe12..c6b2152ccb 100644 --- a/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx +++ b/frontend/src/views/Settings/ProjectSettingsPage/components/EnvironmentSection/UpdateEnvironmentModal.tsx @@ -1,5 +1,6 @@ import { Controller, useForm } from "react-hook-form"; import { yupResolver } from "@hookform/resolvers/yup"; +import slugify from "@sindresorhus/slugify"; import * as yup from "yup"; import { createNotification } from "@app/components/notifications"; @@ -16,13 +17,19 @@ type Props = { const schema = yup.object({ name: yup.string().label("Environment Name").required(), - slug: yup.string().label("Environment Slug").required() + slug: yup + .string() + .label("Environment Slug") + .test({ + test: (slug) => slugify(slug as string) === slug, + message: "Slug must be a valid slug" + }) + .required() }); export type FormData = yup.InferType; export const UpdateEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpToggle }: Props) => { - const { currentWorkspace } = useWorkspace(); const { mutateAsync, isLoading } = useUpdateWsEnvironment(); const { control, handleSubmit, reset } = useForm({ @@ -108,7 +115,11 @@ export const UpdateEnvironmentModal = ({ popUp, handlePopUpClose, handlePopUpTog Update -