mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-15 02:51:51 +00:00
feature: deep search for secrets dashboard
This commit is contained in:
@@ -16,6 +16,7 @@ export type CheckboxProps = Omit<
|
||||
checkIndicatorBg?: string | undefined;
|
||||
isError?: boolean;
|
||||
isIndeterminate?: boolean;
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
export const Checkbox = ({
|
||||
@@ -28,10 +29,11 @@ export const Checkbox = ({
|
||||
checkIndicatorBg,
|
||||
isError,
|
||||
isIndeterminate,
|
||||
containerClassName,
|
||||
...props
|
||||
}: CheckboxProps): JSX.Element => {
|
||||
return (
|
||||
<div className="flex items-center font-inter text-bunker-300">
|
||||
<div className={twMerge("flex items-center font-inter text-bunker-300", containerClassName)}>
|
||||
<CheckboxPrimitive.Root
|
||||
className={twMerge(
|
||||
"flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border border-mineshaft-400 bg-mineshaft-600 shadow transition-all hover:bg-mineshaft-500",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export const reverseTruncate = (text: string, maxLength = 42) => {
|
||||
if (text.length < maxLength) return text;
|
||||
|
||||
return `...${text.substring(text.length - maxLength + 3)}`;
|
||||
};
|
||||
@@ -1 +1,5 @@
|
||||
export { useGetProjectSecretsDetails } from "./queries";
|
||||
export {
|
||||
useGetProjectSecretsDetails,
|
||||
useGetProjectSecretsOverview,
|
||||
useGetProjectSecretsQuickSearch
|
||||
} from "./queries";
|
||||
|
||||
@@ -10,12 +10,15 @@ import {
|
||||
DashboardProjectSecretsOverview,
|
||||
DashboardProjectSecretsOverviewResponse,
|
||||
DashboardSecretsOrderBy,
|
||||
TDashboardProjectSecretsQuickSearch,
|
||||
TDashboardProjectSecretsQuickSearchResponse,
|
||||
TGetDashboardProjectSecretsDetailsDTO,
|
||||
TGetDashboardProjectSecretsOverviewDTO
|
||||
TGetDashboardProjectSecretsOverviewDTO,
|
||||
TGetDashboardProjectSecretsQuickSearchDTO
|
||||
} from "@app/hooks/api/dashboard/types";
|
||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||
import { mergePersonalSecrets } from "@app/hooks/api/secrets/queries";
|
||||
import { unique } from "@app/lib/fn/array";
|
||||
import { groupBy, unique } from "@app/lib/fn/array";
|
||||
|
||||
export const dashboardKeys = {
|
||||
all: () => ["dashboard"] as const,
|
||||
@@ -42,8 +45,18 @@ export const dashboardKeys = {
|
||||
}: TGetDashboardProjectSecretsDetailsDTO) =>
|
||||
[
|
||||
...dashboardKeys.getDashboardSecrets({ projectId, secretPath }),
|
||||
environment,
|
||||
"secrets-details",
|
||||
environment,
|
||||
params
|
||||
] as const,
|
||||
getProjectSecretsQuickSearch: ({
|
||||
projectId,
|
||||
secretPath,
|
||||
...params
|
||||
}: TGetDashboardProjectSecretsQuickSearchDTO) =>
|
||||
[
|
||||
...dashboardKeys.getDashboardSecrets({ projectId, secretPath }),
|
||||
"quick-search",
|
||||
params
|
||||
] as const
|
||||
};
|
||||
@@ -256,3 +269,101 @@ export const useGetProjectSecretsDetails = (
|
||||
keepPreviousData: true
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchProjectSecretsQuickSearch = async ({
|
||||
environments,
|
||||
tags,
|
||||
...params
|
||||
}: TGetDashboardProjectSecretsQuickSearchDTO) => {
|
||||
const { data } = await apiRequest.get<TDashboardProjectSecretsQuickSearchResponse>(
|
||||
"/api/v1/dashboard/secrets-deep-search",
|
||||
{
|
||||
params: {
|
||||
...params,
|
||||
environments: encodeURIComponent(environments.join(",")),
|
||||
tags: encodeURIComponent(
|
||||
Object.entries(tags)
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
.filter(([_, enabled]) => enabled)
|
||||
.map(([tag]) => tag)
|
||||
.join(",")
|
||||
)
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
export const useGetProjectSecretsQuickSearch = (
|
||||
{
|
||||
projectId,
|
||||
secretPath,
|
||||
search = "",
|
||||
environments,
|
||||
tags
|
||||
}: TGetDashboardProjectSecretsQuickSearchDTO,
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TDashboardProjectSecretsQuickSearchResponse,
|
||||
unknown,
|
||||
TDashboardProjectSecretsQuickSearch,
|
||||
ReturnType<typeof dashboardKeys.getProjectSecretsQuickSearch>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>
|
||||
) => {
|
||||
return useQuery({
|
||||
...options,
|
||||
enabled:
|
||||
Boolean(search?.trim() || Object.values(tags).length) &&
|
||||
(options?.enabled ?? true) &&
|
||||
Boolean(environments.length),
|
||||
queryKey: dashboardKeys.getProjectSecretsQuickSearch({
|
||||
secretPath,
|
||||
search,
|
||||
projectId,
|
||||
environments,
|
||||
tags
|
||||
}),
|
||||
queryFn: () =>
|
||||
fetchProjectSecretsQuickSearch({
|
||||
secretPath,
|
||||
search,
|
||||
projectId,
|
||||
environments,
|
||||
tags
|
||||
}),
|
||||
onError: (error) => {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const serverResponse = error.response?.data as { message: string };
|
||||
createNotification({
|
||||
title: "Error fetching secrets deep search",
|
||||
type: "error",
|
||||
text: serverResponse.message
|
||||
});
|
||||
}
|
||||
},
|
||||
select: useCallback((data: Awaited<ReturnType<typeof fetchProjectSecretsQuickSearch>>) => {
|
||||
const { secrets, folders, dynamicSecrets } = data;
|
||||
|
||||
const groupedFolders = groupBy(folders, (folder) => folder.path);
|
||||
const groupedSecrets = groupBy(
|
||||
mergePersonalSecrets(secrets),
|
||||
(secret) => `${secret.path === "/" ? "" : secret.path}/${secret.key}`
|
||||
);
|
||||
const groupedDynamicSecrets = groupBy(
|
||||
dynamicSecrets,
|
||||
(dynamicSecret) =>
|
||||
`${dynamicSecret.path === "/" ? "" : dynamicSecret.path}/${dynamicSecret.name}`
|
||||
);
|
||||
|
||||
return {
|
||||
folders: groupedFolders,
|
||||
secrets: groupedSecrets,
|
||||
dynamicSecrets: groupedDynamicSecrets
|
||||
};
|
||||
}, []),
|
||||
keepPreviousData: true
|
||||
});
|
||||
};
|
||||
|
||||
@@ -69,3 +69,23 @@ export type TGetDashboardProjectSecretsDetailsDTO = Omit<
|
||||
includeImports?: boolean;
|
||||
tags: Record<string, boolean>;
|
||||
};
|
||||
|
||||
export type TDashboardProjectSecretsQuickSearchResponse = {
|
||||
folders: (TSecretFolder & { environment: string; path: string })[];
|
||||
dynamicSecrets: (TDynamicSecret & { environment: string; path: string })[];
|
||||
secrets: SecretV3Raw[];
|
||||
};
|
||||
|
||||
export type TDashboardProjectSecretsQuickSearch = {
|
||||
folders: Record<string, TDashboardProjectSecretsQuickSearchResponse["folders"]>;
|
||||
secrets: Record<string, SecretV3RawSanitized[]>;
|
||||
dynamicSecrets: Record<string, TDashboardProjectSecretsQuickSearchResponse["folders"]>;
|
||||
};
|
||||
|
||||
export type TGetDashboardProjectSecretsQuickSearchDTO = {
|
||||
projectId: string;
|
||||
secretPath: string;
|
||||
tags: Record<string, boolean>;
|
||||
search: string;
|
||||
environments: string[];
|
||||
};
|
||||
|
||||
@@ -66,7 +66,8 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => {
|
||||
createdAt: el.createdAt,
|
||||
updatedAt: el.updatedAt,
|
||||
version: el.version,
|
||||
skipMultilineEncoding: el.skipMultilineEncoding
|
||||
skipMultilineEncoding: el.skipMultilineEncoding,
|
||||
path: el.secretPath
|
||||
};
|
||||
|
||||
if (el.type === SecretType.Personal) {
|
||||
|
||||
@@ -29,7 +29,7 @@ export type EncryptedSecret = {
|
||||
tags: WsTag[];
|
||||
};
|
||||
|
||||
// both personal and shared secret stitiched together for dashboard
|
||||
// both personal and shared secret stitched together for dashboard
|
||||
export type SecretV3RawSanitized = {
|
||||
id: string;
|
||||
version: number;
|
||||
@@ -42,6 +42,7 @@ export type SecretV3RawSanitized = {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
env: string;
|
||||
path?: string;
|
||||
valueOverride?: string;
|
||||
idOverride?: string;
|
||||
overrideAction?: string;
|
||||
@@ -57,6 +58,7 @@ export type SecretV3Raw = {
|
||||
version: number;
|
||||
type: string;
|
||||
secretKey: string;
|
||||
secretPath: string;
|
||||
secretValue?: string;
|
||||
secretComment?: string;
|
||||
secretReminderNote?: string;
|
||||
|
||||
@@ -186,7 +186,9 @@ const SecretMainPageContent = () => {
|
||||
});
|
||||
|
||||
// fetch tags
|
||||
const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : "");
|
||||
const { data: tags } = useGetWsTags(
|
||||
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : ""
|
||||
);
|
||||
|
||||
const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({
|
||||
workspaceId,
|
||||
@@ -305,6 +307,32 @@ const SecretMainPageContent = () => {
|
||||
}
|
||||
}, [secretPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.query.search && !router.query.tags) return;
|
||||
|
||||
const queryTags = router.query.tags
|
||||
? (router.query.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: (router.query.search as string) ?? "",
|
||||
tags: updatedTags
|
||||
}));
|
||||
setDebouncedSearchFilter(router.query.search as string);
|
||||
// this is a temp workaround until we fully transition state to query params,
|
||||
const { search, tags: qTags, ...query } = router.query;
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query
|
||||
});
|
||||
}, [router.query.search, router.query.tags]);
|
||||
|
||||
const selectedSecrets = useSelectedSecrets();
|
||||
const selectedSecretActions = useSelectedSecretActions();
|
||||
|
||||
@@ -389,8 +417,29 @@ const SecretMainPageContent = () => {
|
||||
"sticky top-0 flex border-b border-mineshaft-600 bg-mineshaft-800 font-medium"
|
||||
)}
|
||||
>
|
||||
<Tooltip
|
||||
className="max-w-[20rem] whitespace-nowrap"
|
||||
content={
|
||||
totalCount > 0
|
||||
? `${
|
||||
!allRowsSelectedOnPage.isChecked ? "Select" : "Unselect"
|
||||
} all secrets on page`
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div className="mr-[0.055rem] flex w-11 items-center justify-center pl-2.5">
|
||||
<Checkbox
|
||||
isDisabled={totalCount === 0}
|
||||
id="checkbox-select-all-rows"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
isChecked={allRowsSelectedOnPage.isChecked}
|
||||
isIndeterminate={allRowsSelectedOnPage.isIndeterminate}
|
||||
onCheckedChange={toggleSelectAllRows}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
className="flex w-80 flex-shrink-0 items-center border-r border-mineshaft-600 px-4 py-2"
|
||||
className="flex w-80 flex-shrink-0 items-center border-r border-mineshaft-600 py-2 pl-4"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleSortToggle}
|
||||
@@ -398,27 +447,6 @@ const SecretMainPageContent = () => {
|
||||
if (evt.key === "Enter") handleSortToggle();
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
className="max-w-[20rem] whitespace-nowrap"
|
||||
content={
|
||||
totalCount > 0
|
||||
? `${
|
||||
!allRowsSelectedOnPage.isChecked ? "Select" : "Unselect"
|
||||
} all secrets on page`
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<div className="mr-6 ml-1">
|
||||
<Checkbox
|
||||
isDisabled={totalCount === 0}
|
||||
id="checkbox-select-all-rows"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
isChecked={allRowsSelectedOnPage.isChecked}
|
||||
isIndeterminate={allRowsSelectedOnPage.isIndeterminate}
|
||||
onCheckedChange={toggleSelectAllRows}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
Key
|
||||
<FontAwesomeIcon
|
||||
icon={orderDirection === OrderByDirection.ASC ? faArrowDown : faArrowUp}
|
||||
@@ -427,53 +455,53 @@ const SecretMainPageContent = () => {
|
||||
</div>
|
||||
<div className="flex-grow px-4 py-2">Value</div>
|
||||
</div>
|
||||
)}
|
||||
{canReadSecretImports && Boolean(imports?.length) && (
|
||||
<SecretImportListView
|
||||
searchTerm={debouncedSearchFilter}
|
||||
secretImports={imports}
|
||||
isFetching={isDetailsFetching}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
importedSecrets={importedSecrets}
|
||||
/>
|
||||
)}
|
||||
{Boolean(folders?.length) && (
|
||||
<FolderListView
|
||||
folders={folders}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
onNavigateToFolder={handleResetFilter}
|
||||
/>
|
||||
)}
|
||||
{canReadDynamicSecret && Boolean(dynamicSecrets?.length) && (
|
||||
<DynamicSecretListView
|
||||
environment={environment}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
dynamicSecrets={dynamicSecrets}
|
||||
/>
|
||||
)}
|
||||
{canReadSecret && Boolean(secrets?.length) && (
|
||||
<SecretListView
|
||||
secrets={secrets}
|
||||
tags={tags}
|
||||
isVisible={isVisible}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isProtectedBranch={isProtectedBranch}
|
||||
/>
|
||||
)}
|
||||
{canReadSecret && <SecretNoAccessListView count={noAccessSecretCount} />}
|
||||
{!canReadSecret &&
|
||||
!canReadDynamicSecret &&
|
||||
!canReadSecretImports &&
|
||||
folders?.length === 0 && <PermissionDeniedBanner />}
|
||||
</div>
|
||||
)}
|
||||
{canReadSecretImports && Boolean(imports?.length) && (
|
||||
<SecretImportListView
|
||||
searchTerm={debouncedSearchFilter}
|
||||
secretImports={imports}
|
||||
isFetching={isDetailsFetching}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
importedSecrets={importedSecrets}
|
||||
/>
|
||||
)}
|
||||
{Boolean(folders?.length) && (
|
||||
<FolderListView
|
||||
folders={folders}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
onNavigateToFolder={handleResetFilter}
|
||||
/>
|
||||
)}
|
||||
{canReadDynamicSecret && Boolean(dynamicSecrets?.length) && (
|
||||
<DynamicSecretListView
|
||||
environment={environment}
|
||||
projectSlug={projectSlug}
|
||||
secretPath={secretPath}
|
||||
dynamicSecrets={dynamicSecrets}
|
||||
/>
|
||||
)}
|
||||
{canReadSecret && Boolean(secrets?.length) && (
|
||||
<SecretListView
|
||||
secrets={secrets}
|
||||
tags={tags}
|
||||
isVisible={isVisible}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isProtectedBranch={isProtectedBranch}
|
||||
/>
|
||||
)}
|
||||
{canReadSecret && <SecretNoAccessListView count={noAccessSecretCount} />}
|
||||
{!canReadSecret &&
|
||||
!canReadDynamicSecret &&
|
||||
!canReadSecretImports &&
|
||||
folders?.length === 0 && <PermissionDeniedBanner />}
|
||||
</div>
|
||||
</div>
|
||||
{!isDetailsLoading && totalCount > 0 && (
|
||||
<Pagination
|
||||
startAdornment={
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
faFolder,
|
||||
faFolderPlus,
|
||||
faKey,
|
||||
faMagnifyingGlass,
|
||||
faMinusSquare,
|
||||
faPlus,
|
||||
faTrash
|
||||
@@ -39,7 +38,6 @@ import {
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Tooltip,
|
||||
@@ -49,12 +47,14 @@ import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionDynamicSecretActions,
|
||||
ProjectPermissionSub,
|
||||
useSubscription
|
||||
useSubscription,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useCreateFolder, useDeleteSecretBatch, useMoveSecrets } from "@app/hooks/api";
|
||||
import { fetchProjectSecrets } from "@app/hooks/api/secrets/queries";
|
||||
import { SecretType, WsTag } from "@app/hooks/api/types";
|
||||
import { SecretSearchInput } from "@app/views/SecretOverviewPage/components/SecretSearchInput";
|
||||
|
||||
import {
|
||||
PopUpNames,
|
||||
@@ -123,6 +123,8 @@ export const ActionBar = ({
|
||||
const { reset: resetSelectedSecret } = useSelectedSecretActions();
|
||||
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
|
||||
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const handleFolderCreate = async (folderName: string) => {
|
||||
try {
|
||||
await createFolder({
|
||||
@@ -269,22 +271,15 @@ export const ActionBar = ({
|
||||
return (
|
||||
<>
|
||||
<div className="mt-4 flex items-center space-x-2">
|
||||
<div className="w-2/5">
|
||||
<Input
|
||||
className="bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||
placeholder="Search by folder name, key name, comment..."
|
||||
leftIcon={
|
||||
<FontAwesomeIcon
|
||||
className={filter.searchFilter ? "text-primary" : ""}
|
||||
icon={faMagnifyingGlass}
|
||||
/>
|
||||
}
|
||||
value={filter.searchFilter}
|
||||
onChange={(evt) => {
|
||||
onSearchChange(evt.target.value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<SecretSearchInput
|
||||
isSingleEnv
|
||||
className="w-2/5"
|
||||
value={filter.searchFilter}
|
||||
onChange={onSearchChange}
|
||||
environments={[currentWorkspace?.environments.find((env) => env.slug === environment)!]}
|
||||
projectId={workspaceId}
|
||||
tags={tags}
|
||||
/>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -364,8 +359,10 @@ export const ActionBar = ({
|
||||
>
|
||||
Tags
|
||||
</DropdownSubMenuTrigger>
|
||||
<DropdownSubMenuContent className="rounded-l-none">
|
||||
<DropdownMenuLabel>Apply tags to filter secrets</DropdownMenuLabel>
|
||||
<DropdownSubMenuContent className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-l-none">
|
||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||
Apply Tags to Filter Secrets
|
||||
</DropdownMenuLabel>
|
||||
{tags.map(({ id, slug, color }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
@@ -466,7 +463,10 @@ export const ActionBar = ({
|
||||
<div className="flex flex-col space-y-1 p-1.5">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })}
|
||||
a={subject(ProjectPermissionSub.SecretFolders, {
|
||||
environment,
|
||||
secretPath
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
faFolderPlus,
|
||||
faKey,
|
||||
faList,
|
||||
faMagnifyingGlass,
|
||||
faPlus
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
@@ -33,7 +32,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Pagination,
|
||||
@@ -61,6 +59,7 @@ import {
|
||||
useCreateSecretV3,
|
||||
useDeleteSecretV3,
|
||||
useGetImportedSecretsAllEnvs,
|
||||
useGetWsTags,
|
||||
useUpdateSecretV3
|
||||
} from "@app/hooks/api";
|
||||
import { useGetProjectSecretsOverview } from "@app/hooks/api/dashboard/queries";
|
||||
@@ -76,6 +75,7 @@ import {
|
||||
SecretNoAccessOverviewTableRow,
|
||||
SecretOverviewTableRow
|
||||
} from "@app/views/SecretOverviewPage/components/SecretOverviewTableRow";
|
||||
import { SecretSearchInput } from "@app/views/SecretOverviewPage/components/SecretSearchInput";
|
||||
import { SecretTableResourceCount } from "@app/views/SecretOverviewPage/components/SecretTableResourceCount";
|
||||
|
||||
import { FolderForm } from "../SecretMainPage/components/ActionBar/FolderForm";
|
||||
@@ -231,6 +231,9 @@ export const SecretOverviewPage = () => {
|
||||
useDynamicSecretOverview(dynamicSecrets);
|
||||
|
||||
const { secKeys, getSecretByKey, getEnvSecretKeyCount } = useSecretOverview(secrets);
|
||||
const { data: tags } = useGetWsTags(
|
||||
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : ""
|
||||
);
|
||||
|
||||
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
|
||||
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3();
|
||||
@@ -601,6 +604,20 @@ export const SecretOverviewPage = () => {
|
||||
setSelectedEntries(newChecks);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (router.query.search) {
|
||||
const { search, ...query } = router.query;
|
||||
// temp workaround until we transition state to query params
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query
|
||||
});
|
||||
setFilter(DEFAULT_FILTER_STATE);
|
||||
setSearchFilter(router.query.search as string);
|
||||
setDebouncedSearchFilter(router.query.search as string);
|
||||
}
|
||||
}, [router.query.search]);
|
||||
|
||||
if (isWorkspaceLoading || (isProjectV3 && isOverviewLoading)) {
|
||||
return (
|
||||
<div className="container mx-auto flex h-screen w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
@@ -640,47 +657,49 @@ export const SecretOverviewPage = () => {
|
||||
<NavHeader pageName={t("dashboard.title")} isProjectRelated />
|
||||
</div>
|
||||
<div className="space-y-8">
|
||||
<div className="mt-6">
|
||||
<p className="text-3xl font-semibold text-bunker-100">Secrets Overview</p>
|
||||
<p className="text-md text-bunker-300">
|
||||
Inject your secrets using
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/cli/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Infisical CLI
|
||||
</a>
|
||||
,
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/documentation/getting-started/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Infisical API
|
||||
</a>
|
||||
,
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/sdks/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Infisical SDKs
|
||||
</a>
|
||||
, and
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/documentation/getting-started/introduction"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
more
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<div className="flex w-full items-baseline justify-between">
|
||||
<div className="mt-6">
|
||||
<p className="text-3xl font-semibold text-bunker-100">Secrets Overview</p>
|
||||
<p className="text-md text-bunker-300">
|
||||
Inject your secrets using
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/cli/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Infisical CLI
|
||||
</a>
|
||||
,
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/documentation/getting-started/api"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Infisical API
|
||||
</a>
|
||||
,
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/sdks/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Infisical SDKs
|
||||
</a>
|
||||
, and
|
||||
<a
|
||||
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
|
||||
href="https://infisical.com/docs/documentation/getting-started/introduction"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
more
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<FolderBreadCrumbs secretPath={secretPath} onResetSearch={handleResetSearch} />
|
||||
@@ -780,20 +799,13 @@ export const SecretOverviewPage = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<div className="w-80">
|
||||
<Input
|
||||
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||
placeholder="Search by secret/folder name..."
|
||||
value={searchFilter}
|
||||
onChange={(e) => setSearchFilter(e.target.value)}
|
||||
leftIcon={
|
||||
<FontAwesomeIcon
|
||||
icon={faMagnifyingGlass}
|
||||
className={searchFilter ? "text-primary" : ""}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<SecretSearchInput
|
||||
value={searchFilter}
|
||||
tags={tags}
|
||||
onChange={setSearchFilter}
|
||||
environments={userAvailableEnvs}
|
||||
projectId={currentWorkspace?.id!}
|
||||
/>
|
||||
{userAvailableEnvs.length > 0 && (
|
||||
<div>
|
||||
<Button
|
||||
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { useState } from "react";
|
||||
import { faCircleXmark, faFolderTree, faSearch } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { Combobox, Transition } from "@headlessui/react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { IconButton, Tooltip } from "@app/components/v2";
|
||||
|
||||
import { QuickSearchModal, QuickSearchModalProps } from "./components";
|
||||
|
||||
type ModalProps = Omit<
|
||||
QuickSearchModalProps,
|
||||
"isOpen" | "onClose" | "onOpenChange" | "initialValue"
|
||||
> & {
|
||||
value: string;
|
||||
onChange: (search: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const SecretSearchInput = ({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
isSingleEnv,
|
||||
...props
|
||||
}: ModalProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const hasSearch = Boolean(value.trim());
|
||||
|
||||
return (
|
||||
<div className={twMerge("relative w-80", className)}>
|
||||
<Combobox
|
||||
// keeps combobox from internally controlling state, hacky use of combobox
|
||||
value={undefined}
|
||||
>
|
||||
{({ activeIndex }) => (
|
||||
<>
|
||||
<div className="flex w-full items-center whitespace-nowrap">
|
||||
<Tooltip content="Search Options">
|
||||
<Combobox.Button className="button user-select-none relative inline-flex h-[2.42rem] cursor-pointer items-center justify-center rounded-md rounded-r-none border border-mineshaft-600 bg-mineshaft-600 p-3 font-inter text-sm font-medium text-bunker-200 transition-all duration-100 hover:border-primary-400/50 hover:bg-primary/[0.1] hover:text-bunker-100">
|
||||
<FontAwesomeIcon
|
||||
icon={faSearch}
|
||||
size="sm"
|
||||
className={hasSearch ? "text-primary" : ""}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Combobox.Button>
|
||||
</Tooltip>
|
||||
<div className="relative inline-flex w-full items-center rounded-md rounded-l-none border border-mineshaft-500 bg-bunker-800 font-inter text-gray-400">
|
||||
<Combobox.Input
|
||||
onKeyDown={(e) => {
|
||||
if (activeIndex === 0 && e.key === "Enter") setIsOpen(true);
|
||||
}}
|
||||
autoComplete="off"
|
||||
className="input text-md h-[2.3rem] w-full rounded-md rounded-l-none bg-mineshaft-800 py-[0.375rem] pr-8 pl-2.5 text-gray-400 placeholder-mineshaft-50 placeholder-opacity-50 outline-none duration-200 placeholder:text-sm hover:ring-bunker-400/60 focus:bg-mineshaft-700/80 focus:ring-1 focus:ring-primary-400/50"
|
||||
placeholder="Search by secret/folder name..."
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
{hasSearch && (
|
||||
<IconButton
|
||||
isRounded
|
||||
variant="plain"
|
||||
onClick={() => onChange("")}
|
||||
className="absolute right-2 text-primary"
|
||||
ariaLabel="Clear search"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCircleXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Transition
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<Combobox.Options className="absolute z-30 mt-2 w-full min-w-[220px] overflow-y-auto rounded-md border border-mineshaft-600 bg-mineshaft-900 text-bunker-300 shadow focus:outline-none">
|
||||
<Combobox.Option
|
||||
onClick={() => setIsOpen(true)}
|
||||
value={value}
|
||||
className={({ active }) =>
|
||||
`flex w-full cursor-pointer items-start rounded-sm px-4 py-2 font-inter text-sm text-mineshaft-200 outline-none hover:bg-mineshaft-400 ${
|
||||
active ? "bg-mineshaft-500" : ""
|
||||
}`
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFolderTree} className="mr-2 mt-1 text-yellow-700" />
|
||||
{value.trim()
|
||||
? `Search for "${
|
||||
value.length > 10 ? `${value.substring(0, 10)}...` : value
|
||||
}" in all folders`
|
||||
: "Search in all folders"}
|
||||
</Combobox.Option>
|
||||
</Combobox.Options>
|
||||
</Transition>
|
||||
</>
|
||||
)}
|
||||
</Combobox>
|
||||
<QuickSearchModal
|
||||
isSingleEnv={isSingleEnv}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
initialValue={value}
|
||||
onClose={() => {
|
||||
setIsOpen(false);
|
||||
onChange("");
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { faChevronRight, faFingerprint, faFolder } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { reverseTruncate } from "@app/helpers/reverseTruncate";
|
||||
import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types";
|
||||
|
||||
type Props = {
|
||||
dynamicSecretGroup: TDashboardProjectSecretsQuickSearch["dynamicSecrets"][string];
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const QuickSearchDynamicSecretItem = ({
|
||||
dynamicSecretGroup,
|
||||
|
||||
onClose
|
||||
}: Props) => {
|
||||
const router = useRouter();
|
||||
|
||||
const [groupDynamicSecret] = dynamicSecretGroup;
|
||||
|
||||
const handleNavigate = () => {
|
||||
router.push({
|
||||
pathname: window.location.pathname,
|
||||
query: {
|
||||
secretPath: groupDynamicSecret.path,
|
||||
search: groupDynamicSecret.name
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr
|
||||
className="hover cursor-pointer bg-mineshaft-700 hover:bg-mineshaft-600"
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
<Td className="w-full">
|
||||
<div className="inline-flex max-w-[20rem] flex-col">
|
||||
<span className="truncate">
|
||||
<FontAwesomeIcon className="mr-2 self-center text-yellow-700" icon={faFingerprint} />
|
||||
{groupDynamicSecret.name}
|
||||
</span>
|
||||
<span className="text-xs text-mineshaft-400">
|
||||
<FontAwesomeIcon size="xs" className="mr-0.5 text-yellow-700" icon={faFolder} />{" "}
|
||||
<Tooltip className="max-w-7xl" content={groupDynamicSecret.path}>
|
||||
<span>{reverseTruncate(groupDynamicSecret.path)}</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td />
|
||||
<Td>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { faChevronRight, faFolder } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { Td, Tooltip, Tr } from "@app/components/v2";
|
||||
import { reverseTruncate } from "@app/helpers/reverseTruncate";
|
||||
import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types";
|
||||
|
||||
type Props = {
|
||||
folderGroup: TDashboardProjectSecretsQuickSearch["folders"][string];
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export const QuickSearchFolderItem = ({ folderGroup, onClose }: Props) => {
|
||||
const router = useRouter();
|
||||
|
||||
const [groupFolder] = folderGroup;
|
||||
|
||||
const handleNavigate = () => {
|
||||
router.push({
|
||||
pathname: window.location.pathname,
|
||||
query: {
|
||||
secretPath: groupFolder.path
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr
|
||||
className="hover cursor-pointer bg-mineshaft-700 hover:bg-mineshaft-600"
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
<Td className="w-full whitespace-nowrap">
|
||||
<FontAwesomeIcon className="text-yellow-700" icon={faFolder} />
|
||||
<Tooltip content={groupFolder.path} className="max-w-7xl">
|
||||
<div className="ml-2 inline-block">{reverseTruncate(groupFolder.path)}</div>
|
||||
</Tooltip>
|
||||
</Td>
|
||||
<Td />
|
||||
<Td>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
+285
@@ -0,0 +1,285 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
faCheckCircle,
|
||||
faChevronLeft,
|
||||
faFilter,
|
||||
faFingerprint,
|
||||
faFolder,
|
||||
faKey,
|
||||
faMagnifyingGlass
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Spinner,
|
||||
Table,
|
||||
TableContainer
|
||||
} from "@app/components/v2";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useGetProjectSecretsQuickSearch } from "@app/hooks/api/dashboard";
|
||||
import { WsTag } from "@app/hooks/api/tags/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
|
||||
import { RowType } from "@app/views/SecretMainPage/SecretMainPage.types";
|
||||
|
||||
import { QuickSearchDynamicSecretItem } from "./QuickSearchDynamicSecretItem";
|
||||
import { QuickSearchFolderItem } from "./QuickSearchFolderItem";
|
||||
import { QuickSearchSecretItem } from "./QuickSearchSecretItem";
|
||||
|
||||
export type QuickSearchModalProps = {
|
||||
environments: WorkspaceEnv[];
|
||||
projectId: string;
|
||||
tags?: WsTag[];
|
||||
isSingleEnv?: boolean;
|
||||
initialValue: string;
|
||||
onClose: () => void;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
type ResourceType = RowType.Secret | RowType.DynamicSecret | RowType.Folder;
|
||||
|
||||
const Content = ({
|
||||
environments,
|
||||
projectId,
|
||||
onClose,
|
||||
tags,
|
||||
initialValue = "",
|
||||
isSingleEnv
|
||||
}: Omit<QuickSearchModalProps, "isOpen" | "onOpenChange">) => {
|
||||
const [search, setSearch] = useState(initialValue);
|
||||
const [debouncedSearch] = useDebounce(search);
|
||||
const [filterTags, setFilterTags] = useState<Record<string, boolean>>({});
|
||||
const [showFilter, setShowFilter] = useState<Record<ResourceType, boolean>>({
|
||||
[RowType.Secret]: true,
|
||||
[RowType.Folder]: true,
|
||||
[RowType.DynamicSecret]: true
|
||||
});
|
||||
const isEnabled = Boolean(search.trim()) || Boolean(Object.values(filterTags).length);
|
||||
const { data, isLoading } = useGetProjectSecretsQuickSearch(
|
||||
{
|
||||
secretPath: "/",
|
||||
environments: environments.map((env) => env.slug),
|
||||
projectId,
|
||||
search: debouncedSearch,
|
||||
tags: filterTags
|
||||
},
|
||||
{ enabled: isEnabled }
|
||||
);
|
||||
|
||||
const { folders = {}, secrets = {}, dynamicSecrets = {} } = data ?? {};
|
||||
|
||||
const isEmpty =
|
||||
(!showFilter[RowType.Folder] || Object.values(folders).length === 0) &&
|
||||
(!showFilter[RowType.Secret] || Object.values(secrets).length === 0) &&
|
||||
(!showFilter[RowType.DynamicSecret] || Object.values(dynamicSecrets).length === 0);
|
||||
|
||||
const handleToggleTag = (tag: string) => {
|
||||
setFilterTags((prev) => {
|
||||
const updated = { ...prev };
|
||||
if (prev[tag]) delete updated[tag];
|
||||
else updated[tag] = true;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleToggleShowType = (type: ResourceType) => {
|
||||
setShowFilter((prev) => ({
|
||||
...prev,
|
||||
[type]: !prev[type]
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[14.6rem]">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
className="h-[2.3rem] bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||
placeholder="Search by secret, folder or tag name..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
leftIcon={
|
||||
<FontAwesomeIcon icon={faMagnifyingGlass} className={search ? "text-primary" : ""} />
|
||||
}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
ariaLabel="Filter secrets by tag(s)"
|
||||
className={twMerge(
|
||||
"transition-all",
|
||||
(Object.keys(filterTags).length ||
|
||||
Object.values(showFilter).some((show) => !show)) &&
|
||||
"border-primary/50 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-0">
|
||||
<DropdownMenuLabel>Filter By</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleShowType(RowType.Folder);
|
||||
}}
|
||||
icon={showFilter[RowType.Folder] && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
|
||||
<span>Folders</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleShowType(RowType.DynamicSecret);
|
||||
}}
|
||||
icon={showFilter[RowType.DynamicSecret] && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faFingerprint} className=" text-yellow-700" />
|
||||
<span>Dynamic Secrets</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleToggleShowType(RowType.Secret);
|
||||
}}
|
||||
icon={showFilter[RowType.Secret] && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FontAwesomeIcon icon={faKey} className=" text-bunker-300" />
|
||||
<span>Secrets</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
{tags && tags.length > 0 && (
|
||||
<DropdownSubMenu>
|
||||
<DropdownSubMenuTrigger
|
||||
iconPos="left"
|
||||
icon={<FontAwesomeIcon icon={faChevronLeft} size="sm" />}
|
||||
>
|
||||
Tags
|
||||
</DropdownSubMenuTrigger>
|
||||
<DropdownSubMenuContent
|
||||
collisionPadding={{ right: Infinity }} // forces dropdown to left
|
||||
className="thin-scrollbar max-h-[20rem] overflow-y-auto rounded-r-none"
|
||||
>
|
||||
<DropdownMenuLabel>Filter Secrets by Tag(s)</DropdownMenuLabel>
|
||||
{tags.map(({ id, slug, color }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault();
|
||||
handleToggleTag(slug);
|
||||
}}
|
||||
key={id}
|
||||
icon={filterTags[slug] && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="mr-2 h-2 w-2 rounded-full"
|
||||
style={{ background: color || "#bec2c8" }}
|
||||
/>
|
||||
{slug}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownSubMenuContent>
|
||||
</DropdownSubMenu>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<div className=" mt-4 max-h-[19rem] min-h-[19rem] overflow-auto">
|
||||
{/* eslint-disable-next-line no-nested-ternary */}
|
||||
{isEnabled ? (
|
||||
// eslint-disable-next-line no-nested-ternary
|
||||
isLoading ? (
|
||||
<Spinner size="lg" className="mx-auto mt-24 text-mineshaft-900" />
|
||||
) : isEmpty ? (
|
||||
<EmptyState
|
||||
className="mt-24"
|
||||
title="No results match search."
|
||||
icon={faMagnifyingGlass}
|
||||
/>
|
||||
) : (
|
||||
<TableContainer className="thin-scrollbar h-full overflow-y-auto">
|
||||
<Table>
|
||||
{showFilter[RowType.Folder] &&
|
||||
Object.entries(folders).map(([key, folderGroup]) => (
|
||||
<QuickSearchFolderItem onClose={onClose} folderGroup={folderGroup} key={key} />
|
||||
))}
|
||||
{showFilter[RowType.DynamicSecret] &&
|
||||
Object.entries(dynamicSecrets).map(([key, dynamicSecretGroup]) => (
|
||||
<QuickSearchDynamicSecretItem
|
||||
onClose={onClose}
|
||||
dynamicSecretGroup={dynamicSecretGroup}
|
||||
key={key}
|
||||
/>
|
||||
))}
|
||||
{showFilter[RowType.Secret] &&
|
||||
Object.entries(secrets).map(([key, secretGroup]) => (
|
||||
<QuickSearchSecretItem
|
||||
search={debouncedSearch}
|
||||
tags={Object.keys(filterTags)}
|
||||
isSingleEnv={isSingleEnv}
|
||||
environments={environments}
|
||||
onClose={onClose}
|
||||
secretGroup={secretGroup}
|
||||
key={key}
|
||||
/>
|
||||
))}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)
|
||||
) : (
|
||||
<EmptyState
|
||||
className="mt-24"
|
||||
title="Start typing to begin search..."
|
||||
icon={faMagnifyingGlass}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const QuickSearchModal = ({
|
||||
isOpen,
|
||||
isSingleEnv,
|
||||
onOpenChange,
|
||||
...props
|
||||
}: QuickSearchModalProps) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
title={`Search All Folders${isSingleEnv ? " In Environment" : ""}`}
|
||||
subTitle={`Search the ${
|
||||
isSingleEnv ? "current environment" : "entire project"
|
||||
} to quickly reference secrets and navigate deeply.`}
|
||||
>
|
||||
<Content isSingleEnv={isSingleEnv} {...props} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
faCheck,
|
||||
faChevronRight,
|
||||
faCopy,
|
||||
faEye,
|
||||
faFolder,
|
||||
faKey,
|
||||
faTags
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Badge,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Td,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { reverseTruncate } from "@app/helpers/reverseTruncate";
|
||||
import { useTimedReset } from "@app/hooks";
|
||||
import { TDashboardProjectSecretsQuickSearch } from "@app/hooks/api/dashboard/types";
|
||||
import { WorkspaceEnv } from "@app/hooks/api/workspace/types";
|
||||
|
||||
type Props = {
|
||||
environments: WorkspaceEnv[];
|
||||
secretGroup: TDashboardProjectSecretsQuickSearch["secrets"][string];
|
||||
onClose: () => void;
|
||||
isSingleEnv?: boolean;
|
||||
tags: string[];
|
||||
search: string;
|
||||
};
|
||||
|
||||
export const QuickSearchSecretItem = ({
|
||||
secretGroup,
|
||||
environments,
|
||||
onClose,
|
||||
tags,
|
||||
isSingleEnv,
|
||||
search
|
||||
}: Props) => {
|
||||
const router = useRouter();
|
||||
const envSlugMap = new Map(environments.map((env) => [env.slug, env]));
|
||||
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
|
||||
initialState: false
|
||||
});
|
||||
|
||||
const [groupSecret] = secretGroup;
|
||||
|
||||
const handleNavigate = () => {
|
||||
router.push({
|
||||
pathname: window.location.pathname,
|
||||
query: {
|
||||
secretPath: groupSecret.path,
|
||||
search: groupSecret.key,
|
||||
tags: tags.length ? tags.join(",") : undefined
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCopy = (value: string, env: string) => {
|
||||
navigator.clipboard.writeText(value);
|
||||
createNotification({
|
||||
type: "info",
|
||||
title: isSingleEnv ? "Secret value copied." : `Secret value copied from ${env}.`,
|
||||
text: ""
|
||||
});
|
||||
setIsUrlCopied(true);
|
||||
};
|
||||
|
||||
const secretGroupTags = secretGroup.flatMap((secret) => secret.tags);
|
||||
|
||||
const tagMatch =
|
||||
search.trim() &&
|
||||
secretGroupTags?.find((tag) => tag && tag.slug.toLowerCase().includes(search.toLowerCase()));
|
||||
|
||||
return (
|
||||
<Tr
|
||||
className="hover cursor-pointer bg-mineshaft-700 hover:bg-mineshaft-600"
|
||||
onClick={handleNavigate}
|
||||
>
|
||||
<Td className="w-full">
|
||||
<div className="inline-flex max-w-[20rem] flex-col">
|
||||
<span className="truncate">
|
||||
<FontAwesomeIcon className="mr-2 self-center text-bunker-300" icon={faKey} />
|
||||
{groupSecret.key}
|
||||
</span>
|
||||
<span className="text-xs text-mineshaft-400">
|
||||
<FontAwesomeIcon size="xs" className="mr-0.5 text-yellow-700" icon={faFolder} />{" "}
|
||||
<Tooltip className="max-w-7xl" content={groupSecret.path}>
|
||||
<span>{reverseTruncate(groupSecret.path ?? "")}</span>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex w-full items-center justify-end gap-4">
|
||||
{tagMatch && (
|
||||
<Badge variant="primary" className="flex items-center gap-1 whitespace-nowrap">
|
||||
<FontAwesomeIcon size="xs" icon={faTags} />
|
||||
{tagMatch.slug}
|
||||
</Badge>
|
||||
)}
|
||||
{isSingleEnv ? (
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
ariaLabel="Copy secret value"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopy(groupSecret.value!, envSlugMap.get(groupSecret.env)?.name!);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
ariaLabel="Copy secret value"
|
||||
>
|
||||
<FontAwesomeIcon icon={isUrlCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Copy Value From...</DropdownMenuLabel>
|
||||
{secretGroup.map((secret) => (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopy(secret.value!, envSlugMap.get(secret.env)?.name!);
|
||||
}}
|
||||
key={secret.id}
|
||||
>
|
||||
<p className="text-sm">{envSlugMap.get(secret.env)?.name}</p>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
ariaLabel="View secret value"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEye} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Hover to Reveal...</DropdownMenuLabel>
|
||||
{secretGroup.map((secret) => (
|
||||
<DropdownMenuItem
|
||||
className="group "
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCopy(secret.value!, envSlugMap.get(secret.env)?.name!);
|
||||
}}
|
||||
key={secret.id}
|
||||
>
|
||||
{!isSingleEnv && (
|
||||
<span className="text-xs text-mineshaft-400">
|
||||
{envSlugMap.get(secret.env)?.name}
|
||||
</span>
|
||||
)}
|
||||
<p
|
||||
className={twMerge(
|
||||
"hidden w-[12rem] max-w-[12rem] truncate text-sm group-hover:block",
|
||||
!secret.value && "text-mineshaft-400"
|
||||
)}
|
||||
>
|
||||
{secret.value || "EMPTY"}
|
||||
</p>
|
||||
<p className="w-[12rem] text-sm group-hover:hidden">
|
||||
***************************
|
||||
</p>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
};
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export * from "./QuickSearchDynamicSecretItem";
|
||||
export * from "./QuickSearchFolderItem";
|
||||
export * from "./QuickSearchModal";
|
||||
export * from "./QuickSearchSecretItem";
|
||||
@@ -0,0 +1 @@
|
||||
export { SecretSearchInput } from "./SecretSearchInput";
|
||||
Reference in New Issue
Block a user