Addressed PR suggestions

This commit is contained in:
Carlos Monastyrski
2025-07-18 12:36:28 -03:00
parent 5110d59bea
commit cf6b9d8905
11 changed files with 452 additions and 153 deletions
+19 -1
View File
@@ -535,7 +535,7 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => { handler: async (req) => {
await server.services.pit.processNewCommitRaw({ const result = await server.services.pit.processNewCommitRaw({
actorId: req.permission.id, actorId: req.permission.id,
actor: req.permission.type, actor: req.permission.type,
actorOrgId: req.permission.orgId, actorOrgId: req.permission.orgId,
@@ -549,6 +549,24 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
folders: req.body.changes.folders folders: req.body.changes.folders
} }
}); });
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
projectId: req.body.projectId,
event: {
type: EventType.PIT_PROCESS_NEW_COMMIT_RAW,
metadata: {
commitId: result.commitId,
folderChanges: result.folderChanges,
approvalId: result.approvalId,
projectId: req.body.projectId,
environment: req.body.environment,
secretPath: req.body.secretPath,
message: req.body.message
}
}
});
return { message: "success" }; return { message: "success" };
} }
}); });
@@ -449,6 +449,7 @@ export enum EventType {
PIT_REVERT_COMMIT = "pit-revert-commit", PIT_REVERT_COMMIT = "pit-revert-commit",
PIT_GET_FOLDER_STATE = "pit-get-folder-state", PIT_GET_FOLDER_STATE = "pit-get-folder-state",
PIT_COMPARE_FOLDER_STATES = "pit-compare-folder-states", PIT_COMPARE_FOLDER_STATES = "pit-compare-folder-states",
PIT_PROCESS_NEW_COMMIT_RAW = "pit-process-new-commit-raw",
SECRET_SCANNING_DATA_SOURCE_LIST = "secret-scanning-data-source-list", SECRET_SCANNING_DATA_SOURCE_LIST = "secret-scanning-data-source-list",
SECRET_SCANNING_DATA_SOURCE_CREATE = "secret-scanning-data-source-create", SECRET_SCANNING_DATA_SOURCE_CREATE = "secret-scanning-data-source-create",
SECRET_SCANNING_DATA_SOURCE_UPDATE = "secret-scanning-data-source-update", SECRET_SCANNING_DATA_SOURCE_UPDATE = "secret-scanning-data-source-update",
@@ -3222,6 +3223,23 @@ interface PitCompareFolderStatesEvent {
}; };
} }
interface PitProcessNewCommitRawEvent {
type: EventType.PIT_PROCESS_NEW_COMMIT_RAW;
metadata: {
projectId: string;
environment: string;
secretPath: string;
message: string;
folderChanges: {
create: string[];
update: string[];
delete: string[];
};
approvalId?: string;
commitId?: string;
};
}
interface SecretScanningDataSourceListEvent { interface SecretScanningDataSourceListEvent {
type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST; type: EventType.SECRET_SCANNING_DATA_SOURCE_LIST;
metadata: { metadata: {
@@ -3658,6 +3676,7 @@ export type Event =
| PitRevertCommitEvent | PitRevertCommitEvent
| PitCompareFolderStatesEvent | PitCompareFolderStatesEvent
| PitGetFolderStateEvent | PitGetFolderStateEvent
| PitProcessNewCommitRawEvent
| SecretScanningDataSourceListEvent | SecretScanningDataSourceListEvent
| SecretScanningDataSourceGetEvent | SecretScanningDataSourceGetEvent
| SecretScanningDataSourceCreateEvent | SecretScanningDataSourceCreateEvent
+73 -49
View File
@@ -18,7 +18,7 @@ import {
import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
import { TSecretServiceFactory } from "@app/services/secret/secret-service"; import { TSecretServiceFactory } from "@app/services/secret/secret-service";
import { SecretProtectionType, TProcessNewCommitRawDTO } from "@app/services/secret/secret-types"; import { TProcessNewCommitRawDTO } from "@app/services/secret/secret-types";
import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal";
import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service"; import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service";
import { TSecretV2BridgeServiceFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-service"; import { TSecretV2BridgeServiceFactory } from "@app/services/secret-v2-bridge/secret-v2-bridge-service";
@@ -545,11 +545,7 @@ export const pitServiceFactory = ({
// Check update operations // Check update operations
...(changes.secrets?.update ...(changes.secrets?.update
?.filter( ?.filter((sec) => sec.newSecretName && sec.newSecretName !== sec.newSecretName.toUpperCase())
(sec) =>
sec.secretKey !== sec.secretKey.toUpperCase() ||
(sec.newSecretKey && sec.newSecretKey !== sec.newSecretKey.toUpperCase())
)
.map((sec) => sec.secretKey) ?? []) .map((sec) => sec.secretKey) ?? [])
]; ];
@@ -562,17 +558,22 @@ export const pitServiceFactory = ({
} }
} }
await folderCommitDAL.transaction(async (trx) => { return await folderCommitDAL.transaction(async (trx) => {
const targetFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath); const targetFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath, trx);
if (!targetFolder) if (!targetFolder)
throw new NotFoundError({ throw new NotFoundError({
message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found`, message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found`,
name: "CreateManySecret" name: "CreateManySecret"
}); });
const commitChanges: TCommitResourceChangeDTO[] = []; const commitChanges: TCommitResourceChangeDTO[] = [];
const folderChanges: { create: string[], update: string[], delete: string[] } = {
create: [],
update: [],
delete: []
};
if ((changes.folders?.create?.length ?? 0) > 0) { if ((changes.folders?.create?.length ?? 0) > 0) {
await folderService.createManyFolders({ const createdFolders = await folderService.createManyFolders({
projectId, projectId,
actor, actor,
actorId, actorId,
@@ -588,10 +589,11 @@ export const pitServiceFactory = ({
tx: trx, tx: trx,
commitChanges commitChanges
}); });
folderChanges.create.push(...createdFolders.folders.map((folder) => folder.id));
} }
if ((changes.folders?.update?.length ?? 0) > 0) { if ((changes.folders?.update?.length ?? 0) > 0) {
await folderService.updateManyFolders({ const updatedFolders = await folderService.updateManyFolders({
projectId, projectId,
actor, actor,
actorId, actorId,
@@ -608,10 +610,11 @@ export const pitServiceFactory = ({
tx: trx, tx: trx,
commitChanges commitChanges
}); });
folderChanges.update.push(...updatedFolders.newFolders.map((folder) => folder.id))
} }
if ((changes.folders?.delete?.length ?? 0) > 0) { if ((changes.folders?.delete?.length ?? 0) > 0) {
await folderService.deleteManyFolders({ const deletedFolders = await folderService.deleteManyFolders({
projectId, projectId,
actor, actor,
actorId, actorId,
@@ -626,46 +629,60 @@ export const pitServiceFactory = ({
tx: trx, tx: trx,
commitChanges commitChanges
}); });
folderChanges.delete.push(...deletedFolders.folders.map((folder) => folder.id))
} }
if (policy) { if (policy) {
const approval = await secretApprovalRequestService.generateSecretApprovalRequestV2Bridge({ if (
policy, (changes.secrets?.create?.length ?? 0) > 0 ||
secretPath, (changes.secrets?.update?.length ?? 0) > 0 ||
environment, (changes.secrets?.delete?.length ?? 0) > 0
projectId, ) {
actor, const approval = await secretApprovalRequestService.generateSecretApprovalRequestV2Bridge({
actorId, policy,
actorOrgId, secretPath,
actorAuthMethod, environment,
data: { projectId,
[SecretOperations.Create]: actor,
changes.secrets?.create?.map((el) => ({ actorId,
tagIds: el.tagIds, actorOrgId,
secretValue: el.secretValue, actorAuthMethod,
secretComment: el.secretComment, data: {
metadata: el.metadata, [SecretOperations.Create]:
skipMultilineEncoding: el.skipMultilineEncoding, changes.secrets?.create?.map((el) => ({
secretKey: el.secretKey, tagIds: el.tagIds,
secretMetadata: el.secretMetadata secretValue: el.secretValue,
})) ?? [], secretComment: el.secretComment,
[SecretOperations.Update]: metadata: el.metadata,
changes.secrets?.update?.map((el) => ({ skipMultilineEncoding: el.skipMultilineEncoding,
tagIds: el.tagIds, secretKey: el.secretKey,
secretValue: el.secretValue, secretMetadata: el.secretMetadata
secretComment: el.secretComment, })) ?? [],
metadata: el.metadata, [SecretOperations.Update]:
skipMultilineEncoding: el.skipMultilineEncoding, changes.secrets?.update?.map((el) => ({
secretKey: el.secretKey, tagIds: el.tagIds,
secretMetadata: el.secretMetadata newSecretName: el.newSecretName,
})) ?? [], secretValue: el.secretValue,
[SecretOperations.Delete]: secretComment: el.secretComment,
changes.secrets?.delete?.map((el) => ({ metadata: el.metadata,
secretKey: el.secretKey skipMultilineEncoding: el.skipMultilineEncoding,
})) ?? [] secretKey: el.secretKey,
} secretMetadata: el.secretMetadata
}); })) ?? [],
return { type: SecretProtectionType.Approval as const, approval }; [SecretOperations.Delete]:
changes.secrets?.delete?.map((el) => ({
secretKey: el.secretKey
})) ?? []
}
});
return {
approvalId: approval.id,
folderChanges
};
}
return {
folderChanges
};
} }
if ((changes.secrets?.create?.length ?? 0) > 0) { if ((changes.secrets?.create?.length ?? 0) > 0) {
@@ -712,7 +729,7 @@ export const pitServiceFactory = ({
}); });
} }
if (commitChanges?.length > 0) { if (commitChanges?.length > 0) {
await folderCommitService.createCommit( const commit = await folderCommitService.createCommit(
{ {
actor: { actor: {
type: actor || ActorType.PLATFORM, type: actor || ActorType.PLATFORM,
@@ -726,7 +743,14 @@ export const pitServiceFactory = ({
}, },
trx trx
); );
return {
folderChanges,
commitId: commit?.id
};
} }
return {
folderChanges
};
}); });
}; };
@@ -292,7 +292,7 @@ export const secretFolderServiceFactory = ({
}); });
} }
const env = await projectEnvDAL.findOne({ projectId, slug: environment }); const env = await projectEnvDAL.findOne({ projectId, slug: environment }, tx);
if (!env) { if (!env) {
throw new NotFoundError({ throw new NotFoundError({
message: `Environment with slug '${environment}' in project with ID '${projectId}' not found`, message: `Environment with slug '${environment}' in project with ID '${projectId}' not found`,
+1 -1
View File
@@ -558,7 +558,7 @@ export type TProcessNewCommitRawDTO = {
}[]; }[];
update?: { update?: {
secretKey: string; secretKey: string;
newSecretKey?: string; newSecretName?: string;
secretValue?: string; secretValue?: string;
secretComment?: string; secretComment?: string;
skipMultilineEncoding?: boolean; skipMultilineEncoding?: boolean;
@@ -0,0 +1,26 @@
import { useCallback } from "react";
import { useBlocker } from "@tanstack/react-router";
type TNavigationBlockerReturn = {
isBlocked: boolean;
};
export const useNavigationBlocker = (
shouldBlock: boolean,
message: string = "Are you sure you want to leave? You may have unsaved changes."
): TNavigationBlockerReturn => {
const blockerFn = useCallback(() => {
if (!shouldBlock) return false;
// eslint-disable-next-line no-alert
const confirmed = window.confirm(message);
return !confirmed;
}, [shouldBlock, message]);
useBlocker(blockerFn);
return {
isBlocked: shouldBlock
};
};
@@ -138,24 +138,6 @@ const Page = () => {
} }
}, [isBatchMode, workspaceId, environment, secretPath, loadPendingChanges]); }, [isBatchMode, workspaceId, environment, secretPath, loadPendingChanges]);
const handleCreateCommit = async (changes: PendingChanges, message: string) => {
try {
await createCommit({
workspaceId,
environment,
secretPath,
pendingChanges: changes,
message
});
} catch (error) {
createNotification({
text: "Failed to commit changes",
type: "error"
});
console.error(error);
}
};
const canReadSecret = hasSecretReadValueOrDescribePermission( const canReadSecret = hasSecretReadValueOrDescribePermission(
permission, permission,
ProjectPermissionSecretActions.DescribeSecret, ProjectPermissionSecretActions.DescribeSecret,
@@ -299,6 +281,30 @@ const Page = () => {
}); });
const isProtectedBranch = Boolean(boardPolicy); 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 { const {
data: snapshotList, data: snapshotList,
isFetchingNextPage: isFetchingNextSnapshotList, isFetchingNextPage: isFetchingNextSnapshotList,
@@ -538,7 +544,7 @@ const Page = () => {
value: change.secretValue, value: change.secretValue,
comment: change.secretComment || "", comment: change.secretComment || "",
skipMultilineEncoding: change.skipMultilineEncoding || false, skipMultilineEncoding: change.skipMultilineEncoding || false,
tags: change.tags?.map((tag) => ({ id: tag.id, slug: tag.slug })) || [], tags: change.tags || [],
secretMetadata: change.secretMetadata || [], secretMetadata: change.secretMetadata || [],
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
@@ -568,7 +574,16 @@ const Page = () => {
: mergedSecrets[updateIndex].skipMultilineEncoding, : mergedSecrets[updateIndex].skipMultilineEncoding,
secretMetadata: change.secretMetadata || mergedSecrets[updateIndex].secretMetadata, secretMetadata: change.secretMetadata || mergedSecrets[updateIndex].secretMetadata,
isPending: true, isPending: true,
pendingAction: PendingAction.Update pendingAction: PendingAction.Update,
tags:
change.tags?.map((tag) => ({
id: tag.id,
slug: tag.slug,
projectId: workspaceId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
__v: 0
})) || []
}; };
} }
break; break;
@@ -114,9 +114,9 @@ const savePendingChangesToStorage = (
) => { ) => {
const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`; const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`;
try { try {
localStorage.setItem(key, JSON.stringify(changes)); sessionStorage.setItem(key, JSON.stringify(changes));
} catch (error) { } catch (error) {
console.warn("Failed to save pending changes to localStorage:", error); console.warn("Failed to save pending changes to sessionStorage:", error);
} }
}; };
@@ -126,7 +126,7 @@ const loadPendingChangesFromStorage = (
secretPath: string secretPath: string
): PendingChanges => { ): PendingChanges => {
const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`; const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`;
const stored = localStorage.getItem(key); const stored = sessionStorage.getItem(key);
if (!stored) return { secrets: [], folders: [] }; if (!stored) return { secrets: [], folders: [] };
try { try {
@@ -144,7 +144,7 @@ const loadPendingChangesFromStorage = (
folders: parsed.folders || [] folders: parsed.folders || []
}; };
} catch (error) { } catch (error) {
console.warn("Failed to parse pending changes from localStorage:", error); console.warn("Failed to parse pending changes from sessionStorage:", error);
return { secrets: [], folders: [] }; return { secrets: [], folders: [] };
} }
}; };
@@ -156,12 +156,122 @@ const clearPendingChangesFromStorage = (
) => { ) => {
const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`; const key = `${STORAGE_KEY}_${generateContextKey(workspaceId, environment, secretPath)}`;
try { try {
localStorage.removeItem(key); sessionStorage.removeItem(key);
} catch (error) { } catch (error) {
console.warn("Failed to clear pending changes from localStorage:", error); console.warn("Failed to clear pending changes from sessionStorage:", error);
} }
}; };
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)
) {
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 = { type SelectedSecretState = {
selectedSecret: Record<string, SecretV3RawSanitized>; selectedSecret: Record<string, SecretV3RawSanitized>;
action: { action: {
@@ -189,38 +299,6 @@ const createSelectedSecretStore: StateCreator<CombinedState, [], [], SelectedSec
} }
}); });
const cleanupRevertedFields = (update: PendingSecretUpdate): PendingSecretUpdate => {
const cleaned = { ...update };
if (cleaned.secretValue === cleaned.originalValue) {
cleaned.secretValue = undefined;
}
if (cleaned.secretComment === cleaned.originalComment) {
cleaned.secretComment = undefined;
}
if (cleaned.skipMultilineEncoding === cleaned.originalSkipMultilineEncoding) {
cleaned.skipMultilineEncoding = undefined;
}
// For arrays, compare stringified versions
if (JSON.stringify(cleaned.tags) === JSON.stringify(cleaned.originalTags)) {
cleaned.tags = undefined;
}
if (JSON.stringify(cleaned.secretMetadata) === JSON.stringify(cleaned.originalSecretMetadata)) {
cleaned.secretMetadata = undefined;
}
// If the new name is the same as original key, remove it
if (cleaned.newSecretName === cleaned.secretKey) {
cleaned.newSecretName = undefined;
}
return cleaned;
};
type BatchModeState = { type BatchModeState = {
isBatchMode: boolean; isBatchMode: boolean;
pendingChanges: PendingChanges; pendingChanges: PendingChanges;
@@ -348,7 +426,7 @@ const createBatchModeStore: StateCreator<CombinedState, [], [], BatchModeState>
if (existingUpdateIndex >= 0) { if (existingUpdateIndex >= 0) {
const existingUpdate = secretChanges[existingUpdateIndex] as PendingSecretUpdate; const existingUpdate = secretChanges[existingUpdateIndex] as PendingSecretUpdate;
const improvedUpdate: PendingSecretUpdate = { const mergedUpdate: PendingSecretUpdate = {
...existingUpdate, ...existingUpdate,
secretKey: existingUpdate.secretKey, secretKey: existingUpdate.secretKey,
originalValue: existingUpdate.originalValue, originalValue: existingUpdate.originalValue,
@@ -382,11 +460,23 @@ const createBatchModeStore: StateCreator<CombinedState, [], [], BatchModeState>
timestamp: Date.now() timestamp: Date.now()
}; };
const cleanedUpdate = cleanupRevertedFields(improvedUpdate); // Clean up reverted fields and check if any changes remain
const cleanedUpdate = cleanupRevertedSecretFields(mergedUpdate);
secretChanges[existingUpdateIndex] = cleanedUpdate; if (cleanedUpdate) {
// Still has changes, keep the update
secretChanges[existingUpdateIndex] = cleanedUpdate;
} else {
// No changes remain, remove the pending update
secretChanges.splice(existingUpdateIndex, 1);
}
} else { } else {
secretChanges.push(change); // 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 { } else {
@@ -435,7 +525,7 @@ const createBatchModeStore: StateCreator<CombinedState, [], [], BatchModeState>
if (existingUpdateIndex >= 0) { if (existingUpdateIndex >= 0) {
const existingUpdate = folderChanges[existingUpdateIndex] as PendingFolderUpdate; const existingUpdate = folderChanges[existingUpdateIndex] as PendingFolderUpdate;
folderChanges[existingUpdateIndex] = { const mergedUpdate: PendingFolderUpdate = {
...existingUpdate, ...existingUpdate,
originalFolderName: existingUpdate.originalFolderName, originalFolderName: existingUpdate.originalFolderName,
originalDescription: existingUpdate.originalDescription, originalDescription: existingUpdate.originalDescription,
@@ -449,8 +539,24 @@ const createBatchModeStore: StateCreator<CombinedState, [], [], BatchModeState>
timestamp: Date.now() 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 { } else {
folderChanges.push(change); // 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 { } else {
@@ -1,17 +1,9 @@
/* eslint-disable jsx-a11y/label-has-associated-control */ /* eslint-disable jsx-a11y/label-has-associated-control */
import React, { useState } from "react"; import React, { useState } from "react";
import { faCodeCommit, faFolder, faKey } from "@fortawesome/free-solid-svg-icons"; import { faCodeCommit, faEye, faFolder, faKey, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import { Badge, Button, IconButton, Input, Modal, ModalContent, Tooltip } from "@app/components/v2";
Badge,
Button,
FontAwesomeSymbol,
IconButton,
Input,
Modal,
ModalContent
} from "@app/components/v2";
import { PendingAction } from "@app/hooks/api/secretFolders/types"; import { PendingAction } from "@app/hooks/api/secretFolders/types";
import { import {
@@ -20,7 +12,6 @@ import {
useBatchMode, useBatchMode,
useBatchModeActions useBatchModeActions
} from "../../SecretMainPage.store"; } from "../../SecretMainPage.store";
import { FontAwesomeSpriteName } from "../SecretListView/SecretListView.utils";
interface CommitFormProps { interface CommitFormProps {
onCommit: (changes: PendingChanges, commitMessage: string) => Promise<void>; onCommit: (changes: PendingChanges, commitMessage: string) => Promise<void>;
@@ -384,15 +375,17 @@ const ChangeTable: React.FC<ChangeTableProps> = ({
<span className="font-medium text-mineshaft-100">{getChangeName()}</span> <span className="font-medium text-mineshaft-100">{getChangeName()}</span>
{getChangeBadge(change.type)} {getChangeBadge(change.type)}
</div> </div>
<IconButton <Tooltip content="Discard change">
ariaLabel="delete-change" <IconButton
variant="plain" ariaLabel="delete-change"
colorSchema="danger" variant="plain"
size="sm" colorSchema="danger"
onClick={() => handleDeletePending(change.resourceType, change.id)} size="sm"
> onClick={() => handleDeletePending(change.resourceType, change.id)}
<FontAwesomeSymbol symbolName={FontAwesomeSpriteName.Close} className="h-4 w-4" /> >
</IconButton> <FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
</div> </div>
{change.resourceType === "secret" ? renderSecretChanges() : renderFolderChanges()} {change.resourceType === "secret" ? renderSecretChanges() : renderFolderChanges()}
</div> </div>
@@ -434,7 +427,7 @@ export const CommitForm: React.FC<CommitFormProps> = ({
<> <>
{/* Floating Panel */} {/* Floating Panel */}
{!isModalOpen && ( {!isModalOpen && (
<div className="fixed bottom-4 z-40 w-80 self-center rounded-lg border border-mineshaft-600 bg-mineshaft-800 shadow-2xl"> <div className="fixed bottom-4 z-40 w-64 self-center rounded-lg border border-mineshaft-600 bg-mineshaft-800 shadow-2xl">
<div className="flex w-full justify-center border-b border-mineshaft-600 px-4 py-3"> <div className="flex w-full justify-center border-b border-mineshaft-600 px-4 py-3">
<div className="flex w-full items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -447,14 +440,22 @@ export const CommitForm: React.FC<CommitFormProps> = ({
</div> </div>
</div> </div>
<div className="p-3"> <div className="flex justify-center gap-4 p-3">
<Button <Button
onClick={() => setIsModalOpen(true)} leftIcon={<FontAwesomeIcon icon={faTrash} />}
className="w-full" onClick={() => clearAllPendingChanges({ workspaceId, environment, secretPath })}
isDisabled={totalChangesCount === 0} isDisabled={totalChangesCount === 0}
colorSchema="secondary" colorSchema="secondary"
> >
Review & Commit Discard
</Button>
<Button
leftIcon={<FontAwesomeIcon icon={faEye} />}
onClick={() => setIsModalOpen(true)}
isDisabled={totalChangesCount === 0}
colorSchema="secondary"
>
Review
</Button> </Button>
</div> </div>
</div> </div>
@@ -38,7 +38,7 @@ import { WsTag } from "@app/hooks/api/types";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { memo, useEffect } from "react"; import { memo, useEffect, useRef, useCallback } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form"; import { Controller, useFieldArray, useForm } from "react-hook-form";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { import {
@@ -118,6 +118,9 @@ export const SecretItem = memo(
const { isRotatedSecret } = secret; const { isRotatedSecret } = secret;
const { removePendingChange } = useBatchModeActions(); const { removePendingChange } = useBatchModeActions();
const autoSaveTimeoutRef = useRef<NodeJS.Timeout>();
const isAutoSavingRef = useRef(false);
const handleDeletePending = (pendingSecret: SecretV3RawSanitized) => { const handleDeletePending = (pendingSecret: SecretV3RawSanitized) => {
removePendingChange(pendingSecret.id, "secret", { removePendingChange(pendingSecret.id, "secret", {
workspaceId: currentWorkspace.id, workspaceId: currentWorkspace.id,
@@ -166,7 +169,6 @@ export const SecretItem = memo(
}); });
const secretName = watch("key"); const secretName = watch("key");
const overrideAction = watch("overrideAction"); const overrideAction = watch("overrideAction");
const hasComment = Boolean(watch("comment")); const hasComment = Boolean(watch("comment"));
@@ -182,6 +184,68 @@ export const SecretItem = memo(
name: "tags" 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 {
const hasDirectReferences = importedBy?.some(({ folders }) =>
folders?.some(({ secrets }) =>
secrets?.some(({ referencedSecretKey }) => referencedSecretKey === secret.key)
)
);
if (hasDirectReferences) {
await onSaveSecret(secret, { ...secret, ...data }, () => {
reset();
});
} else {
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 = isPending ? 500 : 1500;
autoSaveTimeoutRef.current = setTimeout(() => {
autoSaveChanges(formValues);
}, debounceTime);
}
return () => {
if (autoSaveTimeoutRef.current) {
clearTimeout(autoSaveTimeoutRef.current);
}
};
}, [formValues, isDirty, isSubmitting, autoSaveChanges, isPending]);
const isReadOnly = const isReadOnly =
hasSecretReadValueOrDescribePermission( hasSecretReadValueOrDescribePermission(
permission, permission,
@@ -202,6 +266,7 @@ export const SecretItem = memo(
secretTags: selectedTagSlugs secretTags: selectedTagSlugs
}) })
); );
const isReadOnlySecret = const isReadOnlySecret =
isReadOnly || isRotatedSecret || (isPending && pendingAction !== PendingAction.Update); isReadOnly || isRotatedSecret || (isPending && pendingAction !== PendingAction.Update);
@@ -216,10 +281,6 @@ export const SecretItem = memo(
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [isSecValueCopied]); }, [isSecValueCopied]);
const isOverriden =
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
const hasTagsApplied = Boolean(fields.length);
const handleOverrideClick = () => { const handleOverrideClick = () => {
if (isOverriden) { if (isOverriden) {
// override need not be flagged delete if it was never saved in server // override need not be flagged delete if it was never saved in server
@@ -284,6 +345,8 @@ export const SecretItem = memo(
setIsSecValueCopied.on(); setIsSecValueCopied.on();
}; };
const isInAutoSaveMode = isDirty && !isSubmitting && !isOverriden;
return ( return (
<form onSubmit={handleSubmit(handleFormSubmit)}> <form onSubmit={handleSubmit(handleFormSubmit)}>
<div <div
@@ -292,6 +355,7 @@ export const SecretItem = memo(
isDirty && "border-primary-400/50", isDirty && "border-primary-400/50",
isRotatedSecret && "bg-mineshaft-700/60", isRotatedSecret && "bg-mineshaft-700/60",
isPending && "bg-mineshaft-700/60", isPending && "bg-mineshaft-700/60",
isInAutoSaveMode && "border-primary-400/75 bg-primary-900/20",
pendingAction === PendingAction.Delete && "border-l-2 border-l-red-600/75", pendingAction === PendingAction.Delete && "border-l-2 border-l-red-600/75",
pendingAction === PendingAction.Update && "border-l-2 border-l-yellow-600/75", pendingAction === PendingAction.Update && "border-l-2 border-l-yellow-600/75",
pendingAction === PendingAction.Create && "border-l-2 border-l-green-600/75" pendingAction === PendingAction.Create && "border-l-2 border-l-green-600/75"
@@ -357,6 +421,14 @@ export const SecretItem = memo(
tabIndex={0} tabIndex={0}
role="button" role="button"
> >
{isInAutoSaveMode && (
<Tooltip content="Auto-saving changes">
<div className="mr-2 flex items-center">
<Spinner className="h-3 w-3 text-primary" />
</div>
</Tooltip>
)}
{secretValueHidden && !isOverriden && ( {secretValueHidden && !isOverriden && (
<Tooltip <Tooltip
content={`You do not have access to view the current value${canEditSecretValue && !isRotatedSecret ? ", but you can set a new one" : "."}`} content={`You do not have access to view the current value${canEditSecretValue && !isRotatedSecret ? ", but you can set a new one" : "."}`}
@@ -399,7 +471,6 @@ export const SecretItem = memo(
)} )}
/> />
)} )}
{/* Only allow to open the side panel if the secret is not in a pending create or delete state */}
{pendingAction !== PendingAction.Create && pendingAction !== PendingAction.Delete && ( {pendingAction !== PendingAction.Create && pendingAction !== PendingAction.Delete && (
<div <div
key="actions" key="actions"
@@ -460,7 +531,7 @@ export const SecretItem = memo(
<ModalContent <ModalContent
title="Secret Reference Details" title="Secret Reference Details"
subTitle="Visual breakdown of secrets referenced by this secret." subTitle="Visual breakdown of secrets referenced by this secret."
onOpenAutoFocus={(e) => e.preventDefault()} // prevents secret input from displaying value on open onOpenAutoFocus={(e) => e.preventDefault()}
> >
<SecretReferenceTree <SecretReferenceTree
secretPath={secretPath} secretPath={secretPath}
@@ -647,7 +718,21 @@ export const SecretItem = memo(
)} )}
</div> </div>
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{!isDirty ? ( {isInAutoSaveMode ? (
<motion.div
key="auto-save-mode"
className="flex h-10 flex-shrink-0 items-center space-x-4 px-[0.64rem]"
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -10, opacity: 0 }}
>
<Tooltip content="Auto-saving changes...">
<div className="flex items-center text-primary">
<Spinner className="h-4 w-4" />
</div>
</Tooltip>
</motion.div>
) : !isDirty ? (
isPending ? ( isPending ? (
<motion.div <motion.div
key="options" key="options"
@@ -717,7 +802,7 @@ export const SecretItem = memo(
secretTags: selectedTagSlugs secretTags: selectedTagSlugs
})} })}
renderTooltip renderTooltip
allowedLabel={isRotatedSecret ? "Cannot Delete Rotated Secret" : "Delete"} // just using label for isRotatedSecret, disabled below allowedLabel={isRotatedSecret ? "Cannot Delete Rotated Secret" : "Delete"}
> >
{(isAllowed) => ( {(isAllowed) => (
<IconButton <IconButton
@@ -16,6 +16,7 @@ import { secretKeys } from "@app/hooks/api/secrets/queries";
import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types"; import { SecretType, SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
import { secretSnapshotKeys } from "@app/hooks/api/secretSnapshots/queries"; import { secretSnapshotKeys } from "@app/hooks/api/secretSnapshots/queries";
import { WsTag } from "@app/hooks/api/types"; import { WsTag } from "@app/hooks/api/types";
import { useNavigationBlocker } from "@app/hooks/useNavigationBlocker";
import { AddShareSecretModal } from "@app/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal"; import { AddShareSecretModal } from "@app/pages/organization/SecretSharingPage/components/ShareSecret/AddShareSecretModal";
import { import {
@@ -85,6 +86,10 @@ export const SecretListView = ({
const selectedSecrets = useSelectedSecrets(); const selectedSecrets = useSelectedSecrets();
const { toggle: toggleSelectedSecret } = useSelectedSecretActions(); const { toggle: toggleSelectedSecret } = useSelectedSecretActions();
const { isBatchMode, pendingChanges } = useBatchMode(); const { isBatchMode, pendingChanges } = useBatchMode();
useNavigationBlocker(
pendingChanges.secrets.length > 0 || pendingChanges.folders.length > 0,
"You have unsaved work. Are you sure you want to leave?"
);
const { addPendingChange } = useBatchModeActions(); const { addPendingChange } = useBatchModeActions();
const handleSecretOperation = async ( const handleSecretOperation = async (
@@ -226,7 +231,7 @@ export const SecretListView = ({
async ( async (
orgSecret: SecretV3RawSanitized, orgSecret: SecretV3RawSanitized,
modSecret: Omit<SecretV3RawSanitized, "tags"> & { modSecret: Omit<SecretV3RawSanitized, "tags"> & {
tags?: { id: string; name?: string }[]; tags?: { id: string; name?: string; slug?: string }[];
secretMetadata?: { key: string; value: string }[]; secretMetadata?: { key: string; value: string }[];
}, },
cb?: () => void cb?: () => void
@@ -347,7 +352,7 @@ export const SecretListView = ({
...(!isSameTags && { ...(!isSameTags && {
originalTags: originalTags:
trueOriginalSecret.tags?.map((tag) => ({ id: tag.id, slug: tag.slug })) || [], trueOriginalSecret.tags?.map((tag) => ({ id: tag.id, slug: tag.slug })) || [],
tags: tags?.map((tag) => ({ id: tag.id, slug: tag.name || "" })) || [] tags: tags?.map((tag) => ({ id: tag.id, slug: tag.name || tag.slug || "" })) || []
}), }),
...(JSON.stringify(secretMetadata) !== ...(JSON.stringify(secretMetadata) !==
JSON.stringify(trueOriginalSecret.secretMetadata) && { JSON.stringify(trueOriginalSecret.secretMetadata) && {