diff --git a/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx new file mode 100644 index 000000000..1487cb302 --- /dev/null +++ b/frontend/src/components/v2/SecretPathInput/SecretPathInput.tsx @@ -0,0 +1,178 @@ +import { InputHTMLAttributes, useEffect, useState } from "react"; +import { faFolder } 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 } from "@app/hooks/api"; + +import { Input } from "../Input"; + +type Props = Omit, "size" | "onChange"> & { + value?: string | null; + isImport?: boolean; + isVisible?: boolean; + isReadOnly?: boolean; + isDisabled?: boolean; + environment?: string; + containerClassName?: string; + onChange?: (arg: string) => void; +}; + +export const SecretPathInput = ({ + containerClassName, + onChange, + environment, + value: propValue, + ...props +}: Props) => { + const [inputValue, setInputValue] = useState(propValue ?? ""); + const [secretPath, setSecretPath] = useState("/"); + const [suggestions, setSuggestions] = useState([]); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const debouncedInputValue = useDebounce(inputValue, 200); + + const { currentWorkspace } = useWorkspace(); + const workspaceId = currentWorkspace?.id || ""; + const { folderNames: folders } = useGetFoldersByEnv({ + path: secretPath, + environments: [environment || currentWorkspace?.environments?.[0].slug!], + projectId: workspaceId + }); + + useEffect(() => { + setInputValue(propValue ?? "/"); + }, [propValue]); + + useEffect(() => { + if (environment) { + setInputValue("/"); + setSecretPath("/"); + onChange?.("/"); + } + }, [environment]); + + useEffect(() => { + // update secret path if input is valid + if ( + (debouncedInputValue.length > 0 && + debouncedInputValue[debouncedInputValue.length - 1] === "/") || + debouncedInputValue.length === 0 + ) { + setSecretPath(debouncedInputValue); + } + + // filter suggestions based on matching + const searchFragment = debouncedInputValue.split("/").pop() || ""; + const filteredSuggestions = folders + .filter((suggestionEntry) => + suggestionEntry.toUpperCase().startsWith(searchFragment.toUpperCase()) + ) + .sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); + + setSuggestions(filteredSuggestions); + }, [debouncedInputValue]); + + const handleSuggestionSelect = (selectedIndex: number) => { + if (!suggestions[selectedIndex]) { + return; + } + + const validPaths = inputValue.split("/"); + validPaths.pop(); + + const newValue = `${validPaths.join("/")}/${suggestions[selectedIndex]}`; + onChange?.(newValue); + setInputValue(newValue); + setSecretPath(newValue); + setHighlightedIndex(-1); + setSuggestions([]); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const mod = (n: number, m: number) => ((n % m) + m) % m; + if (e.key === "ArrowDown") { + setHighlightedIndex((prevIndex) => mod(prevIndex + 1, suggestions.length)); + } else if (e.key === "ArrowUp") { + setHighlightedIndex((prevIndex) => mod(prevIndex - 1, suggestions.length)); + } else if (e.key === "Enter" && highlightedIndex >= 0) { + handleSuggestionSelect(highlightedIndex); + } + if (["ArrowDown", "ArrowUp", "Enter"].includes(e.key)) { + e.preventDefault(); + } + }; + + const handleInputChange = (e: any) => { + // propagate event to react-hook-form onChange + if (onChange) { + onChange(e.target.value); + } + + setInputValue(e.target.value); + }; + + return ( + 0 && inputValue.length > 1} + onOpenChange={() => { + setHighlightedIndex(-1); + }} + > + + + + 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)" + }} + > +
+ {suggestions.map((suggestion, i) => ( +
{ + 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}`} + > +
+
+
+ +
+
{suggestion}
+
+
+
+ ))} +
+
+
+ ); +}; diff --git a/frontend/src/components/v2/SecretPathInput/index.tsx b/frontend/src/components/v2/SecretPathInput/index.tsx new file mode 100644 index 000000000..5d8595891 --- /dev/null +++ b/frontend/src/components/v2/SecretPathInput/index.tsx @@ -0,0 +1 @@ +export { SecretPathInput } from "./SecretPathInput"; diff --git a/frontend/src/pages/integrations/cloudflare-pages/create.tsx b/frontend/src/pages/integrations/cloudflare-pages/create.tsx index c2eb1c61a..570b2b83a 100644 --- a/frontend/src/pages/integrations/cloudflare-pages/create.tsx +++ b/frontend/src/pages/integrations/cloudflare-pages/create.tsx @@ -4,16 +4,10 @@ import axios from "axios"; import queryString from "query-string"; import { createNotification } from "@app/components/notifications"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { useCreateIntegration, useGetWorkspaceById } from "@app/hooks/api"; -import { - Button, - Card, - CardTitle, - FormControl, - Input, - Select, - SelectItem} from "../../../components/v2"; +import { Button, Card, CardTitle, FormControl, Select, SelectItem } from "../../../components/v2"; import { useGetIntegrationAuthApps, useGetIntegrationAuthById @@ -27,7 +21,6 @@ const cloudflareEnvironments = [ export default function CloudflarePagesIntegrationPage() { const router = useRouter(); const { mutateAsync } = useCreateIntegration(); - const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); const [secretPath, setSecretPath] = useState("/"); @@ -130,9 +123,10 @@ export default function CloudflarePagesIntegrationPage() { - setSecretPath(evt.target.value)} + onChange={(value) => setSecretPath(value)} + environment={selectedSourceEnvironment} placeholder="Provide a path, default is /" /> diff --git a/frontend/src/pages/integrations/cloudflare-workers/create.tsx b/frontend/src/pages/integrations/cloudflare-workers/create.tsx index 43808e98d..f4a35d47c 100644 --- a/frontend/src/pages/integrations/cloudflare-workers/create.tsx +++ b/frontend/src/pages/integrations/cloudflare-workers/create.tsx @@ -4,17 +4,10 @@ import axios from "axios"; import queryString from "query-string"; import { createNotification } from "@app/components/notifications"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { useCreateIntegration, useGetWorkspaceById } from "@app/hooks/api"; -import { - Button, - Card, - CardTitle, - FormControl, - Input, - Select, - SelectItem -} from "../../../components/v2"; +import { Button, Card, CardTitle, FormControl, Select, SelectItem } from "../../../components/v2"; import { useGetIntegrationAuthApps, useGetIntegrationAuthById @@ -23,7 +16,6 @@ import { export default function CloudflareWorkersIntegrationPage() { const router = useRouter(); const { mutateAsync } = useCreateIntegration(); - const { integrationAuthId } = queryString.parse(router.asPath.split("?")[1]); const { data: workspace } = useGetWorkspaceById(localStorage.getItem("projectData.id") ?? ""); @@ -122,9 +114,10 @@ export default function CloudflareWorkersIntegrationPage() { - setSecretPath(evt.target.value)} + onChange={(value) => setSecretPath(value)} + environment={selectedSourceEnvironment} placeholder="Provide a path, default is /" /> diff --git a/frontend/src/pages/integrations/gcp-secret-manager/create.tsx b/frontend/src/pages/integrations/gcp-secret-manager/create.tsx index 497f02370..12dcb6ca8 100644 --- a/frontend/src/pages/integrations/gcp-secret-manager/create.tsx +++ b/frontend/src/pages/integrations/gcp-secret-manager/create.tsx @@ -11,6 +11,7 @@ import { motion } from "framer-motion"; import queryString from "query-string"; import * as yup from "yup"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { usePopUp } from "@app/hooks"; import { useCreateIntegration } from "@app/hooks/api"; @@ -258,7 +259,11 @@ export default function GCPSecretManagerCreateIntegrationPage() { isError={Boolean(error)} errorText={error?.message} > - + )} /> diff --git a/frontend/src/pages/integrations/gitlab/create.tsx b/frontend/src/pages/integrations/gitlab/create.tsx index 183040341..15daad270 100644 --- a/frontend/src/pages/integrations/gitlab/create.tsx +++ b/frontend/src/pages/integrations/gitlab/create.tsx @@ -11,6 +11,7 @@ import { motion } from "framer-motion"; import queryString from "query-string"; import * as yup from "yup"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { usePopUp } from "@app/hooks"; import { useCreateIntegration } from "@app/hooks/api"; @@ -268,7 +269,11 @@ export default function GitLabCreateIntegrationPage() { isError={Boolean(error)} errorText={error?.message} > - + )} /> diff --git a/frontend/src/pages/integrations/hasura-cloud/create.tsx b/frontend/src/pages/integrations/hasura-cloud/create.tsx index 6f8eac255..476a6a8f5 100644 --- a/frontend/src/pages/integrations/hasura-cloud/create.tsx +++ b/frontend/src/pages/integrations/hasura-cloud/create.tsx @@ -9,15 +9,8 @@ import { yupResolver } from "@hookform/resolvers/yup"; import queryString from "query-string"; import * as yup from "yup"; -import { - Button, - Card, - CardTitle, - FormControl, - Input, - Select, - SelectItem -} from "@app/components/v2"; +import { Button, Card, CardTitle, FormControl, Select, SelectItem } from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { useCreateIntegration } from "@app/hooks/api"; import { useGetIntegrationAuthApps, @@ -38,6 +31,7 @@ export default function HasuraCloudCreateIntegrationPage() { const { control, handleSubmit, + watch, formState: { isSubmitting } } = useForm({ resolver: yupResolver(schema) @@ -51,6 +45,8 @@ export default function HasuraCloudCreateIntegrationPage() { (integrationAuthId as string) ?? "" ); + const selectedSourceEnvironment = watch("sourceEnvironment"); + const { data: integrationAuthApps, isLoading: isIntegrationAuthAppsLoading } = useGetIntegrationAuthApps({ integrationAuthId: (integrationAuthId as string) ?? "" @@ -147,7 +143,7 @@ export default function HasuraCloudCreateIntegrationPage() { name="secretPath" render={({ field, fieldState: { error } }) => ( - + )} /> diff --git a/frontend/src/pages/integrations/heroku/create.tsx b/frontend/src/pages/integrations/heroku/create.tsx index db2237253..4d25b7260 100644 --- a/frontend/src/pages/integrations/heroku/create.tsx +++ b/frontend/src/pages/integrations/heroku/create.tsx @@ -17,19 +17,12 @@ import queryString from "query-string"; // import { App, Pipeline } from "@app/hooks/api/integrationAuth/types"; import * as yup from "yup"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; // import { RadioGroup } from "@app/components/v2/RadioGroup"; import { useCreateIntegration } from "@app/hooks/api"; import { IntegrationSyncBehavior } from "@app/hooks/api/integrations/types"; -import { - Button, - Card, - CardTitle, - FormControl, - Input, - Select, - SelectItem -} from "../../../components/v2"; +import { Button, Card, CardTitle, FormControl, Select, SelectItem } from "../../../components/v2"; import { useGetIntegrationAuthApps, useGetIntegrationAuthById @@ -280,7 +273,11 @@ export default function HerokuCreateIntegrationPage() { name="secretPath" render={({ field, fieldState: { error } }) => ( - + )} /> diff --git a/frontend/src/pages/integrations/render/create.tsx b/frontend/src/pages/integrations/render/create.tsx index d911940ac..e52f3668e 100644 --- a/frontend/src/pages/integrations/render/create.tsx +++ b/frontend/src/pages/integrations/render/create.tsx @@ -15,6 +15,7 @@ import { yupResolver } from "@hookform/resolvers/yup"; import queryString from "query-string"; import * as yup from "yup"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { useCreateIntegration } from "@app/hooks/api"; import { @@ -22,7 +23,6 @@ import { Card, CardTitle, FormControl, - Input, Select, SelectItem, Switch @@ -185,7 +185,11 @@ export default function RenderCreateIntegrationPage() { name="secretPath" render={({ field, fieldState: { error } }) => ( - + )} /> diff --git a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx index 83a6b6e45..56248145b 100644 --- a/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/IdentityTab/components/IdentityRoleForm/SpecificPrivilegeSection.tsx @@ -34,6 +34,7 @@ import { Tag, Tooltip } from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { ProjectPermissionActions, ProjectPermissionSub, @@ -115,6 +116,7 @@ const SpecificPrivilegeSecretForm = ({ }); const temporaryAccessField = privilegeForm.watch("temporaryAccess"); + const selectedEnvironmentSlug = privilegeForm.watch("environmentSlug"); const isTemporary = temporaryAccessField?.isTemporary; const isExpired = temporaryAccessField.isTemporary && @@ -220,7 +222,12 @@ const SpecificPrivilegeSecretForm = ({ name="secretPath" render={({ field }) => ( - + )} /> diff --git a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx index 2df959a94..97321de7c 100644 --- a/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/views/Project/MembersPage/components/MemberListTab/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -34,6 +34,7 @@ import { Tag, Tooltip } from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { ProjectPermissionActions, ProjectPermissionSub, @@ -107,6 +108,7 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri }); const temporaryAccessField = privilegeForm.watch("temporaryAccess"); + const selectedEnvironmentSlug = privilegeForm.watch("environmentSlug"); const isTemporary = temporaryAccessField?.isTemporary; const isExpired = temporaryAccessField.isTemporary && @@ -208,7 +210,12 @@ const SpecificPrivilegeSecretForm = ({ privilege }: { privilege: TProjectUserPri name="secretPath" render={({ field }) => ( - + )} /> diff --git a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx index eb9d5303a..185c596c2 100644 --- a/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx +++ b/frontend/src/views/SecretApprovalPage/components/SecretApprovalPolicyList/components/SecretPolicyForm.tsx @@ -20,6 +20,7 @@ import { Select, SelectItem } from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { useWorkspace } from "@app/context"; import { useCreateSecretApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; import { TSecretApprovalPolicy } from "@app/hooks/api/types"; @@ -59,13 +60,14 @@ export const SecretPolicyForm = ({ control, handleSubmit, reset, + watch, formState: { isSubmitting } } = useForm({ resolver: zodResolver(formSchema), values: editValues ? { ...editValues, environment: editValues.environment.slug } : undefined }); const { currentWorkspace } = useWorkspace(); - + const selectedEnvironment = watch("environment"); const environments = currentWorkspace?.environments || []; useEffect(() => { @@ -174,7 +176,11 @@ export const SecretPolicyForm = ({ name="secretPath" render={({ field, fieldState: { error } }) => ( - + )} /> diff --git a/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx b/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx index fa8340465..a6dfe7c0e 100644 --- a/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx +++ b/frontend/src/views/SecretMainPage/components/ActionBar/CreateSecretImportForm.tsx @@ -4,15 +4,8 @@ import { AxiosError } from "axios"; import { z } from "zod"; import { createNotification } from "@app/components/notifications"; -import { - Button, - FormControl, - Input, - Modal, - ModalContent, - Select, - SelectItem -} from "@app/components/v2"; +import { Button, FormControl, Modal, ModalContent, Select, SelectItem } from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { useWorkspace } from "@app/context"; import { useCreateSecretImport } from "@app/hooks/api"; @@ -50,12 +43,12 @@ export const CreateSecretImportForm = ({ handleSubmit, control, reset, + watch, formState: { isSubmitting } } = useForm({ resolver: zodResolver(typeSchema) }); const { currentWorkspace } = useWorkspace(); const environments = currentWorkspace?.environments || []; - - + const selectedEnvironment = watch("environment"); const { mutateAsync: createSecretImport } = useCreateSecretImport(); @@ -130,7 +123,7 @@ export const CreateSecretImportForm = ({ defaultValue="/" render={({ field, fieldState: { error } }) => ( - + )} /> diff --git a/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx b/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx index 574b8da94..1d83f591b 100644 --- a/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx +++ b/frontend/src/views/SecretMainPage/components/SecretDropzone/CopySecretsFromBoard.tsx @@ -28,6 +28,7 @@ import { Skeleton, Tooltip } from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context"; import { useDebounce } from "@app/hooks"; import { useGetProjectSecrets } from "@app/hooks/api"; @@ -76,7 +77,6 @@ export const CopySecretsFromBoard = ({ handleSubmit, control, watch, - register, reset, setValue, formState: { isDirty } @@ -192,9 +192,19 @@ export const CopySecretsFromBoard = ({ )} /> - - - + ( + + + + )} + />
diff --git a/frontend/src/views/SecretRotationPage/components/CreateRotationForm/steps/RotationOutputForm.tsx b/frontend/src/views/SecretRotationPage/components/CreateRotationForm/steps/RotationOutputForm.tsx index 4b6449a33..3ddacf5b4 100644 --- a/frontend/src/views/SecretRotationPage/components/CreateRotationForm/steps/RotationOutputForm.tsx +++ b/frontend/src/views/SecretRotationPage/components/CreateRotationForm/steps/RotationOutputForm.tsx @@ -3,6 +3,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Button, FormControl, Input, Select, SelectItem, Spinner } from "@app/components/v2"; +import { SecretPathInput } from "@app/components/v2/SecretPathInput"; import { useWorkspace } from "@app/context"; import { useGetProjectSecrets, useGetUserWsKey } from "@app/hooks/api"; @@ -78,7 +79,7 @@ export const RotationOutputForm = ({ onSubmit, onCancel, outputSchema = {} }: Pr defaultValue="/" render={({ field }) => ( - + )} />