mirror of
https://github.com/Infisical/infisical.git
synced 2026-01-10 07:58:15 -05:00
Merge pull request #1018 from akhilmhdh/feat/dashboard-v3
Feat/dashboard v3
This commit is contained in:
@@ -2,7 +2,7 @@ import { Request, Response } from "express";
|
||||
import { isValidScope } from "../../helpers";
|
||||
import { Folder, IServiceTokenData, SecretImport, ServiceTokenData } from "../../models";
|
||||
import { getAllImportedSecrets } from "../../services/SecretImportService";
|
||||
import { getFolderWithPathFromId } from "../../services/FolderService";
|
||||
import { getFolderByPath, getFolderWithPathFromId } from "../../services/FolderService";
|
||||
import {
|
||||
BadRequestError,
|
||||
ResourceNotFoundError,
|
||||
@@ -95,37 +95,12 @@ export const createSecretImp = async (req: Request, res: Response) => {
|
||||
*/
|
||||
|
||||
const {
|
||||
body: { workspaceId, environment, folderId, secretImport }
|
||||
body: { workspaceId, environment, directory, secretImport }
|
||||
} = await validateRequest(reqValidator.CreateSecretImportV1, req);
|
||||
|
||||
const folders = await Folder.findOne({
|
||||
workspace: workspaceId,
|
||||
environment
|
||||
}).lean();
|
||||
|
||||
if (!folders && folderId !== "root") {
|
||||
throw ResourceNotFoundError({
|
||||
message: "Failed to find folder"
|
||||
});
|
||||
}
|
||||
|
||||
let secretPath = "/";
|
||||
if (folders) {
|
||||
const { folderPath } = getFolderWithPathFromId(folders.nodes, folderId);
|
||||
secretPath = folderPath;
|
||||
}
|
||||
|
||||
if (req.authData.authPayload instanceof ServiceTokenData) {
|
||||
// root check
|
||||
let isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath);
|
||||
if (!isValidScopeAccess) {
|
||||
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
||||
}
|
||||
isValidScopeAccess = isValidScope(
|
||||
req.authData.authPayload,
|
||||
secretImport.environment,
|
||||
secretImport.secretPath
|
||||
);
|
||||
const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory);
|
||||
if (!isValidScopeAccess) {
|
||||
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
||||
}
|
||||
@@ -133,27 +108,31 @@ export const createSecretImp = async (req: Request, res: Response) => {
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: secretImport.environment,
|
||||
secretPath: secretImport.secretPath
|
||||
})
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory })
|
||||
);
|
||||
}
|
||||
|
||||
const folders = await Folder.findOne({
|
||||
workspace: workspaceId,
|
||||
environment
|
||||
}).lean();
|
||||
|
||||
if (!folders && directory !== "/")
|
||||
throw ResourceNotFoundError({ message: "Failed to find folder" });
|
||||
|
||||
let folderId = "root";
|
||||
if (folders) {
|
||||
const folder = getFolderByPath(folders.nodes, directory);
|
||||
if (!folder) throw BadRequestError({ message: "Folder not found" });
|
||||
folderId = folder.id;
|
||||
}
|
||||
|
||||
const importSecDoc = await SecretImport.findOne({
|
||||
workspace: workspaceId,
|
||||
environment,
|
||||
folderId
|
||||
});
|
||||
|
||||
const importToSecretPath = folders
|
||||
? getFolderWithPathFromId(folders.nodes, folderId).folderPath
|
||||
: "/";
|
||||
|
||||
if (!importSecDoc) {
|
||||
const doc = new SecretImport({
|
||||
workspace: workspaceId,
|
||||
@@ -173,7 +152,7 @@ export const createSecretImp = async (req: Request, res: Response) => {
|
||||
importFromEnvironment: secretImport.environment,
|
||||
importFromSecretPath: secretImport.secretPath,
|
||||
importToEnvironment: environment,
|
||||
importToSecretPath
|
||||
importToSecretPath: directory
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -206,7 +185,7 @@ export const createSecretImp = async (req: Request, res: Response) => {
|
||||
importFromEnvironment: secretImport.environment,
|
||||
importFromSecretPath: secretImport.secretPath,
|
||||
importToEnvironment: environment,
|
||||
importToSecretPath
|
||||
importToSecretPath: directory
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -563,8 +542,38 @@ export const getSecretImports = async (req: Request, res: Response) => {
|
||||
}
|
||||
*/
|
||||
const {
|
||||
query: { workspaceId, environment, folderId }
|
||||
query: { workspaceId, environment, directory }
|
||||
} = await validateRequest(reqValidator.GetSecretImportsV1, req);
|
||||
|
||||
if (req.authData.authPayload instanceof ServiceTokenData) {
|
||||
const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory);
|
||||
if (!isValidScopeAccess) {
|
||||
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
||||
}
|
||||
} else {
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath: directory
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const folders = await Folder.findOne({
|
||||
workspace: workspaceId,
|
||||
environment
|
||||
}).lean();
|
||||
if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" });
|
||||
|
||||
let folderId = "root";
|
||||
if (folders) {
|
||||
const folder = getFolderByPath(folders.nodes, directory);
|
||||
if (!folder) throw BadRequestError({ message: "Folder not found" });
|
||||
folderId = folder.id;
|
||||
}
|
||||
|
||||
const importSecDoc = await SecretImport.findOne({
|
||||
workspace: workspaceId,
|
||||
environment,
|
||||
@@ -575,41 +584,6 @@ export const getSecretImports = async (req: Request, res: Response) => {
|
||||
return res.status(200).json({ secretImport: {} });
|
||||
}
|
||||
|
||||
// check for service token validity
|
||||
const folders = await Folder.findOne({
|
||||
workspace: importSecDoc.workspace,
|
||||
environment: importSecDoc.environment
|
||||
}).lean();
|
||||
|
||||
let secretPath = "/";
|
||||
if (folders) {
|
||||
const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId);
|
||||
secretPath = folderPath;
|
||||
}
|
||||
|
||||
if (req.authData.authPayload instanceof ServiceTokenData) {
|
||||
const isValidScopeAccess = isValidScope(
|
||||
req.authData.authPayload,
|
||||
importSecDoc.environment,
|
||||
secretPath
|
||||
);
|
||||
if (!isValidScopeAccess) {
|
||||
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
||||
}
|
||||
} else {
|
||||
const { permission } = await getUserProjectPermissions(
|
||||
req.user._id,
|
||||
importSecDoc.workspace.toString()
|
||||
);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment: importSecDoc.environment,
|
||||
secretPath
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(200).json({ secretImport: importSecDoc });
|
||||
};
|
||||
|
||||
@@ -621,9 +595,39 @@ export const getSecretImports = async (req: Request, res: Response) => {
|
||||
*/
|
||||
export const getAllSecretsFromImport = async (req: Request, res: Response) => {
|
||||
const {
|
||||
query: { workspaceId, environment, folderId }
|
||||
query: { workspaceId, environment, directory }
|
||||
} = await validateRequest(reqValidator.GetAllSecretsFromImportV1, req);
|
||||
|
||||
if (req.authData.authPayload instanceof ServiceTokenData) {
|
||||
// check for service token validity
|
||||
const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory);
|
||||
if (!isValidScopeAccess) {
|
||||
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
||||
}
|
||||
} else {
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath: directory
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const folders = await Folder.findOne({
|
||||
workspace: workspaceId,
|
||||
environment
|
||||
}).lean();
|
||||
if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" });
|
||||
|
||||
let folderId = "root";
|
||||
if (folders) {
|
||||
const folder = getFolderByPath(folders.nodes, directory);
|
||||
if (!folder) throw BadRequestError({ message: "Folder not found" });
|
||||
folderId = folder.id;
|
||||
}
|
||||
|
||||
const importSecDoc = await SecretImport.findOne({
|
||||
workspace: workspaceId,
|
||||
environment,
|
||||
@@ -634,11 +638,6 @@ export const getAllSecretsFromImport = async (req: Request, res: Response) => {
|
||||
return res.status(200).json({ secrets: [] });
|
||||
}
|
||||
|
||||
const folders = await Folder.findOne({
|
||||
workspace: importSecDoc.workspace,
|
||||
environment: importSecDoc.environment
|
||||
}).lean();
|
||||
|
||||
let secretPath = "/";
|
||||
if (folders) {
|
||||
const { folderPath } = getFolderWithPathFromId(folders.nodes, importSecDoc.folderId);
|
||||
|
||||
@@ -9,12 +9,10 @@ import { Secret, ServiceTokenData } from "../../models";
|
||||
import { Folder } from "../../models/folder";
|
||||
import {
|
||||
appendFolder,
|
||||
deleteFolderById,
|
||||
generateFolderId,
|
||||
getAllFolderIds,
|
||||
getFolderByPath,
|
||||
getFolderWithPathFromId,
|
||||
getParentFromFolderId,
|
||||
validateFolderName
|
||||
} from "../../services/FolderService";
|
||||
import {
|
||||
@@ -25,13 +23,9 @@ import {
|
||||
import { BadRequestError, UnauthorizedRequestError } from "../../utils/errors";
|
||||
import * as reqValidator from "../../validation/folders";
|
||||
|
||||
/**
|
||||
* Create folder with name [folderName] for workspace with id [workspaceId]
|
||||
* and environment [environment]
|
||||
* @param req
|
||||
* @param res
|
||||
* @returns
|
||||
*/
|
||||
const ERR_FOLDER_NOT_FOUND = BadRequestError({ message: "The folder doesn't exist" });
|
||||
|
||||
// verify workspace id/environment
|
||||
export const createFolder = async (req: Request, res: Response) => {
|
||||
/*
|
||||
#swagger.summary = 'Create a folder'
|
||||
@@ -107,7 +101,7 @@ export const createFolder = async (req: Request, res: Response) => {
|
||||
}
|
||||
*/
|
||||
const {
|
||||
body: { workspaceId, environment, folderName, parentFolderId }
|
||||
body: { workspaceId, environment, folderName, directory }
|
||||
} = await validateRequest(reqValidator.CreateFolderV1, req);
|
||||
|
||||
if (!validateFolderName(folderName)) {
|
||||
@@ -116,33 +110,29 @@ export const createFolder = async (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (req.authData.authPayload instanceof ServiceTokenData) {
|
||||
// token check
|
||||
const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory);
|
||||
if (!isValidScopeAccess) {
|
||||
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
||||
}
|
||||
} else {
|
||||
// user check
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory })
|
||||
);
|
||||
}
|
||||
|
||||
const folders = await Folder.findOne({
|
||||
workspace: workspaceId,
|
||||
environment
|
||||
}).lean();
|
||||
|
||||
if (req.user) {
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
const secretPath =
|
||||
folders && parentFolderId
|
||||
? getFolderWithPathFromId(folders.nodes, parentFolderId).folderPath
|
||||
: "/";
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
}
|
||||
|
||||
// space has no folders initialized
|
||||
|
||||
if (!folders) {
|
||||
if (req.authData.authPayload instanceof ServiceTokenData) {
|
||||
// root check
|
||||
const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, "/");
|
||||
if (!isValidScopeAccess) {
|
||||
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
||||
}
|
||||
}
|
||||
if (directory !== "/") throw ERR_FOLDER_NOT_FOUND;
|
||||
|
||||
const id = generateFolderId();
|
||||
const folder = new Folder({
|
||||
@@ -186,27 +176,10 @@ export const createFolder = async (req: Request, res: Response) => {
|
||||
return res.json({ folder: { id, name: folderName } });
|
||||
}
|
||||
|
||||
const folder = appendFolder(folders.nodes, { folderName, parentFolderId });
|
||||
|
||||
await Folder.findByIdAndUpdate(folders._id, folders);
|
||||
|
||||
const { folder: parentFolder, folderPath: parentFolderPath } = getFolderWithPathFromId(
|
||||
folders.nodes,
|
||||
parentFolderId || "root"
|
||||
);
|
||||
|
||||
if (req.authData.authPayload instanceof ServiceTokenData) {
|
||||
// root check
|
||||
const isValidScopeAccess = isValidScope(
|
||||
req.authData.authPayload,
|
||||
environment,
|
||||
parentFolderPath
|
||||
);
|
||||
if (!isValidScopeAccess) {
|
||||
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
||||
}
|
||||
}
|
||||
const parentFolder = getFolderByPath(folders.nodes, directory);
|
||||
if (!parentFolder) throw ERR_FOLDER_NOT_FOUND;
|
||||
|
||||
const folder = appendFolder(folders.nodes, { folderName, parentFolderId: parentFolder.id });
|
||||
await Folder.findByIdAndUpdate(folders._id, folders);
|
||||
|
||||
const folderVersion = new FolderVersion({
|
||||
@@ -219,11 +192,9 @@ export const createFolder = async (req: Request, res: Response) => {
|
||||
await EESecretService.takeSecretSnapshot({
|
||||
workspaceId: new Types.ObjectId(workspaceId),
|
||||
environment,
|
||||
folderId: parentFolderId
|
||||
folderId: parentFolder.id
|
||||
});
|
||||
|
||||
const { folderPath } = getFolderWithPathFromId(folders.nodes, folder.id);
|
||||
|
||||
await EEAuditLogService.createAuditLog(
|
||||
req.authData,
|
||||
{
|
||||
@@ -232,7 +203,7 @@ export const createFolder = async (req: Request, res: Response) => {
|
||||
environment,
|
||||
folderId: folder.id,
|
||||
folderName,
|
||||
folderPath
|
||||
folderPath: directory
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -332,8 +303,8 @@ export const updateFolderById = async (req: Request, res: Response) => {
|
||||
}
|
||||
*/
|
||||
const {
|
||||
body: { workspaceId, environment, name },
|
||||
params: { folderId }
|
||||
body: { workspaceId, environment, name, directory },
|
||||
params: { folderName }
|
||||
} = await validateRequest(reqValidator.UpdateFolderV1, req);
|
||||
|
||||
if (!validateFolderName(name)) {
|
||||
@@ -342,38 +313,31 @@ export const updateFolderById = async (req: Request, res: Response) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (req.authData.authPayload instanceof ServiceTokenData) {
|
||||
const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory);
|
||||
if (!isValidScopeAccess) {
|
||||
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
||||
}
|
||||
} else {
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory })
|
||||
);
|
||||
}
|
||||
|
||||
const folders = await Folder.findOne({ workspace: workspaceId, environment });
|
||||
if (!folders) {
|
||||
throw BadRequestError({ message: "The folder doesn't exist" });
|
||||
}
|
||||
|
||||
const parentFolder = getParentFromFolderId(folders.nodes, folderId);
|
||||
const parentFolder = getFolderByPath(folders.nodes, directory);
|
||||
if (!parentFolder) {
|
||||
throw BadRequestError({ message: "The folder doesn't exist" });
|
||||
}
|
||||
|
||||
if (req.user) {
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
const secretPath = getFolderWithPathFromId(folders.nodes, parentFolder.id).folderPath;
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
}
|
||||
|
||||
const folder = parentFolder.children.find(({ id }) => id === folderId);
|
||||
if (!folder) {
|
||||
throw BadRequestError({ message: "The folder doesn't exist" });
|
||||
}
|
||||
|
||||
if (req.authData.authPayload instanceof ServiceTokenData) {
|
||||
const { folderPath: secretPath } = getFolderWithPathFromId(folders.nodes, parentFolder.id);
|
||||
// root check
|
||||
const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath);
|
||||
if (!isValidScopeAccess) {
|
||||
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
||||
}
|
||||
}
|
||||
const folder = parentFolder.children.find(({ name }) => name === folderName);
|
||||
if (!folder) throw ERR_FOLDER_NOT_FOUND;
|
||||
|
||||
const oldFolderName = folder.name;
|
||||
parentFolder.version += 1;
|
||||
@@ -505,24 +469,12 @@ export const deleteFolder = async (req: Request, res: Response) => {
|
||||
}
|
||||
*/
|
||||
const {
|
||||
params: { folderId },
|
||||
body: { environment, workspaceId }
|
||||
params: { folderName },
|
||||
body: { environment, workspaceId, directory }
|
||||
} = await validateRequest(reqValidator.DeleteFolderV1, req);
|
||||
|
||||
const folders = await Folder.findOne({ workspace: workspaceId, environment });
|
||||
if (!folders) {
|
||||
throw BadRequestError({ message: "The folder doesn't exist" });
|
||||
}
|
||||
|
||||
const delOp = deleteFolderById(folders.nodes, folderId);
|
||||
if (!delOp) {
|
||||
throw BadRequestError({ message: "The folder doesn't exist" });
|
||||
}
|
||||
const { deletedNode: delFolder, parent: parentFolder } = delOp;
|
||||
const { folderPath: secretPath } = getFolderWithPathFromId(folders.nodes, parentFolder.id);
|
||||
|
||||
if (req.authData.authPayload instanceof ServiceTokenData) {
|
||||
const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, secretPath);
|
||||
const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory);
|
||||
if (!isValidScopeAccess) {
|
||||
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
||||
}
|
||||
@@ -531,12 +483,23 @@ export const deleteFolder = async (req: Request, res: Response) => {
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath: directory })
|
||||
);
|
||||
}
|
||||
|
||||
const folders = await Folder.findOne({ workspace: workspaceId, environment });
|
||||
if (!folders) throw ERR_FOLDER_NOT_FOUND;
|
||||
|
||||
const parentFolder = getFolderByPath(folders.nodes, directory);
|
||||
if (!parentFolder) throw ERR_FOLDER_NOT_FOUND;
|
||||
|
||||
const index = parentFolder.children.findIndex(({ name }) => name === folderName);
|
||||
if (index === -1) throw ERR_FOLDER_NOT_FOUND;
|
||||
|
||||
const deletedFolder = parentFolder.children.splice(index, 1)[0];
|
||||
|
||||
parentFolder.version += 1;
|
||||
const delFolderIds = getAllFolderIds(delFolder);
|
||||
const delFolderIds = getAllFolderIds(deletedFolder);
|
||||
|
||||
await Folder.findByIdAndUpdate(folders._id, folders);
|
||||
const folderVersion = new FolderVersion({
|
||||
@@ -565,9 +528,9 @@ export const deleteFolder = async (req: Request, res: Response) => {
|
||||
type: EventType.DELETE_FOLDER,
|
||||
metadata: {
|
||||
environment,
|
||||
folderId,
|
||||
folderName: delFolder.name,
|
||||
folderPath: secretPath
|
||||
folderId: deletedFolder.id,
|
||||
folderName: deletedFolder.name,
|
||||
folderPath: directory
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -575,7 +538,7 @@ export const deleteFolder = async (req: Request, res: Response) => {
|
||||
}
|
||||
);
|
||||
|
||||
res.send({ message: "successfully deleted folders", folders: delFolderIds });
|
||||
return res.send({ message: "successfully deleted folders", folders: delFolderIds });
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -677,69 +640,27 @@ export const getFolders = async (req: Request, res: Response) => {
|
||||
}
|
||||
*/
|
||||
const {
|
||||
query: { workspaceId, environment, parentFolderId, parentFolderPath }
|
||||
query: { workspaceId, environment, directory }
|
||||
} = await validateRequest(reqValidator.GetFoldersV1, req);
|
||||
|
||||
const folders = await Folder.findOne({ workspace: workspaceId, environment });
|
||||
|
||||
if (req.user) await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
|
||||
if (!folders) {
|
||||
res.send({ folders: [], dir: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
// if instead of parentFolderId given a path like /folder1/folder2
|
||||
if (parentFolderPath) {
|
||||
if (req.authData.authPayload instanceof ServiceTokenData) {
|
||||
const isValidScopeAccess = isValidScope(
|
||||
req.authData.authPayload,
|
||||
environment,
|
||||
parentFolderPath
|
||||
);
|
||||
if (!isValidScopeAccess) {
|
||||
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
||||
}
|
||||
}
|
||||
const folder = getFolderByPath(folders.nodes, parentFolderPath);
|
||||
|
||||
if (!folder) {
|
||||
res.send({ folders: [], dir: [] });
|
||||
return;
|
||||
}
|
||||
// dir is not needed at present as this is only used in overview section of secrets
|
||||
res.send({
|
||||
folders: folder.children.map(({ id, name }) => ({ id, name })),
|
||||
dir: [{ name: folder.name, id: folder.id }]
|
||||
});
|
||||
}
|
||||
|
||||
if (!parentFolderId) {
|
||||
if (req.authData.authPayload instanceof ServiceTokenData) {
|
||||
const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, "/");
|
||||
if (!isValidScopeAccess) {
|
||||
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
||||
}
|
||||
}
|
||||
|
||||
const rootFolders = folders.nodes.children.map(({ id, name }) => ({
|
||||
id,
|
||||
name
|
||||
}));
|
||||
res.send({ folders: rootFolders });
|
||||
return;
|
||||
}
|
||||
|
||||
const { folder, folderPath, dir } = getFolderWithPathFromId(folders.nodes, parentFolderId);
|
||||
if (req.authData.authPayload instanceof ServiceTokenData) {
|
||||
const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, folderPath);
|
||||
const isValidScopeAccess = isValidScope(req.authData.authPayload, environment, directory);
|
||||
if (!isValidScopeAccess) {
|
||||
throw UnauthorizedRequestError({ message: "Folder Permission Denied" });
|
||||
}
|
||||
} else {
|
||||
// check that user is a member of the workspace
|
||||
await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
}
|
||||
|
||||
res.send({
|
||||
folders: folder.children.map(({ id, name }) => ({ id, name })),
|
||||
dir
|
||||
const folders = await Folder.findOne({ workspace: workspaceId, environment });
|
||||
if (!folders) {
|
||||
return res.send({ folders: [], dir: [] });
|
||||
}
|
||||
|
||||
const folder = getFolderByPath(folders.nodes, directory);
|
||||
|
||||
return res.send({
|
||||
folders: folder?.children?.map(({ id, name }) => ({ id, name })) || []
|
||||
});
|
||||
};
|
||||
|
||||
@@ -196,7 +196,15 @@ export const getSecretByNameRaw = async (req: Request, res: Response) => {
|
||||
export const createSecretRaw = async (req: Request, res: Response) => {
|
||||
const {
|
||||
params: { secretName },
|
||||
body: { secretPath, environment, workspaceId, type, secretValue, secretComment }
|
||||
body: {
|
||||
secretPath,
|
||||
environment,
|
||||
workspaceId,
|
||||
type,
|
||||
secretValue,
|
||||
secretComment,
|
||||
skipMultilineEncoding
|
||||
}
|
||||
} = await validateRequest(reqValidator.CreateSecretRawV3, req);
|
||||
|
||||
if (req.user?._id) {
|
||||
@@ -249,7 +257,8 @@ export const createSecretRaw = async (req: Request, res: Response) => {
|
||||
secretPath,
|
||||
secretCommentCiphertext: secretCommentEncrypted.ciphertext,
|
||||
secretCommentIV: secretCommentEncrypted.iv,
|
||||
secretCommentTag: secretCommentEncrypted.tag
|
||||
secretCommentTag: secretCommentEncrypted.tag,
|
||||
skipMultilineEncoding
|
||||
});
|
||||
|
||||
await EventService.handleEvent({
|
||||
@@ -279,7 +288,7 @@ export const createSecretRaw = async (req: Request, res: Response) => {
|
||||
export const updateSecretByNameRaw = async (req: Request, res: Response) => {
|
||||
const {
|
||||
params: { secretName },
|
||||
body: { secretValue, environment, secretPath, type, workspaceId }
|
||||
body: { secretValue, environment, secretPath, type, workspaceId, skipMultilineEncoding }
|
||||
} = await validateRequest(reqValidator.UpdateSecretByNameRawV3, req);
|
||||
|
||||
if (req.user?._id) {
|
||||
@@ -316,7 +325,8 @@ export const updateSecretByNameRaw = async (req: Request, res: Response) => {
|
||||
secretValueCiphertext: secretValueEncrypted.ciphertext,
|
||||
secretValueIV: secretValueEncrypted.iv,
|
||||
secretValueTag: secretValueEncrypted.tag,
|
||||
secretPath
|
||||
secretPath,
|
||||
skipMultilineEncoding
|
||||
});
|
||||
|
||||
await EventService.handleEvent({
|
||||
@@ -540,7 +550,8 @@ export const createSecret = async (req: Request, res: Response) => {
|
||||
secretCommentTag,
|
||||
secretKeyCiphertext,
|
||||
secretValueCiphertext,
|
||||
secretCommentCiphertext
|
||||
secretCommentCiphertext,
|
||||
skipMultilineEncoding
|
||||
},
|
||||
params: { secretName }
|
||||
} = await validateRequest(reqValidator.CreateSecretV3, req);
|
||||
@@ -577,7 +588,8 @@ export const createSecret = async (req: Request, res: Response) => {
|
||||
secretCommentCiphertext,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
metadata
|
||||
metadata,
|
||||
skipMultilineEncoding
|
||||
});
|
||||
|
||||
await EventService.handleEvent({
|
||||
@@ -610,11 +622,23 @@ export const updateSecretByName = async (req: Request, res: Response) => {
|
||||
type,
|
||||
environment,
|
||||
secretPath,
|
||||
workspaceId
|
||||
workspaceId,
|
||||
tags,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
secretCommentCiphertext,
|
||||
secretName: newSecretName,
|
||||
secretKeyIV,
|
||||
secretKeyTag,
|
||||
secretKeyCiphertext,
|
||||
skipMultilineEncoding
|
||||
},
|
||||
params: { secretName }
|
||||
} = await validateRequest(reqValidator.UpdateSecretByNameV3, req);
|
||||
|
||||
if (newSecretName && (!secretKeyIV || !secretKeyTag || !secretKeyCiphertext))
|
||||
throw BadRequestError({ message: "Missing encrypted key" });
|
||||
|
||||
if (req.user?._id) {
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
@@ -637,10 +661,19 @@ export const updateSecretByName = async (req: Request, res: Response) => {
|
||||
environment,
|
||||
type,
|
||||
authData: req.authData,
|
||||
newSecretName,
|
||||
secretValueCiphertext,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretPath
|
||||
secretPath,
|
||||
tags,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
secretCommentCiphertext,
|
||||
skipMultilineEncoding,
|
||||
secretKeyTag,
|
||||
secretKeyCiphertext,
|
||||
secretKeyIV
|
||||
});
|
||||
|
||||
await EventService.handleEvent({
|
||||
@@ -704,3 +737,105 @@ export const deleteSecretByName = async (req: Request, res: Response) => {
|
||||
secret
|
||||
});
|
||||
};
|
||||
|
||||
export const createSecretByNameBatch = async (req: Request, res: Response) => {
|
||||
const {
|
||||
body: { secrets, secretPath, environment, workspaceId }
|
||||
} = await validateRequest(reqValidator.CreateSecretByNameBatchV3, req);
|
||||
|
||||
if (req.user?._id) {
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Create,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
} else {
|
||||
await validateServiceTokenDataClientForWorkspace({
|
||||
serviceTokenData: req.authData.authPayload as IServiceTokenData,
|
||||
workspaceId: new Types.ObjectId(workspaceId),
|
||||
environment,
|
||||
secretPath,
|
||||
requiredPermissions: [PERMISSION_WRITE_SECRETS]
|
||||
});
|
||||
}
|
||||
|
||||
const createdSecrets = await SecretService.createSecretBatch({
|
||||
secretPath,
|
||||
environment,
|
||||
workspaceId: new Types.ObjectId(workspaceId),
|
||||
secrets,
|
||||
authData: req.authData
|
||||
});
|
||||
|
||||
return res.status(200).send({
|
||||
secrets: createdSecrets
|
||||
});
|
||||
};
|
||||
|
||||
export const updateSecretByNameBatch = async (req: Request, res: Response) => {
|
||||
const {
|
||||
body: { secrets, secretPath, environment, workspaceId }
|
||||
} = await validateRequest(reqValidator.UpdateSecretByNameBatchV3, req);
|
||||
|
||||
if (req.user?._id) {
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
} else {
|
||||
await validateServiceTokenDataClientForWorkspace({
|
||||
serviceTokenData: req.authData.authPayload as IServiceTokenData,
|
||||
workspaceId: new Types.ObjectId(workspaceId),
|
||||
environment,
|
||||
secretPath,
|
||||
requiredPermissions: [PERMISSION_WRITE_SECRETS]
|
||||
});
|
||||
}
|
||||
|
||||
const updatedSecrets = await SecretService.updateSecretBatch({
|
||||
secretPath,
|
||||
environment,
|
||||
workspaceId: new Types.ObjectId(workspaceId),
|
||||
secrets,
|
||||
authData: req.authData
|
||||
});
|
||||
|
||||
return res.status(200).send({
|
||||
secrets: updatedSecrets
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteSecretByNameBatch = async (req: Request, res: Response) => {
|
||||
const {
|
||||
body: { secrets, secretPath, environment, workspaceId }
|
||||
} = await validateRequest(reqValidator.DeleteSecretByNameBatchV3, req);
|
||||
|
||||
if (req.user?._id) {
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
ForbiddenError.from(permission).throwUnlessCan(
|
||||
ProjectPermissionActions.Delete,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
} else {
|
||||
await validateServiceTokenDataClientForWorkspace({
|
||||
serviceTokenData: req.authData.authPayload as IServiceTokenData,
|
||||
workspaceId: new Types.ObjectId(workspaceId),
|
||||
environment,
|
||||
secretPath,
|
||||
requiredPermissions: [PERMISSION_WRITE_SECRETS]
|
||||
});
|
||||
}
|
||||
|
||||
const deletedSecrets = await SecretService.deleteSecretBatch({
|
||||
secretPath,
|
||||
environment,
|
||||
workspaceId: new Types.ObjectId(workspaceId),
|
||||
secrets,
|
||||
authData: req.authData
|
||||
});
|
||||
|
||||
return res.status(200).send({
|
||||
secrets: deletedSecrets
|
||||
});
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { EESecretService } from "../../services";
|
||||
import { getLatestSecretVersionIds } from "../../helpers/secretVersion";
|
||||
// import Folder, { TFolderSchema } from "../../../models/folder";
|
||||
import { searchByFolderId } from "../../../services/FolderService";
|
||||
import { getFolderByPath, searchByFolderId } from "../../../services/FolderService";
|
||||
import { EEAuditLogService, EELicenseService } from "../../services";
|
||||
import { extractIPDetails, isValidIpOrCidr } from "../../../utils/ip";
|
||||
import { validateRequest } from "../../../helpers/validation";
|
||||
@@ -104,7 +104,7 @@ export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) =
|
||||
*/
|
||||
const {
|
||||
params: { workspaceId },
|
||||
query: { environment, folderId, offset, limit }
|
||||
query: { environment, directory, offset, limit }
|
||||
} = await validateRequest(GetWorkspaceSecretSnapshotsV1, req);
|
||||
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
@@ -113,10 +113,20 @@ export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) =
|
||||
ProjectPermissionSub.SecretRollback
|
||||
);
|
||||
|
||||
let folderId = "root";
|
||||
const folders = await Folder.findOne({ workspace: workspaceId, environment });
|
||||
if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" });
|
||||
|
||||
if (folders) {
|
||||
const folder = getFolderByPath(folders?.nodes, directory);
|
||||
if (!folder) throw BadRequestError({ message: "Invalid folder id" });
|
||||
folderId = folder.id;
|
||||
}
|
||||
|
||||
const secretSnapshots = await SecretSnapshot.find({
|
||||
workspace: workspaceId,
|
||||
environment,
|
||||
folderId: folderId || "root"
|
||||
folderId
|
||||
})
|
||||
.sort({ createdAt: -1 })
|
||||
.skip(offset)
|
||||
@@ -135,7 +145,7 @@ export const getWorkspaceSecretSnapshots = async (req: Request, res: Response) =
|
||||
export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Response) => {
|
||||
const {
|
||||
params: { workspaceId },
|
||||
query: { environment, folderId }
|
||||
query: { environment, directory }
|
||||
} = await validateRequest(GetWorkspaceSecretSnapshotsCountV1, req);
|
||||
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
@@ -144,10 +154,20 @@ export const getWorkspaceSecretSnapshotsCount = async (req: Request, res: Respon
|
||||
ProjectPermissionSub.SecretRollback
|
||||
);
|
||||
|
||||
let folderId = "root";
|
||||
const folders = await Folder.findOne({ workspace: workspaceId, environment });
|
||||
if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" });
|
||||
|
||||
if (folders) {
|
||||
const folder = getFolderByPath(folders?.nodes, directory);
|
||||
if (!folder) throw BadRequestError({ message: "Invalid folder id" });
|
||||
folderId = folder.id;
|
||||
}
|
||||
|
||||
const count = await SecretSnapshot.countDocuments({
|
||||
workspace: workspaceId,
|
||||
environment,
|
||||
folderId: folderId || "root"
|
||||
folderId
|
||||
});
|
||||
|
||||
return res.status(200).send({
|
||||
@@ -215,7 +235,7 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons
|
||||
|
||||
const {
|
||||
params: { workspaceId },
|
||||
body: { folderId, environment, version }
|
||||
body: { directory, environment, version }
|
||||
} = await validateRequest(RollbackWorkspaceSecretSnapshotV1, req);
|
||||
|
||||
const { permission } = await getUserProjectPermissions(req.user._id, workspaceId);
|
||||
@@ -224,6 +244,16 @@ export const rollbackWorkspaceSecretSnapshot = async (req: Request, res: Respons
|
||||
ProjectPermissionSub.SecretRollback
|
||||
);
|
||||
|
||||
let folderId = "root";
|
||||
const folders = await Folder.findOne({ workspace: workspaceId, environment });
|
||||
if (!folders && directory !== "/") throw BadRequestError({ message: "Folder not found" });
|
||||
|
||||
if (folders) {
|
||||
const folder = getFolderByPath(folders?.nodes, directory);
|
||||
if (!folder) throw BadRequestError({ message: "Invalid folder id" });
|
||||
folderId = folder.id;
|
||||
}
|
||||
|
||||
// validate secret snapshot
|
||||
const secretSnapshot = await SecretSnapshot.findOne({
|
||||
workspace: workspaceId,
|
||||
|
||||
@@ -1,47 +1,50 @@
|
||||
export enum ActorType {
|
||||
USER = "user",
|
||||
SERVICE = "service"
|
||||
USER = "user",
|
||||
SERVICE = "service"
|
||||
}
|
||||
|
||||
export enum UserAgentType {
|
||||
WEB = "web",
|
||||
CLI = "cli",
|
||||
K8_OPERATOR = "k8-operator",
|
||||
OTHER = "other"
|
||||
WEB = "web",
|
||||
CLI = "cli",
|
||||
K8_OPERATOR = "k8-operator",
|
||||
OTHER = "other"
|
||||
}
|
||||
|
||||
export enum EventType {
|
||||
GET_SECRETS = "get-secrets",
|
||||
GET_SECRET = "get-secret",
|
||||
REVEAL_SECRET = "reveal-secret",
|
||||
CREATE_SECRET = "create-secret",
|
||||
UPDATE_SECRET = "update-secret",
|
||||
DELETE_SECRET = "delete-secret",
|
||||
GET_WORKSPACE_KEY = "get-workspace-key",
|
||||
AUTHORIZE_INTEGRATION = "authorize-integration",
|
||||
UNAUTHORIZE_INTEGRATION = "unauthorize-integration",
|
||||
CREATE_INTEGRATION = "create-integration",
|
||||
DELETE_INTEGRATION = "delete-integration",
|
||||
ADD_TRUSTED_IP = "add-trusted-ip",
|
||||
UPDATE_TRUSTED_IP = "update-trusted-ip",
|
||||
DELETE_TRUSTED_IP = "delete-trusted-ip",
|
||||
CREATE_SERVICE_TOKEN = "create-service-token",
|
||||
DELETE_SERVICE_TOKEN = "delete-service-token",
|
||||
CREATE_ENVIRONMENT = "create-environment",
|
||||
UPDATE_ENVIRONMENT = "update-environment",
|
||||
DELETE_ENVIRONMENT = "delete-environment",
|
||||
ADD_WORKSPACE_MEMBER = "add-workspace-member",
|
||||
REMOVE_WORKSPACE_MEMBER = "remove-workspace-member",
|
||||
CREATE_FOLDER = "create-folder",
|
||||
UPDATE_FOLDER = "update-folder",
|
||||
DELETE_FOLDER = "delete-folder",
|
||||
CREATE_WEBHOOK = "create-webhook",
|
||||
UPDATE_WEBHOOK_STATUS = "update-webhook-status",
|
||||
DELETE_WEBHOOK = "delete-webhook",
|
||||
GET_SECRET_IMPORTS = "get-secret-imports",
|
||||
CREATE_SECRET_IMPORT = "create-secret-import",
|
||||
UPDATE_SECRET_IMPORT = "update-secret-import",
|
||||
DELETE_SECRET_IMPORT = "delete-secret-import",
|
||||
UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role",
|
||||
UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions"
|
||||
}
|
||||
GET_SECRETS = "get-secrets",
|
||||
GET_SECRET = "get-secret",
|
||||
REVEAL_SECRET = "reveal-secret",
|
||||
CREATE_SECRET = "create-secret",
|
||||
CREATE_SECRETS = "create-secrets",
|
||||
UPDATE_SECRET = "update-secret",
|
||||
UPDATE_SECRETS = "update-secrets",
|
||||
DELETE_SECRET = "delete-secret",
|
||||
DELETE_SECRETS = "delete-secrets",
|
||||
GET_WORKSPACE_KEY = "get-workspace-key",
|
||||
AUTHORIZE_INTEGRATION = "authorize-integration",
|
||||
UNAUTHORIZE_INTEGRATION = "unauthorize-integration",
|
||||
CREATE_INTEGRATION = "create-integration",
|
||||
DELETE_INTEGRATION = "delete-integration",
|
||||
ADD_TRUSTED_IP = "add-trusted-ip",
|
||||
UPDATE_TRUSTED_IP = "update-trusted-ip",
|
||||
DELETE_TRUSTED_IP = "delete-trusted-ip",
|
||||
CREATE_SERVICE_TOKEN = "create-service-token",
|
||||
DELETE_SERVICE_TOKEN = "delete-service-token",
|
||||
CREATE_ENVIRONMENT = "create-environment",
|
||||
UPDATE_ENVIRONMENT = "update-environment",
|
||||
DELETE_ENVIRONMENT = "delete-environment",
|
||||
ADD_WORKSPACE_MEMBER = "add-workspace-member",
|
||||
REMOVE_WORKSPACE_MEMBER = "remove-workspace-member",
|
||||
CREATE_FOLDER = "create-folder",
|
||||
UPDATE_FOLDER = "update-folder",
|
||||
DELETE_FOLDER = "delete-folder",
|
||||
CREATE_WEBHOOK = "create-webhook",
|
||||
UPDATE_WEBHOOK_STATUS = "update-webhook-status",
|
||||
DELETE_WEBHOOK = "delete-webhook",
|
||||
GET_SECRET_IMPORTS = "get-secret-imports",
|
||||
CREATE_SECRET_IMPORT = "create-secret-import",
|
||||
UPDATE_SECRET_IMPORT = "update-secret-import",
|
||||
DELETE_SECRET_IMPORT = "delete-secret-import",
|
||||
UPDATE_USER_WORKSPACE_ROLE = "update-user-workspace-role",
|
||||
UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS = "update-user-workspace-denied-permissions"
|
||||
}
|
||||
|
||||
@@ -1,403 +1,428 @@
|
||||
import {
|
||||
ActorType,
|
||||
EventType
|
||||
} from "./enums";
|
||||
import { ActorType, EventType } from "./enums";
|
||||
|
||||
interface UserActorMetadata {
|
||||
userId: string;
|
||||
email: string;
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface ServiceActorMetadata {
|
||||
serviceId: string;
|
||||
name: string;
|
||||
serviceId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserActor {
|
||||
type: ActorType.USER;
|
||||
metadata: UserActorMetadata;
|
||||
type: ActorType.USER;
|
||||
metadata: UserActorMetadata;
|
||||
}
|
||||
|
||||
export interface ServiceActor {
|
||||
type: ActorType.SERVICE;
|
||||
metadata: ServiceActorMetadata;
|
||||
type: ActorType.SERVICE;
|
||||
metadata: ServiceActorMetadata;
|
||||
}
|
||||
|
||||
export type Actor =
|
||||
| UserActor
|
||||
| ServiceActor;
|
||||
export type Actor = UserActor | ServiceActor;
|
||||
|
||||
interface GetSecretsEvent {
|
||||
type: EventType.GET_SECRETS;
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
numberOfSecrets: number;
|
||||
};
|
||||
type: EventType.GET_SECRETS;
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
numberOfSecrets: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetSecretEvent {
|
||||
type: EventType.GET_SECRET;
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secretId: string;
|
||||
secretKey: string;
|
||||
secretVersion: number;
|
||||
};
|
||||
type: EventType.GET_SECRET;
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secretId: string;
|
||||
secretKey: string;
|
||||
secretVersion: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateSecretEvent {
|
||||
type: EventType.CREATE_SECRET;
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secretId: string;
|
||||
secretKey: string;
|
||||
secretVersion: number;
|
||||
}
|
||||
type: EventType.CREATE_SECRET;
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secretId: string;
|
||||
secretKey: string;
|
||||
secretVersion: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateSecretBatchEvent {
|
||||
type: EventType.CREATE_SECRETS;
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateSecretEvent {
|
||||
type: EventType.UPDATE_SECRET;
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secretId: string;
|
||||
secretKey: string;
|
||||
secretVersion: number;
|
||||
}
|
||||
type: EventType.UPDATE_SECRET;
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secretId: string;
|
||||
secretKey: string;
|
||||
secretVersion: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateSecretBatchEvent {
|
||||
type: EventType.UPDATE_SECRETS;
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteSecretEvent {
|
||||
type: EventType.DELETE_SECRET;
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secretId: string;
|
||||
secretKey: string;
|
||||
secretVersion: number;
|
||||
}
|
||||
type: EventType.DELETE_SECRET;
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secretId: string;
|
||||
secretKey: string;
|
||||
secretVersion: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteSecretBatchEvent {
|
||||
type: EventType.DELETE_SECRETS;
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secrets: Array<{ secretId: string; secretKey: string; secretVersion: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetWorkspaceKeyEvent {
|
||||
type: EventType.GET_WORKSPACE_KEY,
|
||||
metadata: {
|
||||
keyId: string;
|
||||
}
|
||||
type: EventType.GET_WORKSPACE_KEY;
|
||||
metadata: {
|
||||
keyId: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AuthorizeIntegrationEvent {
|
||||
type: EventType.AUTHORIZE_INTEGRATION;
|
||||
metadata: {
|
||||
integration: string;
|
||||
}
|
||||
type: EventType.AUTHORIZE_INTEGRATION;
|
||||
metadata: {
|
||||
integration: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UnauthorizeIntegrationEvent {
|
||||
type: EventType.UNAUTHORIZE_INTEGRATION;
|
||||
metadata: {
|
||||
integration: string;
|
||||
}
|
||||
type: EventType.UNAUTHORIZE_INTEGRATION;
|
||||
metadata: {
|
||||
integration: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateIntegrationEvent {
|
||||
type: EventType.CREATE_INTEGRATION;
|
||||
metadata: {
|
||||
integrationId: string;
|
||||
integration: string; // TODO: fix type
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
url?: string;
|
||||
app?: string;
|
||||
appId?: string;
|
||||
targetEnvironment?: string;
|
||||
targetEnvironmentId?: string;
|
||||
targetService?: string;
|
||||
targetServiceId?: string;
|
||||
path?: string;
|
||||
region?: string;
|
||||
}
|
||||
type: EventType.CREATE_INTEGRATION;
|
||||
metadata: {
|
||||
integrationId: string;
|
||||
integration: string; // TODO: fix type
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
url?: string;
|
||||
app?: string;
|
||||
appId?: string;
|
||||
targetEnvironment?: string;
|
||||
targetEnvironmentId?: string;
|
||||
targetService?: string;
|
||||
targetServiceId?: string;
|
||||
path?: string;
|
||||
region?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteIntegrationEvent {
|
||||
type: EventType.DELETE_INTEGRATION;
|
||||
metadata: {
|
||||
integrationId: string;
|
||||
integration: string; // TODO: fix type
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
url?: string;
|
||||
app?: string;
|
||||
appId?: string;
|
||||
targetEnvironment?: string;
|
||||
targetEnvironmentId?: string;
|
||||
targetService?: string;
|
||||
targetServiceId?: string;
|
||||
path?: string;
|
||||
region?: string;
|
||||
}
|
||||
type: EventType.DELETE_INTEGRATION;
|
||||
metadata: {
|
||||
integrationId: string;
|
||||
integration: string; // TODO: fix type
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
url?: string;
|
||||
app?: string;
|
||||
appId?: string;
|
||||
targetEnvironment?: string;
|
||||
targetEnvironmentId?: string;
|
||||
targetService?: string;
|
||||
targetServiceId?: string;
|
||||
path?: string;
|
||||
region?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AddTrustedIPEvent {
|
||||
type: EventType.ADD_TRUSTED_IP;
|
||||
metadata: {
|
||||
trustedIpId: string;
|
||||
ipAddress: string;
|
||||
prefix?: number;
|
||||
}
|
||||
type: EventType.ADD_TRUSTED_IP;
|
||||
metadata: {
|
||||
trustedIpId: string;
|
||||
ipAddress: string;
|
||||
prefix?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateTrustedIPEvent {
|
||||
type: EventType.UPDATE_TRUSTED_IP;
|
||||
metadata: {
|
||||
trustedIpId: string;
|
||||
ipAddress: string;
|
||||
prefix?: number;
|
||||
}
|
||||
type: EventType.UPDATE_TRUSTED_IP;
|
||||
metadata: {
|
||||
trustedIpId: string;
|
||||
ipAddress: string;
|
||||
prefix?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteTrustedIPEvent {
|
||||
type: EventType.DELETE_TRUSTED_IP;
|
||||
metadata: {
|
||||
trustedIpId: string;
|
||||
ipAddress: string;
|
||||
prefix?: number;
|
||||
}
|
||||
type: EventType.DELETE_TRUSTED_IP;
|
||||
metadata: {
|
||||
trustedIpId: string;
|
||||
ipAddress: string;
|
||||
prefix?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateServiceTokenEvent {
|
||||
type: EventType.CREATE_SERVICE_TOKEN;
|
||||
metadata: {
|
||||
name: string;
|
||||
scopes: Array<{
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
}>;
|
||||
}
|
||||
type: EventType.CREATE_SERVICE_TOKEN;
|
||||
metadata: {
|
||||
name: string;
|
||||
scopes: Array<{
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteServiceTokenEvent {
|
||||
type: EventType.DELETE_SERVICE_TOKEN;
|
||||
metadata: {
|
||||
name: string;
|
||||
scopes: Array<{
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
}>;
|
||||
}
|
||||
type: EventType.DELETE_SERVICE_TOKEN;
|
||||
metadata: {
|
||||
name: string;
|
||||
scopes: Array<{
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateEnvironmentEvent {
|
||||
type: EventType.CREATE_ENVIRONMENT;
|
||||
metadata: {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
type: EventType.CREATE_ENVIRONMENT;
|
||||
metadata: {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateEnvironmentEvent {
|
||||
type: EventType.UPDATE_ENVIRONMENT;
|
||||
metadata: {
|
||||
oldName: string;
|
||||
newName: string;
|
||||
oldSlug: string;
|
||||
newSlug: string;
|
||||
}
|
||||
type: EventType.UPDATE_ENVIRONMENT;
|
||||
metadata: {
|
||||
oldName: string;
|
||||
newName: string;
|
||||
oldSlug: string;
|
||||
newSlug: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteEnvironmentEvent {
|
||||
type: EventType.DELETE_ENVIRONMENT;
|
||||
metadata: {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
type: EventType.DELETE_ENVIRONMENT;
|
||||
metadata: {
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AddWorkspaceMemberEvent {
|
||||
type: EventType.ADD_WORKSPACE_MEMBER;
|
||||
metadata: {
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
type: EventType.ADD_WORKSPACE_MEMBER;
|
||||
metadata: {
|
||||
userId: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface RemoveWorkspaceMemberEvent {
|
||||
type: EventType.REMOVE_WORKSPACE_MEMBER;
|
||||
metadata: {
|
||||
userId: string;
|
||||
email: string;
|
||||
}
|
||||
type: EventType.REMOVE_WORKSPACE_MEMBER;
|
||||
metadata: {
|
||||
userId: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateFolderEvent {
|
||||
type: EventType.CREATE_FOLDER;
|
||||
metadata: {
|
||||
environment: string;
|
||||
folderId: string;
|
||||
folderName: string;
|
||||
folderPath: string;
|
||||
}
|
||||
type: EventType.CREATE_FOLDER;
|
||||
metadata: {
|
||||
environment: string;
|
||||
folderId: string;
|
||||
folderName: string;
|
||||
folderPath: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateFolderEvent {
|
||||
type: EventType.UPDATE_FOLDER;
|
||||
metadata: {
|
||||
environment: string;
|
||||
folderId: string;
|
||||
oldFolderName: string;
|
||||
newFolderName: string;
|
||||
folderPath: string;
|
||||
}
|
||||
type: EventType.UPDATE_FOLDER;
|
||||
metadata: {
|
||||
environment: string;
|
||||
folderId: string;
|
||||
oldFolderName: string;
|
||||
newFolderName: string;
|
||||
folderPath: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteFolderEvent {
|
||||
type: EventType.DELETE_FOLDER;
|
||||
metadata: {
|
||||
environment: string;
|
||||
folderId: string;
|
||||
folderName: string;
|
||||
folderPath: string;
|
||||
}
|
||||
type: EventType.DELETE_FOLDER;
|
||||
metadata: {
|
||||
environment: string;
|
||||
folderId: string;
|
||||
folderName: string;
|
||||
folderPath: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateWebhookEvent {
|
||||
type: EventType.CREATE_WEBHOOK,
|
||||
metadata: {
|
||||
webhookId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
webhookUrl: string;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
type: EventType.CREATE_WEBHOOK;
|
||||
metadata: {
|
||||
webhookId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
webhookUrl: string;
|
||||
isDisabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateWebhookStatusEvent {
|
||||
type: EventType.UPDATE_WEBHOOK_STATUS,
|
||||
metadata: {
|
||||
webhookId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
webhookUrl: string;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
type: EventType.UPDATE_WEBHOOK_STATUS;
|
||||
metadata: {
|
||||
webhookId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
webhookUrl: string;
|
||||
isDisabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteWebhookEvent {
|
||||
type: EventType.DELETE_WEBHOOK,
|
||||
metadata: {
|
||||
webhookId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
webhookUrl: string;
|
||||
isDisabled: boolean;
|
||||
}
|
||||
type: EventType.DELETE_WEBHOOK;
|
||||
metadata: {
|
||||
webhookId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
webhookUrl: string;
|
||||
isDisabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface GetSecretImportsEvent {
|
||||
type: EventType.GET_SECRET_IMPORTS,
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretImportId: string;
|
||||
folderId: string;
|
||||
numberOfImports: number;
|
||||
}
|
||||
type: EventType.GET_SECRET_IMPORTS;
|
||||
metadata: {
|
||||
environment: string;
|
||||
secretImportId: string;
|
||||
folderId: string;
|
||||
numberOfImports: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface CreateSecretImportEvent {
|
||||
type: EventType.CREATE_SECRET_IMPORT,
|
||||
metadata: {
|
||||
secretImportId: string;
|
||||
folderId: string;
|
||||
importFromEnvironment: string;
|
||||
importFromSecretPath: string;
|
||||
importToEnvironment: string;
|
||||
importToSecretPath: string;
|
||||
}
|
||||
type: EventType.CREATE_SECRET_IMPORT;
|
||||
metadata: {
|
||||
secretImportId: string;
|
||||
folderId: string;
|
||||
importFromEnvironment: string;
|
||||
importFromSecretPath: string;
|
||||
importToEnvironment: string;
|
||||
importToSecretPath: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateSecretImportEvent {
|
||||
type: EventType.UPDATE_SECRET_IMPORT,
|
||||
metadata: {
|
||||
secretImportId: string;
|
||||
folderId: string;
|
||||
importToEnvironment: string;
|
||||
importToSecretPath: string;
|
||||
orderBefore: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
}[],
|
||||
orderAfter: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
}[]
|
||||
}
|
||||
type: EventType.UPDATE_SECRET_IMPORT;
|
||||
metadata: {
|
||||
secretImportId: string;
|
||||
folderId: string;
|
||||
importToEnvironment: string;
|
||||
importToSecretPath: string;
|
||||
orderBefore: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
}[];
|
||||
orderAfter: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteSecretImportEvent {
|
||||
type: EventType.DELETE_SECRET_IMPORT,
|
||||
metadata: {
|
||||
secretImportId: string;
|
||||
folderId: string;
|
||||
importFromEnvironment: string;
|
||||
importFromSecretPath: string;
|
||||
importToEnvironment: string;
|
||||
importToSecretPath: string;
|
||||
}
|
||||
type: EventType.DELETE_SECRET_IMPORT;
|
||||
metadata: {
|
||||
secretImportId: string;
|
||||
folderId: string;
|
||||
importFromEnvironment: string;
|
||||
importFromSecretPath: string;
|
||||
importToEnvironment: string;
|
||||
importToSecretPath: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateUserRole {
|
||||
type: EventType.UPDATE_USER_WORKSPACE_ROLE,
|
||||
metadata: {
|
||||
userId: string;
|
||||
email: string;
|
||||
oldRole: string;
|
||||
newRole: string;
|
||||
}
|
||||
type: EventType.UPDATE_USER_WORKSPACE_ROLE;
|
||||
metadata: {
|
||||
userId: string;
|
||||
email: string;
|
||||
oldRole: string;
|
||||
newRole: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateUserDeniedPermissions {
|
||||
type: EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS,
|
||||
metadata: {
|
||||
userId: string;
|
||||
email: string;
|
||||
deniedPermissions: {
|
||||
environmentSlug: string;
|
||||
ability: string;
|
||||
}[]
|
||||
}
|
||||
type: EventType.UPDATE_USER_WORKSPACE_DENIED_PERMISSIONS;
|
||||
metadata: {
|
||||
userId: string;
|
||||
email: string;
|
||||
deniedPermissions: {
|
||||
environmentSlug: string;
|
||||
ability: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export type Event =
|
||||
| GetSecretsEvent
|
||||
| GetSecretEvent
|
||||
| CreateSecretEvent
|
||||
| UpdateSecretEvent
|
||||
| DeleteSecretEvent
|
||||
| GetWorkspaceKeyEvent
|
||||
| AuthorizeIntegrationEvent
|
||||
| UnauthorizeIntegrationEvent
|
||||
| CreateIntegrationEvent
|
||||
| DeleteIntegrationEvent
|
||||
| AddTrustedIPEvent
|
||||
| UpdateTrustedIPEvent
|
||||
| DeleteTrustedIPEvent
|
||||
| CreateServiceTokenEvent
|
||||
| DeleteServiceTokenEvent
|
||||
| CreateEnvironmentEvent
|
||||
| UpdateEnvironmentEvent
|
||||
| DeleteEnvironmentEvent
|
||||
| AddWorkspaceMemberEvent
|
||||
| RemoveWorkspaceMemberEvent
|
||||
| CreateFolderEvent
|
||||
| UpdateFolderEvent
|
||||
| DeleteFolderEvent
|
||||
| CreateWebhookEvent
|
||||
| UpdateWebhookStatusEvent
|
||||
| DeleteWebhookEvent
|
||||
| GetSecretImportsEvent
|
||||
| CreateSecretImportEvent
|
||||
| UpdateSecretImportEvent
|
||||
| DeleteSecretImportEvent
|
||||
| UpdateUserRole
|
||||
| UpdateUserDeniedPermissions;
|
||||
export type Event =
|
||||
| GetSecretsEvent
|
||||
| GetSecretEvent
|
||||
| CreateSecretEvent
|
||||
| CreateSecretBatchEvent
|
||||
| UpdateSecretEvent
|
||||
| UpdateSecretBatchEvent
|
||||
| DeleteSecretEvent
|
||||
| DeleteSecretBatchEvent
|
||||
| GetWorkspaceKeyEvent
|
||||
| AuthorizeIntegrationEvent
|
||||
| UnauthorizeIntegrationEvent
|
||||
| CreateIntegrationEvent
|
||||
| DeleteIntegrationEvent
|
||||
| AddTrustedIPEvent
|
||||
| UpdateTrustedIPEvent
|
||||
| DeleteTrustedIPEvent
|
||||
| CreateServiceTokenEvent
|
||||
| DeleteServiceTokenEvent
|
||||
| CreateEnvironmentEvent
|
||||
| UpdateEnvironmentEvent
|
||||
| DeleteEnvironmentEvent
|
||||
| AddWorkspaceMemberEvent
|
||||
| RemoveWorkspaceMemberEvent
|
||||
| CreateFolderEvent
|
||||
| UpdateFolderEvent
|
||||
| DeleteFolderEvent
|
||||
| CreateWebhookEvent
|
||||
| UpdateWebhookStatusEvent
|
||||
| DeleteWebhookEvent
|
||||
| GetSecretImportsEvent
|
||||
| CreateSecretImportEvent
|
||||
| UpdateSecretImportEvent
|
||||
| DeleteSecretImportEvent
|
||||
| UpdateUserRole
|
||||
| UpdateUserDeniedPermissions;
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ENCODING_SCHEME_BASE64,
|
||||
ENCODING_SCHEME_UTF8,
|
||||
SECRET_PERSONAL,
|
||||
SECRET_SHARED,
|
||||
SECRET_SHARED
|
||||
} from "../../variables";
|
||||
|
||||
export interface ISecretVersion {
|
||||
@@ -23,6 +23,7 @@ export interface ISecretVersion {
|
||||
secretValueCiphertext: string;
|
||||
secretValueIV: string;
|
||||
secretValueTag: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
algorithm: "aes-256-gcm";
|
||||
keyEncoding: "utf8" | "base64";
|
||||
createdAt: string;
|
||||
@@ -36,95 +37,96 @@ const secretVersionSchema = new Schema<ISecretVersion>(
|
||||
// could be deleted
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: "Secret",
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
version: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
workspace: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: "Workspace",
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: [SECRET_SHARED, SECRET_PERSONAL],
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
user: {
|
||||
// user associated with the personal secret
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: "User",
|
||||
ref: "User"
|
||||
},
|
||||
environment: {
|
||||
type: String,
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
isDeleted: {
|
||||
// consider removing field
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
secretBlindIndex: {
|
||||
type: String,
|
||||
select: false,
|
||||
select: false
|
||||
},
|
||||
secretKeyCiphertext: {
|
||||
type: String,
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
secretKeyIV: {
|
||||
type: String, // symmetric
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
secretKeyTag: {
|
||||
type: String, // symmetric
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
secretValueCiphertext: {
|
||||
type: String,
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
secretValueIV: {
|
||||
type: String, // symmetric
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
secretValueTag: {
|
||||
type: String, // symmetric
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
skipMultilineEncoding: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
algorithm: {
|
||||
// the encryption algorithm used
|
||||
type: String,
|
||||
enum: [ALGORITHM_AES_256_GCM],
|
||||
required: true,
|
||||
default: ALGORITHM_AES_256_GCM,
|
||||
default: ALGORITHM_AES_256_GCM
|
||||
},
|
||||
keyEncoding: {
|
||||
type: String,
|
||||
enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64],
|
||||
required: true,
|
||||
default: ENCODING_SCHEME_UTF8,
|
||||
default: ENCODING_SCHEME_UTF8
|
||||
},
|
||||
folder: {
|
||||
type: String,
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
tags: {
|
||||
ref: "Tag",
|
||||
type: [Schema.Types.ObjectId],
|
||||
default: [],
|
||||
default: []
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
timestamps: true
|
||||
}
|
||||
);
|
||||
|
||||
export const SecretVersion = model<ISecretVersion>(
|
||||
"SecretVersion",
|
||||
secretVersionSchema
|
||||
);
|
||||
export const SecretVersion = model<ISecretVersion>("SecretVersion", secretVersionSchema);
|
||||
|
||||
@@ -103,7 +103,10 @@ export const getSecretsBotHelper = async ({
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
}) => {
|
||||
const content: Record<string, { value: string; comment?: string }> = {};
|
||||
const content: Record<
|
||||
string,
|
||||
{ value: string; comment?: string; skipMultilineEncoding?: boolean }
|
||||
> = {};
|
||||
const key = await getKey({ workspaceId: workspaceId });
|
||||
|
||||
let folderId = "root";
|
||||
@@ -165,6 +168,8 @@ export const getSecretsBotHelper = async ({
|
||||
});
|
||||
content[secretKey].comment = commentValue;
|
||||
}
|
||||
|
||||
content[secretKey].skipMultilineEncoding = secret.skipMultilineEncoding;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,6 +199,8 @@ export const getSecretsBotHelper = async ({
|
||||
});
|
||||
content[secretKey].comment = commentValue;
|
||||
}
|
||||
|
||||
content[secretKey].skipMultilineEncoding = secret.skipMultilineEncoding;
|
||||
});
|
||||
|
||||
await expandSecrets(workspaceId.toString(), key, content);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
CreateSecretBatchParams,
|
||||
CreateSecretParams,
|
||||
DeleteSecretBatchParams,
|
||||
DeleteSecretParams,
|
||||
GetSecretParams,
|
||||
GetSecretsParams,
|
||||
UpdateSecretBatchParams,
|
||||
UpdateSecretParams
|
||||
} from "../interfaces/services/SecretService";
|
||||
import {
|
||||
@@ -71,6 +74,8 @@ export function containsGlobPatterns(secretPath: string) {
|
||||
return globChars.some((char) => normalizedPath.includes(char));
|
||||
}
|
||||
|
||||
const ERR_FOLDER_NOT_FOUND = BadRequestError({ message: "Folder not found" });
|
||||
|
||||
/**
|
||||
* Returns an object containing secret [secret] but with its value, key, comment decrypted.
|
||||
*
|
||||
@@ -330,7 +335,8 @@ export const createSecretHelper = async ({
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
secretPath = "/",
|
||||
metadata
|
||||
metadata,
|
||||
skipMultilineEncoding
|
||||
}: CreateSecretParams) => {
|
||||
const secretBlindIndex = await generateSecretBlindIndexHelper({
|
||||
secretName,
|
||||
@@ -394,6 +400,7 @@ export const createSecretHelper = async ({
|
||||
secretCommentCiphertext,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
skipMultilineEncoding,
|
||||
folder: folderId,
|
||||
algorithm: ALGORITHM_AES_256_GCM,
|
||||
keyEncoding: ENCODING_SCHEME_UTF8,
|
||||
@@ -416,6 +423,7 @@ export const createSecretHelper = async ({
|
||||
secretValueCiphertext,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
skipMultilineEncoding,
|
||||
algorithm: ALGORITHM_AES_256_GCM,
|
||||
keyEncoding: ENCODING_SCHEME_UTF8
|
||||
});
|
||||
@@ -740,19 +748,50 @@ export const updateSecretHelper = async ({
|
||||
environment,
|
||||
type,
|
||||
authData,
|
||||
newSecretName,
|
||||
secretKeyTag,
|
||||
secretKeyCiphertext,
|
||||
secretKeyIV,
|
||||
secretValueCiphertext,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretPath
|
||||
secretPath,
|
||||
tags,
|
||||
secretCommentCiphertext,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
skipMultilineEncoding
|
||||
}: UpdateSecretParams) => {
|
||||
const secretBlindIndex = await generateSecretBlindIndexHelper({
|
||||
secretName,
|
||||
// get secret blind index salt
|
||||
const salt = await getSecretBlindIndexSaltHelper({
|
||||
workspaceId: new Types.ObjectId(workspaceId)
|
||||
});
|
||||
|
||||
const secretBlindIndex = await generateSecretBlindIndexWithSaltHelper({
|
||||
secretName,
|
||||
salt
|
||||
});
|
||||
|
||||
let secret: ISecret | null = null;
|
||||
const folderId = await getFolderIdFromServiceToken(workspaceId, environment, secretPath);
|
||||
|
||||
let newSecretNameBlindIndex = undefined;
|
||||
if (newSecretName) {
|
||||
newSecretNameBlindIndex = await generateSecretBlindIndexWithSaltHelper({
|
||||
secretName,
|
||||
salt
|
||||
});
|
||||
const doesSecretAlreadyExist = await Secret.exists({
|
||||
secretBlindIndex: newSecretNameBlindIndex,
|
||||
workspace: new Types.ObjectId(workspaceId),
|
||||
environment,
|
||||
folder: folderId,
|
||||
type
|
||||
});
|
||||
if (!doesSecretAlreadyExist)
|
||||
throw BadRequestError({ message: "Secret with the provided name already exist" });
|
||||
}
|
||||
|
||||
if (type === SECRET_SHARED) {
|
||||
// case: update shared secret
|
||||
secret = await Secret.findOneAndUpdate(
|
||||
@@ -767,6 +806,15 @@ export const updateSecretHelper = async ({
|
||||
secretValueCiphertext,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
secretCommentCiphertext,
|
||||
skipMultilineEncoding,
|
||||
secretBlindIndex: newSecretNameBlindIndex,
|
||||
secretKeyIV,
|
||||
secretKeyTag,
|
||||
secretKeyCiphertext,
|
||||
tags,
|
||||
$inc: { version: 1 }
|
||||
},
|
||||
{
|
||||
@@ -782,6 +830,9 @@ export const updateSecretHelper = async ({
|
||||
workspace: new Types.ObjectId(workspaceId),
|
||||
environment,
|
||||
type,
|
||||
secretKeyIV,
|
||||
secretKeyTag,
|
||||
secretKeyCiphertext,
|
||||
folder: folderId,
|
||||
...getAuthDataPayloadUserObj(authData)
|
||||
},
|
||||
@@ -789,6 +840,9 @@ export const updateSecretHelper = async ({
|
||||
secretValueCiphertext,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
tags,
|
||||
skipMultilineEncoding,
|
||||
secretBlindIndex: newSecretNameBlindIndex,
|
||||
$inc: { version: 1 }
|
||||
},
|
||||
{
|
||||
@@ -805,16 +859,18 @@ export const updateSecretHelper = async ({
|
||||
workspace: secret.workspace,
|
||||
folder: folderId,
|
||||
type,
|
||||
tags,
|
||||
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}),
|
||||
environment: secret.environment,
|
||||
isDeleted: false,
|
||||
secretBlindIndex,
|
||||
secretBlindIndex: newSecretName ? newSecretNameBlindIndex : secretBlindIndex,
|
||||
secretKeyCiphertext: secret.secretKeyCiphertext,
|
||||
secretKeyIV: secret.secretKeyIV,
|
||||
secretKeyTag: secret.secretKeyTag,
|
||||
secretValueCiphertext,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
skipMultilineEncoding,
|
||||
algorithm: ALGORITHM_AES_256_GCM,
|
||||
keyEncoding: ENCODING_SCHEME_UTF8
|
||||
});
|
||||
@@ -1147,7 +1203,7 @@ const formatMultiValueEnv = (val?: string) => {
|
||||
export const expandSecrets = async (
|
||||
workspaceId: string,
|
||||
rootEncKey: string,
|
||||
secrets: Record<string, { value: string; comment?: string }>
|
||||
secrets: Record<string, { value: string; comment?: string; skipMultilineEncoding?: boolean }>
|
||||
) => {
|
||||
const expandedSec: Record<string, string> = {};
|
||||
const interpolatedSec: Record<string, string> = {};
|
||||
@@ -1165,7 +1221,10 @@ export const expandSecrets = async (
|
||||
|
||||
for (const key of Object.keys(secrets)) {
|
||||
if (expandedSec?.[key]) {
|
||||
secrets[key].value = formatMultiValueEnv(expandedSec[key]);
|
||||
// should not do multi line encoding if user has set it to skip
|
||||
secrets[key].value = secrets[key].skipMultilineEncoding
|
||||
? expandedSec[key]
|
||||
: formatMultiValueEnv(expandedSec[key]);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1180,8 +1239,506 @@ export const expandSecrets = async (
|
||||
key
|
||||
);
|
||||
|
||||
secrets[key].value = formatMultiValueEnv(expandedVal);
|
||||
secrets[key].value = secrets[key].skipMultilineEncoding
|
||||
? expandedVal
|
||||
: formatMultiValueEnv(expandedVal);
|
||||
}
|
||||
|
||||
return secrets;
|
||||
};
|
||||
|
||||
export const createSecretBatchHelper = async ({
|
||||
secrets,
|
||||
workspaceId,
|
||||
authData,
|
||||
secretPath,
|
||||
environment
|
||||
}: CreateSecretBatchParams) => {
|
||||
let folderId = "root";
|
||||
const folders = await Folder.findOne({
|
||||
workspace: workspaceId,
|
||||
environment
|
||||
});
|
||||
|
||||
if (!folders && secretPath !== "/") throw ERR_FOLDER_NOT_FOUND;
|
||||
if (folders) {
|
||||
const folder = getFolderByPath(folders.nodes, secretPath);
|
||||
if (!folder) throw ERR_FOLDER_NOT_FOUND;
|
||||
folderId = folder.id;
|
||||
}
|
||||
|
||||
// get secret blind index salt
|
||||
const salt = await getSecretBlindIndexSaltHelper({
|
||||
workspaceId: new Types.ObjectId(workspaceId)
|
||||
});
|
||||
|
||||
const secretBlindIndexToKey: Record<string, string> = {}; // used at audit log point
|
||||
const secretBlindIndexes = await Promise.all(
|
||||
secrets.map(({ secretName }) =>
|
||||
generateSecretBlindIndexWithSaltHelper({
|
||||
secretName,
|
||||
salt
|
||||
})
|
||||
)
|
||||
).then((blindIndexes) =>
|
||||
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
|
||||
prev[secrets[i].secretName] = curr;
|
||||
secretBlindIndexToKey[curr] = secrets[i].secretName;
|
||||
return prev;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const exists = await Secret.exists({
|
||||
workspace: new Types.ObjectId(workspaceId),
|
||||
folder: folderId,
|
||||
environment
|
||||
})
|
||||
.or(
|
||||
secrets.map(({ secretName, type }) => ({
|
||||
secretBlindIndex: secretBlindIndexes[secretName],
|
||||
type: type,
|
||||
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {})
|
||||
}))
|
||||
)
|
||||
.exec();
|
||||
|
||||
if (exists)
|
||||
throw BadRequestError({
|
||||
message: "Failed to create secret that already exists"
|
||||
});
|
||||
|
||||
// create secret
|
||||
const newlyCreatedSecrets: ISecret[] = await Secret.insertMany(
|
||||
secrets.map(
|
||||
({
|
||||
type,
|
||||
secretName,
|
||||
secretKeyIV,
|
||||
metadata,
|
||||
secretKeyTag,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
secretKeyCiphertext,
|
||||
secretValueCiphertext,
|
||||
secretCommentCiphertext,
|
||||
skipMultilineEncoding
|
||||
}) => ({
|
||||
version: 1,
|
||||
workspace: new Types.ObjectId(workspaceId),
|
||||
environment,
|
||||
type,
|
||||
secretKeyCiphertext,
|
||||
secretKeyIV,
|
||||
secretKeyTag,
|
||||
secretValueCiphertext,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretCommentCiphertext,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
folder: folderId,
|
||||
algorithm: ALGORITHM_AES_256_GCM,
|
||||
keyEncoding: ENCODING_SCHEME_UTF8,
|
||||
metadata,
|
||||
skipMultilineEncoding,
|
||||
secretBlindIndex: secretBlindIndexes[secretName],
|
||||
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {})
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await EESecretService.addSecretVersions({
|
||||
secretVersions: newlyCreatedSecrets.map(
|
||||
(secret) =>
|
||||
new SecretVersion({
|
||||
secret: secret._id,
|
||||
version: secret.version,
|
||||
workspace: secret.workspace,
|
||||
type: secret.type,
|
||||
folder: folderId,
|
||||
skipMultilineEncoding: secret?.skipMultilineEncoding,
|
||||
...(secret.type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}),
|
||||
environment: secret.environment,
|
||||
isDeleted: false,
|
||||
secretBlindIndex: secret.secretBlindIndex,
|
||||
secretKeyCiphertext: secret.secretKeyCiphertext,
|
||||
secretKeyIV: secret.secretKeyIV,
|
||||
secretKeyTag: secret.secretKeyTag,
|
||||
secretValueCiphertext: secret.secretValueCiphertext,
|
||||
secretValueIV: secret.secretValueIV,
|
||||
secretValueTag: secret.secretValueTag,
|
||||
algorithm: ALGORITHM_AES_256_GCM,
|
||||
keyEncoding: ENCODING_SCHEME_UTF8
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
await EEAuditLogService.createAuditLog(
|
||||
authData,
|
||||
{
|
||||
type: EventType.CREATE_SECRETS,
|
||||
metadata: {
|
||||
environment,
|
||||
secretPath,
|
||||
secrets: newlyCreatedSecrets.map(({ secretBlindIndex, version, _id }) => ({
|
||||
secretId: _id.toString(),
|
||||
secretKey: secretBlindIndexToKey[secretBlindIndex || ""],
|
||||
secretVersion: version
|
||||
}))
|
||||
}
|
||||
},
|
||||
{
|
||||
workspaceId
|
||||
}
|
||||
);
|
||||
|
||||
// (EE) take a secret snapshot
|
||||
await EESecretService.takeSecretSnapshot({
|
||||
workspaceId,
|
||||
environment,
|
||||
folderId
|
||||
});
|
||||
|
||||
const postHogClient = await TelemetryService.getPostHogClient();
|
||||
|
||||
if (postHogClient) {
|
||||
postHogClient.capture({
|
||||
event: "secrets added",
|
||||
distinctId: await TelemetryService.getDistinctId({
|
||||
authData
|
||||
}),
|
||||
properties: {
|
||||
numberOfSecrets: 1,
|
||||
environment,
|
||||
workspaceId,
|
||||
folderId,
|
||||
channel: authData.userAgentType,
|
||||
userAgent: authData.userAgent
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return newlyCreatedSecrets;
|
||||
};
|
||||
|
||||
export const updateSecretBatchHelper = async ({
|
||||
workspaceId,
|
||||
environment,
|
||||
authData,
|
||||
secretPath,
|
||||
secrets
|
||||
}: UpdateSecretBatchParams) => {
|
||||
let folderId = "root";
|
||||
const folders = await Folder.findOne({
|
||||
workspace: workspaceId,
|
||||
environment
|
||||
});
|
||||
|
||||
if (!folders && secretPath !== "/") throw ERR_FOLDER_NOT_FOUND;
|
||||
if (folders) {
|
||||
const folder = getFolderByPath(folders.nodes, secretPath);
|
||||
if (!folder) throw ERR_FOLDER_NOT_FOUND;
|
||||
folderId = folder.id;
|
||||
}
|
||||
|
||||
// get secret blind index salt
|
||||
const salt = await getSecretBlindIndexSaltHelper({
|
||||
workspaceId: new Types.ObjectId(workspaceId)
|
||||
});
|
||||
|
||||
const secretBlindIndexToKey: Record<string, string> = {}; // used at audit log point
|
||||
const secretBlindIndexes = await Promise.all(
|
||||
secrets.map(({ secretName }) =>
|
||||
generateSecretBlindIndexWithSaltHelper({
|
||||
secretName,
|
||||
salt
|
||||
})
|
||||
)
|
||||
).then((blindIndexes) =>
|
||||
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
|
||||
prev[secrets[i].secretName] = curr;
|
||||
secretBlindIndexToKey[curr] = secrets[i].secretName;
|
||||
return prev;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const secretsToBeUpdated = await Secret.find({
|
||||
workspace: new Types.ObjectId(workspaceId),
|
||||
folder: folderId,
|
||||
environment
|
||||
})
|
||||
.select("+secretBlindIndex")
|
||||
.or(
|
||||
secrets.map(({ secretName, type }) => ({
|
||||
secretBlindIndex: secretBlindIndexes[secretName],
|
||||
type: type,
|
||||
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {})
|
||||
}))
|
||||
)
|
||||
.lean();
|
||||
|
||||
if (secretsToBeUpdated.length !== secrets.length)
|
||||
throw BadRequestError({ message: "Some secrets not found" });
|
||||
|
||||
await Secret.bulkWrite(
|
||||
secrets.map(
|
||||
({
|
||||
type,
|
||||
secretName,
|
||||
tags,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
secretValueCiphertext,
|
||||
secretCommentCiphertext,
|
||||
skipMultilineEncoding
|
||||
}) => ({
|
||||
updateOne: {
|
||||
filter: {
|
||||
workspace: new Types.ObjectId(workspaceId),
|
||||
environment,
|
||||
folder: folderId,
|
||||
secretBlindIndex: secretBlindIndexes[secretName],
|
||||
type,
|
||||
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {})
|
||||
},
|
||||
update: {
|
||||
$inc: {
|
||||
version: 1
|
||||
},
|
||||
secretValueCiphertext,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretCommentCiphertext,
|
||||
secretCommentIV,
|
||||
secretCommentTag,
|
||||
algorithm: ALGORITHM_AES_256_GCM,
|
||||
keyEncoding: ENCODING_SCHEME_UTF8,
|
||||
tags,
|
||||
skipMultilineEncoding
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const secretsGroupedByBlindIndex = secretsToBeUpdated.reduce<Record<string, ISecret>>(
|
||||
(prev, curr) => {
|
||||
if (curr.secretBlindIndex) prev[curr.secretBlindIndex] = curr;
|
||||
return prev;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
await EESecretService.addSecretVersions({
|
||||
secretVersions: secrets.map((secret) => {
|
||||
const {
|
||||
_id,
|
||||
version,
|
||||
workspace,
|
||||
type,
|
||||
secretBlindIndex,
|
||||
secretKeyIV,
|
||||
secretKeyTag,
|
||||
secretKeyCiphertext,
|
||||
skipMultilineEncoding
|
||||
} = secretsGroupedByBlindIndex[secretBlindIndexes[secret.secretName]];
|
||||
|
||||
return new SecretVersion({
|
||||
secret: _id,
|
||||
version: version + 1,
|
||||
workspace: workspace,
|
||||
type,
|
||||
folder: folderId,
|
||||
...(secret.type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {}),
|
||||
environment,
|
||||
isDeleted: false,
|
||||
secretBlindIndex: secretBlindIndex,
|
||||
secretKeyCiphertext: secretKeyCiphertext,
|
||||
secretKeyIV: secretKeyIV,
|
||||
secretKeyTag: secretKeyTag,
|
||||
secretValueCiphertext: secret.secretValueCiphertext,
|
||||
secretValueIV: secret.secretValueIV,
|
||||
secretValueTag: secret.secretValueTag,
|
||||
algorithm: ALGORITHM_AES_256_GCM,
|
||||
keyEncoding: ENCODING_SCHEME_UTF8,
|
||||
skipMultilineEncoding
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
await EEAuditLogService.createAuditLog(
|
||||
authData,
|
||||
{
|
||||
type: EventType.UPDATE_SECRETS,
|
||||
metadata: {
|
||||
environment,
|
||||
secretPath,
|
||||
secrets: secretsToBeUpdated.map(({ _id, version, secretBlindIndex }) => ({
|
||||
secretId: _id.toString(),
|
||||
secretKey: secretBlindIndexToKey[secretBlindIndex || ""],
|
||||
secretVersion: version + 1
|
||||
}))
|
||||
}
|
||||
},
|
||||
{
|
||||
workspaceId
|
||||
}
|
||||
);
|
||||
|
||||
// (EE) take a secret snapshot
|
||||
await EESecretService.takeSecretSnapshot({
|
||||
workspaceId,
|
||||
environment,
|
||||
folderId
|
||||
});
|
||||
|
||||
const postHogClient = await TelemetryService.getPostHogClient();
|
||||
|
||||
if (postHogClient) {
|
||||
postHogClient.capture({
|
||||
event: "secrets modified",
|
||||
distinctId: await TelemetryService.getDistinctId({
|
||||
authData
|
||||
}),
|
||||
properties: {
|
||||
numberOfSecrets: 1,
|
||||
environment,
|
||||
workspaceId,
|
||||
folderId,
|
||||
channel: authData.userAgentType,
|
||||
userAgent: authData.userAgent
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
|
||||
export const deleteSecretBatchHelper = async ({
|
||||
workspaceId,
|
||||
environment,
|
||||
authData,
|
||||
secretPath = "/",
|
||||
secrets
|
||||
}: DeleteSecretBatchParams) => {
|
||||
let folderId = "root";
|
||||
const folders = await Folder.findOne({
|
||||
workspace: workspaceId,
|
||||
environment
|
||||
});
|
||||
|
||||
if (!folders && secretPath !== "/") throw ERR_FOLDER_NOT_FOUND;
|
||||
if (folders) {
|
||||
const folder = getFolderByPath(folders.nodes, secretPath);
|
||||
if (!folder) throw ERR_FOLDER_NOT_FOUND;
|
||||
folderId = folder.id;
|
||||
}
|
||||
|
||||
// get secret blind index salt
|
||||
const salt = await getSecretBlindIndexSaltHelper({
|
||||
workspaceId: new Types.ObjectId(workspaceId)
|
||||
});
|
||||
|
||||
const secretBlindIndexToKey: Record<string, string> = {}; // used at audit log point
|
||||
const secretBlindIndexes = await Promise.all(
|
||||
secrets.map(({ secretName }) =>
|
||||
generateSecretBlindIndexWithSaltHelper({
|
||||
secretName,
|
||||
salt
|
||||
})
|
||||
)
|
||||
).then((blindIndexes) =>
|
||||
blindIndexes.reduce<Record<string, string>>((prev, curr, i) => {
|
||||
prev[secrets[i].secretName] = curr;
|
||||
secretBlindIndexToKey[curr] = secrets[i].secretName;
|
||||
return prev;
|
||||
}, {})
|
||||
);
|
||||
|
||||
const deletedSecrets = await Secret.find({
|
||||
workspace: new Types.ObjectId(workspaceId),
|
||||
folder: folderId,
|
||||
environment
|
||||
})
|
||||
.or(
|
||||
secrets.map(({ secretName, type }) => ({
|
||||
secretBlindIndex: secretBlindIndexes[secretName],
|
||||
type: type === "shared" ? { $in: ["shared", "personal"] } : type,
|
||||
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {})
|
||||
}))
|
||||
)
|
||||
.select({ secretBlindIndexes: 1 })
|
||||
.lean()
|
||||
.exec();
|
||||
|
||||
await Secret.deleteMany({
|
||||
workspace: new Types.ObjectId(workspaceId),
|
||||
folder: folderId,
|
||||
environment
|
||||
})
|
||||
.or(
|
||||
secrets.map(({ secretName, type }) => ({
|
||||
secretBlindIndex: secretBlindIndexes[secretName],
|
||||
type: type === "shared" ? { $in: ["shared", "personal"] } : type,
|
||||
...(type === SECRET_PERSONAL ? getAuthDataPayloadUserObj(authData) : {})
|
||||
}))
|
||||
)
|
||||
.exec();
|
||||
|
||||
await EESecretService.markDeletedSecretVersions({
|
||||
secretIds: deletedSecrets.map((secret) => secret._id)
|
||||
});
|
||||
|
||||
await EEAuditLogService.createAuditLog(
|
||||
authData,
|
||||
{
|
||||
type: EventType.DELETE_SECRETS,
|
||||
metadata: {
|
||||
environment,
|
||||
secretPath,
|
||||
secrets: deletedSecrets.map(({ _id, version, secretBlindIndex }) => ({
|
||||
secretId: _id.toString(),
|
||||
secretKey: secretBlindIndexToKey[secretBlindIndex || ""],
|
||||
secretVersion: version
|
||||
}))
|
||||
}
|
||||
},
|
||||
{
|
||||
workspaceId
|
||||
}
|
||||
);
|
||||
|
||||
// (EE) take a secret snapshot
|
||||
await EESecretService.takeSecretSnapshot({
|
||||
workspaceId,
|
||||
environment,
|
||||
folderId
|
||||
});
|
||||
|
||||
const postHogClient = await TelemetryService.getPostHogClient();
|
||||
|
||||
if (postHogClient) {
|
||||
postHogClient.capture({
|
||||
event: "secrets deleted",
|
||||
distinctId: await TelemetryService.getDistinctId({
|
||||
authData
|
||||
}),
|
||||
properties: {
|
||||
numberOfSecrets: secrets.length,
|
||||
environment,
|
||||
workspaceId,
|
||||
folderId,
|
||||
channel: authData.userAgentType,
|
||||
userAgent: authData.userAgent
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
secrets: deletedSecrets
|
||||
};
|
||||
};
|
||||
|
||||
@@ -65,10 +65,10 @@ import sodium from "libsodium-wrappers";
|
||||
import { standardRequest } from "../config/request";
|
||||
|
||||
const getSecretKeyValuePair = (
|
||||
secrets: Record<string, { value: string; comment?: string } | null>
|
||||
secrets: Record<string, { value: string | null; comment?: string } | null>
|
||||
) =>
|
||||
Object.keys(secrets).reduce<Record<string, string>>((prev, key) => {
|
||||
if (secrets[key]) prev[key] = secrets[key]?.value || "";
|
||||
Object.keys(secrets).reduce<Record<string, string | null | undefined>>((prev, key) => {
|
||||
prev[key] = secrets?.[key] === null ? null : secrets?.[key]?.value;
|
||||
return prev;
|
||||
}, {});
|
||||
|
||||
@@ -325,40 +325,42 @@ const syncSecretsGCPSecretManager = async ({
|
||||
name: string;
|
||||
createTime: string;
|
||||
}
|
||||
|
||||
|
||||
interface GCPSMListSecretsRes {
|
||||
secrets?: GCPSecret[];
|
||||
totalSize?: number;
|
||||
nextPageToken?: string;
|
||||
}
|
||||
|
||||
|
||||
let gcpSecrets: GCPSecret[] = [];
|
||||
|
||||
|
||||
const pageSize = 100;
|
||||
let pageToken: string | undefined;
|
||||
let hasMorePages = true;
|
||||
|
||||
const filterParam = integration.metadata.secretGCPLabel
|
||||
? `?filter=labels.${integration.metadata.secretGCPLabel.labelName}=${integration.metadata.secretGCPLabel.labelValue}`
|
||||
const filterParam = integration.metadata.secretGCPLabel
|
||||
? `?filter=labels.${integration.metadata.secretGCPLabel.labelName}=${integration.metadata.secretGCPLabel.labelValue}`
|
||||
: "";
|
||||
|
||||
|
||||
while (hasMorePages) {
|
||||
const params = new URLSearchParams({
|
||||
pageSize: String(pageSize),
|
||||
...(pageToken ? { pageToken } : {})
|
||||
});
|
||||
|
||||
const res: GCPSMListSecretsRes = (await standardRequest.get(
|
||||
`${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets${filterParam}`,
|
||||
{
|
||||
params,
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
"Accept-Encoding": "application/json"
|
||||
const res: GCPSMListSecretsRes = (
|
||||
await standardRequest.get(
|
||||
`${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets${filterParam}`,
|
||||
{
|
||||
params,
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Accept-Encoding": "application/json"
|
||||
}
|
||||
}
|
||||
}
|
||||
)).data;
|
||||
|
||||
)
|
||||
).data;
|
||||
|
||||
if (res.secrets) {
|
||||
const filteredSecrets = res.secrets?.filter((gcpSecret) => {
|
||||
const arr = gcpSecret.name.split("/");
|
||||
@@ -366,54 +368,58 @@ const syncSecretsGCPSecretManager = async ({
|
||||
|
||||
let isValid = true;
|
||||
|
||||
if (integration.metadata.secretPrefix && !key.startsWith(integration.metadata.secretPrefix)) {
|
||||
if (
|
||||
integration.metadata.secretPrefix &&
|
||||
!key.startsWith(integration.metadata.secretPrefix)
|
||||
) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (integration.metadata.secretSuffix && !key.endsWith(integration.metadata.secretSuffix)) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
|
||||
return isValid;
|
||||
});
|
||||
|
||||
gcpSecrets = gcpSecrets.concat(filteredSecrets);
|
||||
}
|
||||
|
||||
|
||||
if (!res.nextPageToken) {
|
||||
hasMorePages = false;
|
||||
}
|
||||
|
||||
|
||||
pageToken = res.nextPageToken;
|
||||
}
|
||||
|
||||
const res: { [key: string]: string; } = {};
|
||||
|
||||
|
||||
const res: { [key: string]: string } = {};
|
||||
|
||||
interface GCPLatestSecretVersionAccess {
|
||||
name: string;
|
||||
payload: {
|
||||
data: string;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
for await (const gcpSecret of gcpSecrets) {
|
||||
const arr = gcpSecret.name.split("/");
|
||||
const key = arr[arr.length - 1];
|
||||
|
||||
const secretLatest: GCPLatestSecretVersionAccess = (await standardRequest.get(
|
||||
`${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}/versions/latest:access`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Accept-Encoding": "application/json"
|
||||
const secretLatest: GCPLatestSecretVersionAccess = (
|
||||
await standardRequest.get(
|
||||
`${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}/versions/latest:access`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
"Accept-Encoding": "application/json"
|
||||
}
|
||||
}
|
||||
}
|
||||
)).data;
|
||||
)
|
||||
).data;
|
||||
|
||||
|
||||
res[key] = Buffer.from(secretLatest.payload.data, "base64").toString("utf-8");
|
||||
}
|
||||
|
||||
|
||||
for await (const key of Object.keys(secrets)) {
|
||||
if (!(key in res)) {
|
||||
// case: create secret
|
||||
@@ -423,11 +429,14 @@ const syncSecretsGCPSecretManager = async ({
|
||||
replication: {
|
||||
automatic: {}
|
||||
},
|
||||
...(integration.metadata.secretGCPLabel ? {
|
||||
labels: {
|
||||
[integration.metadata.secretGCPLabel.labelName]: integration.metadata.secretGCPLabel.labelValue
|
||||
}
|
||||
} : {})
|
||||
...(integration.metadata.secretGCPLabel
|
||||
? {
|
||||
labels: {
|
||||
[integration.metadata.secretGCPLabel.labelName]:
|
||||
integration.metadata.secretGCPLabel.labelValue
|
||||
}
|
||||
}
|
||||
: {})
|
||||
},
|
||||
{
|
||||
params: {
|
||||
@@ -439,7 +448,7 @@ const syncSecretsGCPSecretManager = async ({
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
await standardRequest.post(
|
||||
`${INTEGRATION_GCP_SECRET_MANAGER_URL}/v1/projects/${integration.appId}/secrets/${key}:addVersion`,
|
||||
{
|
||||
@@ -456,7 +465,7 @@ const syncSecretsGCPSecretManager = async ({
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
for await (const key of Object.keys(res)) {
|
||||
if (!(key in secrets)) {
|
||||
// case: delete secret
|
||||
@@ -489,7 +498,7 @@ const syncSecretsGCPSecretManager = async ({
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sync/push [secrets] to Azure Key Vault with vault URI [integration.app]
|
||||
@@ -729,15 +738,12 @@ const syncSecretsAWSParameterStore = async ({
|
||||
} = {};
|
||||
|
||||
if (parameterList) {
|
||||
awsParameterStoreSecretsObj = parameterList.reduce(
|
||||
(obj: any, secret: any) => {
|
||||
return ({
|
||||
...obj,
|
||||
[secret.Name.substring(integration.path.length)]: secret
|
||||
});
|
||||
},
|
||||
{}
|
||||
);
|
||||
awsParameterStoreSecretsObj = parameterList.reduce((obj: any, secret: any) => {
|
||||
return {
|
||||
...obj,
|
||||
[secret.Name.substring(integration.path.length)]: secret
|
||||
};
|
||||
}, {});
|
||||
}
|
||||
|
||||
// Identify secrets to create
|
||||
@@ -1869,8 +1875,10 @@ const syncSecretsGitLab = async ({
|
||||
value: string;
|
||||
environment_scope: string;
|
||||
}
|
||||
|
||||
const gitLabApiUrl = integrationAuth.url ? `${integrationAuth.url}/api` : INTEGRATION_GITLAB_API_URL;
|
||||
|
||||
const gitLabApiUrl = integrationAuth.url
|
||||
? `${integrationAuth.url}/api`
|
||||
: INTEGRATION_GITLAB_API_URL;
|
||||
|
||||
const getAllEnvVariables = async (integrationAppId: string, accessToken: string) => {
|
||||
const headers = {
|
||||
@@ -1880,7 +1888,9 @@ const syncSecretsGitLab = async ({
|
||||
};
|
||||
|
||||
let allEnvVariables: GitLabSecret[] = [];
|
||||
let url: string | null = `${gitLabApiUrl}/v4/projects/${integrationAppId}/variables?per_page=100`;
|
||||
let url:
|
||||
| string
|
||||
| null = `${gitLabApiUrl}/v4/projects/${integrationAppId}/variables?per_page=100`;
|
||||
|
||||
while (url) {
|
||||
const response: any = await standardRequest.get(url, { headers });
|
||||
@@ -1901,23 +1911,27 @@ const syncSecretsGitLab = async ({
|
||||
|
||||
const allEnvVariables = await getAllEnvVariables(integration?.appId, accessToken);
|
||||
const getSecretsRes: GitLabSecret[] = allEnvVariables
|
||||
.filter(
|
||||
(secret: GitLabSecret) => secret.environment_scope === integration.targetEnvironment
|
||||
)
|
||||
.filter((secret: GitLabSecret) => secret.environment_scope === integration.targetEnvironment)
|
||||
.filter((gitLabSecret) => {
|
||||
let isValid = true;
|
||||
|
||||
if (integration.metadata.secretPrefix && !gitLabSecret.key.startsWith(integration.metadata.secretPrefix)) {
|
||||
if (
|
||||
integration.metadata.secretPrefix &&
|
||||
!gitLabSecret.key.startsWith(integration.metadata.secretPrefix)
|
||||
) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (integration.metadata.secretSuffix && !gitLabSecret.key.endsWith(integration.metadata.secretSuffix)) {
|
||||
if (
|
||||
integration.metadata.secretSuffix &&
|
||||
!gitLabSecret.key.endsWith(integration.metadata.secretSuffix)
|
||||
) {
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
|
||||
return isValid;
|
||||
});
|
||||
|
||||
|
||||
for await (const key of Object.keys(secrets)) {
|
||||
const existingSecret = getSecretsRes.find((s: any) => s.key == key);
|
||||
if (!existingSecret) {
|
||||
@@ -2371,41 +2385,43 @@ const syncSecretsTeamCity = async ({
|
||||
|
||||
if (integration.targetEnvironment && integration.targetEnvironmentId) {
|
||||
// case: sync to specific build-config in TeamCity project
|
||||
const res = (await standardRequest.get<GetTeamCityBuildConfigParametersRes>(
|
||||
`${integrationAuth.url}/app/rest/buildTypes/${integration.targetEnvironmentId}/parameters`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
}
|
||||
))
|
||||
.data
|
||||
.property
|
||||
.filter((parameter) => !parameter.inherited)
|
||||
.reduce((obj: any, secret: TeamCitySecret) => {
|
||||
const secretName = secret.name.replace(/^env\./, "");
|
||||
return {
|
||||
...obj,
|
||||
[secretName]: secret.value
|
||||
};
|
||||
}, {});
|
||||
|
||||
const res = (
|
||||
await standardRequest.get<GetTeamCityBuildConfigParametersRes>(
|
||||
`${integrationAuth.url}/app/rest/buildTypes/${integration.targetEnvironmentId}/parameters`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json"
|
||||
}
|
||||
}
|
||||
)
|
||||
).data.property
|
||||
.filter((parameter) => !parameter.inherited)
|
||||
.reduce((obj: any, secret: TeamCitySecret) => {
|
||||
const secretName = secret.name.replace(/^env\./, "");
|
||||
return {
|
||||
...obj,
|
||||
[secretName]: secret.value
|
||||
};
|
||||
}, {});
|
||||
|
||||
for await (const key of Object.keys(secrets)) {
|
||||
if (!(key in res) || (key in res && secrets[key].value !== res[key])) {
|
||||
// case: secret does not exist in TeamCity or secret value has changed
|
||||
// -> create/update secret
|
||||
await standardRequest.post(`${integrationAuth.url}/app/rest/buildTypes/${integration.targetEnvironmentId}/parameters`,
|
||||
{
|
||||
name:`env.${key}`,
|
||||
value: secrets[key].value
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json",
|
||||
await standardRequest.post(
|
||||
`${integrationAuth.url}/app/rest/buildTypes/${integration.targetEnvironmentId}/parameters`,
|
||||
{
|
||||
name: `env.${key}`,
|
||||
value: secrets[key].value
|
||||
},
|
||||
});
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
Accept: "application/json"
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3034,4 +3050,4 @@ const syncSecretsNorthflank = async ({
|
||||
);
|
||||
};
|
||||
|
||||
export { syncSecrets };
|
||||
export { syncSecrets };
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface CreateSecretParams {
|
||||
secretCommentCiphertext?: string;
|
||||
secretCommentIV?: string;
|
||||
secretCommentTag?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
secretPath: string;
|
||||
metadata?: {
|
||||
source?: string;
|
||||
@@ -42,6 +43,10 @@ export interface GetSecretParams {
|
||||
|
||||
export interface UpdateSecretParams {
|
||||
secretName: string;
|
||||
newSecretName?: string;
|
||||
secretKeyCiphertext?: string;
|
||||
secretKeyIV?: string;
|
||||
secretKeyTag?: string;
|
||||
workspaceId: Types.ObjectId;
|
||||
environment: string;
|
||||
type: "shared" | "personal";
|
||||
@@ -50,6 +55,11 @@ export interface UpdateSecretParams {
|
||||
secretValueIV: string;
|
||||
secretValueTag: string;
|
||||
secretPath: string;
|
||||
secretCommentCiphertext?: string;
|
||||
secretCommentIV?: string;
|
||||
secretCommentTag?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface DeleteSecretParams {
|
||||
@@ -60,3 +70,57 @@ export interface DeleteSecretParams {
|
||||
authData: AuthData;
|
||||
secretPath: string;
|
||||
}
|
||||
|
||||
export interface CreateSecretBatchParams {
|
||||
workspaceId: Types.ObjectId;
|
||||
environment: string;
|
||||
authData: AuthData;
|
||||
secretPath: string;
|
||||
secrets: Array<{
|
||||
secretName: string;
|
||||
type: "shared" | "personal";
|
||||
secretKeyCiphertext: string;
|
||||
secretKeyIV: string;
|
||||
secretKeyTag: string;
|
||||
secretValueCiphertext: string;
|
||||
secretValueIV: string;
|
||||
secretValueTag: string;
|
||||
secretCommentCiphertext?: string;
|
||||
secretCommentIV?: string;
|
||||
secretCommentTag?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
metadata?: {
|
||||
source?: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface UpdateSecretBatchParams {
|
||||
workspaceId: Types.ObjectId;
|
||||
environment: string;
|
||||
authData: AuthData;
|
||||
secretPath: string;
|
||||
secrets: Array<{
|
||||
secretName: string;
|
||||
type: "shared" | "personal";
|
||||
secretValueCiphertext: string;
|
||||
secretValueIV: string;
|
||||
secretValueTag: string;
|
||||
secretCommentCiphertext?: string;
|
||||
secretCommentIV?: string;
|
||||
secretCommentTag?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
tags?: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface DeleteSecretBatchParams {
|
||||
workspaceId: Types.ObjectId;
|
||||
environment: string;
|
||||
authData: AuthData;
|
||||
secretPath: string;
|
||||
secrets: Array<{
|
||||
secretName: string;
|
||||
type: "shared" | "personal";
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ENCODING_SCHEME_BASE64,
|
||||
ENCODING_SCHEME_UTF8,
|
||||
SECRET_PERSONAL,
|
||||
SECRET_SHARED,
|
||||
SECRET_SHARED
|
||||
} from "../variables";
|
||||
|
||||
export interface ISecret {
|
||||
@@ -12,7 +12,7 @@ export interface ISecret {
|
||||
version: number;
|
||||
workspace: Types.ObjectId;
|
||||
type: string;
|
||||
user: Types.ObjectId;
|
||||
user?: Types.ObjectId;
|
||||
environment: string;
|
||||
secretBlindIndex?: string;
|
||||
secretKeyCiphertext: string;
|
||||
@@ -27,13 +27,14 @@ export interface ISecret {
|
||||
secretCommentIV?: string;
|
||||
secretCommentTag?: string;
|
||||
secretCommentHash?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
algorithm: "aes-256-gcm";
|
||||
keyEncoding: "utf8" | "base64";
|
||||
tags?: string[];
|
||||
folder?: string;
|
||||
metadata?: {
|
||||
[key: string]: string;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const secretSchema = new Schema<ISecret>(
|
||||
@@ -41,108 +42,112 @@ const secretSchema = new Schema<ISecret>(
|
||||
version: {
|
||||
type: Number,
|
||||
required: true,
|
||||
default: 1,
|
||||
default: 1
|
||||
},
|
||||
workspace: {
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: "Workspace",
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: [SECRET_SHARED, SECRET_PERSONAL],
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
user: {
|
||||
// user associated with the personal secret
|
||||
type: Schema.Types.ObjectId,
|
||||
ref: "User",
|
||||
ref: "User"
|
||||
},
|
||||
tags: {
|
||||
ref: "Tag",
|
||||
type: [Schema.Types.ObjectId],
|
||||
default: [],
|
||||
default: []
|
||||
},
|
||||
environment: {
|
||||
type: String,
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
secretBlindIndex: {
|
||||
type: String,
|
||||
select: false,
|
||||
select: false
|
||||
},
|
||||
secretKeyCiphertext: {
|
||||
type: String,
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
secretKeyIV: {
|
||||
type: String, // symmetric
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
secretKeyTag: {
|
||||
type: String, // symmetric
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
secretKeyHash: {
|
||||
type: String,
|
||||
type: String
|
||||
},
|
||||
secretValueCiphertext: {
|
||||
type: String,
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
secretValueIV: {
|
||||
type: String, // symmetric
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
secretValueTag: {
|
||||
type: String, // symmetric
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
secretValueHash: {
|
||||
type: String,
|
||||
type: String
|
||||
},
|
||||
secretCommentCiphertext: {
|
||||
type: String,
|
||||
required: false,
|
||||
required: false
|
||||
},
|
||||
secretCommentIV: {
|
||||
type: String, // symmetric
|
||||
required: false,
|
||||
required: false
|
||||
},
|
||||
secretCommentTag: {
|
||||
type: String, // symmetric
|
||||
required: false,
|
||||
required: false
|
||||
},
|
||||
secretCommentHash: {
|
||||
type: String,
|
||||
required: false,
|
||||
required: false
|
||||
},
|
||||
skipMultilineEncoding: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
},
|
||||
algorithm: {
|
||||
// the encryption algorithm used
|
||||
type: String,
|
||||
enum: [ALGORITHM_AES_256_GCM],
|
||||
required: true,
|
||||
default: ALGORITHM_AES_256_GCM,
|
||||
default: ALGORITHM_AES_256_GCM
|
||||
},
|
||||
keyEncoding: {
|
||||
type: String,
|
||||
enum: [ENCODING_SCHEME_UTF8, ENCODING_SCHEME_BASE64],
|
||||
required: true,
|
||||
default: ENCODING_SCHEME_UTF8,
|
||||
default: ENCODING_SCHEME_UTF8
|
||||
},
|
||||
folder: {
|
||||
type: String,
|
||||
default: "root",
|
||||
default: "root"
|
||||
},
|
||||
metadata: {
|
||||
type: Schema.Types.Mixed
|
||||
}
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
timestamps: true
|
||||
}
|
||||
);
|
||||
|
||||
secretSchema.index({ tags: 1 }, { background: true });
|
||||
|
||||
export const Secret = model<ISecret>("Secret", secretSchema);
|
||||
export const Secret = model<ISecret>("Secret", secretSchema);
|
||||
|
||||
@@ -18,7 +18,7 @@ router.post(
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/:folderId",
|
||||
"/:folderName",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN]
|
||||
}),
|
||||
@@ -26,7 +26,7 @@ router.patch(
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/:folderId",
|
||||
"/:folderName",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT, AuthMode.SERVICE_TOKEN]
|
||||
}),
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import express from "express";
|
||||
const router = express.Router();
|
||||
import {
|
||||
requireAuth,
|
||||
requireBlindIndicesEnabled,
|
||||
requireE2EEOff
|
||||
} from "../../middleware";
|
||||
import { requireAuth, requireBlindIndicesEnabled, requireE2EEOff } from "../../middleware";
|
||||
import { secretsController } from "../../controllers/v3";
|
||||
import {
|
||||
AuthMode
|
||||
} from "../../variables";
|
||||
import { AuthMode } from "../../variables";
|
||||
|
||||
router.get(
|
||||
"/raw",
|
||||
@@ -85,6 +79,40 @@ router.get(
|
||||
secretsController.getSecrets
|
||||
);
|
||||
|
||||
// akhilmhdh: dont put batch router below the individual operation as those have arbitory name as params
|
||||
router.post(
|
||||
"/batch",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]
|
||||
}),
|
||||
requireBlindIndicesEnabled({
|
||||
locationWorkspaceId: "body"
|
||||
}),
|
||||
secretsController.createSecretByNameBatch
|
||||
);
|
||||
|
||||
router.patch(
|
||||
"/batch",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]
|
||||
}),
|
||||
requireBlindIndicesEnabled({
|
||||
locationWorkspaceId: "body"
|
||||
}),
|
||||
secretsController.updateSecretByNameBatch
|
||||
);
|
||||
|
||||
router.delete(
|
||||
"/batch",
|
||||
requireAuth({
|
||||
acceptedAuthModes: [AuthMode.JWT, AuthMode.API_KEY, AuthMode.SERVICE_TOKEN]
|
||||
}),
|
||||
requireBlindIndicesEnabled({
|
||||
locationWorkspaceId: "body"
|
||||
}),
|
||||
secretsController.deleteSecretByNameBatch
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/:secretName",
|
||||
requireAuth({
|
||||
|
||||
@@ -108,14 +108,6 @@ export const getAllImportedSecrets = async (
|
||||
type: "shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
$lookup: {
|
||||
from: "tags", // note this is the name of the collection in the database, not the Mongoose model name
|
||||
localField: "tags",
|
||||
foreignField: "_id",
|
||||
as: "tags"
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: {
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
CreateSecretParams,
|
||||
DeleteSecretParams,
|
||||
GetSecretParams,
|
||||
GetSecretsParams,
|
||||
UpdateSecretParams,
|
||||
CreateSecretBatchParams,
|
||||
CreateSecretParams,
|
||||
DeleteSecretBatchParams,
|
||||
DeleteSecretParams,
|
||||
GetSecretParams,
|
||||
GetSecretsParams,
|
||||
UpdateSecretBatchParams,
|
||||
UpdateSecretParams
|
||||
} from "../interfaces/services/SecretService";
|
||||
import {
|
||||
createSecretBlindIndexDataHelper,
|
||||
createSecretHelper,
|
||||
deleteSecretHelper,
|
||||
generateSecretBlindIndexHelper,
|
||||
generateSecretBlindIndexWithSaltHelper,
|
||||
getSecretBlindIndexSaltHelper,
|
||||
getSecretHelper,
|
||||
getSecretsHelper,
|
||||
updateSecretHelper,
|
||||
import {
|
||||
createSecretBatchHelper,
|
||||
createSecretBlindIndexDataHelper,
|
||||
createSecretHelper,
|
||||
deleteSecretBatchHelper,
|
||||
deleteSecretHelper,
|
||||
generateSecretBlindIndexHelper,
|
||||
generateSecretBlindIndexWithSaltHelper,
|
||||
getSecretBlindIndexSaltHelper,
|
||||
getSecretHelper,
|
||||
getSecretsHelper,
|
||||
updateSecretBatchHelper,
|
||||
updateSecretHelper
|
||||
} from "../helpers/secrets";
|
||||
|
||||
class SecretService {
|
||||
@@ -26,13 +32,9 @@ class SecretService {
|
||||
* @param {Buffer} obj.salt - 16-byte random salt
|
||||
* @param {Types.ObjectId} obj.workspaceId
|
||||
*/
|
||||
static async createSecretBlindIndexData({
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceId: Types.ObjectId;
|
||||
}) {
|
||||
static async createSecretBlindIndexData({ workspaceId }: { workspaceId: Types.ObjectId }) {
|
||||
return await createSecretBlindIndexDataHelper({
|
||||
workspaceId,
|
||||
workspaceId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -42,13 +44,9 @@ class SecretService {
|
||||
* @param {Types.ObjectId} obj.workspaceId - id of workspace to get salt for
|
||||
* @returns
|
||||
*/
|
||||
static async getSecretBlindIndexSalt({
|
||||
workspaceId,
|
||||
}: {
|
||||
workspaceId: Types.ObjectId;
|
||||
}) {
|
||||
static async getSecretBlindIndexSalt({ workspaceId }: { workspaceId: Types.ObjectId }) {
|
||||
return await getSecretBlindIndexSaltHelper({
|
||||
workspaceId,
|
||||
workspaceId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -61,14 +59,14 @@ class SecretService {
|
||||
*/
|
||||
static async generateSecretBlindIndexWithSalt({
|
||||
secretName,
|
||||
salt,
|
||||
salt
|
||||
}: {
|
||||
secretName: string;
|
||||
salt: string;
|
||||
}) {
|
||||
return await generateSecretBlindIndexWithSaltHelper({
|
||||
secretName,
|
||||
salt,
|
||||
salt
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,14 +79,14 @@ class SecretService {
|
||||
*/
|
||||
static async generateSecretBlindIndex({
|
||||
secretName,
|
||||
workspaceId,
|
||||
workspaceId
|
||||
}: {
|
||||
secretName: string;
|
||||
workspaceId: Types.ObjectId;
|
||||
}) {
|
||||
return await generateSecretBlindIndexHelper({
|
||||
secretName,
|
||||
workspaceId,
|
||||
workspaceId
|
||||
});
|
||||
}
|
||||
|
||||
@@ -163,6 +161,18 @@ class SecretService {
|
||||
static async deleteSecret(deleteSecretParams: DeleteSecretParams) {
|
||||
return await deleteSecretHelper(deleteSecretParams);
|
||||
}
|
||||
|
||||
static async createSecretBatch(createSecretParams: CreateSecretBatchParams) {
|
||||
return await createSecretBatchHelper(createSecretParams);
|
||||
}
|
||||
|
||||
static async updateSecretBatch(updateSecretParams: UpdateSecretBatchParams) {
|
||||
return await updateSecretBatchHelper(updateSecretParams);
|
||||
}
|
||||
|
||||
static async deleteSecretBatch(deleteSecretParams: DeleteSecretBatchParams) {
|
||||
return await deleteSecretBatchHelper(deleteSecretParams);
|
||||
}
|
||||
}
|
||||
|
||||
export default SecretService;
|
||||
|
||||
@@ -5,28 +5,30 @@ export const CreateFolderV1 = z.object({
|
||||
workspaceId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
folderName: z.string().trim(),
|
||||
parentFolderId: z.string().trim().optional()
|
||||
directory: z.string().trim().default("/")
|
||||
})
|
||||
});
|
||||
|
||||
export const UpdateFolderV1 = z.object({
|
||||
params: z.object({
|
||||
folderId: z.string().trim()
|
||||
folderName: z.string().trim()
|
||||
}),
|
||||
body: z.object({
|
||||
workspaceId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
name: z.string().trim()
|
||||
name: z.string().trim(),
|
||||
directory: z.string().trim().default("/")
|
||||
})
|
||||
});
|
||||
|
||||
export const DeleteFolderV1 = z.object({
|
||||
params: z.object({
|
||||
folderId: z.string().trim()
|
||||
folderName: z.string().trim()
|
||||
}),
|
||||
body: z.object({
|
||||
workspaceId: z.string().trim(),
|
||||
environment: z.string().trim()
|
||||
environment: z.string().trim(),
|
||||
directory: z.string().trim().default("/")
|
||||
})
|
||||
});
|
||||
|
||||
@@ -34,7 +36,6 @@ export const GetFoldersV1 = z.object({
|
||||
query: z.object({
|
||||
workspaceId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
parentFolderId: z.string().trim().optional(),
|
||||
parentFolderPath: z.string().trim().optional()
|
||||
directory: z.string().trim().default("/")
|
||||
})
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ export const CreateSecretImportV1 = z.object({
|
||||
body: z.object({
|
||||
workspaceId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
folderId: z.string().trim().default("root"),
|
||||
directory: z.string().trim().default("/"),
|
||||
secretImport: z.object({
|
||||
environment: z.string().trim(),
|
||||
secretPath: z.string().trim()
|
||||
@@ -40,7 +40,7 @@ export const GetSecretImportsV1 = z.object({
|
||||
query: z.object({
|
||||
workspaceId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
folderId: z.string().trim().default("root")
|
||||
directory: z.string().trim().default("/")
|
||||
})
|
||||
});
|
||||
|
||||
@@ -48,6 +48,6 @@ export const GetAllSecretsFromImportV1 = z.object({
|
||||
query: z.object({
|
||||
workspaceId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
folderId: z.string().trim().default("root")
|
||||
directory: z.string().trim().default("/")
|
||||
})
|
||||
});
|
||||
|
||||
@@ -257,8 +257,11 @@ export const CreateSecretRawV3 = z.object({
|
||||
workspaceId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
secretPath: z.string().trim().default("/"),
|
||||
secretValue: z.string().trim(),
|
||||
secretValue: z
|
||||
.string()
|
||||
.transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())),
|
||||
secretComment: z.string().trim(),
|
||||
skipMultilineEncoding: z.boolean().optional(),
|
||||
type: z.enum([SECRET_SHARED, SECRET_PERSONAL])
|
||||
}),
|
||||
params: z.object({
|
||||
@@ -273,8 +276,11 @@ export const UpdateSecretByNameRawV3 = z.object({
|
||||
body: z.object({
|
||||
workspaceId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
secretValue: z.string().trim(),
|
||||
secretValue: z
|
||||
.string()
|
||||
.transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())),
|
||||
secretPath: z.string().trim().default("/"),
|
||||
skipMultilineEncoding: z.boolean().optional(),
|
||||
type: z.enum([SECRET_SHARED, SECRET_PERSONAL]).default(SECRET_SHARED)
|
||||
})
|
||||
});
|
||||
@@ -335,7 +341,8 @@ export const CreateSecretV3 = z.object({
|
||||
secretCommentCiphertext: z.string().trim().optional(),
|
||||
secretCommentIV: z.string().trim().optional(),
|
||||
secretCommentTag: z.string().trim().optional(),
|
||||
metadata: z.record(z.string()).optional()
|
||||
metadata: z.record(z.string()).optional(),
|
||||
skipMultilineEncoding: z.boolean().optional()
|
||||
}),
|
||||
params: z.object({
|
||||
secretName: z.string().trim()
|
||||
@@ -350,7 +357,17 @@ export const UpdateSecretByNameV3 = z.object({
|
||||
secretPath: z.string().trim().default("/"),
|
||||
secretValueCiphertext: z.string().trim(),
|
||||
secretValueIV: z.string().trim(),
|
||||
secretValueTag: z.string().trim()
|
||||
secretValueTag: z.string().trim(),
|
||||
secretCommentCiphertext: z.string().trim().optional(),
|
||||
secretCommentIV: z.string().trim().optional(),
|
||||
secretCommentTag: z.string().trim().optional(),
|
||||
tags: z.string().array().optional(),
|
||||
skipMultilineEncoding: z.boolean().optional(),
|
||||
// to update secret name
|
||||
secretName: z.string().trim().optional(),
|
||||
secretKeyIV: z.string().trim().optional(),
|
||||
secretKeyTag: z.string().trim().optional(),
|
||||
secretKeyCiphertext: z.string().trim().optional()
|
||||
}),
|
||||
params: z.object({
|
||||
secretName: z.string()
|
||||
@@ -368,3 +385,67 @@ export const DeleteSecretByNameV3 = z.object({
|
||||
secretName: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
export const CreateSecretByNameBatchV3 = z.object({
|
||||
body: z.object({
|
||||
workspaceId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
secretPath: z.string().trim().default("/"),
|
||||
secrets: z
|
||||
.object({
|
||||
secretName: z.string().trim(),
|
||||
type: z.enum([SECRET_SHARED, SECRET_PERSONAL]),
|
||||
secretKeyCiphertext: z.string().trim(),
|
||||
secretKeyIV: z.string().trim(),
|
||||
secretKeyTag: z.string().trim(),
|
||||
secretValueCiphertext: z.string().trim(),
|
||||
secretValueIV: z.string().trim(),
|
||||
secretValueTag: z.string().trim(),
|
||||
secretCommentCiphertext: z.string().trim().optional(),
|
||||
secretCommentIV: z.string().trim().optional(),
|
||||
secretCommentTag: z.string().trim().optional(),
|
||||
metadata: z.record(z.string()).optional(),
|
||||
skipMultilineEncoding: z.boolean().optional()
|
||||
})
|
||||
.array()
|
||||
.min(1)
|
||||
})
|
||||
});
|
||||
|
||||
export const UpdateSecretByNameBatchV3 = z.object({
|
||||
body: z.object({
|
||||
workspaceId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
secretPath: z.string().trim().default("/"),
|
||||
secrets: z
|
||||
.object({
|
||||
secretName: z.string().trim(),
|
||||
type: z.enum([SECRET_SHARED, SECRET_PERSONAL]),
|
||||
secretValueCiphertext: z.string().trim(),
|
||||
secretValueIV: z.string().trim(),
|
||||
secretValueTag: z.string().trim(),
|
||||
secretCommentCiphertext: z.string().trim().optional(),
|
||||
secretCommentIV: z.string().trim().optional(),
|
||||
secretCommentTag: z.string().trim().optional(),
|
||||
skipMultilineEncoding: z.boolean().optional(),
|
||||
tags: z.string().array().optional()
|
||||
})
|
||||
.array()
|
||||
.min(1)
|
||||
})
|
||||
});
|
||||
|
||||
export const DeleteSecretByNameBatchV3 = z.object({
|
||||
body: z.object({
|
||||
workspaceId: z.string().trim(),
|
||||
environment: z.string().trim(),
|
||||
secretPath: z.string().trim().default("/"),
|
||||
secrets: z
|
||||
.object({
|
||||
secretName: z.string().trim(),
|
||||
type: z.enum([SECRET_SHARED, SECRET_PERSONAL])
|
||||
})
|
||||
.array()
|
||||
.min(1)
|
||||
})
|
||||
});
|
||||
|
||||
@@ -33,9 +33,10 @@ export const validateClientForWorkspace = async ({
|
||||
}) => {
|
||||
const workspace = await Workspace.findById(workspaceId);
|
||||
|
||||
if (!workspace) throw WorkspaceNotFoundError({
|
||||
message: "Failed to find workspace"
|
||||
});
|
||||
if (!workspace)
|
||||
throw WorkspaceNotFoundError({
|
||||
message: "Failed to find workspace"
|
||||
});
|
||||
|
||||
let membership;
|
||||
switch (authData.actor.type) {
|
||||
@@ -67,7 +68,7 @@ export const GetWorkspaceSecretSnapshotsV1 = z.object({
|
||||
}),
|
||||
query: z.object({
|
||||
environment: z.string().trim(),
|
||||
folderId: z.string().trim().default("root"),
|
||||
directory: z.string().trim().default("/"),
|
||||
offset: z.coerce.number(),
|
||||
limit: z.coerce.number()
|
||||
})
|
||||
@@ -79,7 +80,7 @@ export const GetWorkspaceSecretSnapshotsCountV1 = z.object({
|
||||
}),
|
||||
query: z.object({
|
||||
environment: z.string().trim(),
|
||||
folderId: z.string().trim().default("root")
|
||||
directory: z.string().trim().default("/")
|
||||
})
|
||||
});
|
||||
|
||||
@@ -89,7 +90,7 @@ export const RollbackWorkspaceSecretSnapshotV1 = z.object({
|
||||
}),
|
||||
body: z.object({
|
||||
environment: z.string().trim(),
|
||||
folderId: z.string().trim().default("root"),
|
||||
directory: z.string().trim().default("/"),
|
||||
version: z.number()
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
// secrets
|
||||
export const SECRET_SHARED = "shared";
|
||||
export const SECRET_PERSONAL = "personal";
|
||||
export const SECRET_PERSONAL = "personal";
|
||||
|
||||
62
frontend/package-lock.json
generated
62
frontend/package-lock.json
generated
@@ -51,6 +51,7 @@
|
||||
"cookies": "^0.8.0",
|
||||
"cva": "npm:class-variance-authority@^0.4.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^6.2.3",
|
||||
"fs": "^0.0.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
@@ -92,7 +93,8 @@
|
||||
"uuidv4": "^6.2.13",
|
||||
"yaml": "^2.2.2",
|
||||
"yup": "^0.32.11",
|
||||
"zod": "^3.22.0"
|
||||
"zod": "^3.22.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-essentials": "^7.0.23",
|
||||
@@ -105,6 +107,7 @@
|
||||
"@storybook/react": "^7.0.23",
|
||||
"@storybook/testing-library": "^0.2.0",
|
||||
"@tailwindcss/typography": "^0.5.4",
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"@types/jsrp": "^0.2.4",
|
||||
"@types/node": "^18.11.9",
|
||||
"@types/picomatch": "^2.3.0",
|
||||
@@ -8208,6 +8211,12 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/find-cache-dir": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/find-cache-dir/-/find-cache-dir-3.2.1.tgz",
|
||||
@@ -13444,6 +13453,11 @@
|
||||
"node": "^10.12.0 || >=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
|
||||
},
|
||||
"node_modules/file-system-cache": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/file-system-cache/-/file-system-cache-2.3.0.tgz",
|
||||
@@ -23640,6 +23654,33 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.4.1.tgz",
|
||||
"integrity": "sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -29227,6 +29268,12 @@
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"@types/file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/find-cache-dir": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/find-cache-dir/-/find-cache-dir-3.2.1.tgz",
|
||||
@@ -33358,6 +33405,11 @@
|
||||
"flat-cache": "^3.0.4"
|
||||
}
|
||||
},
|
||||
"file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
|
||||
},
|
||||
"file-system-cache": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/file-system-cache/-/file-system-cache-2.3.0.tgz",
|
||||
@@ -40768,6 +40820,14 @@
|
||||
"version": "3.22.0",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.22.0.tgz",
|
||||
"integrity": "sha512-y5KZY/ssf5n7hCGDGGtcJO/EBJEm5Pa+QQvFBeyMOtnFYOSflalxIFFvdaYevPhePcmcKC4aTbFkCcXN7D0O8Q=="
|
||||
},
|
||||
"zustand": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.4.1.tgz",
|
||||
"integrity": "sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==",
|
||||
"requires": {
|
||||
"use-sync-external-store": "1.2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
"cookies": "^0.8.0",
|
||||
"cva": "npm:class-variance-authority@^0.4.0",
|
||||
"date-fns": "^2.30.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"framer-motion": "^6.2.3",
|
||||
"fs": "^0.0.2",
|
||||
"gray-matter": "^4.0.3",
|
||||
@@ -100,7 +101,8 @@
|
||||
"uuidv4": "^6.2.13",
|
||||
"yaml": "^2.2.2",
|
||||
"yup": "^0.32.11",
|
||||
"zod": "^3.22.0"
|
||||
"zod": "^3.22.0",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-essentials": "^7.0.23",
|
||||
@@ -113,6 +115,7 @@
|
||||
"@storybook/react": "^7.0.23",
|
||||
"@storybook/testing-library": "^0.2.0",
|
||||
"@tailwindcss/typography": "^0.5.4",
|
||||
"@types/file-saver": "^2.0.5",
|
||||
"@types/jsrp": "^0.2.4",
|
||||
"@types/node": "^18.11.9",
|
||||
"@types/picomatch": "^2.3.0",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { faAngleRight } from "@fortawesome/free-solid-svg-icons";
|
||||
@@ -15,7 +14,7 @@ type Props = {
|
||||
currentEnv?: string;
|
||||
userAvailableEnvs?: any[];
|
||||
onEnvChange?: (slug: string) => void;
|
||||
folders?: Array<{ id: string; name: string }>;
|
||||
secretPath?: string;
|
||||
isFolderMode?: boolean;
|
||||
};
|
||||
|
||||
@@ -42,19 +41,14 @@ export default function NavHeader({
|
||||
currentEnv,
|
||||
userAvailableEnvs = [],
|
||||
onEnvChange,
|
||||
folders = [],
|
||||
isFolderMode
|
||||
isFolderMode,
|
||||
secretPath = "/"
|
||||
}: Props): JSX.Element {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { currentOrg } = useOrganization();
|
||||
const router = useRouter();
|
||||
|
||||
const isInRootFolder = isFolderMode && folders.length <= 1;
|
||||
|
||||
const selectedEnv = useMemo(
|
||||
() => userAvailableEnvs?.find((uae) => uae.name === currentEnv),
|
||||
[userAvailableEnvs, currentEnv]
|
||||
);
|
||||
const secretPathSegments = secretPath.split("/").filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row items-center pt-6">
|
||||
@@ -90,13 +84,13 @@ export default function NavHeader({
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">{pageName}</div>
|
||||
)}
|
||||
{currentEnv && isInRootFolder && (
|
||||
{currentEnv && secretPath === "/" && (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
|
||||
<div className="rounded-md pl-3 hover:bg-bunker-100/10">
|
||||
<Tooltip content="Select environment">
|
||||
<Select
|
||||
value={selectedEnv?.slug}
|
||||
value={currentEnv}
|
||||
onValueChange={(value) => {
|
||||
if (value && onEnvChange) onEnvChange(value);
|
||||
}}
|
||||
@@ -113,16 +107,36 @@ export default function NavHeader({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{isFolderMode && Boolean(secretPathSegments.length) && (
|
||||
<div className="flex items-center space-x-3">
|
||||
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
|
||||
<Link
|
||||
passHref
|
||||
legacyBehavior
|
||||
href={{
|
||||
pathname: "/project/[id]/secrets/v2/[env]",
|
||||
query: { id: router.query.id, env: router.query.env }
|
||||
}}
|
||||
>
|
||||
<a className="text-sm font-semibold text-primary/80 hover:text-primary">
|
||||
{userAvailableEnvs?.find(({ slug }) => slug === currentEnv)?.name}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{isFolderMode &&
|
||||
folders?.map(({ id, name }, index) => {
|
||||
secretPathSegments?.map((folderName, index) => {
|
||||
const query = { ...router.query };
|
||||
if (name !== "root") query.folderId = id;
|
||||
else delete query.folderId;
|
||||
query.secretPath = secretPathSegments.slice(0, index + 1);
|
||||
|
||||
return (
|
||||
<div className="flex items-center space-x-3" key={`breadcrumb-folder-${id}`}>
|
||||
<div
|
||||
className="flex items-center space-x-3"
|
||||
key={`breadcrumb-secret-path-${folderName}`}
|
||||
>
|
||||
<FontAwesomeIcon icon={faAngleRight} className="ml-3 mr-1.5 text-xs text-gray-400" />
|
||||
{index + 1 === folders?.length ? (
|
||||
<span className="text-sm font-semibold text-bunker-300">{name}</span>
|
||||
{index + 1 === secretPathSegments?.length ? (
|
||||
<span className="text-sm font-semibold text-bunker-300">{folderName}</span>
|
||||
) : (
|
||||
<Link
|
||||
passHref
|
||||
@@ -130,7 +144,7 @@ export default function NavHeader({
|
||||
href={{ pathname: "/project/[id]/secrets/[env]", query }}
|
||||
>
|
||||
<a className="text-sm font-semibold text-primary/80 hover:text-primary">
|
||||
{name === "root" ? selectedEnv?.name : name}
|
||||
folderName
|
||||
</a>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
266
frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx
Normal file
266
frontend/src/components/tags/CreateTagModal/CreateTagModal.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { faCheck } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useCreateWsTag } from "@app/hooks/api";
|
||||
|
||||
export const secretTagsColors = [
|
||||
{
|
||||
id: 1,
|
||||
hex: "#bec2c8",
|
||||
rgba: "rgb(128,128,128, 0.8)",
|
||||
name: "Grey"
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
hex: "#95a2b3",
|
||||
rgba: "rgb(0,0,255, 0.8)",
|
||||
name: "blue"
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
hex: "#5e6ad2",
|
||||
rgba: "rgb(128,0,128, 0.8)",
|
||||
name: "Purple"
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
hex: "#26b5ce",
|
||||
rgba: "rgb(0,128,128, 0.8)",
|
||||
name: "Teal"
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
hex: "#4cb782",
|
||||
rgba: "rgb(0,128,0, 0.8)",
|
||||
name: "Green"
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
hex: "#f2c94c",
|
||||
rgba: "rgb(255,255,0, 0.8)",
|
||||
name: "Yellow"
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
hex: "#f2994a",
|
||||
rgba: "rgb(128,128,0, 0.8)",
|
||||
name: "Orange"
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
hex: "#f7c8c1",
|
||||
rgba: "rgb(128,0,0, 0.8)",
|
||||
name: "Pink"
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
hex: "#eb5757",
|
||||
rgba: "rgb(255,0,0, 0.8)",
|
||||
name: "Red"
|
||||
}
|
||||
];
|
||||
|
||||
const isValidHexColor = (hexColor: string) => {
|
||||
const hexColorPattern = /^#?([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/;
|
||||
|
||||
return hexColorPattern.test(hexColor);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean;
|
||||
onToggle: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
const createTagSchema = z.object({
|
||||
name: z.string().trim(),
|
||||
color: z.string().trim()
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof createTagSchema>;
|
||||
type TagColor = {
|
||||
id: number;
|
||||
hex: string;
|
||||
rgba: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const CreateTagModal = ({ isOpen, onToggle }: Props): JSX.Element => {
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
watch,
|
||||
setValue,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<FormData>({
|
||||
resolver: zodResolver(createTagSchema)
|
||||
});
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const workspaceId = currentWorkspace?._id || "";
|
||||
|
||||
const { mutateAsync: createWsTag } = useCreateWsTag();
|
||||
|
||||
const [showHexInput, setShowHexInput] = useState<boolean>(false);
|
||||
const selectedTagColor = watch("color", secretTagsColors[0].hex);
|
||||
|
||||
useEffect(()=>{
|
||||
if(!isOpen) reset();
|
||||
},[isOpen])
|
||||
|
||||
const onFormSubmit = async ({ name, color }: FormData) => {
|
||||
try {
|
||||
await createWsTag({
|
||||
workspaceID: workspaceId,
|
||||
tagName: name,
|
||||
tagColor: color,
|
||||
tagSlug: name.replace(" ", "_")
|
||||
});
|
||||
onToggle(false);
|
||||
reset();
|
||||
createNotification({
|
||||
text: "Successfully created a tag",
|
||||
type: "success"
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
createNotification({
|
||||
text: "Failed to create a tag",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onToggle}>
|
||||
<ModalContent
|
||||
title="Create tag"
|
||||
subTitle="Specify your tag name, and the slug will be created automatically."
|
||||
>
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Tag Name" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} placeholder="Type your tag name" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<div className="mb-0.5 ml-1 block text-sm font-normal text-mineshaft-400">
|
||||
Tag Color
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<div className="p-2 rounded flex items-center justify-center border border-mineshaft-500 bg-mineshaft-900 ">
|
||||
<div
|
||||
className="w-6 h-6 rounded-full"
|
||||
style={{ background: `${selectedTagColor}` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow flex items-center rounded border-mineshaft-500 bg-mineshaft-900 px-1 pr-2">
|
||||
{!showHexInput ? (
|
||||
<div className="inline-flex gap-3 items-center pl-3">
|
||||
{secretTagsColors.map(($tagColor: TagColor) => {
|
||||
return (
|
||||
<div key={`tag-color-${$tagColor.id}`}>
|
||||
<Tooltip content={`${$tagColor.name}`}>
|
||||
<div
|
||||
className=" flex items-center justify-center w-[26px] h-[26px] hover:ring-offset-2 hover:ring-2 bg-[#bec2c8] border-2 p-2 hover:shadow-lg border-transparent hover:border-black rounded-full"
|
||||
key={`tag-${$tagColor.id}`}
|
||||
style={{ backgroundColor: `${$tagColor.hex}` }}
|
||||
onClick={() => setValue("color", $tagColor.hex)}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={() => {}}
|
||||
>
|
||||
{$tagColor.hex === selectedTagColor && (
|
||||
<FontAwesomeIcon icon={faCheck} style={{ color: "#00000070" }} />
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-grow items-center px-2 tags-hex-wrapper">
|
||||
<div className="flex items-center relative rounded-md ">
|
||||
{isValidHexColor(selectedTagColor) && (
|
||||
<div
|
||||
className="w-7 h-7 rounded-full flex items-center justify-center"
|
||||
style={{ background: `${selectedTagColor}` }}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCheck} style={{ color: "#00000070" }} />
|
||||
</div>
|
||||
)}
|
||||
{!isValidHexColor(selectedTagColor) && (
|
||||
<div className="border-dashed border bg-blue rounded-full w-7 h-7 border-mineshaft-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-grow">
|
||||
<Input
|
||||
variant="plain"
|
||||
value={selectedTagColor}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setValue("color", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-mineshaft-500 border h-8 mx-4" />
|
||||
<div className="w-7 h-7 flex items-center justify-center">
|
||||
<div
|
||||
className={`flex items-center justify-center w-7 h-7 bg-transparent cursor-pointer hover:ring-offset-1 hover:ring-2 border-mineshaft-500 border bg-mineshaft-900 rounded-sm p-2 ${
|
||||
showHexInput ? "tags-conic-bg rounded-full" : ""
|
||||
}`}
|
||||
onClick={() => setShowHexInput((prev) => !prev)}
|
||||
style={{ border: "1px solid rgba(220, 216, 254, 0.376)" }}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={() => {}}
|
||||
>
|
||||
{!showHexInput && <span>#</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button
|
||||
className="mr-4"
|
||||
type="submit"
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
1
frontend/src/components/tags/CreateTagModal/index.tsx
Normal file
1
frontend/src/components/tags/CreateTagModal/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { CreateTagModal } from "./CreateTagModal";
|
||||
@@ -30,9 +30,10 @@ export const Checkbox = ({
|
||||
<div className="flex items-center font-inter text-bunker-300">
|
||||
<CheckboxPrimitive.Root
|
||||
className={twMerge(
|
||||
"flex items-center justify-center w-4 h-4 mr-3 transition-all rounded shadow border border-mineshaft-400 hover:bg-mineshaft-500 bg-mineshaft-600",
|
||||
"flex items-center justify-center w-4 h-4 transition-all rounded shadow border border-mineshaft-400 hover:bg-mineshaft-500 bg-mineshaft-600",
|
||||
isDisabled && "bg-bunker-400 hover:bg-bunker-400",
|
||||
isChecked && "bg-primary hover:bg-primary",
|
||||
Boolean(children) && "mr-3",
|
||||
className
|
||||
)}
|
||||
required={isRequired}
|
||||
|
||||
46
frontend/src/components/v2/ContentLoader/ContentLoader.tsx
Normal file
46
frontend/src/components/v2/ContentLoader/ContentLoader.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
// this will show a loading animation with text below
|
||||
// if you pass array it will say it one by one giving user clear instruction on what's happening
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
|
||||
type Props = {
|
||||
text?: string | string[];
|
||||
frequency?: number;
|
||||
};
|
||||
|
||||
export const ContentLoader = ({ text, frequency = 2000 }: Props) => {
|
||||
const [pos, setPos] = useState(0);
|
||||
const isTextArray = Array.isArray(text);
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timer;
|
||||
if (isTextArray) {
|
||||
interval = setInterval(() => {
|
||||
setPos((state) => (state + 1) % text.length);
|
||||
}, frequency);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex relative flex-col h-1/2 w-full items-center justify-center px-8 text-mineshaft-50 dark:[color-scheme:dark] space-y-8">
|
||||
<div>
|
||||
<img src="/images/loading/loading.gif" height={210} width={240} alt="loading animation" />
|
||||
</div>
|
||||
{text && isTextArray && (
|
||||
<AnimatePresence exitBeforeEnter>
|
||||
<motion.div
|
||||
className="text-primary"
|
||||
key={`content-loader-${pos}`}
|
||||
initial={{ opacity: 0, translateY: 20 }}
|
||||
animate={{ opacity: 1, translateY: 0 }}
|
||||
exit={{ opacity: 0, translateY: -20 }}
|
||||
>
|
||||
{text[pos]}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
)}
|
||||
{text && !isTextArray && <div className="text-primary text-sm">{text}</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
frontend/src/components/v2/ContentLoader/index.tsx
Normal file
1
frontend/src/components/v2/ContentLoader/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { ContentLoader } from "./ContentLoader";
|
||||
@@ -6,6 +6,9 @@ import { twMerge } from "tailwind-merge";
|
||||
export type DropdownMenuProps = DropdownMenuPrimitive.DropdownMenuProps;
|
||||
export const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
|
||||
export type DropdownSubMenuProps = DropdownMenuPrimitive.DropdownMenuSubProps;
|
||||
export const DropdownSubMenu = DropdownMenuPrimitive.Sub;
|
||||
|
||||
// trigger
|
||||
export type DropdownMenuTriggerProps = DropdownMenuPrimitive.DropdownMenuTriggerProps;
|
||||
export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
@@ -34,6 +37,30 @@ export const DropdownMenuContent = forwardRef<HTMLDivElement, DropdownMenuConten
|
||||
|
||||
DropdownMenuContent.displayName = "DropdownMenuContent";
|
||||
|
||||
// item container
|
||||
export type DropdownSubMenuContentProps = DropdownMenuPrimitive.MenuSubContentProps;
|
||||
export const DropdownSubMenuContent = forwardRef<HTMLDivElement, DropdownSubMenuContentProps>(
|
||||
({ children, className, ...props }, forwardedRef) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
sideOffset={2}
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
className={twMerge(
|
||||
"min-w-[220px] z-30 bg-mineshaft-900 border border-mineshaft-600 will-change-auto text-bunker-300 rounded-md shadow data-[side=top]:animate-slideDownAndFade data-[side=left]:animate-slideRightAndFade data-[side=right]:animate-slideLeftAndFade data-[side=bottom]:animate-slideUpAndFade",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.SubContent>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
DropdownSubMenuContent.displayName = "DropdownMenuContent";
|
||||
|
||||
// item label component
|
||||
export type DropdownLabelProps = DropdownMenuPrimitive.MenuLabelProps;
|
||||
export const DropdownMenuLabel = ({ className, ...props }: DropdownLabelProps) => (
|
||||
@@ -76,11 +103,50 @@ export const DropdownMenuItem = <T extends ElementType = "button">({
|
||||
</DropdownMenuPrimitive.Item>
|
||||
);
|
||||
|
||||
// trigger
|
||||
export type DropdownSubMenuTriggerProps<T extends ElementType> =
|
||||
DropdownMenuPrimitive.DropdownMenuSubTriggerProps & {
|
||||
icon?: ReactNode;
|
||||
as?: T;
|
||||
inputRef?: Ref<T>;
|
||||
iconPos?: "left" | "right";
|
||||
};
|
||||
|
||||
export const DropdownSubMenuTrigger = <T extends ElementType = "button">({
|
||||
children,
|
||||
inputRef,
|
||||
className,
|
||||
icon,
|
||||
as: Item = "button",
|
||||
iconPos = "left",
|
||||
...props
|
||||
}: DropdownMenuItemProps<T> & ComponentPropsWithRef<T>) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
{...props}
|
||||
className={twMerge(
|
||||
"text-xs text-mineshaft-200 block font-inter px-4 py-2 data-[highlighted]:bg-mineshaft-700 rounded-sm outline-none cursor-pointer",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Item type="button" role="menuitem" className="flex w-full items-center" ref={inputRef}>
|
||||
{icon && iconPos === "left" && <span className="flex items-center mr-2">{icon}</span>}
|
||||
<span className="flex-grow text-left">{children}</span>
|
||||
{icon && iconPos === "right" && <span className="flex items-center ml-2">{icon}</span>}
|
||||
</Item>
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
|
||||
// grouping items into 1
|
||||
export type DropdownMenuGroupProps = DropdownMenuPrimitive.DropdownMenuGroupProps;
|
||||
|
||||
export const DropdownMenuGroup = forwardRef<HTMLDivElement, DropdownMenuGroupProps>(
|
||||
({ ...props }, ref) => <DropdownMenuPrimitive.Group {...props} ref={ref} />
|
||||
({ ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Group
|
||||
{...props}
|
||||
className={twMerge("text-xs py-2 pl-3", props.className)}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
DropdownMenuGroup.displayName = "DropdownMenuGroup";
|
||||
@@ -98,3 +164,5 @@ export const DropdownMenuSeparator = forwardRef<
|
||||
));
|
||||
|
||||
DropdownMenuSeparator.displayName = "DropdownMenuSeperator";
|
||||
|
||||
DropdownMenuSeparator.displayName = "DropdownMenuSeperator";
|
||||
|
||||
@@ -4,7 +4,10 @@ export type {
|
||||
DropdownMenuGroupProps,
|
||||
DropdownMenuItemProps,
|
||||
DropdownMenuProps,
|
||||
DropdownMenuTriggerProps
|
||||
DropdownMenuTriggerProps,
|
||||
DropdownSubMenuContentProps,
|
||||
DropdownSubMenuProps,
|
||||
DropdownSubMenuTriggerProps
|
||||
} from "./Dropdown";
|
||||
export {
|
||||
DropdownMenu,
|
||||
@@ -13,5 +16,8 @@ export {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenuTrigger,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger
|
||||
} from "./Dropdown";
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
/* eslint-disable global-require */
|
||||
import { ComponentPropsWithRef, ElementType, ReactNode, Ref, useRef } from "react";
|
||||
import { motion } from "framer-motion"
|
||||
import Lottie from "lottie-react"
|
||||
import { motion } from "framer-motion";
|
||||
import Lottie from "lottie-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export type MenuProps = {
|
||||
@@ -39,13 +39,10 @@ export const MenuItem = <T extends ElementType = "button">({
|
||||
inputRef,
|
||||
...props
|
||||
}: MenuItemProps<T> & ComponentPropsWithRef<T>): JSX.Element => {
|
||||
const iconRef = useRef()
|
||||
const iconRef = useRef();
|
||||
|
||||
return(
|
||||
<a
|
||||
onMouseEnter={() => iconRef.current?.play()}
|
||||
onMouseLeave={() => iconRef.current?.stop()}
|
||||
>
|
||||
return (
|
||||
<a onMouseEnter={() => iconRef.current?.play()} onMouseLeave={() => iconRef.current?.stop()}>
|
||||
<li
|
||||
className={twMerge(
|
||||
"group px-1 py-2 mt-0.5 font-inter flex flex-col text-sm text-bunker-100 transition-all rounded cursor-pointer hover:bg-mineshaft-700 duration-50",
|
||||
@@ -55,25 +52,37 @@ export const MenuItem = <T extends ElementType = "button">({
|
||||
)}
|
||||
>
|
||||
<motion.span className="w-full flex flex-row items-center justify-start rounded-sm">
|
||||
<Item type="button" role="menuitem" className="flex items-center relative" ref={inputRef} {...props}>
|
||||
<div className={`${isSelected ? "visisble" : "invisible"} -left-[0.28rem] absolute w-[0.07rem] rounded-md h-5 bg-primary`}/>
|
||||
{/* {icon && <span className="mr-3 ml-4 w-5 block group-hover:hidden">{icon}</span>} */}
|
||||
<Lottie
|
||||
lottieRef={iconRef}
|
||||
style={{ width: 22, height: 22 }}
|
||||
// eslint-disable-next-line import/no-dynamic-require
|
||||
animationData={require(`../../../../public/lotties/${icon}.json`)}
|
||||
loop={false}
|
||||
autoplay={false}
|
||||
className="my-auto ml-[0.1rem] mr-3"
|
||||
<Item
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="flex items-center relative"
|
||||
ref={inputRef}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={`${
|
||||
isSelected ? "visisble" : "invisible"
|
||||
} -left-[0.28rem] absolute w-[0.07rem] rounded-md h-5 bg-primary`}
|
||||
/>
|
||||
{/* {icon && <span className="mr-3 ml-4 w-5 block group-hover:hidden">{icon}</span>} */}
|
||||
{icon && (
|
||||
<Lottie
|
||||
lottieRef={iconRef}
|
||||
style={{ width: 22, height: 22 }}
|
||||
// eslint-disable-next-line import/no-dynamic-require
|
||||
animationData={require(`../../../../public/lotties/${icon}.json`)}
|
||||
loop={false}
|
||||
autoplay={false}
|
||||
className="my-auto ml-[0.1rem] mr-3"
|
||||
/>
|
||||
)}
|
||||
<span className="flex-grow text-left">{children}</span>
|
||||
</Item>
|
||||
{description && <span className="mt-2 text-xs">{description}</span>}
|
||||
</motion.span>
|
||||
</li>
|
||||
</a>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export const SubMenuItem = <T extends ElementType = "button">({
|
||||
@@ -88,13 +97,10 @@ export const SubMenuItem = <T extends ElementType = "button">({
|
||||
inputRef,
|
||||
...props
|
||||
}: MenuItemProps<T> & ComponentPropsWithRef<T>): JSX.Element => {
|
||||
const iconRef = useRef()
|
||||
const iconRef = useRef();
|
||||
|
||||
return(
|
||||
<a
|
||||
onMouseEnter={() => iconRef.current?.play()}
|
||||
onMouseLeave={() => iconRef.current?.stop()}
|
||||
>
|
||||
return (
|
||||
<a onMouseEnter={() => iconRef.current?.play()} onMouseLeave={() => iconRef.current?.stop()}>
|
||||
<li
|
||||
className={twMerge(
|
||||
"group px-1 py-1 mt-0.5 font-inter flex flex-col text-sm text-mineshaft-300 hover:text-mineshaft-100 transition-all rounded cursor-pointer hover:bg-mineshaft-700 duration-50",
|
||||
@@ -103,7 +109,13 @@ export const SubMenuItem = <T extends ElementType = "button">({
|
||||
)}
|
||||
>
|
||||
<motion.span className="w-full flex flex-row items-center justify-start rounded-sm pl-6">
|
||||
<Item type="button" role="menuitem" className="flex items-center relative" ref={inputRef} {...props}>
|
||||
<Item
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="flex items-center relative"
|
||||
ref={inputRef}
|
||||
{...props}
|
||||
>
|
||||
<Lottie
|
||||
lottieRef={iconRef}
|
||||
style={{ width: 16, height: 16 }}
|
||||
@@ -119,10 +131,9 @@ export const SubMenuItem = <T extends ElementType = "button">({
|
||||
</motion.span>
|
||||
</li>
|
||||
</a>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
MenuItem.displayName = "MenuItem";
|
||||
|
||||
export type MenuGroupProps = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable react/no-danger */
|
||||
import { forwardRef, HTMLAttributes } from "react";
|
||||
import { forwardRef, TextareaHTMLAttributes } from "react";
|
||||
import sanitizeHtml, { DisallowedTagsModes } from "sanitize-html";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
@@ -39,20 +40,28 @@ const syntaxHighlight = (content?: string | null, isVisible?: boolean) => {
|
||||
return `${newContent}<br/>`;
|
||||
};
|
||||
|
||||
type Props = HTMLAttributes<HTMLTextAreaElement> & {
|
||||
type Props = TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||
value?: string | null;
|
||||
isVisible?: boolean;
|
||||
isReadOnly?: boolean;
|
||||
isDisabled?: boolean;
|
||||
containerClassName?: string;
|
||||
};
|
||||
|
||||
const commonClassName = "font-mono text-sm caret-white border-none outline-none w-full break-all";
|
||||
|
||||
export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
({ value, isVisible, onBlur, isDisabled, onFocus, ...props }, ref) => {
|
||||
(
|
||||
{ value, isVisible, containerClassName, onBlur, isDisabled, isReadOnly, onFocus, ...props },
|
||||
ref
|
||||
) => {
|
||||
const [isSecretFocused, setIsSecretFocused] = useToggle();
|
||||
|
||||
return (
|
||||
<div className="overflow-auto w-full" style={{ maxHeight: `${21 * 7}px` }}>
|
||||
<div
|
||||
className={twMerge("overflow-auto w-full no-scrollbar rounded-md", containerClassName)}
|
||||
style={{ maxHeight: `${21 * 7}px` }}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<pre aria-hidden className="m-0 ">
|
||||
<code className={`inline-block w-full ${commonClassName}`}>
|
||||
@@ -78,6 +87,7 @@ export const SecretInput = forwardRef<HTMLTextAreaElement, Props>(
|
||||
}}
|
||||
value={value || ""}
|
||||
{...props}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ export const Spinner = ({ className, size = "md" }: Props): JSX.Element => {
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={twMerge(
|
||||
" text-gray-200 animate-spin dark:text-gray-600 fill-primary m-1",
|
||||
"text-gray-200 animate-spin dark:text-gray-600 fill-primary m-1",
|
||||
sizeChart[size],
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { ReactNode } from "react";
|
||||
import { faClose } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { cva, VariantProps } from "cva";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
onClose?: () => void;
|
||||
} & VariantProps<typeof tagVariants>;
|
||||
|
||||
const tagVariants = cva(
|
||||
"inline-flex items-center whitespace-nowrap text-sm rounded-sm mr-1.5 text-bunker-200 rounded-[30px] text-gray-400 ",
|
||||
"inline-flex items-center whitespace-nowrap text-sm rounded mr-1.5 text-bunker-200 text-gray-400 ",
|
||||
{
|
||||
variants: {
|
||||
colorSchema: {
|
||||
@@ -23,14 +26,13 @@ const tagVariants = cva(
|
||||
}
|
||||
);
|
||||
|
||||
export const Tag = ({
|
||||
children,
|
||||
className,
|
||||
colorSchema = "gray",
|
||||
size = "sm" }: Props) => (
|
||||
<div
|
||||
className={twMerge(tagVariants({ colorSchema, className, size }))}
|
||||
>
|
||||
export const Tag = ({ children, className, colorSchema = "gray", size = "sm", onClose }: Props) => (
|
||||
<div className={twMerge(tagVariants({ colorSchema, className, size }))}>
|
||||
{children}
|
||||
{onClose && (
|
||||
<button type="button" onClick={onClose} className="ml-2 flex items-center justify-center">
|
||||
<FontAwesomeIcon icon={faClose} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./Accordion";
|
||||
export * from "./Button";
|
||||
export * from "./Card";
|
||||
export * from "./Checkbox";
|
||||
export * from "./ContentLoader";
|
||||
export * from "./DatePicker";
|
||||
export * from "./DeleteActionModal";
|
||||
export * from "./Drawer";
|
||||
|
||||
@@ -3,77 +3,75 @@ import crypto from "crypto";
|
||||
import { encryptAssymmetric } from "@app/components/utilities/cryptography/crypto";
|
||||
import encryptSecrets from "@app/components/utilities/secrets/encryptSecrets";
|
||||
import { uploadWsKey } from "@app/hooks/api/keys/queries";
|
||||
import { createSecret } from "@app/hooks/api/secrets/queries";
|
||||
import { createSecret } from "@app/hooks/api/secrets/mutations";
|
||||
import { fetchUserDetails } from "@app/hooks/api/users/queries";
|
||||
import { createWorkspace } from "@app/hooks/api/workspace/queries";
|
||||
|
||||
const secretsToBeAdded = [
|
||||
{
|
||||
pos: 0,
|
||||
key: "DATABASE_URL",
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
value: "mongodb+srv://${DB_USERNAME}:${DB_PASSWORD}@mongodb.net",
|
||||
valueOverride: undefined,
|
||||
comment: "Secret referencing example",
|
||||
id: "",
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
pos: 1,
|
||||
key: "DB_USERNAME",
|
||||
value: "OVERRIDE_THIS",
|
||||
valueOverride: undefined,
|
||||
comment:
|
||||
"Override secrets with personal value",
|
||||
id: "",
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
pos: 2,
|
||||
key: "DB_PASSWORD",
|
||||
value: "OVERRIDE_THIS",
|
||||
valueOverride: undefined,
|
||||
comment:
|
||||
"Another secret override",
|
||||
id: "",
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
pos: 3,
|
||||
key: "DB_USERNAME",
|
||||
value: "user1234",
|
||||
valueOverride: "user1234",
|
||||
comment: "",
|
||||
id: "",
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
pos: 4,
|
||||
key: "DB_PASSWORD",
|
||||
value: "example_password",
|
||||
valueOverride: "example_password",
|
||||
comment: "",
|
||||
id: "",
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
pos: 5,
|
||||
key: "TWILIO_AUTH_TOKEN",
|
||||
value: "example_twillio_token",
|
||||
valueOverride: undefined,
|
||||
comment: "",
|
||||
id: "",
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
pos: 6,
|
||||
key: "WEBSITE_URL",
|
||||
value: "http://localhost:3000",
|
||||
valueOverride: undefined,
|
||||
comment: "",
|
||||
id: "",
|
||||
tags: []
|
||||
}
|
||||
{
|
||||
pos: 0,
|
||||
key: "DATABASE_URL",
|
||||
// eslint-disable-next-line no-template-curly-in-string
|
||||
value: "mongodb+srv://${DB_USERNAME}:${DB_PASSWORD}@mongodb.net",
|
||||
valueOverride: undefined,
|
||||
comment: "Secret referencing example",
|
||||
id: "",
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
pos: 1,
|
||||
key: "DB_USERNAME",
|
||||
value: "OVERRIDE_THIS",
|
||||
valueOverride: undefined,
|
||||
comment: "Override secrets with personal value",
|
||||
id: "",
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
pos: 2,
|
||||
key: "DB_PASSWORD",
|
||||
value: "OVERRIDE_THIS",
|
||||
valueOverride: undefined,
|
||||
comment: "Another secret override",
|
||||
id: "",
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
pos: 3,
|
||||
key: "DB_USERNAME",
|
||||
value: "user1234",
|
||||
valueOverride: "user1234",
|
||||
comment: "",
|
||||
id: "",
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
pos: 4,
|
||||
key: "DB_PASSWORD",
|
||||
value: "example_password",
|
||||
valueOverride: "example_password",
|
||||
comment: "",
|
||||
id: "",
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
pos: 5,
|
||||
key: "TWILIO_AUTH_TOKEN",
|
||||
value: "example_twillio_token",
|
||||
valueOverride: undefined,
|
||||
comment: "",
|
||||
id: "",
|
||||
tags: []
|
||||
},
|
||||
{
|
||||
pos: 6,
|
||||
key: "WEBSITE_URL",
|
||||
value: "http://localhost:3000",
|
||||
valueOverride: undefined,
|
||||
comment: "",
|
||||
id: "",
|
||||
tags: []
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -85,30 +83,32 @@ const secretsToBeAdded = [
|
||||
* @returns {Project} project - new project
|
||||
*/
|
||||
const initProjectHelper = async ({
|
||||
organizationId,
|
||||
projectName
|
||||
organizationId,
|
||||
projectName
|
||||
}: {
|
||||
organizationId: string;
|
||||
projectName: string;
|
||||
organizationId: string;
|
||||
projectName: string;
|
||||
}) => {
|
||||
// create new project
|
||||
const { data: { workspace } } = await createWorkspace({
|
||||
workspaceName: projectName,
|
||||
organizationId
|
||||
const {
|
||||
data: { workspace }
|
||||
} = await createWorkspace({
|
||||
workspaceName: projectName,
|
||||
organizationId
|
||||
});
|
||||
|
||||
|
||||
// create and upload new (encrypted) project key
|
||||
const randomBytes = crypto.randomBytes(16).toString("hex");
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY");
|
||||
|
||||
|
||||
if (!PRIVATE_KEY) throw new Error("Failed to find private key");
|
||||
|
||||
const user = await fetchUserDetails();
|
||||
|
||||
const { ciphertext, nonce } = encryptAssymmetric({
|
||||
plaintext: randomBytes,
|
||||
publicKey: user.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
plaintext: randomBytes,
|
||||
publicKey: user.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
await uploadWsKey({
|
||||
@@ -120,11 +120,11 @@ const initProjectHelper = async ({
|
||||
|
||||
// encrypt and upload secrets to new project
|
||||
const secrets = await encryptSecrets({
|
||||
secretsToEncrypt: secretsToBeAdded,
|
||||
workspaceId: workspace._id,
|
||||
env: "dev"
|
||||
secretsToEncrypt: secretsToBeAdded,
|
||||
workspaceId: workspace._id,
|
||||
env: "dev"
|
||||
});
|
||||
|
||||
|
||||
secrets?.forEach((secret) => {
|
||||
createSecret({
|
||||
workspaceId: workspace._id,
|
||||
@@ -146,10 +146,8 @@ const initProjectHelper = async ({
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
export {
|
||||
initProjectHelper
|
||||
}
|
||||
return workspace;
|
||||
};
|
||||
|
||||
export { initProjectHelper };
|
||||
|
||||
@@ -3,6 +3,5 @@ export {
|
||||
useDeleteFolder,
|
||||
useGetFoldersByEnv,
|
||||
useGetProjectFolders,
|
||||
useGetProjectFoldersBatch,
|
||||
useUpdateFolder
|
||||
} from "./queries";
|
||||
|
||||
@@ -1,86 +1,80 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
useMutation,
|
||||
useQueries,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryOptions
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { secretSnapshotKeys } from "../secretSnapshots/queries";
|
||||
import {
|
||||
CreateFolderDTO,
|
||||
DeleteFolderDTO,
|
||||
GetProjectFoldersBatchDTO,
|
||||
GetProjectFoldersDTO,
|
||||
TCreateFolderDTO,
|
||||
TDeleteFolderDTO,
|
||||
TGetFoldersByEnvDTO,
|
||||
TGetProjectFoldersDTO,
|
||||
TSecretFolder,
|
||||
UpdateFolderDTO
|
||||
TUpdateFolderDTO
|
||||
} from "./types";
|
||||
|
||||
const queryKeys = {
|
||||
getSecretFolders: (workspaceId: string, environment: string, parentFolderId?: string) =>
|
||||
["secret-folders", { workspaceId, environment, parentFolderId }] as const
|
||||
getSecretFolders: ({ workspaceId, environment, directory }: TGetProjectFoldersDTO) =>
|
||||
["secret-folders", { workspaceId, environment, directory }] as const
|
||||
};
|
||||
|
||||
const fetchProjectFolders = async (
|
||||
workspaceId: string,
|
||||
environment: string,
|
||||
parentFolderId?: string,
|
||||
parentFolderPath?: string
|
||||
) => {
|
||||
const { data } = await apiRequest.get<{ folders: TSecretFolder[]; dir: TSecretFolder[] }>(
|
||||
"/api/v1/folders",
|
||||
{
|
||||
params: {
|
||||
workspaceId,
|
||||
environment,
|
||||
parentFolderId,
|
||||
parentFolderPath
|
||||
}
|
||||
const fetchProjectFolders = async (workspaceId: string, environment: string, directory = "/") => {
|
||||
const { data } = await apiRequest.get<{ folders: TSecretFolder[] }>("/api/v1/folders", {
|
||||
params: {
|
||||
workspaceId,
|
||||
environment,
|
||||
directory
|
||||
}
|
||||
);
|
||||
return data;
|
||||
});
|
||||
return data.folders;
|
||||
};
|
||||
|
||||
export const useGetProjectFolders = ({
|
||||
workspaceId,
|
||||
parentFolderId,
|
||||
environment,
|
||||
isPaused,
|
||||
sortDir
|
||||
}: GetProjectFoldersDTO) =>
|
||||
directory = "/",
|
||||
options = {}
|
||||
}: TGetProjectFoldersDTO & {
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TSecretFolder[],
|
||||
unknown,
|
||||
TSecretFolder[],
|
||||
ReturnType<typeof queryKeys.getSecretFolders>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>;
|
||||
}) =>
|
||||
useQuery({
|
||||
queryKey: queryKeys.getSecretFolders(workspaceId, environment, parentFolderId),
|
||||
enabled: Boolean(workspaceId) && Boolean(environment) && !isPaused,
|
||||
queryFn: async () => fetchProjectFolders(workspaceId, environment, parentFolderId),
|
||||
select: useCallback(
|
||||
({ folders, dir }: { folders: TSecretFolder[]; dir: TSecretFolder[] }) => ({
|
||||
dir,
|
||||
folders: folders.sort((a, b) =>
|
||||
sortDir === "asc"
|
||||
? a?.name?.localeCompare(b?.name || "")
|
||||
: b?.name?.localeCompare(a?.name || "")
|
||||
)
|
||||
}),
|
||||
[sortDir]
|
||||
)
|
||||
...options,
|
||||
queryKey: queryKeys.getSecretFolders({ workspaceId, environment, directory }),
|
||||
enabled: Boolean(workspaceId) && Boolean(environment) && (options?.enabled ?? true),
|
||||
queryFn: async () => fetchProjectFolders(workspaceId, environment, directory)
|
||||
});
|
||||
|
||||
export const useGetFoldersByEnv = ({
|
||||
parentFolderPath,
|
||||
directory = "/",
|
||||
workspaceId,
|
||||
environments,
|
||||
parentFolderId
|
||||
environments
|
||||
}: TGetFoldersByEnvDTO) => {
|
||||
const folders = useQueries({
|
||||
queries: environments.map((env) => ({
|
||||
queryKey: queryKeys.getSecretFolders(workspaceId, env, parentFolderPath || parentFolderId),
|
||||
queryFn: async () => fetchProjectFolders(workspaceId, env, parentFolderId, parentFolderPath),
|
||||
enabled: Boolean(workspaceId) && Boolean(env)
|
||||
queries: environments.map((environment) => ({
|
||||
queryKey: queryKeys.getSecretFolders({ workspaceId, environment, directory }),
|
||||
queryFn: async () => fetchProjectFolders(workspaceId, environment, directory),
|
||||
enabled: Boolean(workspaceId) && Boolean(environment)
|
||||
}))
|
||||
});
|
||||
|
||||
const folderNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
folders?.forEach(({ data }) => {
|
||||
data?.folders.forEach(({ name }) => {
|
||||
data?.forEach(({ name }) => {
|
||||
names.add(name);
|
||||
});
|
||||
});
|
||||
@@ -92,9 +86,7 @@ export const useGetFoldersByEnv = ({
|
||||
const selectedEnvIndex = environments.indexOf(env);
|
||||
if (selectedEnvIndex !== -1) {
|
||||
return Boolean(
|
||||
folders?.[selectedEnvIndex]?.data?.folders?.find(
|
||||
({ name: folderName }) => folderName === name
|
||||
)
|
||||
folders?.[selectedEnvIndex]?.data?.find(({ name: folderName }) => folderName === name)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
@@ -105,95 +97,78 @@ export const useGetFoldersByEnv = ({
|
||||
return { folders, folderNames, isFolderPresentInEnv };
|
||||
};
|
||||
|
||||
export const useGetProjectFoldersBatch = ({
|
||||
folders = [],
|
||||
isPaused,
|
||||
parentFolderPath
|
||||
}: GetProjectFoldersBatchDTO) =>
|
||||
useQueries({
|
||||
queries: folders.map(({ workspaceId, environment, parentFolderId }) => ({
|
||||
queryKey: queryKeys.getSecretFolders(workspaceId, environment, parentFolderPath),
|
||||
queryFn: async () =>
|
||||
fetchProjectFolders(workspaceId, environment, parentFolderId, parentFolderPath),
|
||||
enabled: Boolean(workspaceId) && Boolean(environment) && !isPaused,
|
||||
select: (data: { folders: TSecretFolder[]; dir: TSecretFolder[] }) => ({
|
||||
environment,
|
||||
folders: data.folders,
|
||||
dir: data.dir
|
||||
})
|
||||
}))
|
||||
});
|
||||
|
||||
export const useCreateFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, CreateFolderDTO>({
|
||||
return useMutation<{}, {}, TCreateFolderDTO>({
|
||||
mutationFn: async (dto) => {
|
||||
const { data } = await apiRequest.post("/api/v1/folders", dto);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, parentFolderId }) => {
|
||||
onSuccess: (_, { workspaceId, environment, directory }) => {
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.getSecretFolders(workspaceId, environment, parentFolderId)
|
||||
queryKeys.getSecretFolders({ workspaceId, environment, directory })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.count(workspaceId, environment, parentFolderId)
|
||||
secretSnapshotKeys.list({ workspaceId, environment, directory })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.list(workspaceId, environment, parentFolderId)
|
||||
secretSnapshotKeys.count({ workspaceId, environment, directory })
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateFolder = (parentFolderId: string) => {
|
||||
export const useUpdateFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, UpdateFolderDTO>({
|
||||
mutationFn: async ({ folderId, name, environment, workspaceId }) => {
|
||||
const { data } = await apiRequest.patch(`/api/v1/folders/${folderId}`, {
|
||||
return useMutation<{}, {}, TUpdateFolderDTO>({
|
||||
mutationFn: async ({ directory = "/", folderName, name, environment, workspaceId }) => {
|
||||
const { data } = await apiRequest.patch(`/api/v1/folders/${folderName}`, {
|
||||
name,
|
||||
environment,
|
||||
workspaceId
|
||||
workspaceId,
|
||||
directory
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment }) => {
|
||||
onSuccess: (_, { workspaceId, environment, directory }) => {
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.getSecretFolders(workspaceId, environment, parentFolderId)
|
||||
queryKeys.getSecretFolders({ workspaceId, environment, directory })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.count(workspaceId, environment, parentFolderId)
|
||||
secretSnapshotKeys.list({ workspaceId, environment, directory })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.list(workspaceId, environment, parentFolderId)
|
||||
secretSnapshotKeys.count({ workspaceId, environment, directory })
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteFolder = (parentFolderId: string) => {
|
||||
export const useDeleteFolder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, DeleteFolderDTO>({
|
||||
mutationFn: async ({ folderId, environment, workspaceId }) => {
|
||||
const { data } = await apiRequest.delete(`/api/v1/folders/${folderId}`, {
|
||||
return useMutation<{}, {}, TDeleteFolderDTO>({
|
||||
mutationFn: async ({ directory = "/", folderName, environment, workspaceId }) => {
|
||||
const { data } = await apiRequest.delete(`/api/v1/folders/${folderName}`, {
|
||||
data: {
|
||||
environment,
|
||||
workspaceId
|
||||
workspaceId,
|
||||
directory
|
||||
}
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment }) => {
|
||||
onSuccess: (_, { directory = "/", workspaceId, environment }) => {
|
||||
queryClient.invalidateQueries(
|
||||
queryKeys.getSecretFolders(workspaceId, environment, parentFolderId)
|
||||
queryKeys.getSecretFolders({ workspaceId, environment, directory })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.count(workspaceId, environment, parentFolderId)
|
||||
secretSnapshotKeys.list({ workspaceId, environment, directory })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.list(workspaceId, environment, parentFolderId)
|
||||
secretSnapshotKeys.count({ workspaceId, environment, directory })
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,43 +3,36 @@ export type TSecretFolder = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type GetProjectFoldersDTO = {
|
||||
export type TGetProjectFoldersDTO = {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
parentFolderId?: string;
|
||||
isPaused?: boolean;
|
||||
sortDir?: "asc" | "desc";
|
||||
};
|
||||
|
||||
export type GetProjectFoldersBatchDTO = {
|
||||
folders: Omit<GetProjectFoldersDTO, "isPaused" | "sortDir">[];
|
||||
isPaused?: boolean;
|
||||
parentFolderPath?: string;
|
||||
directory?: string;
|
||||
};
|
||||
|
||||
export type TGetFoldersByEnvDTO = {
|
||||
environments: string[];
|
||||
workspaceId: string;
|
||||
parentFolderPath?: string;
|
||||
parentFolderId?: string;
|
||||
directory?: string;
|
||||
};
|
||||
|
||||
export type CreateFolderDTO = {
|
||||
export type TCreateFolderDTO = {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
folderName: string;
|
||||
parentFolderId?: string;
|
||||
directory?: string;
|
||||
};
|
||||
|
||||
export type UpdateFolderDTO = {
|
||||
export type TUpdateFolderDTO = {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
name: string;
|
||||
folderId: string;
|
||||
folderName: string;
|
||||
directory?: string;
|
||||
};
|
||||
|
||||
export type DeleteFolderDTO = {
|
||||
export type TDeleteFolderDTO = {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
folderId: string;
|
||||
folderName: string;
|
||||
directory?: string;
|
||||
};
|
||||
|
||||
@@ -9,21 +9,21 @@ export const useCreateSecretImport = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, TCreateSecretImportDTO>({
|
||||
mutationFn: async ({ secretImport, environment, workspaceId, folderId }) => {
|
||||
mutationFn: async ({ secretImport, environment, workspaceId, directory }) => {
|
||||
const { data } = await apiRequest.post("/api/v1/secret-imports", {
|
||||
secretImport,
|
||||
environment,
|
||||
workspaceId,
|
||||
folderId
|
||||
directory
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, folderId }) => {
|
||||
onSuccess: (_, { workspaceId, environment, directory }) => {
|
||||
queryClient.invalidateQueries(
|
||||
secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId)
|
||||
secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId)
|
||||
secretImportKeys.getSecretImportSecrets({ workspaceId, environment, directory })
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -33,21 +33,21 @@ export const useUpdateSecretImport = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, TUpdateSecretImportDTO>({
|
||||
mutationFn: async ({ environment, workspaceId, folderId, secretImports, id }) => {
|
||||
mutationFn: async ({ environment, workspaceId, directory, secretImports, id }) => {
|
||||
const { data } = await apiRequest.put(`/api/v1/secret-imports/${id}`, {
|
||||
secretImports,
|
||||
environment,
|
||||
workspaceId,
|
||||
folderId
|
||||
directory
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, folderId }) => {
|
||||
onSuccess: (_, { workspaceId, environment, directory }) => {
|
||||
queryClient.invalidateQueries(
|
||||
secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId)
|
||||
secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId)
|
||||
secretImportKeys.getSecretImportSecrets({ workspaceId, environment, directory })
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -66,12 +66,12 @@ export const useDeleteSecretImport = () => {
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, folderId }) => {
|
||||
onSuccess: (_, { workspaceId, environment, directory }) => {
|
||||
queryClient.invalidateQueries(
|
||||
secretImportKeys.getProjectSecretImports(workspaceId, environment, folderId)
|
||||
secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId)
|
||||
secretImportKeys.getSecretImportSecrets({ workspaceId, environment, directory })
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
@@ -7,52 +7,68 @@ import {
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { TGetImportedSecrets, TImportedSecrets, TSecretImports } from "./types";
|
||||
import { TGetImportedSecrets, TGetSecretImports, TImportedSecrets, TSecretImports } from "./types";
|
||||
|
||||
export const secretImportKeys = {
|
||||
getProjectSecretImports: (workspaceId: string, env: string | string[], folderId?: string) => [
|
||||
{ workspaceId, env, folderId },
|
||||
"secrets-imports"
|
||||
],
|
||||
getSecretImportSecrets: (workspaceId: string, env: string | string[], folderId?: string) => [
|
||||
{ workspaceId, env, folderId },
|
||||
"secrets-import-sec"
|
||||
]
|
||||
getProjectSecretImports: ({ environment, workspaceId, directory }: TGetSecretImports) =>
|
||||
[{ workspaceId, directory, environment }, "secrets-imports"] as const,
|
||||
getSecretImportSecrets: ({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory
|
||||
}: Omit<TGetImportedSecrets, "decryptFileKey">) =>
|
||||
[{ workspaceId, environment, directory }, "secrets-import-sec"] as const
|
||||
};
|
||||
|
||||
const fetchSecretImport = async (workspaceId: string, environment: string, folderId?: string) => {
|
||||
const fetchSecretImport = async ({ workspaceId, environment, directory }: TGetSecretImports) => {
|
||||
const { data } = await apiRequest.get<{ secretImport: TSecretImports }>(
|
||||
"/api/v1/secret-imports",
|
||||
{
|
||||
params: {
|
||||
workspaceId,
|
||||
environment,
|
||||
folderId
|
||||
directory
|
||||
}
|
||||
}
|
||||
);
|
||||
return data.secretImport;
|
||||
};
|
||||
|
||||
export const useGetSecretImports = (workspaceId: string, env: string, folderId?: string) =>
|
||||
export const useGetSecretImports = ({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory = "/",
|
||||
options = {}
|
||||
}: TGetSecretImports & {
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TSecretImports,
|
||||
unknown,
|
||||
TSecretImports,
|
||||
ReturnType<typeof secretImportKeys.getProjectSecretImports>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>;
|
||||
}) =>
|
||||
useQuery({
|
||||
enabled: Boolean(workspaceId) && Boolean(env),
|
||||
queryKey: secretImportKeys.getProjectSecretImports(workspaceId, env, folderId),
|
||||
queryFn: () => fetchSecretImport(workspaceId, env, folderId)
|
||||
...options,
|
||||
queryKey: secretImportKeys.getProjectSecretImports({ workspaceId, environment, directory }),
|
||||
enabled: Boolean(workspaceId) && Boolean(environment) && (options?.enabled ?? true),
|
||||
queryFn: () => fetchSecretImport({ workspaceId, environment, directory })
|
||||
});
|
||||
|
||||
const fetchImportedSecrets = async (
|
||||
workspaceId: string,
|
||||
environment: string,
|
||||
folderId?: string
|
||||
directory?: string
|
||||
) => {
|
||||
const { data } = await apiRequest.get<{ secrets: TImportedSecrets }>(
|
||||
const { data } = await apiRequest.get<{ secrets: TImportedSecrets[] }>(
|
||||
"/api/v1/secret-imports/secrets",
|
||||
{
|
||||
params: {
|
||||
workspaceId,
|
||||
environment,
|
||||
folderId
|
||||
directory
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -62,15 +78,34 @@ const fetchImportedSecrets = async (
|
||||
export const useGetImportedSecrets = ({
|
||||
workspaceId,
|
||||
environment,
|
||||
folderId,
|
||||
decryptFileKey
|
||||
}: TGetImportedSecrets) =>
|
||||
decryptFileKey,
|
||||
directory,
|
||||
options = {}
|
||||
}: TGetImportedSecrets & {
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
TImportedSecrets[],
|
||||
unknown,
|
||||
TImportedSecrets[],
|
||||
ReturnType<typeof secretImportKeys.getSecretImportSecrets>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>;
|
||||
}) =>
|
||||
useQuery({
|
||||
enabled: Boolean(workspaceId) && Boolean(environment) && Boolean(decryptFileKey),
|
||||
queryKey: secretImportKeys.getSecretImportSecrets(workspaceId, environment, folderId),
|
||||
queryFn: () => fetchImportedSecrets(workspaceId, environment, folderId),
|
||||
enabled:
|
||||
Boolean(workspaceId) &&
|
||||
Boolean(environment) &&
|
||||
Boolean(decryptFileKey) &&
|
||||
(options?.enabled ?? true),
|
||||
queryKey: secretImportKeys.getSecretImportSecrets({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory
|
||||
}),
|
||||
queryFn: () => fetchImportedSecrets(workspaceId, environment, directory),
|
||||
select: useCallback(
|
||||
(data: TImportedSecrets) => {
|
||||
(data: TImportedSecrets[]) => {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
const latestKey = decryptFileKey;
|
||||
const key = decryptAssymmetric({
|
||||
@@ -114,7 +149,8 @@ export const useGetImportedSecrets = ({
|
||||
tags: encSecret.tags,
|
||||
comment: secretComment,
|
||||
createdAt: encSecret.createdAt,
|
||||
updatedAt: encSecret.updatedAt
|
||||
updatedAt: encSecret.updatedAt,
|
||||
version: encSecret.version
|
||||
};
|
||||
})
|
||||
}));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UserWsKeyPair } from "../keys/types";
|
||||
import { EncryptedSecret } from "../secrets/types";
|
||||
import { UserWsKeyPair } from "../types";
|
||||
|
||||
export type TSecretImports = {
|
||||
_id: string;
|
||||
@@ -16,19 +16,25 @@ export type TImportedSecrets = {
|
||||
secretPath: string;
|
||||
folderId: string;
|
||||
secrets: EncryptedSecret[];
|
||||
}[];
|
||||
};
|
||||
|
||||
export type TGetSecretImports = {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
directory?: string;
|
||||
};
|
||||
|
||||
export type TGetImportedSecrets = {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
folderId?: string;
|
||||
directory?: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
};
|
||||
|
||||
export type TCreateSecretImportDTO = {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
folderId?: string;
|
||||
directory?: string;
|
||||
secretImport: {
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
@@ -39,7 +45,7 @@ export type TUpdateSecretImportDTO = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
folderId?: string;
|
||||
directory?: string;
|
||||
secretImports: Array<{
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
@@ -50,7 +56,7 @@ export type TDeleteSecretImportDTO = {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
folderId?: string;
|
||||
directory?: string;
|
||||
secretImportPath: string;
|
||||
secretImportEnv: string;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export {
|
||||
useGetSnapshotSecrets,
|
||||
useGetWorkspaceSecretSnapshots,
|
||||
useGetWorkspaceSnapshotList,
|
||||
useGetWsSnapshotCount,
|
||||
usePerformSecretRollback
|
||||
} from "./queries";
|
||||
|
||||
@@ -9,39 +9,39 @@ import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { DecryptedSecret } from "../secrets/types";
|
||||
import {
|
||||
GetWorkspaceSecretSnapshotsDTO,
|
||||
TGetSecretSnapshotsDTO,
|
||||
TSecretRollbackDTO,
|
||||
TSnapshotSecret,
|
||||
TSnapshotSecretProps,
|
||||
TWorkspaceSecretSnapshot
|
||||
TSecretSnapshot,
|
||||
TSnapshotData,
|
||||
TSnapshotDataProps
|
||||
} from "./types";
|
||||
|
||||
export const secretSnapshotKeys = {
|
||||
list: (workspaceId: string, env: string, folderId?: string) =>
|
||||
[{ workspaceId, env, folderId }, "secret-snapshot"] as const,
|
||||
snapshotSecrets: (snapshotId: string) => [{ snapshotId }, "secret-snapshot"] as const,
|
||||
count: (workspaceId: string, env: string, folderId?: string) => [
|
||||
{ workspaceId, env, folderId },
|
||||
list: ({ workspaceId, environment, directory }: Omit<TGetSecretSnapshotsDTO, "limit">) =>
|
||||
[{ workspaceId, environment, directory }, "secret-snapshot"] as const,
|
||||
snapshotData: (snapshotId: string) => [{ snapshotId }, "secret-snapshot"] as const,
|
||||
count: ({ environment, workspaceId, directory }: Omit<TGetSecretSnapshotsDTO, "limit">) => [
|
||||
{ workspaceId, environment, directory },
|
||||
"count",
|
||||
"secret-snapshot"
|
||||
]
|
||||
};
|
||||
|
||||
const fetchWorkspaceSecretSnaphots = async (
|
||||
workspaceId: string,
|
||||
environment: string,
|
||||
folderId?: string,
|
||||
const fetchWorkspaceSnaphots = async ({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory = "/",
|
||||
limit = 10,
|
||||
offset = 0
|
||||
) => {
|
||||
const res = await apiRequest.get<{ secretSnapshots: TWorkspaceSecretSnapshot[] }>(
|
||||
}: TGetSecretSnapshotsDTO & { offset: number }) => {
|
||||
const res = await apiRequest.get<{ secretSnapshots: TSecretSnapshot[] }>(
|
||||
`/api/v1/workspace/${workspaceId}/secret-snapshots`,
|
||||
{
|
||||
params: {
|
||||
limit,
|
||||
offset,
|
||||
environment,
|
||||
folderId
|
||||
directory
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -49,32 +49,25 @@ const fetchWorkspaceSecretSnaphots = async (
|
||||
return res.data.secretSnapshots;
|
||||
};
|
||||
|
||||
export const useGetWorkspaceSecretSnapshots = (dto: GetWorkspaceSecretSnapshotsDTO) =>
|
||||
export const useGetWorkspaceSnapshotList = (dto: TGetSecretSnapshotsDTO & { isPaused?: boolean }) =>
|
||||
useInfiniteQuery({
|
||||
enabled: Boolean(dto.workspaceId && dto.environment),
|
||||
queryKey: secretSnapshotKeys.list(dto.workspaceId, dto.environment, dto?.folder),
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchWorkspaceSecretSnaphots(
|
||||
dto.workspaceId,
|
||||
dto.environment,
|
||||
dto?.folder,
|
||||
dto.limit,
|
||||
pageParam
|
||||
),
|
||||
enabled: Boolean(dto.workspaceId && dto.environment) && !dto.isPaused,
|
||||
queryKey: secretSnapshotKeys.list({ ...dto }),
|
||||
queryFn: ({ pageParam }) => fetchWorkspaceSnaphots({ ...dto, offset: pageParam }),
|
||||
getNextPageParam: (lastPage, pages) =>
|
||||
lastPage.length !== 0 ? pages.length * dto.limit : undefined
|
||||
});
|
||||
|
||||
const fetchSnapshotEncSecrets = async (snapshotId: string) => {
|
||||
const res = await apiRequest.get<{ secretSnapshot: TSnapshotSecret }>(
|
||||
const res = await apiRequest.get<{ secretSnapshot: TSnapshotData }>(
|
||||
`/api/v1/secret-snapshot/${snapshotId}`
|
||||
);
|
||||
return res.data.secretSnapshot;
|
||||
};
|
||||
|
||||
export const useGetSnapshotSecrets = ({ decryptFileKey, env, snapshotId }: TSnapshotSecretProps) =>
|
||||
export const useGetSnapshotSecrets = ({ decryptFileKey, env, snapshotId }: TSnapshotDataProps) =>
|
||||
useQuery({
|
||||
queryKey: secretSnapshotKeys.snapshotSecrets(snapshotId),
|
||||
queryKey: secretSnapshotKeys.snapshotData(snapshotId),
|
||||
enabled: Boolean(snapshotId && decryptFileKey),
|
||||
queryFn: () => fetchSnapshotEncSecrets(snapshotId),
|
||||
select: (data) => {
|
||||
@@ -117,7 +110,8 @@ export const useGetSnapshotSecrets = ({ decryptFileKey, env, snapshotId }: TSnap
|
||||
comment: secretComment,
|
||||
createdAt: encSecret.createdAt,
|
||||
updatedAt: encSecret.updatedAt,
|
||||
type: "modified"
|
||||
type: "modified",
|
||||
version: encSecret.version
|
||||
};
|
||||
|
||||
if (encSecret.type === "personal") {
|
||||
@@ -147,25 +141,30 @@ export const useGetSnapshotSecrets = ({ decryptFileKey, env, snapshotId }: TSnap
|
||||
const fetchWorkspaceSecretSnaphotCount = async (
|
||||
workspaceId: string,
|
||||
environment: string,
|
||||
folderId?: string
|
||||
directory = "/"
|
||||
) => {
|
||||
const res = await apiRequest.get<{ count: number }>(
|
||||
`/api/v1/workspace/${workspaceId}/secret-snapshots/count`,
|
||||
{
|
||||
params: {
|
||||
environment,
|
||||
folderId
|
||||
directory
|
||||
}
|
||||
}
|
||||
);
|
||||
return res.data.count;
|
||||
};
|
||||
|
||||
export const useGetWsSnapshotCount = (workspaceId: string, env: string, folderId?: string) =>
|
||||
export const useGetWsSnapshotCount = ({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory,
|
||||
isPaused
|
||||
}: Omit<TGetSecretSnapshotsDTO, "limit"> & { isPaused?: boolean }) =>
|
||||
useQuery({
|
||||
enabled: Boolean(workspaceId && env),
|
||||
queryKey: secretSnapshotKeys.count(workspaceId, env, folderId),
|
||||
queryFn: () => fetchWorkspaceSecretSnaphotCount(workspaceId, env, folderId)
|
||||
enabled: Boolean(workspaceId && environment) && !isPaused,
|
||||
queryKey: secretSnapshotKeys.count({ workspaceId, environment, directory }),
|
||||
queryFn: () => fetchWorkspaceSecretSnaphotCount(workspaceId, environment, directory)
|
||||
});
|
||||
|
||||
export const usePerformSecretRollback = () => {
|
||||
@@ -179,10 +178,17 @@ export const usePerformSecretRollback = () => {
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, folderId }) => {
|
||||
queryClient.invalidateQueries([{ workspaceId, environment }, "secrets"]);
|
||||
queryClient.invalidateQueries(secretSnapshotKeys.list(workspaceId, environment, folderId));
|
||||
queryClient.invalidateQueries(secretSnapshotKeys.count(workspaceId, environment, folderId));
|
||||
onSuccess: (_, { workspaceId, environment, directory }) => {
|
||||
queryClient.invalidateQueries([
|
||||
{ workspaceId, environment, secretPath: directory },
|
||||
"secrets"
|
||||
]);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.list({ workspaceId, environment, directory })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.count({ workspaceId, environment, directory })
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { UserWsKeyPair } from "../keys/types";
|
||||
import { EncryptedSecretVersion } from "../secrets/types";
|
||||
|
||||
export type TWorkspaceSecretSnapshot = {
|
||||
export type TSecretSnapshot = {
|
||||
_id: string;
|
||||
workspace: string;
|
||||
version: number;
|
||||
@@ -11,27 +11,27 @@ export type TWorkspaceSecretSnapshot = {
|
||||
__v: number;
|
||||
};
|
||||
|
||||
export type TSnapshotSecret = Omit<TWorkspaceSecretSnapshot, "secretVersions"> & {
|
||||
export type TSnapshotData = Omit<TSecretSnapshot, "secretVersions"> & {
|
||||
secretVersions: EncryptedSecretVersion[];
|
||||
folderVersion: Array<{ name: string; id: string }>;
|
||||
};
|
||||
|
||||
export type TSnapshotSecretProps = {
|
||||
export type TSnapshotDataProps = {
|
||||
snapshotId: string;
|
||||
env: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
};
|
||||
|
||||
export type GetWorkspaceSecretSnapshotsDTO = {
|
||||
export type TGetSecretSnapshotsDTO = {
|
||||
workspaceId: string;
|
||||
limit: number;
|
||||
environment: string;
|
||||
folder?: string;
|
||||
directory?: string;
|
||||
};
|
||||
|
||||
export type TSecretRollbackDTO = {
|
||||
workspaceId: string;
|
||||
version: number;
|
||||
environment: string;
|
||||
folderId?: string;
|
||||
directory?: string;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
export { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "./mutations";
|
||||
export {
|
||||
useBatchSecretsOp,
|
||||
useGetProjectSecrets,
|
||||
useGetProjectSecretsAllEnv,
|
||||
useGetSecretVersion
|
||||
} from "./queries";
|
||||
useCreateSecretBatch,
|
||||
useCreateSecretV3,
|
||||
useDeleteSecretBatch,
|
||||
useDeleteSecretV3,
|
||||
useUpdateSecretBatch,
|
||||
useUpdateSecretV3
|
||||
} from "./mutations";
|
||||
export { useGetProjectSecrets, useGetProjectSecretsAllEnv, useGetSecretVersion } from "./queries";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import crypto from "crypto";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { MutationOptions, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
@@ -8,8 +8,17 @@ import {
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { secretSnapshotKeys } from "../secretSnapshots/queries";
|
||||
import { secretKeys } from "./queries";
|
||||
import { TCreateSecretsV3DTO, TDeleteSecretsV3DTO, TUpdateSecretsV3DTO } from "./types";
|
||||
import {
|
||||
CreateSecretDTO,
|
||||
TCreateSecretBatchDTO,
|
||||
TCreateSecretsV3DTO,
|
||||
TDeleteSecretBatchDTO,
|
||||
TDeleteSecretsV3DTO,
|
||||
TUpdateSecretBatchDTO,
|
||||
TUpdateSecretsV3DTO
|
||||
} from "./types";
|
||||
|
||||
const encryptSecret = (randomBytes: string, key: string, value?: string, comment?: string) => {
|
||||
// encrypt key
|
||||
@@ -55,7 +64,11 @@ const encryptSecret = (randomBytes: string, key: string, value?: string, comment
|
||||
};
|
||||
};
|
||||
|
||||
export const useCreateSecretV3 = () => {
|
||||
export const useCreateSecretV3 = ({
|
||||
options
|
||||
}: {
|
||||
options?: Omit<MutationOptions<{}, {}, TCreateSecretsV3DTO>, "mutationFn">;
|
||||
} = {}) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<{}, {}, TCreateSecretsV3DTO>({
|
||||
mutationFn: async ({
|
||||
@@ -66,7 +79,8 @@ export const useCreateSecretV3 = () => {
|
||||
secretName,
|
||||
secretValue,
|
||||
latestFileKey,
|
||||
secretComment
|
||||
secretComment,
|
||||
skipMultilineEncoding
|
||||
}) => {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
|
||||
@@ -84,20 +98,32 @@ export const useCreateSecretV3 = () => {
|
||||
environment,
|
||||
type,
|
||||
secretPath,
|
||||
...encryptSecret(randomBytes, secretName, secretValue, secretComment)
|
||||
...encryptSecret(randomBytes, secretName, secretValue, secretComment),
|
||||
skipMultilineEncoding
|
||||
};
|
||||
const { data } = await apiRequest.post(`/api/v3/secrets/${secretName}`, reqBody);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, secretPath }) => {
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret(workspaceId, environment, secretPath)
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
}
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.list({ environment, workspaceId, directory: secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath })
|
||||
);
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateSecretV3 = () => {
|
||||
export const useUpdateSecretV3 = ({
|
||||
options
|
||||
}: {
|
||||
options?: Omit<MutationOptions<{}, {}, TUpdateSecretsV3DTO>, "mutationFn">;
|
||||
} = {}) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<{}, {}, TUpdateSecretsV3DTO>({
|
||||
mutationFn: async ({
|
||||
@@ -107,7 +133,11 @@ export const useUpdateSecretV3 = () => {
|
||||
workspaceId,
|
||||
secretName,
|
||||
secretValue,
|
||||
latestFileKey
|
||||
latestFileKey,
|
||||
tags,
|
||||
secretComment,
|
||||
newSecretName,
|
||||
skipMultilineEncoding
|
||||
}) => {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
|
||||
@@ -119,34 +149,40 @@ export const useUpdateSecretV3 = () => {
|
||||
privateKey: PRIVATE_KEY
|
||||
})
|
||||
: crypto.randomBytes(16).toString("hex");
|
||||
const { secretValueIV, secretValueTag, secretValueCiphertext } = encryptSecret(
|
||||
randomBytes,
|
||||
secretName,
|
||||
secretValue,
|
||||
""
|
||||
);
|
||||
|
||||
const reqBody = {
|
||||
workspaceId,
|
||||
environment,
|
||||
type,
|
||||
secretPath,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretValueCiphertext
|
||||
...encryptSecret(randomBytes, newSecretName ?? secretName, secretValue, secretComment),
|
||||
tags,
|
||||
skipMultilineEncoding,
|
||||
secretName: newSecretName
|
||||
};
|
||||
const { data } = await apiRequest.patch(`/api/v3/secrets/${secretName}`, reqBody);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, secretPath }) => {
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret(workspaceId, environment, secretPath)
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
}
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.list({ environment, workspaceId, directory: secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath })
|
||||
);
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteSecretV3 = () => {
|
||||
export const useDeleteSecretV3 = ({
|
||||
options
|
||||
}: {
|
||||
options?: Omit<MutationOptions<{}, {}, TDeleteSecretsV3DTO>, "mutationFn">;
|
||||
} = {}) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, TDeleteSecretsV3DTO>({
|
||||
@@ -165,8 +201,160 @@ export const useDeleteSecretV3 = () => {
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, secretPath }) => {
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret(workspaceId, environment, secretPath)
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
}
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.list({ environment, workspaceId, directory: secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath })
|
||||
);
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateSecretBatch = ({
|
||||
options
|
||||
}: {
|
||||
options?: Omit<MutationOptions<{}, {}, TCreateSecretBatchDTO>, "mutationFn">;
|
||||
} = {}) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, TCreateSecretBatchDTO>({
|
||||
mutationFn: async ({ secretPath = "/", workspaceId, environment, secrets, latestFileKey }) => {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
const randomBytes = latestFileKey
|
||||
? decryptAssymmetric({
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
})
|
||||
: crypto.randomBytes(16).toString("hex");
|
||||
|
||||
const reqBody = {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath,
|
||||
secrets: secrets.map(
|
||||
({ secretName, secretValue, secretComment, metadata, type, skipMultilineEncoding }) => ({
|
||||
secretName,
|
||||
...encryptSecret(randomBytes, secretName, secretValue, secretComment),
|
||||
type,
|
||||
metadata,
|
||||
skipMultilineEncoding
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
const { data } = await apiRequest.post("/api/v3/secrets/batch", reqBody);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, secretPath }) => {
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.list({ environment, workspaceId, directory: secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath })
|
||||
);
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateSecretBatch = ({
|
||||
options
|
||||
}: {
|
||||
options?: Omit<MutationOptions<{}, {}, TUpdateSecretBatchDTO>, "mutationFn">;
|
||||
} = {}) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, TUpdateSecretBatchDTO>({
|
||||
mutationFn: async ({ secretPath = "/", workspaceId, environment, secrets, latestFileKey }) => {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
const randomBytes = latestFileKey
|
||||
? decryptAssymmetric({
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
})
|
||||
: crypto.randomBytes(16).toString("hex");
|
||||
|
||||
const reqBody = {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath,
|
||||
secrets: secrets.map(
|
||||
({ secretName, secretValue, secretComment, type, tags, skipMultilineEncoding }) => ({
|
||||
secretName,
|
||||
...encryptSecret(randomBytes, secretName, secretValue, secretComment),
|
||||
type,
|
||||
tags,
|
||||
skipMultilineEncoding
|
||||
})
|
||||
)
|
||||
};
|
||||
|
||||
const { data } = await apiRequest.patch("/api/v3/secrets/batch", reqBody);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, secretPath }) => {
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.list({ environment, workspaceId, directory: secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath })
|
||||
);
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteSecretBatch = ({
|
||||
options
|
||||
}: {
|
||||
options?: Omit<MutationOptions<{}, {}, TDeleteSecretBatchDTO>, "mutationFn">;
|
||||
} = {}) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, TDeleteSecretBatchDTO>({
|
||||
mutationFn: async ({ secretPath = "/", workspaceId, environment, secrets }) => {
|
||||
const reqBody = {
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath,
|
||||
secrets
|
||||
};
|
||||
|
||||
const { data } = await apiRequest.delete("/api/v3/secrets/batch", {
|
||||
data: reqBody
|
||||
});
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { workspaceId, environment, secretPath }) => {
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.list({ environment, workspaceId, directory: secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.count({ environment, workspaceId, directory: secretPath })
|
||||
);
|
||||
},
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const createSecret = async (dto: CreateSecretDTO) => {
|
||||
const { data } = await apiRequest.post(`/api/v3/secrets/${dto.secretKey}`, dto);
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable no-param-reassign */
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQueries, useQuery, UseQueryOptions } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
@@ -8,218 +8,148 @@ import {
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import { apiRequest } from "@app/config/request";
|
||||
|
||||
import { secretSnapshotKeys } from "../secretSnapshots/queries";
|
||||
import { UserWsKeyPair } from "../keys/types";
|
||||
import {
|
||||
BatchSecretDTO,
|
||||
CreateSecretDTO,
|
||||
DecryptedSecret,
|
||||
EncryptedSecret,
|
||||
EncryptedSecretVersion,
|
||||
GetProjectSecretsDTO,
|
||||
GetSecretVersionsDTO,
|
||||
TGetProjectSecretsAllEnvDTO} from "./types";
|
||||
TGetProjectSecretsAllEnvDTO,
|
||||
TGetProjectSecretsDTO,
|
||||
TGetProjectSecretsKey
|
||||
} from "./types";
|
||||
|
||||
export const secretKeys = {
|
||||
// this is also used in secretSnapshot part
|
||||
getProjectSecret: (workspaceId: string, env: string | string[], folderId?: string) => [
|
||||
{ workspaceId, env, folderId },
|
||||
"secrets"
|
||||
],
|
||||
getProjectSecretImports: (workspaceId: string, env: string | string[], folderId?: string) => [
|
||||
{ workspaceId, env, folderId },
|
||||
"secrets-imports"
|
||||
],
|
||||
getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"]
|
||||
getProjectSecret: ({ workspaceId, environment, secretPath }: TGetProjectSecretsKey) =>
|
||||
[{ workspaceId, environment, secretPath }, "secrets"] as const,
|
||||
getSecretVersion: (secretId: string) => [{ secretId }, "secret-versions"] as const
|
||||
};
|
||||
|
||||
const fetchProjectEncryptedSecrets = async (
|
||||
workspaceId: string,
|
||||
env: string | string[],
|
||||
folderId?: string,
|
||||
secretPath?: string
|
||||
) => {
|
||||
const decryptSecrets = (encryptedSecrets: EncryptedSecret[], decryptFileKey: UserWsKeyPair) => {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: decryptFileKey.encryptedKey,
|
||||
nonce: decryptFileKey.nonce,
|
||||
publicKey: decryptFileKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
const personalSecrets: Record<string, { id: string; value: string }> = {};
|
||||
const secrets: DecryptedSecret[] = [];
|
||||
encryptedSecrets.forEach((encSecret) => {
|
||||
const secretKey = decryptSymmetric({
|
||||
ciphertext: encSecret.secretKeyCiphertext,
|
||||
iv: encSecret.secretKeyIV,
|
||||
tag: encSecret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
|
||||
const secretValue = decryptSymmetric({
|
||||
ciphertext: encSecret.secretValueCiphertext,
|
||||
iv: encSecret.secretValueIV,
|
||||
tag: encSecret.secretValueTag,
|
||||
key
|
||||
});
|
||||
|
||||
const secretComment = decryptSymmetric({
|
||||
ciphertext: encSecret.secretCommentCiphertext,
|
||||
iv: encSecret.secretCommentIV,
|
||||
tag: encSecret.secretCommentTag,
|
||||
key
|
||||
});
|
||||
|
||||
const decryptedSecret: DecryptedSecret = {
|
||||
_id: encSecret._id,
|
||||
env: encSecret.environment,
|
||||
key: secretKey,
|
||||
value: secretValue,
|
||||
tags: encSecret.tags,
|
||||
comment: secretComment,
|
||||
createdAt: encSecret.createdAt,
|
||||
updatedAt: encSecret.updatedAt,
|
||||
version: encSecret.version,
|
||||
skipMultilineEncoding: encSecret.skipMultilineEncoding
|
||||
};
|
||||
|
||||
if (encSecret.type === "personal") {
|
||||
personalSecrets[decryptedSecret.key] = {
|
||||
id: encSecret._id,
|
||||
value: secretValue
|
||||
};
|
||||
} else {
|
||||
secrets.push(decryptedSecret);
|
||||
}
|
||||
});
|
||||
|
||||
secrets.forEach((sec) => {
|
||||
if (personalSecrets?.[sec.key]) {
|
||||
sec.idOverride = personalSecrets[sec.key].id;
|
||||
sec.valueOverride = personalSecrets[sec.key].value;
|
||||
sec.overrideAction = "modified";
|
||||
}
|
||||
});
|
||||
|
||||
return secrets;
|
||||
};
|
||||
|
||||
const fetchProjectEncryptedSecrets = async ({
|
||||
workspaceId,
|
||||
environment,
|
||||
secretPath
|
||||
}: TGetProjectSecretsKey) => {
|
||||
const { data } = await apiRequest.get<{ secrets: EncryptedSecret[] }>("/api/v3/secrets", {
|
||||
params: {
|
||||
environment: env,
|
||||
environment,
|
||||
workspaceId,
|
||||
folderId: folderId || undefined,
|
||||
secretPath
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return data.secrets;
|
||||
};
|
||||
|
||||
export const useGetProjectSecrets = ({
|
||||
workspaceId,
|
||||
env,
|
||||
environment,
|
||||
decryptFileKey,
|
||||
isPaused,
|
||||
folderId,
|
||||
secretPath
|
||||
}: GetProjectSecretsDTO) =>
|
||||
secretPath,
|
||||
options
|
||||
}: TGetProjectSecretsDTO & {
|
||||
options?: Omit<
|
||||
UseQueryOptions<
|
||||
EncryptedSecret[],
|
||||
unknown,
|
||||
DecryptedSecret[],
|
||||
ReturnType<typeof secretKeys.getProjectSecret>
|
||||
>,
|
||||
"queryKey" | "queryFn"
|
||||
>;
|
||||
}) =>
|
||||
useQuery({
|
||||
...options,
|
||||
// wait for all values to be available
|
||||
enabled: Boolean(decryptFileKey && workspaceId && env) && !isPaused,
|
||||
queryKey: secretKeys.getProjectSecret(workspaceId, env, folderId || secretPath),
|
||||
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath),
|
||||
select: useCallback(
|
||||
(data: EncryptedSecret[]) => {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
const latestKey = decryptFileKey;
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: latestKey.encryptedKey,
|
||||
nonce: latestKey.nonce,
|
||||
publicKey: latestKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
const sharedSecrets: DecryptedSecret[] = [];
|
||||
const personalSecrets: Record<string, { id: string; value: string }> = {};
|
||||
// this used for add-only mode in dashboard
|
||||
// type won't be there thus only one key is shown
|
||||
const duplicateSecretKey: Record<string, boolean> = {};
|
||||
data.forEach((encSecret: EncryptedSecret) => {
|
||||
const secretKey = decryptSymmetric({
|
||||
ciphertext: encSecret.secretKeyCiphertext,
|
||||
iv: encSecret.secretKeyIV,
|
||||
tag: encSecret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
|
||||
const secretValue = decryptSymmetric({
|
||||
ciphertext: encSecret.secretValueCiphertext,
|
||||
iv: encSecret.secretValueIV,
|
||||
tag: encSecret.secretValueTag,
|
||||
key
|
||||
});
|
||||
|
||||
const secretComment = decryptSymmetric({
|
||||
ciphertext: encSecret.secretCommentCiphertext,
|
||||
iv: encSecret.secretCommentIV,
|
||||
tag: encSecret.secretCommentTag,
|
||||
key
|
||||
});
|
||||
|
||||
const decryptedSecret = {
|
||||
_id: encSecret._id,
|
||||
env: encSecret.environment,
|
||||
key: secretKey,
|
||||
value: secretValue,
|
||||
tags: encSecret.tags,
|
||||
comment: secretComment,
|
||||
createdAt: encSecret.createdAt,
|
||||
updatedAt: encSecret.updatedAt
|
||||
};
|
||||
|
||||
if (encSecret.type === "personal") {
|
||||
personalSecrets[`${decryptedSecret.key}-${decryptedSecret.env}`] = {
|
||||
id: encSecret._id,
|
||||
value: secretValue
|
||||
};
|
||||
} else {
|
||||
if (!duplicateSecretKey?.[`${decryptedSecret.key}-${decryptedSecret.env}`]) {
|
||||
sharedSecrets.push(decryptedSecret);
|
||||
}
|
||||
duplicateSecretKey[`${decryptedSecret.key}-${decryptedSecret.env}`] = true;
|
||||
}
|
||||
});
|
||||
sharedSecrets.forEach((val) => {
|
||||
const dupKey = `${val.key}-${val.env}`;
|
||||
if (personalSecrets?.[dupKey]) {
|
||||
val.idOverride = personalSecrets[dupKey].id;
|
||||
val.valueOverride = personalSecrets[dupKey].value;
|
||||
val.overrideAction = "modified";
|
||||
}
|
||||
});
|
||||
return { secrets: sharedSecrets };
|
||||
},
|
||||
[decryptFileKey]
|
||||
)
|
||||
enabled: Boolean(decryptFileKey && workspaceId && environment) && (options?.enabled ?? true),
|
||||
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }),
|
||||
queryFn: async () => fetchProjectEncryptedSecrets({ workspaceId, environment, secretPath }),
|
||||
select: (secrets: EncryptedSecret[]) => decryptSecrets(secrets, decryptFileKey)
|
||||
});
|
||||
|
||||
export const useGetProjectSecretsAllEnv = ({
|
||||
workspaceId,
|
||||
envs,
|
||||
decryptFileKey,
|
||||
folderId,
|
||||
secretPath
|
||||
}: TGetProjectSecretsAllEnvDTO) => {
|
||||
const secrets = useQueries({
|
||||
queries: envs.map((env) => ({
|
||||
queryKey: secretKeys.getProjectSecret(workspaceId, env, secretPath || folderId),
|
||||
enabled: Boolean(decryptFileKey && workspaceId && env),
|
||||
queryFn: () => fetchProjectEncryptedSecrets(workspaceId, env, folderId, secretPath),
|
||||
select: (data: EncryptedSecret[]) => {
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
const latestKey = decryptFileKey;
|
||||
const key = decryptAssymmetric({
|
||||
ciphertext: latestKey.encryptedKey,
|
||||
nonce: latestKey.nonce,
|
||||
publicKey: latestKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
});
|
||||
|
||||
const sharedSecrets: Record<string, DecryptedSecret> = {};
|
||||
const personalSecrets: Record<string, { id: string; value: string }> = {};
|
||||
// this used for add-only mode in dashboard
|
||||
// type won't be there thus only one key is shown
|
||||
const duplicateSecretKey: Record<string, boolean> = {};
|
||||
data.forEach((encSecret: EncryptedSecret) => {
|
||||
const secretKey = decryptSymmetric({
|
||||
ciphertext: encSecret.secretKeyCiphertext,
|
||||
iv: encSecret.secretKeyIV,
|
||||
tag: encSecret.secretKeyTag,
|
||||
key
|
||||
});
|
||||
|
||||
const secretValue = decryptSymmetric({
|
||||
ciphertext: encSecret.secretValueCiphertext,
|
||||
iv: encSecret.secretValueIV,
|
||||
tag: encSecret.secretValueTag,
|
||||
key
|
||||
});
|
||||
|
||||
const secretComment = decryptSymmetric({
|
||||
ciphertext: encSecret.secretCommentCiphertext,
|
||||
iv: encSecret.secretCommentIV,
|
||||
tag: encSecret.secretCommentTag,
|
||||
key
|
||||
});
|
||||
|
||||
const decryptedSecret = {
|
||||
_id: encSecret._id,
|
||||
env: encSecret.environment,
|
||||
key: secretKey,
|
||||
value: secretValue,
|
||||
tags: encSecret.tags,
|
||||
comment: secretComment,
|
||||
createdAt: encSecret.createdAt,
|
||||
updatedAt: encSecret.updatedAt
|
||||
};
|
||||
|
||||
if (encSecret.type === "personal") {
|
||||
personalSecrets[decryptedSecret.key] = {
|
||||
id: encSecret._id,
|
||||
value: secretValue
|
||||
};
|
||||
} else {
|
||||
if (!duplicateSecretKey?.[decryptedSecret.key]) {
|
||||
sharedSecrets[decryptedSecret.key] = decryptedSecret;
|
||||
}
|
||||
duplicateSecretKey[decryptedSecret.key] = true;
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(sharedSecrets).forEach((val) => {
|
||||
if (personalSecrets?.[val]) {
|
||||
sharedSecrets[val].idOverride = personalSecrets[val].id;
|
||||
sharedSecrets[val].valueOverride = personalSecrets[val].value;
|
||||
sharedSecrets[val].overrideAction = "modified";
|
||||
}
|
||||
});
|
||||
return sharedSecrets;
|
||||
}
|
||||
queries: envs.map((environment) => ({
|
||||
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath }),
|
||||
enabled: Boolean(decryptFileKey && workspaceId && environment),
|
||||
queryFn: async () => fetchProjectEncryptedSecrets({ workspaceId, environment, secretPath }),
|
||||
select: (secs: EncryptedSecret[]) =>
|
||||
decryptSecrets(secs, decryptFileKey).reduce<Record<string, DecryptedSecret>>(
|
||||
(prev, curr) => ({ ...prev, [curr.key]: curr }),
|
||||
{}
|
||||
)
|
||||
}))
|
||||
});
|
||||
|
||||
@@ -303,46 +233,3 @@ export const useGetSecretVersion = (dto: GetSecretVersionsDTO) =>
|
||||
[dto.decryptFileKey]
|
||||
)
|
||||
});
|
||||
|
||||
export const useBatchSecretsOp = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, BatchSecretDTO>({
|
||||
mutationFn: async (dto) => {
|
||||
const { data } = await apiRequest.post("/api/v2/secrets/batch", dto);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, dto) => {
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret(dto.workspaceId, dto.environment, dto.folderId)
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.list(dto.workspaceId, dto.environment, dto?.folderId)
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.count(dto.workspaceId, dto.environment, dto?.folderId)
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const createSecret = async (dto: CreateSecretDTO) => {
|
||||
const { data } = await apiRequest.post(`/api/v3/secrets/${dto.secretKey}`, dto);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const useCreateSecret = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{}, {}, CreateSecretDTO>({
|
||||
mutationFn: async (dto) => {
|
||||
const data = createSecret(dto);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, dto) => {
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret(dto.workspaceId, dto.environment)
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ export type EncryptedSecret = {
|
||||
__v: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
secretCommentCiphertext: string;
|
||||
secretCommentIV: string;
|
||||
secretCommentTag: string;
|
||||
@@ -24,6 +25,7 @@ export type EncryptedSecret = {
|
||||
|
||||
export type DecryptedSecret = {
|
||||
_id: string;
|
||||
version: number;
|
||||
key: string;
|
||||
value: string;
|
||||
comment: string;
|
||||
@@ -35,6 +37,7 @@ export type DecryptedSecret = {
|
||||
idOverride?: string;
|
||||
overrideAction?: string;
|
||||
folderId?: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
};
|
||||
|
||||
export type EncryptedSecretVersion = {
|
||||
@@ -53,55 +56,21 @@ export type EncryptedSecretVersion = {
|
||||
secretValueTag: string;
|
||||
tags: WsTag[];
|
||||
__v: number;
|
||||
skipMultilineEncoding?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
// dto
|
||||
type SecretTagArg = { _id: string; name: string; slug: string };
|
||||
|
||||
export type UpdateSecretArg = {
|
||||
_id: string;
|
||||
folderId?: string;
|
||||
type: "shared" | "personal";
|
||||
secretName: string;
|
||||
secretKeyCiphertext: string;
|
||||
secretKeyIV: string;
|
||||
secretKeyTag: string;
|
||||
secretValueCiphertext: string;
|
||||
secretValueIV: string;
|
||||
secretValueTag: string;
|
||||
secretCommentCiphertext: string;
|
||||
secretCommentIV: string;
|
||||
secretCommentTag: string;
|
||||
tags: SecretTagArg[];
|
||||
};
|
||||
|
||||
export type CreateSecretArg = Omit<UpdateSecretArg, "_id">;
|
||||
|
||||
export type DeleteSecretArg = { _id: string, secretName: string; };
|
||||
|
||||
export type BatchSecretDTO = {
|
||||
export type TGetProjectSecretsKey = {
|
||||
workspaceId: string;
|
||||
folderId: string;
|
||||
environment: string;
|
||||
requests: Array<
|
||||
| { method: "POST"; secret: CreateSecretArg }
|
||||
| { method: "PATCH"; secret: UpdateSecretArg }
|
||||
| { method: "DELETE"; secret: DeleteSecretArg }
|
||||
>;
|
||||
secretPath?: string;
|
||||
};
|
||||
|
||||
export type GetProjectSecretsDTO = {
|
||||
workspaceId: string;
|
||||
env: string | string[];
|
||||
export type TGetProjectSecretsDTO = {
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
folderId?: string;
|
||||
secretPath?: string;
|
||||
isPaused?: boolean;
|
||||
include_imports?: boolean;
|
||||
onSuccess?: (data: DecryptedSecret[]) => void;
|
||||
};
|
||||
} & TGetProjectSecretsKey;
|
||||
|
||||
export type TGetProjectSecretsAllEnvDTO = {
|
||||
workspaceId: string;
|
||||
@@ -124,6 +93,7 @@ export type TCreateSecretsV3DTO = {
|
||||
secretName: string;
|
||||
secretValue: string;
|
||||
secretComment: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
secretPath: string;
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
@@ -136,19 +106,63 @@ export type TUpdateSecretsV3DTO = {
|
||||
environment: string;
|
||||
type: string;
|
||||
secretPath: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
newSecretName?: string;
|
||||
secretName: string;
|
||||
secretValue: string;
|
||||
secretComment?: string;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export type TDeleteSecretsV3DTO = {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
type: string;
|
||||
type: "shared" | "personal";
|
||||
secretPath: string;
|
||||
secretName: string;
|
||||
};
|
||||
|
||||
// --- v3
|
||||
export type TCreateSecretBatchDTO = {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
latestFileKey: UserWsKeyPair;
|
||||
secrets: Array<{
|
||||
secretName: string;
|
||||
secretValue: string;
|
||||
secretComment: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
type: "shared" | "personal";
|
||||
metadata?: {
|
||||
source?: string;
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
export type TUpdateSecretBatchDTO = {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
latestFileKey: UserWsKeyPair;
|
||||
secrets: Array<{
|
||||
type: "shared" | "personal";
|
||||
secretName: string;
|
||||
skipMultilineEncoding?: boolean;
|
||||
secretValue: string;
|
||||
secretComment: string;
|
||||
tags?: string[];
|
||||
}>;
|
||||
};
|
||||
|
||||
export type TDeleteSecretBatchDTO = {
|
||||
workspaceId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secrets: Array<{
|
||||
secretName: string;
|
||||
type: "shared" | "personal";
|
||||
}>;
|
||||
};
|
||||
|
||||
export type CreateSecretDTO = {
|
||||
workspaceId: string;
|
||||
@@ -167,5 +181,5 @@ export type CreateSecretDTO = {
|
||||
secretPath: string;
|
||||
metadata?: {
|
||||
source?: string;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,6 +5,9 @@ export type { TCloudIntegration, TIntegration } from "./integrations/types";
|
||||
export type { UserWsKeyPair } from "./keys/types";
|
||||
export type { Organization } from "./organization/types";
|
||||
export type { TSecretApprovalPolicy } from "./secretApproval/types";
|
||||
export type { TSecretFolder } from "./secretFolders/types";
|
||||
export type { TImportedSecrets, TSecretImports } from "./secretImports/types";
|
||||
export * from "./secrets/types";
|
||||
export type { CreateServiceTokenDTO, ServiceToken } from "./serviceTokens/types";
|
||||
export type { SubscriptionPlan } from "./subscriptions/types";
|
||||
export type { WsTag } from "./tags/types";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Head from "next/head";
|
||||
|
||||
import { DashboardPage } from "@app/views/DashboardPage";
|
||||
import { SecretMainPage } from "@app/views/SecretMainPage";
|
||||
|
||||
const Dashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -16,7 +16,7 @@ const Dashboard = () => {
|
||||
<meta name="og:description" content={String(t("dashboard.og-description"))} />
|
||||
</Head>
|
||||
<div className="h-full">
|
||||
<DashboardPage />
|
||||
<SecretMainPage />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
27
frontend/src/pages/project/[id]/secrets/v2/[env].tsx
Normal file
27
frontend/src/pages/project/[id]/secrets/v2/[env].tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Head from "next/head";
|
||||
|
||||
import { SecretMainPage } from "@app/views/SecretMainPage";
|
||||
|
||||
const Dashboard = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{t("common.head-title", { title: t("dashboard.title") })}</title>
|
||||
<link rel="icon" href="/infisical.ico" />
|
||||
<meta property="og:image" content="/images/message.png" />
|
||||
<meta property="og:title" content={String(t("dashboard.og-title"))} />
|
||||
<meta name="og:description" content={String(t("dashboard.og-description"))} />
|
||||
</Head>
|
||||
<div className="h-full">
|
||||
<SecretMainPage />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
|
||||
Dashboard.requireAuth = true;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,289 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
import crypto from "crypto";
|
||||
|
||||
import * as yup from "yup";
|
||||
|
||||
import {
|
||||
decryptAssymmetric,
|
||||
encryptSymmetric
|
||||
} from "@app/components/utilities/cryptography/crypto";
|
||||
import { BatchSecretDTO, DecryptedSecret } from "@app/hooks/api/secrets/types";
|
||||
|
||||
export enum SecretActionType {
|
||||
Created = "created",
|
||||
Modified = "modified",
|
||||
Deleted = "deleted"
|
||||
}
|
||||
|
||||
export const DEFAULT_SECRET_VALUE = {
|
||||
_id: undefined,
|
||||
overrideAction: undefined,
|
||||
idOverride: undefined,
|
||||
valueOverride: undefined,
|
||||
comment: "",
|
||||
key: "",
|
||||
value: "",
|
||||
tags: []
|
||||
};
|
||||
|
||||
const secretSchema = yup.object({
|
||||
_id: yup.string(),
|
||||
key: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required()
|
||||
.label("Secret key")
|
||||
.test("starts-with-number", "Should start with an alphabet", (val) =>
|
||||
Boolean(val?.charAt(0)?.match(/[a-zA-Z]/i))
|
||||
)
|
||||
.test({
|
||||
name: "duplicate-keys",
|
||||
// TODO:(akhilmhdh) ts keeps throwing from not found need to see how to resolve this
|
||||
test: (val, ctx: any) => {
|
||||
const secrets: Array<{ key: string }> = ctx?.from?.[1]?.value?.secrets || [];
|
||||
const duplicateKeys: Record<number, boolean> = {};
|
||||
secrets?.forEach(({ key }, index) => {
|
||||
if (key === val) duplicateKeys[index + 1] = true;
|
||||
});
|
||||
const pos = Object.keys(duplicateKeys);
|
||||
if (pos.length <= 1) {
|
||||
return true;
|
||||
}
|
||||
return ctx.createError({ message: `Same key in row ${pos.join(", ")}` });
|
||||
}
|
||||
}),
|
||||
value: yup.string().trim(),
|
||||
comment: yup.string().trim(),
|
||||
tags: yup.array(
|
||||
yup.object({
|
||||
_id: yup.string().required(),
|
||||
name: yup.string().required(),
|
||||
slug: yup.string().required(),
|
||||
tagColor: yup.string().nullable(),
|
||||
})
|
||||
),
|
||||
overrideAction: yup.string().notRequired().oneOf(Object.values(SecretActionType)),
|
||||
idOverride: yup.string().notRequired(),
|
||||
valueOverride: yup.string().trim().notRequired()
|
||||
});
|
||||
|
||||
export const schema = yup.object({
|
||||
isSnapshotMode: yup.bool().notRequired(),
|
||||
secrets: yup.array(secretSchema)
|
||||
});
|
||||
|
||||
export type FormData = yup.InferType<typeof schema>;
|
||||
export type TSecretDetailsOpen = { index: number; id: string };
|
||||
export type TSecOverwriteOpt = { secrets: Record<string, { comments: string[]; value: string }> };
|
||||
|
||||
// to convert multi line into single line ones by quoting them and changing to string \n
|
||||
const formatMultiValueEnv = (val?: string) => {
|
||||
if (!val) return "";
|
||||
if (!val.match("\n")) return val;
|
||||
return `"${val.replace(/\n/g, "\\n")}"`;
|
||||
};
|
||||
|
||||
export const downloadSecret = (
|
||||
secrets: FormData["secrets"] = [],
|
||||
importedSecrets: { key: string; value?: string; comment?: string }[] = [],
|
||||
env: string = "unknown"
|
||||
) => {
|
||||
const importSecPos: Record<string, number> = {};
|
||||
importedSecrets.forEach((el, index) => {
|
||||
importSecPos[el.key] = index;
|
||||
});
|
||||
const finalSecret = [...importedSecrets];
|
||||
secrets.forEach(({ key, value, valueOverride, overrideAction, comment }) => {
|
||||
const finalVal =
|
||||
overrideAction && overrideAction !== SecretActionType.Deleted ? valueOverride : value;
|
||||
const newValue = {
|
||||
key,
|
||||
value: formatMultiValueEnv(finalVal),
|
||||
comment
|
||||
};
|
||||
// can also be zero thus failing
|
||||
if (typeof importSecPos?.[key] === "undefined") {
|
||||
finalSecret.push(newValue);
|
||||
} else {
|
||||
finalSecret[importSecPos[key]] = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
let file = "";
|
||||
finalSecret.forEach(({ key, value, comment }) => {
|
||||
if (comment) {
|
||||
file += `# ${comment}\n${key}=${value}\n`;
|
||||
return;
|
||||
}
|
||||
file += `${key}=${value}\n`;
|
||||
});
|
||||
|
||||
const blob = new Blob([file]);
|
||||
const fileDownloadUrl = URL.createObjectURL(blob);
|
||||
const alink = document.createElement("a");
|
||||
alink.href = fileDownloadUrl;
|
||||
alink.download = `${env}.env`;
|
||||
alink.click();
|
||||
};
|
||||
|
||||
/*
|
||||
* Below functions are used convert the dashboard secrets to the bulk secret creation request format
|
||||
* They are encrypted back
|
||||
* Formatted to [ { request: "", secret:{} } ]
|
||||
*/
|
||||
const encryptASecret = (randomBytes: string, key: string, value?: string, comment?: string) => {
|
||||
// encrypt key
|
||||
const {
|
||||
ciphertext: secretKeyCiphertext,
|
||||
iv: secretKeyIV,
|
||||
tag: secretKeyTag
|
||||
} = encryptSymmetric({
|
||||
plaintext: key,
|
||||
key: randomBytes
|
||||
});
|
||||
|
||||
// encrypt value
|
||||
const {
|
||||
ciphertext: secretValueCiphertext,
|
||||
iv: secretValueIV,
|
||||
tag: secretValueTag
|
||||
} = encryptSymmetric({
|
||||
plaintext: value ?? "",
|
||||
key: randomBytes
|
||||
});
|
||||
|
||||
// encrypt comment
|
||||
const {
|
||||
ciphertext: secretCommentCiphertext,
|
||||
iv: secretCommentIV,
|
||||
tag: secretCommentTag
|
||||
} = encryptSymmetric({
|
||||
plaintext: comment ?? "",
|
||||
key: randomBytes
|
||||
});
|
||||
|
||||
return {
|
||||
secretKeyCiphertext,
|
||||
secretKeyIV,
|
||||
secretKeyTag,
|
||||
secretValueCiphertext,
|
||||
secretValueIV,
|
||||
secretValueTag,
|
||||
secretCommentCiphertext,
|
||||
secretCommentIV,
|
||||
secretCommentTag
|
||||
};
|
||||
};
|
||||
|
||||
const deepCompareSecrets = (lhs: DecryptedSecret, rhs: any) =>
|
||||
lhs.key === rhs.key &&
|
||||
lhs.value === rhs.value &&
|
||||
lhs.comment === rhs.comment &&
|
||||
lhs?.valueOverride === rhs?.valueOverride &&
|
||||
JSON.stringify(lhs.tags) === JSON.stringify(rhs.tags);
|
||||
|
||||
export const transformSecretsToBatchSecretReq = (
|
||||
deletedSecretIds: { id: string; secretName: string; }[],
|
||||
latestFileKey: any,
|
||||
secrets: FormData["secrets"],
|
||||
intialValues: DecryptedSecret[] = []
|
||||
) => {
|
||||
// deleted secrets
|
||||
const secretsToBeDeleted: BatchSecretDTO["requests"] = deletedSecretIds.map(({ id, secretName }) => ({
|
||||
method: "DELETE",
|
||||
secret: {
|
||||
_id: id,
|
||||
secretName
|
||||
}
|
||||
}));
|
||||
|
||||
const secretsToBeUpdated: BatchSecretDTO["requests"] = [];
|
||||
const secretsToBeCreated: BatchSecretDTO["requests"] = [];
|
||||
const PRIVATE_KEY = localStorage.getItem("PRIVATE_KEY") as string;
|
||||
|
||||
const randomBytes = latestFileKey
|
||||
? decryptAssymmetric({
|
||||
ciphertext: latestFileKey.encryptedKey,
|
||||
nonce: latestFileKey.nonce,
|
||||
publicKey: latestFileKey.sender.publicKey,
|
||||
privateKey: PRIVATE_KEY
|
||||
})
|
||||
: crypto.randomBytes(16).toString("hex");
|
||||
|
||||
secrets?.forEach((secret) => {
|
||||
const {
|
||||
_id,
|
||||
idOverride,
|
||||
value,
|
||||
valueOverride,
|
||||
overrideAction,
|
||||
tags = [],
|
||||
comment,
|
||||
key
|
||||
} = secret;
|
||||
if (!idOverride && overrideAction === SecretActionType.Created) {
|
||||
secretsToBeCreated.push({
|
||||
method: "POST",
|
||||
secret: {
|
||||
type: "personal",
|
||||
tags,
|
||||
secretName: key,
|
||||
...encryptASecret(randomBytes, key, valueOverride, comment)
|
||||
}
|
||||
});
|
||||
}
|
||||
// to be created ones as they don't have server generated id
|
||||
if (!_id) {
|
||||
secretsToBeCreated.push({
|
||||
method: "POST",
|
||||
secret: {
|
||||
type: "shared",
|
||||
tags,
|
||||
secretName: key,
|
||||
...encryptASecret(randomBytes, key, value, comment)
|
||||
}
|
||||
});
|
||||
return; // exit as updated and delete case won't happen when created
|
||||
}
|
||||
// has an id means this is updated one
|
||||
if (_id) {
|
||||
// check value has changed or not
|
||||
const initialSecretValue = intialValues?.find(({ _id: secId }) => secId === _id)!;
|
||||
if (!deepCompareSecrets(initialSecretValue, secret)) {
|
||||
secretsToBeUpdated.push({
|
||||
method: "PATCH",
|
||||
secret: {
|
||||
_id,
|
||||
type: "shared",
|
||||
tags,
|
||||
secretName: key,
|
||||
...encryptASecret(randomBytes, key, value, comment)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (idOverride) {
|
||||
// if action is deleted meaning override has been removed but id is kept to collect at this point
|
||||
if (overrideAction === SecretActionType.Deleted) {
|
||||
secretsToBeDeleted.push({ method: "DELETE", secret: { _id: idOverride, secretName: key } });
|
||||
} else {
|
||||
// if not deleted action then as id is there its an updated
|
||||
const initialSecretValue = intialValues?.find(({ _id: secId }) => secId === _id)!;
|
||||
if (!deepCompareSecrets(initialSecretValue, secret)) {
|
||||
secretsToBeUpdated.push({
|
||||
method: "PATCH",
|
||||
secret: {
|
||||
_id: idOverride,
|
||||
type: "personal",
|
||||
tags,
|
||||
secretName: key,
|
||||
...encryptASecret(randomBytes, key, valueOverride, comment)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return secretsToBeCreated.concat(secretsToBeUpdated, secretsToBeDeleted);
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { FormControl, Input, Spinner } from "@app/components/v2";
|
||||
import { useGetProjectSecrets, useGetUserWsKey } from "@app/hooks/api";
|
||||
|
||||
type SecretValueProps = {
|
||||
workspaceId: string;
|
||||
envName: string;
|
||||
env: string;
|
||||
secretKey: string;
|
||||
};
|
||||
|
||||
const SecretValue = ({ workspaceId, env, envName, secretKey }: SecretValueProps) => {
|
||||
const { data: latestFileKey } = useGetUserWsKey(workspaceId);
|
||||
const { data: secret, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
workspaceId,
|
||||
env,
|
||||
decryptFileKey: latestFileKey!
|
||||
});
|
||||
|
||||
const getValue = useCallback(
|
||||
(data: typeof secret) => {
|
||||
const sec = data?.secrets?.find(({ key: secKey }) => secKey === secretKey);
|
||||
return sec?.value || "Not found";
|
||||
},
|
||||
[secretKey]
|
||||
);
|
||||
|
||||
return (
|
||||
<FormControl label={envName}>
|
||||
<Input
|
||||
className={`w-full text-ellipsis font-mono focus:ring-transparent ${getValue(secret) === "Not found" && "text-mineshaft-500"}`}
|
||||
value={getValue(secret)}
|
||||
isReadOnly
|
||||
rightIcon={isSecretsLoading ? <Spinner /> : undefined}
|
||||
/>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
type Props = {
|
||||
workspaceId: string;
|
||||
secretKey: string;
|
||||
envs: Array<{ name: string; slug: string }>;
|
||||
};
|
||||
|
||||
export const CompareSecret = ({ workspaceId, secretKey, envs }: Props): JSX.Element => {
|
||||
// should not do anything until secretKey is available
|
||||
if (!secretKey) return <div />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{envs.map(({ name, slug }) => (
|
||||
<SecretValue
|
||||
workspaceId={workspaceId}
|
||||
key={`secret-comparison-${slug}`}
|
||||
envName={name}
|
||||
env={slug}
|
||||
secretKey={secretKey}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { CompareSecret } from "./CompareSecret";
|
||||
@@ -1,191 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import {
|
||||
faCheck
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { Button, FormControl, Input, ModalClose, Tooltip } from "@app/components/v2";
|
||||
|
||||
import { isValidHexColor } from "../../../../components/utilities/isValidHexColor";
|
||||
import { secretTagsColors } from "../../../../const"
|
||||
import { TagColor } from "../../../../hooks/api/tags/types";
|
||||
|
||||
|
||||
type Props = {
|
||||
onCreateTag: (tagName: string, tagColor: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const createTagSchema = yup.object({
|
||||
name: yup.string().required().trim().label("Tag Name")
|
||||
});
|
||||
type FormData = yup.InferType<typeof createTagSchema>;
|
||||
|
||||
export const CreateTagModal = ({ onCreateTag }: Props): JSX.Element => {
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<FormData>({
|
||||
resolver: yupResolver(createTagSchema)
|
||||
});
|
||||
|
||||
const [tagsColors] = useState<TagColor[]>(secretTagsColors)
|
||||
const [selectedTagColor, setSelectedTagColor] = useState<TagColor>(tagsColors[0])
|
||||
const [showHexInput, setShowHexInput] = useState<boolean>(false)
|
||||
const [tagColor, setTagColor] = useState<string>("")
|
||||
|
||||
|
||||
const onFormSubmit = async ({ name }: FormData) => {
|
||||
await onCreateTag(name, tagColor);
|
||||
reset();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const clonedTagColors = [...tagsColors]
|
||||
const selectedTagBgColor = clonedTagColors.find($tagColor => $tagColor.selected);
|
||||
|
||||
if (selectedTagBgColor) {
|
||||
setSelectedTagColor(selectedTagBgColor);
|
||||
setTagColor(selectedTagBgColor.hex);
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const tagsList = document.querySelector(".secret-tags-wrapper")
|
||||
const tagsHexWrapper = document.querySelector(".tags-hex-wrapper")
|
||||
|
||||
if (showHexInput) {
|
||||
tagsList?.classList.add("hide-tags")
|
||||
tagsList?.classList.remove("show-tags")
|
||||
tagsHexWrapper?.classList.add("show-hex-input")
|
||||
tagsHexWrapper?.classList.remove("hide-hex-input")
|
||||
} else {
|
||||
tagsList?.classList.remove("hide-tags")
|
||||
tagsList?.classList.add("show-tags")
|
||||
tagsHexWrapper?.classList.remove("show-hex-input")
|
||||
tagsHexWrapper?.classList.add("hide-hex-input")
|
||||
}
|
||||
}, [showHexInput])
|
||||
|
||||
|
||||
const handleColorChange = (clickedTagColor: TagColor) => {
|
||||
const updatedTagColors = [...tagsColors];
|
||||
const clickedTagColorIndex = updatedTagColors.findIndex(($tagColor) => $tagColor.id === clickedTagColor.id);
|
||||
const updatedClickedTagColor = updatedTagColors[clickedTagColorIndex];
|
||||
|
||||
updatedTagColors.forEach((tgColor) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
tgColor.selected = false;
|
||||
});
|
||||
|
||||
if (selectedTagColor.id !== clickedTagColor.id) {
|
||||
updatedClickedTagColor.selected = !updatedClickedTagColor.selected;
|
||||
setSelectedTagColor(updatedClickedTagColor);
|
||||
setTagColor(updatedClickedTagColor.hex);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="name"
|
||||
defaultValue=""
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Tag Name" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} placeholder="Type your tag name" />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-2">
|
||||
<div className="mb-0.5 ml-1 block text-sm font-normal text-mineshaft-400">Tag Color</div>
|
||||
<div className="flex gap-2 h-[50px]">
|
||||
<div className="w-[12%] h-[2.813rem] inline-flex font-inter items-center justify-center border relative rounded-md border-mineshaft-500 bg-mineshaft-900 hover:bg-mineshaft-800">
|
||||
<div className="w-[26px] h-[26px] rounded-full" style={{ background: `${tagColor}` }} />
|
||||
</div>
|
||||
|
||||
<div className="w-[88%] h-[2.813rem] flex-wrap inline-flex gap-3 items-center border rounded-md border-mineshaft-500 bg-mineshaft-900 hover:bg-mineshaft-800 relative">
|
||||
<div className="flex-wrap inline-flex gap-3 items-center secret-tags-wrapper pl-3">
|
||||
{
|
||||
tagsColors.map(($tagColor: TagColor) => {
|
||||
return (
|
||||
<div key={`tag-color-${$tagColor.id}`}>
|
||||
<Tooltip content={`${$tagColor.name}`}>
|
||||
<div className=" flex items-center justify-center w-[26px] h-[26px] hover:ring-offset-2 hover:ring-2 bg-[#bec2c8] border-2 p-2 hover:shadow-lg border-transparent hover:border-black rounded-full"
|
||||
key={`tag-${$tagColor.id}`}
|
||||
style={{ backgroundColor: `${$tagColor.hex}` }}
|
||||
|
||||
onClick={() => handleColorChange($tagColor)}
|
||||
tabIndex={0} role="button"
|
||||
onKeyDown={() => { }}
|
||||
>
|
||||
{
|
||||
$tagColor.selected && <FontAwesomeIcon icon={faCheck} style={{ color: "#00000070" }} />
|
||||
}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-2 tags-hex-wrapper" >
|
||||
<div className="w-1/6 flex items-center relative rounded-md hover:bg-mineshaft-800">
|
||||
{
|
||||
isValidHexColor(tagColor) && (
|
||||
<div className="w-[26px] h-[26px] rounded-full flex items-center justify-center" style={{ background: `${tagColor}` }}>
|
||||
<FontAwesomeIcon icon={faCheck} style={{ color: "#00000070" }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
!isValidHexColor(tagColor) && (
|
||||
<div className="border-dashed border bg-blue rounded-full w-[26px] h-[26px] border-mineshaft-500" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="w-10/12">
|
||||
<Input
|
||||
variant="plain"
|
||||
className="w-full focus:text-bunker-100 focus:ring-transparent bg-transparent"
|
||||
autoCapitalization={false}
|
||||
value={tagColor}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTagColor(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-[26px] h-[26px] flex items-center justify-center absolute top-[10px] right-[-4px] translate-x-[-50%]">
|
||||
<div className="border-mineshaft-500 border h-[2.1rem] mr-4 absolute right-5" />
|
||||
<div className={`flex items-center justify-center w-[26px] h-[26px] bg-transparent cursor-pointer hover:ring-offset-1 hover:ring-2 border-mineshaft-500 border bg-mineshaft-900 rounded-[3px] p-2 ${showHexInput ? "tags-conic-bg rounded-full" : ""}`} onClick={() => setShowHexInput((prev) => !prev)} style={{ border: "1px solid rgba(220, 216, 254, 0.376)" }}
|
||||
tabIndex={0} role="button"
|
||||
onKeyDown={() => { }}>
|
||||
{
|
||||
!showHexInput && <span>#</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
|
||||
Create
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export {CreateTagModal} from "./CreateTagModal"
|
||||
@@ -1,108 +0,0 @@
|
||||
import { memo } from "react";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faEdit, faFolder, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { IconButton, Tooltip } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
|
||||
type Props = {
|
||||
folders?: Array<{ id: string; name: string }>;
|
||||
search?: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
onFolderUpdate: (folderId: string, name: string) => void;
|
||||
onFolderDelete: (folderId: string, name: string) => void;
|
||||
onFolderOpen: (folderId: string) => void;
|
||||
};
|
||||
|
||||
export const FolderSection = memo(
|
||||
({
|
||||
onFolderUpdate: handleFolderUpdate,
|
||||
onFolderDelete: handleFolderDelete,
|
||||
onFolderOpen: handleFolderOpen,
|
||||
search = "",
|
||||
folders = [],
|
||||
environment,
|
||||
secretPath
|
||||
}: Props) => {
|
||||
return (
|
||||
<>
|
||||
{folders
|
||||
.filter(({ name }) => name.toLowerCase().includes(search.toLowerCase()))
|
||||
.map(({ id, name }) => (
|
||||
<tr
|
||||
key={id}
|
||||
className="group flex cursor-default flex-row items-center hover:bg-mineshaft-700"
|
||||
>
|
||||
<td className="ml-0.5 flex h-10 w-10 items-center justify-center border-none px-4">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-yellow-700" />
|
||||
</td>
|
||||
<td
|
||||
colSpan={2}
|
||||
className="relative flex w-full min-w-[220px] items-center justify-between overflow-hidden text-ellipsis lg:min-w-[240px] xl:min-w-[280px]"
|
||||
style={{ paddingTop: "0", paddingBottom: "0" }}
|
||||
>
|
||||
<div
|
||||
className="flex-grow cursor-default p-2"
|
||||
onKeyDown={() => null}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={() => handleFolderOpen(id)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Settings" className="capitalize">
|
||||
<IconButton
|
||||
size="md"
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => handleFolderUpdate(id, name)}
|
||||
ariaLabel="expand"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEdit} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Delete" className="capitalize">
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={() => handleFolderDelete(id, name)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FolderSection.displayName = "FolderSection";
|
||||
@@ -1,3 +0,0 @@
|
||||
export { FolderForm } from "./FolderForm";
|
||||
export { FolderSection } from "./FolderSection";
|
||||
export * from "./types";
|
||||
@@ -1,2 +0,0 @@
|
||||
export type TEditFolderForm = { id: string; name: string };
|
||||
export type TDeleteFolderForm = { id: string; name: string };
|
||||
@@ -1,237 +0,0 @@
|
||||
import { useFormContext, useWatch } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faCircle, faCircleDot, faShuffle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
FormControl,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Switch,
|
||||
TextArea
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
import { FormData, SecretActionType } from "../../DashboardPage.utils";
|
||||
import { GenRandomNumber } from "./GenRandomNumber";
|
||||
|
||||
type Props = {
|
||||
isDrawerOpen: boolean;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
index: number;
|
||||
isReadOnly?: boolean;
|
||||
onEnvCompare: (secretKey: string) => void;
|
||||
secretVersion?: Array<{ id: string; createdAt: string; value: string }>;
|
||||
// to record the ids of deleted ones
|
||||
onSecretDelete: (index: number, secretName: string, id?: string, overrideId?: string) => void;
|
||||
onSave: () => void;
|
||||
};
|
||||
|
||||
export const SecretDetailDrawer = ({
|
||||
isDrawerOpen,
|
||||
onOpenChange,
|
||||
index,
|
||||
secretVersion = [],
|
||||
isReadOnly,
|
||||
onSecretDelete,
|
||||
onSave,
|
||||
onEnvCompare,
|
||||
environment,
|
||||
secretPath
|
||||
}: Props): JSX.Element => {
|
||||
const [canRevealSecVal, setCanRevealSecVal] = useToggle();
|
||||
const [canRevealSecOverride, setCanRevealSecOverride] = useToggle();
|
||||
|
||||
const { register, setValue, control, getValues } = useFormContext<FormData>();
|
||||
|
||||
const overrideAction = useWatch({ control, name: `secrets.${index}.overrideAction` });
|
||||
const isOverridden =
|
||||
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
|
||||
|
||||
const onSecretOverride = () => {
|
||||
const secret = getValues(`secrets.${index}`);
|
||||
if (isOverridden) {
|
||||
// when user created a new override but then removes
|
||||
if (SecretActionType.Created) {
|
||||
setValue(`secrets.${index}.valueOverride`, "", { shouldDirty: true });
|
||||
}
|
||||
setValue(`secrets.${index}.overrideAction`, SecretActionType.Deleted, { shouldDirty: true });
|
||||
} else {
|
||||
setValue(
|
||||
`secrets.${index}.overrideAction`,
|
||||
secret?.idOverride ? SecretActionType.Modified : SecretActionType.Created,
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer onOpenChange={onOpenChange} isOpen={isDrawerOpen}>
|
||||
<DrawerContent
|
||||
className="dark border-l border-mineshaft-500 bg-bunker"
|
||||
title="Secret"
|
||||
footerContent={
|
||||
<div className="flex flex-col space-y-2 pt-4 shadow-md">
|
||||
<div>
|
||||
<Button
|
||||
variant="star"
|
||||
onClick={() => onEnvCompare(getValues(`secrets.${index}.key`))}
|
||||
isFullWidth
|
||||
isDisabled={isReadOnly}
|
||||
>
|
||||
Compare secret across environments
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex w-full space-x-2">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button isFullWidth onClick={onSave} isDisabled={isReadOnly || !isAllowed}>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
colorSchema="danger"
|
||||
isDisabled={isReadOnly || !isAllowed}
|
||||
onClick={() => {
|
||||
const secret = getValues(`secrets.${index}`);
|
||||
|
||||
onSecretDelete(index, secret.key, secret._id, secret.idOverride);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="dark:[color-scheme:dark]">
|
||||
<FormControl label="Key">
|
||||
<Input isDisabled {...register(`secrets.${index}.key`)} />
|
||||
</FormControl>
|
||||
<FormControl label="Value">
|
||||
<Popover>
|
||||
<Input
|
||||
isReadOnly={isOverridden || isReadOnly}
|
||||
{...register(`secrets.${index}.value`)}
|
||||
placeholder="EMPTY"
|
||||
onBlur={setCanRevealSecVal.off}
|
||||
onFocus={setCanRevealSecVal.on}
|
||||
type={canRevealSecVal ? "text" : "password"}
|
||||
rightIcon={
|
||||
<PopoverTrigger disabled={isOverridden || isReadOnly}>
|
||||
<FontAwesomeIcon icon={faShuffle} />
|
||||
</PopoverTrigger>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
hideCloseBtn
|
||||
className="w-auto border-mineshaft-500 bg-bunker p-0"
|
||||
align="end"
|
||||
>
|
||||
<GenRandomNumber
|
||||
onGenerate={(val) =>
|
||||
setValue(`secrets.${index}.value`, val, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormControl>
|
||||
<div className="mb-2 border-t border-mineshaft-600 pt-4">
|
||||
<Switch
|
||||
id="personal-override"
|
||||
onCheckedChange={onSecretOverride}
|
||||
isChecked={isOverridden}
|
||||
isDisabled={isReadOnly}
|
||||
>
|
||||
Override with a personal value
|
||||
</Switch>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Popover>
|
||||
<Input
|
||||
isReadOnly={!isOverridden || isReadOnly}
|
||||
{...register(`secrets.${index}.valueOverride`)}
|
||||
placeholder="EMPTY"
|
||||
type={canRevealSecOverride ? "text" : "password"}
|
||||
onBlur={setCanRevealSecOverride.off}
|
||||
onFocus={setCanRevealSecOverride.on}
|
||||
rightIcon={
|
||||
<PopoverTrigger disabled={!isOverridden || isReadOnly}>
|
||||
<FontAwesomeIcon icon={faShuffle} />
|
||||
</PopoverTrigger>
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
hideCloseBtn
|
||||
className="w-auto border-mineshaft-500 bg-bunker p-0"
|
||||
align="end"
|
||||
>
|
||||
<GenRandomNumber
|
||||
onGenerate={(val) =>
|
||||
setValue(`secrets.${index}.valueOverride`, val, { shouldDirty: true })
|
||||
}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</FormControl>
|
||||
<div className="dark mb-4 text-sm text-bunker-300">
|
||||
<div className="mb-2">Version History</div>
|
||||
<div className="flex h-48 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-bunker-800 p-2 dark:[color-scheme:dark]">
|
||||
{secretVersion?.map(({ createdAt, value, id }, i) => (
|
||||
<div key={id} className="flex flex-col space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={i === 0 ? faCircleDot : faCircle} size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
{new Date(createdAt).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-1.5 flex items-center space-x-2 border-l border-bunker-300 pl-4">
|
||||
<div className="self-start rounded-sm bg-primary-500/30 px-1">Value:</div>
|
||||
<div className="break-all font-mono">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<FormControl label="Comments & Notes">
|
||||
<TextArea
|
||||
className="border border-mineshaft-600 text-sm"
|
||||
isDisabled={isReadOnly}
|
||||
{...register(`secrets.${index}.comment`)}
|
||||
rows={5}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export {SecretDetailDrawer} from "./SecretDetailDrawer"
|
||||
@@ -1,451 +0,0 @@
|
||||
import { ChangeEvent, DragEvent, useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faSquareCheck } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faClone,
|
||||
faKey,
|
||||
faSearch,
|
||||
faSquareXmark,
|
||||
faUpload
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import * as yup from "yup";
|
||||
|
||||
import GlobPatternExamples from "@app/components/basic/popups/GlobPatternExamples";
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality
|
||||
import { parseDotEnv } from "@app/components/utilities/parseDotEnv";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Skeleton,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { useDebounce, usePopUp, useToggle } from "@app/hooks";
|
||||
import { useGetProjectSecrets } from "@app/hooks/api";
|
||||
import { UserWsKeyPair } from "@app/hooks/api/types";
|
||||
|
||||
const formSchema = yup.object({
|
||||
environment: yup.string().required().label("Environment").trim(),
|
||||
secretPath: yup
|
||||
.string()
|
||||
.required()
|
||||
.label("Secret Path")
|
||||
.trim()
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
),
|
||||
secrets: yup.lazy((val) => {
|
||||
const valSchema: Record<string, yup.StringSchema> = {};
|
||||
Object.keys(val).forEach((key) => {
|
||||
valSchema[key] = yup.string().trim();
|
||||
});
|
||||
return yup.object(valSchema);
|
||||
})
|
||||
});
|
||||
|
||||
type TFormSchema = yup.InferType<typeof formSchema>;
|
||||
|
||||
const parseJson = (src: ArrayBuffer) => {
|
||||
const file = src.toString();
|
||||
const formatedData: Record<string, string> = JSON.parse(file);
|
||||
const env: Record<string, { value: string; comments: string[] }> = {};
|
||||
Object.keys(formatedData).forEach((key) => {
|
||||
if (typeof formatedData[key] === "string") {
|
||||
env[key] = { value: formatedData[key], comments: [] };
|
||||
}
|
||||
});
|
||||
return env;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
isSmaller: boolean;
|
||||
onParsedEnv: (env: Record<string, { value: string; comments: string[] }>) => void;
|
||||
onAddNewSecret?: () => void;
|
||||
environments?: { name: string; slug: string }[];
|
||||
workspaceId: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
export const SecretDropzone = ({
|
||||
isSmaller,
|
||||
onParsedEnv,
|
||||
onAddNewSecret,
|
||||
environments = [],
|
||||
workspaceId,
|
||||
decryptFileKey,
|
||||
environment,
|
||||
secretPath
|
||||
}: Props): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const [isDragActive, setDragActive] = useToggle();
|
||||
const [isLoading, setIsLoading] = useToggle();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["importSecEnv"] as const);
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
register,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isDirty }
|
||||
} = useForm<TFormSchema>({
|
||||
resolver: yupResolver(formSchema),
|
||||
defaultValues: { secretPath: "/", environment: environments?.[0]?.slug }
|
||||
});
|
||||
|
||||
const envCopySecPath = watch("secretPath");
|
||||
const selectedEnvSlug = watch("environment");
|
||||
const debouncedEnvCopySecretPath = useDebounce(envCopySecPath);
|
||||
|
||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
workspaceId,
|
||||
env: selectedEnvSlug,
|
||||
secretPath: debouncedEnvCopySecretPath,
|
||||
isPaused:
|
||||
!(Boolean(workspaceId) && Boolean(selectedEnvSlug) && Boolean(debouncedEnvCopySecretPath)) &&
|
||||
!popUp.importSecEnv.isOpen,
|
||||
decryptFileKey
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setValue("secrets", {});
|
||||
setSearchFilter("");
|
||||
}, [debouncedEnvCopySecretPath]);
|
||||
|
||||
const handleDrag = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive.on();
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive.off();
|
||||
}
|
||||
};
|
||||
|
||||
const parseFile = (file?: File, isJson?: boolean) => {
|
||||
const reader = new FileReader();
|
||||
if (!file) {
|
||||
createNotification({
|
||||
text: "You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.",
|
||||
type: "error",
|
||||
timeoutMs: 10000
|
||||
});
|
||||
return;
|
||||
}
|
||||
// const fileType = file.name.split('.')[1];
|
||||
setIsLoading.on();
|
||||
reader.onload = (event) => {
|
||||
if (!event?.target?.result) return;
|
||||
// parse function's argument looks like to be ArrayBuffer
|
||||
const env = isJson
|
||||
? parseJson(event.target.result as ArrayBuffer)
|
||||
: parseDotEnv(event.target.result as ArrayBuffer);
|
||||
setIsLoading.off();
|
||||
onParsedEnv(env);
|
||||
};
|
||||
|
||||
// If something is wrong show an error
|
||||
try {
|
||||
reader.readAsText(file);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!e.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setDragActive.off();
|
||||
parseFile(e.dataTransfer.files[0]);
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
parseFile(e.target?.files?.[0], e.target?.files?.[0]?.type === "application/json");
|
||||
};
|
||||
|
||||
const handleFormSubmit = (data: TFormSchema) => {
|
||||
const secretsToBePulled: Record<string, { value: string; comments: string[] }> = {};
|
||||
Object.keys(data.secrets || {}).forEach((key) => {
|
||||
if (data.secrets[key]) {
|
||||
secretsToBePulled[key] = {
|
||||
value: (shouldIncludeValues && data.secrets[key]) || "",
|
||||
comments: [""]
|
||||
};
|
||||
}
|
||||
});
|
||||
onParsedEnv(secretsToBePulled);
|
||||
handlePopUpClose("importSecEnv");
|
||||
reset();
|
||||
};
|
||||
|
||||
const handleSecSelectAll = () => {
|
||||
if (secrets?.secrets) {
|
||||
setValue(
|
||||
"secrets",
|
||||
secrets?.secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={twMerge(
|
||||
"relative mx-0.5 mb-4 mt-4 flex cursor-pointer items-center justify-center rounded-md bg-mineshaft-900 py-4 px-2 text-sm text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100",
|
||||
isDragActive && "opacity-100",
|
||||
!isSmaller && "w-full max-w-3xl flex-col space-y-4 py-20",
|
||||
isLoading && "bg-bunker-800"
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="mb-16 flex items-center justify-center pt-16">
|
||||
<img src="/images/loading/loading.gif" height={70} width={120} alt="loading animation" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="justify-cente flex flex-col items-center space-y-2">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faUpload} size={isSmaller ? "2x" : "5x"} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="">{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}</p>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<input
|
||||
id="fileSelect"
|
||||
disabled={!isAllowed}
|
||||
type="file"
|
||||
className="absolute h-full w-full cursor-pointer opacity-0"
|
||||
accept=".txt,.env,.yml,.yaml,.json"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex w-full flex-row items-center justify-center py-4",
|
||||
isSmaller && "py-1"
|
||||
)}
|
||||
>
|
||||
<div className="w-1/5 border-t border-mineshaft-700" />
|
||||
<p className="mx-4 text-xs text-mineshaft-400">OR</p>
|
||||
<div className="w-1/5 border-t border-mineshaft-700" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center space-x-8">
|
||||
<Modal
|
||||
isOpen={popUp.importSecEnv.isOpen}
|
||||
onOpenChange={(isOpen) => {
|
||||
handlePopUpToggle("importSecEnv", isOpen);
|
||||
reset();
|
||||
setSearchFilter("");
|
||||
}}
|
||||
>
|
||||
<ModalTrigger asChild>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
isDisabled={!isAllowed}
|
||||
variant="star"
|
||||
size={isSmaller ? "xs" : "sm"}
|
||||
>
|
||||
Copy Secrets From An Environment
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</ModalTrigger>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
title="Copy Secret From An Environment"
|
||||
subTitle="Copy/paste secrets from other environments into this context"
|
||||
>
|
||||
<form>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<FormControl label="Environment" isRequired className="w-1/3">
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
position="popper"
|
||||
>
|
||||
{environments.map((sourceEnvironment) => (
|
||||
<SelectItem
|
||||
value={sourceEnvironment.slug}
|
||||
key={`source-environment-${sourceEnvironment.slug}`}
|
||||
>
|
||||
{sourceEnvironment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<FormControl
|
||||
label="Secret Path"
|
||||
className="flex-grow"
|
||||
isRequired
|
||||
icon={<GlobPatternExamples />}
|
||||
>
|
||||
<Input
|
||||
{...register("secretPath")}
|
||||
placeholder="Provide a path, default is /"
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className="border-t border-mineshaft-600 pt-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>Secrets</div>
|
||||
<div className="flex w-1/2 items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Search for secret"
|
||||
value={searchFilter}
|
||||
size="xs"
|
||||
leftIcon={<FontAwesomeIcon icon={faSearch} />}
|
||||
onChange={(evt) => setSearchFilter(evt.target.value)}
|
||||
/>
|
||||
<Tooltip content="Select All">
|
||||
<IconButton
|
||||
ariaLabel="Select all"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={handleSecSelectAll}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSquareCheck} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content="Unselect All">
|
||||
<IconButton
|
||||
ariaLabel="UnSelect all"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={() => reset()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSquareXmark} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{!isSecretsLoading && !secrets?.secrets?.length && (
|
||||
<EmptyState title="No secrets found" icon={faKey} />
|
||||
)}
|
||||
<div className="thin-scrollbar grid max-h-64 grid-cols-2 gap-4 overflow-auto ">
|
||||
{isSecretsLoading &&
|
||||
Array.apply(0, Array(2)).map((_x, i) => (
|
||||
<Skeleton
|
||||
key={`secret-pull-loading-${i + 1}`}
|
||||
className="bg-mineshaft-700"
|
||||
/>
|
||||
))}
|
||||
|
||||
{secrets?.secrets
|
||||
?.filter(({ key }) =>
|
||||
key.toLowerCase().includes(searchFilter.toLowerCase())
|
||||
)
|
||||
?.map(({ _id, key, value: secVal }) => (
|
||||
<Controller
|
||||
key={`pull-secret--${_id}`}
|
||||
control={control}
|
||||
name={`secrets.${key}`}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Checkbox
|
||||
id={`pull-secret-${_id}`}
|
||||
isChecked={Boolean(value)}
|
||||
onCheckedChange={(isChecked) => onChange(isChecked ? secVal : "")}
|
||||
>
|
||||
{key}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 mb-4">
|
||||
<Checkbox
|
||||
id="populate-include-value"
|
||||
isChecked={shouldIncludeValues}
|
||||
onCheckedChange={(isChecked) =>
|
||||
setShouldIncludeValues(isChecked as boolean)
|
||||
}
|
||||
>
|
||||
Include secret values
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faClone} />}
|
||||
type="submit"
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Paste Secrets
|
||||
</Button>
|
||||
<Button variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
{!isSmaller && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button variant="star" onClick={onAddNewSecret} isDisabled={!isAllowed}>
|
||||
Add a new secret
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,86 +0,0 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
|
||||
import { Button, FormControl, Input, ModalClose, Select, SelectItem } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
onCreate: (environment: string, secretPath: string) => Promise<void>;
|
||||
environments?: Array<{ slug: string; name: string }>;
|
||||
};
|
||||
|
||||
const formSchema = yup.object({
|
||||
environment: yup.string().required().label("Environment").trim(),
|
||||
secretPath: yup
|
||||
.string()
|
||||
.required()
|
||||
.label("Secret Path")
|
||||
.trim()
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
)
|
||||
});
|
||||
|
||||
type TFormData = yup.InferType<typeof formSchema>;
|
||||
|
||||
export const SecretImportForm = ({ onCreate, environments = [] }: Props): JSX.Element => {
|
||||
const {
|
||||
control,
|
||||
reset,
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TFormData>({
|
||||
resolver: yupResolver(formSchema)
|
||||
});
|
||||
|
||||
const onSubmit = async ({ environment, secretPath }: TFormData) => {
|
||||
await onCreate(environment, secretPath);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{environments.map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-8 flex items-center">
|
||||
<Button className="mr-4" type="submit" isDisabled={isSubmitting} isLoading={isSubmitting}>
|
||||
Create
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export { SecretImportForm } from "./SecretImportForm";
|
||||
@@ -1,200 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
import { subject } from "@casl/ability";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import {
|
||||
faFileImport,
|
||||
faFolder,
|
||||
faKey,
|
||||
faUpDown,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { EmptyState, IconButton, SecretInput, TableContainer, Tooltip } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
type Props = {
|
||||
onDelete: (environment: string, secretPath: string) => void;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
importedEnv: string;
|
||||
importedSecPath: string;
|
||||
importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[];
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
// to show the environment and folder icon
|
||||
export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: string }) => (
|
||||
<div className="inline-flex items-center space-x-2">
|
||||
<div style={{ minWidth: "96px" }}>{env || "-"}</div>
|
||||
{secretPath && (
|
||||
<div className="inline-flex items-center space-x-2 border-l border-mineshaft-600 pl-2">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-green-700 text-md" />
|
||||
<span>{secretPath}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SecretImportItem = ({
|
||||
importedEnv,
|
||||
importedSecPath,
|
||||
onDelete,
|
||||
importedSecrets = [],
|
||||
searchTerm = "",
|
||||
secretPath,
|
||||
environment
|
||||
}: Props) => {
|
||||
const [isExpanded, setIsExpanded] = useToggle();
|
||||
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: `${importedEnv}-${importedSecPath}`
|
||||
});
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv);
|
||||
|
||||
useEffect(() => {
|
||||
const filteredSecrets = importedSecrets.filter((secret) =>
|
||||
secret.key.toUpperCase().includes(searchTerm.toUpperCase())
|
||||
);
|
||||
|
||||
if (filteredSecrets.length > 0 && searchTerm) {
|
||||
setIsExpanded.on();
|
||||
} else {
|
||||
setIsExpanded.off();
|
||||
}
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
setIsExpanded.off();
|
||||
}
|
||||
}, [isDragging]);
|
||||
|
||||
const style = {
|
||||
transform: transform ? `translateY(${transform.y ? Math.round(transform.y) : 0}px)` : "",
|
||||
transition
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="group flex cursor-default flex-row items-center hover:bg-mineshaft-700"
|
||||
onClick={() => setIsExpanded.toggle()}
|
||||
>
|
||||
<td
|
||||
className={`ml-0.5 flex h-10 w-10 items-center justify-center border-none px-4 ${
|
||||
isExpanded && "border-t-2 border-mineshaft-500"
|
||||
}`}
|
||||
>
|
||||
<Tooltip content="Secret Import" className="capitalize">
|
||||
<FontAwesomeIcon icon={faFileImport} className="text-green-700" />
|
||||
</Tooltip>
|
||||
</td>
|
||||
<td
|
||||
colSpan={2}
|
||||
className="relative flex w-full min-w-[220px] items-center justify-between overflow-hidden text-ellipsis lg:min-w-[240px] xl:min-w-[280px]"
|
||||
style={{ paddingTop: "0", paddingBottom: "0" }}
|
||||
>
|
||||
<div className="flex-grow p-2">
|
||||
<EnvFolderIcon env={rowEnv?.name || ""} secretPath={importedSecPath} />
|
||||
</div>
|
||||
<div className="duration-0 flex h-10 w-16 items-center justify-end space-x-2.5 overflow-hidden border-l border-mineshaft-600 transition-all">
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Change Order" className="capitalize">
|
||||
<IconButton
|
||||
size="md"
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
ariaLabel="expand"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUpDown} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Delete" className="capitalize">
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
isDisabled={!isAllowed}
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
onDelete(importedEnv, importedSecPath);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
{isExpanded && !isDragging && (
|
||||
<td
|
||||
colSpan={3}
|
||||
className={`bg-bunker-800 ${isExpanded && "border-b-2 border-mineshaft-500"}`}
|
||||
>
|
||||
<div className="rounded-md bg-bunker-700 p-1">
|
||||
<TableContainer>
|
||||
<table className="secret-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Key</td>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Value</td>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Override</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{importedSecrets?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3}>
|
||||
<EmptyState title="No secrets found" icon={faKey} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{importedSecrets
|
||||
.filter((secret) =>
|
||||
secret.key.toUpperCase().includes(searchTerm.toUpperCase())
|
||||
)
|
||||
.map(({ key, value, overriden }, index) => (
|
||||
<tr key={`${importedEnv}-${importedSecPath}-${key}-${index + 1}`}>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
{key}
|
||||
</td>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
<SecretInput value={value} isDisabled isVisible />
|
||||
</td>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
<EnvFolderIcon
|
||||
env={overriden?.env}
|
||||
secretPath={overriden?.secretPath}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,107 +0,0 @@
|
||||
import { memo } from "react";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
|
||||
|
||||
import { SecretImportItem } from "./SecretImportItem";
|
||||
|
||||
type TImportedSecrets = Array<{
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
folderId: string;
|
||||
secrets: DecryptedSecret[];
|
||||
}>;
|
||||
|
||||
const SECRET_IN_DASHBOARD = "Present In Dashboard";
|
||||
|
||||
export const computeImportedSecretRows = (
|
||||
importedSecEnv: string,
|
||||
importedSecPath: string,
|
||||
importSecrets: TImportedSecrets = [],
|
||||
secrets: DecryptedSecret[] = [],
|
||||
environments: { name: string; slug: string }[] = []
|
||||
) => {
|
||||
const importedSecIndex = importSecrets.findIndex(
|
||||
({ secretPath, environment }) =>
|
||||
secretPath === importedSecPath && importedSecEnv === environment
|
||||
);
|
||||
if (importedSecIndex === -1) return [];
|
||||
|
||||
const importedSec = importSecrets[importedSecIndex];
|
||||
|
||||
const overridenSec: Record<string, { env: string; secretPath: string }> = {};
|
||||
const envSlug2Name: Record<string, string> = {};
|
||||
environments.forEach((el) => {
|
||||
envSlug2Name[el.slug] = el.name;
|
||||
});
|
||||
|
||||
for (let i = importedSecIndex + 1; i < importSecrets.length; i += 1) {
|
||||
importSecrets[i].secrets.forEach((el) => {
|
||||
overridenSec[el.key] = {
|
||||
env: envSlug2Name?.[importSecrets[i].environment] || "unknown",
|
||||
secretPath: importSecrets[i].secretPath
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
secrets.forEach((el) => {
|
||||
overridenSec[el.key] = { env: SECRET_IN_DASHBOARD, secretPath: "" };
|
||||
});
|
||||
|
||||
return importedSec.secrets.map(({ key, value }) => ({
|
||||
key,
|
||||
value,
|
||||
overriden: overridenSec?.[key]
|
||||
}));
|
||||
};
|
||||
|
||||
type Props = {
|
||||
secrets?: DecryptedSecret[];
|
||||
importedSecrets?: TImportedSecrets;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
onSecretImportDelete: (env: string, secPath: string) => void;
|
||||
items: { id: string; environment: string; secretPath: string }[];
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
export const SecretImportSection = memo(
|
||||
({
|
||||
secrets = [],
|
||||
environment,
|
||||
secretPath,
|
||||
importedSecrets = [],
|
||||
onSecretImportDelete,
|
||||
items = [],
|
||||
searchTerm = ""
|
||||
}: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
|
||||
return (
|
||||
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||
{items.map(({ secretPath: impSecPath, environment: importSecEnv, id }) => (
|
||||
<SecretImportItem
|
||||
key={id}
|
||||
importedEnv={importSecEnv}
|
||||
importedSecrets={computeImportedSecretRows(
|
||||
importSecEnv,
|
||||
impSecPath,
|
||||
importedSecrets,
|
||||
secrets,
|
||||
environments
|
||||
)}
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
onDelete={onSecretImportDelete}
|
||||
importedSecPath={impSecPath}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SecretImportSection.displayName = "SecretImportSection";
|
||||
@@ -1 +0,0 @@
|
||||
export { SecretImportSection } from "./SecretImportSection";
|
||||
@@ -1,525 +0,0 @@
|
||||
/* eslint-disable react/jsx-no-useless-fragment */
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Control,
|
||||
Controller,
|
||||
useFieldArray,
|
||||
UseFormRegister,
|
||||
UseFormSetValue,
|
||||
useWatch
|
||||
} from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faCheck,
|
||||
faCodeBranch,
|
||||
faComment,
|
||||
faCopy,
|
||||
faEllipsis,
|
||||
faInfoCircle,
|
||||
faTags,
|
||||
faXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { cx } from "cva";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
// TODO:(akhilmhdh): Refactor this
|
||||
import AddTagPopoverContent from "@app/components/AddTagPopoverContent/AddTagPopoverContent";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
FormControl,
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
SecretInput,
|
||||
Tag,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { WsTag } from "@app/hooks/api/types";
|
||||
|
||||
import { FormData, SecretActionType } from "../../DashboardPage.utils";
|
||||
|
||||
type Props = {
|
||||
index: number;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
// backend generated unique id
|
||||
secUniqId?: string;
|
||||
// permission and external state's that decided to hide or show
|
||||
isReadOnly?: boolean;
|
||||
isAddOnly?: boolean;
|
||||
isRollbackMode?: boolean;
|
||||
isSecretValueHidden: boolean;
|
||||
searchTerm: string;
|
||||
// to record the ids of deleted ones
|
||||
onSecretDelete: (index: number, secretName: string, id?: string, overrideId?: string) => void;
|
||||
// sidebar control props
|
||||
onRowExpand: (secId: string | undefined, index: number) => void;
|
||||
// tag props
|
||||
wsTags?: WsTag[];
|
||||
onCreateTagOpen: () => void;
|
||||
// rhf specific functions, dont put this using useFormContext. This is passed as props to avoid re-rendering
|
||||
control: Control<FormData>;
|
||||
register: UseFormRegister<FormData>;
|
||||
setValue: UseFormSetValue<FormData>;
|
||||
isKeyError?: boolean;
|
||||
keyError?: string;
|
||||
autoCapitalization?: boolean;
|
||||
};
|
||||
|
||||
export const SecretInputRow = memo(
|
||||
({
|
||||
index,
|
||||
secretPath,
|
||||
environment,
|
||||
isSecretValueHidden,
|
||||
onRowExpand,
|
||||
isReadOnly,
|
||||
isRollbackMode,
|
||||
isAddOnly,
|
||||
wsTags,
|
||||
onCreateTagOpen,
|
||||
onSecretDelete,
|
||||
searchTerm,
|
||||
control,
|
||||
register,
|
||||
setValue,
|
||||
isKeyError,
|
||||
keyError,
|
||||
secUniqId,
|
||||
autoCapitalization
|
||||
}: Props): JSX.Element => {
|
||||
const isKeySubDisabled = useRef<boolean>(false);
|
||||
// comment management in a row
|
||||
const {
|
||||
fields: secretTags,
|
||||
remove,
|
||||
append
|
||||
} = useFieldArray({ control, name: `secrets.${index}.tags` });
|
||||
|
||||
// display the tags in alphabetical order
|
||||
secretTags.sort((a, b) => a?.name?.localeCompare(b?.name));
|
||||
|
||||
// to get details on a secret
|
||||
const overrideAction = useWatch({
|
||||
control,
|
||||
name: `secrets.${index}.overrideAction`,
|
||||
exact: true
|
||||
});
|
||||
const idOverride = useWatch({ control, name: `secrets.${index}.idOverride`, exact: true });
|
||||
const secComment = useWatch({ control, name: `secrets.${index}.comment`, exact: true });
|
||||
const hasComment = Boolean(secComment);
|
||||
const secKey = useWatch({
|
||||
control,
|
||||
name: `secrets.${index}.key`,
|
||||
disabled: isKeySubDisabled.current,
|
||||
exact: true
|
||||
});
|
||||
const secValue = useWatch({
|
||||
control,
|
||||
name: `secrets.${index}.value`,
|
||||
disabled: isKeySubDisabled.current,
|
||||
exact: true
|
||||
});
|
||||
const secValueOverride = useWatch({
|
||||
control,
|
||||
name: `secrets.${index}.valueOverride`,
|
||||
disabled: isKeySubDisabled.current,
|
||||
exact: true
|
||||
});
|
||||
// when secret is override by personal values
|
||||
const isOverridden =
|
||||
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
|
||||
const [hoveredTag, setHoveredTag] = useState<WsTag | null>(null);
|
||||
|
||||
const handleTagOnMouseEnter = (wsTag: WsTag) => {
|
||||
setHoveredTag(wsTag);
|
||||
};
|
||||
|
||||
const handleTagOnMouseLeave = () => {
|
||||
setHoveredTag(null);
|
||||
};
|
||||
|
||||
const checkIfTagIsVisible = (wsTag: WsTag) => wsTag._id === hoveredTag?._id;
|
||||
|
||||
const secId = useWatch({ control, name: `secrets.${index}._id`, exact: true });
|
||||
const tags =
|
||||
useWatch({ control, name: `secrets.${index}.tags`, exact: true, defaultValue: [] }) || [];
|
||||
|
||||
const selectedTagIds = tags.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({ ...prev, [curr.slug]: true }),
|
||||
{}
|
||||
);
|
||||
|
||||
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isSecValueCopied) {
|
||||
timer = setTimeout(() => setIsSecValueCopied.off(), 2000);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [isSecValueCopied]);
|
||||
|
||||
const copyTokenToClipboard = () => {
|
||||
navigator.clipboard.writeText((secValueOverride || secValue) as string);
|
||||
setIsSecValueCopied.on();
|
||||
};
|
||||
|
||||
const onSecretOverride = () => {
|
||||
if (isOverridden) {
|
||||
// when user created a new override but then removes
|
||||
if (overrideAction === SecretActionType.Created)
|
||||
setValue(`secrets.${index}.valueOverride`, "");
|
||||
setValue(`secrets.${index}.overrideAction`, SecretActionType.Deleted, {
|
||||
shouldDirty: true
|
||||
});
|
||||
} else {
|
||||
setValue(`secrets.${index}.valueOverride`, "");
|
||||
setValue(
|
||||
`secrets.${index}.overrideAction`,
|
||||
idOverride ? SecretActionType.Modified : SecretActionType.Created,
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectTag = (selectedTag: WsTag) => {
|
||||
const shouldAppend = !selectedTagIds[selectedTag.slug];
|
||||
if (shouldAppend) {
|
||||
const { _id: id, name, slug, tagColor } = selectedTag;
|
||||
append({ _id: id, name, slug, tagColor });
|
||||
} else {
|
||||
const pos = tags.findIndex(({ slug }: { slug: string }) => selectedTag.slug === slug);
|
||||
remove(pos);
|
||||
}
|
||||
};
|
||||
const isCreatedSecret = !secId;
|
||||
const shouldBeBlockedInAddOnly = !isCreatedSecret && isAddOnly;
|
||||
|
||||
// Why this instead of filter in parent
|
||||
// Because rhf field.map has default values so basically
|
||||
// keys are not updated there and index needs to kept so that we can monitor
|
||||
// values individually here
|
||||
if (
|
||||
!(
|
||||
secKey?.toUpperCase().includes(searchTerm?.toUpperCase()) ||
|
||||
tags
|
||||
?.map((tag) => tag.name)
|
||||
.join(" ")
|
||||
?.toUpperCase()
|
||||
.includes(searchTerm?.toUpperCase()) ||
|
||||
secComment?.toUpperCase().includes(searchTerm?.toUpperCase())
|
||||
)
|
||||
) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<tr className="group flex flex-row hover:bg-mineshaft-700" key={index}>
|
||||
<td className="flex h-10 w-10 items-center justify-center border-none px-4">
|
||||
<div className="w-10 text-center text-xs text-bunker-400">{index + 1}</div>
|
||||
</td>
|
||||
<Controller
|
||||
control={control}
|
||||
defaultValue=""
|
||||
name={`secrets.${index}.key`}
|
||||
render={({ field }) => (
|
||||
<HoverCard openDelay={0} open={isKeyError ? undefined : false}>
|
||||
<HoverCardTrigger asChild>
|
||||
<td className={cx(isKeyError ? "rounded ring ring-red/50" : null)}>
|
||||
<div className="relative flex w-full min-w-[220px] items-center justify-end lg:min-w-[240px] xl:min-w-[280px]">
|
||||
<Input
|
||||
autoComplete="off"
|
||||
onFocus={() => {
|
||||
isKeySubDisabled.current = true;
|
||||
}}
|
||||
variant="plain"
|
||||
isDisabled={isReadOnly || shouldBeBlockedInAddOnly || isRollbackMode}
|
||||
className="w-full focus:text-bunker-100 focus:ring-transparent"
|
||||
{...field}
|
||||
onBlur={() => {
|
||||
isKeySubDisabled.current = false;
|
||||
field.onBlur();
|
||||
}}
|
||||
autoCapitalization={autoCapitalization}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-auto py-2 pt-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-red" />
|
||||
</div>
|
||||
<div className="text-sm">{keyError}</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
/>
|
||||
<td
|
||||
className="flex w-full flex-grow flex-row border-r border-none border-red"
|
||||
style={{ padding: "0.5rem 0 0.5rem 1rem" }}
|
||||
>
|
||||
<div className="w-full flex items-center">
|
||||
{isOverridden ? (
|
||||
<Controller
|
||||
control={control}
|
||||
key={`secrets.${index}.valueOverride`}
|
||||
name={`secrets.${index}.valueOverride`}
|
||||
render={({ field }) => (
|
||||
<SecretInput
|
||||
key={`secrets.${index}.valueOverride`}
|
||||
isDisabled={
|
||||
isReadOnly ||
|
||||
isRollbackMode ||
|
||||
(isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
|
||||
}
|
||||
isVisible={!isSecretValueHidden}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Controller
|
||||
control={control}
|
||||
key={`secrets.${index}.value`}
|
||||
name={`secrets.${index}.value`}
|
||||
render={({ field }) => (
|
||||
<SecretInput
|
||||
key={`secrets.${index}.value`}
|
||||
isVisible={!isSecretValueHidden}
|
||||
isDisabled={
|
||||
isReadOnly ||
|
||||
isRollbackMode ||
|
||||
(isOverridden ? isAddOnly : shouldBeBlockedInAddOnly)
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="min-w-sm flex">
|
||||
<div className="flex h-8 items-center pl-2">
|
||||
{secretTags.map(({ id, slug, tagColor }) => {
|
||||
return (
|
||||
<>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div>
|
||||
<Tag
|
||||
// isDisabled={isReadOnly || isAddOnly || isRollbackMode}
|
||||
// onClose={() => remove(i)}
|
||||
key={id}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="rounded-full border-mineshaft-500 bg-transparent flex items-center gap-1.5 justify-around">
|
||||
<div
|
||||
className="w-[10px] h-[10px] rounded-full"
|
||||
style={{ background: tagColor || "#bec2c8" }}
|
||||
/>
|
||||
{slug}
|
||||
</div>
|
||||
</Tag>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<AddTagPopoverContent
|
||||
wsTags={wsTags}
|
||||
secKey={secKey || "this secret"}
|
||||
selectedTagIds={selectedTagIds}
|
||||
handleSelectTag={(wsTag: WsTag) => onSelectTag(wsTag)}
|
||||
handleTagOnMouseEnter={(wsTag: WsTag) => handleTagOnMouseEnter(wsTag)}
|
||||
handleTagOnMouseLeave={() => handleTagOnMouseLeave()}
|
||||
checkIfTagIsVisible={(wsTag: WsTag) => checkIfTagIsVisible(wsTag)}
|
||||
handleOnCreateTagOpen={() => onCreateTagOpen()}
|
||||
/>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
<div className="w-0 overflow-hidden group-hover:w-6">
|
||||
<Tooltip content="Copy value">
|
||||
<IconButton
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="add-tag"
|
||||
className="py-[0.42rem]"
|
||||
onClick={copyTokenToClipboard}
|
||||
>
|
||||
<FontAwesomeIcon icon={isSecValueCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!(isReadOnly || isAddOnly || isRollbackMode) && (
|
||||
<div className="duration-0 ml-1 overflow-hidden">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div className="w-0 group-hover:w-6 data-[state=open]:w-6">
|
||||
<ProjectPermissionCan
|
||||
renderTooltip
|
||||
allowedLabel="Add Tags"
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
isDisabled={!isAllowed}
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="add-tags"
|
||||
className="py-[0.42rem]"
|
||||
>
|
||||
<FontAwesomeIcon icon={faTags} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<AddTagPopoverContent
|
||||
wsTags={wsTags}
|
||||
secKey={secKey || "this secret"}
|
||||
selectedTagIds={selectedTagIds}
|
||||
handleSelectTag={(wsTag: WsTag) => onSelectTag(wsTag)}
|
||||
handleTagOnMouseEnter={(wsTag: WsTag) => handleTagOnMouseEnter(wsTag)}
|
||||
handleTagOnMouseLeave={() => handleTagOnMouseLeave()}
|
||||
checkIfTagIsVisible={(wsTag: WsTag) => checkIfTagIsVisible(wsTag)}
|
||||
handleOnCreateTagOpen={() => onCreateTagOpen()}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-8 flex-row items-center pr-2">
|
||||
{!isAddOnly && (
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
renderTooltip
|
||||
allowedLabel="Override with a personal value"
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
variant="plain"
|
||||
className={twMerge(
|
||||
"mt-0.5 w-0 overflow-hidden p-0 group-hover:ml-1 group-hover:w-7",
|
||||
isOverridden && "ml-1 w-7 text-primary"
|
||||
)}
|
||||
onClick={onSecretOverride}
|
||||
size="md"
|
||||
isDisabled={isRollbackMode || isReadOnly || !isAllowed}
|
||||
ariaLabel="info"
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<FontAwesomeIcon icon={faCodeBranch} className="text-base" />
|
||||
</div>
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-0.5 overflow-hidden ">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
renderTooltip
|
||||
allowedLabel="Comment"
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
className={twMerge(
|
||||
"w-7 overflow-hidden p-0",
|
||||
"w-0 group-hover:w-7 data-[state=open]:w-7",
|
||||
hasComment ? "w-7 text-primary" : "group-hover:w-7"
|
||||
)}
|
||||
isDisabled={!isAllowed}
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="add-comment"
|
||||
>
|
||||
<FontAwesomeIcon icon={faComment} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<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
|
||||
isDisabled={isReadOnly || isRollbackMode || shouldBeBlockedInAddOnly}
|
||||
className="border border-mineshaft-600 text-sm"
|
||||
{...register(`secrets.${index}.comment`)}
|
||||
rows={8}
|
||||
cols={30}
|
||||
/>
|
||||
</FormControl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
<div className="duration-0 flex w-16 justify-center overflow-hidden border-l border-mineshaft-600 pl-2 transition-all">
|
||||
<div className="flex h-8 items-center space-x-2.5">
|
||||
{!isAddOnly && (
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<Tooltip content="Settings">
|
||||
<IconButton
|
||||
size="lg"
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
onClick={() => onRowExpand(secUniqId, index)}
|
||||
ariaLabel="expand"
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsis} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
<div className="opacity-0 group-hover:opacity-100">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
size="lg"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
isDisabled={isReadOnly || isRollbackMode || !isAllowed}
|
||||
onClick={() => {
|
||||
onSecretDelete(index, secKey, secId, idOverride);
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SecretInputRow.displayName = "SecretInputRow";
|
||||
@@ -1 +0,0 @@
|
||||
export { SecretInputRow } from "./SecretInputRow";
|
||||
@@ -1,36 +0,0 @@
|
||||
import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { IconButton } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
sortDir: "asc" | "desc";
|
||||
onSort: () => void;
|
||||
};
|
||||
|
||||
export const SecretTableHeader = ({ sortDir, onSort }: Props): JSX.Element => (
|
||||
<thead className="sticky top-0 z-50 bg-mineshaft-800">
|
||||
<tr className="top-0 flex flex-row">
|
||||
<td className="flex w-10 items-center justify-center border-none px-4">
|
||||
<div className="w-10 text-center text-xs text-transparent">{0}</div>
|
||||
</td>
|
||||
<td className="flex items-center">
|
||||
<div className="relative flex w-full min-w-[220px] items-center justify-start pl-2.5 lg:min-w-[240px] xl:min-w-[280px]">
|
||||
<div className="text-md inline-flex items-end font-medium">
|
||||
Key
|
||||
<IconButton variant="plain" className="ml-2" ariaLabel="sort" onClick={onSort}>
|
||||
<FontAwesomeIcon icon={sortDir === "asc" ? faArrowDown : faArrowUp} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div className="flex w-max flex-row items-center justify-end">
|
||||
<div className="mt-1 w-5 overflow-hidden group-hover:w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<th className="flex w-full flex-row">
|
||||
<div className="text-sm font-medium">Value</div>
|
||||
</th>
|
||||
</tr>
|
||||
<tr className="h-0 w-full border border-mineshaft-600" />
|
||||
</thead>
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
export { SecretTableHeader } from "./SecretTableHeader";
|
||||
@@ -1 +0,0 @@
|
||||
export { DashboardPage } from "./DashboardPage";
|
||||
64
frontend/src/views/SecretMainPage/SecretMainPage.store.tsx
Normal file
64
frontend/src/views/SecretMainPage/SecretMainPage.store.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { createContext, ReactNode, useContext, useEffect, useRef } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { createStore, StateCreator, StoreApi, useStore } from "zustand";
|
||||
|
||||
// akhilmhdh: Don't remove this file if ur thinking why use zustand just for selected selects state
|
||||
// This is first step and the whole secret crud will be moved to this global page scope state
|
||||
// this will allow more stuff like undo grouping stuffs etc
|
||||
type SelectedSecretState = {
|
||||
selectedSecret: Record<string, boolean>;
|
||||
action: {
|
||||
toggle: (id: string) => void;
|
||||
reset: () => void;
|
||||
};
|
||||
};
|
||||
const createSelectedSecretStore: StateCreator<SelectedSecretState> = (set) => ({
|
||||
selectedSecret: {},
|
||||
action: {
|
||||
toggle: (id) =>
|
||||
set((state) => {
|
||||
const isChecked = Boolean(state.selectedSecret?.[id]);
|
||||
const newChecks = { ...state.selectedSecret };
|
||||
// remove selection if its present else add it
|
||||
if (isChecked) delete newChecks[id];
|
||||
else newChecks[id] = true;
|
||||
return { selectedSecret: newChecks };
|
||||
}),
|
||||
reset: () => set({ selectedSecret: {} })
|
||||
}
|
||||
});
|
||||
|
||||
const StoreContext = createContext<StoreApi<SelectedSecretState> | null>(null);
|
||||
export const StoreProvider = ({ children }: { children: ReactNode }) => {
|
||||
const storeRef = useRef<StoreApi<SelectedSecretState>>();
|
||||
const router = useRouter();
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = createStore<SelectedSecretState>((...a) => ({
|
||||
...createSelectedSecretStore(...a)
|
||||
}));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const onRouteChangeStart = () => {
|
||||
const state = storeRef.current?.getState();
|
||||
state?.action.reset();
|
||||
};
|
||||
|
||||
router.events.on("routeChangeStart", onRouteChangeStart);
|
||||
return () => {
|
||||
router.events.off("routeChangeStart", onRouteChangeStart);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <StoreContext.Provider value={storeRef.current}>{children}</StoreContext.Provider>;
|
||||
};
|
||||
|
||||
const useStoreContext = <T extends unknown>(selector: (state: SelectedSecretState) => T): T => {
|
||||
const ctx = useContext(StoreContext);
|
||||
if (!ctx) throw new Error("Missing ");
|
||||
return useStore(ctx, selector);
|
||||
};
|
||||
|
||||
// selected secret context
|
||||
export const useSelectedSecrets = () => useStoreContext((state) => state.selectedSecret);
|
||||
export const useSelectedSecretActions = () => useStoreContext((state) => state.action);
|
||||
332
frontend/src/views/SecretMainPage/SecretMainPage.tsx
Normal file
332
frontend/src/views/SecretMainPage/SecretMainPage.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "next/router";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import NavHeader from "@app/components/navigation/NavHeader";
|
||||
import { PermissionDeniedBanner } from "@app/components/permissions";
|
||||
import { ContentLoader } from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import {
|
||||
useGetImportedSecrets,
|
||||
useGetProjectFolders,
|
||||
useGetProjectSecrets,
|
||||
useGetSecretImports,
|
||||
useGetUserWsKey,
|
||||
useGetWorkspaceSnapshotList,
|
||||
useGetWsSnapshotCount,
|
||||
useGetWsTags
|
||||
} from "@app/hooks/api";
|
||||
|
||||
import { ActionBar } from "./components/ActionBar";
|
||||
import { FolderListView } from "./components/FolderListView";
|
||||
import { PitDrawer } from "./components/PitDrawer";
|
||||
import { SecretDropzone } from "./components/SecretDropzone";
|
||||
import { SecretImportListView } from "./components/SecretImportListView";
|
||||
import { SecretListView } from "./components/SecretListView";
|
||||
import { SnapshotView } from "./components/SnapshotView";
|
||||
import { StoreProvider } from "./SecretMainPage.store";
|
||||
import { Filter, GroupBy, SortDir } from "./SecretMainPage.types";
|
||||
|
||||
const LOADER_TEXT = [
|
||||
"Retriving your encrypted secrets",
|
||||
"Fetching folders",
|
||||
"Getting secret import links"
|
||||
];
|
||||
|
||||
export const SecretMainPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const router = useRouter();
|
||||
const permission = useProjectPermission();
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [sortDir, setSortDir] = useState<SortDir>(SortDir.ASC);
|
||||
const [filter, setFilter] = useState<Filter>({
|
||||
tags: {},
|
||||
searchFilter: ""
|
||||
});
|
||||
|
||||
const [snapshotId, setSnapshotId] = useState<string | null>(null);
|
||||
const isRollbackMode = Boolean(snapshotId);
|
||||
const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["snapshots"] as const);
|
||||
|
||||
// env slug
|
||||
const environment = router.query.env as string;
|
||||
const workspaceId = currentWorkspace?._id || "";
|
||||
const secretPath = (router.query.secretPath as string) || "/";
|
||||
const canReadSecret = permission.can(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
const canDoReadRollback = permission.can(
|
||||
ProjectPermissionActions.Read,
|
||||
ProjectPermissionSub.SecretRollback
|
||||
);
|
||||
|
||||
const { data: decryptFileKey } = useGetUserWsKey(workspaceId);
|
||||
|
||||
// fetch secrets
|
||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
decryptFileKey: decryptFileKey!,
|
||||
options: {
|
||||
enabled: canReadSecret
|
||||
}
|
||||
});
|
||||
// fetch folders
|
||||
const { data: folders, isLoading: isFoldersLoading } = useGetProjectFolders({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory: secretPath
|
||||
});
|
||||
// fetch secret imports
|
||||
const {
|
||||
data: secretImports,
|
||||
isLoading: isSecretImportsLoading,
|
||||
isFetching: isSecretImportsFetching
|
||||
} = useGetSecretImports({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory: secretPath,
|
||||
options: {
|
||||
enabled: canReadSecret
|
||||
}
|
||||
});
|
||||
|
||||
// fetch imported secrets to show user the overriden ones
|
||||
const { data: importedSecrets } = useGetImportedSecrets({
|
||||
workspaceId,
|
||||
environment,
|
||||
decryptFileKey: decryptFileKey!,
|
||||
directory: secretPath,
|
||||
options: {
|
||||
enabled: canReadSecret
|
||||
}
|
||||
});
|
||||
// fetch tags
|
||||
const { data: tags } = useGetWsTags(canReadSecret ? workspaceId : "");
|
||||
|
||||
const {
|
||||
data: snapshotList,
|
||||
isFetchingNextPage: isFetchingNextSnapshotList,
|
||||
fetchNextPage: fetchNextSnapshotList,
|
||||
hasNextPage: hasNextSnapshotListPage
|
||||
} = useGetWorkspaceSnapshotList({
|
||||
workspaceId,
|
||||
directory: secretPath,
|
||||
environment,
|
||||
isPaused: !popUp.snapshots.isOpen || !canDoReadRollback,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
const { data: snapshotCount, isLoading: isSnapshotCountLoading } = useGetWsSnapshotCount({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory: secretPath,
|
||||
isPaused: !canDoReadRollback
|
||||
});
|
||||
|
||||
const isNotEmtpy = Boolean(secrets?.length || folders?.length || secretImports?.imports?.length);
|
||||
|
||||
const handleSortToggle = () =>
|
||||
setSortDir((state) => (state === SortDir.ASC ? SortDir.DESC : SortDir.ASC));
|
||||
|
||||
const handleEnvChange = (slug: string) => {
|
||||
const query: Record<string, string> = { ...router.query, env: slug };
|
||||
delete query.secretPath;
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query
|
||||
});
|
||||
};
|
||||
|
||||
const handleGroupByChange = useCallback(
|
||||
(groupBy?: GroupBy) => setFilter((state) => ({ ...state, groupBy })),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleTagToggle = useCallback(
|
||||
(tagId: string) =>
|
||||
setFilter((state) => {
|
||||
const isTagPresent = Boolean(state.tags?.[tagId]);
|
||||
const newTagFilter = { ...state.tags };
|
||||
if (isTagPresent) delete newTagFilter[tagId];
|
||||
else newTagFilter[tagId] = true;
|
||||
return { ...state, tags: newTagFilter };
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(searchFilter: string) => setFilter((state) => ({ ...state, searchFilter })),
|
||||
[]
|
||||
);
|
||||
|
||||
const handleToggleVisibility = useCallback(() => setIsVisible((state) => !state), []);
|
||||
|
||||
// snapshot functions
|
||||
const handleSelectSnapshot = useCallback((snapId: string) => {
|
||||
setSnapshotId(snapId);
|
||||
}, []);
|
||||
|
||||
const handleResetSnapshot = useCallback(() => {
|
||||
setSnapshotId(null);
|
||||
handlePopUpClose("snapshots");
|
||||
}, []);
|
||||
|
||||
// loading screen when u have permission
|
||||
const loadingOnAccess =
|
||||
canReadSecret && (isSecretsLoading || isSecretImportsLoading || isFoldersLoading);
|
||||
// loading screen when you don't have permission but as folder's is viewable need to wait for that
|
||||
const loadingOnDenied = !canReadSecret && isFoldersLoading;
|
||||
if (loadingOnAccess || loadingOnDenied) {
|
||||
return <ContentLoader text={LOADER_TEXT} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<StoreProvider>
|
||||
<div className="container flex flex-col mx-auto h-full px-6 text-mineshaft-50 dark:[color-scheme:dark]">
|
||||
<div className="relative right-6 -top-2 mb-2 ml-6">
|
||||
<NavHeader
|
||||
pageName={t("dashboard.title")}
|
||||
currentEnv={environment}
|
||||
userAvailableEnvs={currentWorkspace?.environments}
|
||||
isFolderMode
|
||||
secretPath={secretPath}
|
||||
isProjectRelated
|
||||
onEnvChange={handleEnvChange}
|
||||
/>
|
||||
</div>
|
||||
{!isRollbackMode ? (
|
||||
<>
|
||||
<ActionBar
|
||||
secrets={secrets}
|
||||
importedSecrets={importedSecrets}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isVisible={isVisible}
|
||||
decryptFileKey={decryptFileKey!}
|
||||
filter={filter}
|
||||
tags={tags}
|
||||
onVisiblilityToggle={handleToggleVisibility}
|
||||
onGroupByChange={handleGroupByChange}
|
||||
onSearchChange={handleSearchChange}
|
||||
onToggleTagFilter={handleTagToggle}
|
||||
snapshotCount={snapshotCount || 0}
|
||||
autoCapitalization={currentWorkspace?.autoCapitalization}
|
||||
isSnapshotCountLoading={isSnapshotCountLoading}
|
||||
onClickRollbackMode={() => handlePopUpToggle("snapshots", true)}
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-3 overflow-auto thin-scrollbar bg-mineshaft-800 text-left text-bunker-300 rounded-md text-sm border border-mineshaft-600"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col ">
|
||||
{isNotEmtpy && (
|
||||
<div className="flex font-medium border-b border-mineshaft-600">
|
||||
<div style={{ width: "2.8rem" }} className="px-4 py-3 flex-shrink-0" />
|
||||
<div
|
||||
className="w-80 flex-shrink-0 border-r flex items-center border-mineshaft-600 px-4 py-2"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleSortToggle}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") handleSortToggle();
|
||||
}}
|
||||
>
|
||||
Key
|
||||
<FontAwesomeIcon
|
||||
icon={sortDir === SortDir.ASC ? faArrowDown : faArrowUp}
|
||||
className="ml-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow px-4 py-2">Value</div>
|
||||
</div>
|
||||
)}
|
||||
{canReadSecret && (
|
||||
<SecretImportListView
|
||||
searchTerm={filter.searchFilter}
|
||||
secretImports={secretImports}
|
||||
isFetching={isSecretImportsLoading || isSecretImportsFetching}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
secrets={secrets}
|
||||
importedSecrets={importedSecrets}
|
||||
/>
|
||||
)}
|
||||
<FolderListView
|
||||
folders={folders}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
sortDir={sortDir}
|
||||
/>
|
||||
{canReadSecret && (
|
||||
<SecretListView
|
||||
secrets={secrets}
|
||||
tags={tags}
|
||||
filter={filter}
|
||||
sortDir={sortDir}
|
||||
isVisible={isVisible}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
decryptFileKey={decryptFileKey!}
|
||||
/>
|
||||
)}
|
||||
{!canReadSecret && folders?.length === 0 && <PermissionDeniedBanner />}
|
||||
</div>
|
||||
</div>
|
||||
<SecretDropzone
|
||||
secrets={secrets}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
decryptFileKey={decryptFileKey!}
|
||||
secretPath={secretPath}
|
||||
isSmaller={isNotEmtpy}
|
||||
environments={currentWorkspace?.environments}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<SnapshotView
|
||||
snapshotId={snapshotId || ""}
|
||||
decryptFileKey={decryptFileKey!}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
secrets={secrets}
|
||||
folders={folders}
|
||||
snapshotCount={snapshotCount}
|
||||
onGoBack={handleResetSnapshot}
|
||||
onClickListSnapshot={() => handlePopUpToggle("snapshots", true)}
|
||||
/>
|
||||
)}
|
||||
<PitDrawer
|
||||
secretSnaphots={snapshotList}
|
||||
snapshotId={snapshotId}
|
||||
isDrawerOpen={popUp.snapshots.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("snapshots", isOpen)}
|
||||
hasNextPage={hasNextSnapshotListPage}
|
||||
fetchNextPage={fetchNextSnapshotList}
|
||||
onSelectSnapshot={handleSelectSnapshot}
|
||||
isFetchingNextPage={isFetchingNextSnapshotList}
|
||||
/>
|
||||
</div>
|
||||
</StoreProvider>
|
||||
);
|
||||
};
|
||||
14
frontend/src/views/SecretMainPage/SecretMainPage.types.ts
Normal file
14
frontend/src/views/SecretMainPage/SecretMainPage.types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type Filter = {
|
||||
tags: Record<string, boolean>;
|
||||
searchFilter: string;
|
||||
groupBy?: GroupBy | null;
|
||||
};
|
||||
|
||||
export enum SortDir {
|
||||
ASC = "asc",
|
||||
DESC = "desc"
|
||||
}
|
||||
|
||||
export enum GroupBy {
|
||||
PREFIX = "prefix"
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faAngleDown,
|
||||
faCheckCircle,
|
||||
faChevronRight,
|
||||
faCodeCommit,
|
||||
faDownload,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFileImport,
|
||||
faFilter,
|
||||
faFolderPlus,
|
||||
faMagnifyingGlass,
|
||||
faMinusSquare,
|
||||
faPlus,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import FileSaver from "file-saver";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
DeleteActionModal,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
DropdownSubMenu,
|
||||
DropdownSubMenuContent,
|
||||
DropdownSubMenuTrigger,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Tooltip,
|
||||
UpgradePlanModal
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useSubscription } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useCreateFolder, useDeleteSecretBatch } from "@app/hooks/api";
|
||||
import { DecryptedSecret, TImportedSecrets, UserWsKeyPair, WsTag } from "@app/hooks/api/types";
|
||||
|
||||
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
|
||||
import { Filter, GroupBy } from "../../SecretMainPage.types";
|
||||
import { CreateSecretForm } from "./CreateSecretForm";
|
||||
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||
import { FolderForm } from "./FolderForm";
|
||||
|
||||
type Props = {
|
||||
secrets?: DecryptedSecret[];
|
||||
// swtich the secrets type as it gets decrypted after api call
|
||||
importedSecrets?: Array<Omit<TImportedSecrets, "secrets"> & { secrets: DecryptedSecret[] }>;
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
secretPath?: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
filter: Filter;
|
||||
tags?: WsTag[];
|
||||
isVisible?: boolean;
|
||||
snapshotCount: number;
|
||||
isSnapshotCountLoading?: boolean;
|
||||
autoCapitalization?: boolean;
|
||||
onGroupByChange: (opt?: GroupBy) => void;
|
||||
onSearchChange: (term: string) => void;
|
||||
onToggleTagFilter: (tagId: string) => void;
|
||||
onVisiblilityToggle: () => void;
|
||||
onClickRollbackMode: () => void;
|
||||
};
|
||||
|
||||
export const ActionBar = ({
|
||||
secrets = [],
|
||||
importedSecrets = [],
|
||||
environment,
|
||||
workspaceId,
|
||||
decryptFileKey,
|
||||
secretPath = "/",
|
||||
filter,
|
||||
tags = [],
|
||||
isVisible,
|
||||
snapshotCount,
|
||||
isSnapshotCountLoading,
|
||||
autoCapitalization,
|
||||
onSearchChange,
|
||||
onToggleTagFilter,
|
||||
onGroupByChange,
|
||||
onVisiblilityToggle,
|
||||
onClickRollbackMode
|
||||
}: Props) => {
|
||||
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
|
||||
"addSecret",
|
||||
"addFolder",
|
||||
"addSecretImport",
|
||||
"bulkDeleteSecrets",
|
||||
"misc",
|
||||
"upgradePlan"
|
||||
] as const);
|
||||
const { subscription } = useSubscription();
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { mutateAsync: createFolder } = useCreateFolder();
|
||||
const { mutateAsync: deleteBatchSecretV3 } = useDeleteSecretBatch();
|
||||
|
||||
const selectedSecrets = useSelectedSecrets();
|
||||
const { reset: resetSelectedSecret } = useSelectedSecretActions();
|
||||
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
|
||||
|
||||
const handleFolderCreate = async (folderName: string) => {
|
||||
try {
|
||||
await createFolder({
|
||||
folderName,
|
||||
directory: secretPath,
|
||||
environment,
|
||||
workspaceId
|
||||
});
|
||||
handlePopUpClose("addFolder");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created folder"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create folder"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecretDownload = async () => {
|
||||
const secPriority: Record<string, boolean> = {};
|
||||
const downloadedSecrets: Array<{ key: string; value: string; comment?: string }> = [];
|
||||
// load up secrets in dashboard
|
||||
secrets?.forEach(({ key, value, comment }) => {
|
||||
secPriority[key] = true;
|
||||
downloadedSecrets.push({ key, value, comment });
|
||||
});
|
||||
// now load imported secrets with secPriority
|
||||
for (let i = importedSecrets.length - 1; i >= 0; i -= 1) {
|
||||
importedSecrets[i].secrets.forEach(({ key, value, comment }) => {
|
||||
if (secPriority?.[key]) return;
|
||||
downloadedSecrets.unshift({ key, value, comment });
|
||||
secPriority[key] = true;
|
||||
});
|
||||
}
|
||||
|
||||
const file = downloadedSecrets
|
||||
.sort((a, b) => a.key.toLowerCase().localeCompare(b.key.toLowerCase()))
|
||||
.reduce(
|
||||
(prev, { key, value, comment }, index) =>
|
||||
prev +
|
||||
(comment
|
||||
? `${index === 0 ? "#" : "\n#"} ${comment}\n${key}=${value}\n`
|
||||
: `${key}=${value}\n`),
|
||||
""
|
||||
);
|
||||
const blob = new Blob([file], { type: "text/plain;charset=utf-8" });
|
||||
FileSaver.saveAs(blob, `${environment}.env`);
|
||||
};
|
||||
|
||||
const handleSecretBulkDelete = async () => {
|
||||
const bulkDeletedSecrets = secrets.filter(({ _id }) => Boolean(selectedSecrets?.[_id]));
|
||||
try {
|
||||
await deleteBatchSecretV3({
|
||||
secretPath,
|
||||
workspaceId,
|
||||
environment,
|
||||
secrets: bulkDeletedSecrets.map(({ key }) => ({ secretName: key, type: "shared" }))
|
||||
});
|
||||
resetSelectedSecret();
|
||||
handlePopUpClose("bulkDeleteSecrets");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted secrets"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete secrets"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center space-x-2 mt-4">
|
||||
<div className="w-2/5">
|
||||
<Input
|
||||
className="bg-mineshaft-800 placeholder-mineshaft-50 duration-200 focus:bg-mineshaft-700/80"
|
||||
placeholder="Search by folder name, key name, comment..."
|
||||
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
|
||||
value={filter.searchFilter}
|
||||
onChange={(evt) => onSearchChange(evt.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
variant="outline_bg"
|
||||
ariaLabel="Download"
|
||||
className={twMerge(
|
||||
"transition-all",
|
||||
Object.keys(filter.tags).length && "text-primary border-primary/50"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faFilter} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="p-0">
|
||||
<DropdownMenuGroup>Group By</DropdownMenuGroup>
|
||||
<DropdownMenuItem
|
||||
iconPos="right"
|
||||
icon={
|
||||
filter?.groupBy === GroupBy.PREFIX && <FontAwesomeIcon icon={faCheckCircle} />
|
||||
}
|
||||
onClick={() => onGroupByChange(!filter.groupBy ? GroupBy.PREFIX : undefined)}
|
||||
>
|
||||
Prefix
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuGroup>Filter By</DropdownMenuGroup>
|
||||
<DropdownSubMenu>
|
||||
<DropdownSubMenuTrigger
|
||||
iconPos="right"
|
||||
icon={<FontAwesomeIcon icon={faChevronRight} size="sm" />}
|
||||
>
|
||||
Tags
|
||||
</DropdownSubMenuTrigger>
|
||||
<DropdownSubMenuContent className="rounded-l-none">
|
||||
<DropdownMenuLabel>Apply tags to filter secrets</DropdownMenuLabel>
|
||||
{tags.map(({ _id, name, tagColor }) => (
|
||||
<DropdownMenuItem
|
||||
onClick={() => onToggleTagFilter(_id)}
|
||||
key={_id}
|
||||
icon={filter?.tags[_id] && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full mr-2"
|
||||
style={{ background: tagColor || "#bec2c8" }}
|
||||
/>
|
||||
{name}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownSubMenuContent>
|
||||
</DropdownSubMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex-grow" />
|
||||
<div>
|
||||
<IconButton variant="outline_bg" ariaLabel="Download" onClick={handleSecretDownload}>
|
||||
<FontAwesomeIcon icon={faDownload} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton variant="outline_bg" ariaLabel="Reveal" onClick={onVisiblilityToggle}>
|
||||
<FontAwesomeIcon icon={isVisible ? faEyeSlash : faEye} />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Read}
|
||||
a={ProjectPermissionSub.SecretRollback}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
onClick={() => {
|
||||
if (subscription && subscription.pitRecovery) {
|
||||
onClickRollbackMode();
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("upgradePlan");
|
||||
}}
|
||||
leftIcon={<FontAwesomeIcon icon={faCodeCommit} />}
|
||||
isLoading={isSnapshotCountLoading}
|
||||
className="h-10"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
{snapshotCount} Commits
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faPlus} />}
|
||||
onClick={() => handlePopUpOpen("addSecret")}
|
||||
className="rounded-r-none h-10"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Secret
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
|
||||
<DropdownMenu
|
||||
open={popUp.misc.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}
|
||||
>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="add-folder-or-import"
|
||||
variant="outline_bg"
|
||||
className="rounded-l-none bg-mineshaft-600 p-3"
|
||||
>
|
||||
<FontAwesomeIcon icon={faAngleDown} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<div className="p-1.5 flex flex-col space-y-1">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faFolderPlus} />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addFolder");
|
||||
handlePopUpClose("misc");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
variant="outline_bg"
|
||||
className="h-10"
|
||||
isFullWidth
|
||||
>
|
||||
Add Folder
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faFileImport} />}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("addSecretImport");
|
||||
handlePopUpClose("misc");
|
||||
}}
|
||||
variant="outline_bg"
|
||||
className="h-10"
|
||||
isFullWidth
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Add Import
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"overflow-hidden transition-all h-0 flex-shrink-0",
|
||||
isMultiSelectActive && "h-16"
|
||||
)}
|
||||
>
|
||||
<div className="text-bunker-300 flex items-center bg-mineshaft-800 mt-3.5 py-2 px-4 rounded-md border border-mineshaft-600">
|
||||
<Tooltip content="Clear">
|
||||
<IconButton variant="plain" ariaLabel="clear-selection" onClick={resetSelectedSecret}>
|
||||
<FontAwesomeIcon icon={faMinusSquare} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<div className="text-sm ml-4 px-2 flex-grow">
|
||||
{Object.keys(selectedSecrets).length} Selected
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
variant="outline_bg"
|
||||
colorSchema="danger"
|
||||
leftIcon={<FontAwesomeIcon icon={faTrash} />}
|
||||
className="ml-4"
|
||||
onClick={() => handlePopUpOpen("bulkDeleteSecrets")}
|
||||
isDisabled={!isAllowed}
|
||||
size="xs"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
{/* all the side triggers from actions like modals etc */}
|
||||
<CreateSecretForm
|
||||
secrets={secrets}
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
autoCapitalize={autoCapitalization}
|
||||
secretPath={secretPath}
|
||||
decryptFileKey={decryptFileKey!}
|
||||
isOpen={popUp.addSecret.isOpen}
|
||||
onClose={() => handlePopUpClose("addSecret")}
|
||||
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecret", isOpen)}
|
||||
/>
|
||||
<CreateSecretImportForm
|
||||
environment={environment}
|
||||
workspaceId={workspaceId}
|
||||
secretPath={secretPath}
|
||||
isOpen={popUp.addSecretImport.isOpen}
|
||||
onClose={() => handlePopUpClose("addSecretImport")}
|
||||
onTogglePopUp={(isOpen) => handlePopUpToggle("addSecretImport", isOpen)}
|
||||
/>
|
||||
<Modal
|
||||
isOpen={popUp.addFolder.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("addFolder", isOpen)}
|
||||
>
|
||||
<ModalContent title="Create Folder">
|
||||
<FolderForm onCreateFolder={handleFolderCreate} />
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.bulkDeleteSecrets.isOpen}
|
||||
deleteKey="delete"
|
||||
title="Do you want to delete these secrets?"
|
||||
onChange={(isOpen) => handlePopUpToggle("bulkDeleteSecrets", isOpen)}
|
||||
onDeleteApproved={handleSecretBulkDelete}
|
||||
/>
|
||||
{subscription && (
|
||||
<UpgradePlanModal
|
||||
isOpen={popUp.upgradePlan.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("upgradePlan", isOpen)}
|
||||
text={
|
||||
subscription.slug === null
|
||||
? "You can perform point-in-time recovery under an Enterprise license"
|
||||
: "You can perform point-in-time recovery if you switch to Infisical's Team plan"
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ActionBar.displayName = "ActionBar";
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { Button, FormControl, Input, Modal, ModalContent, SecretInput } from "@app/components/v2";
|
||||
import { useCreateSecretV3 } from "@app/hooks/api";
|
||||
import { DecryptedSecret, UserWsKeyPair } from "@app/hooks/api/types";
|
||||
|
||||
const typeSchema = z.object({
|
||||
key: z.string(),
|
||||
value: z.string().optional()
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof typeSchema>;
|
||||
|
||||
type Props = {
|
||||
secrets?: DecryptedSecret[];
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
secretPath?: string;
|
||||
// modal props
|
||||
isOpen?: boolean;
|
||||
onClose: () => void;
|
||||
onTogglePopUp: (isOpen: boolean) => void;
|
||||
autoCapitalize?: boolean;
|
||||
};
|
||||
|
||||
export const CreateSecretForm = ({
|
||||
environment,
|
||||
workspaceId,
|
||||
decryptFileKey,
|
||||
secretPath = "/",
|
||||
isOpen,
|
||||
onClose,
|
||||
onTogglePopUp,
|
||||
autoCapitalize = true
|
||||
}: Props) => {
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { errors, isSubmitting }
|
||||
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
|
||||
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { mutateAsync: createSecretV3 } = useCreateSecretV3();
|
||||
|
||||
const handleFormSubmit = async ({ key, value }: TFormSchema) => {
|
||||
try {
|
||||
await createSecretV3({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
secretName: key,
|
||||
secretValue: value || "",
|
||||
secretComment: "",
|
||||
type: "shared",
|
||||
latestFileKey: decryptFileKey
|
||||
});
|
||||
onClose();
|
||||
reset();
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully created secret"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to create secret"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onTogglePopUp}>
|
||||
<ModalContent
|
||||
title="Create secret"
|
||||
subTitle="Add a secret to the particular environment and folder"
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<FormControl label="Key" isError={Boolean(errors?.key)} errorText={errors?.key?.message}>
|
||||
<Input
|
||||
{...register("key")}
|
||||
placeholder="Type your secret name"
|
||||
autoCapitalization={autoCapitalize}
|
||||
/>
|
||||
</FormControl>
|
||||
<Controller
|
||||
control={control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormControl
|
||||
label="Value"
|
||||
isError={Boolean(errors?.value)}
|
||||
errorText={errors?.value?.message}
|
||||
>
|
||||
<SecretInput
|
||||
{...field}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-mineshaft-900 px-2 py-1.5"
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<div className="mt-7 flex items-center">
|
||||
<Button
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
key="layout-create-project-submit"
|
||||
className="mr-4"
|
||||
type="submit"
|
||||
>
|
||||
Create Secret
|
||||
</Button>
|
||||
<Button
|
||||
key="layout-cancel-create-project"
|
||||
onClick={onClose}
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import {
|
||||
Button,
|
||||
FormControl,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
Select,
|
||||
SelectItem
|
||||
} from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { useCreateSecretImport } from "@app/hooks/api";
|
||||
|
||||
const typeSchema = z.object({
|
||||
environment: z.string().trim(),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
)
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof typeSchema>;
|
||||
|
||||
type Props = {
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
secretPath?: string;
|
||||
// modal props
|
||||
isOpen?: boolean;
|
||||
onClose: () => void;
|
||||
onTogglePopUp: (isOpen: boolean) => void;
|
||||
};
|
||||
|
||||
export const CreateSecretImportForm = ({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath = "/",
|
||||
isOpen,
|
||||
onClose,
|
||||
onTogglePopUp
|
||||
}: Props) => {
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
reset,
|
||||
formState: { isSubmitting }
|
||||
} = useForm<TFormSchema>({ resolver: zodResolver(typeSchema) });
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { mutateAsync: createSecretImport } = useCreateSecretImport();
|
||||
|
||||
const handleFormSubmit = async ({
|
||||
environment: importedEnv,
|
||||
secretPath: importedSecPath
|
||||
}: TFormSchema) => {
|
||||
try {
|
||||
await createSecretImport({
|
||||
environment,
|
||||
workspaceId,
|
||||
directory: secretPath,
|
||||
secretImport: {
|
||||
environment: importedEnv,
|
||||
secretPath: importedSecPath
|
||||
}
|
||||
});
|
||||
onClose();
|
||||
reset();
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully linked"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to link secrets"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onTogglePopUp}>
|
||||
<ModalContent
|
||||
title="Add Secret Link"
|
||||
subTitle="To inherit secrets from another environment or folder"
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
render={({ field: { onChange, ...field }, fieldState: { error } }) => (
|
||||
<FormControl label="Environment" errorText={error?.message} isError={Boolean(error)}>
|
||||
<Select
|
||||
defaultValue={field.value}
|
||||
{...field}
|
||||
onValueChange={(e) => onChange(e)}
|
||||
className="w-full"
|
||||
>
|
||||
{environments.map(({ name, slug }) => (
|
||||
<SelectItem value={slug} key={slug}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
control={control}
|
||||
name="secretPath"
|
||||
defaultValue="/"
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<FormControl label="Secret Path" isError={Boolean(error)} errorText={error?.message}>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="mt-7 flex items-center">
|
||||
<Button
|
||||
isDisabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
key="layout-create-project-submit"
|
||||
className="mr-4"
|
||||
type="submit"
|
||||
>
|
||||
Create Link
|
||||
</Button>
|
||||
<Button
|
||||
key="layout-cancel-create-project"
|
||||
onClick={onClose}
|
||||
variant="plain"
|
||||
colorSchema="secondary"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +1,28 @@
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button, FormControl, Input, ModalClose } from "@app/components/v2";
|
||||
|
||||
type Props = {
|
||||
onCreateFolder: (folderName: string) => Promise<void>;
|
||||
onUpdateFolder: (folderName: string) => Promise<void>;
|
||||
onCreateFolder?: (folderName: string) => Promise<void>;
|
||||
onUpdateFolder?: (folderName: string) => Promise<void>;
|
||||
isEdit?: boolean;
|
||||
defaultFolderName?: string;
|
||||
};
|
||||
|
||||
const formSchema = yup.object({
|
||||
name: yup
|
||||
const formSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.required()
|
||||
.trim()
|
||||
.matches(/^[a-zA-Z0-9-_]+$/, "Folder name cannot contain spaces. Only underscore and dashes")
|
||||
.label("Tag Name")
|
||||
.regex(/^[a-zA-Z0-9-_]+$/, "Folder name cannot contain spaces. Only underscore and dashes")
|
||||
});
|
||||
type TFormData = yup.InferType<typeof formSchema>;
|
||||
type TFormData = z.infer<typeof formSchema>;
|
||||
|
||||
export const FolderForm = ({
|
||||
isEdit,
|
||||
onCreateFolder,
|
||||
defaultFolderName,
|
||||
onCreateFolder,
|
||||
onUpdateFolder
|
||||
}: Props): JSX.Element => {
|
||||
const {
|
||||
@@ -33,7 +31,7 @@ export const FolderForm = ({
|
||||
formState: { isSubmitting },
|
||||
handleSubmit
|
||||
} = useForm<TFormData>({
|
||||
resolver: yupResolver(formSchema),
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: defaultFolderName
|
||||
}
|
||||
@@ -41,9 +39,9 @@ export const FolderForm = ({
|
||||
|
||||
const onSubmit = async ({ name }: TFormData) => {
|
||||
if (isEdit) {
|
||||
await onUpdateFolder(name);
|
||||
await onUpdateFolder?.(name);
|
||||
} else {
|
||||
await onCreateFolder(name);
|
||||
await onCreateFolder?.(name);
|
||||
}
|
||||
reset();
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { ActionBar } from "./ActionBar";
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faClose, faFolder, faPencilSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { DeleteActionModal, IconButton, Modal, ModalContent } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteFolder, useUpdateFolder } from "@app/hooks/api";
|
||||
import { TSecretFolder } from "@app/hooks/api/secretFolders/types";
|
||||
|
||||
import { SortDir } from "../../SecretMainPage.types";
|
||||
import { FolderForm } from "../ActionBar/FolderForm";
|
||||
|
||||
type Props = {
|
||||
folders?: TSecretFolder[];
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
secretPath?: string;
|
||||
sortDir: SortDir;
|
||||
};
|
||||
|
||||
export const FolderListView = ({
|
||||
folders = [],
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath = "/",
|
||||
sortDir = SortDir.ASC
|
||||
}: Props) => {
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"updateFolder",
|
||||
"deleteFolder"
|
||||
] as const);
|
||||
const router = useRouter();
|
||||
|
||||
const { createNotification } = useNotificationContext();
|
||||
|
||||
const { mutateAsync: updateFolder } = useUpdateFolder();
|
||||
const { mutateAsync: deleteFolder } = useDeleteFolder();
|
||||
|
||||
const handleFolderUpdate = async (newFolderName: string) => {
|
||||
try {
|
||||
await updateFolder({
|
||||
folderName: popUp.updateFolder.data as string,
|
||||
name: newFolderName,
|
||||
directory: secretPath,
|
||||
environment,
|
||||
workspaceId
|
||||
});
|
||||
handlePopUpClose("updateFolder");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully saved folder"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to save folder"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderDelete = async () => {
|
||||
try {
|
||||
await deleteFolder({
|
||||
folderName: popUp.deleteFolder.data as string,
|
||||
directory: secretPath,
|
||||
environment,
|
||||
workspaceId
|
||||
});
|
||||
handlePopUpClose("deleteFolder");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted folder"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete folder"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleFolderClick = (name: string) => {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: {
|
||||
...router.query,
|
||||
secretPath: `${router.query?.secretPath || ""}/${name}`
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{folders
|
||||
.sort((a, b) =>
|
||||
sortDir === SortDir.ASC
|
||||
? a.name.toLowerCase().localeCompare(b.name.toLowerCase())
|
||||
: b.name.toLowerCase().localeCompare(a.name.toLowerCase())
|
||||
)
|
||||
.map(({ name, id }) => (
|
||||
<div
|
||||
key={id}
|
||||
className="flex group border-b border-mineshaft-600 hover:bg-mineshaft-700 cursor-pointer"
|
||||
>
|
||||
<div className="w-12 px-4 py-2 text-yellow-700 flex items-center">
|
||||
<FontAwesomeIcon icon={faFolder} />
|
||||
</div>
|
||||
<div
|
||||
className="flex-grow px-4 py-2 flex items-center"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") handleFolderClick(name);
|
||||
}}
|
||||
onClick={() => handleFolderClick(name)}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
<div className="px-3 py-2 flex items-center space-x-4 border-l border-mineshaft-600">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Edit"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="edit-folder"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className="group-hover:opacity-100 opacity-0 p-0"
|
||||
onClick={() => handlePopUpOpen("updateFolder", name)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPencilSquare} size="lg" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="delete-folder"
|
||||
variant="plain"
|
||||
size="md"
|
||||
className="group-hover:opacity-100 opacity-0 p-0"
|
||||
onClick={() => handlePopUpOpen("deleteFolder", name)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<Modal
|
||||
isOpen={popUp.updateFolder.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("updateFolder", isOpen)}
|
||||
>
|
||||
<ModalContent title="Edit Folder">
|
||||
<FolderForm
|
||||
isEdit
|
||||
defaultFolderName={popUp.updateFolder.data as string}
|
||||
onUpdateFolder={handleFolderUpdate}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteFolder.isOpen}
|
||||
deleteKey={popUp.deleteFolder?.data as string}
|
||||
title="Do you want to delete this folder?"
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteFolder", isOpen)}
|
||||
onDeleteApproved={handleFolderDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { FolderListView } from "./FolderListView";
|
||||
@@ -3,12 +3,12 @@ import { InfiniteData } from "@tanstack/react-query";
|
||||
|
||||
import { Button, Drawer, DrawerContent } from "@app/components/v2";
|
||||
import timeSince from "@app/ee/utilities/timeSince";
|
||||
import { TWorkspaceSecretSnapshot } from "@app/hooks/api/secretSnapshots/types";
|
||||
import { TSecretSnapshot } from "@app/hooks/api/secretSnapshots/types";
|
||||
|
||||
type Props = {
|
||||
isDrawerOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
secretSnaphots?: InfiniteData<TWorkspaceSecretSnapshot[]>;
|
||||
secretSnaphots?: InfiniteData<TSecretSnapshot[]>;
|
||||
onSelectSnapshot: (id: string) => void;
|
||||
snapshotId: string | null;
|
||||
isFetchingNextPage?: boolean;
|
||||
@@ -0,0 +1,284 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import {
|
||||
faClone,
|
||||
faKey,
|
||||
faSearch,
|
||||
faSquareCheck,
|
||||
faSquareXmark
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalTrigger,
|
||||
Select,
|
||||
SelectItem,
|
||||
Skeleton,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { useDebounce } from "@app/hooks";
|
||||
import { useGetProjectSecrets } from "@app/hooks/api";
|
||||
import { UserWsKeyPair } from "@app/hooks/api/types";
|
||||
|
||||
const formSchema = z.object({
|
||||
environment: z.string().trim(),
|
||||
secretPath: z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((val) =>
|
||||
typeof val === "string" && val.at(-1) === "/" && val.length > 1 ? val.slice(0, -1) : val
|
||||
),
|
||||
secrets: z.record(z.string())
|
||||
});
|
||||
|
||||
type TFormSchema = z.infer<typeof formSchema>;
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean;
|
||||
onToggle: (isOpen: boolean) => void;
|
||||
onParsedEnv: (env: Record<string, { value: string; comments: string[] }>) => void;
|
||||
environments?: { name: string; slug: string }[];
|
||||
workspaceId: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
export const CopySecretsFromBoard = ({
|
||||
environments = [],
|
||||
workspaceId,
|
||||
decryptFileKey,
|
||||
environment,
|
||||
secretPath,
|
||||
isOpen,
|
||||
onToggle,
|
||||
onParsedEnv
|
||||
}: Props) => {
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const [shouldIncludeValues, setShouldIncludeValues] = useState(true);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
watch,
|
||||
register,
|
||||
reset,
|
||||
setValue,
|
||||
formState: { isDirty }
|
||||
} = useForm<TFormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { secretPath: "/", environment: environments?.[0]?.slug }
|
||||
});
|
||||
|
||||
const envCopySecPath = watch("secretPath");
|
||||
const selectedEnvSlug = watch("environment");
|
||||
const debouncedEnvCopySecretPath = useDebounce(envCopySecPath);
|
||||
|
||||
const { data: secrets, isLoading: isSecretsLoading } = useGetProjectSecrets({
|
||||
workspaceId,
|
||||
environment: selectedEnvSlug,
|
||||
secretPath: debouncedEnvCopySecretPath,
|
||||
decryptFileKey,
|
||||
options: {
|
||||
enabled:
|
||||
Boolean(workspaceId) &&
|
||||
Boolean(selectedEnvSlug) &&
|
||||
Boolean(debouncedEnvCopySecretPath) &&
|
||||
isOpen
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setValue("secrets", {});
|
||||
setSearchFilter("");
|
||||
}, [debouncedEnvCopySecretPath]);
|
||||
|
||||
const handleSecSelectAll = () => {
|
||||
if (secrets) {
|
||||
setValue(
|
||||
"secrets",
|
||||
secrets?.reduce((prev, curr) => ({ ...prev, [curr.key]: curr.value }), {}),
|
||||
{ shouldDirty: true }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: TFormSchema) => {
|
||||
const secretsToBePulled: Record<string, { value: string; comments: string[] }> = {};
|
||||
Object.keys(data.secrets || {}).forEach((key) => {
|
||||
if (data.secrets[key]) {
|
||||
secretsToBePulled[key] = {
|
||||
value: (shouldIncludeValues && data.secrets[key]) || "",
|
||||
comments: [""]
|
||||
};
|
||||
}
|
||||
});
|
||||
onParsedEnv(secretsToBePulled);
|
||||
onToggle(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(state) => {
|
||||
onToggle(state);
|
||||
reset();
|
||||
setSearchFilter("");
|
||||
}}
|
||||
>
|
||||
<ModalTrigger asChild>
|
||||
<div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
onClick={() => onToggle(true)}
|
||||
isDisabled={!isAllowed}
|
||||
variant="star"
|
||||
size="xs"
|
||||
>
|
||||
Copy Secrets From An Environment
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</ModalTrigger>
|
||||
<ModalContent
|
||||
className="max-w-2xl"
|
||||
title="Copy Secret From An Environment"
|
||||
subTitle="Copy/paste secrets from other environments into this context"
|
||||
>
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Controller
|
||||
control={control}
|
||||
name="environment"
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<FormControl label="Environment" isRequired className="w-1/3">
|
||||
<Select
|
||||
value={value}
|
||||
onValueChange={(val) => onChange(val)}
|
||||
className="w-full border border-mineshaft-500"
|
||||
defaultValue={environments?.[0]?.slug}
|
||||
position="popper"
|
||||
>
|
||||
{environments.map((sourceEnvironment) => (
|
||||
<SelectItem
|
||||
value={sourceEnvironment.slug}
|
||||
key={`source-environment-${sourceEnvironment.slug}`}
|
||||
>
|
||||
{sourceEnvironment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
<FormControl label="Secret Path" className="flex-grow" isRequired>
|
||||
<Input {...register("secretPath")} placeholder="Provide a path, default is /" />
|
||||
</FormControl>
|
||||
</div>
|
||||
<div className="border-t border-mineshaft-600 pt-4">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>Secrets</div>
|
||||
<div className="w-1/2 flex items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Search for secret"
|
||||
value={searchFilter}
|
||||
size="xs"
|
||||
leftIcon={<FontAwesomeIcon icon={faSearch} />}
|
||||
onChange={(evt) => setSearchFilter(evt.target.value)}
|
||||
/>
|
||||
<Tooltip content="Select All">
|
||||
<IconButton
|
||||
ariaLabel="Select all"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={handleSecSelectAll}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSquareCheck} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content="Unselect All">
|
||||
<IconButton
|
||||
ariaLabel="UnSelect all"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
onClick={() => reset()}
|
||||
>
|
||||
<FontAwesomeIcon icon={faSquareXmark} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{!isSecretsLoading && !secrets?.length && (
|
||||
<EmptyState title="No secrets found" icon={faKey} />
|
||||
)}
|
||||
<div className="grid grid-cols-2 gap-4 max-h-64 overflow-auto thin-scrollbar ">
|
||||
{isSecretsLoading &&
|
||||
Array.apply(0, Array(2)).map((_x, i) => (
|
||||
<Skeleton key={`secret-pull-loading-${i + 1}`} className="bg-mineshaft-700" />
|
||||
))}
|
||||
|
||||
{secrets
|
||||
?.filter(({ key }) => key.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||
?.map(({ _id, key, value: secVal }) => (
|
||||
<Controller
|
||||
key={`pull-secret--${_id}`}
|
||||
control={control}
|
||||
name={`secrets.${key}`}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<Checkbox
|
||||
id={`pull-secret-${_id}`}
|
||||
isChecked={Boolean(value)}
|
||||
onCheckedChange={(isChecked) => onChange(isChecked ? secVal : "")}
|
||||
>
|
||||
{key}
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 mb-4">
|
||||
<Checkbox
|
||||
id="populate-include-value"
|
||||
isChecked={shouldIncludeValues}
|
||||
onCheckedChange={(isChecked) => setShouldIncludeValues(isChecked as boolean)}
|
||||
>
|
||||
Include secret values
|
||||
</Checkbox>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
leftIcon={<FontAwesomeIcon icon={faClone} />}
|
||||
type="submit"
|
||||
isDisabled={!isDirty}
|
||||
>
|
||||
Paste Secrets
|
||||
</Button>
|
||||
<Button variant="plain" colorSchema="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,338 @@
|
||||
import { ChangeEvent, DragEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faUpload } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
// TODO:(akhilmhdh) convert all the util functions like this into a lib folder grouped by functionality
|
||||
import { parseDotEnv } from "@app/components/utilities/parseDotEnv";
|
||||
import { Button, Modal, ModalContent } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
|
||||
import { usePopUp, useToggle } from "@app/hooks";
|
||||
import { useCreateSecretBatch, useUpdateSecretBatch } from "@app/hooks/api";
|
||||
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
import { DecryptedSecret, UserWsKeyPair } from "@app/hooks/api/types";
|
||||
|
||||
import { CopySecretsFromBoard } from "./CopySecretsFromBoard";
|
||||
|
||||
const parseJson = (src: ArrayBuffer) => {
|
||||
const file = src.toString();
|
||||
const formatedData: Record<string, string> = JSON.parse(file);
|
||||
const env: Record<string, { value: string; comments: string[] }> = {};
|
||||
Object.keys(formatedData).forEach((key) => {
|
||||
if (typeof formatedData[key] === "string") {
|
||||
env[key] = { value: formatedData[key], comments: [] };
|
||||
}
|
||||
});
|
||||
return env;
|
||||
};
|
||||
|
||||
type TParsedEnv = Record<string, { value: string; comments: string[] }>;
|
||||
type TSecOverwriteOpt = { update: TParsedEnv; create: TParsedEnv };
|
||||
|
||||
type Props = {
|
||||
isSmaller: boolean;
|
||||
environments?: { name: string; slug: string }[];
|
||||
workspaceId: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
secrets?: DecryptedSecret[];
|
||||
};
|
||||
|
||||
export const SecretDropzone = ({
|
||||
isSmaller,
|
||||
environments = [],
|
||||
workspaceId,
|
||||
decryptFileKey,
|
||||
environment,
|
||||
secretPath,
|
||||
secrets = []
|
||||
}: Props): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const [isDragActive, setDragActive] = useToggle();
|
||||
const [isLoading, setIsLoading] = useToggle();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"importSecEnv",
|
||||
"overlapKeyWarning"
|
||||
] as const);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutateAsync: updateSecretBatch, isLoading: isUpdatingSecrets } = useUpdateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
const { mutateAsync: createSecretBatch, isLoading: isCreatingSecrets } = useCreateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
|
||||
const isSubmitting = isCreatingSecrets || isUpdatingSecrets;
|
||||
|
||||
const handleDrag = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (e.type === "dragenter" || e.type === "dragover") {
|
||||
setDragActive.on();
|
||||
} else if (e.type === "dragleave") {
|
||||
setDragActive.off();
|
||||
}
|
||||
};
|
||||
|
||||
const handleParsedEnv = (env: TParsedEnv) => {
|
||||
const secretsGroupedByKey = secrets?.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({ ...prev, [curr.key]: true }),
|
||||
{}
|
||||
);
|
||||
const overlappedSecrets = Object.keys(env)
|
||||
.filter((secKey) => secretsGroupedByKey?.[secKey])
|
||||
.reduce<TParsedEnv>((prev, curr) => ({ ...prev, [curr]: env[curr] }), {});
|
||||
|
||||
const nonOverlappedSecrets = Object.keys(env)
|
||||
.filter((secKey) => !secretsGroupedByKey?.[secKey])
|
||||
.reduce<TParsedEnv>((prev, curr) => ({ ...prev, [curr]: env[curr] }), {});
|
||||
|
||||
if (!Object.keys(overlappedSecrets).length && !Object.keys(nonOverlappedSecrets).length) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to find secrets"
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
handlePopUpOpen("overlapKeyWarning", {
|
||||
update: overlappedSecrets,
|
||||
create: nonOverlappedSecrets
|
||||
});
|
||||
};
|
||||
|
||||
const parseFile = (file?: File, isJson?: boolean) => {
|
||||
const reader = new FileReader();
|
||||
if (!file) {
|
||||
createNotification({
|
||||
text: "You can't inject files from VS Code. Click 'Reveal in finder', and drag your file directly from the directory where it's located.",
|
||||
type: "error",
|
||||
timeoutMs: 10000
|
||||
});
|
||||
return;
|
||||
}
|
||||
// const fileType = file.name.split('.')[1];
|
||||
setIsLoading.on();
|
||||
reader.onload = (event) => {
|
||||
if (!event?.target?.result) return;
|
||||
// parse function's argument looks like to be ArrayBuffer
|
||||
const env = isJson
|
||||
? parseJson(event.target.result as ArrayBuffer)
|
||||
: parseDotEnv(event.target.result as ArrayBuffer);
|
||||
setIsLoading.off();
|
||||
handleParsedEnv(env);
|
||||
};
|
||||
|
||||
// If something is wrong show an error
|
||||
try {
|
||||
reader.readAsText(file);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!e.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setDragActive.off();
|
||||
parseFile(e.dataTransfer.files[0]);
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
parseFile(e.target?.files?.[0], e.target?.files?.[0]?.type === "application/json");
|
||||
};
|
||||
|
||||
const handleSaveSecrets = async () => {
|
||||
const { update, create } = popUp?.overlapKeyWarning?.data as TSecOverwriteOpt;
|
||||
try {
|
||||
if (Object.keys(create || {}).length) {
|
||||
await createSecretBatch({
|
||||
latestFileKey: decryptFileKey!,
|
||||
secretPath,
|
||||
workspaceId,
|
||||
environment,
|
||||
secrets: Object.entries(create).map(([secretName, secData]) => ({
|
||||
type: "shared",
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretName
|
||||
}))
|
||||
});
|
||||
}
|
||||
if (Object.keys(update || {}).length) {
|
||||
await updateSecretBatch({
|
||||
latestFileKey: decryptFileKey!,
|
||||
secretPath,
|
||||
workspaceId,
|
||||
environment,
|
||||
secrets: Object.entries(update).map(([secretName, secData]) => ({
|
||||
type: "shared",
|
||||
secretComment: secData.comments.join("\n"),
|
||||
secretValue: secData.value,
|
||||
secretName
|
||||
}))
|
||||
});
|
||||
}
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
handlePopUpClose("overlapKeyWarning");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully uploaded secrets"
|
||||
});
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to upload secrets"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isUploadedDuplicateSecretsEmpty = !Object.keys(
|
||||
(popUp.overlapKeyWarning?.data as TSecOverwriteOpt)?.update || {}
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
onDrop={handleDrop}
|
||||
className={twMerge(
|
||||
"relative mx-0.5 mb-4 mt-4 flex cursor-pointer items-center justify-center rounded-md bg-mineshaft-900 py-4 text-sm px-2 text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100",
|
||||
isDragActive && "opacity-100",
|
||||
!isSmaller && "w-full max-w-3xl flex-col space-y-4 py-20 mx-auto",
|
||||
isLoading && "bg-bunker-800"
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="mb-16 flex items-center justify-center pt-16">
|
||||
<img
|
||||
src="/images/loading/loading.gif"
|
||||
height={70}
|
||||
width={120}
|
||||
alt="loading animation"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-cente flex-col space-y-2">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faUpload} size={isSmaller ? "2x" : "5x"} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="">{t(isSmaller ? "common.drop-zone-keys" : "common.drop-zone")}</p>
|
||||
</div>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<input
|
||||
id="fileSelect"
|
||||
disabled={!isAllowed}
|
||||
type="file"
|
||||
className="absolute h-full w-full cursor-pointer opacity-0"
|
||||
accept=".txt,.env,.yml,.yaml,.json"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex w-full flex-row items-center justify-center py-4",
|
||||
isSmaller && "py-1"
|
||||
)}
|
||||
>
|
||||
<div className="w-1/5 border-t border-mineshaft-700" />
|
||||
<p className="mx-4 text-xs text-mineshaft-400">OR</p>
|
||||
<div className="w-1/5 border-t border-mineshaft-700" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center space-x-8">
|
||||
<CopySecretsFromBoard
|
||||
isOpen={popUp.importSecEnv.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("importSecEnv", isOpen)}
|
||||
onParsedEnv={handleParsedEnv}
|
||||
environment={environment}
|
||||
environments={environments}
|
||||
workspaceId={workspaceId}
|
||||
decryptFileKey={decryptFileKey}
|
||||
secretPath={secretPath}
|
||||
/>
|
||||
{!isSmaller && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button variant="star" isDisabled={!isAllowed}>
|
||||
Add a new secret
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Modal
|
||||
isOpen={popUp?.overlapKeyWarning?.isOpen}
|
||||
onOpenChange={(open) => handlePopUpToggle("overlapKeyWarning", open)}
|
||||
>
|
||||
<ModalContent
|
||||
title={isUploadedDuplicateSecretsEmpty ? "Confirmation" : "Duplicate Secrets!!"}
|
||||
footerContent={[
|
||||
<Button
|
||||
isLoading={isSubmitting}
|
||||
isDisabled={isSubmitting}
|
||||
colorSchema={isUploadedDuplicateSecretsEmpty ? "primary" : "danger"}
|
||||
key="overwrite-btn"
|
||||
onClick={handleSaveSecrets}
|
||||
>
|
||||
{isUploadedDuplicateSecretsEmpty ? "Upload" : "Overwrite"}
|
||||
</Button>,
|
||||
<Button
|
||||
key="keep-old-btn"
|
||||
className="mr-4"
|
||||
onClick={() => handlePopUpClose("overlapKeyWarning")}
|
||||
variant="outline_bg"
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
{isUploadedDuplicateSecretsEmpty ? (
|
||||
<div>Upload secrets from this file</div>
|
||||
) : (
|
||||
<div className="flex flex-col space-y-2 text-gray-300">
|
||||
<div>Your file contains following duplicate secrets</div>
|
||||
<div className="text-sm text-gray-400">
|
||||
{Object.keys((popUp?.overlapKeyWarning?.data as TSecOverwriteOpt)?.update || {})
|
||||
?.map((key) => key)
|
||||
.join(", ")}
|
||||
</div>
|
||||
<div>Are you sure you want to overwrite these secrets and create other ones?</div>
|
||||
</div>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useEffect } from "react";
|
||||
import { subject } from "@casl/ability";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import {
|
||||
faClose,
|
||||
faFileImport,
|
||||
faFolder,
|
||||
faKey,
|
||||
faUpDown
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import { EmptyState, IconButton, SecretInput, TableContainer } from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useWorkspace } from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
|
||||
type Props = {
|
||||
onDelete: (environment: string, secretPath: string) => void;
|
||||
environment: string;
|
||||
secretPath?: string;
|
||||
importedEnv: string;
|
||||
importedSecPath: string;
|
||||
importedSecrets: { key: string; value: string; overriden: { env: string; secretPath: string } }[];
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
// to show the environment and folder icon
|
||||
export const EnvFolderIcon = ({ env, secretPath }: { env: string; secretPath: string }) => (
|
||||
<div className="inline-flex items-center space-x-2">
|
||||
<div style={{ minWidth: "96px" }}>{env || "-"}</div>
|
||||
{secretPath && (
|
||||
<div className="inline-flex items-center space-x-2 border-l border-mineshaft-600 pl-2">
|
||||
<FontAwesomeIcon icon={faFolder} className="text-green-700 text-md" />
|
||||
<span>{secretPath}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const SecretImportItem = ({
|
||||
importedEnv,
|
||||
importedSecPath,
|
||||
onDelete,
|
||||
importedSecrets = [],
|
||||
searchTerm = "",
|
||||
secretPath,
|
||||
environment
|
||||
}: Props) => {
|
||||
const [isExpanded, setIsExpanded] = useToggle();
|
||||
const { attributes, listeners, transform, transition, setNodeRef, isDragging } = useSortable({
|
||||
id: `${importedEnv}-${importedSecPath}`
|
||||
});
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const rowEnv = currentWorkspace?.environments?.find(({ slug }) => slug === importedEnv);
|
||||
|
||||
useEffect(() => {
|
||||
const filteredSecrets = importedSecrets.filter((secret) =>
|
||||
secret.key.toUpperCase().includes(searchTerm.toUpperCase())
|
||||
);
|
||||
|
||||
if (filteredSecrets.length > 0 && searchTerm) {
|
||||
setIsExpanded.on();
|
||||
} else {
|
||||
setIsExpanded.off();
|
||||
}
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
setIsExpanded.off();
|
||||
}
|
||||
}, [isDragging]);
|
||||
|
||||
const style = {
|
||||
transform: transform ? `translateY(${transform.y ? Math.round(transform.y) : 0}px)` : "",
|
||||
transition
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex group border-b border-mineshaft-600 hover:bg-mineshaft-700 cursor-pointer"
|
||||
role="button"
|
||||
ref={setNodeRef}
|
||||
tabIndex={0}
|
||||
style={style}
|
||||
onClick={() => setIsExpanded.toggle()}
|
||||
onKeyDown={() => setIsExpanded.toggle()}
|
||||
>
|
||||
<div className="w-12 px-4 py-2 flex items-center text-green-700">
|
||||
<FontAwesomeIcon icon={faFileImport} />
|
||||
</div>
|
||||
<div className="flex-grow px-4 py-2 flex items-center">
|
||||
<EnvFolderIcon env={rowEnv?.name || ""} secretPath={importedSecPath} />
|
||||
</div>
|
||||
<div className="px-4 py-2 flex items-center space-x-4 border-l border-mineshaft-600">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Change order"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
size="md"
|
||||
colorSchema="primary"
|
||||
variant="plain"
|
||||
ariaLabel="expand"
|
||||
className="group-hover:opacity-100 opacity-0 p-0"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faUpDown} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
size="md"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
ariaLabel="delete"
|
||||
className="group-hover:opacity-100 opacity-0 p-0"
|
||||
onClick={(evt) => {
|
||||
evt.stopPropagation();
|
||||
onDelete(importedEnv, importedSecPath);
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && !isDragging && (
|
||||
<td
|
||||
colSpan={3}
|
||||
className={`bg-bunker-800 ${isExpanded && "border-b-2 border-mineshaft-500"}`}
|
||||
>
|
||||
<div className="rounded-md bg-bunker-700 p-1">
|
||||
<TableContainer>
|
||||
<table className="secret-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Key</td>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Value</td>
|
||||
<td style={{ padding: "0.25rem 1rem" }}>Override</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{importedSecrets?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3}>
|
||||
<EmptyState title="No secrets found" icon={faKey} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{importedSecrets
|
||||
.filter((secret) => secret.key.toUpperCase().includes(searchTerm.toUpperCase()))
|
||||
.map(({ key, value, overriden }, index) => (
|
||||
<tr key={`${importedEnv}-${importedSecPath}-${key}-${index + 1}`}>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
{key}
|
||||
</td>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
<SecretInput value={value} isReadOnly />
|
||||
</td>
|
||||
<td className="h-10" style={{ padding: "0.25rem 1rem" }}>
|
||||
<EnvFolderIcon env={overriden?.env} secretPath={overriden?.secretPath} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors
|
||||
} from "@dnd-kit/core";
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
|
||||
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { DeleteActionModal } from "@app/components/v2";
|
||||
import { useWorkspace } from "@app/context";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useDeleteSecretImport, useUpdateSecretImport } from "@app/hooks/api";
|
||||
import { TSecretImports } from "@app/hooks/api/secretImports/types";
|
||||
import { DecryptedSecret } from "@app/hooks/api/types";
|
||||
|
||||
import { SecretImportItem } from "./SecretImportItem";
|
||||
|
||||
const SECRET_IN_DASHBOARD = "Present In Dashboard";
|
||||
|
||||
type TImportedSecrets = Array<{
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
folderId: string;
|
||||
secrets: DecryptedSecret[];
|
||||
}>;
|
||||
|
||||
export const computeImportedSecretRows = (
|
||||
importedSecEnv: string,
|
||||
importedSecPath: string,
|
||||
importSecrets: TImportedSecrets = [],
|
||||
secrets: DecryptedSecret[] = [],
|
||||
environments: { name: string; slug: string }[] = []
|
||||
) => {
|
||||
const importedSecIndex = importSecrets.findIndex(
|
||||
({ secretPath, environment }) =>
|
||||
secretPath === importedSecPath && importedSecEnv === environment
|
||||
);
|
||||
if (importedSecIndex === -1) return [];
|
||||
|
||||
const importedSec = importSecrets[importedSecIndex];
|
||||
|
||||
const overridenSec: Record<string, { env: string; secretPath: string }> = {};
|
||||
const envSlug2Name: Record<string, string> = {};
|
||||
environments.forEach((el) => {
|
||||
envSlug2Name[el.slug] = el.name;
|
||||
});
|
||||
|
||||
for (let i = importedSecIndex + 1; i < importSecrets.length; i += 1) {
|
||||
importSecrets[i].secrets.forEach((el) => {
|
||||
overridenSec[el.key] = {
|
||||
env: envSlug2Name?.[importSecrets[i].environment] || "unknown",
|
||||
secretPath: importSecrets[i].secretPath
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
secrets.forEach((el) => {
|
||||
overridenSec[el.key] = { env: SECRET_IN_DASHBOARD, secretPath: "" };
|
||||
});
|
||||
|
||||
return importedSec.secrets.map(({ key, value }) => ({
|
||||
key,
|
||||
value,
|
||||
overriden: overridenSec?.[key]
|
||||
}));
|
||||
};
|
||||
|
||||
type Props = {
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
secretPath?: string;
|
||||
secretImports?: TSecretImports;
|
||||
isFetching?: boolean;
|
||||
secrets?: DecryptedSecret[];
|
||||
importedSecrets?: TImportedSecrets;
|
||||
searchTerm: string;
|
||||
};
|
||||
|
||||
type TDeleteSecretImport = { environment: string; secretPath: string };
|
||||
|
||||
export const SecretImportListView = ({
|
||||
secretImports,
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
importedSecrets,
|
||||
secrets = [],
|
||||
isFetching,
|
||||
searchTerm
|
||||
}: Props) => {
|
||||
const { popUp, handlePopUpOpen, handlePopUpClose, handlePopUpToggle } = usePopUp([
|
||||
"deleteSecretImport"
|
||||
] as const);
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const { createNotification } = useNotificationContext();
|
||||
const environments = currentWorkspace?.environments || [];
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
);
|
||||
|
||||
const [items, setItems] = useState(
|
||||
(secretImports?.imports || [])?.map((dto) => ({
|
||||
id: `${dto.environment}-${dto.secretPath}`,
|
||||
...dto
|
||||
}))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFetching) {
|
||||
setItems(
|
||||
(secretImports?.imports || [])?.map((dto) => ({
|
||||
id: `${dto.environment}-${dto.secretPath}`,
|
||||
...dto
|
||||
}))
|
||||
);
|
||||
}
|
||||
}, [isFetching]);
|
||||
|
||||
const { mutateAsync: deleteSecretImport } = useDeleteSecretImport();
|
||||
const { mutate: updateSecretImport } = useUpdateSecretImport();
|
||||
|
||||
const handleSecretImportDelete = async () => {
|
||||
const { environment: importEnv, secretPath: impSecPath } = popUp.deleteSecretImport
|
||||
?.data as TDeleteSecretImport;
|
||||
try {
|
||||
if (secretImports?._id) {
|
||||
await deleteSecretImport({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory: secretPath,
|
||||
id: secretImports?._id,
|
||||
secretImportEnv: importEnv,
|
||||
secretImportPath: impSecPath
|
||||
});
|
||||
handlePopUpClose("deleteSecretImport");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully removed secret link"
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
createNotification({
|
||||
text: "Failed to remove secret link",
|
||||
type: "error"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSecretImportReorder = ({ over, active }: DragEndEvent) => {
|
||||
if (active.id !== over?.id) {
|
||||
const oldIndex = items.findIndex(({ id }) => id === active.id);
|
||||
const newIndex = items.findIndex(({ id }) => id === over?.id);
|
||||
const newImportOrder = arrayMove(items, oldIndex, newIndex);
|
||||
setItems(newImportOrder);
|
||||
updateSecretImport({
|
||||
workspaceId,
|
||||
environment,
|
||||
directory: secretPath,
|
||||
id: secretImports?._id || "",
|
||||
secretImports: newImportOrder
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<DndContext
|
||||
onDragEnd={handleSecretImportReorder}
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
>
|
||||
<SortableContext items={items} strategy={verticalListSortingStrategy}>
|
||||
{items?.map(({ secretPath: importedSecPath, environment: importedEnv }) => (
|
||||
<SecretImportItem
|
||||
searchTerm={searchTerm}
|
||||
key={`${importedEnv}-${importedSecPath}`}
|
||||
importedEnv={importedEnv}
|
||||
importedSecPath={importedSecPath}
|
||||
importedSecrets={computeImportedSecretRows(
|
||||
importedEnv,
|
||||
importedSecPath,
|
||||
importedSecrets,
|
||||
secrets,
|
||||
environments
|
||||
)}
|
||||
secretPath={secretPath}
|
||||
environment={environment}
|
||||
onDelete={(env, secPath) =>
|
||||
handlePopUpOpen("deleteSecretImport", { environment: env, secretPath: secPath })
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteSecretImport.isOpen}
|
||||
deleteKey="unlink"
|
||||
title="Do you want to remove this secret import?"
|
||||
subTitle={`This will unlink secrets from environment ${
|
||||
(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.environment
|
||||
} of path ${(popUp.deleteSecretImport?.data as TDeleteSecretImport)?.secretPath}?`}
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteSecretImport", isOpen)}
|
||||
onDeleteApproved={handleSecretImportDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { SecretImportListView } from "./SecretImportListView";
|
||||
@@ -0,0 +1,407 @@
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faCircleQuestion } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faCheckCircle,
|
||||
faCircle,
|
||||
faCircleDot,
|
||||
faPlus,
|
||||
faTag
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { format } from "date-fns";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
SecretInput,
|
||||
Switch,
|
||||
Tag,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission } from "@app/context";
|
||||
import { useGetSecretVersion } from "@app/hooks/api";
|
||||
import { DecryptedSecret, UserWsKeyPair, WsTag } from "@app/hooks/api/types";
|
||||
|
||||
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
|
||||
|
||||
type Props = {
|
||||
isOpen?: boolean;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
onToggle: (isOpen: boolean) => void;
|
||||
onClose: () => void;
|
||||
secret: DecryptedSecret;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
onDeleteSecret: () => void;
|
||||
onSaveSecret: (
|
||||
orgSec: DecryptedSecret,
|
||||
modSec: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] }
|
||||
) => Promise<void>;
|
||||
tags: WsTag[];
|
||||
onCreateTag: () => void;
|
||||
};
|
||||
|
||||
export const SecretDetailSidebar = ({
|
||||
isOpen,
|
||||
onToggle,
|
||||
decryptFileKey,
|
||||
secret,
|
||||
onDeleteSecret,
|
||||
onSaveSecret,
|
||||
tags,
|
||||
onCreateTag,
|
||||
environment,
|
||||
secretPath
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const {
|
||||
register,
|
||||
control,
|
||||
watch,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
reset,
|
||||
formState: { isDirty, isSubmitting }
|
||||
} = useForm<TFormSchema>({
|
||||
resolver: zodResolver(formSchema),
|
||||
values: secret
|
||||
});
|
||||
const permission = useProjectPermission();
|
||||
const cannotEditSecret = permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
const isReadOnly =
|
||||
permission.can(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
) && cannotEditSecret;
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "tags"
|
||||
});
|
||||
const selectedTags = watch("tags", []);
|
||||
const selectedTagsGroupById = selectedTags.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({ ...prev, [curr._id]: true }),
|
||||
{}
|
||||
);
|
||||
|
||||
const overrideAction = watch("overrideAction");
|
||||
const isOverridden =
|
||||
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
|
||||
|
||||
const { data: secretVersion } = useGetSecretVersion({
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
secretId: secret?._id,
|
||||
decryptFileKey
|
||||
});
|
||||
|
||||
const handleOverrideClick = () => {
|
||||
if (isOverridden) {
|
||||
// override need not be flagged delete if it was never saved in server
|
||||
// meaning a new unsaved personal secret but user toggled back later
|
||||
const isUnsavedOverride = !secret.idOverride;
|
||||
setValue(
|
||||
"overrideAction",
|
||||
isUnsavedOverride ? secret?.overrideAction : SecretActionType.Deleted,
|
||||
{
|
||||
shouldDirty: !isUnsavedOverride
|
||||
}
|
||||
);
|
||||
setValue("valueOverride", secret?.valueOverride, { shouldDirty: !isUnsavedOverride });
|
||||
} else {
|
||||
setValue("overrideAction", SecretActionType.Modified, { shouldDirty: true });
|
||||
setValue("valueOverride", "", { shouldDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTagSelect = (tag: WsTag) => {
|
||||
if (selectedTagsGroupById?.[tag._id]) {
|
||||
const tagPos = selectedTags.findIndex(({ _id }) => _id === tag._id);
|
||||
if (tagPos !== -1) {
|
||||
remove(tagPos);
|
||||
}
|
||||
} else {
|
||||
append(tag);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: TFormSchema) => {
|
||||
await onSaveSecret(secret, { ...secret, ...data });
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
onOpenChange={(state) => {
|
||||
if (isOpen && isDirty) {
|
||||
if (
|
||||
// eslint-disable-next-line no-alert
|
||||
window.confirm("You have edited the secret. Are you sure you want to reset the change?")
|
||||
) {
|
||||
onToggle(false);
|
||||
reset();
|
||||
} else return;
|
||||
}
|
||||
onToggle(state);
|
||||
}}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<DrawerContent title="Secret">
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)} className="h-full">
|
||||
<div className="flex flex-col h-full">
|
||||
<FormControl label="Key">
|
||||
<Input isDisabled {...register("key")} />
|
||||
</FormControl>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Controller
|
||||
name="value"
|
||||
key="secret-value"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl label="Value">
|
||||
<SecretInput
|
||||
isReadOnly={isReadOnly}
|
||||
key="secret-value"
|
||||
isDisabled={isOverridden || !isAllowed}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
|
||||
{...field}
|
||||
autoFocus={false}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<div className="mb-2 border-b border-mineshaft-600 pb-4">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Switch
|
||||
isDisabled={!isAllowed}
|
||||
id="personal-override"
|
||||
onCheckedChange={handleOverrideClick}
|
||||
isChecked={isOverridden}
|
||||
>
|
||||
Override with a personal value
|
||||
</Switch>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
{isOverridden && (
|
||||
<Controller
|
||||
name="valueOverride"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormControl label="Value Override">
|
||||
<SecretInput
|
||||
isReadOnly={isReadOnly}
|
||||
containerClassName="text-bunker-300 hover:border-primary-400/50 border border-mineshaft-600 bg-bunker-800 px-2 py-1.5"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<FormControl label="Tags" className="">
|
||||
<div className="overflow-hidden grid gap-2 grid-flow-col auto-cols-min pt-2">
|
||||
{fields.map(({ tagColor, id: formId, name, _id }) => (
|
||||
<Tag
|
||||
className="flex items-center space-x-2 w-min"
|
||||
key={formId}
|
||||
onClose={() => {
|
||||
if (cannotEditSecret) {
|
||||
createNotification({ type: "error", text: "Access denied" });
|
||||
return;
|
||||
}
|
||||
const tag = tags?.find(({ _id: id }) => id === _id);
|
||||
if (tag) handleTagSelect(tag);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: tagColor || "#bec2c8" }}
|
||||
/>
|
||||
<div className="text-sm">{name}</div>
|
||||
</Tag>
|
||||
))}
|
||||
<DropdownMenu>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
ariaLabel="add"
|
||||
variant="outline_bg"
|
||||
size="xs"
|
||||
className="rounded-md"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faPlus} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<DropdownMenuContent align="end" className="z-[100]">
|
||||
<DropdownMenuLabel>Apply tags to this secrets</DropdownMenuLabel>
|
||||
{tags.map((tag) => {
|
||||
const { _id: tagId, name, tagColor } = tag;
|
||||
|
||||
const isSelected = selectedTagsGroupById?.[tagId];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleTagSelect(tag)}
|
||||
key={tagId}
|
||||
icon={isSelected && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full mr-2"
|
||||
style={{ background: tagColor || "#bec2c8" }}
|
||||
/>
|
||||
{name}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={ProjectPermissionSub.Tags}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuItem>
|
||||
<Button
|
||||
size="xs"
|
||||
className="w-full"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faTag} />}
|
||||
onClick={onCreateTag}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
Create a tag
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormControl label="Comments & Notes">
|
||||
<TextArea
|
||||
className="border border-mineshaft-600 text-sm"
|
||||
{...register("comment")}
|
||||
readOnly={isReadOnly}
|
||||
rows={5}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="my-2 mb-6 border-b border-mineshaft-600 pb-4">
|
||||
<Controller
|
||||
control={control}
|
||||
name="skipMultilineEncoding"
|
||||
render={({ field: { value, onChange, onBlur } }) => (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Switch
|
||||
id="skipmultiencoding-option"
|
||||
onCheckedChange={(isChecked) => onChange(!isChecked)}
|
||||
isChecked={!value}
|
||||
onBlur={onBlur}
|
||||
isDisabled={!isAllowed}
|
||||
className="items-center"
|
||||
>
|
||||
Enable multi line encoding
|
||||
<Tooltip
|
||||
content="Infisical encodes multiline secrets by escaping newlines and wrapping in quotes. To disable, enable this option"
|
||||
className="z-[100]"
|
||||
>
|
||||
<FontAwesomeIcon icon={faCircleQuestion} className="ml-1" size="sm" />
|
||||
</Tooltip>
|
||||
</Switch>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="dark mb-4 text-sm text-bunker-300 flex-grow">
|
||||
<div className="mb-2">Version History</div>
|
||||
<div className="flex h-48 flex-col space-y-2 overflow-y-auto overflow-x-hidden rounded-md border border-mineshaft-600 bg-bunker-800 p-2 dark:[color-scheme:dark]">
|
||||
{secretVersion?.map(({ createdAt, value, id }, i) => (
|
||||
<div key={id} className="flex flex-col space-y-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div>
|
||||
<FontAwesomeIcon icon={i === 0 ? faCircleDot : faCircle} size="sm" />
|
||||
</div>
|
||||
<div>{format(new Date(createdAt), "Pp")}</div>
|
||||
</div>
|
||||
<div className="ml-1.5 flex items-center space-x-2 border-l border-bunker-300 pl-4">
|
||||
<div className="self-start rounded-sm bg-primary-500/30 px-1">Value:</div>
|
||||
<div className="break-all font-mono">{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="flex space-x-4 items-center">
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
isFullWidth
|
||||
type="submit"
|
||||
isDisabled={isSubmitting || !isDirty || !isAllowed}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button colorSchema="danger" isDisabled={!isAllowed} onClick={onDeleteSecret}>
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,483 @@
|
||||
import { memo, useEffect } from "react";
|
||||
import { Controller, useFieldArray, useForm } from "react-hook-form";
|
||||
import { subject } from "@casl/ability";
|
||||
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
|
||||
import {
|
||||
faCheck,
|
||||
faClose,
|
||||
faCodeBranch,
|
||||
faComment,
|
||||
faCopy,
|
||||
faEllipsis,
|
||||
faKey,
|
||||
faTag,
|
||||
faTags
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { ProjectPermissionCan } from "@app/components/permissions";
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
FormControl,
|
||||
IconButton,
|
||||
Input,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
SecretInput,
|
||||
Spinner,
|
||||
TextArea,
|
||||
Tooltip
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
ProjectPermissionActions,
|
||||
ProjectPermissionSub,
|
||||
useProjectPermission,
|
||||
useWorkspace
|
||||
} from "@app/context";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
|
||||
import { WsTag } from "@app/hooks/api/types";
|
||||
|
||||
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
|
||||
|
||||
type Props = {
|
||||
secret: DecryptedSecret;
|
||||
onSaveSecret: (
|
||||
orgSec: DecryptedSecret,
|
||||
modSec: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] }
|
||||
) => Promise<void>;
|
||||
onDeleteSecret: (sec: DecryptedSecret) => void;
|
||||
onDetailViewSecret: (sec: DecryptedSecret) => void;
|
||||
isVisible?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSecretSelect: (id: string) => void;
|
||||
tags: WsTag[];
|
||||
onCreateTag: () => void;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
};
|
||||
|
||||
export const SecretItem = memo(
|
||||
({
|
||||
secret,
|
||||
onSaveSecret,
|
||||
onDeleteSecret,
|
||||
onDetailViewSecret,
|
||||
isVisible,
|
||||
isSelected,
|
||||
tags = [],
|
||||
onCreateTag,
|
||||
onToggleSecretSelect,
|
||||
environment,
|
||||
secretPath
|
||||
}: Props) => {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const permission = useProjectPermission();
|
||||
const isReadOnly =
|
||||
permission.can(
|
||||
ProjectPermissionActions.Read,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
) &&
|
||||
permission.cannot(
|
||||
ProjectPermissionActions.Edit,
|
||||
subject(ProjectPermissionSub.Secrets, { environment, secretPath })
|
||||
);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
control,
|
||||
register,
|
||||
watch,
|
||||
setValue,
|
||||
reset,
|
||||
getValues,
|
||||
formState: { isDirty, isSubmitting }
|
||||
} = useForm<TFormSchema>({
|
||||
defaultValues: secret,
|
||||
values: secret,
|
||||
resolver: zodResolver(formSchema)
|
||||
});
|
||||
|
||||
const overrideAction = watch("overrideAction");
|
||||
const hasComment = Boolean(watch("comment"));
|
||||
|
||||
const selectedTags = watch("tags", []);
|
||||
const selectedTagsGroupById = selectedTags.reduce<Record<string, boolean>>(
|
||||
(prev, curr) => ({ ...prev, [curr._id]: true }),
|
||||
{}
|
||||
);
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "tags"
|
||||
});
|
||||
|
||||
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
if (isSecValueCopied) {
|
||||
timer = setTimeout(() => setIsSecValueCopied.off(), 2000);
|
||||
}
|
||||
return () => clearTimeout(timer);
|
||||
}, [isSecValueCopied]);
|
||||
|
||||
const isOverriden =
|
||||
overrideAction === SecretActionType.Created || overrideAction === SecretActionType.Modified;
|
||||
const hasTagsApplied = Boolean(fields.length);
|
||||
|
||||
const handleOverrideClick = () => {
|
||||
if (isOverriden) {
|
||||
// override need not be flagged delete if it was never saved in server
|
||||
// meaning a new unsaved personal secret but user toggled back later
|
||||
const isUnsavedOverride = !secret.idOverride;
|
||||
setValue(
|
||||
"overrideAction",
|
||||
isUnsavedOverride ? secret?.overrideAction : SecretActionType.Deleted,
|
||||
{
|
||||
shouldDirty: !isUnsavedOverride
|
||||
}
|
||||
);
|
||||
setValue("valueOverride", secret?.valueOverride, { shouldDirty: !isUnsavedOverride });
|
||||
} else {
|
||||
setValue("overrideAction", SecretActionType.Modified, { shouldDirty: true });
|
||||
setValue("valueOverride", "", { shouldDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (data: TFormSchema) => {
|
||||
await onSaveSecret(secret, { ...secret, ...data });
|
||||
};
|
||||
|
||||
const handleTagSelect = (tag: WsTag) => {
|
||||
if (selectedTagsGroupById?.[tag._id]) {
|
||||
const tagPos = selectedTags.findIndex(({ _id }) => _id === tag._id);
|
||||
if (tagPos !== -1) {
|
||||
remove(tagPos);
|
||||
}
|
||||
} else {
|
||||
append(tag);
|
||||
}
|
||||
};
|
||||
|
||||
const copyTokenToClipboard = () => {
|
||||
const [overrideValue, value] = getValues(["value", "valueOverride"]);
|
||||
navigator.clipboard.writeText((overrideValue || value) as string);
|
||||
setIsSecValueCopied.on();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(handleFormSubmit)}>
|
||||
<div
|
||||
className={twMerge(
|
||||
"shadow-none border-b border-mineshaft-600 bg-mineshaft-800 hover:bg-mineshaft-700",
|
||||
isDirty && "border-primary-400/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex group">
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center justify-center w-11 px-4 py-3 h-11",
|
||||
isDirty && "text-primary"
|
||||
)}
|
||||
>
|
||||
<Checkbox
|
||||
id={`checkbox-${secret._id}`}
|
||||
isChecked={isSelected}
|
||||
onCheckedChange={() => onToggleSecretSelect(secret._id)}
|
||||
className={twMerge("group-hover:flex hidden ml-3", isSelected && "flex")}
|
||||
/>
|
||||
<FontAwesomeIcon
|
||||
icon={faKey}
|
||||
className={twMerge("group-hover:hidden block ml-3", isSelected && "hidden")}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-80 h-11 flex items-center px-4 py-2 flex-shrink-0">
|
||||
<Controller
|
||||
name="key"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
autoComplete="off"
|
||||
isReadOnly={isReadOnly}
|
||||
autoCapitalization={currentWorkspace?.autoCapitalization}
|
||||
variant="plain"
|
||||
{...field}
|
||||
className="w-full focus:text-bunker-100 focus:ring-transparent px-0"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="flex-grow flex items-center border-x border-mineshaft-600 pl-4 pr-2 py-1"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
>
|
||||
{isOverriden ? (
|
||||
<Controller
|
||||
name="valueOverride"
|
||||
key="value-overriden"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SecretInput
|
||||
key="value-overriden"
|
||||
isVisible={isVisible}
|
||||
isReadOnly={isReadOnly}
|
||||
{...field}
|
||||
containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Controller
|
||||
name="value"
|
||||
key="secret-value"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SecretInput
|
||||
isReadOnly={isReadOnly}
|
||||
key="secret-value"
|
||||
isVisible={isVisible}
|
||||
{...field}
|
||||
containerClassName="py-1.5 rounded-md transition-all group-hover:mr-2"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div key="actions" className="h-8 flex self-start flex-shrink-0 transition-all">
|
||||
<Tooltip content="Copy secret">
|
||||
<IconButton
|
||||
ariaLabel="copy-value"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className="w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0"
|
||||
onClick={copyTokenToClipboard}
|
||||
>
|
||||
<FontAwesomeIcon icon={isSecValueCopied ? faCheck : faCopy} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<DropdownMenu>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<DropdownMenuTrigger asChild disabled={!isAllowed}>
|
||||
<IconButton
|
||||
ariaLabel="tags"
|
||||
variant="plain"
|
||||
size="sm"
|
||||
className={twMerge(
|
||||
"w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0 data-[state=open]:w-5",
|
||||
hasTagsApplied && "w-5 text-primary"
|
||||
)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<Tooltip content="Tags">
|
||||
<FontAwesomeIcon icon={faTags} />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Apply tags to this secrets</DropdownMenuLabel>
|
||||
{tags.map((tag) => {
|
||||
const { _id: tagId, name, tagColor } = tag;
|
||||
|
||||
const isTagSelected = selectedTagsGroupById?.[tagId];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleTagSelect(tag)}
|
||||
key={tagId}
|
||||
icon={isTagSelected && <FontAwesomeIcon icon={faCheckCircle} />}
|
||||
iconPos="right"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full mr-2"
|
||||
style={{ background: tagColor || "#bec2c8" }}
|
||||
/>
|
||||
{name}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuItem className="px-1.5">
|
||||
<Button
|
||||
size="xs"
|
||||
className="w-full"
|
||||
colorSchema="primary"
|
||||
variant="outline_bg"
|
||||
leftIcon={<FontAwesomeIcon icon={faTag} />}
|
||||
onClick={onCreateTag}
|
||||
>
|
||||
Create a tag
|
||||
</Button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Override"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="override-value"
|
||||
isDisabled={!isAllowed}
|
||||
variant="plain"
|
||||
size="sm"
|
||||
onClick={handleOverrideClick}
|
||||
className={twMerge(
|
||||
"w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0",
|
||||
isOverriden && "w-5 text-primary"
|
||||
)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faCodeBranch} />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<Popover>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Edit}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<PopoverTrigger asChild disabled={!isAllowed}>
|
||||
<IconButton
|
||||
className={twMerge(
|
||||
"overflow-hidden w-0 p-0 group-hover:w-5 group-hover:mr-2 data-[state=open]:w-6",
|
||||
hasComment && "w-5 text-primary"
|
||||
)}
|
||||
variant="plain"
|
||||
size="md"
|
||||
ariaLabel="add-comment"
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<Tooltip content="Comment">
|
||||
<FontAwesomeIcon icon={faComment} />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</PopoverTrigger>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
<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 exitBeforeEnter>
|
||||
{!isDirty ? (
|
||||
<motion.div
|
||||
key="options"
|
||||
className="h-10 flex items-center space-x-4 flex-shrink-0 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="group-hover:opacity-100 opacity-0 p-0"
|
||||
onClick={() => onDetailViewSecret(secret)}
|
||||
>
|
||||
<FontAwesomeIcon icon={faEllipsis} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Delete}
|
||||
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
|
||||
renderTooltip
|
||||
allowedLabel="Delete"
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<IconButton
|
||||
ariaLabel="delete-value"
|
||||
variant="plain"
|
||||
colorSchema="danger"
|
||||
size="md"
|
||||
className="group-hover:opacity-100 opacity-0 p-0"
|
||||
onClick={() => onDeleteSecret(secret)}
|
||||
isDisabled={!isAllowed}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
</IconButton>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="options-save"
|
||||
className="h-10 flex items-center space-x-4 flex-shrink-0 px-3"
|
||||
initial={{ x: -10, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -10, opacity: 0 }}
|
||||
>
|
||||
<Tooltip content="Save">
|
||||
<IconButton
|
||||
ariaLabel="more"
|
||||
variant="plain"
|
||||
type="submit"
|
||||
size="md"
|
||||
className={twMerge(
|
||||
"group-hover:opacity-100 opacity-0 p-0 text-primary",
|
||||
isDirty && "opacity-100"
|
||||
)}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Spinner className="w-4 h-4 p-0 m-0" />
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faCheck} size="lg" className="text-primary" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip content="Cancel">
|
||||
<IconButton
|
||||
ariaLabel="more"
|
||||
variant="plain"
|
||||
size="md"
|
||||
className={twMerge(
|
||||
"group-hover:opacity-100 opacity-0 p-0",
|
||||
isDirty && "opacity-100"
|
||||
)}
|
||||
onClick={() => reset()}
|
||||
isDisabled={isSubmitting}
|
||||
>
|
||||
<FontAwesomeIcon icon={faClose} size="lg" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
SecretItem.displayName = "SecretItem";
|
||||
@@ -0,0 +1,345 @@
|
||||
import { useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useNotificationContext } from "@app/components/context/Notifications/NotificationProvider";
|
||||
import { CreateTagModal } from "@app/components/tags/CreateTagModal";
|
||||
import { DeleteActionModal } from "@app/components/v2";
|
||||
import { usePopUp } from "@app/hooks";
|
||||
import { useCreateSecretV3, useDeleteSecretV3, useUpdateSecretV3 } from "@app/hooks/api";
|
||||
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
import { DecryptedSecret } from "@app/hooks/api/secrets/types";
|
||||
import { UserWsKeyPair, WsTag } from "@app/hooks/api/types";
|
||||
|
||||
import { secretSnapshotKeys } from "~/hooks/api/secretSnapshots/queries";
|
||||
|
||||
import { useSelectedSecretActions, useSelectedSecrets } from "../../SecretMainPage.store";
|
||||
import { Filter, GroupBy, SortDir } from "../../SecretMainPage.types";
|
||||
import { SecretDetailSidebar } from "./SecretDetaiSidebar";
|
||||
import { SecretItem } from "./SecretItem";
|
||||
|
||||
type Props = {
|
||||
secrets?: DecryptedSecret[];
|
||||
environment: string;
|
||||
workspaceId: string;
|
||||
decryptFileKey: UserWsKeyPair;
|
||||
secretPath?: string;
|
||||
filter: Filter;
|
||||
sortDir?: SortDir;
|
||||
tags?: WsTag[];
|
||||
isVisible?: boolean;
|
||||
};
|
||||
|
||||
const reorderSecretGroupByUnderscore = (secrets: DecryptedSecret[], sortDir: SortDir) => {
|
||||
const groupedSecrets: Record<string, DecryptedSecret[]> = {};
|
||||
secrets.forEach((secret) => {
|
||||
const lastSeperatorIndex = secret.key.lastIndexOf("_");
|
||||
const namespace =
|
||||
lastSeperatorIndex !== -1 ? secret.key.substring(0, lastSeperatorIndex) : "misc";
|
||||
if (!groupedSecrets?.[namespace]) groupedSecrets[namespace] = [];
|
||||
groupedSecrets[namespace].push(secret);
|
||||
});
|
||||
|
||||
return Object.keys(groupedSecrets)
|
||||
.sort((a, b) =>
|
||||
sortDir === SortDir.ASC
|
||||
? a.toLowerCase().localeCompare(b.toLowerCase())
|
||||
: b.toLowerCase().localeCompare(a.toLowerCase())
|
||||
)
|
||||
.map((namespace) => ({ namespace, secrets: groupedSecrets[namespace] }));
|
||||
};
|
||||
|
||||
const reorderSecret = (secrets: DecryptedSecret[], sortDir: SortDir, filter?: GroupBy | null) => {
|
||||
if (filter === GroupBy.PREFIX) {
|
||||
return reorderSecretGroupByUnderscore(secrets, sortDir);
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
namespace: "",
|
||||
secrets: secrets?.sort((a, b) =>
|
||||
sortDir === SortDir.ASC
|
||||
? a.key.toLowerCase().localeCompare(b.key.toLowerCase())
|
||||
: b.key.toLowerCase().localeCompare(a.key.toLowerCase())
|
||||
)
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
export const filterSecrets = (secrets: DecryptedSecret[], filter: Filter) =>
|
||||
secrets.filter(({ key, value, tags }) => {
|
||||
const isTagFilterActive = Boolean(Object.keys(filter.tags).length);
|
||||
const searchTerm = filter.searchFilter.toLowerCase();
|
||||
return (
|
||||
(!isTagFilterActive || tags.some(({ _id }) => filter.tags?.[_id])) &&
|
||||
(key.toLowerCase().includes(searchTerm) || value.toLowerCase().includes(searchTerm))
|
||||
);
|
||||
});
|
||||
|
||||
export const SecretListView = ({
|
||||
secrets = [],
|
||||
environment,
|
||||
workspaceId,
|
||||
decryptFileKey,
|
||||
secretPath = "/",
|
||||
filter,
|
||||
sortDir = SortDir.ASC,
|
||||
tags: wsTags = [],
|
||||
isVisible
|
||||
}: Props) => {
|
||||
const { createNotification } = useNotificationContext();
|
||||
const queryClient = useQueryClient();
|
||||
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
|
||||
"deleteSecret",
|
||||
"secretDetail",
|
||||
"createTag"
|
||||
] as const);
|
||||
|
||||
// strip of side effect queries
|
||||
const { mutateAsync: createSecretV3 } = useCreateSecretV3({
|
||||
options: {
|
||||
onSuccess: undefined
|
||||
}
|
||||
});
|
||||
const { mutateAsync: updateSecretV3 } = useUpdateSecretV3({
|
||||
options: {
|
||||
onSuccess: undefined
|
||||
}
|
||||
});
|
||||
const { mutateAsync: deleteSecretV3 } = useDeleteSecretV3({
|
||||
options: {
|
||||
onSuccess: undefined
|
||||
}
|
||||
});
|
||||
const selectedSecrets = useSelectedSecrets();
|
||||
const { toggle: toggleSelectedSecret } = useSelectedSecretActions();
|
||||
|
||||
const handleSecretOperation = async (
|
||||
operation: "create" | "update" | "delete",
|
||||
type: "shared" | "personal",
|
||||
key: string,
|
||||
{
|
||||
value,
|
||||
comment,
|
||||
tags,
|
||||
skipMultilineEncoding,
|
||||
newKey
|
||||
}: Partial<{
|
||||
value: string;
|
||||
comment: string;
|
||||
tags: string[];
|
||||
skipMultilineEncoding: boolean;
|
||||
newKey: string;
|
||||
}> = {}
|
||||
) => {
|
||||
if (operation === "delete") {
|
||||
await deleteSecretV3({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
secretName: key,
|
||||
type
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (operation === "update") {
|
||||
await updateSecretV3({
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
secretName: key,
|
||||
secretValue: value || "",
|
||||
type,
|
||||
latestFileKey: decryptFileKey,
|
||||
tags,
|
||||
secretComment: comment,
|
||||
skipMultilineEncoding,
|
||||
newSecretName: newKey
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await createSecretV3(
|
||||
{
|
||||
environment,
|
||||
workspaceId,
|
||||
secretPath,
|
||||
secretName: key,
|
||||
secretValue: value || "",
|
||||
secretComment: "",
|
||||
skipMultilineEncoding,
|
||||
type,
|
||||
latestFileKey: decryptFileKey
|
||||
},
|
||||
{}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveSecret = useCallback(
|
||||
async (
|
||||
orgSecret: DecryptedSecret,
|
||||
modSecret: Omit<DecryptedSecret, "tags"> & { tags: { _id: string }[] }
|
||||
) => {
|
||||
const { key: oldKey } = orgSecret;
|
||||
const { key, value, overrideAction, idOverride, valueOverride, tags, comment } = modSecret;
|
||||
const hasKeyChanged = oldKey !== key;
|
||||
|
||||
const tagIds = tags.map(({ _id }) => _id);
|
||||
const oldTagIds = orgSecret.tags.map(({ _id }) => _id);
|
||||
const isSameTags = JSON.stringify(tagIds) === JSON.stringify(oldTagIds);
|
||||
const isSharedSecUnchanged =
|
||||
(["key", "value", "comment", "skipMultilineEncoding"] as const).every(
|
||||
(el) => orgSecret[el] === modSecret[el]
|
||||
) && isSameTags;
|
||||
|
||||
try {
|
||||
// personal secret change
|
||||
if (overrideAction === "deleted") await handleSecretOperation("delete", "personal", key);
|
||||
else if (overrideAction && idOverride)
|
||||
await handleSecretOperation("update", "personal", oldKey, {
|
||||
value: valueOverride,
|
||||
newKey: hasKeyChanged ? key : undefined,
|
||||
skipMultilineEncoding: modSecret.skipMultilineEncoding
|
||||
});
|
||||
else if (overrideAction)
|
||||
await handleSecretOperation("create", "personal", key, { value: valueOverride });
|
||||
|
||||
// shared secret change
|
||||
if (!isSharedSecUnchanged)
|
||||
await handleSecretOperation("update", "shared", oldKey, {
|
||||
value,
|
||||
tags: tagIds,
|
||||
comment,
|
||||
newKey: hasKeyChanged ? key : undefined,
|
||||
skipMultilineEncoding: modSecret.skipMultilineEncoding
|
||||
});
|
||||
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.list({ workspaceId, environment, directory: secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.count({ workspaceId, environment, directory: secretPath })
|
||||
);
|
||||
handlePopUpClose("secretDetail");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully saved secrets"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to save secret"
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSecretDelete = useCallback(async () => {
|
||||
const { key } = popUp.deleteSecret?.data as DecryptedSecret;
|
||||
try {
|
||||
await handleSecretOperation("delete", "shared", key);
|
||||
queryClient.invalidateQueries(
|
||||
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.list({ workspaceId, environment, directory: secretPath })
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
secretSnapshotKeys.count({ workspaceId, environment, directory: secretPath })
|
||||
);
|
||||
handlePopUpClose("deleteSecret");
|
||||
handlePopUpClose("secretDetail");
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully deleted secret"
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "Failed to delete secret"
|
||||
});
|
||||
}
|
||||
}, [(popUp.deleteSecret?.data as DecryptedSecret)?.key]);
|
||||
|
||||
// for optimization on minimise re-rendering of secret items
|
||||
const onCreateTag = useCallback(() => handlePopUpOpen("createTag"), []);
|
||||
const onDeleteSecret = useCallback(
|
||||
(sec: DecryptedSecret) => handlePopUpOpen("deleteSecret", sec),
|
||||
[]
|
||||
);
|
||||
const onDetailViewSecret = useCallback(
|
||||
(sec: DecryptedSecret) => handlePopUpOpen("secretDetail", sec),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{reorderSecret(secrets, sortDir, filter.groupBy).map(
|
||||
({ namespace, secrets: groupedSecrets }) => {
|
||||
const filteredSecrets = filterSecrets(groupedSecrets, filter);
|
||||
return (
|
||||
<div className="flex flex-col" key={`${namespace}-${groupedSecrets.length}`}>
|
||||
<div
|
||||
className={twMerge(
|
||||
"bg-bunker-600 capitalize text-md h-0 transition-all",
|
||||
Boolean(namespace) && Boolean(filteredSecrets.length) && "h-11 py-3 pl-4 "
|
||||
)}
|
||||
key={namespace}
|
||||
>
|
||||
{namespace}
|
||||
</div>
|
||||
|
||||
{filteredSecrets.map((secret) => (
|
||||
<SecretItem
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
tags={wsTags}
|
||||
isSelected={selectedSecrets?.[secret._id]}
|
||||
onToggleSecretSelect={toggleSelectedSecret}
|
||||
isVisible={isVisible}
|
||||
secret={secret}
|
||||
key={secret._id}
|
||||
onSaveSecret={handleSaveSecret}
|
||||
onDeleteSecret={onDeleteSecret}
|
||||
onDetailViewSecret={onDetailViewSecret}
|
||||
onCreateTag={onCreateTag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
<DeleteActionModal
|
||||
isOpen={popUp.deleteSecret.isOpen}
|
||||
deleteKey={(popUp.deleteSecret?.data as DecryptedSecret)?.key}
|
||||
title="Do you want to delete this secret?"
|
||||
onChange={(isOpen) => handlePopUpToggle("deleteSecret", isOpen)}
|
||||
onDeleteApproved={handleSecretDelete}
|
||||
/>
|
||||
<SecretDetailSidebar
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
isOpen={popUp.secretDetail.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("secretDetail", isOpen)}
|
||||
decryptFileKey={decryptFileKey}
|
||||
secret={popUp.secretDetail.data as DecryptedSecret}
|
||||
onDeleteSecret={() => handlePopUpOpen("deleteSecret", popUp.secretDetail.data)}
|
||||
onClose={() => handlePopUpClose("secretDetail")}
|
||||
onSaveSecret={handleSaveSecret}
|
||||
tags={wsTags}
|
||||
onCreateTag={() => handlePopUpOpen("createTag")}
|
||||
/>
|
||||
<CreateTagModal
|
||||
isOpen={popUp.createTag.isOpen}
|
||||
onToggle={(isOpen) => handlePopUpToggle("createTag", isOpen)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/* eslint-disable no-nested-ternary */
|
||||
import { z } from "zod";
|
||||
|
||||
export enum SecretActionType {
|
||||
Created = "created",
|
||||
Modified = "modified",
|
||||
Deleted = "deleted"
|
||||
}
|
||||
|
||||
export const formSchema = z.object({
|
||||
key: z.string().trim(),
|
||||
value: z.string().transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())),
|
||||
idOverride: z.string().trim().optional(),
|
||||
valueOverride: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((val) =>
|
||||
typeof val === "string" ? (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim()) : val
|
||||
),
|
||||
overrideAction: z.string().trim().optional(),
|
||||
comment: z.string().trim().optional(),
|
||||
skipMultilineEncoding: z.boolean().optional(),
|
||||
tags: z
|
||||
.object({
|
||||
_id: z.string(),
|
||||
name: z.string(),
|
||||
slug: z.string(),
|
||||
tagColor: z.string().optional()
|
||||
})
|
||||
.array()
|
||||
.default([])
|
||||
});
|
||||
|
||||
export type TFormSchema = z.infer<typeof formSchema>;
|
||||
@@ -0,0 +1 @@
|
||||
export { SecretListView } from "./SecretListView";
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
faChevronRight,
|
||||
faKey,
|
||||
faMinusSquare,
|
||||
faPencilSquare,
|
||||
faPlusSquare,
|
||||
faSquare
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import {
|
||||
SecretInput,
|
||||
Table,
|
||||
TableContainer,
|
||||
Tag,
|
||||
TBody,
|
||||
Td,
|
||||
Th,
|
||||
THead,
|
||||
Tooltip,
|
||||
Tr
|
||||
} from "@app/components/v2";
|
||||
import { useToggle } from "@app/hooks";
|
||||
import { DecryptedSecret } from "@app/hooks/api/types";
|
||||
|
||||
export enum TDiffModes {
|
||||
NoChange = "no change",
|
||||
Deleted = "deleted",
|
||||
Modified = "modified",
|
||||
Created = "created"
|
||||
}
|
||||
|
||||
type Props = {
|
||||
mode: TDiffModes;
|
||||
preSecret?: DecryptedSecret;
|
||||
postSecret: DecryptedSecret;
|
||||
};
|
||||
export type TDiffView<T> = {
|
||||
mode: TDiffModes;
|
||||
pre?: T;
|
||||
post: T;
|
||||
};
|
||||
|
||||
export const renderIcon = (mode: TDiffModes) => {
|
||||
if (mode === TDiffModes.NoChange)
|
||||
return <FontAwesomeIcon icon={faSquare} className="text-gray-700" size="lg" />;
|
||||
if (mode === TDiffModes.Deleted)
|
||||
return <FontAwesomeIcon icon={faMinusSquare} className="text-red-700" size="lg" />;
|
||||
if (mode === TDiffModes.Modified)
|
||||
return <FontAwesomeIcon icon={faPencilSquare} className="text-orange-700" size="lg" />;
|
||||
|
||||
return <FontAwesomeIcon icon={faPlusSquare} className="text-green-700" size="lg" />;
|
||||
};
|
||||
|
||||
export const SecretItem = ({ mode, preSecret, postSecret }: Props) => {
|
||||
const [isExpanded, setIsExpanded] = useToggle();
|
||||
|
||||
const isModified = mode === "modified";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex group border-b border-mineshaft-600 hover:bg-mineshaft-700 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={setIsExpanded.toggle}
|
||||
onKeyDown={(evt) => {
|
||||
if (evt.key === "Enter") setIsExpanded.toggle();
|
||||
}}
|
||||
>
|
||||
<div className="w-12 flex-shrink-0 px-4 py-3">
|
||||
<Tooltip content={mode}>{renderIcon(mode)}</Tooltip>
|
||||
</div>
|
||||
<div className="w-12 flex-shrink-0 px-4 py-3">
|
||||
<FontAwesomeIcon icon={faKey} />
|
||||
</div>
|
||||
<div className="flex-grow px-4 py-3 flex items-center space-x-4">
|
||||
{mode === "modified" ? (
|
||||
<>
|
||||
<div>{preSecret?.key}</div>
|
||||
<div className="bg-primary text-black rounded-lg font-bold px-1 py-0.5 text-xs">
|
||||
v{preSecret?.version}
|
||||
</div>
|
||||
<div>
|
||||
<FontAwesomeIcon icon={faChevronRight} size="sm" className="text-orange-700" />
|
||||
</div>
|
||||
<div className="bg-primary text-black rounded-lg font-bold px-1 py-0.5 text-xs">
|
||||
v{postSecret?.version}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
postSecret.key
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="flex bg-bunker-600 cursor-pointer p-4">
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<THead>
|
||||
<Th className="min-table-row min-w-[11rem] border-r border-mineshaft-600">Type</Th>
|
||||
{isModified ? (
|
||||
<>
|
||||
<Th className="border-r border-mineshaft-600">Before</Th>
|
||||
<Th>After</Th>
|
||||
</>
|
||||
) : (
|
||||
<Th>Value</Th>
|
||||
)}
|
||||
</THead>
|
||||
<TBody>
|
||||
<Tr>
|
||||
<Td className="border-r border-mineshaft-600">Key</Td>
|
||||
{isModified && (
|
||||
<Td className="border-r border-mineshaft-600">{preSecret?.key}</Td>
|
||||
)}
|
||||
<Td>{postSecret.key}</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td className="border-r border-mineshaft-600">Value</Td>
|
||||
{isModified && (
|
||||
<Td className="border-r border-mineshaft-600">
|
||||
<SecretInput value={preSecret?.value} />
|
||||
</Td>
|
||||
)}
|
||||
<Td>
|
||||
<SecretInput value={postSecret?.value} />
|
||||
</Td>
|
||||
</Tr>
|
||||
{Boolean(preSecret?.idOverride || postSecret?.idOverride) && (
|
||||
<Tr>
|
||||
<Td className="border-r border-mineshaft-600">Override</Td>
|
||||
{isModified && (
|
||||
<Td className="border-r border-mineshaft-600">
|
||||
<SecretInput value={preSecret?.valueOverride} />
|
||||
</Td>
|
||||
)}
|
||||
<Td>
|
||||
<SecretInput value={postSecret?.valueOverride} />
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
<Tr>
|
||||
<Td className="border-r border-mineshaft-600">Comment</Td>
|
||||
{isModified && (
|
||||
<Td className="border-r border-mineshaft-600">{preSecret?.comment}</Td>
|
||||
)}
|
||||
<Td>{postSecret?.comment}</Td>
|
||||
</Tr>
|
||||
<Tr>
|
||||
<Td className="border-r border-mineshaft-600">Tags</Td>
|
||||
{isModified && (
|
||||
<Td className="border-r border-mineshaft-600">
|
||||
{preSecret?.tags?.map(({ name, _id: tagId, tagColor }) => (
|
||||
<Tag
|
||||
className="flex items-center space-x-2 w-min"
|
||||
key={`${preSecret._id}-${tagId}`}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: tagColor || "#bec2c8" }}
|
||||
/>
|
||||
<div className="text-sm">{name}</div>
|
||||
</Tag>
|
||||
))}
|
||||
</Td>
|
||||
)}
|
||||
<Td>
|
||||
{postSecret?.tags?.map(({ name, _id: tagId, tagColor }) => (
|
||||
<Tag
|
||||
className="flex items-center space-x-2 w-min"
|
||||
key={`${postSecret._id}-${tagId}`}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: tagColor || "#bec2c8" }}
|
||||
/>
|
||||
<div className="text-sm">{name}</div>
|
||||
</Tag>
|
||||
))}
|
||||
</Td>
|
||||
</Tr>
|
||||
</TBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user