mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-10 06:28:50 +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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import * as Popover from "@radix-ui/react-popover";
|
import * as Popover from "@radix-ui/react-popover";
|
||||||
|
|
||||||
|
import { ROUTE_PATHS } from "@app/const/routes";
|
||||||
import { useProject } from "@app/context";
|
import { useProject } from "@app/context";
|
||||||
import { useDebounce, useToggle } from "@app/hooks";
|
import { useDebounce, useToggle } from "@app/hooks";
|
||||||
import { useGetProjectFolders, useGetProjectSecrets } from "@app/hooks/api";
|
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 (
|
return (
|
||||||
<Popover.Root open={isPopupOpen} onOpenChange={handlePopUpOpen}>
|
<Popover.Root open={isPopupOpen} onOpenChange={handlePopUpOpen}>
|
||||||
<Popover.Trigger asChild>
|
<Popover.Trigger asChild>
|
||||||
@@ -329,6 +365,7 @@ export const InfisicalSecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
|||||||
}}
|
}}
|
||||||
onChange={(e) => onChange?.(e.target.value)}
|
onChange={(e) => onChange?.(e.target.value)}
|
||||||
containerClassName={containerClassName}
|
containerClassName={containerClassName}
|
||||||
|
onClickSegment={handleClickSegment}
|
||||||
/>
|
/>
|
||||||
</Popover.Trigger>
|
</Popover.Trigger>
|
||||||
<Popover.Content
|
<Popover.Content
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
/* eslint-disable react/no-danger */
|
/* eslint-disable react/no-danger */
|
||||||
import { forwardRef, TextareaHTMLAttributes } from "react";
|
import { forwardRef, TextareaHTMLAttributes, useEffect, useState } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { useToggle } from "@app/hooks";
|
import { useToggle } from "@app/hooks";
|
||||||
import { HIDDEN_SECRET_VALUE } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretItem";
|
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 = (
|
const syntaxHighlight = (
|
||||||
content?: string | null,
|
content?: string | null,
|
||||||
isVisible?: boolean,
|
isVisible?: boolean,
|
||||||
isImport?: boolean,
|
isImport?: boolean,
|
||||||
isLoadingValue?: 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 (isLoadingValue) return HIDDEN_SECRET_VALUE;
|
||||||
if (isErrorLoadingValue)
|
if (isErrorLoadingValue)
|
||||||
@@ -27,10 +31,51 @@ const syntaxHighlight = (
|
|||||||
const isInterpolationSyntax = el.startsWith("${") && el.endsWith("}");
|
const isInterpolationSyntax = el.startsWith("${") && el.endsWith("}");
|
||||||
if (isInterpolationSyntax) {
|
if (isInterpolationSyntax) {
|
||||||
skipNext = true;
|
skipNext = true;
|
||||||
|
const part = el;
|
||||||
|
const innerContent = el.slice(2, -1); // Remove ${ and }
|
||||||
|
const parts = innerContent.split(".");
|
||||||
|
|
||||||
return (
|
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>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -60,6 +105,7 @@ type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
|||||||
canEditButNotView?: boolean;
|
canEditButNotView?: boolean;
|
||||||
isLoadingValue?: boolean;
|
isLoadingValue?: boolean;
|
||||||
isErrorLoadingValue?: 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";
|
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,
|
canEditButNotView,
|
||||||
isLoadingValue,
|
isLoadingValue,
|
||||||
isErrorLoadingValue,
|
isErrorLoadingValue,
|
||||||
|
onClickSegment,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
const [isSecretFocused, setIsSecretFocused] = useToggle();
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -91,7 +162,7 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
|||||||
style={{ maxHeight: `${21 * 7}px` }}
|
style={{ maxHeight: `${21 * 7}px` }}
|
||||||
>
|
>
|
||||||
<div className="relative overflow-hidden">
|
<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}`}>
|
<code className={`inline-block w-full ${commonClassName}`}>
|
||||||
<span style={{ whiteSpace: "break-spaces" }}>
|
<span style={{ whiteSpace: "break-spaces" }}>
|
||||||
{syntaxHighlight(
|
{syntaxHighlight(
|
||||||
@@ -99,7 +170,13 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
|||||||
isVisible || (isSecretFocused && !valueAlwaysHidden),
|
isVisible || (isSecretFocused && !valueAlwaysHidden),
|
||||||
isImport,
|
isImport,
|
||||||
isLoadingValue,
|
isLoadingValue,
|
||||||
isErrorLoadingValue
|
isErrorLoadingValue,
|
||||||
|
(part) => {
|
||||||
|
setHoveredPart(part);
|
||||||
|
},
|
||||||
|
hoveredPart,
|
||||||
|
isCmdOrCtrlPressed,
|
||||||
|
onClickSegment
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</code>
|
</code>
|
||||||
@@ -128,6 +205,9 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
|||||||
onBlur?.(evt);
|
onBlur?.(evt);
|
||||||
setIsSecretFocused.off();
|
setIsSecretFocused.off();
|
||||||
}}
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
setHoveredPart(undefined);
|
||||||
|
}}
|
||||||
value={value || ""}
|
value={value || ""}
|
||||||
{...props}
|
{...props}
|
||||||
readOnly={isReadOnly || isLoadingValue || isErrorLoadingValue}
|
readOnly={isReadOnly || isLoadingValue || isErrorLoadingValue}
|
||||||
|
|||||||
@@ -534,7 +534,7 @@ const Page = () => {
|
|||||||
}, [secretPath]);
|
}, [secretPath]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!routerQueryParams.search && !routerQueryParams.tags) return;
|
if (!routerQueryParams.search && !routerQueryParams.tags && !routerQueryParams.filterBy) return;
|
||||||
|
|
||||||
const queryTags = routerQueryParams.tags
|
const queryTags = routerQueryParams.tags
|
||||||
? (routerQueryParams.tags as string).split(",").filter((tag) => Boolean(tag.trim()))
|
? (routerQueryParams.tags as string).split(",").filter((tag) => Boolean(tag.trim()))
|
||||||
@@ -544,21 +544,34 @@ const Page = () => {
|
|||||||
updatedTags[tag] = true;
|
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) => ({
|
setFilter((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
...defaultFilterState,
|
...defaultFilterState,
|
||||||
searchFilter: (routerQueryParams.search as string) ?? "",
|
searchFilter: (routerQueryParams.search as string) ?? "",
|
||||||
tags: updatedTags
|
tags: updatedTags,
|
||||||
|
include: includeFilter
|
||||||
}));
|
}));
|
||||||
setDebouncedSearchFilter(routerQueryParams.search as string);
|
setDebouncedSearchFilter(routerQueryParams.search as string);
|
||||||
// this is a temp workaround until we fully transition state to query params,
|
// this is a temp workaround until we fully transition state to query params,
|
||||||
navigate({
|
navigate({
|
||||||
search: (state) => {
|
search: (state) => {
|
||||||
const { search, tags: qTags, ...query } = state;
|
const { search, tags: qTags, filterBy: qFilterBy, ...query } = state;
|
||||||
return query;
|
return query;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [routerQueryParams.search, routerQueryParams.tags]);
|
}, [routerQueryParams.search, routerQueryParams.tags, routerQueryParams.filterBy]);
|
||||||
|
|
||||||
const selectedSecrets = useSelectedSecrets();
|
const selectedSecrets = useSelectedSecrets();
|
||||||
const selectedSecretActions = useSelectedSecretActions();
|
const selectedSecretActions = useSelectedSecretActions();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const SecretDashboardPageQueryParamsSchema = z.object({
|
|||||||
secretPath: z.string().catch("/"),
|
secretPath: z.string().catch("/"),
|
||||||
search: z.string().catch(""),
|
search: z.string().catch(""),
|
||||||
tags: z.string().catch(""),
|
tags: z.string().catch(""),
|
||||||
|
filterBy: z.string().catch(""),
|
||||||
connectionId: z.string().optional(),
|
connectionId: z.string().optional(),
|
||||||
connectionName: z.string().optional()
|
connectionName: z.string().optional()
|
||||||
});
|
});
|
||||||
@@ -20,7 +21,7 @@ export const Route = createFileRoute(
|
|||||||
component: SecretDashboardPage,
|
component: SecretDashboardPage,
|
||||||
validateSearch: zodValidator(SecretDashboardPageQueryParamsSchema),
|
validateSearch: zodValidator(SecretDashboardPageQueryParamsSchema),
|
||||||
search: {
|
search: {
|
||||||
middlewares: [stripSearchParams({ secretPath: "/", search: "", tags: "" })]
|
middlewares: [stripSearchParams({ secretPath: "/", search: "", tags: "", filterBy: "" })]
|
||||||
},
|
},
|
||||||
beforeLoad: ({ context, params, search }) => {
|
beforeLoad: ({ context, params, search }) => {
|
||||||
const secretPathSegments = search.secretPath.split("/").filter(Boolean);
|
const secretPathSegments = search.secretPath.split("/").filter(Boolean);
|
||||||
|
|||||||
Reference in New Issue
Block a user