mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-06 16:27:46 +00:00
Merge pull request #3993 from Infisical/feat/secretsBatchUI
feat(secrets-ui): Improve SecretDashboard table to support multiple changes on a single commit
This commit is contained in:
@@ -3,11 +3,14 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
import { removeTrailingSlash } from "@app/lib/fn";
|
import { removeTrailingSlash } from "@app/lib/fn";
|
||||||
import { readLimit } from "@app/server/config/rateLimiter";
|
import { isValidFolderName } from "@app/lib/validator";
|
||||||
|
import { readLimit, secretsLimit } from "@app/server/config/rateLimiter";
|
||||||
|
import { SecretNameSchema } from "@app/server/lib/schemas";
|
||||||
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
|
||||||
import { booleanSchema } from "@app/server/routes/sanitizedSchemas";
|
import { booleanSchema } from "@app/server/routes/sanitizedSchemas";
|
||||||
import { AuthMode } from "@app/services/auth/auth-type";
|
import { AuthMode } from "@app/services/auth/auth-type";
|
||||||
import { commitChangesResponseSchema, resourceChangeSchema } from "@app/services/folder-commit/folder-commit-schemas";
|
import { commitChangesResponseSchema, resourceChangeSchema } from "@app/services/folder-commit/folder-commit-schemas";
|
||||||
|
import { ResourceMetadataSchema } from "@app/services/resource-metadata/resource-metadata-schema";
|
||||||
|
|
||||||
const commitHistoryItemSchema = z.object({
|
const commitHistoryItemSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -413,4 +416,166 @@ export const registerPITRouter = async (server: FastifyZodProvider) => {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
server.route({
|
||||||
|
method: "POST",
|
||||||
|
url: "/batch/commit",
|
||||||
|
config: {
|
||||||
|
rateLimit: secretsLimit
|
||||||
|
},
|
||||||
|
schema: {
|
||||||
|
hide: true,
|
||||||
|
description: "Commit changes",
|
||||||
|
security: [
|
||||||
|
{
|
||||||
|
bearerAuth: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
body: z.object({
|
||||||
|
projectId: z.string().trim(),
|
||||||
|
environment: z.string().trim(),
|
||||||
|
secretPath: z.string().trim().default("/").transform(removeTrailingSlash),
|
||||||
|
message: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(255)
|
||||||
|
.refine((message) => message.trim() !== "", {
|
||||||
|
message: "Commit message cannot be empty"
|
||||||
|
}),
|
||||||
|
changes: z.object({
|
||||||
|
secrets: z.object({
|
||||||
|
create: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
secretKey: SecretNameSchema,
|
||||||
|
secretValue: z.string().transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())),
|
||||||
|
secretComment: z.string().trim().optional().default(""),
|
||||||
|
skipMultilineEncoding: z.boolean().optional(),
|
||||||
|
metadata: z.record(z.string()).optional(),
|
||||||
|
secretMetadata: ResourceMetadataSchema.optional(),
|
||||||
|
tagIds: z.string().array().optional()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
update: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
secretKey: SecretNameSchema,
|
||||||
|
newSecretName: SecretNameSchema.optional(),
|
||||||
|
secretValue: z
|
||||||
|
.string()
|
||||||
|
.transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim()))
|
||||||
|
.optional(),
|
||||||
|
secretComment: z.string().trim().optional().default(""),
|
||||||
|
skipMultilineEncoding: z.boolean().optional(),
|
||||||
|
metadata: z.record(z.string()).optional(),
|
||||||
|
secretMetadata: ResourceMetadataSchema.optional(),
|
||||||
|
tagIds: z.string().array().optional()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
delete: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
secretKey: SecretNameSchema
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
}),
|
||||||
|
folders: z.object({
|
||||||
|
create: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
folderName: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((name) => isValidFolderName(name), {
|
||||||
|
message: "Invalid folder name. Only alphanumeric characters, dashes, and underscores are allowed."
|
||||||
|
}),
|
||||||
|
description: z.string().optional()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
update: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
folderName: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((name) => isValidFolderName(name), {
|
||||||
|
message: "Invalid folder name. Only alphanumeric characters, dashes, and underscores are allowed."
|
||||||
|
}),
|
||||||
|
description: z.string().nullable().optional(),
|
||||||
|
id: z.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
delete: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
folderName: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.refine((name) => isValidFolderName(name), {
|
||||||
|
message: "Invalid folder name. Only alphanumeric characters, dashes, and underscores are allowed."
|
||||||
|
}),
|
||||||
|
id: z.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
response: {
|
||||||
|
200: z.object({
|
||||||
|
message: z.string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRequest: verifyAuth([AuthMode.JWT]),
|
||||||
|
handler: async (req) => {
|
||||||
|
const result = await server.services.pit.processNewCommitRaw({
|
||||||
|
actorId: req.permission.id,
|
||||||
|
actor: req.permission.type,
|
||||||
|
actorOrgId: req.permission.orgId,
|
||||||
|
actorAuthMethod: req.permission.authMethod,
|
||||||
|
projectId: req.body.projectId,
|
||||||
|
environment: req.body.environment,
|
||||||
|
secretPath: req.body.secretPath,
|
||||||
|
message: req.body.message,
|
||||||
|
changes: {
|
||||||
|
secrets: req.body.changes.secrets,
|
||||||
|
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,
|
||||||
|
approvalId: result.approvalId,
|
||||||
|
projectId: req.body.projectId,
|
||||||
|
environment: req.body.environment,
|
||||||
|
secretPath: req.body.secretPath,
|
||||||
|
message: req.body.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const event of result.secretMutationEvents) {
|
||||||
|
await server.services.auditLog.createAuditLog({
|
||||||
|
...req.auditLogInfo,
|
||||||
|
orgId: req.permission.orgId,
|
||||||
|
projectId: req.body.projectId,
|
||||||
|
event
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
@@ -1546,8 +1547,9 @@ interface UpdateFolderEvent {
|
|||||||
metadata: {
|
metadata: {
|
||||||
environment: string;
|
environment: string;
|
||||||
folderId: string;
|
folderId: string;
|
||||||
oldFolderName: string;
|
oldFolderName?: string;
|
||||||
newFolderName: string;
|
newFolderName: string;
|
||||||
|
newFolderDescription?: string;
|
||||||
folderPath: string;
|
folderPath: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -3222,6 +3224,18 @@ interface PitCompareFolderStatesEvent {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PitProcessNewCommitRawEvent {
|
||||||
|
type: EventType.PIT_PROCESS_NEW_COMMIT_RAW;
|
||||||
|
metadata: {
|
||||||
|
projectId: string;
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
message: 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 +3672,7 @@ export type Event =
|
|||||||
| PitRevertCommitEvent
|
| PitRevertCommitEvent
|
||||||
| PitCompareFolderStatesEvent
|
| PitCompareFolderStatesEvent
|
||||||
| PitGetFolderStateEvent
|
| PitGetFolderStateEvent
|
||||||
|
| PitProcessNewCommitRawEvent
|
||||||
| SecretScanningDataSourceListEvent
|
| SecretScanningDataSourceListEvent
|
||||||
| SecretScanningDataSourceGetEvent
|
| SecretScanningDataSourceGetEvent
|
||||||
| SecretScanningDataSourceCreateEvent
|
| SecretScanningDataSourceCreateEvent
|
||||||
|
|||||||
@@ -1,29 +1,52 @@
|
|||||||
/* eslint-disable no-await-in-loop */
|
/* eslint-disable no-await-in-loop */
|
||||||
import { ForbiddenError } from "@casl/ability";
|
import { ForbiddenError } from "@casl/ability";
|
||||||
|
|
||||||
|
import { Event, EventType } from "@app/ee/services/audit-log/audit-log-types";
|
||||||
import { ProjectPermissionCommitsActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
import { ProjectPermissionCommitsActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
|
||||||
import { NotFoundError } from "@app/lib/errors";
|
import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
import { logger } from "@app/lib/logger";
|
import { logger } from "@app/lib/logger";
|
||||||
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
|
import { ActorAuthMethod, ActorType } from "@app/services/auth/auth-type";
|
||||||
import { ResourceType, TFolderCommitServiceFactory } from "@app/services/folder-commit/folder-commit-service";
|
import { TFolderCommitDALFactory } from "@app/services/folder-commit/folder-commit-dal";
|
||||||
|
import {
|
||||||
|
ResourceType,
|
||||||
|
TCommitResourceChangeDTO,
|
||||||
|
TFolderCommitServiceFactory
|
||||||
|
} from "@app/services/folder-commit/folder-commit-service";
|
||||||
import {
|
import {
|
||||||
isFolderCommitChange,
|
isFolderCommitChange,
|
||||||
isSecretCommitChange
|
isSecretCommitChange
|
||||||
} from "@app/services/folder-commit-changes/folder-commit-changes-dal";
|
} from "@app/services/folder-commit-changes/folder-commit-changes-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 { 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 { SecretOperations, SecretUpdateMode } from "@app/services/secret-v2-bridge/secret-v2-bridge-types";
|
||||||
|
|
||||||
import { TPermissionServiceFactory } from "../permission/permission-service-types";
|
import { TPermissionServiceFactory } from "../permission/permission-service-types";
|
||||||
|
import { TSecretApprovalPolicyServiceFactory } from "../secret-approval-policy/secret-approval-policy-service";
|
||||||
|
import { TSecretApprovalRequestServiceFactory } from "../secret-approval-request/secret-approval-request-service";
|
||||||
|
|
||||||
type TPitServiceFactoryDep = {
|
type TPitServiceFactoryDep = {
|
||||||
folderCommitService: TFolderCommitServiceFactory;
|
folderCommitService: TFolderCommitServiceFactory;
|
||||||
secretService: Pick<TSecretServiceFactory, "getSecretVersionsV2ByIds" | "getChangeVersions">;
|
secretService: Pick<TSecretServiceFactory, "getSecretVersionsV2ByIds" | "getChangeVersions">;
|
||||||
folderService: Pick<TSecretFolderServiceFactory, "getFolderById" | "getFolderVersions">;
|
folderService: Pick<
|
||||||
|
TSecretFolderServiceFactory,
|
||||||
|
"getFolderById" | "getFolderVersions" | "createManyFolders" | "updateManyFolders" | "deleteManyFolders"
|
||||||
|
>;
|
||||||
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
|
||||||
folderDAL: Pick<TSecretFolderDALFactory, "findSecretPathByFolderIds">;
|
folderDAL: Pick<TSecretFolderDALFactory, "findSecretPathByFolderIds" | "findBySecretPath">;
|
||||||
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
projectEnvDAL: Pick<TProjectEnvDALFactory, "findOne">;
|
||||||
|
secretApprovalRequestService: Pick<
|
||||||
|
TSecretApprovalRequestServiceFactory,
|
||||||
|
"generateSecretApprovalRequest" | "generateSecretApprovalRequestV2Bridge"
|
||||||
|
>;
|
||||||
|
secretApprovalPolicyService: Pick<TSecretApprovalPolicyServiceFactory, "getSecretApprovalPolicy">;
|
||||||
|
projectDAL: Pick<TProjectDALFactory, "checkProjectUpgradeStatus" | "findProjectBySlug" | "findById">;
|
||||||
|
secretV2BridgeService: TSecretV2BridgeServiceFactory;
|
||||||
|
folderCommitDAL: Pick<TFolderCommitDALFactory, "transaction">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TPitServiceFactory = ReturnType<typeof pitServiceFactory>;
|
export type TPitServiceFactory = ReturnType<typeof pitServiceFactory>;
|
||||||
@@ -34,7 +57,12 @@ export const pitServiceFactory = ({
|
|||||||
folderService,
|
folderService,
|
||||||
permissionService,
|
permissionService,
|
||||||
folderDAL,
|
folderDAL,
|
||||||
projectEnvDAL
|
projectEnvDAL,
|
||||||
|
secretApprovalRequestService,
|
||||||
|
secretApprovalPolicyService,
|
||||||
|
projectDAL,
|
||||||
|
secretV2BridgeService,
|
||||||
|
folderCommitDAL
|
||||||
}: TPitServiceFactoryDep) => {
|
}: TPitServiceFactoryDep) => {
|
||||||
const getCommitsCount = async ({
|
const getCommitsCount = async ({
|
||||||
actor,
|
actor,
|
||||||
@@ -471,6 +499,347 @@ export const pitServiceFactory = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const processNewCommitRaw = async ({
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
environment,
|
||||||
|
actor,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
secretPath,
|
||||||
|
message,
|
||||||
|
changes = {
|
||||||
|
secrets: {
|
||||||
|
create: [],
|
||||||
|
update: [],
|
||||||
|
delete: []
|
||||||
|
},
|
||||||
|
folders: {
|
||||||
|
create: [],
|
||||||
|
update: [],
|
||||||
|
delete: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}: {
|
||||||
|
actorId: string;
|
||||||
|
projectId: string;
|
||||||
|
environment: string;
|
||||||
|
actor: ActorType;
|
||||||
|
actorOrgId: string;
|
||||||
|
actorAuthMethod: ActorAuthMethod;
|
||||||
|
secretPath: string;
|
||||||
|
message: string;
|
||||||
|
changes: TProcessNewCommitRawDTO;
|
||||||
|
}) => {
|
||||||
|
const policy =
|
||||||
|
actor === ActorType.USER
|
||||||
|
? await secretApprovalPolicyService.getSecretApprovalPolicy(projectId, environment, secretPath)
|
||||||
|
: undefined;
|
||||||
|
const secretMutationEvents: Event[] = [];
|
||||||
|
|
||||||
|
const project = await projectDAL.findById(projectId);
|
||||||
|
if (project.enforceCapitalization) {
|
||||||
|
const caseViolatingSecretKeys = [
|
||||||
|
// Check create operations
|
||||||
|
...(changes.secrets?.create
|
||||||
|
?.filter((sec) => sec.secretKey !== sec.secretKey.toUpperCase())
|
||||||
|
.map((sec) => sec.secretKey) ?? []),
|
||||||
|
|
||||||
|
// Check update operations
|
||||||
|
...(changes.secrets?.update
|
||||||
|
?.filter((sec) => sec.newSecretName && sec.newSecretName !== sec.newSecretName.toUpperCase())
|
||||||
|
.map((sec) => sec.secretKey) ?? [])
|
||||||
|
];
|
||||||
|
|
||||||
|
if (caseViolatingSecretKeys.length) {
|
||||||
|
throw new BadRequestError({
|
||||||
|
message: `Secret names must be in UPPERCASE per project requirements: ${caseViolatingSecretKeys.join(
|
||||||
|
", "
|
||||||
|
)}. You can disable this requirement in project settings`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await folderCommitDAL.transaction(async (trx) => {
|
||||||
|
const targetFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath, trx);
|
||||||
|
if (!targetFolder)
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found`,
|
||||||
|
name: "CreateManySecret"
|
||||||
|
});
|
||||||
|
const commitChanges: TCommitResourceChangeDTO[] = [];
|
||||||
|
const folderChanges: { create: string[]; update: string[]; delete: string[] } = {
|
||||||
|
create: [],
|
||||||
|
update: [],
|
||||||
|
delete: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if ((changes.folders?.create?.length ?? 0) > 0) {
|
||||||
|
const createdFolders = await folderService.createManyFolders({
|
||||||
|
projectId,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
folders:
|
||||||
|
changes.folders?.create?.map((folder) => ({
|
||||||
|
name: folder.folderName,
|
||||||
|
environment,
|
||||||
|
path: secretPath,
|
||||||
|
description: folder.description
|
||||||
|
})) ?? [],
|
||||||
|
tx: trx,
|
||||||
|
commitChanges
|
||||||
|
});
|
||||||
|
const newFolderEvents = createdFolders.folders.map(
|
||||||
|
(folder) =>
|
||||||
|
({
|
||||||
|
type: EventType.CREATE_FOLDER,
|
||||||
|
metadata: {
|
||||||
|
environment,
|
||||||
|
folderId: folder.id,
|
||||||
|
folderName: folder.name,
|
||||||
|
folderPath: secretPath,
|
||||||
|
...(folder.description ? { description: folder.description } : {})
|
||||||
|
}
|
||||||
|
}) as Event
|
||||||
|
);
|
||||||
|
secretMutationEvents.push(...newFolderEvents);
|
||||||
|
folderChanges.create.push(...createdFolders.folders.map((folder) => folder.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((changes.folders?.update?.length ?? 0) > 0) {
|
||||||
|
const updatedFolders = await folderService.updateManyFolders({
|
||||||
|
projectId,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
folders:
|
||||||
|
changes.folders?.update?.map((folder) => ({
|
||||||
|
environment,
|
||||||
|
path: secretPath,
|
||||||
|
id: folder.id,
|
||||||
|
name: folder.folderName,
|
||||||
|
description: folder.description
|
||||||
|
})) ?? [],
|
||||||
|
tx: trx,
|
||||||
|
commitChanges
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedFolderEvents = updatedFolders.newFolders.map(
|
||||||
|
(folder) =>
|
||||||
|
({
|
||||||
|
type: EventType.UPDATE_FOLDER,
|
||||||
|
metadata: {
|
||||||
|
environment,
|
||||||
|
folderId: folder.id,
|
||||||
|
folderPath: secretPath,
|
||||||
|
newFolderName: folder.name,
|
||||||
|
newFolderDescription: folder.description
|
||||||
|
}
|
||||||
|
}) as Event
|
||||||
|
);
|
||||||
|
secretMutationEvents.push(...updatedFolderEvents);
|
||||||
|
folderChanges.update.push(...updatedFolders.newFolders.map((folder) => folder.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((changes.folders?.delete?.length ?? 0) > 0) {
|
||||||
|
const deletedFolders = await folderService.deleteManyFolders({
|
||||||
|
projectId,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
folders:
|
||||||
|
changes.folders?.delete?.map((folder) => ({
|
||||||
|
environment,
|
||||||
|
path: secretPath,
|
||||||
|
idOrName: folder.id
|
||||||
|
})) ?? [],
|
||||||
|
tx: trx,
|
||||||
|
commitChanges
|
||||||
|
});
|
||||||
|
const deletedFolderEvents = deletedFolders.folders.map(
|
||||||
|
(folder) =>
|
||||||
|
({
|
||||||
|
type: EventType.DELETE_FOLDER,
|
||||||
|
metadata: {
|
||||||
|
environment,
|
||||||
|
folderId: folder.id,
|
||||||
|
folderPath: secretPath,
|
||||||
|
folderName: folder.name
|
||||||
|
}
|
||||||
|
}) as Event
|
||||||
|
);
|
||||||
|
secretMutationEvents.push(...deletedFolderEvents);
|
||||||
|
folderChanges.delete.push(...deletedFolders.folders.map((folder) => folder.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (policy) {
|
||||||
|
if (
|
||||||
|
(changes.secrets?.create?.length ?? 0) > 0 ||
|
||||||
|
(changes.secrets?.update?.length ?? 0) > 0 ||
|
||||||
|
(changes.secrets?.delete?.length ?? 0) > 0
|
||||||
|
) {
|
||||||
|
const approval = await secretApprovalRequestService.generateSecretApprovalRequestV2Bridge({
|
||||||
|
policy,
|
||||||
|
secretPath,
|
||||||
|
environment,
|
||||||
|
projectId,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
data: {
|
||||||
|
[SecretOperations.Create]:
|
||||||
|
changes.secrets?.create?.map((el) => ({
|
||||||
|
tagIds: el.tagIds,
|
||||||
|
secretValue: el.secretValue,
|
||||||
|
secretComment: el.secretComment,
|
||||||
|
metadata: el.metadata,
|
||||||
|
skipMultilineEncoding: el.skipMultilineEncoding,
|
||||||
|
secretKey: el.secretKey,
|
||||||
|
secretMetadata: el.secretMetadata
|
||||||
|
})) ?? [],
|
||||||
|
[SecretOperations.Update]:
|
||||||
|
changes.secrets?.update?.map((el) => ({
|
||||||
|
tagIds: el.tagIds,
|
||||||
|
newSecretName: el.newSecretName,
|
||||||
|
secretValue: el.secretValue,
|
||||||
|
secretComment: el.secretComment,
|
||||||
|
metadata: el.metadata,
|
||||||
|
skipMultilineEncoding: el.skipMultilineEncoding,
|
||||||
|
secretKey: el.secretKey,
|
||||||
|
secretMetadata: el.secretMetadata
|
||||||
|
})) ?? [],
|
||||||
|
[SecretOperations.Delete]:
|
||||||
|
changes.secrets?.delete?.map((el) => ({
|
||||||
|
secretKey: el.secretKey
|
||||||
|
})) ?? []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
approvalId: approval.id,
|
||||||
|
folderChanges,
|
||||||
|
secretMutationEvents
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
folderChanges,
|
||||||
|
secretMutationEvents
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((changes.secrets?.create?.length ?? 0) > 0) {
|
||||||
|
const newSecrets = await secretV2BridgeService.createManySecret({
|
||||||
|
secretPath,
|
||||||
|
environment,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
secrets: changes.secrets?.create ?? [],
|
||||||
|
tx: trx,
|
||||||
|
commitChanges
|
||||||
|
});
|
||||||
|
secretMutationEvents.push({
|
||||||
|
type: EventType.CREATE_SECRETS,
|
||||||
|
metadata: {
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
secrets: newSecrets.map((secret) => ({
|
||||||
|
secretId: secret.id,
|
||||||
|
secretKey: secret.secretKey,
|
||||||
|
secretVersion: secret.version
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ((changes.secrets?.update?.length ?? 0) > 0) {
|
||||||
|
const updatedSecrets = await secretV2BridgeService.updateManySecret({
|
||||||
|
secretPath,
|
||||||
|
environment,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
secrets: changes.secrets?.update ?? [],
|
||||||
|
mode: SecretUpdateMode.FailOnNotFound,
|
||||||
|
tx: trx,
|
||||||
|
commitChanges
|
||||||
|
});
|
||||||
|
secretMutationEvents.push({
|
||||||
|
type: EventType.UPDATE_SECRETS,
|
||||||
|
metadata: {
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
secrets: updatedSecrets.map((secret) => ({
|
||||||
|
secretId: secret.id,
|
||||||
|
secretKey: secret.secretKey,
|
||||||
|
secretVersion: secret.version
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if ((changes.secrets?.delete?.length ?? 0) > 0) {
|
||||||
|
const deletedSecrets = await secretV2BridgeService.deleteManySecret({
|
||||||
|
secretPath,
|
||||||
|
environment,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
secrets: changes.secrets?.delete ?? [],
|
||||||
|
tx: trx,
|
||||||
|
commitChanges
|
||||||
|
});
|
||||||
|
secretMutationEvents.push({
|
||||||
|
type: EventType.DELETE_SECRETS,
|
||||||
|
metadata: {
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
secrets: deletedSecrets.map((secret) => ({
|
||||||
|
secretId: secret.id,
|
||||||
|
secretKey: secret.secretKey,
|
||||||
|
secretVersion: secret.version
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (commitChanges?.length > 0) {
|
||||||
|
const commit = await folderCommitService.createCommit(
|
||||||
|
{
|
||||||
|
actor: {
|
||||||
|
type: actor || ActorType.PLATFORM,
|
||||||
|
metadata: {
|
||||||
|
id: actorId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message,
|
||||||
|
folderId: targetFolder.id,
|
||||||
|
changes: commitChanges
|
||||||
|
},
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
folderChanges,
|
||||||
|
commitId: commit?.id,
|
||||||
|
secretMutationEvents
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
folderChanges,
|
||||||
|
secretMutationEvents
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return response;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getCommitsCount,
|
getCommitsCount,
|
||||||
getCommitsForFolder,
|
getCommitsForFolder,
|
||||||
@@ -478,6 +847,7 @@ export const pitServiceFactory = ({
|
|||||||
compareCommitChanges,
|
compareCommitChanges,
|
||||||
rollbackToCommit,
|
rollbackToCommit,
|
||||||
revertCommit,
|
revertCommit,
|
||||||
getFolderStateAtCommit
|
getFolderStateAtCommit,
|
||||||
|
processNewCommitRaw
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/* eslint-disable no-nested-ternary */
|
/* eslint-disable no-nested-ternary */
|
||||||
import { ForbiddenError, subject } from "@casl/ability";
|
import { ForbiddenError, subject } from "@casl/ability";
|
||||||
|
import { Knex } from "knex";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ProjectMembershipRole,
|
ProjectMembershipRole,
|
||||||
@@ -1368,8 +1369,9 @@ export const secretApprovalRequestServiceFactory = ({
|
|||||||
policy,
|
policy,
|
||||||
projectId,
|
projectId,
|
||||||
secretPath,
|
secretPath,
|
||||||
environment
|
environment,
|
||||||
}: TGenerateSecretApprovalRequestV2BridgeDTO) => {
|
trx: providedTx
|
||||||
|
}: TGenerateSecretApprovalRequestV2BridgeDTO & { trx?: Knex }) => {
|
||||||
if (actor === ActorType.SERVICE || actor === ActorType.Machine)
|
if (actor === ActorType.SERVICE || actor === ActorType.Machine)
|
||||||
throw new BadRequestError({ message: "Cannot use service token or machine token over protected branches" });
|
throw new BadRequestError({ message: "Cannot use service token or machine token over protected branches" });
|
||||||
|
|
||||||
@@ -1595,7 +1597,7 @@ export const secretApprovalRequestServiceFactory = ({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const secretApprovalRequest = await secretApprovalRequestDAL.transaction(async (tx) => {
|
const executeApprovalRequestCreation = async (tx: Knex) => {
|
||||||
const doc = await secretApprovalRequestDAL.create(
|
const doc = await secretApprovalRequestDAL.create(
|
||||||
{
|
{
|
||||||
folderId,
|
folderId,
|
||||||
@@ -1657,7 +1659,11 @@ export const secretApprovalRequestServiceFactory = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { ...doc, commits: approvalCommits };
|
return { ...doc, commits: approvalCommits };
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const secretApprovalRequest = providedTx
|
||||||
|
? await executeApprovalRequestCreation(providedTx)
|
||||||
|
: await secretApprovalRequestDAL.transaction(executeApprovalRequestCreation);
|
||||||
|
|
||||||
const user = await userDAL.findById(actorId);
|
const user = await userDAL.findById(actorId);
|
||||||
const env = await projectEnvDAL.findOne({ id: policy.envId });
|
const env = await projectEnvDAL.findOne({ id: policy.envId });
|
||||||
|
|||||||
@@ -1538,7 +1538,12 @@ export const registerRoutes = async (
|
|||||||
folderService,
|
folderService,
|
||||||
permissionService,
|
permissionService,
|
||||||
folderDAL,
|
folderDAL,
|
||||||
projectEnvDAL
|
projectEnvDAL,
|
||||||
|
secretApprovalRequestService,
|
||||||
|
secretApprovalPolicyService,
|
||||||
|
projectDAL,
|
||||||
|
secretV2BridgeService,
|
||||||
|
folderCommitDAL
|
||||||
});
|
});
|
||||||
|
|
||||||
const identityOidcAuthService = identityOidcAuthServiceFactory({
|
const identityOidcAuthService = identityOidcAuthServiceFactory({
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ export enum ResourceType {
|
|||||||
FOLDER = "folder"
|
FOLDER = "folder"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TCommitResourceChangeDTO = {
|
||||||
|
type: string;
|
||||||
|
secretVersionId?: string;
|
||||||
|
folderVersionId?: string;
|
||||||
|
isUpdate?: boolean;
|
||||||
|
folderId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type TCreateCommitDTO = {
|
type TCreateCommitDTO = {
|
||||||
actor: {
|
actor: {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -57,13 +65,7 @@ type TCreateCommitDTO = {
|
|||||||
};
|
};
|
||||||
message?: string;
|
message?: string;
|
||||||
folderId: string;
|
folderId: string;
|
||||||
changes: {
|
changes: TCommitResourceChangeDTO[];
|
||||||
type: string;
|
|
||||||
secretVersionId?: string;
|
|
||||||
folderVersionId?: string;
|
|
||||||
isUpdate?: boolean;
|
|
||||||
folderId?: string;
|
|
||||||
}[];
|
|
||||||
omitIgnoreFilter?: boolean;
|
omitIgnoreFilter?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
/* eslint-disable no-await-in-loop */
|
||||||
import { ForbiddenError, subject } from "@casl/ability";
|
import { ForbiddenError, subject } from "@casl/ability";
|
||||||
|
import { Knex } from "knex";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { v4 as uuidv4, validate as uuidValidate } from "uuid";
|
import { v4 as uuidv4, validate as uuidValidate } from "uuid";
|
||||||
|
|
||||||
@@ -12,14 +14,21 @@ import { BadRequestError, NotFoundError } from "@app/lib/errors";
|
|||||||
import { OrderByDirection, OrgServiceActor } from "@app/lib/types";
|
import { OrderByDirection, OrgServiceActor } from "@app/lib/types";
|
||||||
import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns";
|
import { buildFolderPath } from "@app/services/secret-folder/secret-folder-fns";
|
||||||
|
|
||||||
import { ChangeType, CommitType, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
|
import {
|
||||||
|
ChangeType,
|
||||||
|
CommitType,
|
||||||
|
TCommitResourceChangeDTO,
|
||||||
|
TFolderCommitServiceFactory
|
||||||
|
} from "../folder-commit/folder-commit-service";
|
||||||
import { TProjectDALFactory } from "../project/project-dal";
|
import { TProjectDALFactory } from "../project/project-dal";
|
||||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||||
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
|
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
|
||||||
import { TSecretFolderDALFactory } from "./secret-folder-dal";
|
import { TSecretFolderDALFactory } from "./secret-folder-dal";
|
||||||
import {
|
import {
|
||||||
TCreateFolderDTO,
|
TCreateFolderDTO,
|
||||||
|
TCreateManyFoldersDTO,
|
||||||
TDeleteFolderDTO,
|
TDeleteFolderDTO,
|
||||||
|
TDeleteManyFoldersDTO,
|
||||||
TGetFolderByIdDTO,
|
TGetFolderByIdDTO,
|
||||||
TGetFolderDTO,
|
TGetFolderDTO,
|
||||||
TGetFoldersDeepByEnvsDTO,
|
TGetFoldersDeepByEnvsDTO,
|
||||||
@@ -236,19 +245,29 @@ export const secretFolderServiceFactory = ({
|
|||||||
actor,
|
actor,
|
||||||
actorId,
|
actorId,
|
||||||
projectSlug,
|
projectSlug,
|
||||||
|
projectId: providedProjectId,
|
||||||
actorAuthMethod,
|
actorAuthMethod,
|
||||||
actorOrgId,
|
actorOrgId,
|
||||||
folders
|
folders,
|
||||||
}: TUpdateManyFoldersDTO) => {
|
tx: providedTx,
|
||||||
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
commitChanges
|
||||||
if (!project) {
|
}: TUpdateManyFoldersDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[]; projectId?: string }) => {
|
||||||
throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
|
let projectId = providedProjectId;
|
||||||
|
if (!projectId && projectSlug) {
|
||||||
|
const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId);
|
||||||
|
if (!project) {
|
||||||
|
throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` });
|
||||||
|
}
|
||||||
|
projectId = project.id;
|
||||||
|
}
|
||||||
|
if (!projectId) {
|
||||||
|
throw new BadRequestError({ message: "Must provide either project slug or projectId" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { permission } = await permissionService.getProjectPermission({
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
actor,
|
actor,
|
||||||
actorId,
|
actorId,
|
||||||
projectId: project.id,
|
projectId,
|
||||||
actorAuthMethod,
|
actorAuthMethod,
|
||||||
actorOrgId
|
actorOrgId
|
||||||
});
|
});
|
||||||
@@ -260,12 +279,12 @@ export const secretFolderServiceFactory = ({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await folderDAL.transaction(async (tx) =>
|
const executeBulkUpdate = async (tx: Knex) => {
|
||||||
Promise.all(
|
return Promise.all(
|
||||||
folders.map(async (newFolder) => {
|
folders.map(async (newFolder) => {
|
||||||
const { environment, path: secretPath, id, name, description } = newFolder;
|
const { environment, path: secretPath, id, name, description } = newFolder;
|
||||||
|
|
||||||
const parentFolder = await folderDAL.findBySecretPath(project.id, environment, secretPath);
|
const parentFolder = await folderDAL.findBySecretPath(projectId as string, environment, secretPath, tx);
|
||||||
if (!parentFolder) {
|
if (!parentFolder) {
|
||||||
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`,
|
||||||
@@ -273,10 +292,10 @@ export const secretFolderServiceFactory = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const env = await projectEnvDAL.findOne({ projectId: project.id, 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 '${project.id}' not found`,
|
message: `Environment with slug '${environment}' in project with ID '${projectId}' not found`,
|
||||||
name: "UpdateManyFolders"
|
name: "UpdateManyFolders"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -323,26 +342,34 @@ export const secretFolderServiceFactory = ({
|
|||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
await folderCommitService.createCommit(
|
if (commitChanges) {
|
||||||
{
|
commitChanges.push({
|
||||||
actor: {
|
type: CommitType.ADD,
|
||||||
type: actor,
|
isUpdate: true,
|
||||||
metadata: {
|
folderVersionId: folderVersion.id
|
||||||
id: actorId
|
});
|
||||||
}
|
} else {
|
||||||
|
await folderCommitService.createCommit(
|
||||||
|
{
|
||||||
|
actor: {
|
||||||
|
type: actor,
|
||||||
|
metadata: {
|
||||||
|
id: actorId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: "Folder updated",
|
||||||
|
folderId: parentFolder.id,
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: CommitType.ADD,
|
||||||
|
isUpdate: true,
|
||||||
|
folderVersionId: folderVersion.id
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
message: "Folder updated",
|
tx
|
||||||
folderId: parentFolder.id,
|
);
|
||||||
changes: [
|
}
|
||||||
{
|
|
||||||
type: CommitType.ADD,
|
|
||||||
isUpdate: true,
|
|
||||||
folderVersionId: folderVersion.id
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
if (!doc) {
|
if (!doc) {
|
||||||
throw new NotFoundError({
|
throw new NotFoundError({
|
||||||
message: `Failed to update folder with id '${id}', not found`,
|
message: `Failed to update folder with id '${id}', not found`,
|
||||||
@@ -352,13 +379,16 @@ export const secretFolderServiceFactory = ({
|
|||||||
|
|
||||||
return { oldFolder: folder, newFolder: doc };
|
return { oldFolder: folder, newFolder: doc };
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
);
|
};
|
||||||
|
|
||||||
|
// Execute with provided transaction or create new one
|
||||||
|
const result = providedTx ? await executeBulkUpdate(providedTx) : await folderDAL.transaction(executeBulkUpdate);
|
||||||
|
|
||||||
await Promise.all(result.map(async (res) => snapshotService.performSnapshot(res.newFolder.parentId as string)));
|
await Promise.all(result.map(async (res) => snapshotService.performSnapshot(res.newFolder.parentId as string)));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectId: project.id,
|
projectId,
|
||||||
newFolders: result.map((res) => res.newFolder),
|
newFolders: result.map((res) => res.newFolder),
|
||||||
oldFolders: result.map((res) => res.oldFolder)
|
oldFolders: result.map((res) => res.oldFolder)
|
||||||
};
|
};
|
||||||
@@ -974,6 +1004,361 @@ export const secretFolderServiceFactory = ({
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createManyFolders = async ({
|
||||||
|
projectId,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId,
|
||||||
|
folders,
|
||||||
|
tx: providedTx,
|
||||||
|
commitChanges
|
||||||
|
}: TCreateManyFoldersDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[] }) => {
|
||||||
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
});
|
||||||
|
|
||||||
|
folders.forEach(({ environment, path: secretPath }) => {
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Create,
|
||||||
|
subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const foldersByEnv = folders.reduce(
|
||||||
|
(acc, folder) => {
|
||||||
|
if (!acc[folder.environment]) {
|
||||||
|
acc[folder.environment] = [];
|
||||||
|
}
|
||||||
|
acc[folder.environment].push(folder);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, typeof folders>
|
||||||
|
);
|
||||||
|
|
||||||
|
const executeBulkCreate = async (tx: Knex) => {
|
||||||
|
const createdFolders = [];
|
||||||
|
|
||||||
|
for (const [environment, envFolders] of Object.entries(foldersByEnv)) {
|
||||||
|
const env = await projectEnvDAL.findOne({ projectId, slug: environment });
|
||||||
|
if (!env) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Environment with slug '${environment}' in project with ID '${projectId}' not found`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.raw("SELECT pg_advisory_xact_lock(?)", [PgSqlLock.CreateFolder(env.id, env.projectId)]);
|
||||||
|
|
||||||
|
for (const folderSpec of envFolders) {
|
||||||
|
const { name, path: secretPath, description } = folderSpec;
|
||||||
|
|
||||||
|
const pathWithFolder = path.join(secretPath, name);
|
||||||
|
const parentFolder = await folderDAL.findClosestFolder(projectId, environment, pathWithFolder, tx);
|
||||||
|
|
||||||
|
if (!parentFolder) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Parent folder for path '${pathWithFolder}' not found`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the exact folder already exists
|
||||||
|
const existingFolder = await folderDAL.findOne(
|
||||||
|
{
|
||||||
|
envId: env.id,
|
||||||
|
parentId: parentFolder.id,
|
||||||
|
name,
|
||||||
|
isReserved: false
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingFolder) {
|
||||||
|
createdFolders.push(existingFolder);
|
||||||
|
// eslint-disable-next-line no-continue
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle exact folder case
|
||||||
|
if (parentFolder.path === pathWithFolder) {
|
||||||
|
createdFolders.push(parentFolder);
|
||||||
|
// eslint-disable-next-line no-continue
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentParentId = parentFolder.id;
|
||||||
|
|
||||||
|
// Build the full path we need by processing each segment
|
||||||
|
if (parentFolder.path !== secretPath) {
|
||||||
|
const missingSegments = secretPath.substring(parentFolder.path.length).split("/").filter(Boolean);
|
||||||
|
const newFolders: TSecretFoldersInsert[] = [];
|
||||||
|
|
||||||
|
for (const segment of missingSegments) {
|
||||||
|
const existingSegment = await folderDAL.findOne(
|
||||||
|
{
|
||||||
|
name: segment,
|
||||||
|
parentId: currentParentId,
|
||||||
|
envId: env.id,
|
||||||
|
isReserved: false
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingSegment) {
|
||||||
|
currentParentId = existingSegment.id;
|
||||||
|
} else {
|
||||||
|
const newFolder = {
|
||||||
|
name: segment,
|
||||||
|
parentId: currentParentId,
|
||||||
|
id: uuidv4(),
|
||||||
|
envId: env.id,
|
||||||
|
version: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
currentParentId = newFolder.id;
|
||||||
|
newFolders.push(newFolder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newFolders.length) {
|
||||||
|
const docs = await folderDAL.insertMany(newFolders, tx);
|
||||||
|
const folderVersions = await folderVersionDAL.insertMany(
|
||||||
|
docs.map((doc) => ({
|
||||||
|
name: doc.name,
|
||||||
|
envId: doc.envId,
|
||||||
|
version: doc.version,
|
||||||
|
folderId: doc.id,
|
||||||
|
description: doc.description
|
||||||
|
})),
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
await folderCommitService.createCommit(
|
||||||
|
{
|
||||||
|
actor: {
|
||||||
|
type: actor,
|
||||||
|
metadata: {
|
||||||
|
id: actorId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: "Folders created (batch)",
|
||||||
|
folderId: currentParentId,
|
||||||
|
changes: folderVersions.map((fv) => ({
|
||||||
|
type: CommitType.ADD,
|
||||||
|
folderVersionId: fv.id
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the target folder
|
||||||
|
const doc = await folderDAL.create(
|
||||||
|
{ name, envId: env.id, version: 1, parentId: currentParentId, description },
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const folderVersion = await folderVersionDAL.create(
|
||||||
|
{
|
||||||
|
name: doc.name,
|
||||||
|
envId: doc.envId,
|
||||||
|
version: doc.version,
|
||||||
|
folderId: doc.id,
|
||||||
|
description: doc.description
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
if (commitChanges) {
|
||||||
|
commitChanges.push({
|
||||||
|
type: CommitType.ADD,
|
||||||
|
folderVersionId: folderVersion.id
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await folderCommitService.createCommit(
|
||||||
|
{
|
||||||
|
actor: {
|
||||||
|
type: actor,
|
||||||
|
metadata: {
|
||||||
|
id: actorId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: "Folder created (batch)",
|
||||||
|
folderId: doc.id,
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: CommitType.ADD,
|
||||||
|
folderVersionId: folderVersion.id
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
createdFolders.push(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdFolders;
|
||||||
|
};
|
||||||
|
const result = providedTx ? await executeBulkCreate(providedTx) : await folderDAL.transaction(executeBulkCreate);
|
||||||
|
const uniqueParentIds = [...new Set(result.map((folder) => folder.parentId).filter(Boolean))];
|
||||||
|
await Promise.all(uniqueParentIds.map((parentId) => snapshotService.performSnapshot(parentId as string)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
folders: result,
|
||||||
|
count: result.length
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteManyFolders = async ({
|
||||||
|
projectId,
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
actorOrgId,
|
||||||
|
actorAuthMethod,
|
||||||
|
folders,
|
||||||
|
tx: providedTx,
|
||||||
|
commitChanges
|
||||||
|
}: TDeleteManyFoldersDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[] }) => {
|
||||||
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
|
actor,
|
||||||
|
actorId,
|
||||||
|
projectId,
|
||||||
|
actorAuthMethod,
|
||||||
|
actorOrgId
|
||||||
|
});
|
||||||
|
|
||||||
|
folders.forEach(({ environment, path: secretPath }) => {
|
||||||
|
ForbiddenError.from(permission).throwUnlessCan(
|
||||||
|
ProjectPermissionActions.Delete,
|
||||||
|
subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const foldersByEnv = folders.reduce(
|
||||||
|
(acc, folder) => {
|
||||||
|
if (!acc[folder.environment]) {
|
||||||
|
acc[folder.environment] = [];
|
||||||
|
}
|
||||||
|
acc[folder.environment].push(folder);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, typeof folders>
|
||||||
|
);
|
||||||
|
|
||||||
|
const executeBulkDelete = async (tx: Knex) => {
|
||||||
|
const deletedFolders = [];
|
||||||
|
|
||||||
|
for (const [environment, envFolders] of Object.entries(foldersByEnv)) {
|
||||||
|
const env = await projectEnvDAL.findOne({ projectId, slug: environment });
|
||||||
|
if (!env) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Environment with slug '${environment}' not found`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const folderSpec of envFolders) {
|
||||||
|
const { path: secretPath, idOrName } = folderSpec;
|
||||||
|
|
||||||
|
const parentFolder = await folderDAL.findBySecretPath(projectId, environment, secretPath, tx);
|
||||||
|
if (!parentFolder) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Folder with path '${secretPath}' in environment with slug '${environment}' not found`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await $checkFolderPolicy({ projectId, env, parentId: parentFolder.id, idOrName });
|
||||||
|
|
||||||
|
let folderToDelete = await folderDAL
|
||||||
|
.findOne({
|
||||||
|
envId: env.id,
|
||||||
|
name: idOrName,
|
||||||
|
parentId: parentFolder.id,
|
||||||
|
isReserved: false
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (!folderToDelete && uuidValidate(idOrName)) {
|
||||||
|
folderToDelete = await folderDAL
|
||||||
|
.findOne({
|
||||||
|
envId: env.id,
|
||||||
|
id: idOrName,
|
||||||
|
parentId: parentFolder.id,
|
||||||
|
isReserved: false
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!folderToDelete) {
|
||||||
|
throw new NotFoundError({
|
||||||
|
message: `Folder with ID/name '${idOrName}' not found`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [doc] = await folderDAL.delete(
|
||||||
|
{
|
||||||
|
envId: env.id,
|
||||||
|
id: folderToDelete.id,
|
||||||
|
parentId: parentFolder.id,
|
||||||
|
isReserved: false
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
|
||||||
|
const folderVersions = await folderVersionDAL.findLatestFolderVersions([doc.id], tx);
|
||||||
|
|
||||||
|
if (commitChanges) {
|
||||||
|
commitChanges.push({
|
||||||
|
type: CommitType.DELETE,
|
||||||
|
folderVersionId: folderVersions[doc.id].id,
|
||||||
|
folderId: doc.id
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await folderCommitService.createCommit(
|
||||||
|
{
|
||||||
|
actor: {
|
||||||
|
type: actor,
|
||||||
|
metadata: {
|
||||||
|
id: actorId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: "Folder deleted (batch)",
|
||||||
|
folderId: parentFolder.id,
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
type: CommitType.DELETE,
|
||||||
|
folderVersionId: folderVersions[doc.id].id,
|
||||||
|
folderId: doc.id
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
deletedFolders.push(doc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return deletedFolders;
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = providedTx ? await executeBulkDelete(providedTx) : await folderDAL.transaction(executeBulkDelete);
|
||||||
|
|
||||||
|
const uniqueParentIds = [...new Set(result.map((folder) => folder.parentId).filter(Boolean))];
|
||||||
|
await Promise.all(uniqueParentIds.map((parentId) => snapshotService.performSnapshot(parentId as string)));
|
||||||
|
|
||||||
|
return {
|
||||||
|
folders: result,
|
||||||
|
count: result.length
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
createFolder,
|
createFolder,
|
||||||
updateFolder,
|
updateFolder,
|
||||||
@@ -986,6 +1371,8 @@ export const secretFolderServiceFactory = ({
|
|||||||
getFoldersDeepByEnvs,
|
getFoldersDeepByEnvs,
|
||||||
getProjectEnvironmentsFolders,
|
getProjectEnvironmentsFolders,
|
||||||
getFolderVersionsByIds,
|
getFolderVersionsByIds,
|
||||||
getFolderVersions
|
getFolderVersions,
|
||||||
|
createManyFolders,
|
||||||
|
deleteManyFolders
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
|
import { OrderByDirection, TProjectPermission } from "@app/lib/types";
|
||||||
import { SecretsOrderBy } from "@app/services/secret/secret-types";
|
import { SecretsOrderBy } from "@app/services/secret/secret-types";
|
||||||
|
|
||||||
|
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
|
||||||
|
|
||||||
export enum ReservedFolders {
|
export enum ReservedFolders {
|
||||||
SecretReplication = "__reserve_replication_"
|
SecretReplication = "__reserve_replication_"
|
||||||
}
|
}
|
||||||
@@ -21,7 +23,7 @@ export type TUpdateFolderDTO = {
|
|||||||
} & TProjectPermission;
|
} & TProjectPermission;
|
||||||
|
|
||||||
export type TUpdateManyFoldersDTO = {
|
export type TUpdateManyFoldersDTO = {
|
||||||
projectSlug: string;
|
projectSlug?: string;
|
||||||
folders: {
|
folders: {
|
||||||
environment: string;
|
environment: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -62,3 +64,30 @@ export type TGetFoldersDeepByEnvsDTO = {
|
|||||||
export type TFindFoldersDeepByParentIdsDTO = {
|
export type TFindFoldersDeepByParentIdsDTO = {
|
||||||
parentIds: string[];
|
parentIds: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TCreateManyFoldersDTO = {
|
||||||
|
projectId: string;
|
||||||
|
actor: ActorType;
|
||||||
|
actorId: string;
|
||||||
|
actorAuthMethod: ActorAuthMethod;
|
||||||
|
actorOrgId?: string;
|
||||||
|
folders: Array<{
|
||||||
|
name: string;
|
||||||
|
environment: string;
|
||||||
|
path: string;
|
||||||
|
description?: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TDeleteManyFoldersDTO = {
|
||||||
|
projectId: string;
|
||||||
|
actor: ActorType;
|
||||||
|
actorId: string;
|
||||||
|
actorAuthMethod: ActorAuthMethod;
|
||||||
|
actorOrgId?: string;
|
||||||
|
folders: Array<{
|
||||||
|
environment: string;
|
||||||
|
path: string;
|
||||||
|
idOrName: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export const getAllSecretReferences = (maybeSecretReference: string) => {
|
|||||||
export const fnSecretBulkInsert = async ({
|
export const fnSecretBulkInsert = async ({
|
||||||
// TODO: Pick types here
|
// TODO: Pick types here
|
||||||
folderId,
|
folderId,
|
||||||
|
commitChanges,
|
||||||
orgId,
|
orgId,
|
||||||
inputSecrets,
|
inputSecrets,
|
||||||
secretDAL,
|
secretDAL,
|
||||||
@@ -134,28 +135,32 @@ export const fnSecretBulkInsert = async ({
|
|||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
|
|
||||||
const commitChanges = secretVersions
|
const changes = secretVersions
|
||||||
.filter(({ type }) => type === SecretType.Shared)
|
.filter(({ type }) => type === SecretType.Shared)
|
||||||
.map((sv) => ({
|
.map((sv) => ({
|
||||||
type: CommitType.ADD,
|
type: CommitType.ADD,
|
||||||
secretVersionId: sv.id
|
secretVersionId: sv.id
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (commitChanges.length > 0) {
|
if (changes.length > 0) {
|
||||||
await folderCommitService.createCommit(
|
if (commitChanges) {
|
||||||
{
|
commitChanges.push(...changes);
|
||||||
actor: {
|
} else {
|
||||||
type: actorType || ActorType.PLATFORM,
|
await folderCommitService.createCommit(
|
||||||
metadata: {
|
{
|
||||||
id: actor?.actorId
|
actor: {
|
||||||
}
|
type: actorType || ActorType.PLATFORM,
|
||||||
|
metadata: {
|
||||||
|
id: actor?.actorId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: "Secret Created",
|
||||||
|
folderId,
|
||||||
|
changes
|
||||||
},
|
},
|
||||||
message: "Secret Created",
|
tx
|
||||||
folderId,
|
);
|
||||||
changes: commitChanges
|
}
|
||||||
},
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await secretDAL.upsertSecretReferences(
|
await secretDAL.upsertSecretReferences(
|
||||||
@@ -209,6 +214,7 @@ export const fnSecretBulkUpdate = async ({
|
|||||||
tx,
|
tx,
|
||||||
inputSecrets,
|
inputSecrets,
|
||||||
folderId,
|
folderId,
|
||||||
|
commitChanges,
|
||||||
orgId,
|
orgId,
|
||||||
secretDAL,
|
secretDAL,
|
||||||
secretVersionDAL,
|
secretVersionDAL,
|
||||||
@@ -233,6 +239,7 @@ export const fnSecretBulkUpdate = async ({
|
|||||||
userId,
|
userId,
|
||||||
encryptedComment,
|
encryptedComment,
|
||||||
metadata,
|
metadata,
|
||||||
|
secretMetadata,
|
||||||
reminderNote,
|
reminderNote,
|
||||||
reminderRepeatDays
|
reminderRepeatDays
|
||||||
}
|
}
|
||||||
@@ -244,7 +251,7 @@ export const fnSecretBulkUpdate = async ({
|
|||||||
key,
|
key,
|
||||||
userId,
|
userId,
|
||||||
encryptedComment,
|
encryptedComment,
|
||||||
metadata,
|
metadata: JSON.stringify(metadata || secretMetadata || []),
|
||||||
reminderNote,
|
reminderNote,
|
||||||
encryptedValue,
|
encryptedValue,
|
||||||
reminderRepeatDays
|
reminderRepeatDays
|
||||||
@@ -359,28 +366,32 @@ export const fnSecretBulkUpdate = async ({
|
|||||||
{ tx }
|
{ tx }
|
||||||
);
|
);
|
||||||
|
|
||||||
const commitChanges = secretVersions
|
const changes = secretVersions
|
||||||
.filter(({ type }) => type === SecretType.Shared)
|
.filter(({ type }) => type === SecretType.Shared)
|
||||||
.map((sv) => ({
|
.map((sv) => ({
|
||||||
type: CommitType.ADD,
|
type: CommitType.ADD,
|
||||||
isUpdate: true,
|
isUpdate: true,
|
||||||
secretVersionId: sv.id
|
secretVersionId: sv.id
|
||||||
}));
|
}));
|
||||||
if (commitChanges.length > 0) {
|
if (changes.length > 0) {
|
||||||
await folderCommitService.createCommit(
|
if (commitChanges) {
|
||||||
{
|
commitChanges.push(...changes);
|
||||||
actor: {
|
} else {
|
||||||
type: actorType || ActorType.PLATFORM,
|
await folderCommitService.createCommit(
|
||||||
metadata: {
|
{
|
||||||
id: actor?.actorId
|
actor: {
|
||||||
}
|
type: actorType || ActorType.PLATFORM,
|
||||||
|
metadata: {
|
||||||
|
id: actor?.actorId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: "Secret Updated",
|
||||||
|
folderId,
|
||||||
|
changes
|
||||||
},
|
},
|
||||||
message: "Secret Updated",
|
tx
|
||||||
folderId,
|
);
|
||||||
changes: commitChanges
|
}
|
||||||
},
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return secretsWithTags.map((secret) => ({ ...secret, _id: secret.id }));
|
return secretsWithTags.map((secret) => ({ ...secret, _id: secret.id }));
|
||||||
@@ -395,7 +406,8 @@ export const fnSecretBulkDelete = async ({
|
|||||||
secretDAL,
|
secretDAL,
|
||||||
secretQueueService,
|
secretQueueService,
|
||||||
folderCommitService,
|
folderCommitService,
|
||||||
secretVersionDAL
|
secretVersionDAL,
|
||||||
|
commitChanges
|
||||||
}: TFnSecretBulkDelete) => {
|
}: TFnSecretBulkDelete) => {
|
||||||
const deletedSecrets = await secretDAL.deleteMany(
|
const deletedSecrets = await secretDAL.deleteMany(
|
||||||
inputSecrets.map(({ type, secretKey }) => ({
|
inputSecrets.map(({ type, secretKey }) => ({
|
||||||
@@ -421,27 +433,31 @@ export const fnSecretBulkDelete = async ({
|
|||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
|
|
||||||
const commitChanges = deletedSecrets
|
const changes = deletedSecrets
|
||||||
.filter(({ type }) => type === SecretType.Shared)
|
.filter(({ type }) => type === SecretType.Shared)
|
||||||
.map(({ id }) => ({
|
.map(({ id }) => ({
|
||||||
type: CommitType.DELETE,
|
type: CommitType.DELETE,
|
||||||
secretVersionId: secretVersions[id].id
|
secretVersionId: secretVersions[id].id
|
||||||
}));
|
}));
|
||||||
if (commitChanges.length > 0) {
|
if (changes.length > 0) {
|
||||||
await folderCommitService.createCommit(
|
if (commitChanges) {
|
||||||
{
|
commitChanges.push(...changes);
|
||||||
actor: {
|
} else {
|
||||||
type: actorType || ActorType.PLATFORM,
|
await folderCommitService.createCommit(
|
||||||
metadata: {
|
{
|
||||||
id: actorId
|
actor: {
|
||||||
}
|
type: actorType || ActorType.PLATFORM,
|
||||||
|
metadata: {
|
||||||
|
id: actorId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: "Secret Deleted",
|
||||||
|
folderId,
|
||||||
|
changes
|
||||||
},
|
},
|
||||||
message: "Secret Deleted",
|
tx
|
||||||
folderId,
|
);
|
||||||
changes: commitChanges
|
}
|
||||||
},
|
|
||||||
tx
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return deletedSecrets;
|
return deletedSecrets;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { logger } from "@app/lib/logger";
|
|||||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||||
|
|
||||||
import { ActorType } from "../auth/auth-type";
|
import { ActorType } from "../auth/auth-type";
|
||||||
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
|
import { TCommitResourceChangeDTO, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
|
||||||
import { TKmsServiceFactory } from "../kms/kms-service";
|
import { TKmsServiceFactory } from "../kms/kms-service";
|
||||||
import { KmsDataKey } from "../kms/kms-types";
|
import { KmsDataKey } from "../kms/kms-types";
|
||||||
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
|
||||||
@@ -1505,8 +1505,10 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
actorOrgId,
|
actorOrgId,
|
||||||
environment,
|
environment,
|
||||||
projectId,
|
projectId,
|
||||||
secrets: inputSecrets
|
secrets: inputSecrets,
|
||||||
}: TCreateManySecretDTO) => {
|
tx: providedTx,
|
||||||
|
commitChanges
|
||||||
|
}: TCreateManySecretDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[] }) => {
|
||||||
const { permission } = await permissionService.getProjectPermission({
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
actor,
|
actor,
|
||||||
actorId,
|
actorId,
|
||||||
@@ -1589,8 +1591,8 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } =
|
const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } =
|
||||||
await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId });
|
await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId });
|
||||||
|
|
||||||
const newSecrets = await secretDAL.transaction(async (tx) =>
|
const executeBulkInsert = async (tx: Knex) => {
|
||||||
fnSecretBulkInsert({
|
return fnSecretBulkInsert({
|
||||||
inputSecrets: inputSecrets.map((el) => {
|
inputSecrets: inputSecrets.map((el) => {
|
||||||
const references = secretReferencesGroupByInputSecretKey[el.secretKey]?.nestedReferences;
|
const references = secretReferencesGroupByInputSecretKey[el.secretKey]?.nestedReferences;
|
||||||
|
|
||||||
@@ -1612,6 +1614,7 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
folderId,
|
folderId,
|
||||||
|
commitChanges,
|
||||||
orgId: actorOrgId,
|
orgId: actorOrgId,
|
||||||
secretDAL,
|
secretDAL,
|
||||||
resourceMetadataDAL,
|
resourceMetadataDAL,
|
||||||
@@ -1624,8 +1627,13 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
actorId
|
actorId
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
})
|
});
|
||||||
);
|
};
|
||||||
|
|
||||||
|
const newSecrets = providedTx
|
||||||
|
? await executeBulkInsert(providedTx)
|
||||||
|
: await secretDAL.transaction(executeBulkInsert);
|
||||||
|
|
||||||
await secretDAL.invalidateSecretCacheByProjectId(projectId);
|
await secretDAL.invalidateSecretCacheByProjectId(projectId);
|
||||||
await snapshotService.performSnapshot(folderId);
|
await snapshotService.performSnapshot(folderId);
|
||||||
await secretQueueService.syncSecrets({
|
await secretQueueService.syncSecrets({
|
||||||
@@ -1672,8 +1680,10 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
projectId,
|
projectId,
|
||||||
secretPath: defaultSecretPath = "/",
|
secretPath: defaultSecretPath = "/",
|
||||||
secrets: inputSecrets,
|
secrets: inputSecrets,
|
||||||
mode: updateMode
|
mode: updateMode,
|
||||||
}: TUpdateManySecretDTO) => {
|
tx: providedTx,
|
||||||
|
commitChanges
|
||||||
|
}: TUpdateManySecretDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[] }) => {
|
||||||
const { permission } = await permissionService.getProjectPermission({
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
actor,
|
actor,
|
||||||
actorId,
|
actorId,
|
||||||
@@ -1702,18 +1712,20 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } =
|
const { encryptor: secretManagerEncryptor, decryptor: secretManagerDecryptor } =
|
||||||
await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId });
|
await kmsService.createCipherPairWithDataKey({ type: KmsDataKey.SecretManager, projectId });
|
||||||
|
|
||||||
const updatedSecrets: Array<
|
// Function to execute the bulk update operation
|
||||||
TSecretsV2 & {
|
const executeBulkUpdate = async (tx: Knex) => {
|
||||||
secretPath: string;
|
const updatedSecrets: Array<
|
||||||
tags: {
|
TSecretsV2 & {
|
||||||
id: string;
|
secretPath: string;
|
||||||
slug: string;
|
tags: {
|
||||||
color?: string | null;
|
id: string;
|
||||||
name: string;
|
slug: string;
|
||||||
}[];
|
color?: string | null;
|
||||||
}
|
name: string;
|
||||||
> = [];
|
}[];
|
||||||
await secretDAL.transaction(async (tx) => {
|
}
|
||||||
|
> = [];
|
||||||
|
|
||||||
for await (const folder of folders) {
|
for await (const folder of folders) {
|
||||||
if (!folder) throw new NotFoundError({ message: "Folder not found" });
|
if (!folder) throw new NotFoundError({ message: "Folder not found" });
|
||||||
|
|
||||||
@@ -1832,7 +1844,7 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
{
|
{
|
||||||
operator: "eq",
|
operator: "eq",
|
||||||
field: `${TableName.SecretV2}.key` as "key",
|
field: `${TableName.SecretV2}.key` as "key",
|
||||||
value: el.secretKey
|
value: el.newSecretName as string
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
operator: "eq",
|
operator: "eq",
|
||||||
@@ -1886,6 +1898,7 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
orgId: actorOrgId,
|
orgId: actorOrgId,
|
||||||
folderCommitService,
|
folderCommitService,
|
||||||
tx,
|
tx,
|
||||||
|
commitChanges,
|
||||||
inputSecrets: secretsToUpdate.map((el) => {
|
inputSecrets: secretsToUpdate.map((el) => {
|
||||||
const originalSecret = secretsToUpdateInDBGroupedByKey[el.secretKey][0];
|
const originalSecret = secretsToUpdateInDBGroupedByKey[el.secretKey][0];
|
||||||
const encryptedValue =
|
const encryptedValue =
|
||||||
@@ -1965,7 +1978,13 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
updatedSecrets.push(...bulkInsertedSecrets.map((el) => ({ ...el, secretPath: folder.path })));
|
updatedSecrets.push(...bulkInsertedSecrets.map((el) => ({ ...el, secretPath: folder.path })));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
return updatedSecrets;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedSecrets = providedTx
|
||||||
|
? await executeBulkUpdate(providedTx)
|
||||||
|
: await secretDAL.transaction(executeBulkUpdate);
|
||||||
|
|
||||||
await secretDAL.invalidateSecretCacheByProjectId(projectId);
|
await secretDAL.invalidateSecretCacheByProjectId(projectId);
|
||||||
await Promise.allSettled(folders.map((el) => (el?.id ? snapshotService.performSnapshot(el.id) : undefined)));
|
await Promise.allSettled(folders.map((el) => (el?.id ? snapshotService.performSnapshot(el.id) : undefined)));
|
||||||
@@ -2022,8 +2041,10 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
actor,
|
actor,
|
||||||
actorId,
|
actorId,
|
||||||
actorAuthMethod,
|
actorAuthMethod,
|
||||||
actorOrgId
|
actorOrgId,
|
||||||
}: TDeleteManySecretDTO) => {
|
tx: providedTx,
|
||||||
|
commitChanges
|
||||||
|
}: TDeleteManySecretDTO & { tx?: Knex; commitChanges?: TCommitResourceChangeDTO[] }) => {
|
||||||
const { permission } = await permissionService.getProjectPermission({
|
const { permission } = await permissionService.getProjectPermission({
|
||||||
actor,
|
actor,
|
||||||
actorId,
|
actorId,
|
||||||
@@ -2082,24 +2103,29 @@ export const secretV2BridgeServiceFactory = ({
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const executeBulkDelete = async (tx: Knex) => {
|
||||||
|
return fnSecretBulkDelete({
|
||||||
|
secretDAL,
|
||||||
|
secretQueueService,
|
||||||
|
folderCommitService,
|
||||||
|
secretVersionDAL,
|
||||||
|
inputSecrets: inputSecrets.map(({ type, secretKey }) => ({
|
||||||
|
secretKey,
|
||||||
|
type: type || SecretType.Shared
|
||||||
|
})),
|
||||||
|
projectId,
|
||||||
|
folderId,
|
||||||
|
actorId,
|
||||||
|
actorType: actor,
|
||||||
|
commitChanges,
|
||||||
|
tx
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const secretsDeleted = await secretDAL.transaction(async (tx) =>
|
const secretsDeleted = providedTx
|
||||||
fnSecretBulkDelete({
|
? await executeBulkDelete(providedTx)
|
||||||
secretDAL,
|
: await secretDAL.transaction(executeBulkDelete);
|
||||||
secretQueueService,
|
|
||||||
folderCommitService,
|
|
||||||
secretVersionDAL,
|
|
||||||
inputSecrets: inputSecrets.map(({ type, secretKey }) => ({
|
|
||||||
secretKey,
|
|
||||||
type: type || SecretType.Shared
|
|
||||||
})),
|
|
||||||
projectId,
|
|
||||||
folderId,
|
|
||||||
actorId,
|
|
||||||
actorType: actor,
|
|
||||||
tx
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
await secretDAL.invalidateSecretCacheByProjectId(projectId);
|
await secretDAL.invalidateSecretCacheByProjectId(projectId);
|
||||||
await snapshotService.performSnapshot(folderId);
|
await snapshotService.performSnapshot(folderId);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { SecretsOrderBy } 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 { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
import { TSecretTagDALFactory } from "@app/services/secret-tag/secret-tag-dal";
|
||||||
|
|
||||||
import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
|
import { TCommitResourceChangeDTO, TFolderCommitServiceFactory } from "../folder-commit/folder-commit-service";
|
||||||
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
|
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
|
||||||
import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema";
|
import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema";
|
||||||
import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal";
|
import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal";
|
||||||
@@ -167,6 +167,7 @@ export type TFnSecretBulkInsert = {
|
|||||||
folderId: string;
|
folderId: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
tx?: Knex;
|
tx?: Knex;
|
||||||
|
commitChanges?: TCommitResourceChangeDTO[];
|
||||||
inputSecrets: Array<
|
inputSecrets: Array<
|
||||||
Omit<TSecretsV2Insert, "folderId"> & {
|
Omit<TSecretsV2Insert, "folderId"> & {
|
||||||
tagIds?: string[];
|
tagIds?: string[];
|
||||||
@@ -214,6 +215,7 @@ export type TFnSecretBulkUpdate = {
|
|||||||
actorId?: string;
|
actorId?: string;
|
||||||
};
|
};
|
||||||
tx?: Knex;
|
tx?: Knex;
|
||||||
|
commitChanges?: TCommitResourceChangeDTO[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TFnSecretBulkDelete = {
|
export type TFnSecretBulkDelete = {
|
||||||
@@ -223,6 +225,7 @@ export type TFnSecretBulkDelete = {
|
|||||||
actorId: string;
|
actorId: string;
|
||||||
actorType?: string;
|
actorType?: string;
|
||||||
tx?: Knex;
|
tx?: Knex;
|
||||||
|
commitChanges?: TCommitResourceChangeDTO[];
|
||||||
secretDAL: Pick<TSecretV2BridgeDALFactory, "deleteMany">;
|
secretDAL: Pick<TSecretV2BridgeDALFactory, "deleteMany">;
|
||||||
secretQueueService: {
|
secretQueueService: {
|
||||||
removeSecretReminder: (data: TRemoveSecretReminderDTO, tx?: Knex) => Promise<void>;
|
removeSecretReminder: (data: TRemoveSecretReminderDTO, tx?: Knex) => Promise<void>;
|
||||||
|
|||||||
@@ -544,3 +544,33 @@ export enum SecretProtectionType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TStartSecretsV2MigrationDTO = TProjectPermission;
|
export type TStartSecretsV2MigrationDTO = TProjectPermission;
|
||||||
|
|
||||||
|
export type TProcessNewCommitRawDTO = {
|
||||||
|
secrets: {
|
||||||
|
create?: {
|
||||||
|
secretKey: string;
|
||||||
|
secretValue: string;
|
||||||
|
secretComment?: string;
|
||||||
|
skipMultilineEncoding?: boolean;
|
||||||
|
tagIds?: string[];
|
||||||
|
secretMetadata?: ResourceMetadataDTO;
|
||||||
|
metadata?: { source?: string };
|
||||||
|
}[];
|
||||||
|
update?: {
|
||||||
|
secretKey: string;
|
||||||
|
newSecretName?: string;
|
||||||
|
secretValue?: string;
|
||||||
|
secretComment?: string;
|
||||||
|
skipMultilineEncoding?: boolean;
|
||||||
|
tagIds?: string[];
|
||||||
|
secretMetadata?: ResourceMetadataDTO;
|
||||||
|
metadata?: { source?: string };
|
||||||
|
}[];
|
||||||
|
delete?: { secretKey: string }[];
|
||||||
|
};
|
||||||
|
folders: {
|
||||||
|
create?: { folderName: string; description?: string }[];
|
||||||
|
update?: { folderName: string; description?: string | null; id: string }[];
|
||||||
|
delete?: { folderName: string; id: string }[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ export const CopyButton = ({
|
|||||||
variant={variant}
|
variant={variant}
|
||||||
className={twMerge("group relative", color)}
|
className={twMerge("group relative", color)}
|
||||||
size={size}
|
size={size}
|
||||||
onClick={() => {
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
handleCopyText();
|
handleCopyText();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,11 +4,19 @@ export enum ReservedFolders {
|
|||||||
SecretReplication = "__reserve_replication_"
|
SecretReplication = "__reserve_replication_"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum PendingAction {
|
||||||
|
Create = "create",
|
||||||
|
Update = "update",
|
||||||
|
Delete = "delete"
|
||||||
|
}
|
||||||
|
|
||||||
export type TSecretFolder = {
|
export type TSecretFolder = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
|
isPending?: boolean;
|
||||||
|
pendingAction?: PendingAction;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TSecretFolderWithPath = TSecretFolder & { path: string };
|
export type TSecretFolderWithPath = TSecretFolder & { path: string };
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ import { MutationOptions, useMutation, useQueryClient } from "@tanstack/react-qu
|
|||||||
|
|
||||||
import { apiRequest } from "@app/config/request";
|
import { apiRequest } from "@app/config/request";
|
||||||
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
|
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
|
||||||
|
import {
|
||||||
|
PendingChanges,
|
||||||
|
PendingSecretUpdate
|
||||||
|
} from "@app/pages/secret-manager/SecretDashboardPage/SecretMainPage.store";
|
||||||
|
|
||||||
import { commitKeys } from "../folderCommits/queries";
|
import { commitKeys } from "../folderCommits/queries";
|
||||||
import { secretApprovalRequestKeys } from "../secretApprovalRequest/queries";
|
import { secretApprovalRequestKeys } from "../secretApprovalRequest/queries";
|
||||||
|
import { PendingAction } from "../secretFolders/types";
|
||||||
import { secretSnapshotKeys } from "../secretSnapshots/queries";
|
import { secretSnapshotKeys } from "../secretSnapshots/queries";
|
||||||
import { secretKeys } from "./queries";
|
import { secretKeys } from "./queries";
|
||||||
import {
|
import {
|
||||||
@@ -420,3 +425,102 @@ export const useBackfillSecretReference = () =>
|
|||||||
return data.message;
|
return data.message;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const useCreateCommit = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation<
|
||||||
|
object,
|
||||||
|
object,
|
||||||
|
{
|
||||||
|
workspaceId: string;
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
pendingChanges: PendingChanges;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
mutationFn: async ({ workspaceId, environment, secretPath, pendingChanges, message }) => {
|
||||||
|
const { data } = await apiRequest.post("/api/v1/pit/batch/commit", {
|
||||||
|
projectId: workspaceId,
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
changes: {
|
||||||
|
secrets: {
|
||||||
|
create:
|
||||||
|
pendingChanges.secrets
|
||||||
|
.filter((change) => change.type === PendingAction.Create)
|
||||||
|
.map((change) => ({
|
||||||
|
secretKey: change.secretKey,
|
||||||
|
secretValue: change.secretValue,
|
||||||
|
secretComment: change.secretComment,
|
||||||
|
skipMultilineEncoding: change.skipMultilineEncoding,
|
||||||
|
tagIds: change.tags?.map((tag) => tag.id),
|
||||||
|
secretMetadata: change.secretMetadata
|
||||||
|
})) || [],
|
||||||
|
update:
|
||||||
|
pendingChanges.secrets
|
||||||
|
.filter((change) => change.type === PendingAction.Update)
|
||||||
|
.map((change: PendingSecretUpdate) => ({
|
||||||
|
secretKey: change.secretKey,
|
||||||
|
newSecretName: change.newSecretName,
|
||||||
|
secretValue:
|
||||||
|
change.secretValue === ""
|
||||||
|
? ""
|
||||||
|
: change.secretValue || change.existingSecret.value,
|
||||||
|
secretComment:
|
||||||
|
change.secretComment === ""
|
||||||
|
? ""
|
||||||
|
: change.secretComment || change.existingSecret.comment,
|
||||||
|
skipMultilineEncoding:
|
||||||
|
change.skipMultilineEncoding !== undefined
|
||||||
|
? change.skipMultilineEncoding
|
||||||
|
: change.existingSecret.skipMultilineEncoding,
|
||||||
|
tagIds:
|
||||||
|
change.tags?.map((tag) => tag.id) ||
|
||||||
|
change.existingSecret.tags?.map((tag) => tag.id),
|
||||||
|
secretMetadata: change.secretMetadata || change.existingSecret.secretMetadata
|
||||||
|
})) || [],
|
||||||
|
delete:
|
||||||
|
pendingChanges.secrets.filter((change) => change.type === PendingAction.Delete) || []
|
||||||
|
},
|
||||||
|
folders: {
|
||||||
|
create:
|
||||||
|
pendingChanges.folders.filter((change) => change.type === PendingAction.Create) || [],
|
||||||
|
update:
|
||||||
|
pendingChanges.folders
|
||||||
|
.filter((change) => change.type === PendingAction.Update)
|
||||||
|
.map((change) => ({
|
||||||
|
...change,
|
||||||
|
description: change.description || null
|
||||||
|
})) || [],
|
||||||
|
delete:
|
||||||
|
pendingChanges.folders.filter((change) => change.type === PendingAction.Delete) || []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
onSuccess: (_, { workspaceId, environment, secretPath }) => {
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: dashboardKeys.getDashboardSecrets({ projectId: workspaceId, secretPath })
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: secretSnapshotKeys.list({ environment, workspaceId, directory: secretPath })
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath })
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: commitKeys.count({ workspaceId, environment, directory: secretPath })
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: commitKeys.history({ workspaceId, environment, directory: secretPath })
|
||||||
|
});
|
||||||
|
queryClient.invalidateQueries({ queryKey: secretApprovalRequestKeys.count({ workspaceId }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ProjectPermissionActions } from "@app/context";
|
import { ProjectPermissionActions } from "@app/context";
|
||||||
|
|
||||||
|
import { PendingAction } from "../secretFolders/types";
|
||||||
import type { WsTag } from "../tags/types";
|
import type { WsTag } from "../tags/types";
|
||||||
|
|
||||||
export enum SecretType {
|
export enum SecretType {
|
||||||
@@ -66,6 +67,8 @@ export type SecretV3RawSanitized = {
|
|||||||
isRotatedSecret?: boolean;
|
isRotatedSecret?: boolean;
|
||||||
secretReminderRecipients?: SecretReminderRecipient[];
|
secretReminderRecipients?: SecretReminderRecipient[];
|
||||||
rotationId?: string;
|
rotationId?: string;
|
||||||
|
isPending?: boolean;
|
||||||
|
pendingAction?: PendingAction;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SecretV3Raw = {
|
export type SecretV3Raw = {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useBlocker } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BatchContext,
|
||||||
|
useBatchModeActions
|
||||||
|
} from "@app/pages/secret-manager/SecretDashboardPage/SecretMainPage.store";
|
||||||
|
|
||||||
|
type TNavigationBlockerReturn = {
|
||||||
|
isBlocked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useNavigationBlocker = ({
|
||||||
|
shouldBlock = false,
|
||||||
|
message = "Are you sure you want to leave? You may have unsaved changes.",
|
||||||
|
context
|
||||||
|
}: {
|
||||||
|
shouldBlock: boolean;
|
||||||
|
message: string;
|
||||||
|
context: BatchContext;
|
||||||
|
}): TNavigationBlockerReturn => {
|
||||||
|
const { clearAllPendingChanges } = useBatchModeActions();
|
||||||
|
const blockerFn = useCallback(() => {
|
||||||
|
if (!shouldBlock) return false;
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
const confirmed = window.confirm(message);
|
||||||
|
if (confirmed) {
|
||||||
|
clearAllPendingChanges(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return !confirmed;
|
||||||
|
}, [shouldBlock, message, context]);
|
||||||
|
|
||||||
|
useBlocker(blockerFn, shouldBlock);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isBlocked: shouldBlock
|
||||||
|
};
|
||||||
|
};
|
||||||
+23
-4
@@ -1,9 +1,11 @@
|
|||||||
/* eslint-disable no-nested-ternary */
|
/* eslint-disable no-nested-ternary */
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { faChevronDown, faChevronUp } from "@fortawesome/free-solid-svg-icons";
|
import { faChevronDown, faChevronUp, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
import { IconButton, Tooltip } from "@app/components/v2";
|
||||||
|
|
||||||
export interface Version {
|
export interface Version {
|
||||||
id?: string;
|
id?: string;
|
||||||
version: number;
|
version: number;
|
||||||
@@ -33,6 +35,7 @@ interface SecretVersionDiffViewProps {
|
|||||||
showHeader?: boolean;
|
showHeader?: boolean;
|
||||||
customHeader?: JSX.Element;
|
customHeader?: JSX.Element;
|
||||||
excludedFieldsHighlight?: string[];
|
excludedFieldsHighlight?: string[];
|
||||||
|
onDiscard?: VoidFunction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isObject = (obj: JsonValue): obj is JsonObject => {
|
const isObject = (obj: JsonValue): obj is JsonObject => {
|
||||||
@@ -409,7 +412,6 @@ const renderJsonWithDiffs = (
|
|||||||
<div>
|
<div>
|
||||||
{indent}
|
{indent}
|
||||||
<span>{"}"}</span>
|
<span>{"}"}</span>
|
||||||
{comma}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -465,7 +467,9 @@ const formatDeletedJson = (json: JsonValue): JSX.Element => {
|
|||||||
|
|
||||||
const cleanVersionForComparison = (version: Version): JsonValue => {
|
const cleanVersionForComparison = (version: Version): JsonValue => {
|
||||||
const { id, version: versionNumber, ...cleanVersion } = version;
|
const { id, version: versionNumber, ...cleanVersion } = version;
|
||||||
return cleanVersion;
|
return Object.fromEntries(
|
||||||
|
Object.entries(cleanVersion).filter((entry) => typeof entry[1] !== "undefined")
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SecretVersionDiffView = ({
|
export const SecretVersionDiffView = ({
|
||||||
@@ -474,7 +478,8 @@ export const SecretVersionDiffView = ({
|
|||||||
onToggleCollapse,
|
onToggleCollapse,
|
||||||
showHeader = true,
|
showHeader = true,
|
||||||
customHeader,
|
customHeader,
|
||||||
excludedFieldsHighlight = ["metadata", "tags"]
|
excludedFieldsHighlight = ["metadata", "tags"],
|
||||||
|
onDiscard
|
||||||
}: SecretVersionDiffViewProps) => {
|
}: SecretVersionDiffViewProps) => {
|
||||||
const oldContainerRef = useRef<HTMLDivElement>(null);
|
const oldContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const newContainerRef = useRef<HTMLDivElement>(null);
|
const newContainerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -609,6 +614,20 @@ export const SecretVersionDiffView = ({
|
|||||||
<p className={twMerge(textStyle, "truncate")}>{key}</p>
|
<p className={twMerge(textStyle, "truncate")}>{key}</p>
|
||||||
{changeBadge}
|
{changeBadge}
|
||||||
</div>
|
</div>
|
||||||
|
{onDiscard && (
|
||||||
|
<Tooltip side="left" content="Discard change">
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="discard-change"
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="danger"
|
||||||
|
size="sm"
|
||||||
|
className="ml-2"
|
||||||
|
onClick={onDiscard}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<FontAwesomeIcon
|
<FontAwesomeIcon
|
||||||
icon={collapsed ? faChevronDown : faChevronUp}
|
icon={collapsed ? faChevronDown : faChevronUp}
|
||||||
className="ml-2 text-gray-400"
|
className="ml-2 text-gray-400"
|
||||||
|
|||||||
+2
-2
@@ -156,11 +156,11 @@ export const SecretApprovalRequestChangeItem = ({
|
|||||||
</div>
|
</div>
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<div className="text-sm font-medium text-mineshaft-300">Tags</div>
|
<div className="text-sm font-medium text-mineshaft-300">Tags</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-y-2">
|
||||||
{(secretVersion?.tags?.length ?? 0) ? (
|
{(secretVersion?.tags?.length ?? 0) ? (
|
||||||
secretVersion?.tags?.map(({ slug, id: tagId, color }) => (
|
secretVersion?.tags?.map(({ slug, id: tagId, color }) => (
|
||||||
<Tag
|
<Tag
|
||||||
className="flex w-min items-center space-x-2"
|
className="flex w-min items-center space-x-1.5 border border-mineshaft-500 bg-mineshaft-800"
|
||||||
key={`${secretVersion.id}-${tagId}`}
|
key={`${secretVersion.id}-${tagId}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@ export const generateCommitText = (commits: { op: CommitType }[] = [], isReplica
|
|||||||
if (score[CommitType.DELETE])
|
if (score[CommitType.DELETE])
|
||||||
text.push(
|
text.push(
|
||||||
<span className="deleted-commit">
|
<span className="deleted-commit">
|
||||||
{Boolean(text.length) && "and"}
|
{Boolean(text.length) && " and "}
|
||||||
{score[CommitType.DELETE]} Secret{score[CommitType.DELETE] !== 1 && "s"}
|
{score[CommitType.DELETE]} Secret{score[CommitType.DELETE] !== 1 && "s"}
|
||||||
<span className="text-red-600"> Deleted</span>
|
<span className="text-red-600"> Deleted</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable no-case-declarations */
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -48,6 +49,9 @@ import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard";
|
|||||||
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
|
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
|
||||||
import { useGetFolderCommitsCount } from "@app/hooks/api/folderCommits";
|
import { useGetFolderCommitsCount } from "@app/hooks/api/folderCommits";
|
||||||
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
import { OrderByDirection } from "@app/hooks/api/generic/types";
|
||||||
|
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||||
|
import { useCreateCommit } from "@app/hooks/api/secrets/mutations";
|
||||||
|
import { SecretV3RawSanitized } from "@app/hooks/api/types";
|
||||||
import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies";
|
import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies";
|
||||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||||
import { RequestAccessModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal";
|
import { RequestAccessModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal";
|
||||||
@@ -56,6 +60,7 @@ import { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboar
|
|||||||
import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount";
|
import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount";
|
||||||
import { SecretV2MigrationSection } from "../OverviewPage/components/SecretV2MigrationSection";
|
import { SecretV2MigrationSection } from "../OverviewPage/components/SecretV2MigrationSection";
|
||||||
import { ActionBar } from "./components/ActionBar";
|
import { ActionBar } from "./components/ActionBar";
|
||||||
|
import { CommitForm } from "./components/CommitForm";
|
||||||
import { CreateSecretForm } from "./components/CreateSecretForm";
|
import { CreateSecretForm } from "./components/CreateSecretForm";
|
||||||
import { DynamicSecretListView } from "./components/DynamicSecretListView";
|
import { DynamicSecretListView } from "./components/DynamicSecretListView";
|
||||||
import { FolderListView } from "./components/FolderListView";
|
import { FolderListView } from "./components/FolderListView";
|
||||||
@@ -65,8 +70,11 @@ import { SecretImportListView } from "./components/SecretImportListView";
|
|||||||
import { SecretListView, SecretNoAccessListView } from "./components/SecretListView";
|
import { SecretListView, SecretNoAccessListView } from "./components/SecretListView";
|
||||||
import { SnapshotView } from "./components/SnapshotView";
|
import { SnapshotView } from "./components/SnapshotView";
|
||||||
import {
|
import {
|
||||||
|
PendingChanges,
|
||||||
PopUpNames,
|
PopUpNames,
|
||||||
StoreProvider,
|
StoreProvider,
|
||||||
|
useBatchMode,
|
||||||
|
useBatchModeActions,
|
||||||
usePopUpAction,
|
usePopUpAction,
|
||||||
usePopUpState,
|
usePopUpState,
|
||||||
useSelectedSecretActions,
|
useSelectedSecretActions,
|
||||||
@@ -94,8 +102,11 @@ const Page = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { permission } = useProjectPermission();
|
const { permission } = useProjectPermission();
|
||||||
|
const { mutateAsync: createCommit } = useCreateCommit();
|
||||||
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const { isBatchMode, pendingChanges } = useBatchMode();
|
||||||
|
const { loadPendingChanges, setExistingKeys } = useBatchModeActions();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
offset,
|
offset,
|
||||||
@@ -128,6 +139,12 @@ const Page = () => {
|
|||||||
const projectSlug = currentWorkspace?.slug || "";
|
const projectSlug = currentWorkspace?.slug || "";
|
||||||
const secretPath = (routerQueryParams.secretPath as string) || "/";
|
const secretPath = (routerQueryParams.secretPath as string) || "/";
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isBatchMode && workspaceId && environment && secretPath) {
|
||||||
|
loadPendingChanges({ workspaceId, environment, secretPath });
|
||||||
|
}
|
||||||
|
}, [isBatchMode, workspaceId, environment, secretPath, loadPendingChanges]);
|
||||||
|
|
||||||
const canReadSecret = hasSecretReadValueOrDescribePermission(
|
const canReadSecret = hasSecretReadValueOrDescribePermission(
|
||||||
permission,
|
permission,
|
||||||
ProjectPermissionSecretActions.DescribeSecret,
|
ProjectPermissionSecretActions.DescribeSecret,
|
||||||
@@ -293,6 +310,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,
|
||||||
@@ -376,9 +417,21 @@ const Page = () => {
|
|||||||
imports?.length ||
|
imports?.length ||
|
||||||
dynamicSecrets?.length ||
|
dynamicSecrets?.length ||
|
||||||
secretRotations?.length ||
|
secretRotations?.length ||
|
||||||
noAccessSecretCount
|
noAccessSecretCount ||
|
||||||
|
pendingChanges.secrets.length ||
|
||||||
|
pendingChanges.folders.length
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data && isBatchMode) {
|
||||||
|
const existingSecretKeys = [...(secrets?.map((s) => s.key) || [])];
|
||||||
|
|
||||||
|
const existingFolderNames = folders?.map((f) => f.name) || [];
|
||||||
|
|
||||||
|
setExistingKeys(existingSecretKeys, existingFolderNames);
|
||||||
|
}
|
||||||
|
}, [data, isBatchMode, setExistingKeys, secrets, importedSecrets, folders]);
|
||||||
|
|
||||||
const handleSortToggle = () =>
|
const handleSortToggle = () =>
|
||||||
setOrderDirection((state) =>
|
setOrderDirection((state) =>
|
||||||
state === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
|
state === OrderByDirection.ASC ? OrderByDirection.DESC : OrderByDirection.ASC
|
||||||
@@ -506,6 +559,143 @@ const Page = () => {
|
|||||||
setDebouncedSearchFilter("");
|
setDebouncedSearchFilter("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getMergedSecretsWithPending = () => {
|
||||||
|
if (!isBatchMode || pendingChanges.secrets.length === 0) {
|
||||||
|
return secrets;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedSecrets = [...(secrets || [])];
|
||||||
|
|
||||||
|
pendingChanges.secrets.forEach((change) => {
|
||||||
|
switch (change.type) {
|
||||||
|
case PendingAction.Create:
|
||||||
|
mergedSecrets.unshift({
|
||||||
|
id: change.id,
|
||||||
|
key: change.secretKey,
|
||||||
|
value: change.secretValue,
|
||||||
|
comment: change.secretComment || "",
|
||||||
|
skipMultilineEncoding: change.skipMultilineEncoding || false,
|
||||||
|
tags: change.tags || [],
|
||||||
|
secretMetadata: change.secretMetadata || [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
version: 1,
|
||||||
|
isPending: true,
|
||||||
|
pendingAction: PendingAction.Create
|
||||||
|
} as unknown as SecretV3RawSanitized);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PendingAction.Update:
|
||||||
|
const updateIndex = mergedSecrets.findIndex((s) => s.key === change.secretKey);
|
||||||
|
if (updateIndex >= 0) {
|
||||||
|
mergedSecrets[updateIndex] = {
|
||||||
|
...mergedSecrets[updateIndex],
|
||||||
|
key: change.newSecretName || change.secretKey,
|
||||||
|
value:
|
||||||
|
change.secretValue !== undefined
|
||||||
|
? change.secretValue
|
||||||
|
: mergedSecrets[updateIndex].value,
|
||||||
|
comment:
|
||||||
|
change.secretComment !== undefined
|
||||||
|
? change.secretComment
|
||||||
|
: mergedSecrets[updateIndex].comment,
|
||||||
|
skipMultilineEncoding:
|
||||||
|
change.skipMultilineEncoding !== undefined
|
||||||
|
? change.skipMultilineEncoding
|
||||||
|
: mergedSecrets[updateIndex].skipMultilineEncoding,
|
||||||
|
secretMetadata: change.secretMetadata || mergedSecrets[updateIndex].secretMetadata,
|
||||||
|
isPending: true,
|
||||||
|
pendingAction: PendingAction.Update,
|
||||||
|
tags: change.tags
|
||||||
|
? change.tags?.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
slug: tag.slug,
|
||||||
|
projectId: workspaceId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
__v: 0
|
||||||
|
})) || []
|
||||||
|
: mergedSecrets[updateIndex].tags
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PendingAction.Delete:
|
||||||
|
const deleteIndex = mergedSecrets.findIndex((s) => s.key === change.secretKey);
|
||||||
|
if (deleteIndex >= 0) {
|
||||||
|
mergedSecrets[deleteIndex] = {
|
||||||
|
...mergedSecrets[deleteIndex],
|
||||||
|
isPending: true,
|
||||||
|
pendingAction: PendingAction.Delete
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return mergedSecrets;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMergedFoldersWithPending = () => {
|
||||||
|
if (!isBatchMode || pendingChanges.folders.length === 0) {
|
||||||
|
return folders;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergedFolders = [...(folders || [])];
|
||||||
|
|
||||||
|
pendingChanges.folders.forEach((change) => {
|
||||||
|
switch (change.type) {
|
||||||
|
case PendingAction.Create:
|
||||||
|
mergedFolders.unshift({
|
||||||
|
id: change.id,
|
||||||
|
name: change.folderName,
|
||||||
|
description: change.description,
|
||||||
|
parentId: null,
|
||||||
|
isPending: true,
|
||||||
|
pendingAction: PendingAction.Create
|
||||||
|
} as any);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PendingAction.Update:
|
||||||
|
const updateIndex = mergedFolders.findIndex((f) => f.id === change.id);
|
||||||
|
if (updateIndex >= 0) {
|
||||||
|
mergedFolders[updateIndex] = {
|
||||||
|
...mergedFolders[updateIndex],
|
||||||
|
name: change.folderName,
|
||||||
|
description:
|
||||||
|
change.description !== undefined
|
||||||
|
? change.description
|
||||||
|
: mergedFolders[updateIndex].description,
|
||||||
|
isPending: true,
|
||||||
|
pendingAction: PendingAction.Update
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case PendingAction.Delete:
|
||||||
|
const deleteIndex = mergedFolders.findIndex((f) => f.id === change.id);
|
||||||
|
if (deleteIndex >= 0) {
|
||||||
|
mergedFolders[deleteIndex] = {
|
||||||
|
...mergedFolders[deleteIndex],
|
||||||
|
isPending: true,
|
||||||
|
pendingAction: PendingAction.Delete
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return mergedFolders;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergedSecrets = getMergedSecretsWithPending();
|
||||||
|
const mergedFolders = getMergedFoldersWithPending();
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto flex max-w-7xl flex-col text-mineshaft-50 dark:[color-scheme:dark]">
|
<div className="container mx-auto flex max-w-7xl flex-col text-mineshaft-50 dark:[color-scheme:dark]">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -563,6 +753,7 @@ const Page = () => {
|
|||||||
projectSlug={projectSlug}
|
projectSlug={projectSlug}
|
||||||
secretPath={secretPath}
|
secretPath={secretPath}
|
||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
|
isBatchMode={isBatchMode}
|
||||||
filter={filter}
|
filter={filter}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
onVisibilityToggle={handleToggleVisibility}
|
onVisibilityToggle={handleToggleVisibility}
|
||||||
@@ -698,9 +889,9 @@ const Page = () => {
|
|||||||
importedSecrets={importedSecrets}
|
importedSecrets={importedSecrets}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{Boolean(folders?.length) && (
|
{Boolean(mergedFolders?.length) && (
|
||||||
<FolderListView
|
<FolderListView
|
||||||
folders={folders}
|
folders={mergedFolders}
|
||||||
environment={environment}
|
environment={environment}
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
secretPath={secretPath}
|
secretPath={secretPath}
|
||||||
@@ -718,9 +909,9 @@ const Page = () => {
|
|||||||
{canReadSecretRotations && Boolean(secretRotations?.length) && (
|
{canReadSecretRotations && Boolean(secretRotations?.length) && (
|
||||||
<SecretRotationListView secretRotations={secretRotations} />
|
<SecretRotationListView secretRotations={secretRotations} />
|
||||||
)}
|
)}
|
||||||
{canReadSecret && Boolean(secrets?.length) && (
|
{canReadSecret && Boolean(mergedSecrets?.length) && (
|
||||||
<SecretListView
|
<SecretListView
|
||||||
secrets={secrets}
|
secrets={mergedSecrets}
|
||||||
tags={tags}
|
tags={tags}
|
||||||
isVisible={isVisible}
|
isVisible={isVisible}
|
||||||
environment={environment}
|
environment={environment}
|
||||||
@@ -731,6 +922,15 @@ const Page = () => {
|
|||||||
usedBySecretSyncs={usedBySecretSyncs}
|
usedBySecretSyncs={usedBySecretSyncs}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{(pendingChanges.secrets.length > 0 || pendingChanges.folders.length > 0) && (
|
||||||
|
<CommitForm
|
||||||
|
onCommit={handleCreateCommit}
|
||||||
|
environment={environment}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
secretPath={secretPath}
|
||||||
|
isCommitting={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />}
|
{noAccessSecretCount > 0 && <SecretNoAccessListView count={noAccessSecretCount} />}
|
||||||
{!canReadSecret &&
|
{!canReadSecret &&
|
||||||
!canReadDynamicSecret &&
|
!canReadDynamicSecret &&
|
||||||
@@ -738,25 +938,34 @@ const Page = () => {
|
|||||||
folders?.length === 0 && <PermissionDeniedBanner />}
|
folders?.length === 0 && <PermissionDeniedBanner />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!isDetailsLoading && totalCount > 0 && (
|
{!isDetailsLoading &&
|
||||||
<Pagination
|
(totalCount > 0 ||
|
||||||
startAdornment={
|
pendingChanges.secrets.length > 0 ||
|
||||||
<SecretTableResourceCount
|
pendingChanges.folders.length > 0) && (
|
||||||
dynamicSecretCount={totalDynamicSecretCount}
|
<Pagination
|
||||||
importCount={totalImportCount}
|
startAdornment={
|
||||||
secretCount={totalSecretCount}
|
<SecretTableResourceCount
|
||||||
folderCount={totalFolderCount}
|
dynamicSecretCount={totalDynamicSecretCount}
|
||||||
secretRotationCount={totalSecretRotationCount}
|
importCount={totalImportCount}
|
||||||
/>
|
secretCount={
|
||||||
}
|
totalSecretCount +
|
||||||
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
|
pendingChanges.secrets.filter((s) => s.type === PendingAction.Create).length
|
||||||
count={totalCount}
|
}
|
||||||
page={page}
|
folderCount={
|
||||||
perPage={perPage}
|
totalFolderCount +
|
||||||
onChangePage={(newPage) => setPage(newPage)}
|
pendingChanges.folders.filter((f) => f.type === PendingAction.Create).length
|
||||||
onChangePerPage={handlePerPageChange}
|
}
|
||||||
/>
|
secretRotationCount={totalSecretRotationCount}
|
||||||
)}
|
/>
|
||||||
|
}
|
||||||
|
className="rounded-b-md border-t border-solid border-t-mineshaft-600"
|
||||||
|
count={totalCount + pendingChanges.secrets.length + pendingChanges.folders.length}
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
onChangePage={(newPage) => setPage(newPage)}
|
||||||
|
onChangePerPage={handlePerPageChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={createSecretPopUp.isOpen}
|
isOpen={createSecretPopUp.isOpen}
|
||||||
onOpenChange={(state) => togglePopUp(PopUpNames.CreateSecretForm, state)}
|
onOpenChange={(state) => togglePopUp(PopUpNames.CreateSecretForm, state)}
|
||||||
@@ -772,6 +981,7 @@ const Page = () => {
|
|||||||
secretPath={secretPath}
|
secretPath={secretPath}
|
||||||
autoCapitalize={currentWorkspace?.autoCapitalization}
|
autoCapitalize={currentWorkspace?.autoCapitalization}
|
||||||
isProtectedBranch={isProtectedBranch}
|
isProtectedBranch={isProtectedBranch}
|
||||||
|
isBatchMode={isBatchMode}
|
||||||
/>
|
/>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -1,13 +1,218 @@
|
|||||||
|
/* eslint-disable no-nested-ternary */
|
||||||
import { createContext, ReactNode, useContext, useEffect, useRef } from "react";
|
import { createContext, ReactNode, useContext, useEffect, useRef } from "react";
|
||||||
import { useRouter } from "@tanstack/react-router";
|
import { useRouter } from "@tanstack/react-router";
|
||||||
import { createStore, StateCreator, StoreApi, useStore } from "zustand";
|
import { createStore, StateCreator, StoreApi, useStore } from "zustand";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
|
|
||||||
|
import { createNotification } from "@app/components/notifications";
|
||||||
|
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||||
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
|
import { SecretV3RawSanitized } from "@app/hooks/api/secrets/types";
|
||||||
|
|
||||||
// akhilmhdh: Don't remove this file if ur thinking why use zustand just for selected selects state
|
// akhilmhdh: Don't remove this file if ur thinking why use zustand just for selected selects state
|
||||||
// This is first step and the whole secret crud will be moved to this global page scope state
|
// This is first step and the whole secret crud will be moved to this global page scope state
|
||||||
// this will allow more stuff like undo grouping stuffs etc
|
// this will allow more stuff like undo grouping stuffs etc
|
||||||
|
|
||||||
|
// Base interface for all pending changes
|
||||||
|
export interface BasePendingChange {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Secret-related change types
|
||||||
|
export interface PendingSecretCreate extends BasePendingChange {
|
||||||
|
resourceType: "secret";
|
||||||
|
type: PendingAction.Create;
|
||||||
|
secretKey: string;
|
||||||
|
secretValue: string;
|
||||||
|
secretComment?: string;
|
||||||
|
skipMultilineEncoding?: boolean;
|
||||||
|
tags?: { id: string; slug: string }[];
|
||||||
|
secretMetadata?: { key: string; value: string }[];
|
||||||
|
originalKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingSecretUpdate extends BasePendingChange {
|
||||||
|
resourceType: "secret";
|
||||||
|
type: PendingAction.Update;
|
||||||
|
secretKey: string;
|
||||||
|
newSecretName?: string;
|
||||||
|
originalValue?: string;
|
||||||
|
secretValue?: string;
|
||||||
|
originalComment?: string;
|
||||||
|
secretComment?: string;
|
||||||
|
originalSkipMultilineEncoding?: boolean;
|
||||||
|
skipMultilineEncoding?: boolean;
|
||||||
|
originalTags?: { id: string; slug: string }[];
|
||||||
|
tags?: { id: string; slug: string }[];
|
||||||
|
originalSecretMetadata?: { key: string; value: string }[];
|
||||||
|
secretMetadata?: { key: string; value: string }[];
|
||||||
|
existingSecret: SecretV3RawSanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingSecretDelete extends BasePendingChange {
|
||||||
|
resourceType: "secret";
|
||||||
|
type: PendingAction.Delete;
|
||||||
|
secretKey: string;
|
||||||
|
secretValue: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Folder-related change types
|
||||||
|
export interface PendingFolderCreate extends BasePendingChange {
|
||||||
|
resourceType: "folder";
|
||||||
|
type: PendingAction.Create;
|
||||||
|
id: string;
|
||||||
|
folderName: string;
|
||||||
|
description?: string;
|
||||||
|
parentPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingFolderUpdate extends BasePendingChange {
|
||||||
|
resourceType: "folder";
|
||||||
|
type: PendingAction.Update;
|
||||||
|
originalFolderName: string;
|
||||||
|
folderName: string;
|
||||||
|
id: string;
|
||||||
|
originalDescription?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingFolderDelete extends BasePendingChange {
|
||||||
|
resourceType: "folder";
|
||||||
|
type: PendingAction.Delete;
|
||||||
|
id: string;
|
||||||
|
folderName: string;
|
||||||
|
folderPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union types for each resource
|
||||||
|
export type PendingSecretChange = PendingSecretCreate | PendingSecretUpdate | PendingSecretDelete;
|
||||||
|
export type PendingFolderChange = PendingFolderCreate | PendingFolderUpdate | PendingFolderDelete;
|
||||||
|
export type PendingChange = PendingSecretChange | PendingFolderChange;
|
||||||
|
|
||||||
|
// Grouped changes for better processing
|
||||||
|
export interface PendingChanges {
|
||||||
|
secrets: PendingSecretChange[];
|
||||||
|
folders: PendingFolderChange[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context interface for batch operations
|
||||||
|
export interface BatchContext {
|
||||||
|
workspaceId: string;
|
||||||
|
environment: string;
|
||||||
|
secretPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeValue = (value: any): string | boolean | undefined => {
|
||||||
|
if (value === null || value === undefined || value === "") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const areValuesEqual = (value1: any, value2: any): boolean => {
|
||||||
|
const normalized1 = normalizeValue(value1);
|
||||||
|
const normalized2 = normalizeValue(value2);
|
||||||
|
|
||||||
|
if (normalized1 === undefined && normalized2 === undefined) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized1 === normalized2;
|
||||||
|
};
|
||||||
|
|
||||||
|
const areArraysEqual = (arr1: any[] | undefined, arr2: any[] | undefined): boolean => {
|
||||||
|
// Handle undefined/null arrays
|
||||||
|
if (!arr1 && !arr2) return true;
|
||||||
|
if (!arr1 || !arr2) return false;
|
||||||
|
|
||||||
|
// Compare lengths
|
||||||
|
if (arr1.length !== arr2.length) return false;
|
||||||
|
|
||||||
|
// Deep comparison using JSON stringify (for simple objects)
|
||||||
|
return JSON.stringify(arr1.sort()) === JSON.stringify(arr2.sort());
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupRevertedSecretFields = (update: PendingSecretUpdate): PendingSecretUpdate | null => {
|
||||||
|
const cleaned = { ...update };
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
cleaned.secretValue !== undefined &&
|
||||||
|
!areValuesEqual(cleaned.secretValue, cleaned.originalValue)
|
||||||
|
) {
|
||||||
|
hasChanges = true;
|
||||||
|
} else {
|
||||||
|
cleaned.secretValue = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cleaned.secretComment !== undefined &&
|
||||||
|
(!areValuesEqual(cleaned.secretComment, cleaned.originalComment) ||
|
||||||
|
!areValuesEqual(cleaned.secretComment, cleaned.existingSecret.comment))
|
||||||
|
) {
|
||||||
|
hasChanges = true;
|
||||||
|
} else {
|
||||||
|
cleaned.secretComment = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cleaned.skipMultilineEncoding !== undefined &&
|
||||||
|
cleaned.skipMultilineEncoding !== cleaned.originalSkipMultilineEncoding
|
||||||
|
) {
|
||||||
|
hasChanges = true;
|
||||||
|
} else {
|
||||||
|
cleaned.skipMultilineEncoding = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleaned.tags !== undefined && !areArraysEqual(cleaned.tags, cleaned.originalTags)) {
|
||||||
|
hasChanges = true;
|
||||||
|
} else {
|
||||||
|
cleaned.tags = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cleaned.secretMetadata !== undefined &&
|
||||||
|
!areArraysEqual(cleaned.secretMetadata, cleaned.originalSecretMetadata)
|
||||||
|
) {
|
||||||
|
hasChanges = true;
|
||||||
|
} else {
|
||||||
|
cleaned.secretMetadata = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cleaned.newSecretName !== undefined && cleaned.newSecretName !== cleaned.secretKey) {
|
||||||
|
hasChanges = true;
|
||||||
|
} else {
|
||||||
|
cleaned.newSecretName = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no changes remain, return null to indicate this update should be removed
|
||||||
|
return hasChanges ? cleaned : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupRevertedFolderFields = (update: PendingFolderUpdate): PendingFolderUpdate | null => {
|
||||||
|
const cleaned = { ...update };
|
||||||
|
let hasChanges = false;
|
||||||
|
|
||||||
|
if (
|
||||||
|
cleaned.folderName !== undefined &&
|
||||||
|
!areValuesEqual(cleaned.folderName, cleaned.originalFolderName)
|
||||||
|
) {
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
cleaned.description !== undefined &&
|
||||||
|
!areValuesEqual(cleaned.description, cleaned.originalDescription)
|
||||||
|
) {
|
||||||
|
hasChanges = true;
|
||||||
|
} else {
|
||||||
|
cleaned.description = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no changes remain, return null to indicate this update should be removed
|
||||||
|
return hasChanges ? cleaned : null;
|
||||||
|
};
|
||||||
|
|
||||||
type SelectedSecretState = {
|
type SelectedSecretState = {
|
||||||
selectedSecret: Record<string, SecretV3RawSanitized>;
|
selectedSecret: Record<string, SecretV3RawSanitized>;
|
||||||
action: {
|
action: {
|
||||||
@@ -16,14 +221,16 @@ type SelectedSecretState = {
|
|||||||
set: (secrets: Record<string, SecretV3RawSanitized>) => void;
|
set: (secrets: Record<string, SecretV3RawSanitized>) => void;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
|
|
||||||
|
const createSelectedSecretStore: StateCreator<CombinedState, [], [], SelectedSecretState> = (
|
||||||
|
set
|
||||||
|
) => ({
|
||||||
selectedSecret: {},
|
selectedSecret: {},
|
||||||
action: {
|
action: {
|
||||||
toggle: (secret) =>
|
toggle: (secret) =>
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const isChecked = Boolean(state.selectedSecret?.[secret.id]);
|
const isChecked = Boolean(state.selectedSecret?.[secret.id]);
|
||||||
const newChecks = { ...state.selectedSecret };
|
const newChecks = { ...state.selectedSecret };
|
||||||
// remove selection if its present else add it
|
|
||||||
if (isChecked) delete newChecks[secret.id];
|
if (isChecked) delete newChecks[secret.id];
|
||||||
else newChecks[secret.id] = secret;
|
else newChecks[secret.id] = secret;
|
||||||
return { selectedSecret: newChecks };
|
return { selectedSecret: newChecks };
|
||||||
@@ -33,6 +240,432 @@ const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type BatchModeState = {
|
||||||
|
isBatchMode: boolean;
|
||||||
|
pendingChanges: PendingChanges;
|
||||||
|
pendingChangesByContext: Map<string, PendingChanges>;
|
||||||
|
existingSecretKeys: Set<string>;
|
||||||
|
existingFolderNames: Set<string>;
|
||||||
|
currentContext: BatchContext | null;
|
||||||
|
batchActions: {
|
||||||
|
addPendingChange: (change: PendingChange, context: BatchContext) => void;
|
||||||
|
loadPendingChanges: (context: BatchContext) => void;
|
||||||
|
clearAllPendingChanges: (context: BatchContext) => void;
|
||||||
|
setExistingKeys: (secretKeys: string[], folderNames: string[]) => void;
|
||||||
|
getTotalPendingChangesCount: () => number;
|
||||||
|
removePendingChange: (changeId: string, resourceType: string, context: BatchContext) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateContextKey = (workspaceId: string, environment: string, secretPath: string) => {
|
||||||
|
return `${workspaceId}_${environment}_${secretPath}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createBatchModeStore: StateCreator<CombinedState, [], [], BatchModeState> = (set, get) => ({
|
||||||
|
isBatchMode: true, // Always enabled by default
|
||||||
|
pendingChanges: { secrets: [], folders: [] },
|
||||||
|
pendingChangesByContext: new Map<string, PendingChanges>(),
|
||||||
|
currentContext: null,
|
||||||
|
existingSecretKeys: new Set<string>(),
|
||||||
|
existingFolderNames: new Set<string>(),
|
||||||
|
batchActions: {
|
||||||
|
addPendingChange: (change: PendingChange, context: BatchContext) =>
|
||||||
|
set((state) => {
|
||||||
|
const contextKey = generateContextKey(
|
||||||
|
context.workspaceId,
|
||||||
|
context.environment,
|
||||||
|
context.secretPath
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get existing changes for this context or create new empty state
|
||||||
|
const existingChanges = state.pendingChangesByContext.get(contextKey) || {
|
||||||
|
secrets: [],
|
||||||
|
folders: []
|
||||||
|
};
|
||||||
|
const newChanges = { ...existingChanges };
|
||||||
|
|
||||||
|
if (change.resourceType === "folder") {
|
||||||
|
const existingFolder =
|
||||||
|
state.existingFolderNames.has(change.folderName) ||
|
||||||
|
newChanges.folders.some((f) => f.folderName === change.folderName);
|
||||||
|
|
||||||
|
if (change.type === PendingAction.Create && existingFolder) {
|
||||||
|
createNotification({
|
||||||
|
text: "A folder with this name already exists",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return { pendingChanges: newChanges };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
change.type === PendingAction.Update &&
|
||||||
|
change.folderName !== change.originalFolderName &&
|
||||||
|
existingFolder
|
||||||
|
) {
|
||||||
|
createNotification({
|
||||||
|
text: "A folder with this name already exists",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return { pendingChanges: newChanges };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.resourceType === "secret") {
|
||||||
|
const existingSecret =
|
||||||
|
state.existingSecretKeys.has(change.secretKey) ||
|
||||||
|
newChanges.secrets.some(
|
||||||
|
(s) =>
|
||||||
|
(s.secretKey === change.secretKey && s.type !== PendingAction.Create) ||
|
||||||
|
(change.type === PendingAction.Create &&
|
||||||
|
change.originalKey !== change.secretKey &&
|
||||||
|
s.secretKey === change.secretKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (change.type === PendingAction.Create && existingSecret) {
|
||||||
|
createNotification({
|
||||||
|
text: "A secret with this name already exists",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return { pendingChanges: newChanges };
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNewSecretName =
|
||||||
|
change.type === PendingAction.Update &&
|
||||||
|
change.newSecretName &&
|
||||||
|
change.newSecretName !== change.secretKey &&
|
||||||
|
(state.existingSecretKeys.has(change.newSecretName) ||
|
||||||
|
newChanges.secrets.some(
|
||||||
|
(s) =>
|
||||||
|
(s.secretKey === change.newSecretName ||
|
||||||
|
(s.type === PendingAction.Update &&
|
||||||
|
s.newSecretName === change.newSecretName)) &&
|
||||||
|
s.id !== change.id
|
||||||
|
));
|
||||||
|
|
||||||
|
if (existingNewSecretName) {
|
||||||
|
createNotification({
|
||||||
|
text: "A secret with this name already exists",
|
||||||
|
type: "error"
|
||||||
|
});
|
||||||
|
return { pendingChanges: newChanges };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.resourceType === "secret") {
|
||||||
|
const secretChanges = [...newChanges.secrets];
|
||||||
|
|
||||||
|
if (change.type === PendingAction.Create) {
|
||||||
|
const existingCreateIndex = secretChanges.findIndex(
|
||||||
|
(c) =>
|
||||||
|
c.type === PendingAction.Create &&
|
||||||
|
(c.secretKey === change.secretKey || c.secretKey === change.originalKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCreateIndex >= 0) {
|
||||||
|
secretChanges[existingCreateIndex] = {
|
||||||
|
...secretChanges[existingCreateIndex],
|
||||||
|
...change,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
secretChanges.push(change);
|
||||||
|
}
|
||||||
|
} else if (change.type === PendingAction.Update) {
|
||||||
|
const existingCreateIndex = secretChanges.findIndex(
|
||||||
|
(c) => c.type === PendingAction.Create && c.id === change.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCreateIndex >= 0) {
|
||||||
|
const existingCreate = secretChanges[existingCreateIndex] as PendingSecretCreate;
|
||||||
|
secretChanges[existingCreateIndex] = {
|
||||||
|
...existingCreate,
|
||||||
|
secretKey: change.newSecretName || change.secretKey || existingCreate.secretKey,
|
||||||
|
secretValue:
|
||||||
|
change.secretValue !== undefined
|
||||||
|
? change.secretValue
|
||||||
|
: existingCreate.secretValue,
|
||||||
|
secretComment:
|
||||||
|
change.secretComment !== undefined
|
||||||
|
? change.secretComment
|
||||||
|
: existingCreate.secretComment,
|
||||||
|
skipMultilineEncoding:
|
||||||
|
change.skipMultilineEncoding !== undefined
|
||||||
|
? change.skipMultilineEncoding
|
||||||
|
: existingCreate.skipMultilineEncoding,
|
||||||
|
tags: change.tags !== undefined ? change.tags : existingCreate.tags,
|
||||||
|
secretMetadata:
|
||||||
|
change.secretMetadata !== undefined
|
||||||
|
? change.secretMetadata
|
||||||
|
: existingCreate.secretMetadata,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const existingUpdateIndex = secretChanges.findIndex(
|
||||||
|
(c) => c.type === PendingAction.Update && c.id === change.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUpdateIndex >= 0) {
|
||||||
|
const existingUpdate = secretChanges[existingUpdateIndex] as PendingSecretUpdate;
|
||||||
|
const mergedUpdate: PendingSecretUpdate = {
|
||||||
|
...existingUpdate,
|
||||||
|
secretKey: existingUpdate.secretKey,
|
||||||
|
originalValue: existingUpdate.originalValue,
|
||||||
|
originalComment: existingUpdate.originalComment,
|
||||||
|
originalSkipMultilineEncoding: existingUpdate.originalSkipMultilineEncoding,
|
||||||
|
originalTags: existingUpdate.originalTags,
|
||||||
|
originalSecretMetadata: existingUpdate.originalSecretMetadata,
|
||||||
|
|
||||||
|
newSecretName:
|
||||||
|
change.newSecretName !== undefined
|
||||||
|
? change.newSecretName
|
||||||
|
: existingUpdate.newSecretName,
|
||||||
|
secretValue:
|
||||||
|
change.secretValue !== undefined
|
||||||
|
? change.secretValue
|
||||||
|
: existingUpdate.secretValue,
|
||||||
|
secretComment:
|
||||||
|
change.secretComment !== undefined
|
||||||
|
? change.secretComment
|
||||||
|
: existingUpdate.secretComment,
|
||||||
|
skipMultilineEncoding:
|
||||||
|
change.skipMultilineEncoding !== undefined
|
||||||
|
? change.skipMultilineEncoding
|
||||||
|
: existingUpdate.skipMultilineEncoding,
|
||||||
|
tags: change.tags !== undefined ? change.tags : existingUpdate.tags,
|
||||||
|
secretMetadata:
|
||||||
|
change.secretMetadata !== undefined
|
||||||
|
? change.secretMetadata
|
||||||
|
: existingUpdate.secretMetadata,
|
||||||
|
existingSecret: existingUpdate.existingSecret,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up reverted fields and check if any changes remain
|
||||||
|
const cleanedUpdate = cleanupRevertedSecretFields(mergedUpdate);
|
||||||
|
|
||||||
|
if (cleanedUpdate) {
|
||||||
|
// Still has changes, keep the update
|
||||||
|
secretChanges[existingUpdateIndex] = cleanedUpdate;
|
||||||
|
} else {
|
||||||
|
// No changes remain, remove the pending update
|
||||||
|
secretChanges.splice(existingUpdateIndex, 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New update - clean it up before adding
|
||||||
|
const cleanedUpdate = cleanupRevertedSecretFields(change);
|
||||||
|
if (cleanedUpdate) {
|
||||||
|
secretChanges.push(cleanedUpdate);
|
||||||
|
}
|
||||||
|
// If cleanedUpdate is null, don't add it (no actual changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
secretChanges.push(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
newChanges.secrets = secretChanges;
|
||||||
|
} else if (change.resourceType === "folder") {
|
||||||
|
const folderChanges = [...newChanges.folders];
|
||||||
|
|
||||||
|
if (change.type === PendingAction.Create) {
|
||||||
|
const existingCreateIndex = folderChanges.findIndex(
|
||||||
|
(c) => c.type === PendingAction.Create && c.folderName === change.folderName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCreateIndex >= 0) {
|
||||||
|
folderChanges[existingCreateIndex] = {
|
||||||
|
...folderChanges[existingCreateIndex],
|
||||||
|
...change,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
folderChanges.push(change);
|
||||||
|
}
|
||||||
|
} else if (change.type === PendingAction.Update) {
|
||||||
|
const existingCreateIndex = folderChanges.findIndex(
|
||||||
|
(c) => c.type === PendingAction.Create && c.folderName === change.originalFolderName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingCreateIndex >= 0) {
|
||||||
|
const existingCreate = folderChanges[existingCreateIndex] as PendingFolderCreate;
|
||||||
|
folderChanges[existingCreateIndex] = {
|
||||||
|
...existingCreate,
|
||||||
|
folderName: change.folderName || existingCreate.folderName,
|
||||||
|
description:
|
||||||
|
change.description !== undefined
|
||||||
|
? change.description
|
||||||
|
: existingCreate.description,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const existingUpdateIndex = folderChanges.findIndex(
|
||||||
|
(c) => c.type === PendingAction.Update && c.id === change.id
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingUpdateIndex >= 0) {
|
||||||
|
const existingUpdate = folderChanges[existingUpdateIndex] as PendingFolderUpdate;
|
||||||
|
|
||||||
|
const mergedUpdate: PendingFolderUpdate = {
|
||||||
|
...existingUpdate,
|
||||||
|
originalFolderName: existingUpdate.originalFolderName,
|
||||||
|
originalDescription: existingUpdate.originalDescription,
|
||||||
|
|
||||||
|
folderName:
|
||||||
|
change.folderName !== undefined ? change.folderName : existingUpdate.folderName,
|
||||||
|
description:
|
||||||
|
change.description !== undefined
|
||||||
|
? change.description
|
||||||
|
: existingUpdate.description,
|
||||||
|
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clean up reverted fields and check if any changes remain
|
||||||
|
const cleanedUpdate = cleanupRevertedFolderFields(mergedUpdate);
|
||||||
|
|
||||||
|
if (cleanedUpdate) {
|
||||||
|
// Still has changes, keep the update
|
||||||
|
folderChanges[existingUpdateIndex] = cleanedUpdate;
|
||||||
|
} else {
|
||||||
|
// No changes remain, remove the pending update
|
||||||
|
folderChanges.splice(existingUpdateIndex, 1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// New update - clean it up before adding
|
||||||
|
const cleanedUpdate = cleanupRevertedFolderFields(change);
|
||||||
|
if (cleanedUpdate) {
|
||||||
|
folderChanges.push(cleanedUpdate);
|
||||||
|
}
|
||||||
|
// If cleanedUpdate is null, don't add it (no actual changes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
folderChanges.push(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
newChanges.folders = folderChanges;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedContextMap = new Map(state.pendingChangesByContext);
|
||||||
|
updatedContextMap.set(contextKey, newChanges);
|
||||||
|
|
||||||
|
const currentChanges =
|
||||||
|
contextKey ===
|
||||||
|
generateContextKey(
|
||||||
|
state.currentContext?.workspaceId || context.workspaceId,
|
||||||
|
state.currentContext?.environment || context.environment,
|
||||||
|
state.currentContext?.secretPath || context.secretPath
|
||||||
|
)
|
||||||
|
? newChanges
|
||||||
|
: state.pendingChanges;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingChangesByContext: updatedContextMap,
|
||||||
|
pendingChanges: currentChanges,
|
||||||
|
currentContext: context
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
removePendingChange: (changeId: string, resourceType: string, context: BatchContext) =>
|
||||||
|
set((state) => {
|
||||||
|
const contextKey = generateContextKey(
|
||||||
|
context.workspaceId,
|
||||||
|
context.environment,
|
||||||
|
context.secretPath
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get existing changes for this context
|
||||||
|
const existingChanges = state.pendingChangesByContext.get(contextKey) || {
|
||||||
|
secrets: [],
|
||||||
|
folders: []
|
||||||
|
};
|
||||||
|
const newChanges = { ...existingChanges };
|
||||||
|
|
||||||
|
if (resourceType === "secret") {
|
||||||
|
newChanges.secrets = newChanges.secrets.filter((c) => c.id !== changeId);
|
||||||
|
} else if (resourceType === "folder") {
|
||||||
|
newChanges.folders = newChanges.folders.filter((c) => c.id !== changeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedContextMap = new Map(state.pendingChangesByContext);
|
||||||
|
updatedContextMap.set(contextKey, newChanges);
|
||||||
|
|
||||||
|
const isCurrentContext =
|
||||||
|
state.currentContext &&
|
||||||
|
contextKey ===
|
||||||
|
generateContextKey(
|
||||||
|
state.currentContext.workspaceId,
|
||||||
|
state.currentContext.environment,
|
||||||
|
state.currentContext.secretPath
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingChangesByContext: updatedContextMap,
|
||||||
|
pendingChanges: isCurrentContext ? newChanges : state.pendingChanges
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
loadPendingChanges: (context) => {
|
||||||
|
const contextKey = generateContextKey(
|
||||||
|
context.workspaceId,
|
||||||
|
context.environment,
|
||||||
|
context.secretPath
|
||||||
|
);
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const contextChanges = state.pendingChangesByContext.get(contextKey) || {
|
||||||
|
secrets: [],
|
||||||
|
folders: []
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingChanges: contextChanges,
|
||||||
|
currentContext: context
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAllPendingChanges: (context) => {
|
||||||
|
const contextKey = generateContextKey(
|
||||||
|
context.workspaceId,
|
||||||
|
context.environment,
|
||||||
|
context.secretPath
|
||||||
|
);
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
// Clear changes for this specific context
|
||||||
|
const updatedContextMap = new Map(state.pendingChangesByContext);
|
||||||
|
updatedContextMap.delete(contextKey);
|
||||||
|
|
||||||
|
// If this is the current context, also clear the active pending changes
|
||||||
|
const isCurrentContext =
|
||||||
|
state.currentContext &&
|
||||||
|
contextKey ===
|
||||||
|
generateContextKey(
|
||||||
|
state.currentContext.workspaceId,
|
||||||
|
state.currentContext.environment,
|
||||||
|
state.currentContext.secretPath
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pendingChangesByContext: updatedContextMap,
|
||||||
|
pendingChanges: isCurrentContext ? { secrets: [], folders: [] } : state.pendingChanges
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setExistingKeys: (secretKeys, folderNames) =>
|
||||||
|
set({
|
||||||
|
existingSecretKeys: new Set(secretKeys),
|
||||||
|
existingFolderNames: new Set(folderNames)
|
||||||
|
}),
|
||||||
|
|
||||||
|
getTotalPendingChangesCount: () => {
|
||||||
|
const state = get();
|
||||||
|
return state.pendingChanges.secrets.length + state.pendingChanges.folders.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export enum PopUpNames {
|
export enum PopUpNames {
|
||||||
CreateSecretForm = "create-secret-form"
|
CreateSecretForm = "create-secret-form"
|
||||||
}
|
}
|
||||||
@@ -58,13 +691,14 @@ const createPopUpStore: StateCreator<PopUpState> = (set) => ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
type CombinedState = SelectedSecretState & PopUpState;
|
type CombinedState = SelectedSecretState & PopUpState & BatchModeState;
|
||||||
const StoreContext = createContext<StoreApi<CombinedState> | null>(null);
|
const StoreContext = createContext<StoreApi<CombinedState> | null>(null);
|
||||||
export const StoreProvider = ({ children }: { children: ReactNode }) => {
|
export const StoreProvider = ({ children }: { children: ReactNode }) => {
|
||||||
const storeRef = useRef<StoreApi<CombinedState>>(
|
const storeRef = useRef<StoreApi<CombinedState>>(
|
||||||
createStore<CombinedState>((...a) => ({
|
createStore<CombinedState>((...a) => ({
|
||||||
...createSelectedSecretStore(...a),
|
...createSelectedSecretStore(...a),
|
||||||
...createPopUpStore(...a)
|
...createPopUpStore(...a),
|
||||||
|
...createBatchModeStore(...a)
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -99,3 +733,17 @@ export const useSelectedSecretActions = () => useStoreContext(useShallow((state)
|
|||||||
export const usePopUpState = (id: PopUpNames) =>
|
export const usePopUpState = (id: PopUpNames) =>
|
||||||
useStoreContext(useShallow((state) => state.popUp?.[id] || { isOpen: false }));
|
useStoreContext(useShallow((state) => state.popUp?.[id] || { isOpen: false }));
|
||||||
export const usePopUpAction = () => useStoreContext(useShallow((state) => state.popUpActions));
|
export const usePopUpAction = () => useStoreContext(useShallow((state) => state.popUpActions));
|
||||||
|
|
||||||
|
export const useBatchMode = () =>
|
||||||
|
useStoreContext(
|
||||||
|
useShallow((state) => ({
|
||||||
|
isBatchMode: state.isBatchMode,
|
||||||
|
pendingChanges: state.pendingChanges,
|
||||||
|
currentContext: state.currentContext,
|
||||||
|
totalChangesCount: state.batchActions.getTotalPendingChangesCount(),
|
||||||
|
secretChangesCount: state.pendingChanges.secrets.length,
|
||||||
|
folderChangesCount: state.pendingChanges.folders.length
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
export const useBatchModeActions = () => useStoreContext(useShallow((state) => state.batchActions));
|
||||||
|
|||||||
+28
@@ -77,12 +77,15 @@ import {
|
|||||||
} from "@app/hooks/api/dashboard/queries";
|
} from "@app/hooks/api/dashboard/queries";
|
||||||
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
|
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
|
||||||
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
|
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
|
||||||
|
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||||
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
|
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
|
||||||
import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types";
|
import { ApiErrorTypes, SecretType, TApiErrors, WsTag } from "@app/hooks/api/types";
|
||||||
import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput";
|
import { SecretSearchInput } from "@app/pages/secret-manager/OverviewPage/components/SecretSearchInput";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
PendingFolderCreate,
|
||||||
PopUpNames,
|
PopUpNames,
|
||||||
|
useBatchModeActions,
|
||||||
usePopUpAction,
|
usePopUpAction,
|
||||||
useSelectedSecretActions,
|
useSelectedSecretActions,
|
||||||
useSelectedSecrets
|
useSelectedSecrets
|
||||||
@@ -112,6 +115,7 @@ type Props = {
|
|||||||
filter: Filter;
|
filter: Filter;
|
||||||
tags?: WsTag[];
|
tags?: WsTag[];
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
|
isBatchMode?: boolean;
|
||||||
snapshotCount: number;
|
snapshotCount: number;
|
||||||
isSnapshotCountLoading?: boolean;
|
isSnapshotCountLoading?: boolean;
|
||||||
protectedBranchPolicyName?: string;
|
protectedBranchPolicyName?: string;
|
||||||
@@ -142,6 +146,7 @@ export const ActionBar = ({
|
|||||||
filter,
|
filter,
|
||||||
tags = [],
|
tags = [],
|
||||||
isVisible,
|
isVisible,
|
||||||
|
isBatchMode,
|
||||||
snapshotCount,
|
snapshotCount,
|
||||||
isSnapshotCountLoading,
|
isSnapshotCountLoading,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
@@ -182,6 +187,7 @@ export const ActionBar = ({
|
|||||||
options: { onSuccess: undefined }
|
options: { onSuccess: undefined }
|
||||||
});
|
});
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { addPendingChange } = useBatchModeActions();
|
||||||
|
|
||||||
const selectedSecrets = useSelectedSecrets();
|
const selectedSecrets = useSelectedSecrets();
|
||||||
const { reset: resetSelectedSecret } = useSelectedSecretActions();
|
const { reset: resetSelectedSecret } = useSelectedSecretActions();
|
||||||
@@ -192,6 +198,28 @@ export const ActionBar = ({
|
|||||||
|
|
||||||
const handleFolderCreate = async (folderName: string, description: string | null) => {
|
const handleFolderCreate = async (folderName: string, description: string | null) => {
|
||||||
try {
|
try {
|
||||||
|
if (isBatchMode) {
|
||||||
|
const folderId = `${folderName}`;
|
||||||
|
const pendingFolderCreate: PendingFolderCreate = {
|
||||||
|
id: folderId,
|
||||||
|
resourceType: "folder",
|
||||||
|
type: PendingAction.Create,
|
||||||
|
folderName,
|
||||||
|
description: description || undefined,
|
||||||
|
parentPath: secretPath,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
addPendingChange(pendingFolderCreate, {
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpClose("addFolder");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await createFolder({
|
await createFolder({
|
||||||
name: folderName,
|
name: folderName,
|
||||||
path: secretPath,
|
path: secretPath,
|
||||||
|
|||||||
+7
-2
@@ -8,7 +8,12 @@ import { TextArea } from "@app/components/v2/TextArea/TextArea";
|
|||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
onCreateFolder?: (folderName: string, description: string | null) => Promise<void>;
|
onCreateFolder?: (folderName: string, description: string | null) => Promise<void>;
|
||||||
onUpdateFolder?: (folderName: string, description: string | null) => Promise<void>;
|
onUpdateFolder?: (
|
||||||
|
folderName: string,
|
||||||
|
description: string | null,
|
||||||
|
oldFolderName?: string,
|
||||||
|
oldFolderDescription?: string
|
||||||
|
) => Promise<void>;
|
||||||
isEdit?: boolean;
|
isEdit?: boolean;
|
||||||
defaultFolderName?: string;
|
defaultFolderName?: string;
|
||||||
defaultDescription?: string;
|
defaultDescription?: string;
|
||||||
@@ -69,7 +74,7 @@ export const FolderForm = ({
|
|||||||
const descriptionShaped = description && description.trim() !== "" ? description : null;
|
const descriptionShaped = description && description.trim() !== "" ? description : null;
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await onUpdateFolder?.(name, descriptionShaped);
|
await onUpdateFolder?.(name, descriptionShaped, defaultFolderName, defaultDescription);
|
||||||
} else {
|
} else {
|
||||||
await onCreateFolder?.(name, descriptionShaped);
|
await onCreateFolder?.(name, descriptionShaped);
|
||||||
}
|
}
|
||||||
|
|||||||
+439
@@ -0,0 +1,439 @@
|
|||||||
|
/* eslint-disable jsx-a11y/label-has-associated-control */
|
||||||
|
import React, { useCallback, useState } from "react";
|
||||||
|
import { faCodeCommit, faEye, faFolder, faKey, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
|
import { Badge, Button, Input, Modal, ModalContent } from "@app/components/v2";
|
||||||
|
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||||
|
import { SecretVersionDiffView } from "@app/pages/secret-manager/CommitDetailsPage/components/SecretVersionDiffView";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PendingChange,
|
||||||
|
PendingChanges,
|
||||||
|
useBatchMode,
|
||||||
|
useBatchModeActions
|
||||||
|
} from "../../SecretMainPage.store";
|
||||||
|
|
||||||
|
interface CommitFormProps {
|
||||||
|
onCommit: (changes: PendingChanges, commitMessage: string) => Promise<void>;
|
||||||
|
isCommitting?: boolean;
|
||||||
|
environment: string;
|
||||||
|
workspaceId: string;
|
||||||
|
secretPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceChangeProps {
|
||||||
|
change: PendingChange;
|
||||||
|
environment: string;
|
||||||
|
workspaceId: string;
|
||||||
|
secretPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenderResourceProps = {
|
||||||
|
onDiscard: () => void;
|
||||||
|
change: PendingChange;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RenderSecretChanges = ({ onDiscard, change }: RenderResourceProps) => {
|
||||||
|
if (change.resourceType !== "secret") return null;
|
||||||
|
|
||||||
|
if (change.type === PendingAction.Create) {
|
||||||
|
return (
|
||||||
|
<SecretVersionDiffView
|
||||||
|
onDiscard={onDiscard}
|
||||||
|
item={{
|
||||||
|
secretKey: change.secretKey,
|
||||||
|
isAdded: true,
|
||||||
|
type: "secret",
|
||||||
|
id: change.id,
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
version: 1, // placeholder, not used
|
||||||
|
secretKey: change.secretKey,
|
||||||
|
secretValue: change.secretValue,
|
||||||
|
tags: change.tags,
|
||||||
|
secretMetadata: change.secretMetadata,
|
||||||
|
skipMultilineEncoding: change.skipMultilineEncoding,
|
||||||
|
comment: change.secretComment
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.type === PendingAction.Update) {
|
||||||
|
const { existingSecret } = change;
|
||||||
|
|
||||||
|
const hasKeyChange = change.newSecretName && change.secretKey !== change.newSecretName;
|
||||||
|
const hasValueChange = change.secretValue !== change.originalValue;
|
||||||
|
const hasCommentChange = change.secretComment !== change.originalComment;
|
||||||
|
const hasMultilineChange =
|
||||||
|
change.skipMultilineEncoding !== change.originalSkipMultilineEncoding;
|
||||||
|
const hasTagsChange = JSON.stringify(change.tags) !== JSON.stringify(change.originalTags);
|
||||||
|
const hasMetadataChange =
|
||||||
|
JSON.stringify(change.secretMetadata) !== JSON.stringify(change.originalSecretMetadata);
|
||||||
|
|
||||||
|
const hasChanges = [
|
||||||
|
hasKeyChange,
|
||||||
|
hasValueChange,
|
||||||
|
hasCommentChange,
|
||||||
|
hasMultilineChange,
|
||||||
|
hasTagsChange,
|
||||||
|
hasMetadataChange
|
||||||
|
].some(Boolean);
|
||||||
|
|
||||||
|
if (!hasChanges) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecretVersionDiffView
|
||||||
|
onDiscard={onDiscard}
|
||||||
|
item={{
|
||||||
|
secretKey: change.secretKey,
|
||||||
|
isUpdated: true,
|
||||||
|
type: "secret",
|
||||||
|
id: change.id,
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
version: 1, // placeholder, not used
|
||||||
|
secretKey: change.newSecretName ? existingSecret.key : undefined,
|
||||||
|
secretValue:
|
||||||
|
change.secretValue !== undefined ? (existingSecret.value ?? "") : undefined,
|
||||||
|
tags: change.tags ? (existingSecret.tags?.map((tag) => tag.slug) ?? []) : undefined,
|
||||||
|
secretMetadata: change.secretMetadata ? existingSecret.secretMetadata : undefined,
|
||||||
|
skipMultilineEncoding:
|
||||||
|
typeof change.skipMultilineEncoding === "boolean"
|
||||||
|
? existingSecret.skipMultilineEncoding
|
||||||
|
: undefined,
|
||||||
|
comment: change.secretComment !== undefined ? existingSecret.comment : undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 2, // placeholder, not used
|
||||||
|
secretKey: change.newSecretName,
|
||||||
|
secretValue: change.secretValue,
|
||||||
|
tags: change.tags?.map((tag) => tag.slug),
|
||||||
|
secretMetadata: change.secretMetadata,
|
||||||
|
skipMultilineEncoding: change.skipMultilineEncoding,
|
||||||
|
comment: change.secretComment
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.type === PendingAction.Delete) {
|
||||||
|
const { secretKey, secretValue } = change;
|
||||||
|
return (
|
||||||
|
<SecretVersionDiffView
|
||||||
|
onDiscard={onDiscard}
|
||||||
|
item={{
|
||||||
|
secretKey: change.secretKey,
|
||||||
|
isDeleted: true,
|
||||||
|
type: "secret",
|
||||||
|
id: change.id,
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
version: 1, // placeholder, not used
|
||||||
|
secretKey,
|
||||||
|
secretValue
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RenderFolderChanges = ({ onDiscard, change }: RenderResourceProps) => {
|
||||||
|
if (change.resourceType !== "folder") return null;
|
||||||
|
|
||||||
|
if (change.type === PendingAction.Create) {
|
||||||
|
return (
|
||||||
|
<SecretVersionDiffView
|
||||||
|
onDiscard={onDiscard}
|
||||||
|
item={{
|
||||||
|
folderName: change.folderName,
|
||||||
|
isAdded: true,
|
||||||
|
type: "folder",
|
||||||
|
id: change.id,
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
name: change.folderName,
|
||||||
|
description: change.description
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.type === PendingAction.Update) {
|
||||||
|
const hasNameChange = change.folderName !== change.originalFolderName;
|
||||||
|
const hasDescriptionChange = change.description !== change.originalDescription;
|
||||||
|
|
||||||
|
const hasChanges = [hasNameChange, hasDescriptionChange].some(Boolean);
|
||||||
|
|
||||||
|
if (!hasChanges) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecretVersionDiffView
|
||||||
|
onDiscard={onDiscard}
|
||||||
|
item={{
|
||||||
|
folderName: change.folderName,
|
||||||
|
isUpdated: true,
|
||||||
|
type: "folder",
|
||||||
|
id: change.id,
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
version: 1, // placeholder, not used
|
||||||
|
name: change.folderName ? change.originalFolderName : undefined,
|
||||||
|
description: change.description ? change.originalDescription : undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 2, // placeholder, not used
|
||||||
|
name: change.folderName,
|
||||||
|
description: change.description
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (change.type === PendingAction.Delete) {
|
||||||
|
return (
|
||||||
|
<SecretVersionDiffView
|
||||||
|
onDiscard={onDiscard}
|
||||||
|
item={{
|
||||||
|
folderName: change.folderName,
|
||||||
|
isDeleted: true,
|
||||||
|
type: "folder",
|
||||||
|
id: change.id,
|
||||||
|
versions: [
|
||||||
|
{
|
||||||
|
version: 1,
|
||||||
|
name: change.folderName
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ResourceChange: React.FC<ResourceChangeProps> = ({
|
||||||
|
change,
|
||||||
|
environment,
|
||||||
|
workspaceId,
|
||||||
|
secretPath
|
||||||
|
}) => {
|
||||||
|
const { removePendingChange } = useBatchModeActions();
|
||||||
|
|
||||||
|
const handleDeletePending = useCallback(
|
||||||
|
(changeType: string, id: string) => {
|
||||||
|
removePendingChange(id, changeType, {
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[change.resourceType, change.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
return change.resourceType === "secret" ? (
|
||||||
|
<RenderSecretChanges
|
||||||
|
key={change.id}
|
||||||
|
change={change}
|
||||||
|
onDiscard={() => handleDeletePending(change.resourceType, change.id)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<RenderFolderChanges
|
||||||
|
key={change.id}
|
||||||
|
change={change}
|
||||||
|
onDiscard={() => handleDeletePending(change.resourceType, change.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommitForm: React.FC<CommitFormProps> = ({
|
||||||
|
onCommit,
|
||||||
|
isCommitting = false,
|
||||||
|
environment,
|
||||||
|
workspaceId,
|
||||||
|
secretPath
|
||||||
|
}) => {
|
||||||
|
const { isBatchMode, pendingChanges, totalChangesCount } = useBatchMode();
|
||||||
|
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [commitMessage, setCommitMessage] = useState("");
|
||||||
|
const { clearAllPendingChanges } = useBatchModeActions();
|
||||||
|
|
||||||
|
if (!isBatchMode || totalChangesCount === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCommit = async () => {
|
||||||
|
if (!commitMessage.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onCommit(pendingChanges, commitMessage);
|
||||||
|
clearAllPendingChanges({
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
});
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setCommitMessage("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating Panel */}
|
||||||
|
{!isModalOpen && (
|
||||||
|
<div className="fixed bottom-4 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 p-2">
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FontAwesomeIcon icon={faCodeCommit} className="text-mineshaft-400" />
|
||||||
|
<span className="font-medium text-mineshaft-100">Pending Changes</span>
|
||||||
|
</div>
|
||||||
|
<Badge variant="primary" className="text-xs">
|
||||||
|
{totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-center gap-2 p-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||||
|
onClick={() => clearAllPendingChanges({ workspaceId, environment, secretPath })}
|
||||||
|
isDisabled={totalChangesCount === 0}
|
||||||
|
variant="outline_bg"
|
||||||
|
className="h-8 flex-1 hover:border-red/40 hover:bg-red/[0.1]"
|
||||||
|
>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline_bg"
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faEye} />}
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
isDisabled={totalChangesCount === 0}
|
||||||
|
className="h-8 flex-1"
|
||||||
|
>
|
||||||
|
Review
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Commit Modal */}
|
||||||
|
<Modal isOpen={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||||
|
<ModalContent
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FontAwesomeIcon icon={faCodeCommit} className="text-mineshaft-400" />
|
||||||
|
Commit Changes
|
||||||
|
<Badge variant="primary" className="mt-[0.05rem]">
|
||||||
|
{totalChangesCount} Change{totalChangesCount !== 1 ? "s" : ""}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
subTitle={"Write a commit message and review the changes you're about to commit."}
|
||||||
|
className="max-h-[90vh] max-w-[95%] md:max-w-7xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Changes List */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="max-h-[50vh] space-y-4 overflow-y-auto">
|
||||||
|
{/* Folder Changes */}
|
||||||
|
{pendingChanges.folders.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-4 flex items-center gap-2 border-b border-mineshaft-700 pb-2 text-sm font-semibold text-mineshaft-200">
|
||||||
|
<FontAwesomeIcon icon={faFolder} className="text-mineshaft-300" />
|
||||||
|
Folders ({pendingChanges.folders.length})
|
||||||
|
</h4>
|
||||||
|
<div>
|
||||||
|
{pendingChanges.folders.map((change) => (
|
||||||
|
<ResourceChange
|
||||||
|
key={change.id}
|
||||||
|
change={change}
|
||||||
|
environment={environment}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
secretPath={secretPath}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Secret Changes */}
|
||||||
|
{pendingChanges.secrets.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-4 flex items-center gap-2 border-b border-mineshaft-700 pb-2 text-sm font-semibold text-mineshaft-200">
|
||||||
|
<FontAwesomeIcon icon={faKey} className="mr-1 text-mineshaft-300" />
|
||||||
|
Secrets ({pendingChanges.secrets.length})
|
||||||
|
</h4>
|
||||||
|
<div>
|
||||||
|
{pendingChanges.secrets.map((change) => (
|
||||||
|
<ResourceChange
|
||||||
|
key={change.id}
|
||||||
|
change={change}
|
||||||
|
environment={environment}
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
secretPath={secretPath}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Commit Message */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-mineshaft-200">
|
||||||
|
Commit Message <span className="text-red-400">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={commitMessage}
|
||||||
|
onChange={(e) => setCommitMessage(e.target.value)}
|
||||||
|
placeholder="Describe your changes..."
|
||||||
|
className="w-full"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="secondary"
|
||||||
|
onClick={() => setIsModalOpen(false)}
|
||||||
|
isDisabled={isCommitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCommit}
|
||||||
|
isLoading={isCommitting}
|
||||||
|
isDisabled={isCommitting || !commitMessage.trim()}
|
||||||
|
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="outline_bg"
|
||||||
|
>
|
||||||
|
{isCommitting ? "Committing..." : "Commit Changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { CommitForm } from "./CommitForm";
|
||||||
+31
-2
@@ -12,9 +12,15 @@ import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput";
|
|||||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||||
import { getKeyValue } from "@app/helpers/parseEnvVar";
|
import { getKeyValue } from "@app/helpers/parseEnvVar";
|
||||||
import { useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
|
import { useCreateSecretV3, useCreateWsTag, useGetWsTags } from "@app/hooks/api";
|
||||||
|
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||||
import { SecretType } from "@app/hooks/api/types";
|
import { SecretType } from "@app/hooks/api/types";
|
||||||
|
|
||||||
import { PopUpNames, usePopUpAction } from "../../SecretMainPage.store";
|
import {
|
||||||
|
PendingSecretCreate,
|
||||||
|
PopUpNames,
|
||||||
|
useBatchModeActions,
|
||||||
|
usePopUpAction
|
||||||
|
} from "../../SecretMainPage.store";
|
||||||
|
|
||||||
const typeSchema = z.object({
|
const typeSchema = z.object({
|
||||||
key: z.string().trim().min(1, { message: "Secret key is required" }),
|
key: z.string().trim().min(1, { message: "Secret key is required" }),
|
||||||
@@ -31,6 +37,7 @@ type Props = {
|
|||||||
// modal props
|
// modal props
|
||||||
autoCapitalize?: boolean;
|
autoCapitalize?: boolean;
|
||||||
isProtectedBranch?: boolean;
|
isProtectedBranch?: boolean;
|
||||||
|
isBatchMode?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CreateSecretForm = ({
|
export const CreateSecretForm = ({
|
||||||
@@ -38,7 +45,8 @@ export const CreateSecretForm = ({
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
secretPath = "/",
|
secretPath = "/",
|
||||||
autoCapitalize = true,
|
autoCapitalize = true,
|
||||||
isProtectedBranch = false
|
isProtectedBranch = false,
|
||||||
|
isBatchMode = false
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -53,6 +61,7 @@ export const CreateSecretForm = ({
|
|||||||
|
|
||||||
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
|
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
|
||||||
const createWsTag = useCreateWsTag();
|
const createWsTag = useCreateWsTag();
|
||||||
|
const { addPendingChange } = useBatchModeActions();
|
||||||
|
|
||||||
const { permission } = useProjectPermission();
|
const { permission } = useProjectPermission();
|
||||||
const canReadTags = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags);
|
const canReadTags = permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags);
|
||||||
@@ -85,6 +94,26 @@ export const CreateSecretForm = ({
|
|||||||
|
|
||||||
const handleFormSubmit = async ({ key, value, tags }: TFormSchema) => {
|
const handleFormSubmit = async ({ key, value, tags }: TFormSchema) => {
|
||||||
try {
|
try {
|
||||||
|
if (isBatchMode) {
|
||||||
|
const pendingSecretCreate: PendingSecretCreate = {
|
||||||
|
id: key,
|
||||||
|
type: PendingAction.Create,
|
||||||
|
secretKey: key,
|
||||||
|
secretValue: value || "",
|
||||||
|
secretComment: "",
|
||||||
|
tags: tags?.map((el) => ({ id: el.value, slug: el.label })),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
resourceType: "secret"
|
||||||
|
};
|
||||||
|
addPendingChange(pendingSecretCreate, {
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
});
|
||||||
|
closePopUp(PopUpNames.CreateSecretForm);
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await createSecretV3({
|
await createSecretV3({
|
||||||
environment,
|
environment,
|
||||||
workspaceId,
|
workspaceId,
|
||||||
|
|||||||
+5
-9
@@ -1,10 +1,5 @@
|
|||||||
import { subject } from "@casl/ability";
|
import { subject } from "@casl/ability";
|
||||||
import {
|
import { faEdit, faFingerprint, faTrash, faWarning } from "@fortawesome/free-solid-svg-icons";
|
||||||
faClose,
|
|
||||||
faFingerprint,
|
|
||||||
faPencilSquare,
|
|
||||||
faWarning
|
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
@@ -187,7 +182,7 @@ export const DynamicSecretListView = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
|
<div className="flex w-16 items-center justify-between border-l border-mineshaft-600 px-3 py-3">
|
||||||
<ProjectPermissionCan
|
<ProjectPermissionCan
|
||||||
I={ProjectPermissionDynamicSecretActions.EditRootCredential}
|
I={ProjectPermissionDynamicSecretActions.EditRootCredential}
|
||||||
a={subject(ProjectPermissionSub.DynamicSecrets, {
|
a={subject(ProjectPermissionSub.DynamicSecrets, {
|
||||||
@@ -210,7 +205,7 @@ export const DynamicSecretListView = ({
|
|||||||
}}
|
}}
|
||||||
isDisabled={!isAllowed || isRevoking}
|
isDisabled={!isAllowed || isRevoking}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
|
<FontAwesomeIcon icon={faEdit} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
@@ -228,6 +223,7 @@ export const DynamicSecretListView = ({
|
|||||||
<IconButton
|
<IconButton
|
||||||
ariaLabel="delete-dynamic-secret"
|
ariaLabel="delete-dynamic-secret"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
|
colorSchema="danger"
|
||||||
size="md"
|
size="md"
|
||||||
className="p-0 opacity-0 group-hover:opacity-100"
|
className="p-0 opacity-0 group-hover:opacity-100"
|
||||||
onClick={(evt) => {
|
onClick={(evt) => {
|
||||||
@@ -236,7 +232,7 @@ export const DynamicSecretListView = ({
|
|||||||
}}
|
}}
|
||||||
isDisabled={!isAllowed || isRevoking}
|
isDisabled={!isAllowed || isRevoking}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
|
|||||||
+183
-51
@@ -1,7 +1,15 @@
|
|||||||
import { subject } from "@casl/ability";
|
import { subject } from "@casl/ability";
|
||||||
import { faClose, faFolder, faInfoCircle, faPencilSquare } from "@fortawesome/free-solid-svg-icons";
|
import {
|
||||||
|
faClose,
|
||||||
|
faEdit,
|
||||||
|
faFolder,
|
||||||
|
faInfoCircle,
|
||||||
|
faPencilSquare,
|
||||||
|
faTrash
|
||||||
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
import { createNotification } from "@app/components/notifications";
|
||||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||||
@@ -11,8 +19,15 @@ import { ROUTE_PATHS } from "@app/const/routes";
|
|||||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||||
import { usePopUp } from "@app/hooks";
|
import { usePopUp } from "@app/hooks";
|
||||||
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
|
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
|
||||||
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
import { PendingAction, TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
||||||
|
|
||||||
|
import {
|
||||||
|
PendingFolderCreate,
|
||||||
|
PendingFolderDelete,
|
||||||
|
PendingFolderUpdate,
|
||||||
|
useBatchMode,
|
||||||
|
useBatchModeActions
|
||||||
|
} from "../../SecretMainPage.store";
|
||||||
import { FolderForm } from "../ActionBar/FolderForm";
|
import { FolderForm } from "../ActionBar/FolderForm";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -42,10 +57,62 @@ export const FolderListView = ({
|
|||||||
|
|
||||||
const { mutateAsync: updateFolder } = useUpdateFolder();
|
const { mutateAsync: updateFolder } = useUpdateFolder();
|
||||||
const { mutateAsync: deleteFolder } = useDeleteFolder();
|
const { mutateAsync: deleteFolder } = useDeleteFolder();
|
||||||
|
const { isBatchMode } = useBatchMode();
|
||||||
|
const { addPendingChange, removePendingChange } = useBatchModeActions();
|
||||||
|
|
||||||
const handleFolderUpdate = async (newFolderName: string, newFolderDescription: string | null) => {
|
const handleFolderUpdate = async (
|
||||||
|
newFolderName: string,
|
||||||
|
newFolderDescription: string | null,
|
||||||
|
oldFolderName?: string,
|
||||||
|
oldFolderDescription?: string
|
||||||
|
) => {
|
||||||
try {
|
try {
|
||||||
const { id: folderId } = popUp.updateFolder.data as TSecretFolder;
|
const updateFolderData = popUp.updateFolder.data;
|
||||||
|
if (!updateFolderData) throw new Error("Update folder data is required");
|
||||||
|
const { id: folderId, pendingAction, isPending } = updateFolderData as TSecretFolder;
|
||||||
|
|
||||||
|
if (isBatchMode) {
|
||||||
|
const isEditingPendingCreation = isPending && pendingAction === PendingAction.Create;
|
||||||
|
|
||||||
|
if (isEditingPendingCreation) {
|
||||||
|
const updatedCreate: PendingFolderCreate = {
|
||||||
|
id: folderId,
|
||||||
|
type: PendingAction.Create,
|
||||||
|
folderName: newFolderName,
|
||||||
|
description: newFolderDescription || undefined,
|
||||||
|
parentPath: secretPath,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
resourceType: "folder"
|
||||||
|
};
|
||||||
|
|
||||||
|
addPendingChange(updatedCreate, {
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const updateChange: PendingFolderUpdate = {
|
||||||
|
id: folderId,
|
||||||
|
type: PendingAction.Update,
|
||||||
|
originalFolderName: oldFolderName || "",
|
||||||
|
folderName: newFolderName,
|
||||||
|
originalDescription: oldFolderDescription,
|
||||||
|
description: newFolderDescription || undefined,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
resourceType: "folder"
|
||||||
|
};
|
||||||
|
|
||||||
|
addPendingChange(updateChange, {
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePopUpClose("updateFolder");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await updateFolder({
|
await updateFolder({
|
||||||
folderId,
|
folderId,
|
||||||
name: newFolderName,
|
name: newFolderName,
|
||||||
@@ -68,15 +135,45 @@ export const FolderListView = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDeletePending = (id: string) => {
|
||||||
|
removePendingChange(id, "folder", {
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleFolderDelete = async () => {
|
const handleFolderDelete = async () => {
|
||||||
try {
|
try {
|
||||||
const { id: folderId } = popUp.deleteFolder.data as TSecretFolder;
|
const folderData = popUp.deleteFolder?.data as TSecretFolder;
|
||||||
|
|
||||||
|
if (isBatchMode) {
|
||||||
|
const pendingFolderDelete: PendingFolderDelete = {
|
||||||
|
id: folderData.id,
|
||||||
|
folderName: folderData.name,
|
||||||
|
folderPath: secretPath,
|
||||||
|
resourceType: "folder",
|
||||||
|
type: PendingAction.Delete,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
addPendingChange(pendingFolderDelete, {
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpClose("deleteFolder");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await deleteFolder({
|
await deleteFolder({
|
||||||
folderId,
|
folderId: folderData.id,
|
||||||
path: secretPath,
|
path: secretPath,
|
||||||
environment,
|
environment,
|
||||||
projectId: workspaceId
|
projectId: workspaceId
|
||||||
});
|
});
|
||||||
|
|
||||||
handlePopUpClose("deleteFolder");
|
handlePopUpClose("deleteFolder");
|
||||||
createNotification({
|
createNotification({
|
||||||
type: "success",
|
type: "success",
|
||||||
@@ -91,7 +188,10 @@ export const FolderListView = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFolderClick = (name: string) => {
|
const handleFolderClick = (name: string, isPending?: boolean) => {
|
||||||
|
if (isPending) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const path = `${secretPathQueryparam === "/" ? "" : secretPathQueryparam}/${name}`;
|
const path = `${secretPathQueryparam === "/" ? "" : secretPathQueryparam}/${name}`;
|
||||||
navigate({
|
navigate({
|
||||||
search: (el) => ({ ...el, secretPath: path })
|
search: (el) => ({ ...el, secretPath: path })
|
||||||
@@ -100,10 +200,16 @@ export const FolderListView = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{folders.map(({ name, id, description }) => (
|
{folders.map(({ name, id, description, pendingAction, isPending }) => (
|
||||||
<div
|
<div
|
||||||
key={id}
|
key={id}
|
||||||
className="group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700"
|
className={twMerge(
|
||||||
|
"group flex cursor-pointer border-b border-mineshaft-600 hover:bg-mineshaft-700",
|
||||||
|
isPending && "bg-mineshaft-700/60",
|
||||||
|
pendingAction === PendingAction.Delete && "border-l-2 border-l-red-600/75",
|
||||||
|
pendingAction === PendingAction.Update && "border-l-2 border-l-yellow-600/75",
|
||||||
|
pendingAction === PendingAction.Create && "border-l-2 border-l-green-600/75"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex w-11 items-center px-5 py-3 text-yellow-700">
|
<div className="flex w-11 items-center px-5 py-3 text-yellow-700">
|
||||||
<FontAwesomeIcon icon={faFolder} />
|
<FontAwesomeIcon icon={faFolder} />
|
||||||
@@ -113,9 +219,9 @@ export const FolderListView = ({
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(evt) => {
|
onKeyDown={(evt) => {
|
||||||
if (evt.key === "Enter") handleFolderClick(name);
|
if (evt.key === "Enter") handleFolderClick(name, isPending);
|
||||||
}}
|
}}
|
||||||
onClick={() => handleFolderClick(name)}
|
onClick={() => handleFolderClick(name, isPending)}
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
{description && (
|
{description && (
|
||||||
@@ -128,46 +234,72 @@ export const FolderListView = ({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
|
{isPending ? (
|
||||||
<ProjectPermissionCan
|
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-3 py-3">
|
||||||
I={ProjectPermissionActions.Edit}
|
<IconButton
|
||||||
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })}
|
ariaLabel="edit-folder"
|
||||||
renderTooltip
|
variant="plain"
|
||||||
allowedLabel="Edit"
|
size="sm"
|
||||||
>
|
className="p-0 opacity-0 group-hover:opacity-100"
|
||||||
{(isAllowed) => (
|
isDisabled
|
||||||
<IconButton
|
onClick={() => {}}
|
||||||
ariaLabel="edit-folder"
|
>
|
||||||
variant="plain"
|
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
|
||||||
size="sm"
|
</IconButton>
|
||||||
className="p-0 opacity-0 group-hover:opacity-100"
|
|
||||||
onClick={() => handlePopUpOpen("updateFolder", { id, name, description })}
|
<IconButton
|
||||||
isDisabled={!isAllowed}
|
ariaLabel="delete-folder"
|
||||||
>
|
variant="plain"
|
||||||
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
|
size="md"
|
||||||
</IconButton>
|
className="p-0 opacity-0 group-hover:opacity-100"
|
||||||
)}
|
onClick={() => handleDeletePending(id)}
|
||||||
</ProjectPermissionCan>
|
>
|
||||||
<ProjectPermissionCan
|
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||||
I={ProjectPermissionActions.Delete}
|
</IconButton>
|
||||||
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })}
|
</div>
|
||||||
renderTooltip
|
) : (
|
||||||
allowedLabel="Delete"
|
<div className="flex w-16 items-center justify-between border-l border-mineshaft-600 px-3 py-3">
|
||||||
>
|
<ProjectPermissionCan
|
||||||
{(isAllowed) => (
|
I={ProjectPermissionActions.Edit}
|
||||||
<IconButton
|
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })}
|
||||||
ariaLabel="delete-folder"
|
renderTooltip
|
||||||
variant="plain"
|
allowedLabel="Edit"
|
||||||
size="md"
|
>
|
||||||
className="p-0 opacity-0 group-hover:opacity-100"
|
{(isAllowed) => (
|
||||||
onClick={() => handlePopUpOpen("deleteFolder", { id, name })}
|
<IconButton
|
||||||
isDisabled={!isAllowed}
|
ariaLabel="edit-folder"
|
||||||
>
|
variant="plain"
|
||||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
size="sm"
|
||||||
</IconButton>
|
className="p-0 opacity-0 group-hover:opacity-100"
|
||||||
)}
|
onClick={() => handlePopUpOpen("updateFolder", { id, name, description })}
|
||||||
</ProjectPermissionCan>
|
isDisabled={!isAllowed}
|
||||||
</div>
|
>
|
||||||
|
<FontAwesomeIcon icon={faEdit} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Delete}
|
||||||
|
a={subject(ProjectPermissionSub.SecretFolders, { environment, secretPath })}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel="Delete"
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="delete-folder"
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="danger"
|
||||||
|
size="md"
|
||||||
|
className="p-0 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={() => handlePopUpOpen("deleteFolder", { id, name })}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
+3
-3
@@ -3,13 +3,13 @@ import { subject } from "@casl/ability";
|
|||||||
import { useSortable } from "@dnd-kit/sortable";
|
import { useSortable } from "@dnd-kit/sortable";
|
||||||
import {
|
import {
|
||||||
faCalendarCheck,
|
faCalendarCheck,
|
||||||
faClose,
|
|
||||||
faFileImport,
|
faFileImport,
|
||||||
faFolder,
|
faFolder,
|
||||||
faInfoCircle,
|
faInfoCircle,
|
||||||
faKey,
|
faKey,
|
||||||
faRotate,
|
faRotate,
|
||||||
faSearch,
|
faSearch,
|
||||||
|
faTrash,
|
||||||
faUpDown,
|
faUpDown,
|
||||||
faWarning,
|
faWarning,
|
||||||
faXmark
|
faXmark
|
||||||
@@ -237,7 +237,7 @@ export const SecretImportItem = ({
|
|||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4 border-l border-mineshaft-600 px-4 py-2">
|
<div className="flex w-16 items-center justify-between border-l border-mineshaft-600 py-3.5 pl-4 pr-3">
|
||||||
<ProjectPermissionCan
|
<ProjectPermissionCan
|
||||||
I={ProjectPermissionActions.Edit}
|
I={ProjectPermissionActions.Edit}
|
||||||
a={subject(ProjectPermissionSub.SecretImports, {
|
a={subject(ProjectPermissionSub.SecretImports, {
|
||||||
@@ -281,7 +281,7 @@ export const SecretImportItem = ({
|
|||||||
}}
|
}}
|
||||||
isDisabled={!isAllowed}
|
isDisabled={!isAllowed}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
|
|||||||
+1
-1
@@ -1088,7 +1088,7 @@ export const SecretDetailSidebar = ({
|
|||||||
isDisabled={isSubmitting || !isDirty || !isAllowed}
|
isDisabled={isSubmitting || !isDirty || !isAllowed}
|
||||||
isLoading={isSubmitting}
|
isLoading={isSubmitting}
|
||||||
>
|
>
|
||||||
Save Changes
|
Apply Changes
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
|
|||||||
+491
-276
@@ -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, useCallback, useEffect, useRef } 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 {
|
||||||
@@ -50,6 +50,7 @@ import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionCo
|
|||||||
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { faEyeSlash, faKey, faRotate } from "@fortawesome/free-solid-svg-icons";
|
import { faEyeSlash, faKey, faRotate } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||||
import {
|
import {
|
||||||
FontAwesomeSpriteName,
|
FontAwesomeSpriteName,
|
||||||
formSchema,
|
formSchema,
|
||||||
@@ -57,6 +58,7 @@ import {
|
|||||||
TFormSchema
|
TFormSchema
|
||||||
} from "./SecretListView.utils";
|
} from "./SecretListView.utils";
|
||||||
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
|
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
|
||||||
|
import { useBatchModeActions } from "../../SecretMainPage.store";
|
||||||
|
|
||||||
export const HIDDEN_SECRET_VALUE = "******";
|
export const HIDDEN_SECRET_VALUE = "******";
|
||||||
export const HIDDEN_SECRET_VALUE_API_MASK = "<hidden-by-infisical>";
|
export const HIDDEN_SECRET_VALUE_API_MASK = "<hidden-by-infisical>";
|
||||||
@@ -86,6 +88,8 @@ type Props = {
|
|||||||
isImported: boolean;
|
isImported: boolean;
|
||||||
}[];
|
}[];
|
||||||
}[];
|
}[];
|
||||||
|
isPending?: boolean;
|
||||||
|
pendingAction?: PendingAction;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SecretItem = memo(
|
export const SecretItem = memo(
|
||||||
@@ -102,7 +106,9 @@ export const SecretItem = memo(
|
|||||||
environment,
|
environment,
|
||||||
secretPath,
|
secretPath,
|
||||||
handleSecretShare,
|
handleSecretShare,
|
||||||
importedBy
|
importedBy,
|
||||||
|
isPending,
|
||||||
|
pendingAction
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||||
"editSecret"
|
"editSecret"
|
||||||
@@ -110,6 +116,18 @@ export const SecretItem = memo(
|
|||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const { permission } = useProjectPermission();
|
const { permission } = useProjectPermission();
|
||||||
const { isRotatedSecret } = secret;
|
const { isRotatedSecret } = secret;
|
||||||
|
const { removePendingChange } = useBatchModeActions();
|
||||||
|
|
||||||
|
const autoSaveTimeoutRef = useRef<NodeJS.Timeout>();
|
||||||
|
const isAutoSavingRef = useRef(false);
|
||||||
|
|
||||||
|
const handleDeletePending = (pendingSecret: SecretV3RawSanitized) => {
|
||||||
|
removePendingChange(pendingSecret.id, "secret", {
|
||||||
|
workspaceId: currentWorkspace.id,
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const canEditSecretValue = permission.can(
|
const canEditSecretValue = permission.can(
|
||||||
ProjectPermissionSecretActions.Edit,
|
ProjectPermissionSecretActions.Edit,
|
||||||
@@ -151,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"));
|
||||||
|
|
||||||
@@ -167,6 +184,56 @@ 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 {
|
||||||
|
await onSaveSecret(secret, { ...secret, ...data }, () => {
|
||||||
|
reset();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Auto-save failed:", error);
|
||||||
|
} finally {
|
||||||
|
isAutoSavingRef.current = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[secret, onSaveSecret, importedBy, reset]
|
||||||
|
);
|
||||||
|
|
||||||
|
const formValues = watch();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoSaveTimeoutRef.current) {
|
||||||
|
clearTimeout(autoSaveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirty && !isSubmitting && !isAutoSavingRef.current) {
|
||||||
|
const debounceTime = 600;
|
||||||
|
|
||||||
|
autoSaveTimeoutRef.current = setTimeout(() => {
|
||||||
|
autoSaveChanges(formValues);
|
||||||
|
}, debounceTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (autoSaveTimeoutRef.current) {
|
||||||
|
clearTimeout(autoSaveTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [formValues, isDirty, isSubmitting, autoSaveChanges, isPending]);
|
||||||
|
|
||||||
const isReadOnly =
|
const isReadOnly =
|
||||||
hasSecretReadValueOrDescribePermission(
|
hasSecretReadValueOrDescribePermission(
|
||||||
permission,
|
permission,
|
||||||
@@ -188,6 +255,9 @@ export const SecretItem = memo(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isReadOnlySecret =
|
||||||
|
isReadOnly || isRotatedSecret || (isPending && pendingAction === PendingAction.Delete);
|
||||||
|
|
||||||
const { secretValueHidden } = secret;
|
const { secretValueHidden } = secret;
|
||||||
|
|
||||||
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
|
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
|
||||||
@@ -199,10 +269,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
|
||||||
@@ -267,20 +333,27 @@ 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
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"border-b border-mineshaft-600 bg-mineshaft-800 shadow-none hover:bg-mineshaft-700",
|
"border-b border-mineshaft-600 bg-mineshaft-800 shadow-none hover:bg-mineshaft-700",
|
||||||
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",
|
||||||
|
pendingAction === PendingAction.Delete && "border-l-2 border-l-red-600/75",
|
||||||
|
pendingAction === PendingAction.Update && "border-l-2 border-l-yellow-600/75",
|
||||||
|
pendingAction === PendingAction.Create && "border-l-2 border-l-green-600/75"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="group flex">
|
<div className="group flex">
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"flex h-11 w-11 items-center justify-center px-4 py-3 text-mineshaft-300",
|
"flex h-11 w-11 items-center justify-center px-4 py-3 text-mineshaft-300",
|
||||||
isDirty && "text-primary"
|
isDirty && "text-primary",
|
||||||
|
isPending && "ml-[-2px]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{secret.isRotatedSecret ? (
|
{secret.isRotatedSecret ? (
|
||||||
@@ -364,7 +437,7 @@ export const SecretItem = memo(
|
|||||||
control={control}
|
control={control}
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<InfisicalSecretInput
|
<InfisicalSecretInput
|
||||||
isReadOnly={isReadOnly || isRotatedSecret}
|
isReadOnly={isReadOnlySecret}
|
||||||
key="secret-value"
|
key="secret-value"
|
||||||
isVisible={isVisible && !secretValueHidden}
|
isVisible={isVisible && !secretValueHidden}
|
||||||
canEditButNotView={secretValueHidden && !isOverriden}
|
canEditButNotView={secretValueHidden && !isOverriden}
|
||||||
@@ -377,275 +450,160 @@ export const SecretItem = memo(
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div
|
{pendingAction !== PendingAction.Create && pendingAction !== PendingAction.Delete && (
|
||||||
key="actions"
|
<div
|
||||||
className="flex h-full flex-shrink-0 self-start transition-all group-hover:gap-x-2"
|
key="actions"
|
||||||
>
|
className="flex h-full flex-shrink-0 self-start transition-all group-hover:gap-x-2"
|
||||||
<Tooltip content="Copy secret">
|
|
||||||
<IconButton
|
|
||||||
isDisabled={secret.secretValueHidden}
|
|
||||||
ariaLabel="copy-value"
|
|
||||||
variant="plain"
|
|
||||||
size="sm"
|
|
||||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
|
||||||
onClick={copyTokenToClipboard}
|
|
||||||
>
|
|
||||||
<FontAwesomeSymbol
|
|
||||||
className="h-3.5 w-3"
|
|
||||||
symbolName={
|
|
||||||
isSecValueCopied
|
|
||||||
? FontAwesomeSpriteName.Check
|
|
||||||
: FontAwesomeSpriteName.ClipboardCopy
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
<ProjectPermissionCan
|
|
||||||
I={ProjectPermissionActions.Edit}
|
|
||||||
a={subject(ProjectPermissionSub.Secrets, {
|
|
||||||
environment,
|
|
||||||
secretPath,
|
|
||||||
secretName,
|
|
||||||
secretTags: selectedTagSlugs
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{(isAllowed) => (
|
<Tooltip content="Copy secret">
|
||||||
<Modal>
|
|
||||||
<ModalTrigger asChild>
|
|
||||||
<IconButton
|
|
||||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
|
||||||
variant="plain"
|
|
||||||
size="md"
|
|
||||||
ariaLabel="reference-tree"
|
|
||||||
isDisabled={!isAllowed || !hasSecretReference(secret?.value)}
|
|
||||||
>
|
|
||||||
<Tooltip
|
|
||||||
content={
|
|
||||||
hasSecretReference(secret?.value)
|
|
||||||
? "Secret Reference Tree"
|
|
||||||
: "Secret does not contain references"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FontAwesomeSymbol
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
symbolName={FontAwesomeSpriteName.SecretReferenceTree}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</IconButton>
|
|
||||||
</ModalTrigger>
|
|
||||||
<ModalContent
|
|
||||||
title="Secret Reference Details"
|
|
||||||
subTitle="Visual breakdown of secrets referenced by this secret."
|
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()} // prevents secret input from displaying value on open
|
|
||||||
>
|
|
||||||
<SecretReferenceTree
|
|
||||||
secretPath={secretPath}
|
|
||||||
environment={environment}
|
|
||||||
secretKey={secret?.key}
|
|
||||||
/>
|
|
||||||
</ModalContent>
|
|
||||||
</Modal>
|
|
||||||
)}
|
|
||||||
</ProjectPermissionCan>
|
|
||||||
<DropdownMenu>
|
|
||||||
<ProjectPermissionCan
|
|
||||||
I={ProjectPermissionActions.Edit}
|
|
||||||
a={subject(ProjectPermissionSub.Secrets, {
|
|
||||||
environment,
|
|
||||||
secretPath,
|
|
||||||
secretName,
|
|
||||||
secretTags: selectedTagSlugs
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{(isAllowed) => (
|
|
||||||
<DropdownMenuTrigger asChild disabled={!isAllowed}>
|
|
||||||
<IconButton
|
|
||||||
ariaLabel="tags"
|
|
||||||
variant="plain"
|
|
||||||
size="sm"
|
|
||||||
className={twMerge(
|
|
||||||
"w-0 overflow-hidden p-0 group-hover:w-5 data-[state=open]:w-5",
|
|
||||||
hasTagsApplied && "w-5 text-primary"
|
|
||||||
)}
|
|
||||||
isDisabled={!isAllowed}
|
|
||||||
>
|
|
||||||
<Tooltip content="Tags">
|
|
||||||
<FontAwesomeSymbol
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
symbolName={FontAwesomeSpriteName.Tags}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</IconButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
)}
|
|
||||||
</ProjectPermissionCan>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuLabel>Add tags to this secret</DropdownMenuLabel>
|
|
||||||
{tags.map((tag) => {
|
|
||||||
const { id: tagId, slug, color } = tag;
|
|
||||||
|
|
||||||
const isTagSelected = selectedTagsGroupById?.[tagId];
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleTagSelect(tag)}
|
|
||||||
key={`${secret.id}-${tagId}`}
|
|
||||||
icon={
|
|
||||||
isTagSelected && (
|
|
||||||
<FontAwesomeSymbol
|
|
||||||
symbolName={FontAwesomeSpriteName.CheckedCircle}
|
|
||||||
className="h-3 w-3"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
iconPos="right"
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div
|
|
||||||
className="mr-2 h-2 w-2 rounded-full"
|
|
||||||
style={{ background: color || "#bec2c8" }}
|
|
||||||
/>
|
|
||||||
{slug}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<DropdownMenuItem className="px-1.5" asChild>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
className="w-full"
|
|
||||||
colorSchema="primary"
|
|
||||||
variant="outline_bg"
|
|
||||||
leftIcon={
|
|
||||||
<FontAwesomeSymbol
|
|
||||||
symbolName={FontAwesomeSpriteName.Tags}
|
|
||||||
className="h-3 w-3"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
onClick={onCreateTag}
|
|
||||||
>
|
|
||||||
Create a tag
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<ProjectPermissionCan
|
|
||||||
I={ProjectPermissionActions.Edit}
|
|
||||||
a={subject(ProjectPermissionSub.Secrets, {
|
|
||||||
environment,
|
|
||||||
secretPath,
|
|
||||||
secretName,
|
|
||||||
secretTags: selectedTagSlugs
|
|
||||||
})}
|
|
||||||
renderTooltip
|
|
||||||
allowedLabel="Override"
|
|
||||||
>
|
|
||||||
{(isAllowed) => (
|
|
||||||
<IconButton
|
<IconButton
|
||||||
ariaLabel="override-value"
|
isDisabled={secret.secretValueHidden}
|
||||||
isDisabled={!isAllowed}
|
ariaLabel="copy-value"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleOverrideClick}
|
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||||
className={twMerge(
|
onClick={copyTokenToClipboard}
|
||||||
"w-0 overflow-hidden p-0 group-hover:w-5",
|
|
||||||
isOverriden && "w-5 text-primary"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<FontAwesomeSymbol
|
<FontAwesomeSymbol
|
||||||
symbolName={FontAwesomeSpriteName.Override}
|
className="h-3.5 w-3"
|
||||||
className="h-3.5 w-3.5"
|
symbolName={
|
||||||
/>
|
isSecValueCopied
|
||||||
</IconButton>
|
? FontAwesomeSpriteName.Check
|
||||||
)}
|
: FontAwesomeSpriteName.ClipboardCopy
|
||||||
</ProjectPermissionCan>
|
}
|
||||||
<Popover>
|
|
||||||
<ProjectPermissionCan
|
|
||||||
I={ProjectPermissionActions.Edit}
|
|
||||||
a={subject(ProjectPermissionSub.Secrets, {
|
|
||||||
environment,
|
|
||||||
secretPath,
|
|
||||||
secretName,
|
|
||||||
secretTags: selectedTagSlugs
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{(isAllowed) => (
|
|
||||||
<PopoverTrigger asChild disabled={!isAllowed}>
|
|
||||||
<IconButton
|
|
||||||
className={twMerge(
|
|
||||||
"w-0 overflow-hidden p-0 group-hover:w-5",
|
|
||||||
hasComment && "w-5 text-primary"
|
|
||||||
)}
|
|
||||||
variant="plain"
|
|
||||||
size="md"
|
|
||||||
ariaLabel="add-comment"
|
|
||||||
isDisabled={!isAllowed}
|
|
||||||
>
|
|
||||||
<Tooltip content="Comment">
|
|
||||||
<FontAwesomeSymbol
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
symbolName={FontAwesomeSpriteName.Comment}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</IconButton>
|
|
||||||
</PopoverTrigger>
|
|
||||||
)}
|
|
||||||
</ProjectPermissionCan>
|
|
||||||
<IconButton
|
|
||||||
isDisabled={secret.secretValueHidden || !currentWorkspace.secretSharing}
|
|
||||||
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
|
||||||
variant="plain"
|
|
||||||
size="md"
|
|
||||||
ariaLabel="share-secret"
|
|
||||||
onClick={handleSecretShare}
|
|
||||||
>
|
|
||||||
<Tooltip content="Share Secret">
|
|
||||||
<FontAwesomeSymbol
|
|
||||||
className="h-3.5 w-3.5"
|
|
||||||
symbolName={FontAwesomeSpriteName.ShareSecret}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</IconButton>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl"
|
|
||||||
sticky="always"
|
|
||||||
>
|
|
||||||
<FormControl label="Comment" className="mb-0">
|
|
||||||
<TextArea
|
|
||||||
className="border border-mineshaft-600 text-sm"
|
|
||||||
rows={8}
|
|
||||||
cols={30}
|
|
||||||
{...register("comment")}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
{!isDirty ? (
|
|
||||||
<motion.div
|
|
||||||
key="options"
|
|
||||||
className="flex h-10 flex-shrink-0 items-center space-x-4 px-[0.64rem]"
|
|
||||||
initial={{ x: 0, opacity: 0 }}
|
|
||||||
animate={{ x: 0, opacity: 1 }}
|
|
||||||
exit={{ x: 10, opacity: 0 }}
|
|
||||||
>
|
|
||||||
<Tooltip content="More">
|
|
||||||
<IconButton
|
|
||||||
ariaLabel="more"
|
|
||||||
variant="plain"
|
|
||||||
size="md"
|
|
||||||
className="h-5 w-4 p-0 opacity-0 group-hover:opacity-100"
|
|
||||||
onClick={() => onDetailViewSecret(secret)}
|
|
||||||
>
|
|
||||||
<FontAwesomeSymbol
|
|
||||||
symbolName={FontAwesomeSpriteName.More}
|
|
||||||
className="h-5 w-4"
|
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<ProjectPermissionCan
|
<ProjectPermissionCan
|
||||||
I={ProjectPermissionActions.Delete}
|
I={ProjectPermissionActions.Edit}
|
||||||
|
a={subject(ProjectPermissionSub.Secrets, {
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
secretName,
|
||||||
|
secretTags: selectedTagSlugs
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<Modal>
|
||||||
|
<ModalTrigger asChild>
|
||||||
|
<IconButton
|
||||||
|
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||||
|
variant="plain"
|
||||||
|
size="md"
|
||||||
|
ariaLabel="reference-tree"
|
||||||
|
isDisabled={!isAllowed || !hasSecretReference(secret?.value)}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
hasSecretReference(secret?.value)
|
||||||
|
? "Secret Reference Tree"
|
||||||
|
: "Secret does not contain references"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<FontAwesomeSymbol
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
symbolName={FontAwesomeSpriteName.SecretReferenceTree}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</IconButton>
|
||||||
|
</ModalTrigger>
|
||||||
|
<ModalContent
|
||||||
|
title="Secret Reference Details"
|
||||||
|
subTitle="Visual breakdown of secrets referenced by this secret."
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<SecretReferenceTree
|
||||||
|
secretPath={secretPath}
|
||||||
|
environment={environment}
|
||||||
|
secretKey={secret?.key}
|
||||||
|
/>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<DropdownMenu>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Edit}
|
||||||
|
a={subject(ProjectPermissionSub.Secrets, {
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
secretName,
|
||||||
|
secretTags: selectedTagSlugs
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<DropdownMenuTrigger asChild disabled={!isAllowed}>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="tags"
|
||||||
|
variant="plain"
|
||||||
|
size="sm"
|
||||||
|
className={twMerge(
|
||||||
|
"w-0 overflow-hidden p-0 group-hover:w-5 data-[state=open]:w-5",
|
||||||
|
hasTagsApplied && "w-5 text-primary"
|
||||||
|
)}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
<Tooltip content="Tags">
|
||||||
|
<FontAwesomeSymbol
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
symbolName={FontAwesomeSpriteName.Tags}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Add tags to this secret</DropdownMenuLabel>
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const { id: tagId, slug, color } = tag;
|
||||||
|
|
||||||
|
const isTagSelected = selectedTagsGroupById?.[tagId];
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleTagSelect(tag)}
|
||||||
|
key={`${secret.id}-${tagId}`}
|
||||||
|
icon={
|
||||||
|
isTagSelected && (
|
||||||
|
<FontAwesomeSymbol
|
||||||
|
symbolName={FontAwesomeSpriteName.CheckedCircle}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div
|
||||||
|
className="mr-2 h-2 w-2 rounded-full"
|
||||||
|
style={{ background: color || "#bec2c8" }}
|
||||||
|
/>
|
||||||
|
{slug}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<DropdownMenuItem className="px-1.5" asChild>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
className="w-full"
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="outline_bg"
|
||||||
|
leftIcon={
|
||||||
|
<FontAwesomeSymbol
|
||||||
|
symbolName={FontAwesomeSpriteName.Tags}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onClick={onCreateTag}
|
||||||
|
>
|
||||||
|
Create a tag
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Edit}
|
||||||
a={subject(ProjectPermissionSub.Secrets, {
|
a={subject(ProjectPermissionSub.Secrets, {
|
||||||
environment,
|
environment,
|
||||||
secretPath,
|
secretPath,
|
||||||
@@ -653,30 +611,287 @@ 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="Override"
|
||||||
>
|
>
|
||||||
{(isAllowed) => (
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="override-value"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
variant="plain"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleOverrideClick}
|
||||||
|
className={twMerge(
|
||||||
|
"w-0 overflow-hidden p-0 group-hover:w-5",
|
||||||
|
isOverriden && "w-5 text-primary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FontAwesomeSymbol
|
||||||
|
symbolName={FontAwesomeSpriteName.Override}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<Popover>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Edit}
|
||||||
|
a={subject(ProjectPermissionSub.Secrets, {
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
secretName,
|
||||||
|
secretTags: selectedTagSlugs
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<PopoverTrigger asChild disabled={!isAllowed}>
|
||||||
|
<IconButton
|
||||||
|
className={twMerge(
|
||||||
|
"w-0 overflow-hidden p-0 group-hover:w-5",
|
||||||
|
hasComment && "w-5 text-primary"
|
||||||
|
)}
|
||||||
|
variant="plain"
|
||||||
|
size="md"
|
||||||
|
ariaLabel="add-comment"
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
<Tooltip content="Comment">
|
||||||
|
<FontAwesomeSymbol
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
symbolName={FontAwesomeSpriteName.Comment}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</IconButton>
|
||||||
|
</PopoverTrigger>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<IconButton
|
||||||
|
isDisabled={secret.secretValueHidden || !currentWorkspace.secretSharing}
|
||||||
|
className="w-0 overflow-hidden p-0 group-hover:w-5"
|
||||||
|
variant="plain"
|
||||||
|
size="md"
|
||||||
|
ariaLabel="share-secret"
|
||||||
|
onClick={handleSecretShare}
|
||||||
|
>
|
||||||
|
<Tooltip content="Share Secret">
|
||||||
|
<FontAwesomeSymbol
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
symbolName={FontAwesomeSpriteName.ShareSecret}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</IconButton>
|
||||||
|
<PopoverContent
|
||||||
|
className="w-auto border border-mineshaft-600 bg-mineshaft-800 p-2 drop-shadow-2xl"
|
||||||
|
sticky="always"
|
||||||
|
>
|
||||||
|
<FormControl label="Comment" className="mb-0">
|
||||||
|
<TextArea
|
||||||
|
className="border border-mineshaft-600 text-sm"
|
||||||
|
rows={8}
|
||||||
|
cols={30}
|
||||||
|
{...register("comment")}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pendingAction === PendingAction.Create && (
|
||||||
|
<div
|
||||||
|
key="actions"
|
||||||
|
className="flex h-full flex-shrink-0 self-start transition-all group-hover:gap-x-2"
|
||||||
|
>
|
||||||
|
<DropdownMenu>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Edit}
|
||||||
|
a={subject(ProjectPermissionSub.Secrets, {
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
secretName,
|
||||||
|
secretTags: selectedTagSlugs
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<DropdownMenuTrigger asChild disabled={!isAllowed}>
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="tags"
|
||||||
|
variant="plain"
|
||||||
|
size="sm"
|
||||||
|
className={twMerge(
|
||||||
|
"w-0 overflow-hidden p-0 group-hover:w-5 data-[state=open]:w-5",
|
||||||
|
hasTagsApplied && "w-5 text-primary"
|
||||||
|
)}
|
||||||
|
isDisabled={!isAllowed}
|
||||||
|
>
|
||||||
|
<Tooltip content="Tags">
|
||||||
|
<FontAwesomeSymbol
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
symbolName={FontAwesomeSpriteName.Tags}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</IconButton>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Add tags to this secret</DropdownMenuLabel>
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const { id: tagId, slug, color } = tag;
|
||||||
|
|
||||||
|
const isTagSelected = selectedTagsGroupById?.[tagId];
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleTagSelect(tag)}
|
||||||
|
key={`${secret.id}-${tagId}`}
|
||||||
|
icon={
|
||||||
|
isTagSelected && (
|
||||||
|
<FontAwesomeSymbol
|
||||||
|
symbolName={FontAwesomeSpriteName.CheckedCircle}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
iconPos="right"
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div
|
||||||
|
className="mr-2 h-2 w-2 rounded-full"
|
||||||
|
style={{ background: color || "#bec2c8" }}
|
||||||
|
/>
|
||||||
|
{slug}
|
||||||
|
</div>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<DropdownMenuItem className="px-1.5" asChild>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
className="w-full"
|
||||||
|
colorSchema="primary"
|
||||||
|
variant="outline_bg"
|
||||||
|
leftIcon={
|
||||||
|
<FontAwesomeSymbol
|
||||||
|
symbolName={FontAwesomeSpriteName.Tags}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onClick={onCreateTag}
|
||||||
|
>
|
||||||
|
Create a tag
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{isInAutoSaveMode ? (
|
||||||
|
<motion.div
|
||||||
|
key="auto-save-mode"
|
||||||
|
className="flex w-[63px] flex-shrink-0 items-center justify-between px-3"
|
||||||
|
initial={{ x: -10, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
exit={{ x: -10, opacity: 0 }}
|
||||||
|
>
|
||||||
|
<div className="h-10 w-12" />
|
||||||
|
</motion.div>
|
||||||
|
) : !isDirty ? (
|
||||||
|
isPending ? (
|
||||||
|
<motion.div
|
||||||
|
key="options"
|
||||||
|
className="flex w-[63px] flex-shrink-0 items-center justify-between px-3"
|
||||||
|
initial={{ x: 0, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
exit={{ x: 10, opacity: 0 }}
|
||||||
|
>
|
||||||
|
<Tooltip content="More">
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="more"
|
||||||
|
variant="plain"
|
||||||
|
size="md"
|
||||||
|
className="h-5 w-4 p-0 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={() => onDetailViewSecret(secret)}
|
||||||
|
isDisabled={pendingAction !== "update"}
|
||||||
|
>
|
||||||
|
<FontAwesomeSymbol
|
||||||
|
symbolName={FontAwesomeSpriteName.More}
|
||||||
|
className="h-5 w-4"
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content="Discard">
|
||||||
<IconButton
|
<IconButton
|
||||||
ariaLabel="delete-value"
|
ariaLabel="delete-value"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
colorSchema="danger"
|
colorSchema="danger"
|
||||||
size="md"
|
size="md"
|
||||||
className="p-0 opacity-0 group-hover:opacity-100"
|
className="p-0 opacity-0 group-hover:opacity-100"
|
||||||
onClick={() => onDeleteSecret(secret)}
|
onClick={() => handleDeletePending(secret)}
|
||||||
isDisabled={!isAllowed || isRotatedSecret}
|
|
||||||
>
|
>
|
||||||
<FontAwesomeSymbol
|
<FontAwesomeSymbol
|
||||||
symbolName={FontAwesomeSpriteName.Close}
|
symbolName={FontAwesomeSpriteName.Close}
|
||||||
className="h-5 w-4"
|
className="h-5 w-4"
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
</Tooltip>
|
||||||
</ProjectPermissionCan>
|
</motion.div>
|
||||||
</motion.div>
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="options"
|
||||||
|
className="flex w-[63px] flex-shrink-0 items-center justify-between px-3"
|
||||||
|
initial={{ x: 0, opacity: 0 }}
|
||||||
|
animate={{ x: 0, opacity: 1 }}
|
||||||
|
exit={{ x: 10, opacity: 0 }}
|
||||||
|
>
|
||||||
|
<Tooltip content="More">
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="more"
|
||||||
|
variant="plain"
|
||||||
|
size="md"
|
||||||
|
className="h-5 w-4 p-0 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={() => onDetailViewSecret(secret)}
|
||||||
|
>
|
||||||
|
<FontAwesomeSymbol
|
||||||
|
symbolName={FontAwesomeSpriteName.More}
|
||||||
|
className="h-5 w-4"
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
<ProjectPermissionCan
|
||||||
|
I={ProjectPermissionActions.Delete}
|
||||||
|
a={subject(ProjectPermissionSub.Secrets, {
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
secretName,
|
||||||
|
secretTags: selectedTagSlugs
|
||||||
|
})}
|
||||||
|
renderTooltip
|
||||||
|
allowedLabel={isRotatedSecret ? "Cannot Delete Rotated Secret" : "Delete"}
|
||||||
|
>
|
||||||
|
{(isAllowed) => (
|
||||||
|
<IconButton
|
||||||
|
ariaLabel="delete-value"
|
||||||
|
variant="plain"
|
||||||
|
colorSchema="danger"
|
||||||
|
size="md"
|
||||||
|
className="p-0 opacity-0 group-hover:opacity-100"
|
||||||
|
onClick={() => onDeleteSecret(secret)}
|
||||||
|
isDisabled={!isAllowed || isRotatedSecret}
|
||||||
|
>
|
||||||
|
<FontAwesomeSymbol
|
||||||
|
symbolName={FontAwesomeSpriteName.Trash}
|
||||||
|
className="h-4 w-3"
|
||||||
|
/>
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</ProjectPermissionCan>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<motion.div
|
<motion.div
|
||||||
key="options-save"
|
key="options-save"
|
||||||
className="flex h-10 flex-shrink-0 items-center space-x-4 px-3"
|
className="flex w-[63px] flex-shrink-0 items-center justify-between px-3"
|
||||||
initial={{ x: -10, opacity: 0 }}
|
initial={{ x: -10, opacity: 0 }}
|
||||||
animate={{ x: 0, opacity: 1 }}
|
animate={{ x: 0, opacity: 1 }}
|
||||||
exit={{ x: -10, opacity: 0 }}
|
exit={{ x: -10, opacity: 0 }}
|
||||||
|
|||||||
+188
-15
@@ -11,13 +11,24 @@ import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
|
|||||||
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
|
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
|
||||||
import { commitKeys } from "@app/hooks/api/folderCommits/queries";
|
import { commitKeys } from "@app/hooks/api/folderCommits/queries";
|
||||||
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
|
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
|
||||||
|
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||||
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
import { 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 { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
|
import {
|
||||||
|
PendingSecretChange,
|
||||||
|
PendingSecretCreate,
|
||||||
|
PendingSecretDelete,
|
||||||
|
PendingSecretUpdate,
|
||||||
|
useBatchMode,
|
||||||
|
useBatchModeActions,
|
||||||
|
useSelectedSecretActions,
|
||||||
|
useSelectedSecrets
|
||||||
|
} from "../../SecretMainPage.store";
|
||||||
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
|
import { CollapsibleSecretImports } from "./CollapsibleSecretImports";
|
||||||
import { SecretDetailSidebar } from "./SecretDetailSidebar";
|
import { SecretDetailSidebar } from "./SecretDetailSidebar";
|
||||||
import { HIDDEN_SECRET_VALUE, HIDDEN_SECRET_VALUE_API_MASK, SecretItem } from "./SecretItem";
|
import { HIDDEN_SECRET_VALUE, HIDDEN_SECRET_VALUE_API_MASK, SecretItem } from "./SecretItem";
|
||||||
@@ -63,22 +74,29 @@ export const SecretListView = ({
|
|||||||
|
|
||||||
// strip of side effect queries
|
// strip of side effect queries
|
||||||
const { mutateAsync: createSecretV3 } = useCreateSecretV3({
|
const { mutateAsync: createSecretV3 } = useCreateSecretV3({
|
||||||
options: {
|
options: { onSuccess: undefined }
|
||||||
onSuccess: undefined
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3({
|
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3({
|
||||||
options: {
|
options: { onSuccess: undefined }
|
||||||
onSuccess: undefined
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3({
|
const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3({
|
||||||
options: {
|
options: { onSuccess: undefined }
|
||||||
onSuccess: undefined
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const selectedSecrets = useSelectedSecrets();
|
const selectedSecrets = useSelectedSecrets();
|
||||||
const { toggle: toggleSelectedSecret } = useSelectedSecretActions();
|
const { toggle: toggleSelectedSecret } = useSelectedSecretActions();
|
||||||
|
const { isBatchMode, pendingChanges } = useBatchMode();
|
||||||
|
useNavigationBlocker({
|
||||||
|
shouldBlock: pendingChanges.secrets.length > 0 || pendingChanges.folders.length > 0,
|
||||||
|
message:
|
||||||
|
"You have unsaved changes. If you leave now, your work will be lost. Do you want to continue?",
|
||||||
|
context: {
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const { addPendingChange } = useBatchModeActions();
|
||||||
|
|
||||||
const handleSecretOperation = async (
|
const handleSecretOperation = async (
|
||||||
operation: "create" | "update" | "delete",
|
operation: "create" | "update" | "delete",
|
||||||
@@ -170,11 +188,56 @@ export const SecretListView = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getTrueOriginalSecret(
|
||||||
|
currentSecret: SecretV3RawSanitized,
|
||||||
|
pendingSecrets: PendingSecretChange[]
|
||||||
|
): Omit<SecretV3RawSanitized, "tags"> & {
|
||||||
|
tags?: { id: string; slug: string }[];
|
||||||
|
} {
|
||||||
|
// Find if there's already a pending change for this secret
|
||||||
|
const existingChange = pendingSecrets.find((change) => change.id === currentSecret.id);
|
||||||
|
|
||||||
|
if (!existingChange || existingChange.type === "create") {
|
||||||
|
// If no existing change or it's a creation, current secret is the original
|
||||||
|
return {
|
||||||
|
...currentSecret,
|
||||||
|
tags: currentSecret.tags?.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
slug: tag.slug
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingChange.type === "update") {
|
||||||
|
// If there's an existing update, reconstruct the original from the stored original values
|
||||||
|
return {
|
||||||
|
...currentSecret,
|
||||||
|
key: existingChange.secretKey, // Original key
|
||||||
|
value: existingChange.originalValue || currentSecret.value,
|
||||||
|
comment: existingChange.originalComment || currentSecret.comment,
|
||||||
|
skipMultilineEncoding:
|
||||||
|
existingChange.originalSkipMultilineEncoding ?? currentSecret.skipMultilineEncoding,
|
||||||
|
tags:
|
||||||
|
existingChange.originalTags?.map((tag) => ({
|
||||||
|
id: tag.id,
|
||||||
|
slug: tag.slug
|
||||||
|
})) || currentSecret.tags,
|
||||||
|
secretMetadata: existingChange.originalSecretMetadata || currentSecret.secretMetadata
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...currentSecret,
|
||||||
|
tags: currentSecret.tags?.map((tag) => ({ id: tag.id, slug: tag.slug }))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Improved handleSaveSecret function
|
||||||
const handleSaveSecret = useCallback(
|
const handleSaveSecret = useCallback(
|
||||||
async (
|
async (
|
||||||
orgSecret: SecretV3RawSanitized,
|
orgSecret: SecretV3RawSanitized,
|
||||||
modSecret: Omit<SecretV3RawSanitized, "tags"> & {
|
modSecret: Omit<SecretV3RawSanitized, "tags"> & {
|
||||||
tags?: { id: string }[];
|
tags?: { id: string; name?: string; slug?: string }[];
|
||||||
secretMetadata?: { key: string; value: string }[];
|
secretMetadata?: { key: string; value: string }[];
|
||||||
},
|
},
|
||||||
cb?: () => void
|
cb?: () => void
|
||||||
@@ -192,7 +255,9 @@ export const SecretListView = ({
|
|||||||
reminderNote,
|
reminderNote,
|
||||||
reminderRecipients,
|
reminderRecipients,
|
||||||
secretMetadata,
|
secretMetadata,
|
||||||
isReminderEvent
|
isReminderEvent,
|
||||||
|
isPending,
|
||||||
|
pendingAction
|
||||||
} = modSecret;
|
} = modSecret;
|
||||||
const hasKeyChanged = oldKey !== key && key;
|
const hasKeyChanged = oldKey !== key && key;
|
||||||
|
|
||||||
@@ -246,6 +311,80 @@ export const SecretListView = ({
|
|||||||
|
|
||||||
// shared secret change
|
// shared secret change
|
||||||
if (!isSharedSecUnchanged && !personalAction) {
|
if (!isSharedSecUnchanged && !personalAction) {
|
||||||
|
if (isBatchMode) {
|
||||||
|
const isEditingPendingCreation = isPending && pendingAction === PendingAction.Create;
|
||||||
|
|
||||||
|
if (isEditingPendingCreation) {
|
||||||
|
const updatedCreate: PendingSecretCreate = {
|
||||||
|
id: orgSecret.id,
|
||||||
|
type: PendingAction.Create,
|
||||||
|
secretKey: key,
|
||||||
|
secretValue: value || "",
|
||||||
|
secretComment: comment || "",
|
||||||
|
skipMultilineEncoding: modSecret.skipMultilineEncoding || false,
|
||||||
|
tags: tags?.map((tag) => ({ id: tag.id, slug: tag.name || tag.slug || "" })) || [],
|
||||||
|
secretMetadata: secretMetadata || [],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
resourceType: "secret",
|
||||||
|
originalKey: oldKey
|
||||||
|
};
|
||||||
|
|
||||||
|
addPendingChange(updatedCreate, {
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const trueOriginalSecret = getTrueOriginalSecret(orgSecret, pendingChanges.secrets);
|
||||||
|
|
||||||
|
const updateChange: PendingSecretUpdate = {
|
||||||
|
id: orgSecret.id,
|
||||||
|
type: PendingAction.Update,
|
||||||
|
secretKey: trueOriginalSecret.key,
|
||||||
|
...(key !== trueOriginalSecret.key && { newSecretName: key }),
|
||||||
|
...(value !== trueOriginalSecret.value && {
|
||||||
|
originalValue: trueOriginalSecret.value,
|
||||||
|
secretValue: value
|
||||||
|
}),
|
||||||
|
...(comment !== trueOriginalSecret.comment && {
|
||||||
|
originalComment: trueOriginalSecret.comment,
|
||||||
|
secretComment: comment
|
||||||
|
}),
|
||||||
|
...(modSecret.skipMultilineEncoding !==
|
||||||
|
trueOriginalSecret.skipMultilineEncoding && {
|
||||||
|
originalSkipMultilineEncoding: trueOriginalSecret.skipMultilineEncoding,
|
||||||
|
skipMultilineEncoding: modSecret.skipMultilineEncoding
|
||||||
|
}),
|
||||||
|
...(!isSameTags && {
|
||||||
|
originalTags:
|
||||||
|
trueOriginalSecret.tags?.map((tag) => ({ id: tag.id, slug: tag.slug })) || [],
|
||||||
|
tags: tags?.map((tag) => ({ id: tag.id, slug: tag.name || tag.slug || "" })) || []
|
||||||
|
}),
|
||||||
|
...(JSON.stringify(secretMetadata) !==
|
||||||
|
JSON.stringify(trueOriginalSecret.secretMetadata) && {
|
||||||
|
originalSecretMetadata: trueOriginalSecret.secretMetadata || [],
|
||||||
|
secretMetadata: secretMetadata || []
|
||||||
|
}),
|
||||||
|
|
||||||
|
timestamp: Date.now(),
|
||||||
|
resourceType: "secret",
|
||||||
|
existingSecret: orgSecret
|
||||||
|
};
|
||||||
|
|
||||||
|
addPendingChange(updateChange, {
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isReminderEvent) {
|
||||||
|
handlePopUpClose("secretDetail");
|
||||||
|
}
|
||||||
|
if (cb) cb();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await handleSecretOperation("update", SecretType.Shared, oldKey, {
|
await handleSecretOperation("update", SecretType.Shared, oldKey, {
|
||||||
value,
|
value,
|
||||||
tags: tagIds,
|
tags: tagIds,
|
||||||
@@ -314,12 +453,41 @@ export const SecretListView = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[environment, secretPath, isProtectedBranch]
|
[
|
||||||
|
environment,
|
||||||
|
secretPath,
|
||||||
|
isProtectedBranch,
|
||||||
|
isBatchMode,
|
||||||
|
workspaceId,
|
||||||
|
addPendingChange,
|
||||||
|
pendingChanges.secrets
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSecretDelete = useCallback(async () => {
|
const handleSecretDelete = useCallback(async () => {
|
||||||
const { key, id: secretId } = popUp.deleteSecret?.data as SecretV3RawSanitized;
|
const { key, id: secretId, value } = popUp.deleteSecret?.data as SecretV3RawSanitized;
|
||||||
try {
|
try {
|
||||||
|
if (isBatchMode) {
|
||||||
|
const deleteChange: PendingSecretDelete = {
|
||||||
|
id: `${secretId}`,
|
||||||
|
type: PendingAction.Delete,
|
||||||
|
secretKey: key,
|
||||||
|
secretValue: value || "",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
resourceType: "secret"
|
||||||
|
};
|
||||||
|
|
||||||
|
addPendingChange(deleteChange, {
|
||||||
|
workspaceId,
|
||||||
|
environment,
|
||||||
|
secretPath
|
||||||
|
});
|
||||||
|
|
||||||
|
handlePopUpClose("deleteSecret");
|
||||||
|
handlePopUpClose("secretDetail");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await handleSecretOperation("delete", SecretType.Shared, key, { secretId });
|
await handleSecretOperation("delete", SecretType.Shared, key, { secretId });
|
||||||
// wrap this in another function and then reuse
|
// wrap this in another function and then reuse
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
@@ -362,7 +530,10 @@ export const SecretListView = ({
|
|||||||
(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key,
|
(popUp.deleteSecret?.data as SecretV3RawSanitized)?.key,
|
||||||
environment,
|
environment,
|
||||||
secretPath,
|
secretPath,
|
||||||
isProtectedBranch
|
isProtectedBranch,
|
||||||
|
isBatchMode,
|
||||||
|
workspaceId,
|
||||||
|
addPendingChange
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// for optimization on minimise re-rendering of secret items
|
// for optimization on minimise re-rendering of secret items
|
||||||
@@ -401,6 +572,8 @@ export const SecretListView = ({
|
|||||||
value: secret.valueOverride ?? secret.value
|
value: secret.valueOverride ?? secret.value
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
isPending={secret.isPending}
|
||||||
|
pendingAction={secret.pendingAction}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<DeleteActionModal
|
<DeleteActionModal
|
||||||
|
|||||||
+6
-3
@@ -13,7 +13,8 @@ import {
|
|||||||
faLock,
|
faLock,
|
||||||
faProjectDiagram,
|
faProjectDiagram,
|
||||||
faShare,
|
faShare,
|
||||||
faTags
|
faTags,
|
||||||
|
faTrash
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -82,7 +83,8 @@ export enum FontAwesomeSpriteName {
|
|||||||
ReplicatedSecretKey = "secret-replicated",
|
ReplicatedSecretKey = "secret-replicated",
|
||||||
ShareSecret = "share-secret",
|
ShareSecret = "share-secret",
|
||||||
KeyLock = "key-lock",
|
KeyLock = "key-lock",
|
||||||
SecretReferenceTree = "secret-reference-tree"
|
SecretReferenceTree = "secret-reference-tree",
|
||||||
|
Trash = "trash"
|
||||||
}
|
}
|
||||||
|
|
||||||
// this is an optimization technique
|
// this is an optimization technique
|
||||||
@@ -101,5 +103,6 @@ export const FontAwesomeSpriteSymbols = [
|
|||||||
{ icon: faClone, symbol: FontAwesomeSpriteName.ReplicatedSecretKey },
|
{ icon: faClone, symbol: FontAwesomeSpriteName.ReplicatedSecretKey },
|
||||||
{ icon: faShare, symbol: FontAwesomeSpriteName.ShareSecret },
|
{ icon: faShare, symbol: FontAwesomeSpriteName.ShareSecret },
|
||||||
{ icon: faLock, symbol: FontAwesomeSpriteName.KeyLock },
|
{ icon: faLock, symbol: FontAwesomeSpriteName.KeyLock },
|
||||||
{ icon: faProjectDiagram, symbol: FontAwesomeSpriteName.SecretReferenceTree }
|
{ icon: faProjectDiagram, symbol: FontAwesomeSpriteName.SecretReferenceTree },
|
||||||
|
{ icon: faTrash, symbol: FontAwesomeSpriteName.Trash }
|
||||||
];
|
];
|
||||||
|
|||||||
+5
-7
@@ -2,11 +2,11 @@ import { useState } from "react";
|
|||||||
import { subject } from "@casl/ability";
|
import { subject } from "@casl/ability";
|
||||||
import {
|
import {
|
||||||
faAsterisk,
|
faAsterisk,
|
||||||
faClose,
|
|
||||||
faEdit,
|
faEdit,
|
||||||
faInfoCircle,
|
faInfoCircle,
|
||||||
faKey,
|
faKey,
|
||||||
faRotate
|
faRotate,
|
||||||
|
faTrash
|
||||||
} from "@fortawesome/free-solid-svg-icons";
|
} from "@fortawesome/free-solid-svg-icons";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
@@ -48,7 +48,7 @@ export const SecretRotationItem = ({
|
|||||||
<div className="text- flex w-11 items-center py-2 pl-5 text-mineshaft-400">
|
<div className="text- flex w-11 items-center py-2 pl-5 text-mineshaft-400">
|
||||||
<FontAwesomeIcon icon={faRotate} />
|
<FontAwesomeIcon icon={faRotate} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-grow items-center border-r border-mineshaft-600 py-2 pl-4 pr-2">
|
<div className="flex flex-grow items-center py-2 pl-4 pr-2">
|
||||||
<div className="flex w-full flex-wrap items-center">
|
<div className="flex w-full flex-wrap items-center">
|
||||||
<span>{name}</span>
|
<span>{name}</span>
|
||||||
<Tag className="mx-2.5 flex items-center gap-1 px-1.5 py-0 text-xs normal-case">
|
<Tag className="mx-2.5 flex items-center gap-1 px-1.5 py-0 text-xs normal-case">
|
||||||
@@ -141,7 +141,7 @@ export const SecretRotationItem = ({
|
|||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
<motion.div
|
<motion.div
|
||||||
key="options"
|
key="options"
|
||||||
className="flex h-10 flex-shrink-0 items-center space-x-1 px-[0.665rem]"
|
className="flex w-16 items-center justify-between border-l border-mineshaft-600 px-2 py-3"
|
||||||
initial={{ x: 0, opacity: 0 }}
|
initial={{ x: 0, opacity: 0 }}
|
||||||
animate={{ x: 0, opacity: 1 }}
|
animate={{ x: 0, opacity: 1 }}
|
||||||
exit={{ x: 10, opacity: 0 }}
|
exit={{ x: 10, opacity: 0 }}
|
||||||
@@ -159,7 +159,6 @@ export const SecretRotationItem = ({
|
|||||||
<IconButton
|
<IconButton
|
||||||
ariaLabel="Edit rotation"
|
ariaLabel="Edit rotation"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
size="md"
|
|
||||||
isDisabled={!isAllowed}
|
isDisabled={!isAllowed}
|
||||||
className="opacity-0 group-hover:opacity-100"
|
className="opacity-0 group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -185,7 +184,6 @@ export const SecretRotationItem = ({
|
|||||||
ariaLabel="Delete rotation"
|
ariaLabel="Delete rotation"
|
||||||
variant="plain"
|
variant="plain"
|
||||||
colorSchema="danger"
|
colorSchema="danger"
|
||||||
size="md"
|
|
||||||
className="opacity-0 group-hover:opacity-100"
|
className="opacity-0 group-hover:opacity-100"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -193,7 +191,7 @@ export const SecretRotationItem = ({
|
|||||||
}}
|
}}
|
||||||
isDisabled={!isAllowed}
|
isDisabled={!isAllowed}
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
<FontAwesomeIcon icon={faTrash} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</ProjectPermissionCan>
|
</ProjectPermissionCan>
|
||||||
|
|||||||
Reference in New Issue
Block a user