diff --git a/docs/documentation/platform/secret-reference.mdx b/docs/documentation/platform/secret-reference.mdx index 545ed6b3b..dfb3bf0ec 100644 --- a/docs/documentation/platform/secret-reference.mdx +++ b/docs/documentation/platform/secret-reference.mdx @@ -17,6 +17,10 @@ For example, if secret A references values from secrets B and C located in diffe This is an important security consideration when planning your secret access strategy, especially when working with cross-environment or cross-folder references. + + You can hold the `Cmd` (Mac) or `Ctrl` (Windows/Linux) key and click the secret reference to be redirected to it. + + ### Syntax When defining a secret reference, interpolation syntax is used to define references to secrets in other environments and [folders](./folder). diff --git a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx index 9c4dc0b83..a109004e1 100644 --- a/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx +++ b/frontend/src/components/v2/InfisicalSecretInput/InfisicalSecretInput.tsx @@ -2,10 +2,15 @@ import { forwardRef, TextareaHTMLAttributes, useCallback, useMemo, useRef, useSt import { faFolder, faKey, faLayerGroup, faSearch } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import * as Popover from "@radix-ui/react-popover"; +import { useNavigate } from "@tanstack/react-router"; -import { useProject } from "@app/context"; +import { createNotification } from "@app/components/notifications"; +import { ROUTE_PATHS } from "@app/const/routes"; +import { useProject, useProjectPermission } from "@app/context"; +import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { useDebounce, useToggle } from "@app/hooks"; import { useGetProjectFolders, useGetProjectSecrets } from "@app/hooks/api"; +import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { SecretInput } from "../SecretInput"; @@ -80,6 +85,8 @@ export const InfisicalSecretInput = forwardRef( ) => { const { currentProject } = useProject(); const projectId = currentProject?.id || ""; + const navigate = useNavigate({ from: ROUTE_PATHS.SecretManager.SecretDashboardPage.path }); + const { permission } = useProjectPermission(); const [debouncedValue] = useDebounce(value, 100); @@ -307,6 +314,120 @@ export const InfisicalSecretInput = forwardRef( } }, []); + const handleClickSegment = useCallback( + (segment: string, allSegments: string[]) => { + if (!projectId) { + createNotification({ + text: "Project ID is not set", + type: "error" + }); + return; + } + + if (allSegments.length === 0) { + createNotification({ + text: "Invalid secret reference", + type: "error" + }); + return; + } + + if (allSegments.length === 1) { + const canReadSecretValue = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: propEnvironment ?? "*", + secretPath: propSecretPath ?? "/", + secretName: segment, + secretTags: ["*"] + } + ); + + if (!canReadSecretValue) { + createNotification({ + text: "You do not have permission to access this secret", + type: "error" + }); + return; + } + + navigate({ + search: (prev) => ({ + ...prev, + search: segment, + filterBy: "secret", + tags: "" + }) + }); + return; + } + + const environmentSlug = allSegments[0]; + const secretName = allSegments[allSegments.length - 1]; + let folderPath = "/"; + + if (allSegments.length > 2) { + const pathSegments = allSegments.slice(1, -1); + for (let i = 0; i < pathSegments.length; i += 1) { + if (!pathSegments[i]) { + createNotification({ + text: "Invalid secret reference", + type: "error" + }); + return; + } + + const pathSegment = pathSegments[i]; + folderPath += `${pathSegment}`; + if (pathSegment === segment) { + folderPath += "/"; + break; + } + folderPath += "/"; + } + } + + // Only validate secret permission, users can always view environments and folders + if (segment === secretName) { + const canReadSecretValue = hasSecretReadValueOrDescribePermission( + permission, + ProjectPermissionSecretActions.ReadValue, + { + environment: environmentSlug, + secretPath: folderPath, + secretName, + secretTags: ["*"] + } + ); + + if (!canReadSecretValue) { + createNotification({ + text: "You do not have permission to access this secret", + type: "error" + }); + return; + } + } + + navigate({ + to: ROUTE_PATHS.SecretManager.SecretDashboardPage.path, + params: { + projectId, + envSlug: environmentSlug + }, + search: (prev) => ({ + ...prev, + secretPath: segment === environmentSlug ? "/" : folderPath, + search: segment === secretName ? secretName : prev.search, + filterBy: segment === secretName ? "secret" : prev.filterBy, + tags: "" + }) + }); + }, + [navigate, projectId, permission, propEnvironment, propSecretPath] + ); + return ( @@ -329,6 +450,7 @@ export const InfisicalSecretInput = forwardRef( }} onChange={(e) => onChange?.(e.target.value)} containerClassName={containerClassName} + onClickSegment={handleClickSegment} /> void, + hoveredPart?: string, + isCmdOrCtrlPressed?: boolean, + onClickSegment?: (segment: string, allSegments: string[]) => void, placeholder?: string ) => { if (isLoadingValue) return HIDDEN_SECRET_VALUE; @@ -29,10 +33,57 @@ const syntaxHighlight = ( const isInterpolationSyntax = el.startsWith("${") && el.endsWith("}"); if (isInterpolationSyntax) { skipNext = true; + const part = el; + const innerContent = el.slice(2, -1); // Remove ${ and } + const parts = innerContent.split("."); + return ( - + ${ - {el.slice(2, -1)} + {parts.map((segment, segmentIndex) => { + const segmentKey = `${part}-segment-${segmentIndex}`; + const isHovered = hoveredPart === segmentKey; + const shouldShowHoverStyle = isHovered && isCmdOrCtrlPressed; + + return ( + + onHoverPart?.(segmentKey)} + onMouseLeave={() => onHoverPart?.("")} + onMouseDown={(e) => { + if (isCmdOrCtrlPressed) { + e.preventDefault(); + e.stopPropagation(); + } + }} + onClick={(e) => { + e.stopPropagation(); + if (isCmdOrCtrlPressed) { + e.preventDefault(); + onClickSegment?.(segment, parts); + } + }} + onKeyDown={(e) => { + if (isCmdOrCtrlPressed && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + e.stopPropagation(); + onClickSegment?.(segment, parts); + } + }} + > + {segment} + + {segmentIndex < parts.length - 1 && ( + . + )} + + ); + })} } ); @@ -62,6 +113,7 @@ type Props = TextareaHTMLAttributes & { canEditButNotView?: boolean; isLoadingValue?: boolean; isErrorLoadingValue?: boolean; + onClickSegment?: (segment: string, allSegments: string[]) => void; }; const commonClassName = "font-mono text-sm caret-white border-none outline-hidden w-full break-all"; @@ -81,12 +133,43 @@ export const SecretInput = forwardRef( canEditButNotView, isLoadingValue, isErrorLoadingValue, + onClickSegment, placeholder, ...props }, ref ) => { const [isSecretFocused, setIsSecretFocused] = useToggle(); + const [hoveredPart, setHoveredPart] = useState(); + const [isCmdOrCtrlPressed, setIsCmdOrCtrlPressed] = useState(false); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey) { + setIsCmdOrCtrlPressed(true); + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (!e.metaKey && !e.ctrlKey) { + setIsCmdOrCtrlPressed(false); + } + }; + + const handleBlur = () => { + setIsCmdOrCtrlPressed(false); + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); + }; + }, []); return (
( style={{ maxHeight: `${21 * 7}px` }} >
-
+          
             
               (
                   isImport,
                   isLoadingValue,
                   isErrorLoadingValue,
+                  (part) => {
+                    setHoveredPart(part);
+                  },
+                  hoveredPart,
+                  isCmdOrCtrlPressed,
+                  onClickSegment,
                   placeholder
                 )}
               
@@ -138,6 +227,9 @@ export const SecretInput = forwardRef(
               onBlur?.(evt);
               setIsSecretFocused.off();
             }}
+            onMouseLeave={() => {
+              setHoveredPart(undefined);
+            }}
             value={value || ""}
             {...props}
             readOnly={isReadOnly || isLoadingValue || isErrorLoadingValue}
diff --git a/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx b/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx
index 977943330..a821a029d 100644
--- a/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx
+++ b/frontend/src/pages/secret-manager/OverviewPage/components/CreateSecretForm/CreateSecretForm.tsx
@@ -249,6 +249,21 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
         name="value"
         render={({ field }) => (
           
+                You can add references to other secrets using the format{" "}
+                
+                  ${"{"}secret_name{"}"}
+                
+                
+
+ You can go to the referenced secret by holding the{" "} + Cmd (Mac) or{" "} + Ctrl{" "} + (Windows/Linux) key and clicking on the secret name. +
+ } + tooltipClassName="max-w-md" label="Value" isError={Boolean(errors?.value)} errorText={errors?.value?.message} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index a2ee814f8..203a3f6eb 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -219,22 +219,43 @@ const Page = () => { ProjectPermissionSub.Commits ); - const defaultFilterState = { - tags: {}, - searchFilter: (routerQueryParams.search as string) || "", - // these should always be on by default for the UI, they will be disabled for the query below based off permissions - include: { - [RowType.Folder]: false, - [RowType.Import]: false, - [RowType.DynamicSecret]: false, - [RowType.Secret]: false, - [RowType.SecretRotation]: false - } - }; + const getFilterStateFromQueryParams = useCallback(() => { + const filterByArray = routerQueryParams.filterBy + ? (routerQueryParams.filterBy as string).split(",").filter(Boolean) + : []; + + const includeFilters = { + [RowType.Folder]: filterByArray.includes("folder") || false, + [RowType.Import]: filterByArray.includes("import") || false, + [RowType.DynamicSecret]: filterByArray.includes("dynamic") || false, + [RowType.Secret]: filterByArray.includes("secret") || false, + [RowType.SecretRotation]: filterByArray.includes("rotation") || false + }; + + const tags = routerQueryParams.tags + ? routerQueryParams.tags.split(",").reduce( + (acc, tag) => { + const trimmedTag = tag.trim(); + if (trimmedTag) { + acc[trimmedTag] = true; + } + return acc; + }, + {} as Record + ) + : {}; + + return { + tags, + searchFilter: (routerQueryParams.search as string) || "", + include: includeFilters + }; + }, [routerQueryParams.search, routerQueryParams.tags, routerQueryParams.filterBy]); + + const defaultFilterState = getFilterStateFromQueryParams(); const [filter, setFilter] = useState(defaultFilterState); const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(filter.searchFilter); - const [filterHistory, setFilterHistory] = useState>(new Map()); const createSecretPopUp = usePopUpState(PopUpNames.CreateSecretForm); const { togglePopUp } = usePopUpAction(); @@ -475,34 +496,96 @@ const Page = () => { ); const handleTagToggle = useCallback( - (tagSlug: string) => + (tagSlug: string) => { setFilter((state) => { const isTagPresent = Boolean(state.tags?.[tagSlug]); const newTagFilter = { ...state.tags }; if (isTagPresent) delete newTagFilter[tagSlug]; else newTagFilter[tagSlug] = true; + + // Update URL to match filter state + const tagsList = Object.keys(newTagFilter).filter((tag) => newTagFilter[tag]); + navigate({ + search: (prev) => ({ + ...prev, + tags: tagsList.length > 0 ? tagsList.join(",") : "" + }) + }); + return { ...state, tags: newTagFilter }; - }), - [] + }); + }, + [navigate] ); const handleToggleRowType = useCallback( - (rowType: RowType) => + (rowType: RowType) => { setFilter((state) => { + const newInclude = { + ...state.include, + [rowType]: !state.include[rowType] + }; + + // Update URL to match filter state + const filterByList: string[] = []; + if (newInclude[RowType.Folder]) filterByList.push("folder"); + if (newInclude[RowType.Import]) filterByList.push("import"); + if (newInclude[RowType.DynamicSecret]) filterByList.push("dynamic"); + if (newInclude[RowType.Secret]) filterByList.push("secret"); + if (newInclude[RowType.SecretRotation]) filterByList.push("rotation"); + + navigate({ + search: (prev) => ({ + ...prev, + filterBy: filterByList.length > 0 ? filterByList.join(",") : "" + }) + }); + return { ...state, - include: { - ...state.include, - [rowType]: !state.include[rowType] - } + include: newInclude }; - }), - [] + }); + }, + [navigate] ); + const handleClearFilters = useCallback(() => { + setFilter({ + searchFilter: "", + tags: {}, + include: { + [RowType.Folder]: false, + [RowType.Import]: false, + [RowType.DynamicSecret]: false, + [RowType.Secret]: false, + [RowType.SecretRotation]: false + } + }); + setDebouncedSearchFilter(""); + navigate({ + search: (prev) => ({ + ...prev, + search: "", + tags: "", + filterBy: "" + }) + }); + }, [navigate]); + const handleSearchChange = useCallback( - (searchFilter: string) => setFilter((state) => ({ ...state, searchFilter })), - [] + (searchFilter: string) => { + setFilter((state) => ({ ...state, searchFilter })); + + // Update URL to match filter state + navigate({ + search: (prev) => ({ + ...prev, + search: searchFilter || "" + }) + }); + }, + [navigate] ); const handleToggleVisibility = useCallback(() => setIsVisible((state) => !state), []); @@ -527,38 +610,10 @@ const Page = () => { }); useEffect(() => { - // restore filters for path if set - const restore = filterHistory.get(secretPath); - setFilter(restore ?? defaultFilterState); - setDebouncedSearchFilter(restore?.searchFilter ?? ""); - }, [secretPath]); - - useEffect(() => { - if (!routerQueryParams.search && !routerQueryParams.tags) return; - - const queryTags = routerQueryParams.tags - ? (routerQueryParams.tags as string).split(",").filter((tag) => Boolean(tag.trim())) - : []; - const updatedTags: Record = {}; - queryTags.forEach((tag) => { - updatedTags[tag] = true; - }); - - setFilter((prev) => ({ - ...prev, - ...defaultFilterState, - searchFilter: (routerQueryParams.search as string) ?? "", - tags: updatedTags - })); - setDebouncedSearchFilter(routerQueryParams.search as string); - // this is a temp workaround until we fully transition state to query params, - navigate({ - search: (state) => { - const { search, tags: qTags, ...query } = state; - return query; - } - }); - }, [routerQueryParams.search, routerQueryParams.tags]); + const filterState = getFilterStateFromQueryParams(); + setFilter(filterState); + setDebouncedSearchFilter(filterState.searchFilter); + }, [getFilterStateFromQueryParams]); const selectedSecrets = useSelectedSecrets(); const selectedSecretActions = useSelectedSecretActions(); @@ -597,13 +652,6 @@ const Page = () => { } const handleResetFilter = () => { - // store for breadcrumb nav to restore previously used filters - setFilterHistory((prev) => { - const curr = new Map(prev); - curr.set(secretPath, filter); - return curr; - }); - setFilter(defaultFilterState); setDebouncedSearchFilter(""); }; @@ -838,19 +886,7 @@ const Page = () => { isPITEnabled={isPITEnabled} hasPathPolicies={hasPathPolicies} onRequestAccess={(params) => handlePopUpOpen("requestAccess", params)} - onClearFilters={() => - setFilter((prev) => ({ - ...prev, - tags: {}, - include: { - secret: false, - import: false, - dynamic: false, - rotation: false, - folder: false - } - })) - } + onClearFilters={handleClearFilters} />
( + You can add references to other secrets using the format{" "} + + ${"{"}secret_name{"}"} + +
+
+ You can go to the referenced secret by holding the{" "} + Cmd (Mac) or{" "} + Ctrl{" "} + (Windows/Linux) key and clicking on the secret name. +
+ } + tooltipClassName="max-w-md" isError={Boolean(errors?.value)} errorText={errors?.value?.message} > diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/route.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/route.tsx index 0700b3322..da48a04f0 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/route.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/route.tsx @@ -11,6 +11,7 @@ const SecretDashboardPageQueryParamsSchema = z.object({ secretPath: z.string().catch("/"), search: z.string().catch(""), tags: z.string().catch(""), + filterBy: z.string().catch(""), connectionId: z.string().optional(), connectionName: z.string().optional() }); @@ -20,7 +21,7 @@ export const Route = createFileRoute( component: SecretDashboardPage, validateSearch: zodValidator(SecretDashboardPageQueryParamsSchema), search: { - middlewares: [stripSearchParams({ secretPath: "/", search: "", tags: "" })] + middlewares: [stripSearchParams({ secretPath: "/", search: "", tags: "", filterBy: "" })] }, beforeLoad: ({ context, params, search }) => { const secretPathSegments = search.secretPath.split("/").filter(Boolean);