1
0
mirror of https://github.com/Infisical/infisical.git synced 2025-03-25 14:05:03 +00:00

Compare commits

..

16 Commits

Author SHA1 Message Date
80f7ff1ea8 Create 20240507162149_test.ts 2024-05-07 14:09:38 -04:00
c87620109b Rename 20240507162141_access to 20240507162141_access.ts 2024-05-07 13:58:10 -04:00
02c158b4ed Delete backend/src/db/migrations/20240507162180_test 2024-05-07 13:47:25 -04:00
ddfa64eb33 Merge pull request from Infisical/maidul98-patch-8
testing-ignore
2024-05-07 13:27:19 -04:00
7fdaa1543a Create 20240507162180_test 2024-05-07 13:26:52 -04:00
c8433f39ed Delete backend/src/db/migrations/20240507162180_test 2024-05-07 13:26:42 -04:00
ba238a8f3b get pr details by pr number 2024-05-07 13:25:35 -04:00
dd89a80449 Merge pull request from Infisical/feature/add-multi-select-deletion-overview
Feature: Add support for deleting secrets and folders in the Overview page
2024-05-08 01:25:21 +08:00
a1585db76a Merge pull request from Infisical/maidul98-patch-7
Create 20240507162180_test
2024-05-07 13:16:59 -04:00
f957b9d970 misc: migrated to react-state 2024-05-08 01:03:41 +08:00
c08fcc6f5e adjustment: finalized notification text 2024-05-08 00:12:55 +08:00
06c103c10a misc: added handling for no changes made 2024-05-07 22:19:20 +08:00
b6a73459a8 misc: addressed rbac for bulk delete in overview 2024-05-07 16:37:10 +08:00
536f51f6ba misc: added descriptive error message 2024-05-07 15:21:17 +08:00
a9b72b2da3 feat: added handling of folder/secret deletion 2024-05-07 15:16:37 +08:00
a3552d00d1 feat: add multi-select in secret overview 2024-05-07 13:52:42 +08:00
8 changed files with 315 additions and 32 deletions
.github/workflows
backend/src/db/migrations
frontend/src
hooks/api/secretFolders
views/SecretOverviewPage
SecretOverviewPage.tsx
components
SecretOverviewFolderRow
SecretOverviewTableRow
SelectionPanel

@ -39,22 +39,13 @@ jobs:
git commit -m "chore: renamed new migration files to latest timestamp (gh-action)"
- name: Get the username of the person who closed the PR
run: |
run: |
PR_NUMBER=${{ github.event.pull_request.number }}
REPO_NAME=${{ github.repository }}
GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}
# Use GitHub API to fetch PR data
PR_DATA=$(curl \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Accept: application/vnd.github.v3+json" \
https://api.github.com/repos/$REPO_NAME/pulls/$PR_NUMBER)
PR_CLOSER=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/${{ github.repository }}/pulls/$PR_NUMBER" | jq -r '.closed_by.login')
echo "PR Number: $PR_NUMBER"
echo "PR Closer: $PR_CLOSER"
echo "pr_closer=$PR_CLOSER" >> $GITHUB_OUTPUT
# Extract the username of the person who closed the PR
CLOSED_BY=$(echo $PR_DATA | jq -r .closed_by.login)
echo "Pull Request #$PR_NUMBER was closed by $CLOSED_BY"
- name: Create Pull Request
if: env.SKIP_RENAME != 'true'
uses: peter-evans/create-pull-request@v6

@ -94,7 +94,21 @@ export const useGetFoldersByEnv = ({
[(folders || []).map((folder) => folder.data)]
);
return { folders, folderNames, isFolderPresentInEnv };
const getFolderByNameAndEnv = useCallback(
(name: string, env: string) => {
const selectedEnvIndex = environments.indexOf(env);
if (selectedEnvIndex !== -1) {
return folders?.[selectedEnvIndex]?.data?.find(
({ name: folderName }) => folderName === name
);
}
return undefined;
},
[(folders || []).map((folder) => folder.data)]
);
return { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv };
};
export const useCreateFolder = () => {

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import Link from "next/link";
import { useRouter } from "next/router";
@ -70,6 +70,12 @@ import { ProjectIndexSecretsSection } from "./components/ProjectIndexSecretsSect
import { SecretOverviewDynamicSecretRow } from "./components/SecretOverviewDynamicSecretRow";
import { SecretOverviewFolderRow } from "./components/SecretOverviewFolderRow";
import { SecretOverviewTableRow } from "./components/SecretOverviewTableRow";
import { SelectionPanel } from "./components/SelectionPanel/SelectionPanel";
export enum EntryType {
FOLDER = "folder",
SECRET = "secret"
}
export const SecretOverviewPage = () => {
const { t } = useTranslation();
@ -82,15 +88,6 @@ export const SecretOverviewPage = () => {
const [expandableTableWidth, setExpandableTableWidth] = useState(0);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
useEffect(() => {
const handleParentTableWidthResize = () => {
setExpandableTableWidth(parentTableRef.current?.clientWidth || 0);
};
window.addEventListener("resize", handleParentTableWidthResize);
return () => window.removeEventListener("resize", handleParentTableWidthResize);
}, []);
useEffect(() => {
if (parentTableRef.current) {
setExpandableTableWidth(parentTableRef.current.clientWidth);
@ -105,6 +102,56 @@ export const SecretOverviewPage = () => {
const [searchFilter, setSearchFilter] = useState("");
const secretPath = (router.query?.secretPath as string) || "/";
const [selectedEntries, setSelectedEntries] = useState<{
[EntryType.FOLDER]: Record<string, boolean>;
[EntryType.SECRET]: Record<string, boolean>;
}>({
[EntryType.FOLDER]: {},
[EntryType.SECRET]: {}
});
const toggleSelectedEntry = useCallback(
(type: EntryType, key: string) => {
const isChecked = Boolean(selectedEntries[type]?.[key]);
const newChecks = { ...selectedEntries };
// remove selection if its present else add it
if (isChecked) {
delete newChecks[type][key];
} else {
newChecks[type][key] = true;
}
setSelectedEntries(newChecks);
},
[selectedEntries]
);
const resetSelectedEntries = useCallback(() => {
setSelectedEntries({
[EntryType.FOLDER]: {},
[EntryType.SECRET]: {}
});
}, []);
useEffect(() => {
const handleParentTableWidthResize = () => {
setExpandableTableWidth(parentTableRef.current?.clientWidth || 0);
};
const onRouteChangeStart = () => {
resetSelectedEntries();
};
router.events.on("routeChangeStart", onRouteChangeStart);
window.addEventListener("resize", handleParentTableWidthResize);
return () => {
window.removeEventListener("resize", handleParentTableWidthResize);
router.events.off("routeChangeStart", onRouteChangeStart);
};
}, []);
useEffect(() => {
if (!isWorkspaceLoading && !workspaceId && router.isReady) {
router.push(`/org/${currentOrg?.id}/overview`);
@ -129,7 +176,8 @@ export const SecretOverviewPage = () => {
secretPath,
decryptFileKey: latestFileKey!
});
const { folders, folderNames, isFolderPresentInEnv } = useGetFoldersByEnv({
const { folders, folderNames, isFolderPresentInEnv, getFolderByNameAndEnv } = useGetFoldersByEnv({
projectId: workspaceId,
path: secretPath,
environments: userAvailableEnvs.map(({ slug }) => slug)
@ -543,6 +591,13 @@ export const SecretOverviewPage = () => {
</div>
</div>
</div>
<SelectionPanel
secretPath={secretPath}
getSecretByKey={getSecretByKey}
getFolderByNameAndEnv={getFolderByNameAndEnv}
selectedEntries={selectedEntries}
resetSelectedEntries={resetSelectedEntries}
/>
<div className="thin-scrollbar mt-4" ref={parentTableRef}>
<TableContainer className="max-h-[calc(100vh-250px)] overflow-y-auto">
<Table>
@ -666,6 +721,8 @@ export const SecretOverviewPage = () => {
<SecretOverviewFolderRow
folderName={folderName}
isFolderPresentInEnv={isFolderPresentInEnv}
isSelected={selectedEntries.folder[folderName]}
onToggleFolderSelect={() => toggleSelectedEntry(EntryType.FOLDER, folderName)}
environments={visibleEnvs}
key={`overview-${folderName}-${index + 1}`}
onClick={handleFolderClick}
@ -684,6 +741,8 @@ export const SecretOverviewPage = () => {
visibleEnvs?.length > 0 &&
filteredSecretNames.map((key, index) => (
<SecretOverviewTableRow
isSelected={selectedEntries.secret[key]}
onToggleSecretSelect={() => toggleSelectedEntry(EntryType.SECRET, key)}
secretPath={secretPath}
isImportedSecretPresentInEnv={isImportedSecretPresentInEnv}
onSecretCreate={handleSecretCreate}

@ -2,20 +2,23 @@ import { faCheck, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Td, Tr } from "@app/components/v2";
import { Checkbox, Td, Tr } from "@app/components/v2";
type Props = {
folderName: string;
environments: { name: string; slug: string }[];
isFolderPresentInEnv: (name: string, env: string) => boolean;
onClick: (path: string) => void;
isSelected: boolean;
onToggleFolderSelect: (folderName: string) => void;
};
export const SecretOverviewFolderRow = ({
folderName,
environments = [],
isFolderPresentInEnv,
isSelected,
onToggleFolderSelect,
onClick
}: Props) => {
return (
@ -23,7 +26,21 @@ export const SecretOverviewFolderRow = ({
<Td className="sticky left-0 z-10 border-0 bg-mineshaft-800 bg-clip-padding p-0 group-hover:bg-mineshaft-700">
<div className="flex items-center space-x-5 border-r border-mineshaft-600 px-5 py-2.5">
<div className="text-yellow-700">
<FontAwesomeIcon icon={faFolder} />
<Checkbox
id={`checkbox-${folderName}`}
isChecked={isSelected}
onCheckedChange={() => {
onToggleFolderSelect(folderName);
}}
onClick={(e) => {
e.stopPropagation();
}}
className={twMerge("hidden group-hover:flex", isSelected && "flex")}
/>
<FontAwesomeIcon
className={twMerge("block group-hover:hidden", isSelected && "hidden")}
icon={faFolder}
/>
</div>
<div>{folderName}</div>
</div>

@ -11,7 +11,7 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { Button, TableContainer, Td, Tooltip, Tr } from "@app/components/v2";
import { Button, Checkbox, TableContainer, Td, Tooltip, Tr } from "@app/components/v2";
import { useToggle } from "@app/hooks";
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
@ -23,6 +23,8 @@ type Props = {
secretPath: string;
environments: { name: string; slug: string }[];
expandableColWidth: number;
isSelected: boolean;
onToggleSecretSelect: (key: string) => void;
getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined;
onSecretCreate: (env: string, key: string, value: string) => Promise<void>;
onSecretUpdate: (env: string, key: string, value: string, secretId?: string) => Promise<void>;
@ -39,7 +41,9 @@ export const SecretOverviewTableRow = ({
onSecretCreate,
onSecretDelete,
isImportedSecretPresentInEnv,
expandableColWidth
expandableColWidth,
onToggleSecretSelect,
isSelected
}: Props) => {
const [isFormExpanded, setIsFormExpanded] = useToggle();
const totalCols = environments.length + 1; // secret key row
@ -56,7 +60,21 @@ export const SecretOverviewTableRow = ({
<div className="h-full w-full border-r border-mineshaft-600 py-2.5 px-5">
<div className="flex items-center space-x-5">
<div className="text-blue-300/70">
<FontAwesomeIcon icon={isFormExpanded ? faAngleDown : faKey} />
<Checkbox
id={`checkbox-${secretKey}`}
isChecked={isSelected}
onCheckedChange={() => {
onToggleSecretSelect(secretKey);
}}
onClick={(e) => {
e.stopPropagation();
}}
className={twMerge("hidden group-hover:flex", isSelected && "flex")}
/>
<FontAwesomeIcon
className={twMerge("block group-hover:hidden", isSelected && "hidden")}
icon={isFormExpanded ? faAngleDown : faKey}
/>
</div>
<div title={secretKey}>{secretKey}</div>
</div>

@ -0,0 +1,184 @@
import { subject } from "@casl/ability";
import { faMinusSquare, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { Button, DeleteActionModal, IconButton, Tooltip } from "@app/components/v2";
import {
ProjectPermissionActions,
ProjectPermissionSub,
useProjectPermission,
useWorkspace
} from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useDeleteSecretBatch } from "@app/hooks/api";
import { DecryptedSecret, TDeleteSecretBatchDTO, TSecretFolder } from "@app/hooks/api/types";
export enum EntryType {
FOLDER = "folder",
SECRET = "secret"
}
type Props = {
secretPath: string;
getSecretByKey: (slug: string, key: string) => DecryptedSecret | undefined;
getFolderByNameAndEnv: (name: string, env: string) => TSecretFolder | undefined;
resetSelectedEntries: () => void;
selectedEntries: {
[EntryType.FOLDER]: Record<string, boolean>;
[EntryType.SECRET]: Record<string, boolean>;
};
};
export const SelectionPanel = ({
getFolderByNameAndEnv,
getSecretByKey,
secretPath,
resetSelectedEntries,
selectedEntries
}: Props) => {
const { permission } = useProjectPermission();
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"bulkDeleteEntries"
] as const);
const selectedCount =
Object.keys(selectedEntries.folder).length + Object.keys(selectedEntries.secret).length;
const { currentWorkspace } = useWorkspace();
const workspaceId = currentWorkspace?.id || "";
const userAvailableEnvs = currentWorkspace?.environments || [];
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
const { mutateAsync: deleteFolder } = useDeleteFolder();
const isMultiSelectActive = selectedCount > 0;
// user should have the ability to delete secrets/folders in at least one of the envs
const shouldShowDelete = userAvailableEnvs.some((env) =>
permission.can(
ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath })
)
);
const handleBulkDelete = async () => {
let processedEntries = 0;
const promises = userAvailableEnvs.map(async (env) => {
// additional check: ensure that bulk delete is only executed on envs that user has access to
if (
permission.cannot(
ProjectPermissionActions.Delete,
subject(ProjectPermissionSub.Secrets, { environment: env.slug, secretPath })
)
) {
return;
}
await Promise.all(
Object.keys(selectedEntries.folder).map(async (folderName) => {
const folder = getFolderByNameAndEnv(folderName, env.slug);
if (folder) {
processedEntries += 1;
await deleteFolder({
folderId: folder?.id,
path: secretPath,
environment: env.slug,
projectId: workspaceId
});
}
})
);
const secretsToDelete = Object.keys(selectedEntries.secret).reduce(
(accum: TDeleteSecretBatchDTO["secrets"], secretName) => {
const entry = getSecretByKey(env.slug, secretName);
if (entry) {
return [
...accum,
{
secretName: entry.key,
type: "shared" as "shared"
}
];
}
return accum;
},
[]
);
if (secretsToDelete.length > 0) {
processedEntries += secretsToDelete.length;
await deleteBatchSecretV3({
secretPath,
workspaceId,
environment: env.slug,
secrets: secretsToDelete
});
}
});
const results = await Promise.allSettled(promises);
const areEntriesDeleted = results.some((result) => result.status === "fulfilled");
if (processedEntries === 0) {
handlePopUpClose("bulkDeleteEntries");
createNotification({
type: "info",
text: "You don't have access to delete selected items"
});
} else if (areEntriesDeleted) {
handlePopUpClose("bulkDeleteEntries");
resetSelectedEntries();
createNotification({
type: "success",
text: "Successfully deleted selected secrets and folders"
});
} else {
createNotification({
type: "error",
text: "Failed to delete selected secrets and folders"
});
}
};
return (
<>
<div
className={twMerge(
"h-0 flex-shrink-0 overflow-hidden transition-all",
isMultiSelectActive && "h-16"
)}
>
<div className="mt-3.5 flex items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 py-2 px-4 text-bunker-300">
<Tooltip content="Clear">
<IconButton variant="plain" ariaLabel="clear-selection" onClick={resetSelectedEntries}>
<FontAwesomeIcon icon={faMinusSquare} size="lg" />
</IconButton>
</Tooltip>
<div className="ml-4 flex-grow px-2 text-sm">{selectedCount} Selected</div>
{shouldShowDelete && (
<Button
variant="outline_bg"
colorSchema="danger"
leftIcon={<FontAwesomeIcon icon={faTrash} />}
className="ml-4"
onClick={() => handlePopUpOpen("bulkDeleteEntries")}
size="xs"
>
Delete
</Button>
)}
</div>
</div>
<DeleteActionModal
isOpen={popUp.bulkDeleteEntries.isOpen}
deleteKey="delete"
title="Do you want to delete the selected secrets and folders across envs?"
onChange={(isOpen) => handlePopUpToggle("bulkDeleteEntries", isOpen)}
onDeleteApproved={handleBulkDelete}
/>
</>
);
};