You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
docker-infisical/frontend/src/views/SecretMainPage/components/SecretListView/SecretListView.tsx

364 lines
12 KiB

import { useCallback } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge";
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
import { CreateTagModal } from "@app/components/tags/CreateTagModal";
import { DeleteActionModal } from "@app/components/v2";
import { usePopUp } from "@app/hooks";
import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api";
import { secretKeys } from "@app/hooks/api/secrets/queries";
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
import { secretSnapshotKeys } from "@app/hooks/api/secretSnapshots/queries";
import { UserWsKeyPair, WsTag } from "@app/hooks/api/types";
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
import { Filter, GroupBy, SortDir } from "../../SecretMainPage.types";
import { SecretDetailSidebar } from "./SecretDetaiSidebar";
import { SecretItem } from "./SecretItem";
type Props = {
secrets?: DecryptedSecret[];
environment: string;
workspaceId: string;
decryptFileKey: UserWsKeyPair;
secretPath?: string;
filter: Filter;
sortDir?: SortDir;
tags?: WsTag[];
isVisible?: boolean;
isProtectedBranch?: boolean;
};
const reorderSecretGroupByUnderscore = (secrets: DecryptedSecret[], sortDir: SortDir) => {
const groupedSecrets: Record<string, DecryptedSecret[]> = {};
secrets.forEach((secret) => {
const lastSeperatorIndex = secret.key.lastIndexOf("_");
const namespace =
lastSeperatorIndex !== -1 ? secret.key.substring(0, lastSeperatorIndex) : "misc";
if (!groupedSecrets?.[namespace]) groupedSecrets[namespace] = [];
groupedSecrets[namespace].push(secret);
});
return Object.keys(groupedSecrets)
.sort((a, b) =>
sortDir === SortDir.ASC
? a.toLowerCase().localeCompare(b.toLowerCase())
: b.toLowerCase().localeCompare(a.toLowerCase())
)
.map((namespace) => ({ namespace, secrets: groupedSecrets[namespace] }));
};
const reorderSecret = (secrets: DecryptedSecret[], sortDir: SortDir, filter?: GroupBy | null) => {
if (filter === GroupBy.PREFIX) {
return reorderSecretGroupByUnderscore(secrets, sortDir);
}
return [
{
namespace: "",
secrets: secrets?.sort((a, b) =>
sortDir === SortDir.ASC
? a.key.toLowerCase().localeCompare(b.key.toLowerCase())
: b.key.toLowerCase().localeCompare(a.key.toLowerCase())
)
}
];
};
export const filterSecrets = (secrets: DecryptedSecret[], filter: Filter) =>
secrets.filter(({ key, value, tags }) => {
const isTagFilterActive = Boolean(Object.keys(filter.tags).length);
const searchTerm = filter.searchFilter.toLowerCase();
return (
(!isTagFilterActive || tags.some(({ _id }) => filter.tags?.[_id])) &&
(key.toLowerCase().includes(searchTerm) || value.toLowerCase().includes(searchTerm))
);
});
export const SecretListView = ({
secrets = [],
environment,
workspaceId,
decryptFileKey,
secretPath = "/",
filter,
sortDir = SortDir.ASC,
tags: wsTags = [],
isVisible,
isProtectedBranch = false
}: Props) => {
const { createNotification } = useNotificationContext();
const queryClient = useQueryClient();
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"deleteSecret",
"secretDetail",
"createTag"
] as const);
// strip of side effect queries
const { mutateAsync: createSecretV3 } = useCreateSecretV3({
options: {
onSuccess: undefined
}
});
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3({
options: {
onSuccess: undefined
}
});
const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3({
options: {
onSuccess: undefined
}
});
const selectedSecrets = useSelectedSecrets();
const { toggle: toggleSelectedSecret } = useSelectedSecretActions();
const handleSecretOperation = async (
operation: "create" | "update" | "delete",
type: "shared" | "personal",
key: string,
{
value,
comment,
tags,
skipMultilineEncoding,
newKey,
secretId
}: Partial<{
value: string;
comment: string;
tags: string[];
skipMultilineEncoding: boolean;
newKey: string;
secretId: string;
}> = {}
) => {
if (operation === "delete") {
await deleteSecretV3({
environment,
workspaceId,
secretPath,
secretName: key,
type,
secretId
});
return;
}
if (operation === "update") {
await updateSecretV3({
environment,
workspaceId,
secretPath,
secretName: key,
secretId,
secretValue: value || "",
type,
latestFileKey: decryptFileKey,
tags,
secretComment: comment,
skipMultilineEncoding,
newSecretName: newKey
});
return;
}
await createSecretV3(
{
environment,
workspaceId,
secretPath,
secretName: key,
secretValue: value || "",
secretComment: "",
skipMultilineEncoding,
type,
latestFileKey: decryptFileKey
},
{}
);
};
const handleSaveSecret = useCallback(
async (
orgSecret: DecryptedSecret,
modSecret: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] },
cb?: () => void
) => {
const { key: oldKey } = orgSecret;
const { key, value, overrideAction, idOverride, valueOverride, tags, comment } = modSecret;
const hasKeyChanged = oldKey !== key;
const tagIds = tags.map(({ _id }) => _id);
const oldTagIds = orgSecret.tags.map(({ _id }) => _id);
const isSameTags = JSON.stringify(tagIds) === JSON.stringify(oldTagIds);
const isSharedSecUnchanged =
(["key", "value", "comment", "skipMultilineEncoding"] as const).every(
(el) => orgSecret[el] === modSecret[el]
) && isSameTags;
try {
// personal secret change
if (overrideAction === "deleted") {
await handleSecretOperation("delete", "personal", oldKey, {
secretId: orgSecret.idOverride
});
} else if (overrideAction && idOverride) {
await handleSecretOperation("update", "personal", oldKey, {
value: valueOverride,
newKey: hasKeyChanged ? key : undefined,
secretId: orgSecret.idOverride,
skipMultilineEncoding: modSecret.skipMultilineEncoding
});
} else if (overrideAction) {
await handleSecretOperation("create", "personal", oldKey, { value: valueOverride });
}
// shared secret change
if (!isSharedSecUnchanged) {
await handleSecretOperation("update", "shared", oldKey, {
value,
tags: tagIds,
comment,
secretId: orgSecret._id,
newKey: hasKeyChanged ? key : undefined,
skipMultilineEncoding: modSecret.skipMultilineEncoding
});
if (cb) cb();
}
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
queryClient.invalidateQueries(
secretSnapshotKeys.list({ workspaceId, environment, directory: secretPath })
);
queryClient.invalidateQueries(
secretSnapshotKeys.count({ workspaceId, environment, directory: secretPath })
);
handlePopUpClose("secretDetail");
createNotification({
type: "success",
text: isProtectedBranch
? "Requested changes have been sent for review"
: "Successfully saved secrets"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to save secret"
});
}
},
[environment, secretPath, isProtectedBranch]
);
const handleSecretDelete = useCallback(async () => {
const { key, _id: secretId } = popUp.deleteSecret?.data as DecryptedSecret;
try {
await handleSecretOperation("delete", "shared", key, { secretId });
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);
queryClient.invalidateQueries(
secretSnapshotKeys.list({ workspaceId, environment, directory: secretPath })
);
queryClient.invalidateQueries(
secretSnapshotKeys.count({ workspaceId, environment, directory: secretPath })
);
handlePopUpClose("deleteSecret");
handlePopUpClose("secretDetail");
createNotification({
type: "success",
text: isProtectedBranch
? "Requested changes have been sent for review"
: "Successfully deleted secret"
});
} catch (error) {
console.log(error);
createNotification({
type: "error",
text: "Failed to delete secret"
});
}
}, [(popUp.deleteSecret?.data as DecryptedSecret)?.key, environment, secretPath]);
// for optimization on minimise re-rendering of secret items
const onCreateTag = useCallback(() => handlePopUpOpen("createTag"), []);
const onDeleteSecret = useCallback(
(sec: DecryptedSecret) => handlePopUpOpen("deleteSecret", sec),
[]
);
const onDetailViewSecret = useCallback(
(sec: DecryptedSecret) => handlePopUpOpen("secretDetail", sec),
[]
);
return (
<>
{reorderSecret(secrets, sortDir, filter.groupBy).map(
({ namespace, secrets: groupedSecrets }) => {
const filteredSecrets = filterSecrets(groupedSecrets, filter);
return (
<div className="flex flex-col" key={`${namespace}-${groupedSecrets.length}`}>
<div
className={twMerge(
"bg-bunker-600 capitalize text-md h-0 transition-all",
Boolean(namespace) && Boolean(filteredSecrets.length) && "h-11 py-3 pl-4 "
)}
key={namespace}
>
{namespace}
</div>
{filteredSecrets.map((secret) => (
<SecretItem
environment={environment}
secretPath={secretPath}
tags={wsTags}
isSelected={selectedSecrets?.[secret._id]}
onToggleSecretSelect={toggleSelectedSecret}
isVisible={isVisible}
secret={secret}
key={secret._id}
onSaveSecret={handleSaveSecret}
onDeleteSecret={onDeleteSecret}
onDetailViewSecret={onDetailViewSecret}
onCreateTag={onCreateTag}
/>
))}
</div>
);
}
)}
<DeleteActionModal
isOpen={popUp.deleteSecret.isOpen}
deleteKey={(popUp.deleteSecret?.data as DecryptedSecret)?.key}
title="Do you want to delete this secret?"
onChange={(isOpen) => handlePopUpToggle("deleteSecret", isOpen)}
onDeleteApproved={handleSecretDelete}
buttonText="Delete Secret"
/>
<SecretDetailSidebar
environment={environment}
secretPath={secretPath}
isOpen={popUp.secretDetail.isOpen}
onToggle={(isOpen) => handlePopUpToggle("secretDetail", isOpen)}
decryptFileKey={decryptFileKey}
secret={popUp.secretDetail.data as DecryptedSecret}
onDeleteSecret={() => handlePopUpOpen("deleteSecret", popUp.secretDetail.data)}
onClose={() => handlePopUpClose("secretDetail")}
onSaveSecret={handleSaveSecret}
tags={wsTags}
onCreateTag={() => handlePopUpOpen("createTag")}
/>
<CreateTagModal
isOpen={popUp.createTag.isOpen}
onToggle={(isOpen) => handlePopUpToggle("createTag", isOpen)}
/>
</>
);
};