mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-21 06:27:13 +00:00
Revert "Revert "Revert "Revert "feat(PIT): Point In Time Revamp""""
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionCommitsActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { CommitDetailsTab } from "./components/CommitDetailsTab";
|
||||
|
||||
export const CommitDetailsPage = () => {
|
||||
const envSlug = useParams({
|
||||
from: ROUTE_PATHS.SecretManager.CommitDetailsPage.id,
|
||||
select: (el) => el.environment
|
||||
});
|
||||
const selectedCommitId = useParams({
|
||||
from: ROUTE_PATHS.SecretManager.CommitDetailsPage.id,
|
||||
select: (el) => el.commitId
|
||||
});
|
||||
const folderId = useParams({
|
||||
from: ROUTE_PATHS.SecretManager.CommitDetailsPage.id,
|
||||
select: (el) => el.folderId
|
||||
});
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const routerQueryParams: { secretPath?: string } = useSearch({
|
||||
from: ROUTE_PATHS.SecretManager.CommitDetailsPage.id
|
||||
});
|
||||
|
||||
const secretPath = (routerQueryParams.secretPath as string) || "/";
|
||||
|
||||
const handleGoBackToHistory = () => {
|
||||
navigate({
|
||||
to: `/${ProjectType.SecretManager}/$projectId/commits/$environment/$folderId` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id,
|
||||
folderId,
|
||||
environment: envSlug
|
||||
},
|
||||
search: (query) => ({
|
||||
...query,
|
||||
secretPath
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
const handleGoToRollbackPreview = () => {
|
||||
navigate({
|
||||
to: `/${ProjectType.SecretManager}/$projectId/commits/$environment/$folderId/$commitId/restore` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id,
|
||||
folderId,
|
||||
environment: envSlug,
|
||||
commitId: selectedCommitId
|
||||
},
|
||||
search: (query) => ({
|
||||
...query,
|
||||
secretPath
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl justify-center bg-bunker-800 pb-4 pt-2 text-white">
|
||||
<div className="w-full max-w-[75vw]">
|
||||
<ProjectPermissionCan
|
||||
renderGuardBanner
|
||||
I={ProjectPermissionCommitsActions.Read}
|
||||
a={ProjectPermissionSub.Commits}
|
||||
>
|
||||
<CommitDetailsTab
|
||||
selectedCommitId={selectedCommitId}
|
||||
workspaceId={currentWorkspace.id}
|
||||
goBackToHistory={handleGoBackToHistory}
|
||||
envSlug={envSlug}
|
||||
goToRollbackPreview={handleGoToRollbackPreview}
|
||||
/>
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+362
@@ -0,0 +1,362 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { faAngleDown } 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";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
IconButton,
|
||||
Spinner
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import {
|
||||
ProjectPermissionCommitsActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { CommitWithChanges } from "@app/hooks/api/folderCommits";
|
||||
import { useCommitRevert, useGetCommitDetails } from "@app/hooks/api/folderCommits/queries";
|
||||
import { CommitType } from "@app/hooks/api/types";
|
||||
|
||||
import { SecretVersionDiffView } from "../SecretVersionDiffView";
|
||||
import { MergedItem } from "./types";
|
||||
|
||||
const formatDisplayDate = (dateString: string): string => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true
|
||||
};
|
||||
return new Intl.DateTimeFormat("en-US", options).format(date);
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
export const CommitDetailsTab = ({
|
||||
selectedCommitId,
|
||||
workspaceId,
|
||||
envSlug,
|
||||
goBackToHistory,
|
||||
goToRollbackPreview
|
||||
}: {
|
||||
selectedCommitId: string;
|
||||
workspaceId: string;
|
||||
envSlug: string;
|
||||
goBackToHistory: () => void;
|
||||
goToRollbackPreview: () => void;
|
||||
}): JSX.Element => {
|
||||
// State for tracking collapsed items (empty by default means all are expanded)
|
||||
const [collapsedItems, setCollapsedItems] = useState<Record<string, boolean>>({});
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"revertChanges"
|
||||
] as const);
|
||||
|
||||
const { data: commitDetails, isLoading } = useGetCommitDetails(workspaceId, selectedCommitId);
|
||||
|
||||
const routerQueryParams: { secretPath?: string } = useSearch({
|
||||
from: ROUTE_PATHS.SecretManager.CommitDetailsPage.id
|
||||
});
|
||||
const secretPath = (routerQueryParams.secretPath as string) || "/";
|
||||
|
||||
const { mutateAsync: revert } = useCommitRevert({
|
||||
commitId: selectedCommitId,
|
||||
projectId: workspaceId,
|
||||
environment: envSlug,
|
||||
directory: secretPath
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCollapsedItems({});
|
||||
}, [selectedCommitId]);
|
||||
|
||||
const toggleItemCollapsed = (itemId: string): void => {
|
||||
setCollapsedItems((prev) => ({ ...prev, [itemId]: !prev[itemId] }));
|
||||
};
|
||||
|
||||
const handleRevertChanges = async (): Promise<void> => {
|
||||
const response = await revert();
|
||||
if (!response.success) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: response.message
|
||||
});
|
||||
return;
|
||||
}
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: response.message
|
||||
});
|
||||
|
||||
handlePopUpClose("revertChanges");
|
||||
|
||||
goBackToHistory();
|
||||
};
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the commit details if it's a string
|
||||
let parsedCommitDetails: CommitWithChanges;
|
||||
try {
|
||||
parsedCommitDetails =
|
||||
typeof commitDetails === "string" ? JSON.parse(commitDetails) : commitDetails;
|
||||
} 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>
|
||||
);
|
||||
}
|
||||
|
||||
// Get all changes from the commit
|
||||
const commitChanges = parsedCommitDetails.changes?.changes || [];
|
||||
|
||||
// Separate changes by type
|
||||
const addedChanges = commitChanges.filter((c) => c.changeType === CommitType.ADD && !c.isUpdate);
|
||||
const updatedChanges = commitChanges.filter((c) => c.changeType === CommitType.ADD && c.isUpdate);
|
||||
const deletedChanges = commitChanges.filter((c) => c.changeType === CommitType.DELETE);
|
||||
|
||||
// Create merged item list from changes only
|
||||
const changedItems: MergedItem[] = [];
|
||||
|
||||
// Add items from added changes
|
||||
addedChanges.forEach((change) => {
|
||||
changedItems.push({
|
||||
id: change.id,
|
||||
type: change.secretVersionId || change.secretKey ? "secret" : "folder",
|
||||
versionId: change.secretVersionId || change.id,
|
||||
folderName: change.folderName,
|
||||
folderVersion: change.folderVersion,
|
||||
secretKey: change.secretKey,
|
||||
secretVersion: change.secretVersion,
|
||||
isAdded: true,
|
||||
versions: change.versions,
|
||||
changeId: change.id
|
||||
});
|
||||
});
|
||||
|
||||
// Add items from updated changes
|
||||
updatedChanges.forEach((change) => {
|
||||
changedItems.push({
|
||||
id: change.id,
|
||||
type: change.secretVersionId || change.secretKey ? "secret" : "folder",
|
||||
versionId: change.secretVersionId || change.id,
|
||||
folderName: change.folderName,
|
||||
folderVersion: change.folderVersion,
|
||||
secretKey: change.secretKey,
|
||||
secretVersion: change.secretVersion,
|
||||
isUpdated: true,
|
||||
versions: change.versions,
|
||||
changeId: change.id
|
||||
});
|
||||
});
|
||||
|
||||
// Add deleted items
|
||||
deletedChanges.forEach((change) => {
|
||||
changedItems.push({
|
||||
id: change.id,
|
||||
type: change.secretVersionId || change.secretKey ? "secret" : "folder",
|
||||
secretKey: change.secretKey,
|
||||
folderName: change.folderName,
|
||||
secretVersion: change.secretVersion,
|
||||
folderVersion: change.folderVersion,
|
||||
isDeleted: true,
|
||||
versions: change.versions,
|
||||
changeId: change.id
|
||||
});
|
||||
});
|
||||
|
||||
// Sort items: deleted first, then folders, then secrets, all alphabetically
|
||||
const sortedChangedItems = [...changedItems].sort((a, b) => {
|
||||
// First sort deleted items to the top
|
||||
if (a.isDeleted !== b.isDeleted) {
|
||||
return a.isDeleted ? -1 : 1;
|
||||
}
|
||||
|
||||
// Then sort by type (folders before secrets)
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "folder" ? -1 : 1;
|
||||
}
|
||||
|
||||
// Finally sort alphabetically by name
|
||||
const aName = a.type === "folder" ? a.folderName || "" : a.secretKey || "";
|
||||
const bName = b.type === "folder" ? b.folderName || "" : b.secretKey || "";
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
|
||||
// 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>
|
||||
);
|
||||
};
|
||||
|
||||
// Format actor display
|
||||
const actorDisplay =
|
||||
parsedCommitDetails.changes?.actorMetadata?.name ||
|
||||
parsedCommitDetails.changes?.actorType ||
|
||||
"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" : ""}`}
|
||||
>
|
||||
<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>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.revertChanges.isOpen}
|
||||
deleteKey="revert"
|
||||
title="Are you sure you want to revert all changes made in this commit?"
|
||||
subTitle="This will undo all changes made in this commit."
|
||||
onChange={(isOpen) => handlePopUpToggle("revertChanges", isOpen)}
|
||||
onDeleteApproved={handleRevertChanges}
|
||||
buttonText="Yes, revert changes"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { CommitDetailsTab } from "./CommitDetailsTab";
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
export interface MergedItem {
|
||||
id: string;
|
||||
type: "secret" | "folder";
|
||||
versionId?: string;
|
||||
folderName?: string;
|
||||
folderVersion?: string;
|
||||
secretKey?: string;
|
||||
secretVersion?: string;
|
||||
isAdded?: boolean;
|
||||
isUpdated?: boolean;
|
||||
isDeleted?: boolean;
|
||||
versions?: any[];
|
||||
changeId: string;
|
||||
}
|
||||
+389
@@ -0,0 +1,389 @@
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
import { useEffect, useState } from "react";
|
||||
import { faFolder, faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
Input,
|
||||
PageHeader,
|
||||
Spinner,
|
||||
Switch,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { ROUTE_PATHS } from "@app/const/routes";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import {
|
||||
ProjectPermissionCommitsActions,
|
||||
ProjectPermissionSub
|
||||
} from "@app/context/ProjectPermissionContext/types";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useCommitRollback, useGetRollbackPreview } from "@app/hooks/api/folderCommits/queries";
|
||||
import { ProjectType } from "@app/hooks/api/workspace/types";
|
||||
|
||||
import { SecretVersionDiffView } from "../SecretVersionDiffView";
|
||||
|
||||
interface Version {
|
||||
// Common fields
|
||||
id?: string;
|
||||
version: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
|
||||
// Secret-specific fields
|
||||
secretKey?: string;
|
||||
secretValue?: string;
|
||||
secretComment?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
secretReminderRepeatDays?: number | null;
|
||||
secretReminderNote?: string | null;
|
||||
secretReminderRecipients?: string[];
|
||||
tags?: string[];
|
||||
metadata?: Record<string, unknown>;
|
||||
|
||||
// Folder-specific fields
|
||||
name?: string;
|
||||
envId?: string;
|
||||
folderId?: string;
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface RollbackChange {
|
||||
type: "secret" | "folder";
|
||||
id: string;
|
||||
versionId: string;
|
||||
fromVersion?: number;
|
||||
changeType: "create" | "update" | "delete";
|
||||
commitId: string;
|
||||
secretKey?: string;
|
||||
secretVersion?: number;
|
||||
folderName?: string;
|
||||
folderVersion?: number;
|
||||
versions?: Version[];
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
interface FolderChanges {
|
||||
folderId: string;
|
||||
folderName: string;
|
||||
folderPath: string;
|
||||
changes: RollbackChange[];
|
||||
}
|
||||
|
||||
export const RollbackPreviewTab = (): JSX.Element => {
|
||||
const [deepRollback, setDeepRollback] = useState<boolean>(false);
|
||||
const [message, setMessage] = useState<string>("");
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const envSlug = useParams({
|
||||
from: ROUTE_PATHS.SecretManager.RollbackPreviewPage.id,
|
||||
select: (el) => el.environment
|
||||
});
|
||||
const selectedCommitId = useParams({
|
||||
from: ROUTE_PATHS.SecretManager.RollbackPreviewPage.id,
|
||||
select: (el) => el.commitId
|
||||
});
|
||||
const folderId = useParams({
|
||||
from: ROUTE_PATHS.SecretManager.RollbackPreviewPage.id,
|
||||
select: (el) => el.folderId
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const routerQueryParams = useSearch({
|
||||
from: ROUTE_PATHS.SecretManager.RollbackPreviewPage.id
|
||||
});
|
||||
|
||||
const secretPath = (routerQueryParams.secretPath as string) || "/";
|
||||
|
||||
const goBackToHistory = () => {
|
||||
navigate({
|
||||
to: `/${ProjectType.SecretManager}/$projectId/commits/$environment/$folderId` as const,
|
||||
params: {
|
||||
projectId: currentWorkspace.id,
|
||||
folderId,
|
||||
environment: envSlug
|
||||
},
|
||||
search: (query) => ({
|
||||
...query,
|
||||
secretPath
|
||||
})
|
||||
});
|
||||
};
|
||||
|
||||
const { handlePopUpToggle, popUp, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"rollbackConfirm"
|
||||
] as const);
|
||||
|
||||
const { mutateAsync: rollback } = useCommitRollback({
|
||||
workspaceId: currentWorkspace.id,
|
||||
commitId: selectedCommitId,
|
||||
folderId,
|
||||
deepRollback,
|
||||
environment: envSlug,
|
||||
directory: secretPath,
|
||||
envSlug
|
||||
});
|
||||
|
||||
const { data: rollbackChangesNested, isLoading } = useGetRollbackPreview(
|
||||
folderId,
|
||||
selectedCommitId,
|
||||
envSlug,
|
||||
currentWorkspace.id,
|
||||
deepRollback,
|
||||
secretPath
|
||||
);
|
||||
|
||||
const handleRollback = async (): Promise<void> => {
|
||||
try {
|
||||
await rollback(message);
|
||||
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Rollback completed successfully"
|
||||
});
|
||||
|
||||
handlePopUpClose("rollbackConfirm");
|
||||
goBackToHistory();
|
||||
} catch (error) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : "Failed to rollback changes"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const folderChanges: FolderChanges[] = rollbackChangesNested || [];
|
||||
|
||||
const currentFolderChanges: FolderChanges = folderChanges.find(
|
||||
(folder) => folder.folderId === folderId
|
||||
) || {
|
||||
folderId: folderId || "",
|
||||
folderName: "Current Folder",
|
||||
folderPath: secretPath,
|
||||
changes: []
|
||||
};
|
||||
const nestedFolderChanges: FolderChanges[] = folderChanges.filter(
|
||||
(folder) => folder.folderId !== folderId
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Select the current folder by default
|
||||
if (folderChanges.length > 0) {
|
||||
setSelectedFolderId(currentFolderChanges.folderId);
|
||||
}
|
||||
}, [folderChanges, currentFolderChanges.folderId]);
|
||||
|
||||
if (!selectedCommitId) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<p className="text-gray-400">Select a commit to view rollback preview</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Spinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderSidebar = (): JSX.Element => {
|
||||
return (
|
||||
<div className="h-[70vh] w-72 overflow-y-auto bg-mineshaft-900">
|
||||
<div
|
||||
className={`cursor-pointer border-b border-mineshaft-600 ${
|
||||
selectedFolderId === currentFolderChanges.folderId ? "bg-mineshaft-700" : ""
|
||||
}`}
|
||||
onClick={() => setSelectedFolderId(currentFolderChanges.folderId)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center">
|
||||
<FontAwesomeIcon icon={faFolder} className="mr-2 text-yellow-500" />
|
||||
<span className="font-medium text-white">
|
||||
{currentFolderChanges.folderPath || currentFolderChanges.folderName}
|
||||
</span>
|
||||
</div>
|
||||
{currentFolderChanges.changes.length > 0 && (
|
||||
<span className="ml-2 rounded-full bg-mineshaft-600 px-2 py-0.5 text-xs text-gray-300">
|
||||
{currentFolderChanges.changes.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deepRollback && nestedFolderChanges.length > 0 && (
|
||||
<>
|
||||
<div className="border-b border-mineshaft-600 bg-mineshaft-800 px-4 py-2">
|
||||
<span className="text-sm font-semibold text-white">Child folders to be restored</span>
|
||||
</div>
|
||||
{nestedFolderChanges.map((folder) => (
|
||||
<div
|
||||
key={folder.folderId}
|
||||
className={`cursor-pointer border-b border-mineshaft-600 ${
|
||||
selectedFolderId === folder.folderId ? "bg-mineshaft-700" : ""
|
||||
}`}
|
||||
onClick={() => setSelectedFolderId(folder.folderId)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<div className="flex items-center">
|
||||
<FontAwesomeIcon icon={faFolder} className="mr-2 text-yellow-500" size="sm" />
|
||||
<span className="max-w-[150px] truncate text-sm font-medium text-white">
|
||||
{folder.folderPath || folder.folderName}
|
||||
</span>
|
||||
</div>
|
||||
{folder.changes.length > 0 && (
|
||||
<span className="rounded-full bg-mineshaft-600 px-2 py-0.5 text-xs text-gray-300">
|
||||
{folder.changes.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getSelectedFolderChanges = (): RollbackChange[] => {
|
||||
if (!selectedFolderId) return [];
|
||||
|
||||
const folder = folderChanges.find((f) => f.folderId === selectedFolderId);
|
||||
return folder?.changes || [];
|
||||
};
|
||||
|
||||
const renderMainContent = (): JSX.Element => {
|
||||
const selectedFolderChanges = getSelectedFolderChanges();
|
||||
const selectedFolder = folderChanges.find((f) => f.folderId === selectedFolderId);
|
||||
|
||||
if (!selectedFolder || selectedFolderChanges.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[70vh] w-full items-center justify-center border border-mineshaft-600">
|
||||
<p className="text-gray-400">No changes in selected folder</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[70vh] w-full overflow-y-auto border-l border-mineshaft-600">
|
||||
<div className="space-y-4 p-4">
|
||||
{selectedFolderChanges.map((change) => (
|
||||
<div key={change.id} className="mb-4">
|
||||
<SecretVersionDiffView
|
||||
item={{
|
||||
id: change.id,
|
||||
type: change.type,
|
||||
isAdded: change.changeType === "create",
|
||||
isDeleted: change.changeType === "delete",
|
||||
isUpdated: change.changeType === "update",
|
||||
versions: change.versions,
|
||||
isRollback: true,
|
||||
secretKey: change.secretKey,
|
||||
folderName: change.folderName
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-7xl justify-center bg-bunker-800 pb-4 pt-2 text-white">
|
||||
<ProjectPermissionCan
|
||||
renderGuardBanner
|
||||
I={ProjectPermissionCommitsActions.PerformRollback}
|
||||
a={ProjectPermissionSub.Commits}
|
||||
>
|
||||
<div className="w-full max-w-[75vw]">
|
||||
<div className="h-full w-full">
|
||||
<div>
|
||||
<PageHeader
|
||||
title={`Restore folder at commit ${selectedCommitId.substring(0, 8)}`}
|
||||
description={`Will return all changes in this folder to how they appeared at the point of commit ${selectedCommitId.substring(0, 8)}. Any modifications made after this commit will be undone.`}
|
||||
/>
|
||||
|
||||
<div className="flex w-full border border-mineshaft-600">
|
||||
{renderSidebar()}
|
||||
{renderMainContent()}
|
||||
</div>
|
||||
|
||||
<div className="border-x border-mineshaft-600 bg-mineshaft-800 px-6 py-3">
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center">
|
||||
<Tooltip content="Will rollback all nested folders to their state at the time of this commit">
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="mr-2 text-mineshaft-400" />
|
||||
</Tooltip>
|
||||
<Switch
|
||||
className="ml-2 bg-mineshaft-400/50 shadow-inner data-[state=checked]:bg-green/50"
|
||||
thumbClassName="bg-mineshaft-800"
|
||||
isChecked={deepRollback}
|
||||
onCheckedChange={setDeepRollback}
|
||||
id="deep-rollback"
|
||||
>
|
||||
Restore All Child Folders
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-mineshaft-600 py-4">
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Input
|
||||
placeholder="Restore Message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
className="w-full border-mineshaft-500 bg-mineshaft-700 py-2 text-sm"
|
||||
maxLength={256}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => {
|
||||
handlePopUpOpen("rollbackConfirm");
|
||||
}}
|
||||
colorSchema="primary"
|
||||
className="px-6 py-2"
|
||||
isDisabled={
|
||||
message.length === 0 ||
|
||||
!rollbackChangesNested ||
|
||||
!rollbackChangesNested?.some((folder) => {
|
||||
return folder.changes.length > 0;
|
||||
})
|
||||
}
|
||||
>
|
||||
Restore
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.rollbackConfirm.isOpen}
|
||||
deleteKey="restore"
|
||||
title={`Are you sure you want to restore to commit ${selectedCommitId?.substring(0, 8)}?`}
|
||||
subTitle={`
|
||||
${
|
||||
deepRollback
|
||||
? "This will revert this folder and all child folders to how they appeared at the point in time of this commit."
|
||||
: "This will revert all changes to how they appeared at the point in time of this commit."
|
||||
}
|
||||
Any changes made after this commit will be permanently removed.
|
||||
`}
|
||||
onChange={(isOpen) => handlePopUpToggle("rollbackConfirm", isOpen)}
|
||||
onDeleteApproved={handleRollback}
|
||||
buttonText="Restore"
|
||||
/>
|
||||
</div>{" "}
|
||||
</div>{" "}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { RollbackPreviewTab } from "./RollbackPreviewTab";
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretDashboardPathBreadcrumb } from "@app/components/navigation/SecretDashboardPathBreadcrumb";
|
||||
import { BreadcrumbTypes } from "@app/components/v2";
|
||||
|
||||
import { RollbackPreviewTab } from "./RollbackPreviewTab";
|
||||
|
||||
const RollbackPreviewTabQueryParamsSchema = z.object({
|
||||
secretPath: z.string().catch("/")
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId/restore"
|
||||
)({
|
||||
component: RollbackPreviewTab,
|
||||
validateSearch: zodValidator(RollbackPreviewTabQueryParamsSchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ secretPath: "/" })]
|
||||
},
|
||||
beforeLoad: ({ context, params, search }) => {
|
||||
const secretPathSegments = search.secretPath.split("/").filter(Boolean);
|
||||
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
type: BreadcrumbTypes.Dropdown,
|
||||
label:
|
||||
context.project.environments.find((el) => el.slug === params.environment)?.name || "",
|
||||
dropdownTitle: "Environments",
|
||||
links: context.project.environments.map((el) => ({
|
||||
label: el.name,
|
||||
link: linkOptions({
|
||||
to: "/secret-manager/$projectId/secrets/$envSlug",
|
||||
params: {
|
||||
projectId: params.projectId,
|
||||
envSlug: el.slug
|
||||
}
|
||||
})
|
||||
}))
|
||||
},
|
||||
...secretPathSegments.map((_, index) => ({
|
||||
type: BreadcrumbTypes.Component,
|
||||
component: () => (
|
||||
<SecretDashboardPathBreadcrumb
|
||||
secretPathSegments={secretPathSegments}
|
||||
selectedPathSegmentIndex={index}
|
||||
environmentSlug={params.environment}
|
||||
projectId={params.projectId}
|
||||
disableCopy
|
||||
/>
|
||||
)
|
||||
})),
|
||||
{
|
||||
label: "Commits",
|
||||
link: linkOptions({
|
||||
to: "/secret-manager/$projectId/commits/$environment/$folderId",
|
||||
params: {
|
||||
projectId: params.projectId,
|
||||
environment: params.environment,
|
||||
folderId: params.folderId
|
||||
},
|
||||
search: {
|
||||
secretPath: search.secretPath
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: params.commitId,
|
||||
link: linkOptions({
|
||||
to: "/secret-manager/$projectId/commits/$environment/$folderId/$commitId",
|
||||
params: {
|
||||
projectId: params.projectId,
|
||||
environment: params.environment,
|
||||
folderId: params.folderId,
|
||||
commitId: params.commitId
|
||||
},
|
||||
search: {
|
||||
secretPath: search.secretPath
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: "Restore"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
+652
@@ -0,0 +1,652 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
export interface Version {
|
||||
id?: string;
|
||||
version: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
||||
type JsonObject = { [key: string]: JsonValue };
|
||||
type JsonArray = JsonValue[];
|
||||
|
||||
export interface DiffViewItem {
|
||||
type: "secret" | "folder";
|
||||
isAdded?: boolean;
|
||||
isDeleted?: boolean;
|
||||
isUpdated?: boolean;
|
||||
versions?: Version[];
|
||||
isRollback?: boolean;
|
||||
id: string;
|
||||
secretKey?: string;
|
||||
folderName?: string;
|
||||
}
|
||||
|
||||
interface SecretVersionDiffViewProps {
|
||||
item: DiffViewItem;
|
||||
isCollapsed?: boolean;
|
||||
onToggleCollapse?: (id: string) => void;
|
||||
showHeader?: boolean;
|
||||
customHeader?: JSX.Element;
|
||||
excludedFieldsHighlight?: string[];
|
||||
}
|
||||
|
||||
const isObject = (obj: JsonValue): obj is JsonObject => {
|
||||
return obj !== null && typeof obj === "object" && !Array.isArray(obj);
|
||||
};
|
||||
|
||||
const isArray = (obj: JsonValue): obj is JsonArray => {
|
||||
return Array.isArray(obj);
|
||||
};
|
||||
|
||||
const deepEqual = (a: JsonValue, b: JsonValue): boolean => {
|
||||
if (a === b) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (typeof a !== typeof b) return false;
|
||||
|
||||
if (isArray(a) && isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((item: JsonValue, index: number) => deepEqual(item, b[index]));
|
||||
}
|
||||
|
||||
if (isObject(a) && isObject(b)) {
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
return keysA.every((key) => keysB.includes(key) && deepEqual(a[key], b[key]));
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const getDiffPaths = (oldObj: JsonValue, newObj: JsonValue, path: string = ""): Set<string> => {
|
||||
const diffPaths = new Set<string>();
|
||||
|
||||
if (oldObj === newObj) return diffPaths;
|
||||
|
||||
if (oldObj == null || newObj == null) {
|
||||
diffPaths.add(path || "root");
|
||||
return diffPaths;
|
||||
}
|
||||
|
||||
if (typeof oldObj !== typeof newObj) {
|
||||
diffPaths.add(path || "root");
|
||||
return diffPaths;
|
||||
}
|
||||
|
||||
if (isArray(oldObj) && isArray(newObj)) {
|
||||
return diffPaths;
|
||||
}
|
||||
|
||||
if (isObject(oldObj) && isObject(newObj)) {
|
||||
const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
const currentPath = path ? `${path}.${key}` : key;
|
||||
|
||||
if (path.includes("[")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(key in oldObj) || !(key in newObj) || !deepEqual(oldObj[key], newObj[key])) {
|
||||
diffPaths.add(currentPath);
|
||||
|
||||
if (
|
||||
key in oldObj &&
|
||||
key in newObj &&
|
||||
(isObject(oldObj[key]) || isArray(oldObj[key])) &&
|
||||
(isObject(newObj[key]) || isArray(newObj[key]))
|
||||
) {
|
||||
const nestedDiffs = getDiffPaths(oldObj[key], newObj[key], currentPath);
|
||||
nestedDiffs.forEach((p) => diffPaths.add(p));
|
||||
}
|
||||
}
|
||||
});
|
||||
return diffPaths;
|
||||
}
|
||||
|
||||
if (oldObj !== newObj) {
|
||||
diffPaths.add(path || "root");
|
||||
}
|
||||
|
||||
return diffPaths;
|
||||
};
|
||||
|
||||
const getNestedValue = (obj: JsonValue, path: string): JsonValue => {
|
||||
if (!path) return obj;
|
||||
|
||||
const parts = path.split(/[.[\]]+/).filter(Boolean);
|
||||
let current: JsonValue = obj;
|
||||
|
||||
parts.forEach((part) => {
|
||||
if (current == null) {
|
||||
return;
|
||||
}
|
||||
if (isObject(current)) {
|
||||
current = current[part];
|
||||
} else if (isArray(current)) {
|
||||
const index = parseInt(part, 10);
|
||||
if (!Number.isNaN(index)) {
|
||||
current = current[index];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return current;
|
||||
};
|
||||
|
||||
const isPathDifferent = (jsonPath: string, diffPaths: Set<string>): boolean => {
|
||||
if (diffPaths.has(jsonPath)) return true;
|
||||
|
||||
const diffPathsArray = Array.from(diffPaths);
|
||||
return diffPathsArray.some((diffPath) => {
|
||||
return (
|
||||
jsonPath.startsWith(`${diffPath}.`) ||
|
||||
jsonPath.startsWith(`${diffPath}[`) ||
|
||||
diffPath.startsWith(`${jsonPath}.`) ||
|
||||
diffPath.startsWith(`${jsonPath}[`)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const isContainerActuallyChanged = (
|
||||
path: string,
|
||||
diffPaths: Set<string>,
|
||||
oldObj: JsonValue,
|
||||
newObj: JsonValue
|
||||
): boolean => {
|
||||
if (diffPaths.has(path)) {
|
||||
if (oldObj == null || newObj == null) return true;
|
||||
if (typeof oldObj !== typeof newObj) return true;
|
||||
if (isArray(oldObj) !== isArray(newObj)) return true;
|
||||
if (isObject(oldObj) !== isObject(newObj)) return true;
|
||||
|
||||
if (!isObject(oldObj) && !isArray(oldObj)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isArray(oldObj) && isArray(newObj)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isObject(oldObj) && isObject(newObj)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return oldObj !== newObj;
|
||||
};
|
||||
|
||||
const renderJsonWithDiffs = (
|
||||
obj: JsonValue,
|
||||
diffPaths: Set<string>,
|
||||
isOldVersion: boolean,
|
||||
path: string = "",
|
||||
indentLevel: number = 0,
|
||||
keyName?: string,
|
||||
isLastItem: boolean = false,
|
||||
excludedFieldsHighlight: string[] = [],
|
||||
oldVersionObj?: JsonValue,
|
||||
newVersionObj?: JsonValue
|
||||
): JSX.Element => {
|
||||
const indent = " ".repeat(indentLevel);
|
||||
|
||||
let isDifferent = false;
|
||||
|
||||
if (path.includes("[") && oldVersionObj && newVersionObj) {
|
||||
const arrayMatch = path.match(/^([^[]+)\[(\d+)\]/);
|
||||
if (arrayMatch) {
|
||||
const arrayPath = arrayMatch[1];
|
||||
const itemIndex = parseInt(arrayMatch[2], 10);
|
||||
|
||||
const oldArray = getNestedValue(oldVersionObj, arrayPath);
|
||||
const newArray = getNestedValue(newVersionObj, arrayPath);
|
||||
|
||||
if (isArray(oldArray) && isArray(newArray)) {
|
||||
const currentItem = isOldVersion ? oldArray[itemIndex] : newArray[itemIndex];
|
||||
|
||||
if (isOldVersion) {
|
||||
isDifferent = !newArray.some((newItem: JsonValue) => deepEqual(currentItem, newItem));
|
||||
} else {
|
||||
isDifferent = !oldArray.some((oldItem: JsonValue) => deepEqual(currentItem, oldItem));
|
||||
}
|
||||
} else {
|
||||
isDifferent = isPathDifferent(path, diffPaths);
|
||||
}
|
||||
} else {
|
||||
isDifferent = isPathDifferent(path, diffPaths);
|
||||
}
|
||||
} else {
|
||||
isDifferent = isPathDifferent(path, diffPaths);
|
||||
}
|
||||
|
||||
const getLineClass = (different: boolean) => {
|
||||
if (!different) return "flex";
|
||||
return isOldVersion ? "flex bg-red-950 text-red-300" : "flex bg-green-950 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 keyDisplay = keyName ? `"${keyName}": ` : "";
|
||||
const comma = !isLastItem ? "," : "";
|
||||
|
||||
const reactKey = `${path || "root"}-${keyName || "value"}-${indentLevel}-${typeof obj}`;
|
||||
|
||||
if (
|
||||
obj === null ||
|
||||
typeof obj === "string" ||
|
||||
typeof obj === "number" ||
|
||||
typeof obj === "boolean"
|
||||
) {
|
||||
let valueDisplay = "";
|
||||
if (obj === null) valueDisplay = "null";
|
||||
else if (typeof obj === "string") valueDisplay = `"${obj}"`;
|
||||
else valueDisplay = String(obj);
|
||||
|
||||
return (
|
||||
<div key={reactKey} className={getLineClass(isDifferent)}>
|
||||
<div className="w-4 flex-shrink-0">{prefix}</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
|
||||
<span className={getHighlightClass(isDifferent)}>{valueDisplay}</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isArray(obj) && obj.length === 0) {
|
||||
return (
|
||||
<div key={reactKey} className={getLineClass(isDifferent)}>
|
||||
<div className="w-4 flex-shrink-0">{prefix}</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
|
||||
<span className={getHighlightClass(isDifferent)}>[]</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(obj) && Object.keys(obj).length === 0) {
|
||||
return (
|
||||
<div key={reactKey} className={getLineClass(isDifferent)}>
|
||||
<div className="w-4 flex-shrink-0">{prefix}</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && <span className={getHighlightClass(isDifferent)}>{keyDisplay}</span>}
|
||||
<span className={getHighlightClass(isDifferent)}>{"{}"}</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let isContainerAddedOrRemoved = false;
|
||||
|
||||
if (oldVersionObj && newVersionObj) {
|
||||
const oldValue = getNestedValue(oldVersionObj, path);
|
||||
const newValue = getNestedValue(newVersionObj, path);
|
||||
|
||||
if (oldValue == null || newValue == null) {
|
||||
isContainerAddedOrRemoved = true;
|
||||
} else if (typeof oldValue !== typeof newValue) {
|
||||
isContainerAddedOrRemoved = true;
|
||||
} else if (isArray(oldValue) !== isArray(newValue)) {
|
||||
isContainerAddedOrRemoved = true;
|
||||
} else if (isObject(oldValue) !== isObject(newValue)) {
|
||||
isContainerAddedOrRemoved = true;
|
||||
}
|
||||
} else {
|
||||
isContainerAddedOrRemoved = isContainerActuallyChanged(
|
||||
path,
|
||||
diffPaths,
|
||||
isOldVersion ? obj : oldVersionObj || null,
|
||||
isOldVersion ? newVersionObj || null : obj
|
||||
);
|
||||
}
|
||||
|
||||
if (isArray(obj)) {
|
||||
return (
|
||||
<div key={reactKey}>
|
||||
<div className={getLineClass(isContainerAddedOrRemoved)}>
|
||||
<div className="w-4 flex-shrink-0">
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
|
||||
</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && (
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>
|
||||
{keyDisplay}
|
||||
</span>
|
||||
)}
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>[</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{obj.map((item: JsonValue, index: number) => {
|
||||
const itemPath = path ? `${path}[${index}]` : `[${index}]`;
|
||||
const isLast = index === obj.length - 1;
|
||||
|
||||
return (
|
||||
<div key={`${reactKey}-item-${index + 1}`}>
|
||||
{renderJsonWithDiffs(
|
||||
item,
|
||||
diffPaths,
|
||||
isOldVersion,
|
||||
itemPath,
|
||||
indentLevel + 1,
|
||||
undefined,
|
||||
isLast,
|
||||
excludedFieldsHighlight,
|
||||
oldVersionObj,
|
||||
newVersionObj
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className={getLineClass(isContainerAddedOrRemoved)}>
|
||||
<div className="w-4 flex-shrink-0">
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
|
||||
</div>
|
||||
<div>
|
||||
{indent}
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>]</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(obj)) {
|
||||
const keys = Object.keys(obj);
|
||||
|
||||
return (
|
||||
<div key={reactKey}>
|
||||
<div className={getLineClass(isContainerAddedOrRemoved)}>
|
||||
<div className="w-4 flex-shrink-0">
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
|
||||
</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyName && (
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>
|
||||
{keyDisplay}
|
||||
</span>
|
||||
)}
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>{"{"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{keys.map((key, index) => {
|
||||
const keyPath = path ? `${path}.${key}` : key;
|
||||
const isLast = index === keys.length - 1;
|
||||
const propKey = `${reactKey}-prop-${key}`;
|
||||
|
||||
return (
|
||||
<div key={propKey}>
|
||||
{renderJsonWithDiffs(
|
||||
obj[key],
|
||||
diffPaths,
|
||||
isOldVersion,
|
||||
keyPath,
|
||||
indentLevel + 1,
|
||||
key,
|
||||
isLast,
|
||||
excludedFieldsHighlight,
|
||||
oldVersionObj,
|
||||
newVersionObj
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className={getLineClass(isContainerAddedOrRemoved)}>
|
||||
<div className="w-4 flex-shrink-0">
|
||||
{isContainerAddedOrRemoved ? (isOldVersion ? "-" : "+") : " "}
|
||||
</div>
|
||||
<div>
|
||||
{indent}
|
||||
<span className={isContainerAddedOrRemoved ? getHighlightClass(true) : ""}>{"}"}</span>
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={reactKey} className={getLineClass(isDifferent)}>
|
||||
<div className="w-4 flex-shrink-0">{prefix}</div>
|
||||
<div>
|
||||
{indent}
|
||||
{keyDisplay}
|
||||
{String(obj)}
|
||||
{comma}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatAddedJson = (json: JsonValue): JSX.Element => {
|
||||
const lines = JSON.stringify(json, null, 2).split("\n");
|
||||
return (
|
||||
<div className="font-mono text-sm text-green-300">
|
||||
{lines.map((line, lineIndex) => {
|
||||
const lineKey = `added-${line.slice(0, 30)}-${lineIndex}`;
|
||||
return (
|
||||
<div key={lineKey} className="flex">
|
||||
<div className="w-4 flex-shrink-0">+</div>
|
||||
<div>{line}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const formatDeletedJson = (json: JsonValue): JSX.Element => {
|
||||
const lines = JSON.stringify(json, null, 2).split("\n");
|
||||
return (
|
||||
<div className="font-mono text-sm text-red-300">
|
||||
{lines.map((line, lineIndex) => {
|
||||
const lineKey = `deleted-${line.slice(0, 30)}-${lineIndex}`;
|
||||
return (
|
||||
<div key={lineKey} className="flex">
|
||||
<div className="w-4 flex-shrink-0">-</div>
|
||||
<div>{line}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const cleanVersionForComparison = (version: Version): JsonValue => {
|
||||
const { id, version: versionNumber, ...cleanVersion } = version;
|
||||
return cleanVersion;
|
||||
};
|
||||
|
||||
export const SecretVersionDiffView = ({
|
||||
item,
|
||||
isCollapsed = false,
|
||||
onToggleCollapse,
|
||||
showHeader = true,
|
||||
customHeader,
|
||||
excludedFieldsHighlight = ["metadata", "tags"]
|
||||
}: SecretVersionDiffViewProps) => {
|
||||
const oldContainerRef = useRef<HTMLDivElement>(null);
|
||||
const newContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [internalCollapsed, setInternalCollapsed] = useState(isCollapsed);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
if (onToggleCollapse && item.id) {
|
||||
onToggleCollapse(item.id);
|
||||
} else {
|
||||
setInternalCollapsed((prev) => !prev);
|
||||
}
|
||||
}, [onToggleCollapse, item.id]);
|
||||
|
||||
const collapsed = onToggleCollapse ? isCollapsed : internalCollapsed;
|
||||
|
||||
if (!item.versions || item.versions.length === 0) {
|
||||
return <div className="px-6 py-3 text-gray-400">No details available</div>;
|
||||
}
|
||||
|
||||
const sortedVersions = [...item.versions].sort((a, b) => b.version - a.version);
|
||||
let oldVersion = null;
|
||||
let newVersion = null;
|
||||
let oldVersionContent = null;
|
||||
let newVersionContent = null;
|
||||
let diffPaths = new Set<string>();
|
||||
|
||||
if (item.isUpdated && sortedVersions.length >= 2) {
|
||||
if (item.isRollback) {
|
||||
[oldVersion, newVersion] = sortedVersions;
|
||||
} else {
|
||||
[newVersion, oldVersion] = sortedVersions;
|
||||
}
|
||||
|
||||
const cleanOldVersion = cleanVersionForComparison(oldVersion);
|
||||
const cleanNewVersion = cleanVersionForComparison(newVersion);
|
||||
diffPaths = getDiffPaths(cleanOldVersion, cleanNewVersion);
|
||||
|
||||
if (diffPaths.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
oldVersionContent = (
|
||||
<div className="font-mono text-sm">
|
||||
{renderJsonWithDiffs(
|
||||
cleanOldVersion,
|
||||
diffPaths,
|
||||
true,
|
||||
"",
|
||||
0,
|
||||
undefined,
|
||||
false,
|
||||
excludedFieldsHighlight,
|
||||
cleanOldVersion,
|
||||
cleanNewVersion
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
newVersionContent = (
|
||||
<div className="font-mono text-sm">
|
||||
{renderJsonWithDiffs(
|
||||
cleanNewVersion,
|
||||
diffPaths,
|
||||
false,
|
||||
"",
|
||||
0,
|
||||
undefined,
|
||||
false,
|
||||
excludedFieldsHighlight,
|
||||
cleanOldVersion,
|
||||
cleanNewVersion
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else if (item.isAdded) {
|
||||
[newVersion] = sortedVersions;
|
||||
const cleanNewVersion = cleanVersionForComparison(newVersion);
|
||||
newVersionContent = formatAddedJson(cleanNewVersion);
|
||||
} else if (item.isDeleted) {
|
||||
[oldVersion] = sortedVersions;
|
||||
const cleanOldVersion = cleanVersionForComparison(oldVersion);
|
||||
oldVersionContent = formatDeletedJson(cleanOldVersion);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderHeader = () => {
|
||||
if (customHeader) {
|
||||
return customHeader;
|
||||
}
|
||||
|
||||
const isSecret = item.type === "secret";
|
||||
const key = isSecret ? item.secretKey || "Unnamed Secret" : item.folderName || "Unnamed Folder";
|
||||
let textStyle = "text-white";
|
||||
let changeBadge = null;
|
||||
|
||||
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">
|
||||
{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">
|
||||
{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">
|
||||
{isSecret ? "Secret" : "Folder"} Updated
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center justify-between p-4 hover:bg-mineshaft-700"
|
||||
onClick={handleToggle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleToggle();
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-expanded={!collapsed}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<span className={textStyle}>{key}</span>
|
||||
{changeBadge}
|
||||
</div>
|
||||
<FontAwesomeIcon icon={collapsed ? faChevronDown : faChevronUp} className="text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-mineshaft-600 bg-mineshaft-800">
|
||||
{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
|
||||
ref={oldContainerRef}
|
||||
className="thin-scrollbar max-h-96 overflow-auto whitespace-pre rounded border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
>
|
||||
{oldVersionContent}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={newContainerRef}
|
||||
className="thin-scrollbar max-h-96 overflow-auto whitespace-pre rounded border border-mineshaft-600 bg-mineshaft-900 p-4"
|
||||
>
|
||||
{newVersionContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+1
@@ -0,0 +1 @@
|
||||
export { SecretVersionDiffView } from "./SecretVersionDiffView";
|
||||
@@ -0,0 +1 @@
|
||||
export { CommitDetailsPage } from "./CommitDetailsPage";
|
||||
@@ -0,0 +1,76 @@
|
||||
import { createFileRoute, linkOptions, stripSearchParams } from "@tanstack/react-router";
|
||||
import { zodValidator } from "@tanstack/zod-adapter";
|
||||
import { z } from "zod";
|
||||
|
||||
import { SecretDashboardPathBreadcrumb } from "@app/components/navigation/SecretDashboardPathBreadcrumb";
|
||||
import { BreadcrumbTypes } from "@app/components/v2";
|
||||
|
||||
import { CommitDetailsPage } from "./CommitDetailsPage";
|
||||
|
||||
const CommitDetailsPageQueryParamsSchema = z.object({
|
||||
secretPath: z.string().catch("/")
|
||||
});
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_authenticate/_inject-org-details/_org-layout/secret-manager/$projectId/_secret-manager-layout/commits/$environment/$folderId/$commitId/"
|
||||
)({
|
||||
component: CommitDetailsPage,
|
||||
validateSearch: zodValidator(CommitDetailsPageQueryParamsSchema),
|
||||
search: {
|
||||
middlewares: [stripSearchParams({ secretPath: "/" })]
|
||||
},
|
||||
beforeLoad: ({ context, params, search }) => {
|
||||
const secretPathSegments = search.secretPath.split("/").filter(Boolean);
|
||||
|
||||
return {
|
||||
breadcrumbs: [
|
||||
...context.breadcrumbs,
|
||||
{
|
||||
type: BreadcrumbTypes.Dropdown,
|
||||
label:
|
||||
context.project.environments.find((el) => el.slug === params.environment)?.name || "",
|
||||
dropdownTitle: "Environments",
|
||||
links: context.project.environments.map((el) => ({
|
||||
label: el.name,
|
||||
link: linkOptions({
|
||||
to: "/secret-manager/$projectId/secrets/$envSlug",
|
||||
params: {
|
||||
projectId: params.projectId,
|
||||
envSlug: el.slug
|
||||
}
|
||||
})
|
||||
}))
|
||||
},
|
||||
...secretPathSegments.map((_, index) => ({
|
||||
type: BreadcrumbTypes.Component,
|
||||
component: () => (
|
||||
<SecretDashboardPathBreadcrumb
|
||||
secretPathSegments={secretPathSegments}
|
||||
selectedPathSegmentIndex={index}
|
||||
environmentSlug={params.environment}
|
||||
projectId={params.projectId}
|
||||
disableCopy
|
||||
/>
|
||||
)
|
||||
})),
|
||||
{
|
||||
label: "Commits",
|
||||
link: linkOptions({
|
||||
to: "/secret-manager/$projectId/commits/$environment/$folderId",
|
||||
params: {
|
||||
projectId: params.projectId,
|
||||
environment: params.environment,
|
||||
folderId: params.folderId
|
||||
},
|
||||
search: {
|
||||
secretPath: search.secretPath
|
||||
}
|
||||
})
|
||||
},
|
||||
{
|
||||
label: params.commitId
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user