mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat: add segment click handling in SecretInput and InfisicalSecretInput components
This commit is contained in:
@@ -3,6 +3,7 @@ import { faFolder, faKey, faLayerGroup, faSearch } from "@fortawesome/free-solid
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useProject } from "@app/context";
|
||||
import { useDebounce, useToggle } from "@app/hooks";
|
||||
import { useGetProjectFolders, useGetProjectSecrets } from "@app/hooks/api";
|
||||
@@ -307,6 +308,41 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClickSegment = useCallback(
|
||||
(segment: string, allSegments: string[]) => {
|
||||
// Single segment: search in current environment and path
|
||||
if (allSegments.length === 1) {
|
||||
const currentEnv = propEnvironment || "";
|
||||
const currentPath = propSecretPath || "/";
|
||||
const encodedPath = encodeURIComponent(currentPath);
|
||||
const url = ROUTE_PATHS.SecretManager.SecretDashboardPage.path
|
||||
.replace("$projectId", projectId)
|
||||
.replace("$envSlug", currentEnv);
|
||||
window.open(
|
||||
`${url}?secretPath=${encodedPath}&search=${encodeURIComponent(segment)}&filterBy=secret`,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multiple segments: first is env, last is secret, middle are folders
|
||||
const environmentSlug = allSegments[0];
|
||||
const secretName = allSegments[allSegments.length - 1];
|
||||
const folderPath = allSegments.length > 2 ? `/${allSegments.slice(1, -1).join("/")}` : "/";
|
||||
const encodedPath = encodeURIComponent(folderPath);
|
||||
const url = ROUTE_PATHS.SecretManager.SecretDashboardPage.path
|
||||
.replace("$projectId", projectId)
|
||||
.replace("$envSlug", environmentSlug);
|
||||
window.open(
|
||||
`${url}?secretPath=${encodedPath}&search=${encodeURIComponent(secretName)}&filterBy=secret`,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
);
|
||||
},
|
||||
[projectId, propEnvironment, propSecretPath]
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover.Root open={isPopupOpen} onOpenChange={handlePopUpOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
@@ -329,6 +365,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
}}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
containerClassName={containerClassName}
|
||||
onClickSegment={handleClickSegment}
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
/* 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,
|
||||
isVisible?: boolean,
|
||||
isImport?: boolean,
|
||||
isLoadingValue?: boolean,
|
||||
isErrorLoadingValue?: boolean
|
||||
isErrorLoadingValue?: boolean,
|
||||
onHoverPart?: (part: string) => void,
|
||||
hoveredPart?: string,
|
||||
isCmdOrCtrlPressed?: boolean,
|
||||
onClickSegment?: (segment: string, allSegments: string[]) => void
|
||||
) => {
|
||||
if (isLoadingValue) return HIDDEN_SECRET_VALUE;
|
||||
if (isErrorLoadingValue)
|
||||
@@ -27,10 +31,51 @@ 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}`}>
|
||||
${
|
||||
<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?.("")}
|
||||
onClick={(e) => {
|
||||
if (isCmdOrCtrlPressed) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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>
|
||||
);
|
||||
})}
|
||||
}
|
||||
</span>
|
||||
);
|
||||
@@ -60,6 +105,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";
|
||||
@@ -79,11 +125,36 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
canEditButNotView,
|
||||
isLoadingValue,
|
||||
isErrorLoadingValue,
|
||||
onClickSegment,
|
||||
...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);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("keyup", handleKeyUp);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("keyup", handleKeyUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -91,7 +162,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 style={{ whiteSpace: "break-spaces" }}>
|
||||
{syntaxHighlight(
|
||||
@@ -99,7 +170,13 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
isVisible || (isSecretFocused && !valueAlwaysHidden),
|
||||
isImport,
|
||||
isLoadingValue,
|
||||
isErrorLoadingValue
|
||||
isErrorLoadingValue,
|
||||
(part) => {
|
||||
setHoveredPart(part);
|
||||
},
|
||||
hoveredPart,
|
||||
isCmdOrCtrlPressed,
|
||||
onClickSegment
|
||||
)}
|
||||
</span>
|
||||
</code>
|
||||
@@ -128,6 +205,9 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
onBlur?.(evt);
|
||||
setIsSecretFocused.off();
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoveredPart(undefined);
|
||||
}}
|
||||
value={value || ""}
|
||||
{...props}
|
||||
readOnly={isReadOnly || isLoadingValue || isErrorLoadingValue}
|
||||
|
||||
@@ -534,7 +534,7 @@ const Page = () => {
|
||||
}, [secretPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routerQueryParams.search && !routerQueryParams.tags) return;
|
||||
if (!routerQueryParams.search && !routerQueryParams.tags && !routerQueryParams.filterBy) return;
|
||||
|
||||
const queryTags = routerQueryParams.tags
|
||||
? (routerQueryParams.tags as string).split(",").filter((tag) => Boolean(tag.trim()))
|
||||
@@ -544,21 +544,34 @@ const Page = () => {
|
||||
updatedTags[tag] = true;
|
||||
});
|
||||
|
||||
const filterBy = routerQueryParams.filterBy
|
||||
? (routerQueryParams.filterBy as string).split(",").filter(Boolean)
|
||||
: [];
|
||||
|
||||
const includeFilter: Record<RowType, boolean> = {
|
||||
[RowType.Folder]: filterBy.includes("folder"),
|
||||
[RowType.Import]: filterBy.includes("import"),
|
||||
[RowType.DynamicSecret]: filterBy.includes("dynamic"),
|
||||
[RowType.Secret]: filterBy.includes("secret"),
|
||||
[RowType.SecretRotation]: filterBy.includes("rotation")
|
||||
};
|
||||
|
||||
setFilter((prev) => ({
|
||||
...prev,
|
||||
...defaultFilterState,
|
||||
searchFilter: (routerQueryParams.search as string) ?? "",
|
||||
tags: updatedTags
|
||||
tags: updatedTags,
|
||||
include: includeFilter
|
||||
}));
|
||||
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;
|
||||
const { search, tags: qTags, filterBy: qFilterBy, ...query } = state;
|
||||
return query;
|
||||
}
|
||||
});
|
||||
}, [routerQueryParams.search, routerQueryParams.tags]);
|
||||
}, [routerQueryParams.search, routerQueryParams.tags, routerQueryParams.filterBy]);
|
||||
|
||||
const selectedSecrets = useSelectedSecrets();
|
||||
const selectedSecretActions = useSelectedSecretActions();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user