Merge pull request #4804 from Infisical/feature/secret-reference-shortcut

feature: secret reference shortcut
This commit is contained in:
Victor Hugo dos Santos
2025-11-12 09:53:20 -03:00
committed by GitHub
7 changed files with 369 additions and 84 deletions

View File

@@ -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.
<Tip>
You can hold the `Cmd` (Mac) or `Ctrl` (Windows/Linux) key and click the secret reference to be redirected to it.
</Tip>
### Syntax
When defining a secret reference, interpolation syntax is used to define references to secrets in other environments and [folders](./folder).

View File

@@ -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<HTMLTextAreaElement, Props>(
) => {
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<HTMLTextAreaElement, Props>(
}
}, []);
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 (
<Popover.Root open={isPopupOpen} onOpenChange={handlePopUpOpen}>
<Popover.Trigger asChild>
@@ -329,6 +450,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
}}
onChange={(e) => onChange?.(e.target.value)}
containerClassName={containerClassName}
onClickSegment={handleClickSegment}
/>
</Popover.Trigger>
<Popover.Content

View File

@@ -1,11 +1,11 @@
/* eslint-disable react/no-danger */
import { forwardRef, TextareaHTMLAttributes } from "react";
import { forwardRef, TextareaHTMLAttributes, useEffect, useState } from "react";
import { twMerge } from "tailwind-merge";
import { useToggle } from "@app/hooks";
import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem";
const REGEX = /(\${([a-zA-Z0-9-_.]+)})/g;
const REGEX = /(\${([a-zA-Z0-9-_. ]+)})/g;
const syntaxHighlight = (
content?: string | null,
@@ -13,6 +13,10 @@ const syntaxHighlight = (
isImport?: boolean,
isLoadingValue?: boolean,
isErrorLoadingValue?: boolean,
onHoverPart?: (part: string) => 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 (
<span className="ph-no-capture text-yellow" key={`secret-value-${i + 1}`}>
<span className="ph-no-capture relative z-10 text-yellow" key={`secret-value-${i + 1}`}>
&#36;&#123;
<span className="ph-no-capture text-yellow-200/80">{el.slice(2, -1)}</span>
{parts.map((segment, segmentIndex) => {
const segmentKey = `${part}-segment-${segmentIndex}`;
const isHovered = hoveredPart === segmentKey;
const shouldShowHoverStyle = isHovered && isCmdOrCtrlPressed;
return (
<span key={segmentKey}>
<span
role="button"
tabIndex={isCmdOrCtrlPressed ? 0 : -1}
className={`ph-no-capture text-yellow-200/80 ${
isCmdOrCtrlPressed ? "pointer-events-auto" : "pointer-events-none"
} ${shouldShowHoverStyle ? "cursor-pointer underline decoration-yellow-400" : ""}`}
onMouseEnter={() => 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}
</span>
{segmentIndex < parts.length - 1 && (
<span className="ph-no-capture pointer-events-none text-yellow-200/80">.</span>
)}
</span>
);
})}
&#125;
</span>
);
@@ -62,6 +113,7 @@ type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & {
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<HTMLTextAreaElement, Props>(
canEditButNotView,
isLoadingValue,
isErrorLoadingValue,
onClickSegment,
placeholder,
...props
},
ref
) => {
const [isSecretFocused, setIsSecretFocused] = useToggle();
const [hoveredPart, setHoveredPart] = useState<string | undefined>();
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 (
<div
@@ -94,7 +177,7 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
style={{ maxHeight: `${21 * 7}px` }}
>
<div className="relative overflow-hidden">
<pre aria-hidden className="m-0">
<pre aria-hidden className="pointer-events-none relative z-10 m-0">
<code className={`inline-block w-full ${commonClassName}`}>
<span
className={twMerge(
@@ -108,6 +191,12 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
isImport,
isLoadingValue,
isErrorLoadingValue,
(part) => {
setHoveredPart(part);
},
hoveredPart,
isCmdOrCtrlPressed,
onClickSegment,
placeholder
)}
</span>
@@ -138,6 +227,9 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
onBlur?.(evt);
setIsSecretFocused.off();
}}
onMouseLeave={() => {
setHoveredPart(undefined);
}}
value={value || ""}
{...props}
readOnly={isReadOnly || isLoadingValue || isErrorLoadingValue}

View File

@@ -249,6 +249,21 @@ export const CreateSecretForm = ({ secretPath = "/", onClose }: Props) => {
name="value"
render={({ field }) => (
<FormControl
tooltipText={
<div>
You can add references to other secrets using the format{" "}
<code className="rounded-sm bg-mineshaft-600 px-1 py-0.5">
&#36;{"{"}secret_name{"}"}
</code>
<br />
<br />
You can go to the referenced secret by holding the{" "}
<code className="rounded-sm bg-mineshaft-600 px-1 py-0.5">Cmd</code> (Mac) or{" "}
<code className="rounded-sm bg-mineshaft-600 px-1 py-0.5">Ctrl</code>{" "}
(Windows/Linux) key and clicking on the secret name.
</div>
}
tooltipClassName="max-w-md"
label="Value"
isError={Boolean(errors?.value)}
errorText={errors?.value?.message}

View File

@@ -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<string, boolean>
)
: {};
return {
tags,
searchFilter: (routerQueryParams.search as string) || "",
include: includeFilters
};
}, [routerQueryParams.search, routerQueryParams.tags, routerQueryParams.filterBy]);
const defaultFilterState = getFilterStateFromQueryParams();
const [filter, setFilter] = useState<Filter>(defaultFilterState);
const [debouncedSearchFilter, setDebouncedSearchFilter] = useDebounce(filter.searchFilter);
const [filterHistory, setFilterHistory] = useState<Map<string, Filter>>(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<string, boolean> = {};
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}
/>
<div
ref={tableRef}

View File

@@ -197,6 +197,21 @@ export const CreateSecretForm = ({
render={({ field }) => (
<FormControl
label="Value"
tooltipText={
<div>
You can add references to other secrets using the format{" "}
<code className="rounded-sm bg-mineshaft-600 px-1 py-0.5">
&#36;{"{"}secret_name{"}"}
</code>
<br />
<br />
You can go to the referenced secret by holding the{" "}
<code className="rounded-sm bg-mineshaft-600 px-1 py-0.5">Cmd</code> (Mac) or{" "}
<code className="rounded-sm bg-mineshaft-600 px-1 py-0.5">Ctrl</code>{" "}
(Windows/Linux) key and clicking on the secret name.
</div>
}
tooltipClassName="max-w-md"
isError={Boolean(errors?.value)}
errorText={errors?.value?.message}
>

View File

@@ -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);