Merge branch 'main' into feat/remindersImprovement

This commit is contained in:
Carlos Monastyrski
2025-07-23 11:27:31 -03:00
619 changed files with 15481 additions and 6720 deletions
@@ -34,7 +34,7 @@ export const CommitDetailsPage = () => {
const handleGoBackToHistory = () => {
navigate({
to: "/projects/$projectId/secret-manager/commits/$environment/$folderId",
to: "/projects/secret-management/$projectId/commits/$environment/$folderId",
params: {
projectId: currentWorkspace.id,
folderId,
@@ -49,7 +49,7 @@ export const CommitDetailsPage = () => {
const handleGoToRollbackPreview = () => {
navigate({
to: "/projects/$projectId/secret-manager/commits/$environment/$folderId/$commitId/restore",
to: "/projects/secret-management/$projectId/commits/$environment/$folderId/$commitId/restore",
params: {
projectId: currentWorkspace.id,
folderId,
@@ -1,5 +1,10 @@
import { useEffect, useState } from "react";
import { faAngleDown } from "@fortawesome/free-solid-svg-icons";
import {
faAngleDown,
faChevronLeft,
faCodeCommit,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { DropdownMenuItem } from "@radix-ui/react-dropdown-menu";
import { useSearch } from "@tanstack/react-router";
@@ -7,12 +12,14 @@ import { useSearch } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
ContentLoader,
DeleteActionModal,
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
IconButton,
Spinner
EmptyState,
PageHeader
} from "@app/components/v2";
import { ROUTE_PATHS } from "@app/const/routes";
import {
@@ -108,25 +115,25 @@ export const CommitDetailsTab = ({
// If no commit is selected or data is loading, show appropriate message
if (!selectedCommitId) {
return (
<div className="flex h-64 items-center justify-center">
<p className="text-gray-400">Select a commit to view details</p>
</div>
<EmptyState className="mt-40" title="Select a commit to view details." icon={faCodeCommit}>
<Button className="mt-4" colorSchema="secondary" onClick={() => goBackToHistory()}>
Back to Commits
</Button>
</EmptyState>
);
}
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<Spinner size="lg" />
</div>
);
return <ContentLoader />;
}
if (!commitDetails) {
return (
<div className="flex h-64 items-center justify-center">
<p className="text-gray-400">No details found for this commit</p>
</div>
<EmptyState className="mt-40" title="No details found for this commit." icon={faCodeCommit}>
<Button className="mt-4" colorSchema="secondary" onClick={() => goBackToHistory()}>
Back to Commits
</Button>
</EmptyState>
);
}
@@ -138,9 +145,11 @@ export const CommitDetailsTab = ({
} catch (error) {
console.error("Failed to parse commit details:", error);
return (
<div className="flex h-64 items-center justify-center">
<p className="text-gray-400">Error parsing commit details</p>
</div>
<EmptyState className="mt-40" title="Error parsing commit details." icon={faWarning}>
<Button className="mt-4" colorSchema="secondary" onClick={() => goBackToHistory()}>
Back to Commits
</Button>
</EmptyState>
);
}
@@ -223,13 +232,12 @@ export const CommitDetailsTab = ({
// Render an item from the merged list
const renderMergedItem = (item: MergedItem): JSX.Element => {
return (
<div key={item.id} className="mb-2">
<SecretVersionDiffView
item={item}
isCollapsed={collapsedItems[item.id]}
onToggleCollapse={(id) => toggleItemCollapsed(id)}
/>
</div>
<SecretVersionDiffView
key={item.id}
item={item}
isCollapsed={collapsedItems[item.id]}
onToggleCollapse={(id) => toggleItemCollapsed(id)}
/>
);
};
@@ -240,114 +248,104 @@ export const CommitDetailsTab = ({
"Unknown";
return (
<div className="w-full">
<div>
<div className="flex justify-between pb-2">
<div className="w-5/6">
<div>
<div className="flex items-center">
<h1 className="mr-4 truncate text-3xl font-semibold text-white">
{parsedCommitDetails.changes?.message || "No message"}
</h1>
</div>
</div>
<div className="font-small mb-4 mt-2 flex items-center text-sm">
<p>
<span> Commited by </span>
<b>{actorDisplay}</b>
<span> on </span>
<b>
{formatDisplayDate(
parsedCommitDetails.changes?.createdAt || new Date().toISOString()
)}
</b>
{parsedCommitDetails.changes?.isLatest && (
<span className="ml-1 italic text-gray-400">(Latest)</span>
)}
</p>
</div>
</div>
<div className="flex items-center justify-start">
<ProjectPermissionCan
I={ProjectPermissionCommitsActions.PerformRollback}
a={ProjectPermissionSub.Commits}
>
{(isAllowed) => (
<DropdownMenu>
<DropdownMenuTrigger
asChild
disabled={!isAllowed}
className={`${!isAllowed ? "cursor-not-allowed" : ""}`}
<>
<Button
variant="link"
type="submit"
leftIcon={<FontAwesomeIcon icon={faChevronLeft} />}
onClick={() => {
goBackToHistory();
}}
>
Commit History
</Button>
<PageHeader
title={`${parsedCommitDetails.changes?.message}` || "No message"}
description={
<>
Commited by {actorDisplay} on{" "}
{formatDisplayDate(parsedCommitDetails.changes?.createdAt || new Date().toISOString())}
{parsedCommitDetails.changes?.isLatest && (
<span className="ml-1 text-mineshaft-400">(Latest)</span>
)}
</>
}
>
<ProjectPermissionCan
I={ProjectPermissionCommitsActions.PerformRollback}
a={ProjectPermissionSub.Commits}
>
{(isAllowed) => (
<DropdownMenu>
<DropdownMenuTrigger
asChild
disabled={!isAllowed}
className={`${!isAllowed ? "cursor-not-allowed" : ""}`}
>
<Button
rightIcon={<FontAwesomeIcon className="ml-2" icon={faAngleDown} />}
variant="solid"
className="h-min"
colorSchema="secondary"
>
Restore Options
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-w-sm bg-bunker-500" sideOffset={2}>
{!parsedCommitDetails.changes.isLatest && (
<DropdownMenuItem
className="group cursor-pointer border-b border-mineshaft-600 px-3 py-3 transition-colors hover:bg-mineshaft-700"
onClick={() => goToRollbackPreview()}
>
<IconButton
ariaLabel="commit-options"
variant="outline_bg"
className="h-10 rounded border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-sm font-medium"
>
<p className="mr-2">Restore Options</p>
<FontAwesomeIcon icon={faAngleDown} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={2}
className="animate-in fade-in-50 zoom-in-95 min-w-[240px] rounded-md bg-mineshaft-800 p-1 shadow-lg"
style={{ marginTop: "0" }}
>
{!parsedCommitDetails.changes.isLatest && (
<DropdownMenuItem
className="group cursor-pointer rounded-md px-3 py-3 transition-colors hover:bg-mineshaft-700"
onClick={() => goToRollbackPreview()}
>
<div className="flex items-center space-x-3">
<div className="flex flex-col">
<span className="text-sm font-medium text-white">
Roll back to this commit
</span>
<span className="max-w-[180px] whitespace-normal break-words text-xs leading-snug text-gray-400">
Return this folder to its exact state at the time of this commit,
discarding all other changes made after it
</span>
</div>
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="group cursor-pointer rounded-md px-3 py-3 transition-colors hover:bg-mineshaft-700"
onClick={() => handlePopUpOpen("revertChanges")}
>
<div className="flex items-center space-x-3">
<div className="flex flex-col">
<span className="text-sm font-medium text-white">Revert changes</span>
<span className="max-w-[180px] whitespace-normal break-words text-xs leading-snug text-gray-400">
Will restore to the previous version of affected resources
</span>
</div>
<div className="flex items-center space-x-3">
<div className="flex flex-col">
<span className="text-sm font-medium text-white">
Roll back to this commit
</span>
<span className="whitespace-normal break-words text-xs leading-snug text-gray-400">
Return this folder to its exact state at the time of this commit,
discarding all other changes made after it
</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</ProjectPermissionCan>
</div>
</div>
</DropdownMenuItem>
)}
<DropdownMenuItem
className="group cursor-pointer px-3 py-3 transition-colors hover:bg-mineshaft-700"
onClick={() => handlePopUpOpen("revertChanges")}
>
<div className="flex items-center space-x-3">
<div className="flex flex-col">
<span className="text-sm font-medium text-white">Revert changes</span>
<span className="whitespace-normal break-words text-xs leading-snug text-gray-400">
Will restore to the previous version of affected resources
</span>
</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</ProjectPermissionCan>
</PageHeader>
<div className="flex w-full flex-col rounded-lg border border-mineshaft-600 bg-mineshaft-900 pt-4">
<div className="mx-4 flex items-center justify-between border-b border-mineshaft-400 pb-4">
<h3 className="text-lg font-semibold text-mineshaft-100">Commit Changes</h3>
</div>
<div className="py-2">
<div className="overflow-hidden">
<div className="space-y-2">
{sortedChangedItems.length > 0 ? (
sortedChangedItems.map((item) => renderMergedItem(item))
) : (
<div className="flex h-32 items-center justify-center rounded-lg border border-mineshaft-600 bg-mineshaft-800">
<p className="text-gray-400">No changed items found</p>
</div>
)}
</div>
<div className="flex flex-col overflow-hidden px-4">
<div className="thin-scrollbar overflow-y-auto py-4">
{sortedChangedItems.length > 0 ? (
sortedChangedItems.map((item) => renderMergedItem(item))
) : (
<EmptyState
title="No changes found."
className="h-full pb-0 pt-28"
icon={faCodeCommit}
/>
)}
</div>
</div>
</div>
<DeleteActionModal
isOpen={popUp.revertChanges.isOpen}
deleteKey="revert"
@@ -357,6 +355,6 @@ export const CommitDetailsTab = ({
onDeleteApproved={handleRevertChanges}
buttonText="Yes, revert changes"
/>
</div>
</>
);
};
@@ -102,7 +102,7 @@ export const RollbackPreviewTab = (): JSX.Element => {
const goBackToHistory = () => {
navigate({
to: "/projects/$projectId/secret-manager/commits/$environment/$folderId",
to: "/projects/secret-management/$projectId/commits/$environment/$folderId",
params: {
projectId: currentWorkspace.id,
folderId,
@@ -12,7 +12,7 @@ const RollbackPreviewTabQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/commits/$environment/$folderId/$commitId/restore"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId/restore"
)({
component: RollbackPreviewTab,
validateSearch: zodValidator(RollbackPreviewTabQueryParamsSchema),
@@ -33,7 +33,7 @@ export const Route = createFileRoute(
links: context.project.environments.map((el) => ({
label: el.name,
link: linkOptions({
to: "/projects/$projectId/secret-manager/secrets/$envSlug",
to: "/projects/secret-management/$projectId/secrets/$envSlug",
params: {
projectId: params.projectId,
envSlug: el.slug
@@ -56,7 +56,7 @@ export const Route = createFileRoute(
{
label: "Commits",
link: linkOptions({
to: "/projects/$projectId/secret-manager/commits/$environment/$folderId",
to: "/projects/secret-management/$projectId/commits/$environment/$folderId",
params: {
projectId: params.projectId,
environment: params.environment,
@@ -70,7 +70,7 @@ export const Route = createFileRoute(
{
label: params.commitId,
link: linkOptions({
to: "/projects/$projectId/secret-manager/commits/$environment/$folderId/$commitId",
to: "/projects/secret-management/$projectId/commits/$environment/$folderId/$commitId",
params: {
projectId: params.projectId,
environment: params.environment,
@@ -1,7 +1,10 @@
/* eslint-disable no-nested-ternary */
import { useCallback, useRef, useState } from "react";
import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons";
import { faChevronDown, faChevronUp, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { IconButton, Tooltip } from "@app/components/v2";
export interface Version {
id?: string;
@@ -32,6 +35,7 @@ interface SecretVersionDiffViewProps {
showHeader?: boolean;
customHeader?: JSX.Element;
excludedFieldsHighlight?: string[];
onDiscard?: VoidFunction;
}
const isObject = (obj: JsonValue): obj is JsonObject => {
@@ -225,15 +229,12 @@ const renderJsonWithDiffs = (
const getLineClass = (different: boolean) => {
if (!different) return "flex";
return isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 text-green-300";
return isOldVersion
? "flex bg-red-500/50 rounded-sm text-red-300"
: "flex bg-green-500/50 rounded-sm text-green-300";
};
const getHighlightClass = (different: boolean) => {
if (!different) return "";
return isOldVersion ? "bg-red-900 rounded px-1" : "bg-green-900 rounded px-1";
};
const prefix = isDifferent ? (isOldVersion ? "-" : "+") : " ";
const prefix = isDifferent ? (isOldVersion ? " -" : " +") : " ";
const keyDisplay = keyName ? `"${keyName}": ` : "";
const comma = !isLastItem ? "," : "";
@@ -255,8 +256,8 @@ const renderJsonWithDiffs = (
<div className="w-4 flex-shrink-0">{prefix}</div>
<div>
{indent}
{keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
<span className={getHighlightClass(isDifferent)}>{valueDisplay}</span>
{keyName && <span>{keyDisplay}</span>}
<span>{valueDisplay}</span>
{comma}
</div>
</div>
@@ -269,8 +270,8 @@ const renderJsonWithDiffs = (
<div className="w-4 flex-shrink-0">{prefix}</div>
<div>
{indent}
{keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
<span className={getHighlightClass(isDifferent)}>[]</span>
{keyName && <span>{keyDisplay}</span>}
<span>[]</span>
{comma}
</div>
</div>
@@ -283,8 +284,8 @@ const renderJsonWithDiffs = (
<div className="w-4 flex-shrink-0">{prefix}</div>
<div>
{indent}
{keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
<span className={getHighlightClass(isDifferent)}>{"{}"}</span>
{keyName && <span>{keyDisplay}</span>}
<span>{"{}"}</span>
{comma}
</div>
</div>
@@ -320,16 +321,12 @@ const renderJsonWithDiffs = (
<div key={reactKey}>
<div className={getLineClass(isContainerAddedOrRemoved)}>
<div className="w-4 flex-shrink-0">
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
{isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
</div>
<div>
{indent}
{keyName && (
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>
{keyDisplay}
</span>
)}
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>[</span>
{keyName && <span>{keyDisplay}</span>}
<span>[</span>
</div>
</div>
@@ -357,11 +354,11 @@ const renderJsonWithDiffs = (
<div className={getLineClass(isContainerAddedOrRemoved)}>
<div className="w-4 flex-shrink-0">
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
{isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
</div>
<div>
{indent}
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>]</span>
<span>]</span>
{comma}
</div>
</div>
@@ -376,16 +373,12 @@ const renderJsonWithDiffs = (
<div key={reactKey}>
<div className={getLineClass(isContainerAddedOrRemoved)}>
<div className="w-4 flex-shrink-0">
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
{isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
</div>
<div>
{indent}
{keyName && (
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>
{keyDisplay}
</span>
)}
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>{"{"}</span>
{keyName && <span>{keyDisplay}</span>}
<span>{"{"}</span>
</div>
</div>
@@ -414,12 +407,11 @@ const renderJsonWithDiffs = (
<div className={getLineClass(isContainerAddedOrRemoved)}>
<div className="w-4 flex-shrink-0">
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
{isContainerAddedOrRemoved ? (isOldVersion ? " -" : " +") : " "}
</div>
<div>
{indent}
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>{"}"}</span>
{comma}
<span>{"}"}</span>
</div>
</div>
</div>
@@ -475,7 +467,9 @@ const formatDeletedJson = (json: JsonValue): JSX.Element => {
const cleanVersionForComparison = (version: Version): JsonValue => {
const { id, version: versionNumber, ...cleanVersion } = version;
return cleanVersion;
return Object.fromEntries(
Object.entries(cleanVersion).filter((entry) => typeof entry[1] !== "undefined")
);
};
export const SecretVersionDiffView = ({
@@ -484,7 +478,8 @@ export const SecretVersionDiffView = ({
onToggleCollapse,
showHeader = true,
customHeader,
excludedFieldsHighlight = ["metadata", "tags"]
excludedFieldsHighlight = ["metadata", "tags"],
onDiscard
}: SecretVersionDiffViewProps) => {
const oldContainerRef = useRef<HTMLDivElement>(null);
const newContainerRef = useRef<HTMLDivElement>(null);
@@ -527,7 +522,7 @@ export const SecretVersionDiffView = ({
}
oldVersionContent = (
<div className="font-mono text-sm">
<div className="w-fit min-w-[100%] font-mono text-sm">
{renderJsonWithDiffs(
cleanOldVersion,
diffPaths,
@@ -543,7 +538,7 @@ export const SecretVersionDiffView = ({
</div>
);
newVersionContent = (
<div className="font-mono text-sm">
<div className="w-fit min-w-[100%] font-mono text-sm">
{renderJsonWithDiffs(
cleanNewVersion,
diffPaths,
@@ -583,19 +578,19 @@ export const SecretVersionDiffView = ({
if (item.isDeleted) {
textStyle = "line-through text-red-300";
changeBadge = (
<span className="ml-2 rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
<span className="ml-2 whitespace-nowrap rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
{isSecret ? "Secret" : "Folder"} Deleted
</span>
);
} else if (item.isAdded) {
changeBadge = (
<span className="ml-2 rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
<span className="ml-2 whitespace-nowrap rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
{isSecret ? "Secret" : "Folder"} Added
</span>
);
} else if (item.isUpdated) {
changeBadge = (
<span className="ml-2 rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
<span className="ml-2 whitespace-nowrap rounded-md bg-mineshaft-600 px-2 py-0.5 text-xs font-medium">
{isSecret ? "Secret" : "Folder"} Updated
</span>
);
@@ -615,32 +610,48 @@ export const SecretVersionDiffView = ({
tabIndex={0}
aria-expanded={!collapsed}
>
<div className="flex items-center">
<span className={textStyle}>{key}</span>
<div className="flex min-w-0 flex-1 items-center">
<p className={twMerge(textStyle, "truncate")}>{key}</p>
{changeBadge}
</div>
<FontAwesomeIcon icon={collapsed ? faChevronDown : faChevronUp} className="text-gray-400" />
{onDiscard && (
<Tooltip side="left" content="Discard change">
<IconButton
ariaLabel="discard-change"
variant="plain"
colorSchema="danger"
size="sm"
className="ml-2"
onClick={onDiscard}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
)}
<FontAwesomeIcon
icon={collapsed ? faChevronDown : faChevronUp}
className="ml-2 text-gray-400"
/>
</div>
);
};
return (
<div className="overflow-hidden rounded-lg border border-mineshaft-600 bg-mineshaft-800">
<div className="overflow-hidden border border-b-0 border-mineshaft-600 bg-mineshaft-800 first:rounded-t last:rounded-b last:border-b">
{showHeader && renderHeader()}
{!collapsed && (
<div className="border-t border-mineshaft-700 bg-mineshaft-900 px-6 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="border-t border-mineshaft-700 bg-mineshaft-900 p-3 text-mineshaft-100">
<div className="flex gap-3">
<div
ref={oldContainerRef}
className="thin-scrollbar max-h-96 overflow-auto whitespace-pre rounded border border-mineshaft-600 bg-mineshaft-900 p-4"
className="thin-scrollbar max-h-96 flex-1 overflow-auto whitespace-pre"
>
{oldVersionContent}
</div>
<div className="max-h-96 w-[0.05rem] self-stretch bg-mineshaft-600" />
<div
ref={newContainerRef}
className="thin-scrollbar max-h-96 overflow-auto whitespace-pre rounded border border-mineshaft-600 bg-mineshaft-900 p-4"
className="thin-scrollbar max-h-96 flex-1 overflow-auto whitespace-pre"
>
{newVersionContent}
</div>
@@ -12,7 +12,7 @@ const CommitDetailsPageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/commits/$environment/$folderId/$commitId/"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId/"
)({
component: CommitDetailsPage,
validateSearch: zodValidator(CommitDetailsPageQueryParamsSchema),
@@ -33,7 +33,7 @@ export const Route = createFileRoute(
links: context.project.environments.map((el) => ({
label: el.name,
link: linkOptions({
to: "/projects/$projectId/secret-manager/secrets/$envSlug",
to: "/projects/secret-management/$projectId/secrets/$envSlug",
params: {
projectId: params.projectId,
envSlug: el.slug
@@ -56,7 +56,7 @@ export const Route = createFileRoute(
{
label: "Commits",
link: linkOptions({
to: "/projects/$projectId/secret-manager/commits/$environment/$folderId",
to: "/projects/secret-management/$projectId/commits/$environment/$folderId",
params: {
projectId: params.projectId,
environment: params.environment,
@@ -31,7 +31,7 @@ export const CommitsPage = () => {
const handleSelectCommit = (commitId: string) => {
navigate({
to: "/projects/$projectId/secret-manager/commits/$environment/$folderId/$commitId",
to: "/projects/secret-management/$projectId/commits/$environment/$folderId/$commitId",
params: {
projectId: currentWorkspace.id,
folderId,
@@ -52,7 +52,7 @@ export const CommitsPage = () => {
title="Commits"
description="Track, inspect, and restore your secrets and folders with confidence. View the complete history of changes made to your environment, examine specific modifications at each commit point, and preview the exact impact before rolling back to previous states."
/>
<NoticeBannerV2 title="" className="mb-2">
<NoticeBannerV2 title="Secret Snapshots Update" className="mb-2">
<p className="my-1 text-sm text-mineshaft-300">
Secret Snapshots have been officially renamed to Commits. Going forward, all secret
changes will be tracked as Commits. If you made changes before this update, you can
@@ -1,29 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import {
faArrowDownWideShort,
faArrowUpWideShort,
faCodeCommit,
faCopy,
faSearch
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { format, formatDistanceToNow } from "date-fns";
import { formatDistanceToNow } from "date-fns";
import { Button, Input, Spinner } from "@app/components/v2";
import { Button, ContentLoader, EmptyState, IconButton, Input } from "@app/components/v2";
import { CopyButton } from "@app/components/v2/CopyButton";
import { useGetFolderCommitHistory } from "@app/hooks/api/folderCommits";
interface CommitActorMetadata {
email?: string;
name?: string;
}
interface Commit {
id: string;
message: string;
createdAt: string;
actorType: string;
actorMetadata?: CommitActorMetadata;
}
import { Commit, useGetFolderCommitHistory } from "@app/hooks/api/folderCommits";
const formatTimeAgo = (timestamp: string): string => {
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
@@ -40,58 +28,40 @@ const CommitItem = ({
onSelectCommit: (commitId: string, tab: string) => void;
}) => {
return (
<div className="border-b border-zinc-800 last:border-b-0">
<div className="px-4 py-4 transition-colors duration-200 hover:bg-zinc-800">
<div className="flex flex-col sm:flex-row sm:justify-between">
<div className="w-5/6 flex-1">
<div className="flex items-center">
<Button
variant="link"
className="truncate text-left text-white hover:underline"
isFullWidth
onClick={(e) => {
e.stopPropagation();
onSelectCommit(commit.id, "tab-commit-details");
}}
>
{commit.message}
</Button>
</div>
<p className="text-white-400 mt-2 flex flex-wrap items-center gap-4 text-sm">
<span className="flex items-center text-mineshaft-300">
{commit.actorMetadata?.email || commit.actorMetadata?.name || commit.actorType}
<p className="ml-1 mr-1">committed</p>
<time dateTime={commit.createdAt}>{formatTimeAgo(commit.createdAt)}</time>
</span>
</p>
</div>
<div className="mt-2 flex w-1/6 items-center justify-end sm:mt-0">
<div className="flex items-center space-x-1">
<Button
variant="link"
className="text-white hover:underline"
onClick={(e) => {
e.stopPropagation();
onSelectCommit(commit.id, "tab-commit-details");
}}
>
<code className="text-white-400 px-3 py-1 font-mono text-sm">
{commit.id?.substring(0, 11)}
</code>
</Button>
<CopyButton
value={commit.id}
name={commit.id}
size="sm"
variant="plain"
color="text-mineshaft-400"
icon={faCopy}
/>
</div>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onSelectCommit(commit.id, "tab-commit-details");
}}
className="w-full border border-b-0 border-mineshaft-600 bg-mineshaft-800 first:rounded-t-md last:rounded-b-md last:border-b"
>
<div className="flex gap-2 px-4 py-3 transition-colors duration-200 hover:bg-zinc-800">
<div className="flex min-w-0 flex-1 flex-col items-start">
<p className="block w-full truncate text-left text-sm text-mineshaft-100">
{commit.message}
</p>
<p className="text-left text-xs text-mineshaft-300">
{commit.actorMetadata?.email || commit.actorMetadata?.name || commit.actorType}{" "}
committed <time dateTime={commit.createdAt}>{formatTimeAgo(commit.createdAt)}</time>
</p>
</div>
<div className="flex items-center space-x-2">
<code className="mt-0.5 font-mono text-xs text-mineshaft-400">
{commit.id?.substring(0, 11)}
</code>
<CopyButton
value={commit.id}
name={commit.id}
size="xs"
variant="plain"
color="text-mineshaft-400"
icon={faCopy}
/>
</div>
</div>
</div>
</button>
);
};
@@ -108,24 +78,16 @@ const DateGroup = ({
onSelectCommit: (commitId: string, tab: string) => void;
}) => {
return (
<div className="mb-8 last:mb-0 last:pb-2">
<div className="mb-4 flex items-center">
<div className="relative mr-3 flex h-6 w-6 items-center justify-center">
<div className="z-10 h-3 w-3 rounded-full border-2 border-mineshaft-600 bg-bunker-800" />
<div className="absolute left-0 right-0 top-1/2 h-0.5 -translate-y-1/2 bg-mineshaft-600" />
</div>
<h2 className="text-sm text-white">Commits on {date}</h2>
<div className="mt-4 first:mt-0">
<div className="mb-4 ml-[0.15rem] flex items-center">
<FontAwesomeIcon icon={faCodeCommit} className="text-mineshaft-400" />
<h2 className="ml-4 text-sm text-mineshaft-400">Commits on {date}</h2>
</div>
<div className="relative">
<div className="absolute bottom-0 left-3 top-0 w-0.5 bg-mineshaft-600" />
<div className="absolute bottom-0 left-3 top-0 w-[0.1rem] bg-mineshaft-500" />
<div className="ml-10">
{commits.map((commit) => (
<div key={commit.id} className="relative mb-3 pb-1">
<div className="overflow-hidden rounded-md border border-solid border-mineshaft-600">
<CommitItem commit={commit} onSelectCommit={onSelectCommit} />
</div>
</div>
<CommitItem key={commit.id} commit={commit} onSelectCommit={onSelectCommit} />
))}
</div>
</div>
@@ -147,10 +109,8 @@ export const CommitHistoryTab = ({
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState("");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
const [offset, setOffset] = useState(0);
const [allCommits, setAllCommits] = useState<Commit[]>([]);
const debounceTimeoutRef = useRef<NodeJS.Timeout>();
const limit = 5;
const limit = 10;
// Debounce search term
useEffect(() => {
@@ -170,55 +130,20 @@ export const CommitHistoryTab = ({
}, [searchTerm]);
const {
data: response,
data: groupedCommits,
isLoading,
isFetching
fetchNextPage,
isFetchingNextPage,
hasNextPage
} = useGetFolderCommitHistory({
workspaceId: projectId,
environment,
directory: secretPath,
offset,
limit,
search: debouncedSearchTerm,
sort: sortDirection
});
const commits = response?.commits || [];
const hasMore = response?.hasMore || false;
// Reset accumulated commits when search or sort changes
useEffect(() => {
setAllCommits([]);
setOffset(0);
}, [debouncedSearchTerm, sortDirection]);
// Accumulate commits instead of replacing them
useEffect(() => {
if (commits.length > 0) {
if (offset === 0) {
// First load or after search/sort change - replace all commits
setAllCommits(commits);
} else {
// Subsequent loads - append new commits
setAllCommits((prev) => [...prev, ...commits]);
}
}
}, [commits, offset]);
const groupedCommits = useMemo(() => {
return allCommits.reduce(
(acc, commit) => {
const date = format(new Date(commit.createdAt), "MMM d, yyyy");
if (!acc[date]) {
acc[date] = [];
}
acc[date].push(commit);
return acc;
},
{} as Record<string, Commit[]>
);
}, [allCommits]);
const handleSort = useCallback(() => {
setSortDirection((prev) => (prev === "desc" ? "asc" : "desc"));
}, []);
@@ -227,50 +152,39 @@ export const CommitHistoryTab = ({
setSearchTerm(value);
}, []);
const loadMoreCommits = useCallback(() => {
if (hasMore && !isFetching) {
setOffset((prev) => prev + limit);
}
}, [hasMore, isFetching, limit]);
return (
<div className="w-full">
<div className="mt-4 w-full rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<p className="mb-4 text-xl font-semibold text-mineshaft-100">Commit History</p>
<div className="mb-4 flex flex-col sm:flex-row sm:justify-end">
<div className="flex w-full flex-wrap items-center gap-2">
<div className="relative flex-grow">
<Input
leftIcon={<FontAwesomeIcon icon={faSearch} aria-hidden="true" />}
placeholder="Search commits..."
className="h-10 w-full rounded-md border-transparent bg-zinc-800 pl-9 pr-3 text-sm text-white placeholder-gray-400 focus:border-gray-600 focus:ring-primary-500/20"
onChange={(e) => handleSearch(e.target.value)}
value={searchTerm}
aria-label="Search commits"
/>
<div className="absolute left-3 top-1/2 -translate-y-1/2 transform text-gray-400">
<FontAwesomeIcon icon={faSearch} aria-hidden="true" />
</div>
</div>
<Button
<IconButton
variant="outline_bg"
size="md"
className="flex h-10 items-center justify-center gap-2 rounded-md bg-zinc-800 px-4 py-2 text-sm text-white transition-colors duration-200 hover:bg-zinc-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
size="sm"
className="flex h-[2.4rem] items-center justify-center gap-2 rounded-md"
onClick={handleSort}
aria-label={`Sort by date ${sortDirection === "desc" ? "ascending" : "descending"}`}
ariaLabel={`Sort by date ${sortDirection === "desc" ? "ascending" : "descending"}`}
>
<FontAwesomeIcon
icon={sortDirection === "desc" ? faArrowDownWideShort : faArrowUpWideShort}
aria-hidden="true"
/>
</Button>
</IconButton>
</div>
</div>
{isLoading && offset === 0 ? (
<div className="flex h-64 items-center justify-center">
<Spinner size="lg" aria-label="Loading commits" />
</div>
{isLoading ? (
<ContentLoader className="h-80" />
) : (
<div className="space-y-8">
{Object.keys(groupedCommits).length > 0 ? (
<div>
{groupedCommits && Object.keys(groupedCommits).length > 0 ? (
<>
{Object.entries(groupedCommits).map(([date, dateCommits]) => (
<DateGroup
@@ -282,34 +196,20 @@ export const CommitHistoryTab = ({
))}
</>
) : (
<div className="text-white-400 flex min-h-40 flex-col items-center justify-center rounded-lg bg-zinc-900 py-8 text-center">
<FontAwesomeIcon
icon={faSearch}
className="text-white-500 mb-3 text-3xl"
aria-hidden="true"
/>
<p>No matching commits found. Try a different search term.</p>
</div>
<EmptyState title="No commits found." icon={faCodeCommit} />
)}
{hasMore && (
{hasNextPage && (
<div className="flex justify-center pb-2">
<Button
variant="outline_bg"
size="md"
className="rounded-md bg-zinc-900 px-6 py-2 text-sm font-medium text-white transition-colors duration-200 hover:bg-zinc-800 focus:outline-none focus:ring-2 focus:ring-primary-500"
onClick={loadMoreCommits}
disabled={isFetching}
size="sm"
className="ml-10 mt-4 w-full"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
isLoading={isFetchingNextPage}
aria-label="Load more commits"
>
{isFetching ? (
<>
<Spinner size="sm" className="mr-2" />
Loading...
</>
) : (
"Load more commits"
)}
Load More Commits
</Button>
</div>
)}
@@ -13,7 +13,7 @@ const CommitsPageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/commits/$environment/$folderId/"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/commits/$environment/$folderId/"
)({
component: CommitsPage,
validateSearch: zodValidator(CommitsPageQueryParamsSchema),
@@ -34,7 +34,7 @@ export const Route = createFileRoute(
links: context.project.environments.map((el) => ({
label: el.name,
link: linkOptions({
to: "/projects/$projectId/secret-manager/secrets/$envSlug",
to: "/projects/secret-management/$projectId/secrets/$envSlug",
params: {
projectId: params.projectId,
envSlug: el.slug
@@ -57,7 +57,7 @@ export const Route = createFileRoute(
{
label: "Commits",
link: linkOptions({
to: "/projects/$projectId/secret-manager/commits/$environment/$folderId",
to: "/projects/secret-management/$projectId/commits/$environment/$folderId",
params: {
projectId: params.projectId,
environment: params.environment,
@@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { IPAllowListPage } from "./IPAllowlistPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/allowlist"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/allowlist"
)({
component: () => IPAllowListPage
});
@@ -5,7 +5,7 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
import { IntegrationDetailsByIDPage } from "./IntegrationsDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/$integrationId"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/$integrationId"
)({
component: IntegrationDetailsByIDPage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -42,7 +42,7 @@ export const redirectForProviderAuth = (
switch (integrationOption.slug) {
case "gcp-secret-manager":
navigate({
to: "/projects/$projectId/secret-manager/integrations/gcp-secret-manager/authorize",
to: "/projects/secret-management/$projectId/integrations/gcp-secret-manager/authorize",
params: {
projectId
}
@@ -54,7 +54,7 @@ export const redirectForProviderAuth = (
return;
}
navigate({
to: "/projects/$projectId/secret-manager/integrations/azure-key-vault/authorize",
to: "/projects/secret-management/$projectId/integrations/azure-key-vault/authorize",
params: {
projectId
},
@@ -76,7 +76,7 @@ export const redirectForProviderAuth = (
}
case "aws-parameter-store":
navigate({
to: "/projects/$projectId/secret-manager/integrations/aws-parameter-store/authorize",
to: "/projects/secret-management/$projectId/integrations/aws-parameter-store/authorize",
params: {
projectId
}
@@ -84,7 +84,7 @@ export const redirectForProviderAuth = (
break;
case "aws-secret-manager":
navigate({
to: "/projects/$projectId/secret-manager/integrations/aws-secret-manager/authorize",
to: "/projects/secret-management/$projectId/integrations/aws-secret-manager/authorize",
params: {
projectId
}
@@ -120,7 +120,7 @@ export const redirectForProviderAuth = (
}
case "github":
navigate({
to: "/projects/$projectId/secret-manager/integrations/github/auth-mode-selection",
to: "/projects/secret-management/$projectId/integrations/github/auth-mode-selection",
params: {
projectId
}
@@ -128,7 +128,7 @@ export const redirectForProviderAuth = (
break;
case "gitlab":
navigate({
to: "/projects/$projectId/secret-manager/integrations/gitlab/authorize",
to: "/projects/secret-management/$projectId/integrations/gitlab/authorize",
params: {
projectId
}
@@ -136,7 +136,7 @@ export const redirectForProviderAuth = (
break;
case "render":
navigate({
to: "/projects/$projectId/secret-manager/integrations/render/authorize",
to: "/projects/secret-management/$projectId/integrations/render/authorize",
params: {
projectId
}
@@ -144,7 +144,7 @@ export const redirectForProviderAuth = (
break;
case "flyio":
navigate({
to: "/projects/$projectId/secret-manager/integrations/flyio/authorize",
to: "/projects/secret-management/$projectId/integrations/flyio/authorize",
params: {
projectId
}
@@ -152,7 +152,7 @@ export const redirectForProviderAuth = (
break;
case "circleci":
navigate({
to: "/projects/$projectId/secret-manager/integrations/circleci/authorize",
to: "/projects/secret-management/$projectId/integrations/circleci/authorize",
params: {
projectId
}
@@ -160,7 +160,7 @@ export const redirectForProviderAuth = (
break;
case "databricks":
navigate({
to: "/projects/$projectId/secret-manager/integrations/databricks/authorize",
to: "/projects/secret-management/$projectId/integrations/databricks/authorize",
params: {
projectId
}
@@ -168,7 +168,7 @@ export const redirectForProviderAuth = (
break;
case "laravel-forge":
navigate({
to: "/projects/$projectId/secret-manager/integrations/laravel-forge/authorize",
to: "/projects/secret-management/$projectId/integrations/laravel-forge/authorize",
params: {
projectId
}
@@ -176,7 +176,7 @@ export const redirectForProviderAuth = (
break;
case "travisci":
navigate({
to: "/projects/$projectId/secret-manager/integrations/travisci/authorize",
to: "/projects/secret-management/$projectId/integrations/travisci/authorize",
params: {
projectId
}
@@ -184,7 +184,7 @@ export const redirectForProviderAuth = (
break;
case "supabase":
navigate({
to: "/projects/$projectId/secret-manager/integrations/supabase/authorize",
to: "/projects/secret-management/$projectId/integrations/supabase/authorize",
params: {
projectId
}
@@ -192,7 +192,7 @@ export const redirectForProviderAuth = (
break;
case "checkly":
navigate({
to: "/projects/$projectId/secret-manager/integrations/checkly/authorize",
to: "/projects/secret-management/$projectId/integrations/checkly/authorize",
params: {
projectId
}
@@ -200,7 +200,7 @@ export const redirectForProviderAuth = (
break;
case "qovery":
navigate({
to: "/projects/$projectId/secret-manager/integrations/qovery/authorize",
to: "/projects/secret-management/$projectId/integrations/qovery/authorize",
params: {
projectId
}
@@ -208,7 +208,7 @@ export const redirectForProviderAuth = (
break;
case "railway":
navigate({
to: "/projects/$projectId/secret-manager/integrations/railway/authorize",
to: "/projects/secret-management/$projectId/integrations/railway/authorize",
params: {
projectId
}
@@ -216,7 +216,7 @@ export const redirectForProviderAuth = (
break;
case "terraform-cloud":
navigate({
to: "/projects/$projectId/secret-manager/integrations/terraform-cloud/authorize",
to: "/projects/secret-management/$projectId/integrations/terraform-cloud/authorize",
params: {
projectId
}
@@ -224,7 +224,7 @@ export const redirectForProviderAuth = (
break;
case "hashicorp-vault":
navigate({
to: "/projects/$projectId/secret-manager/integrations/hashicorp-vault/authorize",
to: "/projects/secret-management/$projectId/integrations/hashicorp-vault/authorize",
params: {
projectId
}
@@ -232,7 +232,7 @@ export const redirectForProviderAuth = (
break;
case "cloudflare-pages":
navigate({
to: "/projects/$projectId/secret-manager/integrations/cloudflare-pages/authorize",
to: "/projects/secret-management/$projectId/integrations/cloudflare-pages/authorize",
params: {
projectId
}
@@ -240,7 +240,7 @@ export const redirectForProviderAuth = (
break;
case "cloudflare-workers":
navigate({
to: "/projects/$projectId/secret-manager/integrations/cloudflare-workers/authorize",
to: "/projects/secret-management/$projectId/integrations/cloudflare-workers/authorize",
params: {
projectId
}
@@ -257,7 +257,7 @@ export const redirectForProviderAuth = (
}
case "codefresh":
navigate({
to: "/projects/$projectId/secret-manager/integrations/codefresh/authorize",
to: "/projects/secret-management/$projectId/integrations/codefresh/authorize",
params: {
projectId
}
@@ -265,7 +265,7 @@ export const redirectForProviderAuth = (
break;
case "digital-ocean-app-platform":
navigate({
to: "/projects/$projectId/secret-manager/integrations/digital-ocean-app-platform/authorize",
to: "/projects/secret-management/$projectId/integrations/digital-ocean-app-platform/authorize",
params: {
projectId
}
@@ -273,7 +273,7 @@ export const redirectForProviderAuth = (
break;
case "cloud-66":
navigate({
to: "/projects/$projectId/secret-manager/integrations/cloud-66/authorize",
to: "/projects/secret-management/$projectId/integrations/cloud-66/authorize",
params: {
projectId
}
@@ -281,7 +281,7 @@ export const redirectForProviderAuth = (
break;
case "northflank":
navigate({
to: "/projects/$projectId/secret-manager/integrations/northflank/authorize",
to: "/projects/secret-management/$projectId/integrations/northflank/authorize",
params: {
projectId
}
@@ -289,7 +289,7 @@ export const redirectForProviderAuth = (
break;
case "windmill":
navigate({
to: "/projects/$projectId/secret-manager/integrations/windmill/authorize",
to: "/projects/secret-management/$projectId/integrations/windmill/authorize",
params: {
projectId
}
@@ -297,7 +297,7 @@ export const redirectForProviderAuth = (
break;
case "teamcity":
navigate({
to: "/projects/$projectId/secret-manager/integrations/teamcity/authorize",
to: "/projects/secret-management/$projectId/integrations/teamcity/authorize",
params: {
projectId
}
@@ -305,7 +305,7 @@ export const redirectForProviderAuth = (
break;
case "hasura-cloud":
navigate({
to: "/projects/$projectId/secret-manager/integrations/hasura-cloud/authorize",
to: "/projects/secret-management/$projectId/integrations/hasura-cloud/authorize",
params: {
projectId
}
@@ -313,7 +313,7 @@ export const redirectForProviderAuth = (
break;
case "rundeck":
navigate({
to: "/projects/$projectId/secret-manager/integrations/rundeck/authorize",
to: "/projects/secret-management/$projectId/integrations/rundeck/authorize",
params: {
projectId
}
@@ -321,7 +321,7 @@ export const redirectForProviderAuth = (
break;
case "azure-devops":
navigate({
to: "/projects/$projectId/secret-manager/integrations/azure-devops/authorize",
to: "/projects/secret-management/$projectId/integrations/azure-devops/authorize",
params: {
projectId
}
@@ -329,7 +329,7 @@ export const redirectForProviderAuth = (
break;
case "octopus-deploy":
navigate({
to: "/projects/$projectId/secret-manager/integrations/octopus-deploy/authorize",
to: "/projects/secret-management/$projectId/integrations/octopus-deploy/authorize",
params: {
projectId
}
@@ -59,7 +59,7 @@ export const IntegrationRow = ({
<Tr
onClick={() =>
navigate({
to: "/projects/$projectId/secret-manager/integrations/$integrationId",
to: "/projects/secret-management/$projectId/integrations/$integrationId",
params: {
integrationId: integration.id,
projectId: currentWorkspace.id
@@ -0,0 +1,14 @@
import { TBitbucketSync } from "@app/hooks/api/secretSyncs/types/bitbucket-sync";
import { getSecretSyncDestinationColValues } from "../helpers";
import { SecretSyncTableCell } from "../SecretSyncTableCell";
type Props = {
secretSync: TBitbucketSync;
};
export const BitbucketSyncDestinationCol = ({ secretSync }: Props) => {
const { primaryText, secondaryText } = getSecretSyncDestinationColValues(secretSync);
return <SecretSyncTableCell primaryText={primaryText} secondaryText={secondaryText} />;
};
@@ -6,6 +6,7 @@ import { AwsSecretsManagerSyncDestinationCol } from "./AwsSecretsManagerSyncDest
import { AzureAppConfigurationDestinationSyncCol } from "./AzureAppConfigurationDestinationSyncCol";
import { AzureDevOpsSyncDestinationCol } from "./AzureDevOpsSyncDestinationCol";
import { AzureKeyVaultDestinationSyncCol } from "./AzureKeyVaultDestinationSyncCol";
import { BitbucketSyncDestinationCol } from "./BitbucketSyncDestinationCol";
import { CamundaSyncDestinationCol } from "./CamundaSyncDestinationCol";
import { ChecklySyncDestinationCol } from "./ChecklySyncDestinationCol";
import { CloudflarePagesSyncDestinationCol } from "./CloudflarePagesSyncDestinationCol";
@@ -88,6 +89,8 @@ export const SecretSyncDestinationCol = ({ secretSync }: Props) => {
return <ChecklySyncDestinationCol secretSync={secretSync} />;
case SecretSync.Supabase:
return <SupabaseSyncDestinationCol secretSync={secretSync} />;
case SecretSync.Bitbucket:
return <BitbucketSyncDestinationCol secretSync={secretSync} />;
default:
throw new Error(
`Unhandled Secret Sync Destination Col: ${(secretSync as TSecretSync).destination}`
@@ -174,6 +174,10 @@ export const getSecretSyncDestinationColValues = (secretSync: TSecretSync) => {
primaryText = destinationConfig.projectName;
secondaryText = "Supabase Project";
break;
case SecretSync.Bitbucket:
primaryText = destinationConfig.workspaceSlug;
secondaryText = destinationConfig.repositorySlug;
break;
default:
throw new Error(`Unhandled Destination Col Values ${destination}`);
}
@@ -21,7 +21,7 @@ const IntegrationsListPageQuerySchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/"
)({
component: IntegrationsListPage,
validateSearch: zodValidator(IntegrationsListPageQuerySchema),
@@ -36,7 +36,7 @@ export const Route = createFileRoute(
});
} catch {
throw redirect({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId
},
@@ -46,7 +46,7 @@ export const Route = createFileRoute(
if (secretSyncs.length) {
throw redirect({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId
},
@@ -62,7 +62,7 @@ export const Route = createFileRoute(
});
} catch {
throw redirect({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId
},
@@ -72,7 +72,7 @@ export const Route = createFileRoute(
if (integrations.length) {
throw redirect({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId
},
@@ -81,7 +81,7 @@ export const Route = createFileRoute(
}
throw redirect({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId
},
@@ -667,7 +667,7 @@ export const OverviewPage = () => {
const envIndex = visibleEnvs.findIndex((el) => slug === el.slug);
if (envIndex !== -1) {
navigate({
to: "/projects/$projectId/secret-manager/secrets/$envSlug",
to: "/projects/secret-management/$projectId/secrets/$envSlug",
params: {
projectId: workspaceId,
envSlug: slug
@@ -1420,7 +1420,7 @@ export const OverviewPage = () => {
iconSize="3x"
>
<Link
to="/projects/$projectId/secret-manager/settings"
to="/projects/secret-management/$projectId/settings"
params={{
projectId: workspaceId
}}
@@ -9,7 +9,7 @@ type Props = {
export const FolderBreadCrumbs = ({ secretPath = "/", onResetSearch }: Props) => {
const navigate = useNavigate({
from: "/projects/$projectId/secret-manager/overview"
from: "/projects/secret-management/$projectId/overview"
});
const onFolderCrumbClick = (index: number) => {
@@ -17,7 +17,7 @@ export const QuickSearchDynamicSecretItem = ({
onClose
}: Props) => {
const navigate = useNavigate({
from: "/projects/$projectId/secret-manager/overview"
from: "/projects/secret-management/$projectId/overview"
});
const [groupDynamicSecret] = dynamicSecretGroup;
@@ -13,7 +13,7 @@ type Props = {
export const QuickSearchFolderItem = ({ folderGroup, onClose }: Props) => {
const navigate = useNavigate({
from: "/projects/$projectId/secret-manager/overview"
from: "/projects/secret-management/$projectId/overview"
});
const [groupFolder] = folderGroup;
@@ -46,7 +46,7 @@ export const QuickSearchSecretItem = ({
isSingleEnv,
search
}: Props) => {
const navigate = useNavigate({ from: "/projects/$projectId/secret-manager/overview" });
const navigate = useNavigate({ from: "/projects/secret-management/$projectId/overview" });
const envSlugMap = new Map(environments.map((env) => [env.slug, env]));
const [isUrlCopied, , setIsUrlCopied] = useTimedReset<boolean>({
initialState: false
@@ -13,7 +13,7 @@ type Props = {
export const QuickSearchSecretRotationItem = ({ secretRotationGroup, onClose }: Props) => {
const navigate = useNavigate({
from: "/projects/$projectId/secret-manager/overview"
from: "/projects/secret-management/$projectId/overview"
});
const [groupSecretRotation] = secretRotationGroup;
@@ -1,6 +1,6 @@
export const getExpandedRowStyle = (scrollOffset: number) => ({
marginLeft: scrollOffset,
width: "calc(100vw - 355px)", // 350px accounts for sidebar and margin
width: "calc(100vw - 275px)", // accounts for sidebar and margin
maxWidth: "1270px" // largest width of table on ultra-wide
});
@@ -10,7 +10,7 @@ const SecretOverviewPageQuerySchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/overview"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/overview"
)({
component: OverviewPage,
validateSearch: zodValidator(SecretOverviewPageQuerySchema),
@@ -24,7 +24,7 @@ export const Route = createFileRoute(
{
label: "Secrets",
link: linkOptions({
to: "/projects/$projectId/secret-manager/overview",
to: "/projects/secret-management/$projectId/overview",
params
})
}
@@ -156,11 +156,11 @@ export const SecretApprovalRequestChangeItem = ({
</div>
<div className="mb-2">
<div className="text-sm font-medium text-mineshaft-300">Tags</div>
<div className="flex flex-wrap gap-2">
<div className="flex flex-wrap gap-y-2">
{(secretVersion?.tags?.length ?? 0) ? (
secretVersion?.tags?.map(({ slug, id: tagId, color }) => (
<Tag
className="flex w-min items-center space-x-2"
className="flex w-min items-center space-x-1.5 border border-mineshaft-500 bg-mineshaft-800"
key={`${secretVersion.id}-${tagId}`}
>
<div
@@ -72,7 +72,7 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica
if (score[CommitType.DELETE])
text.push(
<span className="deleted-commit">
{Boolean(text.length) && "and"}
{Boolean(text.length) && " and "}
{score[CommitType.DELETE]} Secret{score[CommitType.DELETE] !== 1 && "s"}
<span className="text-red-600"> Deleted</span>
</span>
@@ -9,7 +9,7 @@ const SecretApprovalPageQueryParams = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/approval"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/approval"
)({
component: SecretApprovalsPage,
validateSearch: zodValidator(SecretApprovalPageQueryParams),
@@ -1,3 +1,4 @@
/* eslint-disable no-case-declarations */
import { useCallback, useEffect, useMemo, useState } from "react";
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
@@ -48,6 +49,9 @@ import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard";
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
import { useGetFolderCommitsCount } from "@app/hooks/api/folderCommits";
import { OrderByDirection } from "@app/hooks/api/generic/types";
import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { useCreateCommit } from "@app/hooks/api/secrets/mutations";
import { SecretV3RawSanitized } from "@app/hooks/api/types";
import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
import { RequestAccessModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal";
@@ -56,6 +60,7 @@ import { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboar
import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount";
import { SecretV2MigrationSection } from "../OverviewPage/components/SecretV2MigrationSection";
import { ActionBar } from "./components/ActionBar";
import { CommitForm } from "./components/CommitForm";
import { CreateSecretForm } from "./components/CreateSecretForm";
import { DynamicSecretListView } from "./components/DynamicSecretListView";
import { FolderListView } from "./components/FolderListView";
@@ -65,8 +70,11 @@ import { SecretImportListView } from "./components/SecretImportListView";
import { SecretListView, SecretNoAccessListView } from "./components/SecretListView";
import { SnapshotView } from "./components/SnapshotView";
import {
PendingChanges,
PopUpNames,
StoreProvider,
useBatchMode,
useBatchModeActions,
usePopUpAction,
usePopUpState,
useSelectedSecretActions,
@@ -94,8 +102,11 @@ const Page = () => {
});
const { permission } = useProjectPermission();
const { mutateAsync: createCommit } = useCreateCommit();
const [isVisible, setIsVisible] = useState(false);
const { isBatchMode, pendingChanges } = useBatchMode();
const { loadPendingChanges, setExistingKeys } = useBatchModeActions();
const {
offset,
@@ -128,6 +139,12 @@ const Page = () => {
const projectSlug = currentWorkspace?.slug || "";
const secretPath = (routerQueryParams.secretPath as string) || "/";
useEffect(() => {
if (isBatchMode && workspaceId && environment && secretPath) {
loadPendingChanges({ workspaceId, environment, secretPath });
}
}, [isBatchMode, workspaceId, environment, secretPath, loadPendingChanges]);
const canReadSecret = hasSecretReadValueOrDescribePermission(
permission,
ProjectPermissionSecretActions.DescribeSecret,
@@ -217,7 +234,7 @@ const Page = () => {
type: "error"
});
navigate({
to: "/projects/$projectId/secret-manager/overview",
to: "/projects/secret-management/$projectId/overview",
params: {
projectId: workspaceId
}
@@ -293,6 +310,30 @@ const Page = () => {
});
const isProtectedBranch = Boolean(boardPolicy);
const handleCreateCommit = async (changes: PendingChanges, message: string) => {
try {
await createCommit({
workspaceId,
environment,
secretPath,
pendingChanges: changes,
message
});
createNotification({
text: isProtectedBranch
? "Requested changes have been sent for review"
: "Changes committed successfully",
type: "success"
});
} catch (error) {
createNotification({
text: "Failed to commit changes",
type: "error"
});
console.error(error);
}
};
const {
data: snapshotList,
isFetchingNextPage: isFetchingNextSnapshotList,
@@ -345,7 +386,7 @@ const Page = () => {
const handleOnClickRollbackMode = () => {
if (isPITEnabled) {
navigate({
to: "/projects/$projectId/secret-manager/commits/$environment/$folderId",
to: "/projects/secret-management/$projectId/commits/$environment/$folderId",
params: {
projectId: workspaceId,
folderId,
@@ -376,9 +417,21 @@ const Page = () => {
imports?.length ||
dynamicSecrets?.length ||
secretRotations?.length ||
noAccessSecretCount
noAccessSecretCount ||
pendingChanges.secrets.length ||
pendingChanges.folders.length
);
useEffect(() => {
if (data && isBatchMode) {
const existingSecretKeys = [...(secrets?.map((s) => s.key) || [])];
const existingFolderNames = folders?.map((f) => f.name) || [];
setExistingKeys(existingSecretKeys, existingFolderNames);
}
}, [data, isBatchMode, setExistingKeys, secrets, importedSecrets, folders]);
const handleSortToggle = () =>
setOrderDirection((state) =>
state === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
@@ -506,6 +559,143 @@ const Page = () => {
setDebouncedSearchFilter("");
};
const getMergedSecretsWithPending = () => {
if (!isBatchMode || pendingChanges.secrets.length === 0) {
return secrets;
}
const mergedSecrets = [...(secrets || [])];
pendingChanges.secrets.forEach((change) => {
switch (change.type) {
case PendingAction.Create:
mergedSecrets.unshift({
id: change.id,
key: change.secretKey,
value: change.secretValue,
comment: change.secretComment || "",
skipMultilineEncoding: change.skipMultilineEncoding || false,
tags: change.tags || [],
secretMetadata: change.secretMetadata || [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
version: 1,
isPending: true,
pendingAction: PendingAction.Create
} as unknown as SecretV3RawSanitized);
break;
case PendingAction.Update:
const updateIndex = mergedSecrets.findIndex((s) => s.key === change.secretKey);
if (updateIndex >= 0) {
mergedSecrets[updateIndex] = {
...mergedSecrets[updateIndex],
key: change.newSecretName || change.secretKey,
value:
change.secretValue !== undefined
? change.secretValue
: mergedSecrets[updateIndex].value,
comment:
change.secretComment !== undefined
? change.secretComment
: mergedSecrets[updateIndex].comment,
skipMultilineEncoding:
change.skipMultilineEncoding !== undefined
? change.skipMultilineEncoding
: mergedSecrets[updateIndex].skipMultilineEncoding,
secretMetadata: change.secretMetadata || mergedSecrets[updateIndex].secretMetadata,
isPending: true,
pendingAction: PendingAction.Update,
tags: change.tags
? change.tags?.map((tag) => ({
id: tag.id,
slug: tag.slug,
projectId: workspaceId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
__v: 0
})) || []
: mergedSecrets[updateIndex].tags
};
}
break;
case PendingAction.Delete:
const deleteIndex = mergedSecrets.findIndex((s) => s.key === change.secretKey);
if (deleteIndex >= 0) {
mergedSecrets[deleteIndex] = {
...mergedSecrets[deleteIndex],
isPending: true,
pendingAction: PendingAction.Delete
};
}
break;
default:
break;
}
});
return mergedSecrets;
};
const getMergedFoldersWithPending = () => {
if (!isBatchMode || pendingChanges.folders.length === 0) {
return folders;
}
const mergedFolders = [...(folders || [])];
pendingChanges.folders.forEach((change) => {
switch (change.type) {
case PendingAction.Create:
mergedFolders.unshift({
id: change.id,
name: change.folderName,
description: change.description,
parentId: null,
isPending: true,
pendingAction: PendingAction.Create
} as any);
break;
case PendingAction.Update:
const updateIndex = mergedFolders.findIndex((f) => f.id === change.id);
if (updateIndex >= 0) {
mergedFolders[updateIndex] = {
...mergedFolders[updateIndex],
name: change.folderName,
description:
change.description !== undefined
? change.description
: mergedFolders[updateIndex].description,
isPending: true,
pendingAction: PendingAction.Update
};
}
break;
case PendingAction.Delete:
const deleteIndex = mergedFolders.findIndex((f) => f.id === change.id);
if (deleteIndex >= 0) {
mergedFolders[deleteIndex] = {
...mergedFolders[deleteIndex],
isPending: true,
pendingAction: PendingAction.Delete
};
}
break;
default:
break;
}
});
return mergedFolders;
};
const mergedSecrets = getMergedSecretsWithPending();
const mergedFolders = getMergedFoldersWithPending();
return (
<div className="container mx-auto flex max-w-7xl flex-col text-mineshaft-50 dark:[color-scheme:dark]">
<PageHeader
@@ -563,6 +753,7 @@ const Page = () => {
projectSlug={projectSlug}
secretPath={secretPath}
isVisible={isVisible}
isBatchMode={isBatchMode}
filter={filter}
tags={tags}
onVisibilityToggle={handleToggleVisibility}
@@ -698,9 +889,9 @@ const Page = () => {
importedSecrets={importedSecrets}
/>
)}
{Boolean(folders?.length) && (
{Boolean(mergedFolders?.length) && (
<FolderListView
folders={folders}
folders={mergedFolders}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
@@ -718,9 +909,9 @@ const Page = () => {
{canReadSecretRotations && Boolean(secretRotations?.length) && (
<SecretRotationListView secretRotations={secretRotations} />
)}
{canReadSecret && Boolean(secrets?.length) && (
{canReadSecret && Boolean(mergedSecrets?.length) && (
<SecretListView
secrets={secrets}
secrets={mergedSecrets}
tags={tags}
isVisible={isVisible}
environment={environment}
@@ -731,6 +922,15 @@ const Page = () => {
usedBySecretSyncs={usedBySecretSyncs}
/>
)}
{(pendingChanges.secrets.length > 0 || pendingChanges.folders.length > 0) && (
<CommitForm
onCommit={handleCreateCommit}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
isCommitting={false}
/>
)}
{noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />}
{!canReadSecret &&
!canReadDynamicSecret &&
@@ -738,25 +938,34 @@ const Page = () => {
folders?.length === 0 && <PermissionDeniedBanner />}
</div>
</div>
{!isDetailsLoading && totalCount > 0 && (
<Pagination
startAdornment={
<SecretTableResourceCount
dynamicSecretCount={totalDynamicSecretCount}
importCount={totalImportCount}
secretCount={totalSecretCount}
folderCount={totalFolderCount}
secretRotationCount={totalSecretRotationCount}
/>
}
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{!isDetailsLoading &&
(totalCount > 0 ||
pendingChanges.secrets.length > 0 ||
pendingChanges.folders.length > 0) && (
<Pagination
startAdornment={
<SecretTableResourceCount
dynamicSecretCount={totalDynamicSecretCount}
importCount={totalImportCount}
secretCount={
totalSecretCount +
pendingChanges.secrets.filter((s) => s.type === PendingAction.Create).length
}
folderCount={
totalFolderCount +
pendingChanges.folders.filter((f) => f.type === PendingAction.Create).length
}
secretRotationCount={totalSecretRotationCount}
/>
}
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
count={totalCount + pendingChanges.secrets.length + pendingChanges.folders.length}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
<Modal
isOpen={createSecretPopUp.isOpen}
onOpenChange={(state) => togglePopUp(PopUpNames.CreateSecretForm, state)}
@@ -772,6 +981,7 @@ const Page = () => {
secretPath={secretPath}
autoCapitalize={currentWorkspace?.autoCapitalization}
isProtectedBranch={isProtectedBranch}
isBatchMode={isBatchMode}
/>
</ModalContent>
</Modal>
@@ -1,13 +1,218 @@
/* eslint-disable no-nested-ternary */
import { createContext, ReactNode, useContext, useEffect, useRef } from "react";
import { useRouter } from "@tanstack/react-router";
import { createStore, StateCreator, StoreApi, useStore } from "zustand";
import { useShallow } from "zustand/react/shallow";
import { createNotification } from "@app/components/notifications";
import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
// akhilmhdh: Don't remove this file if ur thinking why use zustand just for selected selects state
// This is first step and the whole secret crud will be moved to this global page scope state
// this will allow more stuff like undo grouping stuffs etc
// Base interface for all pending changes
export interface BasePendingChange {
id: string;
timestamp: number;
}
// Secret-related change types
export interface PendingSecretCreate extends BasePendingChange {
resourceType: "secret";
type: PendingAction.Create;
secretKey: string;
secretValue: string;
secretComment?: string;
skipMultilineEncoding?: boolean;
tags?: { id: string; slug: string }[];
secretMetadata?: { key: string; value: string }[];
originalKey?: string;
}
export interface PendingSecretUpdate extends BasePendingChange {
resourceType: "secret";
type: PendingAction.Update;
secretKey: string;
newSecretName?: string;
originalValue?: string;
secretValue?: string;
originalComment?: string;
secretComment?: string;
originalSkipMultilineEncoding?: boolean;
skipMultilineEncoding?: boolean;
originalTags?: { id: string; slug: string }[];
tags?: { id: string; slug: string }[];
originalSecretMetadata?: { key: string; value: string }[];
secretMetadata?: { key: string; value: string }[];
existingSecret: SecretV3RawSanitized;
}
export interface PendingSecretDelete extends BasePendingChange {
resourceType: "secret";
type: PendingAction.Delete;
secretKey: string;
secretValue: string;
}
// Folder-related change types
export interface PendingFolderCreate extends BasePendingChange {
resourceType: "folder";
type: PendingAction.Create;
id: string;
folderName: string;
description?: string;
parentPath: string;
}
export interface PendingFolderUpdate extends BasePendingChange {
resourceType: "folder";
type: PendingAction.Update;
originalFolderName: string;
folderName: string;
id: string;
originalDescription?: string;
description?: string;
}
export interface PendingFolderDelete extends BasePendingChange {
resourceType: "folder";
type: PendingAction.Delete;
id: string;
folderName: string;
folderPath: string;
}
// Union types for each resource
export type PendingSecretChange = PendingSecretCreate | PendingSecretUpdate | PendingSecretDelete;
export type PendingFolderChange = PendingFolderCreate | PendingFolderUpdate | PendingFolderDelete;
export type PendingChange = PendingSecretChange | PendingFolderChange;
// Grouped changes for better processing
export interface PendingChanges {
secrets: PendingSecretChange[];
folders: PendingFolderChange[];
}
// Context interface for batch operations
export interface BatchContext {
workspaceId: string;
environment: string;
secretPath: string;
}
const normalizeValue = (value: any): string | boolean | undefined => {
if (value === null || value === undefined || value === "") {
return undefined;
}
return value;
};
const areValuesEqual = (value1: any, value2: any): boolean => {
const normalized1 = normalizeValue(value1);
const normalized2 = normalizeValue(value2);
if (normalized1 === undefined && normalized2 === undefined) {
return true;
}
return normalized1 === normalized2;
};
const areArraysEqual = (arr1: any[] | undefined, arr2: any[] | undefined): boolean => {
// Handle undefined/null arrays
if (!arr1 && !arr2) return true;
if (!arr1 || !arr2) return false;
// Compare lengths
if (arr1.length !== arr2.length) return false;
// Deep comparison using JSON stringify (for simple objects)
return JSON.stringify(arr1.sort()) === JSON.stringify(arr2.sort());
};
const cleanupRevertedSecretFields = (update: PendingSecretUpdate): PendingSecretUpdate | null => {
const cleaned = { ...update };
let hasChanges = false;
if (
cleaned.secretValue !== undefined &&
!areValuesEqual(cleaned.secretValue, cleaned.originalValue)
) {
hasChanges = true;
} else {
cleaned.secretValue = undefined;
}
if (
cleaned.secretComment !== undefined &&
(!areValuesEqual(cleaned.secretComment, cleaned.originalComment) ||
!areValuesEqual(cleaned.secretComment, cleaned.existingSecret.comment))
) {
hasChanges = true;
} else {
cleaned.secretComment = undefined;
}
if (
cleaned.skipMultilineEncoding !== undefined &&
cleaned.skipMultilineEncoding !== cleaned.originalSkipMultilineEncoding
) {
hasChanges = true;
} else {
cleaned.skipMultilineEncoding = undefined;
}
if (cleaned.tags !== undefined && !areArraysEqual(cleaned.tags, cleaned.originalTags)) {
hasChanges = true;
} else {
cleaned.tags = undefined;
}
if (
cleaned.secretMetadata !== undefined &&
!areArraysEqual(cleaned.secretMetadata, cleaned.originalSecretMetadata)
) {
hasChanges = true;
} else {
cleaned.secretMetadata = undefined;
}
if (cleaned.newSecretName !== undefined && cleaned.newSecretName !== cleaned.secretKey) {
hasChanges = true;
} else {
cleaned.newSecretName = undefined;
}
// If no changes remain, return null to indicate this update should be removed
return hasChanges ? cleaned : null;
};
const cleanupRevertedFolderFields = (update: PendingFolderUpdate): PendingFolderUpdate | null => {
const cleaned = { ...update };
let hasChanges = false;
if (
cleaned.folderName !== undefined &&
!areValuesEqual(cleaned.folderName, cleaned.originalFolderName)
) {
hasChanges = true;
}
if (
cleaned.description !== undefined &&
!areValuesEqual(cleaned.description, cleaned.originalDescription)
) {
hasChanges = true;
} else {
cleaned.description = undefined;
}
// If no changes remain, return null to indicate this update should be removed
return hasChanges ? cleaned : null;
};
type SelectedSecretState = {
selectedSecret: Record<string, SecretV3RawSanitized>;
action: {
@@ -16,14 +221,16 @@ type SelectedSecretState = {
set: (secrets: Record<string, SecretV3RawSanitized>) => void;
};
};
const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
const createSelectedSecretStore: StateCreator<CombinedState, [], [], SelectedSecretState> = (
set
) => ({
selectedSecret: {},
action: {
toggle: (secret) =>
set((state) => {
const isChecked = Boolean(state.selectedSecret?.[secret.id]);
const newChecks = { ...state.selectedSecret };
// remove selection if its present else add it
if (isChecked) delete newChecks[secret.id];
else newChecks[secret.id] = secret;
return { selectedSecret: newChecks };
@@ -33,6 +240,432 @@ const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
}
});
type BatchModeState = {
isBatchMode: boolean;
pendingChanges: PendingChanges;
pendingChangesByContext: Map<string, PendingChanges>;
existingSecretKeys: Set<string>;
existingFolderNames: Set<string>;
currentContext: BatchContext | null;
batchActions: {
addPendingChange: (change: PendingChange, context: BatchContext) => void;
loadPendingChanges: (context: BatchContext) => void;
clearAllPendingChanges: (context: BatchContext) => void;
setExistingKeys: (secretKeys: string[], folderNames: string[]) => void;
getTotalPendingChangesCount: () => number;
removePendingChange: (changeId: string, resourceType: string, context: BatchContext) => void;
};
};
const generateContextKey = (workspaceId: string, environment: string, secretPath: string) => {
return `${workspaceId}_${environment}_${secretPath}`;
};
const createBatchModeStore: StateCreator<CombinedState, [], [], BatchModeState> = (set, get) => ({
isBatchMode: true, // Always enabled by default
pendingChanges: { secrets: [], folders: [] },
pendingChangesByContext: new Map<string, PendingChanges>(),
currentContext: null,
existingSecretKeys: new Set<string>(),
existingFolderNames: new Set<string>(),
batchActions: {
addPendingChange: (change: PendingChange, context: BatchContext) =>
set((state) => {
const contextKey = generateContextKey(
context.workspaceId,
context.environment,
context.secretPath
);
// Get existing changes for this context or create new empty state
const existingChanges = state.pendingChangesByContext.get(contextKey) || {
secrets: [],
folders: []
};
const newChanges = { ...existingChanges };
if (change.resourceType === "folder") {
const existingFolder =
state.existingFolderNames.has(change.folderName) ||
newChanges.folders.some((f) => f.folderName === change.folderName);
if (change.type === PendingAction.Create && existingFolder) {
createNotification({
text: "A folder with this name already exists",
type: "error"
});
return { pendingChanges: newChanges };
}
if (
change.type === PendingAction.Update &&
change.folderName !== change.originalFolderName &&
existingFolder
) {
createNotification({
text: "A folder with this name already exists",
type: "error"
});
return { pendingChanges: newChanges };
}
}
if (change.resourceType === "secret") {
const existingSecret =
state.existingSecretKeys.has(change.secretKey) ||
newChanges.secrets.some(
(s) =>
(s.secretKey === change.secretKey && s.type !== PendingAction.Create) ||
(change.type === PendingAction.Create &&
change.originalKey !== change.secretKey &&
s.secretKey === change.secretKey)
);
if (change.type === PendingAction.Create && existingSecret) {
createNotification({
text: "A secret with this name already exists",
type: "error"
});
return { pendingChanges: newChanges };
}
const existingNewSecretName =
change.type === PendingAction.Update &&
change.newSecretName &&
change.newSecretName !== change.secretKey &&
(state.existingSecretKeys.has(change.newSecretName) ||
newChanges.secrets.some(
(s) =>
(s.secretKey === change.newSecretName ||
(s.type === PendingAction.Update &&
s.newSecretName === change.newSecretName)) &&
s.id !== change.id
));
if (existingNewSecretName) {
createNotification({
text: "A secret with this name already exists",
type: "error"
});
return { pendingChanges: newChanges };
}
}
if (change.resourceType === "secret") {
const secretChanges = [...newChanges.secrets];
if (change.type === PendingAction.Create) {
const existingCreateIndex = secretChanges.findIndex(
(c) =>
c.type === PendingAction.Create &&
(c.secretKey === change.secretKey || c.secretKey === change.originalKey)
);
if (existingCreateIndex >= 0) {
secretChanges[existingCreateIndex] = {
...secretChanges[existingCreateIndex],
...change,
timestamp: Date.now()
};
} else {
secretChanges.push(change);
}
} else if (change.type === PendingAction.Update) {
const existingCreateIndex = secretChanges.findIndex(
(c) => c.type === PendingAction.Create && c.id === change.id
);
if (existingCreateIndex >= 0) {
const existingCreate = secretChanges[existingCreateIndex] as PendingSecretCreate;
secretChanges[existingCreateIndex] = {
...existingCreate,
secretKey: change.newSecretName || change.secretKey || existingCreate.secretKey,
secretValue:
change.secretValue !== undefined
? change.secretValue
: existingCreate.secretValue,
secretComment:
change.secretComment !== undefined
? change.secretComment
: existingCreate.secretComment,
skipMultilineEncoding:
change.skipMultilineEncoding !== undefined
? change.skipMultilineEncoding
: existingCreate.skipMultilineEncoding,
tags: change.tags !== undefined ? change.tags : existingCreate.tags,
secretMetadata:
change.secretMetadata !== undefined
? change.secretMetadata
: existingCreate.secretMetadata,
timestamp: Date.now()
};
} else {
const existingUpdateIndex = secretChanges.findIndex(
(c) => c.type === PendingAction.Update && c.id === change.id
);
if (existingUpdateIndex >= 0) {
const existingUpdate = secretChanges[existingUpdateIndex] as PendingSecretUpdate;
const mergedUpdate: PendingSecretUpdate = {
...existingUpdate,
secretKey: existingUpdate.secretKey,
originalValue: existingUpdate.originalValue,
originalComment: existingUpdate.originalComment,
originalSkipMultilineEncoding: existingUpdate.originalSkipMultilineEncoding,
originalTags: existingUpdate.originalTags,
originalSecretMetadata: existingUpdate.originalSecretMetadata,
newSecretName:
change.newSecretName !== undefined
? change.newSecretName
: existingUpdate.newSecretName,
secretValue:
change.secretValue !== undefined
? change.secretValue
: existingUpdate.secretValue,
secretComment:
change.secretComment !== undefined
? change.secretComment
: existingUpdate.secretComment,
skipMultilineEncoding:
change.skipMultilineEncoding !== undefined
? change.skipMultilineEncoding
: existingUpdate.skipMultilineEncoding,
tags: change.tags !== undefined ? change.tags : existingUpdate.tags,
secretMetadata:
change.secretMetadata !== undefined
? change.secretMetadata
: existingUpdate.secretMetadata,
existingSecret: existingUpdate.existingSecret,
timestamp: Date.now()
};
// Clean up reverted fields and check if any changes remain
const cleanedUpdate = cleanupRevertedSecretFields(mergedUpdate);
if (cleanedUpdate) {
// Still has changes, keep the update
secretChanges[existingUpdateIndex] = cleanedUpdate;
} else {
// No changes remain, remove the pending update
secretChanges.splice(existingUpdateIndex, 1);
}
} else {
// New update - clean it up before adding
const cleanedUpdate = cleanupRevertedSecretFields(change);
if (cleanedUpdate) {
secretChanges.push(cleanedUpdate);
}
// If cleanedUpdate is null, don't add it (no actual changes)
}
}
} else {
secretChanges.push(change);
}
newChanges.secrets = secretChanges;
} else if (change.resourceType === "folder") {
const folderChanges = [...newChanges.folders];
if (change.type === PendingAction.Create) {
const existingCreateIndex = folderChanges.findIndex(
(c) => c.type === PendingAction.Create && c.folderName === change.folderName
);
if (existingCreateIndex >= 0) {
folderChanges[existingCreateIndex] = {
...folderChanges[existingCreateIndex],
...change,
timestamp: Date.now()
};
} else {
folderChanges.push(change);
}
} else if (change.type === PendingAction.Update) {
const existingCreateIndex = folderChanges.findIndex(
(c) => c.type === PendingAction.Create && c.folderName === change.originalFolderName
);
if (existingCreateIndex >= 0) {
const existingCreate = folderChanges[existingCreateIndex] as PendingFolderCreate;
folderChanges[existingCreateIndex] = {
...existingCreate,
folderName: change.folderName || existingCreate.folderName,
description:
change.description !== undefined
? change.description
: existingCreate.description,
timestamp: Date.now()
};
} else {
const existingUpdateIndex = folderChanges.findIndex(
(c) => c.type === PendingAction.Update && c.id === change.id
);
if (existingUpdateIndex >= 0) {
const existingUpdate = folderChanges[existingUpdateIndex] as PendingFolderUpdate;
const mergedUpdate: PendingFolderUpdate = {
...existingUpdate,
originalFolderName: existingUpdate.originalFolderName,
originalDescription: existingUpdate.originalDescription,
folderName:
change.folderName !== undefined ? change.folderName : existingUpdate.folderName,
description:
change.description !== undefined
? change.description
: existingUpdate.description,
timestamp: Date.now()
};
// Clean up reverted fields and check if any changes remain
const cleanedUpdate = cleanupRevertedFolderFields(mergedUpdate);
if (cleanedUpdate) {
// Still has changes, keep the update
folderChanges[existingUpdateIndex] = cleanedUpdate;
} else {
// No changes remain, remove the pending update
folderChanges.splice(existingUpdateIndex, 1);
}
} else {
// New update - clean it up before adding
const cleanedUpdate = cleanupRevertedFolderFields(change);
if (cleanedUpdate) {
folderChanges.push(cleanedUpdate);
}
// If cleanedUpdate is null, don't add it (no actual changes)
}
}
} else {
folderChanges.push(change);
}
newChanges.folders = folderChanges;
}
const updatedContextMap = new Map(state.pendingChangesByContext);
updatedContextMap.set(contextKey, newChanges);
const currentChanges =
contextKey ===
generateContextKey(
state.currentContext?.workspaceId || context.workspaceId,
state.currentContext?.environment || context.environment,
state.currentContext?.secretPath || context.secretPath
)
? newChanges
: state.pendingChanges;
return {
pendingChangesByContext: updatedContextMap,
pendingChanges: currentChanges,
currentContext: context
};
}),
removePendingChange: (changeId: string, resourceType: string, context: BatchContext) =>
set((state) => {
const contextKey = generateContextKey(
context.workspaceId,
context.environment,
context.secretPath
);
// Get existing changes for this context
const existingChanges = state.pendingChangesByContext.get(contextKey) || {
secrets: [],
folders: []
};
const newChanges = { ...existingChanges };
if (resourceType === "secret") {
newChanges.secrets = newChanges.secrets.filter((c) => c.id !== changeId);
} else if (resourceType === "folder") {
newChanges.folders = newChanges.folders.filter((c) => c.id !== changeId);
}
const updatedContextMap = new Map(state.pendingChangesByContext);
updatedContextMap.set(contextKey, newChanges);
const isCurrentContext =
state.currentContext &&
contextKey ===
generateContextKey(
state.currentContext.workspaceId,
state.currentContext.environment,
state.currentContext.secretPath
);
return {
pendingChangesByContext: updatedContextMap,
pendingChanges: isCurrentContext ? newChanges : state.pendingChanges
};
}),
loadPendingChanges: (context) => {
const contextKey = generateContextKey(
context.workspaceId,
context.environment,
context.secretPath
);
set((state) => {
const contextChanges = state.pendingChangesByContext.get(contextKey) || {
secrets: [],
folders: []
};
return {
pendingChanges: contextChanges,
currentContext: context
};
});
},
clearAllPendingChanges: (context) => {
const contextKey = generateContextKey(
context.workspaceId,
context.environment,
context.secretPath
);
set((state) => {
// Clear changes for this specific context
const updatedContextMap = new Map(state.pendingChangesByContext);
updatedContextMap.delete(contextKey);
// If this is the current context, also clear the active pending changes
const isCurrentContext =
state.currentContext &&
contextKey ===
generateContextKey(
state.currentContext.workspaceId,
state.currentContext.environment,
state.currentContext.secretPath
);
return {
pendingChangesByContext: updatedContextMap,
pendingChanges: isCurrentContext ? { secrets: [], folders: [] } : state.pendingChanges
};
});
},
setExistingKeys: (secretKeys, folderNames) =>
set({
existingSecretKeys: new Set(secretKeys),
existingFolderNames: new Set(folderNames)
}),
getTotalPendingChangesCount: () => {
const state = get();
return state.pendingChanges.secrets.length + state.pendingChanges.folders.length;
}
}
});
export enum PopUpNames {
CreateSecretForm = "create-secret-form"
}
@@ -58,13 +691,14 @@ const createPopUpStore: StateCreator<PopUpState> = (set) => ({
}
});
type CombinedState = SelectedSecretState & PopUpState;
type CombinedState = SelectedSecretState & PopUpState & BatchModeState;
const StoreContext = createContext<StoreApi<CombinedState> | null>(null);
export const StoreProvider = ({ children }: { children: ReactNode }) => {
const storeRef = useRef<StoreApi<CombinedState>>(
createStore<CombinedState>((...a) => ({
...createSelectedSecretStore(...a),
...createPopUpStore(...a)
...createPopUpStore(...a),
...createBatchModeStore(...a)
}))
);
const router = useRouter();
@@ -99,3 +733,17 @@ export const useSelectedSecretActions = () => useStoreContext(useShallow((state)
export const usePopUpState = (id: PopUpNames) =>
useStoreContext(useShallow((state) => state.popUp?.[id] || { isOpen: false }));
export const usePopUpAction = () => useStoreContext(useShallow((state) => state.popUpActions));
export const useBatchMode = () =>
useStoreContext(
useShallow((state) => ({
isBatchMode: state.isBatchMode,
pendingChanges: state.pendingChanges,
currentContext: state.currentContext,
totalChangesCount: state.batchActions.getTotalPendingChangesCount(),
secretChangesCount: state.pendingChanges.secrets.length,
folderChangesCount: state.pendingChanges.folders.length
}))
);
export const useBatchModeActions = () => useStoreContext(useShallow((state) => state.batchActions));
@@ -77,12 +77,15 @@ import {
} from "@app/hooks/api/dashboard/queries";
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types";
import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput";
import {
PendingFolderCreate,
PopUpNames,
useBatchModeActions,
usePopUpAction,
useSelectedSecretActions,
useSelectedSecrets
@@ -112,6 +115,7 @@ type Props = {
filter: Filter;
tags?: WsTag[];
isVisible?: boolean;
isBatchMode?: boolean;
snapshotCount: number;
isSnapshotCountLoading?: boolean;
protectedBranchPolicyName?: string;
@@ -142,6 +146,7 @@ export const ActionBar = ({
filter,
tags = [],
isVisible,
isBatchMode,
snapshotCount,
isSnapshotCountLoading,
onSearchChange,
@@ -182,6 +187,7 @@ export const ActionBar = ({
options: { onSuccess: undefined }
});
const queryClient = useQueryClient();
const { addPendingChange } = useBatchModeActions();
const selectedSecrets = useSelectedSecrets();
const { reset: resetSelectedSecret } = useSelectedSecretActions();
@@ -192,6 +198,28 @@ export const ActionBar = ({
const handleFolderCreate = async (folderName: string, description: string | null) => {
try {
if (isBatchMode) {
const folderId = `${folderName}`;
const pendingFolderCreate: PendingFolderCreate = {
id: folderId,
resourceType: "folder",
type: PendingAction.Create,
folderName,
description: description || undefined,
parentPath: secretPath,
timestamp: Date.now()
};
addPendingChange(pendingFolderCreate, {
workspaceId,
environment,
secretPath
});
handlePopUpClose("addFolder");
return;
}
await createFolder({
name: folderName,
path: secretPath,
@@ -8,7 +8,12 @@ import { TextArea } from "@app/components/v2/TextArea/TextArea";
type Props = {
onCreateFolder?: (folderName: string, description: string | null) => Promise<void>;
onUpdateFolder?: (folderName: string, description: string | null) => Promise<void>;
onUpdateFolder?: (
folderName: string,
description: string | null,
oldFolderName?: string,
oldFolderDescription?: string
) => Promise<void>;
isEdit?: boolean;
defaultFolderName?: string;
defaultDescription?: string;
@@ -69,7 +74,7 @@ export const FolderForm = ({
const descriptionShaped = description && description.trim() !== "" ? description : null;
if (isEdit) {
await onUpdateFolder?.(name, descriptionShaped);
await onUpdateFolder?.(name, descriptionShaped, defaultFolderName, defaultDescription);
} else {
await onCreateFolder?.(name, descriptionShaped);
}
@@ -0,0 +1,446 @@
/* eslint-disable jsx-a11y/label-has-associated-control */
import React, { useCallback, useState } from "react";
import { faCodeCommit, faEye, faFolder, faKey } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Badge, Button, Input, Modal, ModalContent } from "@app/components/v2";
import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { SecretVersionDiffView } from "@app/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView";
import {
PendingChange,
PendingChanges,
useBatchMode,
useBatchModeActions
} from "../../SecretMainPage.store";
interface CommitFormProps {
onCommit: (changes: PendingChanges, commitMessage: string) => Promise<void>;
isCommitting?: boolean;
environment: string;
workspaceId: string;
secretPath: string;
}
interface ResourceChangeProps {
change: PendingChange;
environment: string;
workspaceId: string;
secretPath: string;
}
type RenderResourceProps = {
onDiscard: () => void;
change: PendingChange;
};
const RenderSecretChanges = ({ onDiscard, change }: RenderResourceProps) => {
if (change.resourceType !== "secret") return null;
if (change.type === PendingAction.Create) {
return (
<SecretVersionDiffView
onDiscard={onDiscard}
item={{
secretKey: change.secretKey,
isAdded: true,
type: "secret",
id: change.id,
versions: [
{
version: 1, // placeholder, not used
secretKey: change.secretKey,
secretValue: change.secretValue,
tags: change.tags,
secretMetadata: change.secretMetadata,
skipMultilineEncoding: change.skipMultilineEncoding,
comment: change.secretComment
}
]
}}
/>
);
}
if (change.type === PendingAction.Update) {
const { existingSecret } = change;
const hasKeyChange = change.newSecretName && change.secretKey !== change.newSecretName;
const hasValueChange = change.secretValue !== change.originalValue;
const hasCommentChange = change.secretComment !== change.originalComment;
const hasMultilineChange =
change.skipMultilineEncoding !== change.originalSkipMultilineEncoding;
const hasTagsChange = JSON.stringify(change.tags) !== JSON.stringify(change.originalTags);
const hasMetadataChange =
JSON.stringify(change.secretMetadata) !== JSON.stringify(change.originalSecretMetadata);
const hasChanges = [
hasKeyChange,
hasValueChange,
hasCommentChange,
hasMultilineChange,
hasTagsChange,
hasMetadataChange
].some(Boolean);
if (!hasChanges) return null;
return (
<SecretVersionDiffView
onDiscard={onDiscard}
item={{
secretKey: change.secretKey,
isUpdated: true,
type: "secret",
id: change.id,
versions: [
{
version: 1, // placeholder, not used
secretKey: change.newSecretName ? existingSecret.key : undefined,
secretValue:
change.secretValue !== undefined ? (existingSecret.value ?? "") : undefined,
tags: change.tags ? (existingSecret.tags?.map((tag) => tag.slug) ?? []) : undefined,
secretMetadata: change.secretMetadata ? existingSecret.secretMetadata : undefined,
skipMultilineEncoding:
typeof change.skipMultilineEncoding === "boolean"
? existingSecret.skipMultilineEncoding
: undefined,
comment: change.secretComment !== undefined ? existingSecret.comment : undefined
},
{
version: 2, // placeholder, not used
secretKey: change.newSecretName,
secretValue: change.secretValue,
tags: change.tags?.map((tag) => tag.slug),
secretMetadata: change.secretMetadata,
skipMultilineEncoding: change.skipMultilineEncoding,
comment: change.secretComment
}
]
}}
/>
);
}
if (change.type === PendingAction.Delete) {
const { secretKey, secretValue } = change;
return (
<SecretVersionDiffView
onDiscard={onDiscard}
item={{
secretKey: change.secretKey,
isDeleted: true,
type: "secret",
id: change.id,
versions: [
{
version: 1, // placeholder, not used
secretKey,
secretValue
}
]
}}
/>
);
}
return null;
};
const RenderFolderChanges = ({ onDiscard, change }: RenderResourceProps) => {
if (change.resourceType !== "folder") return null;
if (change.type === PendingAction.Create) {
return (
<SecretVersionDiffView
onDiscard={onDiscard}
item={{
folderName: change.folderName,
isAdded: true,
type: "folder",
id: change.id,
versions: [
{
version: 1,
name: change.folderName,
description: change.description
}
]
}}
/>
);
}
if (change.type === PendingAction.Update) {
const hasNameChange = change.folderName !== change.originalFolderName;
const hasDescriptionChange = change.description !== change.originalDescription;
const hasChanges = [hasNameChange, hasDescriptionChange].some(Boolean);
if (!hasChanges) return null;
return (
<SecretVersionDiffView
onDiscard={onDiscard}
item={{
folderName: change.folderName,
isUpdated: true,
type: "folder",
id: change.id,
versions: [
{
version: 1, // placeholder, not used
name: change.folderName ? change.originalFolderName : undefined,
description: change.description ? change.originalDescription : undefined
},
{
version: 2, // placeholder, not used
name: change.folderName,
description: change.description
}
]
}}
/>
);
}
if (change.type === PendingAction.Delete) {
return (
<SecretVersionDiffView
onDiscard={onDiscard}
item={{
folderName: change.folderName,
isDeleted: true,
type: "folder",
id: change.id,
versions: [
{
version: 1,
name: change.folderName
}
]
}}
/>
);
}
return null;
};
const ResourceChange: React.FC<ResourceChangeProps> = ({
change,
environment,
workspaceId,
secretPath
}) => {
const { removePendingChange } = useBatchModeActions();
const handleDeletePending = useCallback(
(changeType: string, id: string) => {
removePendingChange(id, changeType, {
workspaceId,
environment,
secretPath
});
},
[change.resourceType, change.id]
);
return change.resourceType === "secret" ? (
<RenderSecretChanges
key={change.id}
change={change}
onDiscard={() => handleDeletePending(change.resourceType, change.id)}
/>
) : (
<RenderFolderChanges
key={change.id}
change={change}
onDiscard={() => handleDeletePending(change.resourceType, change.id)}
/>
);
};
export const CommitForm: React.FC<CommitFormProps> = ({
onCommit,
isCommitting = false,
environment,
workspaceId,
secretPath
}) => {
const { isBatchMode, pendingChanges, totalChangesCount } = useBatchMode();
const [isModalOpen, setIsModalOpen] = useState(false);
const [commitMessage, setCommitMessage] = useState("");
const { clearAllPendingChanges } = useBatchModeActions();
if (!isBatchMode || totalChangesCount === 0) {
return null;
}
const handleCommit = async () => {
if (!commitMessage.trim()) {
return;
}
await onCommit(pendingChanges, commitMessage);
clearAllPendingChanges({
workspaceId,
environment,
secretPath
});
setIsModalOpen(false);
setCommitMessage("");
};
return (
<>
{/* Floating Panel */}
{!isModalOpen && (
<div className="fixed bottom-4 left-1/2 z-40 w-full max-w-3xl -translate-x-1/2 self-center rounded-lg border border-yellow/30 bg-mineshaft-800 shadow-2xl lg:left-auto lg:translate-x-0">
<div className="flex items-center justify-between p-4">
{/* Left Content */}
<div className="flex-1">
{/* Header */}
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-yellow-500" />
<span className="font-medium text-mineshaft-100">Pending Changes</span>
<Badge variant="primary" className="text-xs">
{totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""}
</Badge>
</div>
{/* Description */}
<p className="text-sm leading-5 text-mineshaft-400">
Review pending changes and commit them to apply the updates.
</p>
</div>
{/* Right Buttons */}
<div className="ml-6 mt-0.5 flex items-center gap-3">
<Button
size="sm"
onClick={() => clearAllPendingChanges({ workspaceId, environment, secretPath })}
isDisabled={totalChangesCount === 0}
variant="outline_bg"
className="px-4 hover:border-red/40 hover:bg-red/[0.1]"
>
Discard
</Button>
<Button
variant="solid"
leftIcon={<FontAwesomeIcon icon={faEye} />}
onClick={() => setIsModalOpen(true)}
isDisabled={totalChangesCount === 0}
className="px-6"
>
Review Changes
</Button>
</div>
</div>
</div>
)}
{/* Commit Modal */}
<Modal isOpen={isModalOpen} onOpenChange={setIsModalOpen}>
<ModalContent
title={
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={faCodeCommit} className="text-mineshaft-400" />
Commit Changes
<Badge variant="primary" className="mt-[0.05rem]">
{totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""}
</Badge>
</div>
}
subTitle={"Write a commit message and review the changes you&apos;re about to commit."}
className="max-h-[90vh] max-w-[95%] md:max-w-7xl"
>
<div className="space-y-6">
{/* Changes List */}
<div className="space-y-6">
<div className="max-h-[50vh] space-y-4 overflow-y-auto">
{/* Folder Changes */}
{pendingChanges.folders.length > 0 && (
<div>
<h4 className="mb-4 flex items-center gap-2 border-b border-mineshaft-700 pb-2 text-sm font-semibold text-mineshaft-200">
<FontAwesomeIcon icon={faFolder} className="text-mineshaft-300" />
Folders ({pendingChanges.folders.length})
</h4>
<div>
{pendingChanges.folders.map((change) => (
<ResourceChange
key={change.id}
change={change}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
/>
))}
</div>
</div>
)}
{/* Secret Changes */}
{pendingChanges.secrets.length > 0 && (
<div>
<h4 className="mb-4 flex items-center gap-2 border-b border-mineshaft-700 pb-2 text-sm font-semibold text-mineshaft-200">
<FontAwesomeIcon icon={faKey} className="mr-1 text-mineshaft-300" />
Secrets ({pendingChanges.secrets.length})
</h4>
<div>
{pendingChanges.secrets.map((change) => (
<ResourceChange
key={change.id}
change={change}
environment={environment}
workspaceId={workspaceId}
secretPath={secretPath}
/>
))}
</div>
</div>
)}
</div>
</div>
{/* Commit Message */}
<div>
<label className="mb-2 block text-sm font-medium text-mineshaft-200">
Commit Message <span className="text-red-400">*</span>
</label>
<Input
value={commitMessage}
onChange={(e) => setCommitMessage(e.target.value)}
placeholder="Describe your changes..."
className="w-full"
required
/>
</div>
{/* Action Buttons */}
<div className="flex justify-end gap-3">
<Button
variant="plain"
colorSchema="secondary"
onClick={() => setIsModalOpen(false)}
isDisabled={isCommitting}
>
Cancel
</Button>
<Button
onClick={handleCommit}
isLoading={isCommitting}
isDisabled={isCommitting || !commitMessage.trim()}
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
colorSchema="primary"
variant="outline_bg"
>
{isCommitting ? "Committing..." : "Commit Changes"}
</Button>
</div>
</div>
</ModalContent>
</Modal>
</>
);
};
@@ -0,0 +1 @@
export { CommitForm } from "./CommitForm";
@@ -12,9 +12,15 @@ import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
import { getKeyValue } from "@app/helpers/parseEnvVar";
import { useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { SecretType } from "@app/hooks/api/types";
import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store";
import {
PendingSecretCreate,
PopUpNames,
useBatchModeActions,
usePopUpAction
} from "../../SecretMainPage.store";
const typeSchema = z.object({
key: z.string().trim().min(1, { message: "Secret key is required" }),
@@ -31,6 +37,7 @@ type Props = {
// modal props
autoCapitalize?: boolean;
isProtectedBranch?: boolean;
isBatchMode?: boolean;
};
export const CreateSecretForm = ({
@@ -38,7 +45,8 @@ export const CreateSecretForm = ({
workspaceId,
secretPath = "/",
autoCapitalize = true,
isProtectedBranch = false
isProtectedBranch = false,
isBatchMode = false
}: Props) => {
const {
register,
@@ -53,6 +61,7 @@ export const CreateSecretForm = ({
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
const createWsTag = useCreateWsTag();
const { addPendingChange } = useBatchModeActions();
const { permission } = useProjectPermission();
const canReadTags = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags);
@@ -85,6 +94,26 @@ export const CreateSecretForm = ({
const handleFormSubmit = async ({ key, value, tags }: TFormSchema) => {
try {
if (isBatchMode) {
const pendingSecretCreate: PendingSecretCreate = {
id: key,
type: PendingAction.Create,
secretKey: key,
secretValue: value || "",
secretComment: "",
tags: tags?.map((el) => ({ id: el.value, slug: el.label })),
timestamp: Date.now(),
resourceType: "secret"
};
addPendingChange(pendingSecretCreate, {
workspaceId,
environment,
secretPath
});
closePopUp(PopUpNames.CreateSecretForm);
reset();
return;
}
await createSecretV3({
environment,
workspaceId,
@@ -1,10 +1,5 @@
import { subject } from "@casl/ability";
import {
faClose,
faFingerprint,
faPencilSquare,
faWarning
} from "@fortawesome/free-solid-svg-icons";
import { faEdit, faFingerprint, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { createNotification } from "@app/components/notifications";
@@ -187,7 +182,7 @@ export const DynamicSecretListView = ({
</Tooltip>
)}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<div className="flex w-16 items-center justify-between border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionDynamicSecretActions.EditRootCredential}
a={subject(ProjectPermissionSub.DynamicSecrets, {
@@ -210,7 +205,7 @@ export const DynamicSecretListView = ({
}}
isDisabled={!isAllowed || isRevoking}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
<FontAwesomeIcon icon={faEdit} />
</IconButton>
)}
</ProjectPermissionCan>
@@ -228,6 +223,7 @@ export const DynamicSecretListView = ({
<IconButton
ariaLabel="delete-dynamic-secret"
variant="plain"
colorSchema="danger"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={(evt) => {
@@ -236,7 +232,7 @@ export const DynamicSecretListView = ({
}}
isDisabled={!isAllowed || isRevoking}
>
<FontAwesomeIcon icon={faClose} size="lg" />
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
@@ -1,7 +1,15 @@
import { subject } from "@casl/ability";
import { faClose, faFolder, faInfoCircle, faPencilSquare } from "@fortawesome/free-solid-svg-icons";
import {
faClose,
faEdit,
faFolder,
faInfoCircle,
faPencilSquare,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
import { ProjectPermissionCan } from "@app/components/permissions";
@@ -11,8 +19,15 @@ import { ROUTE_PATHS } from "@app/const/routes";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { usePopUp } from "@app/hooks";
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
import { PendingAction, TSecretFolder } from "@app/hooks/api/secretFolders/types";
import {
PendingFolderCreate,
PendingFolderDelete,
PendingFolderUpdate,
useBatchMode,
useBatchModeActions
} from "../../SecretMainPage.store";
import { FolderForm } from "../ActionBar/FolderForm";
type Props = {
@@ -42,10 +57,62 @@ export const FolderListView = ({
const { mutateAsync: updateFolder } = useUpdateFolder();
const { mutateAsync: deleteFolder } = useDeleteFolder();
const { isBatchMode } = useBatchMode();
const { addPendingChange, removePendingChange } = useBatchModeActions();
const handleFolderUpdate = async (newFolderName: string, newFolderDescription: string | null) => {
const handleFolderUpdate = async (
newFolderName: string,
newFolderDescription: string | null,
oldFolderName?: string,
oldFolderDescription?: string
) => {
try {
const { id: folderId } = popUp.updateFolder.data as TSecretFolder;
const updateFolderData = popUp.updateFolder.data;
if (!updateFolderData) throw new Error("Update folder data is required");
const { id: folderId, pendingAction, isPending } = updateFolderData as TSecretFolder;
if (isBatchMode) {
const isEditingPendingCreation = isPending && pendingAction === PendingAction.Create;
if (isEditingPendingCreation) {
const updatedCreate: PendingFolderCreate = {
id: folderId,
type: PendingAction.Create,
folderName: newFolderName,
description: newFolderDescription || undefined,
parentPath: secretPath,
timestamp: Date.now(),
resourceType: "folder"
};
addPendingChange(updatedCreate, {
workspaceId,
environment,
secretPath
});
} else {
const updateChange: PendingFolderUpdate = {
id: folderId,
type: PendingAction.Update,
originalFolderName: oldFolderName || "",
folderName: newFolderName,
originalDescription: oldFolderDescription,
description: newFolderDescription || undefined,
timestamp: Date.now(),
resourceType: "folder"
};
addPendingChange(updateChange, {
workspaceId,
environment,
secretPath
});
}
handlePopUpClose("updateFolder");
return;
}
await updateFolder({
folderId,
name: newFolderName,
@@ -68,15 +135,45 @@ export const FolderListView = ({
}
};
const handleDeletePending = (id: string) => {
removePendingChange(id, "folder", {
workspaceId,
environment,
secretPath
});
};
const handleFolderDelete = async () => {
try {
const { id: folderId } = popUp.deleteFolder.data as TSecretFolder;
const folderData = popUp.deleteFolder?.data as TSecretFolder;
if (isBatchMode) {
const pendingFolderDelete: PendingFolderDelete = {
id: folderData.id,
folderName: folderData.name,
folderPath: secretPath,
resourceType: "folder",
type: PendingAction.Delete,
timestamp: Date.now()
};
addPendingChange(pendingFolderDelete, {
workspaceId,
environment,
secretPath
});
handlePopUpClose("deleteFolder");
return;
}
await deleteFolder({
folderId,
folderId: folderData.id,
path: secretPath,
environment,
projectId: workspaceId
});
handlePopUpClose("deleteFolder");
createNotification({
type: "success",
@@ -91,7 +188,10 @@ export const FolderListView = ({
}
};
const handleFolderClick = (name: string) => {
const handleFolderClick = (name: string, isPending?: boolean) => {
if (isPending) {
return;
}
const path = `${secretPathQueryparam === "/" ? "" : secretPathQueryparam}/${name}`;
navigate({
search: (el) => ({ ...el, secretPath: path })
@@ -100,10 +200,16 @@ export const FolderListView = ({
return (
<>
{folders.map(({ name, id, description }) => (
{folders.map(({ name, id, description, pendingAction, isPending }) => (
<div
key={id}
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
className={twMerge(
"group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700",
isPending && "bg-mineshaft-700/60",
pendingAction === PendingAction.Delete && "border-l-2 border-l-red-600/75",
pendingAction === PendingAction.Update && "border-l-2 border-l-yellow-600/75",
pendingAction === PendingAction.Create && "border-l-2 border-l-green-600/75"
)}
>
<div className="flex w-11 items-center px-5 py-3 text-yellow-700">
<FontAwesomeIcon icon={faFolder} />
@@ -113,9 +219,9 @@ export const FolderListView = ({
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") handleFolderClick(name);
if (evt.key === "Enter") handleFolderClick(name, isPending);
}}
onClick={() => handleFolderClick(name)}
onClick={() => handleFolderClick(name, isPending)}
>
{name}
{description && (
@@ -128,46 +234,72 @@ export const FolderListView = ({
</Tooltip>
)}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-folder"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("updateFolder", { id, name, description })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-folder"
variant="plain"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("deleteFolder", { id, name })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</div>
{isPending ? (
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
<IconButton
ariaLabel="edit-folder"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
isDisabled
onClick={() => {}}
>
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
</IconButton>
<IconButton
ariaLabel="delete-folder"
variant="plain"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handleDeletePending(id)}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
</div>
) : (
<div className="flex w-16 items-center justify-between border-l border-mineshaft-600 px-3 py-3">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })}
renderTooltip
allowedLabel="Edit"
>
{(isAllowed) => (
<IconButton
ariaLabel="edit-folder"
variant="plain"
size="sm"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("updateFolder", { id, name, description })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faEdit} />
</IconButton>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })}
renderTooltip
allowedLabel="Delete"
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-folder"
variant="plain"
colorSchema="danger"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => handlePopUpOpen("deleteFolder", { id, name })}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
</div>
)}
</div>
))}
<Modal
@@ -3,13 +3,13 @@ import { subject } from "@casl/ability";
import { useSortable } from "@dnd-kit/sortable";
import {
faCalendarCheck,
faClose,
faFileImport,
faFolder,
faInfoCircle,
faKey,
faRotate,
faSearch,
faTrash,
faUpDown,
faWarning,
faXmark
@@ -237,7 +237,7 @@ export const SecretImportItem = ({
</ProjectPermissionCan>
)}
</div>
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-4 py-2">
<div className="flex w-16 items-center justify-between border-l border-mineshaft-600 py-3.5 pl-4 pr-3">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.SecretImports, {
@@ -281,7 +281,7 @@ export const SecretImportItem = ({
}}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faClose} size="lg" />
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
@@ -52,6 +52,7 @@ import {
useWorkspace
} from "@app/context";
import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types";
import { getProjectBaseURL } from "@app/helpers/project";
import { usePopUp, useToggle } from "@app/hooks";
import { useGetSecretVersion } from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
@@ -980,7 +981,9 @@ export const SecretDetailSidebar = ({
className="z-[100] capitalize"
>
<Link
to="/projects/$projectId/members/$membershipId"
to={
`${getProjectBaseURL(currentWorkspace.type)}/members/$membershipId` as const
}
params={{
projectId: currentWorkspace.id,
membershipId: user.membershipId
@@ -1011,7 +1014,9 @@ export const SecretDetailSidebar = ({
className="z-[100]"
>
<Link
to="/projects/$projectId/identities/$identityId"
to={
`${getProjectBaseURL(currentWorkspace.type)}/identities/$identityId` as const
}
params={{
projectId: currentWorkspace.id,
identityId: identity.id
@@ -1078,7 +1083,7 @@ export const SecretDetailSidebar = ({
isDisabled={isSubmitting || !isDirty || !isAllowed}
isLoading={isSubmitting}
>
Save Changes
Apply Changes
</Button>
)}
</ProjectPermissionCan>
@@ -38,7 +38,7 @@ import { WsTag } from "@app/hooks/api/types";
import { subject } from "@casl/ability";
import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion";
import { memo, useEffect } from "react";
import { memo, useCallback, useEffect, useRef } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge";
import {
@@ -50,6 +50,7 @@ import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionCo
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faEyeSlash, faKey, faRotate } from "@fortawesome/free-solid-svg-icons";
import { PendingAction } from "@app/hooks/api/secretFolders/types";
import {
FontAwesomeSpriteName,
formSchema,
@@ -57,6 +58,7 @@ import {
TFormSchema
} from "./SecretListView.utils";
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
import { useBatchModeActions } from "../../SecretMainPage.store";
export const HIDDEN_SECRET_VALUE = "******";
export const HIDDEN_SECRET_VALUE_API_MASK = "<hidden-by-infisical>";
@@ -86,6 +88,8 @@ type Props = {
isImported: boolean;
}[];
}[];
isPending?: boolean;
pendingAction?: PendingAction;
};
export const SecretItem = memo(
@@ -102,7 +106,9 @@ export const SecretItem = memo(
environment,
secretPath,
handleSecretShare,
importedBy
importedBy,
isPending,
pendingAction
}: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"editSecret"
@@ -110,6 +116,18 @@ export const SecretItem = memo(
const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission();
const { isRotatedSecret } = secret;
const { removePendingChange } = useBatchModeActions();
const autoSaveTimeoutRef = useRef<NodeJS.Timeout>();
const isAutoSavingRef = useRef(false);
const handleDeletePending = (pendingSecret: SecretV3RawSanitized) => {
removePendingChange(pendingSecret.id, "secret", {
workspaceId: currentWorkspace.id,
environment,
secretPath
});
};
const canEditSecretValue = permission.can(
ProjectPermissionSecretActions.Edit,
@@ -151,7 +169,6 @@ export const SecretItem = memo(
});
const secretName = watch("key");
const overrideAction = watch("overrideAction");
const hasComment = Boolean(watch("comment"));
@@ -167,6 +184,56 @@ export const SecretItem = memo(
name: "tags"
});
const isOverriden =
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
const hasTagsApplied = Boolean(fields.length);
const autoSaveChanges = useCallback(
async (data: TFormSchema) => {
if (isAutoSavingRef.current) return;
if (
data.overrideAction === SecretActionType.Created ||
data.overrideAction === SecretActionType.Modified
) {
return;
}
isAutoSavingRef.current = true;
try {
await onSaveSecret(secret, { ...secret, ...data }, () => {
reset();
});
} catch (error) {
console.error("Auto-save failed:", error);
} finally {
isAutoSavingRef.current = false;
}
},
[secret, onSaveSecret, importedBy, reset]
);
const formValues = watch();
useEffect(() => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
if (isDirty && !isSubmitting && !isAutoSavingRef.current) {
const debounceTime = 600;
autoSaveTimeoutRef.current = setTimeout(() => {
autoSaveChanges(formValues);
}, debounceTime);
}
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
};
}, [formValues, isDirty, isSubmitting, autoSaveChanges, isPending]);
const isReadOnly =
hasSecretReadValueOrDescribePermission(
permission,
@@ -188,6 +255,9 @@ export const SecretItem = memo(
})
);
const isReadOnlySecret =
isReadOnly || isRotatedSecret || (isPending && pendingAction === PendingAction.Delete);
const { secretValueHidden } = secret;
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
@@ -199,10 +269,6 @@ export const SecretItem = memo(
return () => clearTimeout(timer);
}, [isSecValueCopied]);
const isOverriden =
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
const hasTagsApplied = Boolean(fields.length);
const handleOverrideClick = () => {
if (isOverriden) {
// override need not be flagged delete if it was never saved in server
@@ -267,20 +333,27 @@ export const SecretItem = memo(
setIsSecValueCopied.on();
};
const isInAutoSaveMode = isDirty && !isSubmitting && !isOverriden;
return (
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div
className={twMerge(
"border-b border-mineshaft-600 bg-mineshaft-800 shadow-none hover:bg-mineshaft-700",
isDirty && "border-primary-400/50",
isRotatedSecret && "bg-mineshaft-700/60"
isRotatedSecret && "bg-mineshaft-700/60",
isPending && "bg-mineshaft-700/60",
pendingAction === PendingAction.Delete && "border-l-2 border-l-red-600/75",
pendingAction === PendingAction.Update && "border-l-2 border-l-yellow-600/75",
pendingAction === PendingAction.Create && "border-l-2 border-l-green-600/75"
)}
>
<div className="group flex">
<div
className={twMerge(
"flex h-11 w-11 items-center justify-center px-4 py-3 text-mineshaft-300",
isDirty && "text-primary"
isDirty && "text-primary",
isPending && "ml-[-2px]"
)}
>
{secret.isRotatedSecret ? (
@@ -364,7 +437,7 @@ export const SecretItem = memo(
control={control}
render={({ field }) => (
<InfisicalSecretInput
isReadOnly={isReadOnly || isRotatedSecret}
isReadOnly={isReadOnlySecret}
key="secret-value"
isVisible={isVisible && !secretValueHidden}
canEditButNotView={secretValueHidden && !isOverriden}
@@ -377,275 +450,160 @@ export const SecretItem = memo(
)}
/>
)}
<div
key="actions"
className="flex h-full flex-shrink-0 self-start transition-all group-hover:gap-x-2"
>
<Tooltip content="Copy secret">
<IconButton
isDisabled={secret.secretValueHidden}
ariaLabel="copy-value"
variant="plain"
size="sm"
className="w-0 overflow-hidden p-0 group-hover:w-5"
onClick={copyTokenToClipboard}
>
<FontAwesomeSymbol
className="h-3.5 w-3"
symbolName={
isSecValueCopied
? FontAwesomeSpriteName.Check
: FontAwesomeSpriteName.ClipboardCopy
}
/>
</IconButton>
</Tooltip>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})}
{pendingAction !== PendingAction.Create && pendingAction !== PendingAction.Delete && (
<div
key="actions"
className="flex h-full flex-shrink-0 self-start transition-all group-hover:gap-x-2"
>
{(isAllowed) => (
<Modal>
<ModalTrigger asChild>
<IconButton
className="w-0 overflow-hidden p-0 group-hover:w-5"
variant="plain"
size="md"
ariaLabel="reference-tree"
isDisabled={!isAllowed || !hasSecretReference(secret?.value)}
>
<Tooltip
content={
hasSecretReference(secret?.value)
? "Secret Reference Tree"
: "Secret does not contain references"
}
>
<FontAwesomeSymbol
className="h-3.5 w-3.5"
symbolName={FontAwesomeSpriteName.SecretReferenceTree}
/>
</Tooltip>
</IconButton>
</ModalTrigger>
<ModalContent
title="Secret Reference Details"
subTitle="Visual breakdown of secrets referenced by this secret."
onOpenAutoFocus={(e) => e.preventDefault()} // prevents secret input from displaying value on open
>
<SecretReferenceTree
secretPath={secretPath}
environment={environment}
secretKey={secret?.key}
/>
</ModalContent>
</Modal>
)}
</ProjectPermissionCan>
<DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})}
>
{(isAllowed) => (
<DropdownMenuTrigger asChild disabled={!isAllowed}>
<IconButton
ariaLabel="tags"
variant="plain"
size="sm"
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:w-5 data-[state=open]:w-5",
hasTagsApplied && "w-5 text-primary"
)}
isDisabled={!isAllowed}
>
<Tooltip content="Tags">
<FontAwesomeSymbol
className="h-3.5 w-3.5"
symbolName={FontAwesomeSpriteName.Tags}
/>
</Tooltip>
</IconButton>
</DropdownMenuTrigger>
)}
</ProjectPermissionCan>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Add tags to this secret</DropdownMenuLabel>
{tags.map((tag) => {
const { id: tagId, slug, color } = tag;
const isTagSelected = selectedTagsGroupById?.[tagId];
return (
<DropdownMenuItem
onClick={() => handleTagSelect(tag)}
key={`${secret.id}-${tagId}`}
icon={
isTagSelected && (
<FontAwesomeSymbol
symbolName={FontAwesomeSpriteName.CheckedCircle}
className="h-3 w-3"
/>
)
}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: color || "#bec2c8" }}
/>
{slug}
</div>
</DropdownMenuItem>
);
})}
<DropdownMenuItem className="px-1.5" asChild>
<Button
size="xs"
className="w-full"
colorSchema="primary"
variant="outline_bg"
leftIcon={
<FontAwesomeSymbol
symbolName={FontAwesomeSpriteName.Tags}
className="h-3 w-3"
/>
}
onClick={onCreateTag}
>
Create a tag
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})}
renderTooltip
allowedLabel="Override"
>
{(isAllowed) => (
<Tooltip content="Copy secret">
<IconButton
ariaLabel="override-value"
isDisabled={!isAllowed}
isDisabled={secret.secretValueHidden}
ariaLabel="copy-value"
variant="plain"
size="sm"
onClick={handleOverrideClick}
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:w-5",
isOverriden && "w-5 text-primary"
)}
className="w-0 overflow-hidden p-0 group-hover:w-5"
onClick={copyTokenToClipboard}
>
<FontAwesomeSymbol
symbolName={FontAwesomeSpriteName.Override}
className="h-3.5 w-3.5"
/>
</IconButton>
)}
</ProjectPermissionCan>
<Popover>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})}
>
{(isAllowed) => (
<PopoverTrigger asChild disabled={!isAllowed}>
<IconButton
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:w-5",
hasComment && "w-5 text-primary"
)}
variant="plain"
size="md"
ariaLabel="add-comment"
isDisabled={!isAllowed}
>
<Tooltip content="Comment">
<FontAwesomeSymbol
className="h-3.5 w-3.5"
symbolName={FontAwesomeSpriteName.Comment}
/>
</Tooltip>
</IconButton>
</PopoverTrigger>
)}
</ProjectPermissionCan>
<IconButton
isDisabled={secret.secretValueHidden || !currentWorkspace.secretSharing}
className="w-0 overflow-hidden p-0 group-hover:w-5"
variant="plain"
size="md"
ariaLabel="share-secret"
onClick={handleSecretShare}
>
<Tooltip content="Share Secret">
<FontAwesomeSymbol
className="h-3.5 w-3.5"
symbolName={FontAwesomeSpriteName.ShareSecret}
/>
</Tooltip>
</IconButton>
<PopoverContent
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl"
sticky="always"
>
<FormControl label="Comment" className="mb-0">
<TextArea
className="border border-mineshaft-600 text-sm"
rows={8}
cols={30}
{...register("comment")}
/>
</FormControl>
</PopoverContent>
</Popover>
</div>
</div>
<AnimatePresence mode="wait">
{!isDirty ? (
<motion.div
key="options"
className="flex h-10 flex-shrink-0 items-center space-x-4 px-[0.64rem]"
initial={{ x: 0, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 10, opacity: 0 }}
>
<Tooltip content="More">
<IconButton
ariaLabel="more"
variant="plain"
size="md"
className="h-5 w-4 p-0 opacity-0 group-hover:opacity-100"
onClick={() => onDetailViewSecret(secret)}
>
<FontAwesomeSymbol
symbolName={FontAwesomeSpriteName.More}
className="h-5 w-4"
className="h-3.5 w-3"
symbolName={
isSecValueCopied
? FontAwesomeSpriteName.Check
: FontAwesomeSpriteName.ClipboardCopy
}
/>
</IconButton>
</Tooltip>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})}
>
{(isAllowed) => (
<Modal>
<ModalTrigger asChild>
<IconButton
className="w-0 overflow-hidden p-0 group-hover:w-5"
variant="plain"
size="md"
ariaLabel="reference-tree"
isDisabled={!isAllowed || !hasSecretReference(secret?.value)}
>
<Tooltip
content={
hasSecretReference(secret?.value)
? "Secret Reference Tree"
: "Secret does not contain references"
}
>
<FontAwesomeSymbol
className="h-3.5 w-3.5"
symbolName={FontAwesomeSpriteName.SecretReferenceTree}
/>
</Tooltip>
</IconButton>
</ModalTrigger>
<ModalContent
title="Secret Reference Details"
subTitle="Visual breakdown of secrets referenced by this secret."
onOpenAutoFocus={(e) => e.preventDefault()}
>
<SecretReferenceTree
secretPath={secretPath}
environment={environment}
secretKey={secret?.key}
/>
</ModalContent>
</Modal>
)}
</ProjectPermissionCan>
<DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})}
>
{(isAllowed) => (
<DropdownMenuTrigger asChild disabled={!isAllowed}>
<IconButton
ariaLabel="tags"
variant="plain"
size="sm"
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:w-5 data-[state=open]:w-5",
hasTagsApplied && "w-5 text-primary"
)}
isDisabled={!isAllowed}
>
<Tooltip content="Tags">
<FontAwesomeSymbol
className="h-3.5 w-3.5"
symbolName={FontAwesomeSpriteName.Tags}
/>
</Tooltip>
</IconButton>
</DropdownMenuTrigger>
)}
</ProjectPermissionCan>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Add tags to this secret</DropdownMenuLabel>
{tags.map((tag) => {
const { id: tagId, slug, color } = tag;
const isTagSelected = selectedTagsGroupById?.[tagId];
return (
<DropdownMenuItem
onClick={() => handleTagSelect(tag)}
key={`${secret.id}-${tagId}`}
icon={
isTagSelected && (
<FontAwesomeSymbol
symbolName={FontAwesomeSpriteName.CheckedCircle}
className="h-3 w-3"
/>
)
}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: color || "#bec2c8" }}
/>
{slug}
</div>
</DropdownMenuItem>
);
})}
<DropdownMenuItem className="px-1.5" asChild>
<Button
size="xs"
className="w-full"
colorSchema="primary"
variant="outline_bg"
leftIcon={
<FontAwesomeSymbol
symbolName={FontAwesomeSpriteName.Tags}
className="h-3 w-3"
/>
}
onClick={onCreateTag}
>
Create a tag
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
@@ -653,30 +611,287 @@ export const SecretItem = memo(
secretTags: selectedTagSlugs
})}
renderTooltip
allowedLabel={isRotatedSecret ? "Cannot Delete Rotated Secret" : "Delete"} // just using label for isRotatedSecret, disabled below
allowedLabel="Override"
>
{(isAllowed) => (
<IconButton
ariaLabel="override-value"
isDisabled={!isAllowed}
variant="plain"
size="sm"
onClick={handleOverrideClick}
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:w-5",
isOverriden && "w-5 text-primary"
)}
>
<FontAwesomeSymbol
symbolName={FontAwesomeSpriteName.Override}
className="h-3.5 w-3.5"
/>
</IconButton>
)}
</ProjectPermissionCan>
<Popover>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})}
>
{(isAllowed) => (
<PopoverTrigger asChild disabled={!isAllowed}>
<IconButton
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:w-5",
hasComment && "w-5 text-primary"
)}
variant="plain"
size="md"
ariaLabel="add-comment"
isDisabled={!isAllowed}
>
<Tooltip content="Comment">
<FontAwesomeSymbol
className="h-3.5 w-3.5"
symbolName={FontAwesomeSpriteName.Comment}
/>
</Tooltip>
</IconButton>
</PopoverTrigger>
)}
</ProjectPermissionCan>
<IconButton
isDisabled={secret.secretValueHidden || !currentWorkspace.secretSharing}
className="w-0 overflow-hidden p-0 group-hover:w-5"
variant="plain"
size="md"
ariaLabel="share-secret"
onClick={handleSecretShare}
>
<Tooltip content="Share Secret">
<FontAwesomeSymbol
className="h-3.5 w-3.5"
symbolName={FontAwesomeSpriteName.ShareSecret}
/>
</Tooltip>
</IconButton>
<PopoverContent
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl"
sticky="always"
>
<FormControl label="Comment" className="mb-0">
<TextArea
className="border border-mineshaft-600 text-sm"
rows={8}
cols={30}
{...register("comment")}
/>
</FormControl>
</PopoverContent>
</Popover>
</div>
)}
{pendingAction === PendingAction.Create && (
<div
key="actions"
className="flex h-full flex-shrink-0 self-start transition-all group-hover:gap-x-2"
>
<DropdownMenu>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})}
>
{(isAllowed) => (
<DropdownMenuTrigger asChild disabled={!isAllowed}>
<IconButton
ariaLabel="tags"
variant="plain"
size="sm"
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:w-5 data-[state=open]:w-5",
hasTagsApplied && "w-5 text-primary"
)}
isDisabled={!isAllowed}
>
<Tooltip content="Tags">
<FontAwesomeSymbol
className="h-3.5 w-3.5"
symbolName={FontAwesomeSpriteName.Tags}
/>
</Tooltip>
</IconButton>
</DropdownMenuTrigger>
)}
</ProjectPermissionCan>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Add tags to this secret</DropdownMenuLabel>
{tags.map((tag) => {
const { id: tagId, slug, color } = tag;
const isTagSelected = selectedTagsGroupById?.[tagId];
return (
<DropdownMenuItem
onClick={() => handleTagSelect(tag)}
key={`${secret.id}-${tagId}`}
icon={
isTagSelected && (
<FontAwesomeSymbol
symbolName={FontAwesomeSpriteName.CheckedCircle}
className="h-3 w-3"
/>
)
}
iconPos="right"
>
<div className="flex items-center">
<div
className="mr-2 h-2 w-2 rounded-full"
style={{ background: color || "#bec2c8" }}
/>
{slug}
</div>
</DropdownMenuItem>
);
})}
<DropdownMenuItem className="px-1.5" asChild>
<Button
size="xs"
className="w-full"
colorSchema="primary"
variant="outline_bg"
leftIcon={
<FontAwesomeSymbol
symbolName={FontAwesomeSpriteName.Tags}
className="h-3 w-3"
/>
}
onClick={onCreateTag}
>
Create a tag
</Button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
<AnimatePresence mode="wait">
{isInAutoSaveMode ? (
<motion.div
key="auto-save-mode"
className="flex w-[63px] flex-shrink-0 items-center justify-between px-3"
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -10, opacity: 0 }}
>
<div className="h-10 w-12" />
</motion.div>
) : !isDirty ? (
isPending ? (
<motion.div
key="options"
className="flex w-[63px] flex-shrink-0 items-center justify-between px-3"
initial={{ x: 0, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 10, opacity: 0 }}
>
<Tooltip content="More">
<IconButton
ariaLabel="more"
variant="plain"
size="md"
className="h-5 w-4 p-0 opacity-0 group-hover:opacity-100"
onClick={() => onDetailViewSecret(secret)}
isDisabled={pendingAction !== "update"}
>
<FontAwesomeSymbol
symbolName={FontAwesomeSpriteName.More}
className="h-5 w-4"
/>
</IconButton>
</Tooltip>
<Tooltip content="Discard">
<IconButton
ariaLabel="delete-value"
variant="plain"
colorSchema="danger"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => onDeleteSecret(secret)}
isDisabled={!isAllowed || isRotatedSecret}
onClick={() => handleDeletePending(secret)}
>
<FontAwesomeSymbol
symbolName={FontAwesomeSpriteName.Close}
className="h-5 w-4"
/>
</IconButton>
)}
</ProjectPermissionCan>
</motion.div>
</Tooltip>
</motion.div>
) : (
<motion.div
key="options"
className="flex w-[63px] flex-shrink-0 items-center justify-between px-3"
initial={{ x: 0, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 10, opacity: 0 }}
>
<Tooltip content="More">
<IconButton
ariaLabel="more"
variant="plain"
size="md"
className="h-5 w-4 p-0 opacity-0 group-hover:opacity-100"
onClick={() => onDetailViewSecret(secret)}
>
<FontAwesomeSymbol
symbolName={FontAwesomeSpriteName.More}
className="h-5 w-4"
/>
</IconButton>
</Tooltip>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName,
secretTags: selectedTagSlugs
})}
renderTooltip
allowedLabel={isRotatedSecret ? "Cannot Delete Rotated Secret" : "Delete"}
>
{(isAllowed) => (
<IconButton
ariaLabel="delete-value"
variant="plain"
colorSchema="danger"
size="md"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => onDeleteSecret(secret)}
isDisabled={!isAllowed || isRotatedSecret}
>
<FontAwesomeSymbol
symbolName={FontAwesomeSpriteName.Trash}
className="h-4 w-3"
/>
</IconButton>
)}
</ProjectPermissionCan>
</motion.div>
)
) : (
<motion.div
key="options-save"
className="flex h-10 flex-shrink-0 items-center space-x-4 px-3"
className="flex w-[63px] flex-shrink-0 items-center justify-between px-3"
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -10, opacity: 0 }}
@@ -11,13 +11,24 @@ import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
import { commitKeys } from "@app/hooks/api/folderCommits/queries";
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { secretKeys } from "@app/hooks/api/secrets/queries";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
import { secretSnapshotKeys } from "@app/hooks/api/secretSnapshots/queries";
import { WsTag } from "@app/hooks/api/types";
import { useNavigationBlocker } from "@app/hooks/useNavigationBlocker";
import { AddShareSecretModal } from "@app/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal";
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
import {
PendingSecretChange,
PendingSecretCreate,
PendingSecretDelete,
PendingSecretUpdate,
useBatchMode,
useBatchModeActions,
useSelectedSecretActions,
useSelectedSecrets
} from "../../SecretMainPage.store";
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
import { SecretDetailSidebar } from "./SecretDetailSidebar";
import { HIDDEN_SECRET_VALUE, HIDDEN_SECRET_VALUE_API_MASK, SecretItem } from "./SecretItem";
@@ -63,22 +74,29 @@ export const SecretListView = ({
// strip of side effect queries
const { mutateAsync: createSecretV3 } = useCreateSecretV3({
options: {
onSuccess: undefined
}
options: { onSuccess: undefined }
});
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3({
options: {
onSuccess: undefined
}
options: { onSuccess: undefined }
});
const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3({
options: {
onSuccess: undefined
}
options: { onSuccess: undefined }
});
const selectedSecrets = useSelectedSecrets();
const { toggle: toggleSelectedSecret } = useSelectedSecretActions();
const { isBatchMode, pendingChanges } = useBatchMode();
useNavigationBlocker({
shouldBlock: pendingChanges.secrets.length > 0 || pendingChanges.folders.length > 0,
message:
"You have unsaved changes. If you leave now, your work will be lost. Do you want to continue?",
context: {
workspaceId,
environment,
secretPath
}
});
const { addPendingChange } = useBatchModeActions();
const handleSecretOperation = async (
operation: "create" | "update" | "delete",
@@ -170,11 +188,56 @@ export const SecretListView = ({
);
};
function getTrueOriginalSecret(
currentSecret: SecretV3RawSanitized,
pendingSecrets: PendingSecretChange[]
): Omit<SecretV3RawSanitized, "tags"> & {
tags?: { id: string; slug: string }[];
} {
// Find if there's already a pending change for this secret
const existingChange = pendingSecrets.find((change) => change.id === currentSecret.id);
if (!existingChange || existingChange.type === "create") {
// If no existing change or it's a creation, current secret is the original
return {
...currentSecret,
tags: currentSecret.tags?.map((tag) => ({
id: tag.id,
slug: tag.slug
}))
};
}
if (existingChange.type === "update") {
// If there's an existing update, reconstruct the original from the stored original values
return {
...currentSecret,
key: existingChange.secretKey, // Original key
value: existingChange.originalValue || currentSecret.value,
comment: existingChange.originalComment || currentSecret.comment,
skipMultilineEncoding:
existingChange.originalSkipMultilineEncoding ?? currentSecret.skipMultilineEncoding,
tags:
existingChange.originalTags?.map((tag) => ({
id: tag.id,
slug: tag.slug
})) || currentSecret.tags,
secretMetadata: existingChange.originalSecretMetadata || currentSecret.secretMetadata
};
}
return {
...currentSecret,
tags: currentSecret.tags?.map((tag) => ({ id: tag.id, slug: tag.slug }))
};
}
// Improved handleSaveSecret function
const handleSaveSecret = useCallback(
async (
orgSecret: SecretV3RawSanitized,
modSecret: Omit<SecretV3RawSanitized, "tags"> & {
tags?: { id: string }[];
tags?: { id: string; name?: string; slug?: string }[];
secretMetadata?: { key: string; value: string }[];
},
cb?: () => void
@@ -192,7 +255,9 @@ export const SecretListView = ({
reminderNote,
reminderRecipients,
secretMetadata,
isReminderEvent
isReminderEvent,
isPending,
pendingAction
} = modSecret;
const hasKeyChanged = oldKey !== key && key;
@@ -246,6 +311,80 @@ export const SecretListView = ({
// shared secret change
if (!isSharedSecUnchanged && !personalAction) {
if (isBatchMode) {
const isEditingPendingCreation = isPending && pendingAction === PendingAction.Create;
if (isEditingPendingCreation) {
const updatedCreate: PendingSecretCreate = {
id: orgSecret.id,
type: PendingAction.Create,
secretKey: key,
secretValue: value || "",
secretComment: comment || "",
skipMultilineEncoding: modSecret.skipMultilineEncoding || false,
tags: tags?.map((tag) => ({ id: tag.id, slug: tag.name || tag.slug || "" })) || [],
secretMetadata: secretMetadata || [],
timestamp: Date.now(),
resourceType: "secret",
originalKey: oldKey
};
addPendingChange(updatedCreate, {
workspaceId,
environment,
secretPath
});
} else {
const trueOriginalSecret = getTrueOriginalSecret(orgSecret, pendingChanges.secrets);
const updateChange: PendingSecretUpdate = {
id: orgSecret.id,
type: PendingAction.Update,
secretKey: trueOriginalSecret.key,
...(key !== trueOriginalSecret.key && { newSecretName: key }),
...(value !== trueOriginalSecret.value && {
originalValue: trueOriginalSecret.value,
secretValue: value
}),
...(comment !== trueOriginalSecret.comment && {
originalComment: trueOriginalSecret.comment,
secretComment: comment
}),
...(modSecret.skipMultilineEncoding !==
trueOriginalSecret.skipMultilineEncoding && {
originalSkipMultilineEncoding: trueOriginalSecret.skipMultilineEncoding,
skipMultilineEncoding: modSecret.skipMultilineEncoding
}),
...(!isSameTags && {
originalTags:
trueOriginalSecret.tags?.map((tag) => ({ id: tag.id, slug: tag.slug })) || [],
tags: tags?.map((tag) => ({ id: tag.id, slug: tag.name || tag.slug || "" })) || []
}),
...(JSON.stringify(secretMetadata) !==
JSON.stringify(trueOriginalSecret.secretMetadata) && {
originalSecretMetadata: trueOriginalSecret.secretMetadata || [],
secretMetadata: secretMetadata || []
}),
timestamp: Date.now(),
resourceType: "secret",
existingSecret: orgSecret
};
addPendingChange(updateChange, {
workspaceId,
environment,
secretPath
});
}
if (!isReminderEvent) {
handlePopUpClose("secretDetail");
}
if (cb) cb();
return;
}
await handleSecretOperation("update", SecretType.Shared, oldKey, {
value,
tags: tagIds,
@@ -314,12 +453,41 @@ export const SecretListView = ({
});
}
},
[environment, secretPath, isProtectedBranch]
[
environment,
secretPath,
isProtectedBranch,
isBatchMode,
workspaceId,
addPendingChange,
pendingChanges.secrets
]
);
const handleSecretDelete = useCallback(async () => {
const { key, id: secretId } = popUp.deleteSecret?.data as SecretV3RawSanitized;
const { key, id: secretId, value } = popUp.deleteSecret?.data as SecretV3RawSanitized;
try {
if (isBatchMode) {
const deleteChange: PendingSecretDelete = {
id: `${secretId}`,
type: PendingAction.Delete,
secretKey: key,
secretValue: value || "",
timestamp: Date.now(),
resourceType: "secret"
};
addPendingChange(deleteChange, {
workspaceId,
environment,
secretPath
});
handlePopUpClose("deleteSecret");
handlePopUpClose("secretDetail");
return;
}
await handleSecretOperation("delete", SecretType.Shared, key, { secretId });
// wrap this in another function and then reuse
queryClient.invalidateQueries({
@@ -362,7 +530,10 @@ export const SecretListView = ({
(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key,
environment,
secretPath,
isProtectedBranch
isProtectedBranch,
isBatchMode,
workspaceId,
addPendingChange
]);
// for optimization on minimise re-rendering of secret items
@@ -401,6 +572,8 @@ export const SecretListView = ({
value: secret.valueOverride ?? secret.value
})
}
isPending={secret.isPending}
pendingAction={secret.pendingAction}
/>
))}
<DeleteActionModal
@@ -13,7 +13,8 @@ import {
faLock,
faProjectDiagram,
faShare,
faTags
faTags,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { z } from "zod";
@@ -82,7 +83,8 @@ export enum FontAwesomeSpriteName {
ReplicatedSecretKey = "secret-replicated",
ShareSecret = "share-secret",
KeyLock = "key-lock",
SecretReferenceTree = "secret-reference-tree"
SecretReferenceTree = "secret-reference-tree",
Trash = "trash"
}
// this is an optimization technique
@@ -101,5 +103,6 @@ export const FontAwesomeSpriteSymbols = [
{ icon: faClone, symbol: FontAwesomeSpriteName.ReplicatedSecretKey },
{ icon: faShare, symbol: FontAwesomeSpriteName.ShareSecret },
{ icon: faLock, symbol: FontAwesomeSpriteName.KeyLock },
{ icon: faProjectDiagram, symbol: FontAwesomeSpriteName.SecretReferenceTree }
{ icon: faProjectDiagram, symbol: FontAwesomeSpriteName.SecretReferenceTree },
{ icon: faTrash, symbol: FontAwesomeSpriteName.Trash }
];
@@ -2,11 +2,11 @@ import { useState } from "react";
import { subject } from "@casl/ability";
import {
faAsterisk,
faClose,
faEdit,
faInfoCircle,
faKey,
faRotate
faRotate,
faTrash
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AnimatePresence, motion } from "framer-motion";
@@ -48,7 +48,7 @@ export const SecretRotationItem = ({
<div className="text- flex w-11 items-center py-2 pl-5 text-mineshaft-400">
<FontAwesomeIcon icon={faRotate} />
</div>
<div className="flex flex-grow items-center border-r border-mineshaft-600 py-2 pl-4 pr-2">
<div className="flex flex-grow items-center py-2 pl-4 pr-2">
<div className="flex w-full flex-wrap items-center">
<span>{name}</span>
<Tag className="mx-2.5 flex items-center gap-1 px-1.5 py-0 text-xs normal-case">
@@ -141,7 +141,7 @@ export const SecretRotationItem = ({
<AnimatePresence mode="wait">
<motion.div
key="options"
className="flex h-10 flex-shrink-0 items-center space-x-1 px-[0.665rem]"
className="flex w-16 items-center justify-between border-l border-mineshaft-600 px-2 py-3"
initial={{ x: 0, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 10, opacity: 0 }}
@@ -159,7 +159,6 @@ export const SecretRotationItem = ({
<IconButton
ariaLabel="Edit rotation"
variant="plain"
size="md"
isDisabled={!isAllowed}
className="opacity-0 group-hover:opacity-100"
onClick={(e) => {
@@ -185,7 +184,6 @@ export const SecretRotationItem = ({
ariaLabel="Delete rotation"
variant="plain"
colorSchema="danger"
size="md"
className="opacity-0 group-hover:opacity-100"
onClick={(e) => {
e.stopPropagation();
@@ -193,7 +191,7 @@ export const SecretRotationItem = ({
}}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faClose} size="lg" />
<FontAwesomeIcon icon={faTrash} />
</IconButton>
)}
</ProjectPermissionCan>
@@ -13,7 +13,7 @@ const SecretDashboardPageQueryParamsSchema = z.object({
tags: z.string().catch("")
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/secrets/$envSlug"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/secrets/$envSlug"
)({
component: SecretDashboardPage,
validateSearch: zodValidator(SecretDashboardPageQueryParamsSchema),
@@ -28,7 +28,7 @@ export const Route = createFileRoute(
{
label: "Secrets",
link: linkOptions({
to: "/projects/$projectId/secret-manager/overview",
to: "/projects/secret-management/$projectId/overview",
params
})
},
@@ -39,7 +39,7 @@ export const Route = createFileRoute(
links: context.project.environments.map((el) => ({
label: el.name,
link: linkOptions({
to: "/projects/$projectId/secret-manager/secrets/$envSlug",
to: "/projects/secret-management/$projectId/secrets/$envSlug",
params: {
projectId: params.projectId,
envSlug: el.slug
@@ -172,7 +172,7 @@ const Page = () => {
PostgreSQL and Microsoft SQL Server Rotations can now be created from the{" "}
<Link
className="text-mineshaft-100 underline decoration-primary underline-offset-2 hover:text-mineshaft-200"
to="/projects/$projectId/secret-manager/overview"
to="/projects/secret-management/$projectId/overview"
params={{ projectId: currentWorkspace.id }}
>
Secret Manager Dashboard
@@ -407,7 +407,7 @@ const Page = () => {
Infisical is revamping its Secret Rotation experience. Navigate to the{" "}
<Link
className="text-mineshaft-100 underline decoration-primary underline-offset-2 hover:text-mineshaft-200"
to="/projects/$projectId/secret-manager/overview"
to="/projects/secret-management/$projectId/overview"
params={{ projectId: currentWorkspace.id }}
>
Secret Manager Dashboard
@@ -424,7 +424,7 @@ const Page = () => {
<Button
onClick={() =>
navigate({
to: "/projects/$projectId/secret-manager/overview",
to: "/projects/secret-management/$projectId/overview",
params: { projectId: currentWorkspace.id }
})
}
@@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { SecretRotationPage } from "./SecretRotationPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/secret-rotation"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/secret-rotation"
)({
component: SecretRotationPage,
beforeLoad: ({ context }) => {
@@ -0,0 +1,22 @@
import { GenericFieldLabel } from "@app/components/secret-syncs";
import { TBitbucketSync } from "@app/hooks/api/secretSyncs/types/bitbucket-sync";
type Props = {
secretSync: TBitbucketSync;
};
export const BitbucketSyncDestinationSection = ({ secretSync }: Props) => {
const {
destinationConfig: { workspaceSlug, repositorySlug, environmentId }
} = secretSync;
return (
<>
<GenericFieldLabel label="Workspace">{workspaceSlug}</GenericFieldLabel>
<GenericFieldLabel label="Repository">{repositorySlug}</GenericFieldLabel>
{environmentId && (
<GenericFieldLabel label="Deployment Environment">{environmentId}</GenericFieldLabel>
)}
</>
);
};
@@ -17,6 +17,7 @@ import { AwsSecretsManagerSyncDestinationSection } from "./AwsSecretsManagerSync
import { AzureAppConfigurationSyncDestinationSection } from "./AzureAppConfigurationSyncDestinationSection";
import { AzureDevOpsSyncDestinationSection } from "./AzureDevOpsSyncDestinationSection";
import { AzureKeyVaultSyncDestinationSection } from "./AzureKeyVaultSyncDestinationSection";
import { BitbucketSyncDestinationSection } from "./BitbucketSyncDestinationSection";
import { CamundaSyncDestinationSection } from "./CamundaSyncDestinationSection";
import { ChecklySyncDestinationSection } from "./ChecklySyncDestinationSection";
import { CloudflarePagesSyncDestinationSection } from "./CloudflarePagesSyncDestinationSection";
@@ -134,6 +135,9 @@ export const SecretSyncDestinationSection = ({ secretSync, onEditDestination }:
case SecretSync.Supabase:
DestinationComponents = <SupabaseSyncDestinationSection secretSync={secretSync} />;
break;
case SecretSync.Bitbucket:
DestinationComponents = <BitbucketSyncDestinationSection secretSync={secretSync} />;
break;
default:
throw new Error(`Unhandled Destination Section components: ${destination}`);
}
@@ -65,6 +65,7 @@ export const SecretSyncOptionsSection = ({ secretSync, onEditOptions }: Props) =
case SecretSync.Railway:
case SecretSync.Supabase:
case SecretSync.Checkly:
case SecretSync.Bitbucket:
AdditionalSyncOptionsComponent = null;
break;
default:
@@ -5,7 +5,7 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
import { SecretSyncDetailsByIDPage } from "./SecretSyncDetailsByIDPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/secret-syncs/$destination/$syncId"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/secret-syncs/$destination/$syncId"
)({
component: SecretSyncDetailsByIDPage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.SecretSyncs
@@ -4,9 +4,10 @@ import { useTranslation } from "react-i18next";
import { PageHeader, Tab, TabList, TabPanel, Tabs } from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { ProjectVersion } from "@app/hooks/api/workspace/types";
import { ProjectGeneralTab } from "@app/pages/project/SettingsPage/components/ProjectGeneralTab";
import { EncryptionTab } from "./components/EncryptionTab";
import { ProjectGeneralTab } from "./components/ProjectGeneralTab";
import { SecretSettingsTab } from "./components/ProjectGeneralTab";
import { WebhooksTab } from "./components/WebhooksTab";
import { WorkflowIntegrationTab } from "./components/WorkflowIntegrationSection";
@@ -15,6 +16,7 @@ export const SettingsPage = () => {
const { currentWorkspace } = useWorkspace();
const tabs = [
{ name: "General", key: "tab-project-general", Component: ProjectGeneralTab },
{ name: "Secrets Management", key: "tab-secret-general", Component: SecretSettingsTab },
{
name: "Encryption",
key: "tab-project-encryption",
@@ -6,7 +6,7 @@ import { SecretSharingSection } from "../SecretSharingSection";
import { SecretSnapshotsLegacySection } from "../SecretSnapshotsLegacySection";
import { SecretTagsSection } from "../SecretTagsSection";
export const ProjectGeneralTab = () => {
export const SecretSettingsTab = () => {
return (
<div>
<EnvironmentSection />
@@ -1 +1 @@
export { ProjectGeneralTab } from "./ProjectGeneralTab";
export { SecretSettingsTab } from "./ProjectGeneralTab";
@@ -3,7 +3,7 @@ import { createFileRoute } from "@tanstack/react-router";
import { SettingsPage } from "./SettingsPage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/settings"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/settings"
)({
component: SettingsPage,
beforeLoad: ({ context }) => {
@@ -68,7 +68,7 @@ export const AWSParameterStoreAuthorizeIntegrationPage = () => {
});
navigate({
to: "/projects/$projectId/secret-manager/integrations/aws-parameter-store/create",
to: "/projects/secret-management/$projectId/integrations/aws-parameter-store/create",
params: {
projectId: currentWorkspace.id
},
@@ -5,7 +5,7 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
import { AWSParameterStoreAuthorizeIntegrationPage } from "./AwsParameterStoreAuthorizePage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/aws-parameter-store/authorize"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/aws-parameter-store/authorize"
)({
component: AWSParameterStoreAuthorizeIntegrationPage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -156,7 +156,7 @@ export const AWSParameterStoreConfigurePage = () => {
setPathErrorText("");
navigate({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId: currentWorkspace.id
},
@@ -11,7 +11,7 @@ const AwsParameterStoreConfigurePageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/aws-parameter-store/create"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/aws-parameter-store/create"
)({
component: AWSParameterStoreConfigurePage,
validateSearch: zodValidator(AwsParameterStoreConfigurePageQueryParamsSchema),
@@ -22,7 +22,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -66,7 +66,7 @@ export const AWSSecretManagerAuthorizePage = () => {
})
});
navigate({
to: "/projects/$projectId/secret-manager/integrations/aws-secret-manager/create",
to: "/projects/secret-management/$projectId/integrations/aws-secret-manager/create",
params: {
projectId: currentWorkspace.id
},
@@ -5,7 +5,7 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
import { AWSSecretManagerAuthorizePage } from "./AwsSecretManagerAuthorizePage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/aws-secret-manager/authorize"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/aws-secret-manager/authorize"
)({
component: AWSSecretManagerAuthorizePage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -204,7 +204,7 @@ export const AwsSecretManagerConfigurePage = () => {
});
navigate({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId: currentWorkspace.id
},
@@ -11,7 +11,7 @@ const AwsSecretManagerConfigurePageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/aws-secret-manager/create"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/aws-secret-manager/create"
)({
component: AwsSecretManagerConfigurePage,
validateSearch: zodValidator(AwsSecretManagerConfigurePageQueryParamsSchema),
@@ -22,7 +22,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -131,7 +131,7 @@ export const AzureAppConfigurationConfigurePage = () => {
});
navigate({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId: currentWorkspace.id
},
@@ -11,7 +11,7 @@ const AzureAppConfigurationPageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/azure-app-configuration/create"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/azure-app-configuration/create"
)({
component: AzureAppConfigurationConfigurePage,
validateSearch: zodValidator(AzureAppConfigurationPageQueryParamsSchema),
@@ -22,7 +22,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -28,7 +28,7 @@ export const AzureAppConfigurationOauthCallbackPage = () => {
});
navigate({
to: "/projects/$projectId/secret-manager/integrations/azure-app-configuration/create",
to: "/projects/secret-management/$projectId/integrations/azure-app-configuration/create",
params: {
projectId: currentWorkspace.id
},
@@ -12,7 +12,7 @@ export const AzureAppConfigurationOauthCallbackPageQueryParamsSchema = z.object(
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/azure-app-configuration/oauth2/callback"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/azure-app-configuration/oauth2/callback"
)({
component: AzureAppConfigurationOauthCallbackPage,
validateSearch: zodValidator(AzureAppConfigurationOauthCallbackPageQueryParamsSchema),
@@ -23,7 +23,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -39,7 +39,7 @@ export const AzureDevopsAuthorizePage = () => {
setIsLoading(false);
navigate({
to: "/projects/$projectId/secret-manager/integrations/azure-devops/create",
to: "/projects/secret-management/$projectId/integrations/azure-devops/create",
params: {
projectId: currentWorkspace.id
},
@@ -5,7 +5,7 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
import { AzureDevopsAuthorizePage } from "./AzureDevopsAuthorizePage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/azure-devops/authorize"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/azure-devops/authorize"
)({
component: AzureDevopsAuthorizePage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -80,7 +80,7 @@ export const AzureDevopsConfigurePage = () => {
setIsLoading(false);
navigate({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId: currentWorkspace.id
},
@@ -11,7 +11,7 @@ const AzureDevopsConfigurePageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/azure-devops/create"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/azure-devops/create"
)({
component: AzureDevopsConfigurePage,
validateSearch: zodValidator(AzureDevopsConfigurePageQueryParamsSchema),
@@ -22,7 +22,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -11,7 +11,7 @@ const PageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/azure-key-vault/authorize"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/azure-key-vault/authorize"
)({
component: AzureKeyVaultAuthorizePage,
validateSearch: PageQueryParamsSchema,
@@ -22,7 +22,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -88,7 +88,7 @@ export const AzureKeyVaultConfigurePage = () => {
setIsLoading(false);
navigate({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId: currentWorkspace.id
},
@@ -11,7 +11,7 @@ const AzureKeyVaultConfigurePageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/azure-key-vault/create"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/azure-key-vault/create"
)({
component: AzureKeyVaultConfigurePage,
validateSearch: zodValidator(AzureKeyVaultConfigurePageQueryParamsSchema),
@@ -22,7 +22,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -28,7 +28,7 @@ export const AzureKeyVaultOauthCallbackPage = () => {
});
navigate({
to: "/projects/$projectId/secret-manager/integrations/azure-key-vault/create",
to: "/projects/secret-management/$projectId/integrations/azure-key-vault/create",
params: {
projectId: currentWorkspace.id
},
@@ -12,7 +12,7 @@ export const AzureKeyVaultOauthCallbackQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/azure-key-vault/oauth2/callback"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/azure-key-vault/oauth2/callback"
)({
component: AzureKeyVaultOauthCallbackPage,
validateSearch: zodValidator(AzureKeyVaultOauthCallbackQueryParamsSchema),
@@ -23,7 +23,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -148,7 +148,7 @@ export const BitbucketConfigurePage = () => {
text: "Successfully created integration"
});
navigate({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId: currentWorkspace.id
},
@@ -11,7 +11,7 @@ const BitbucketConfigurePageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/bitbucket/create"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/bitbucket/create"
)({
component: BitbucketConfigurePage,
validateSearch: zodValidator(BitbucketConfigurePageQueryParamsSchema),
@@ -22,7 +22,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -27,7 +27,7 @@ export const BitbucketOauthCallbackPage = () => {
});
navigate({
to: "/projects/$projectId/secret-manager/integrations/bitbucket/create",
to: "/projects/secret-management/$projectId/integrations/bitbucket/create",
params: {
projectId: currentWorkspace.id
},
@@ -12,7 +12,7 @@ export const BitbucketOauthCallbackQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/bitbucket/oauth2/callback"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/bitbucket/oauth2/callback"
)({
component: BitbucketOauthCallbackPage,
validateSearch: zodValidator(BitbucketOauthCallbackQueryParamsSchema),
@@ -23,7 +23,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -36,7 +36,7 @@ export const ChecklyAuthorizePage = () => {
setIsLoading(false);
navigate({
to: "/projects/$projectId/secret-manager/integrations/checkly/create",
to: "/projects/secret-management/$projectId/integrations/checkly/create",
params: {
projectId: currentWorkspace.id
},
@@ -5,7 +5,7 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
import { ChecklyAuthorizePage } from "./ChecklyAuthorizePage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/checkly/authorize"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/checkly/authorize"
)({
component: ChecklyAuthorizePage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -113,7 +113,7 @@ export const ChecklyConfigurePage = () => {
setIsLoading(false);
navigate({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId: currentWorkspace.id
},
@@ -11,7 +11,7 @@ const ChecklyConfigurePageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/checkly/create"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/checkly/create"
)({
component: ChecklyConfigurePage,
validateSearch: zodValidator(ChecklyConfigurePageQueryParamsSchema),
@@ -22,7 +22,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -36,7 +36,7 @@ export const CircleCIAuthorizePage = () => {
setIsLoading(false);
navigate({
to: "/projects/$projectId/secret-manager/integrations/circleci/create",
to: "/projects/secret-management/$projectId/integrations/circleci/create",
params: {
projectId: currentWorkspace.id
},
@@ -5,7 +5,7 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
import { CircleCIAuthorizePage } from "./CircleCIAuthorizePage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/circleci/authorize"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/circleci/authorize"
)({
component: CircleCIAuthorizePage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -103,7 +103,7 @@ export const CircleCIConfigurePage = () => {
text: "Successfully created integration"
});
navigate({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId: currentWorkspace.id
},
@@ -11,7 +11,7 @@ const CircleCIConfigurePageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/circleci/create"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/circleci/create"
)({
component: CircleCIConfigurePage,
validateSearch: zodValidator(CircleCIConfigurePageQueryParamsSchema),
@@ -22,7 +22,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -34,7 +34,7 @@ export const Cloud66AuthorizePage = () => {
setIsLoading(false);
navigate({
to: "/projects/$projectId/secret-manager/integrations/cloud-66/create",
to: "/projects/secret-management/$projectId/integrations/cloud-66/create",
params: {
projectId: currentWorkspace.id
},
@@ -5,7 +5,7 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
import { Cloud66AuthorizePage } from "./Cloud66AuthorizePage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/cloud-66/authorize"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/cloud-66/authorize"
)({
component: Cloud66AuthorizePage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -75,7 +75,7 @@ export const Cloud66ConfigurePage = () => {
setIsLoading(false);
navigate({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId: currentWorkspace.id
},
@@ -11,7 +11,7 @@ const Cloud66ConfigurePageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/cloud-66/create"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/cloud-66/create"
)({
component: Cloud66ConfigurePage,
validateSearch: zodValidator(Cloud66ConfigurePageQueryParamsSchema),
@@ -22,7 +22,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -41,7 +41,7 @@ export const CloudflarePagesAuthorizePage = () => {
setIsLoading(false);
navigate({
to: "/projects/$projectId/secret-manager/integrations/cloudflare-pages/create",
to: "/projects/secret-management/$projectId/integrations/cloudflare-pages/create",
params: {
projectId: currentWorkspace.id
},
@@ -5,7 +5,7 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
import { CloudflarePagesAuthorizePage } from "./CloudflarePagesAuthorizePage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/cloudflare-pages/authorize"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/cloudflare-pages/authorize"
)({
component: CloudflarePagesAuthorizePage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -94,7 +94,7 @@ export const CloudflarePagesConfigurePage = () => {
setIsLoading(false);
navigate({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId: currentWorkspace.id
},
@@ -11,7 +11,7 @@ const CloudflarePagesConfigurePageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/cloudflare-pages/create"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/cloudflare-pages/create"
)({
component: CloudflarePagesConfigurePage,
validateSearch: zodValidator(CloudflarePagesConfigurePageQueryParamsSchema),
@@ -22,7 +22,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -41,7 +41,7 @@ export const CloudflareWorkersAuthorizePage = () => {
setIsLoading(false);
navigate({
to: "/projects/$projectId/secret-manager/integrations/cloudflare-workers/create",
to: "/projects/secret-management/$projectId/integrations/cloudflare-workers/create",
params: {
projectId: currentWorkspace.id
},
@@ -5,7 +5,7 @@ import { IntegrationsListPageTabs } from "@app/types/integrations";
import { CloudflareWorkersAuthorizePage } from "./CloudflareWorkersAuthorizePage";
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/cloudflare-workers/authorize"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/cloudflare-workers/authorize"
)({
component: CloudflareWorkersAuthorizePage,
beforeLoad: ({ context, params }) => {
@@ -15,7 +15,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -67,7 +67,7 @@ export const CloudflareWorkersConfigurePage = () => {
setIsLoading(false);
navigate({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params: {
projectId: currentWorkspace.id
},
@@ -11,7 +11,7 @@ const CloudflareWorkersConfigurePageQueryParamsSchema = z.object({
});
export const Route = createFileRoute(
"/_authenticate/_inject-org-details/_org-layout/projects/$projectId/_project-layout/secret-manager/_secret-manager-layout/integrations/cloudflare-workers/create"
"/_authenticate/_inject-org-details/_org-layout/projects/secret-management/$projectId/_secret-manager-layout/integrations/cloudflare-workers/create"
)({
component: CloudflareWorkersConfigurePage,
validateSearch: zodValidator(CloudflareWorkersConfigurePageQueryParamsSchema),
@@ -22,7 +22,7 @@ export const Route = createFileRoute(
{
label: "Integrations",
link: linkOptions({
to: "/projects/$projectId/secret-manager/integrations",
to: "/projects/secret-management/$projectId/integrations",
params,
search: {
selectedTab: IntegrationsListPageTabs.NativeIntegrations
@@ -33,7 +33,7 @@ export const CodefreshAuthorizePage = () => {
setIsLoading(false);
navigate({
to: "/projects/$projectId/secret-manager/integrations/codefresh/create",
to: "/projects/secret-management/$projectId/integrations/codefresh/create",
params: {
projectId: currentWorkspace.id
},

Some files were not shown because too many files have changed in this diff Show More