Merge pull request #1198 from DanielHougaard/daniel/assignment

(feat): Secret reminders
This commit is contained in:
Daniel Hougaard
2023-11-28 19:58:40 +04:00
committed by GitHub
17 changed files with 814 additions and 314 deletions
+111 -28
View File
@@ -6,13 +6,7 @@ import { BotService } from "../../services";
import { containsGlobPatterns, repackageSecretToRaw } from "../../helpers/secrets"; import { containsGlobPatterns, repackageSecretToRaw } from "../../helpers/secrets";
import { encryptSymmetric128BitHexKeyUTF8 } from "../../utils/crypto"; import { encryptSymmetric128BitHexKeyUTF8 } from "../../utils/crypto";
import { getAllImportedSecrets } from "../../services/SecretImportService"; import { getAllImportedSecrets } from "../../services/SecretImportService";
import { import { Folder, IServiceTokenData, Membership, ServiceTokenData, User } from "../../models";
Folder,
IServiceTokenData,
Membership,
ServiceTokenData,
User
} from "../../models";
import { getFolderByPath } from "../../services/FolderService"; import { getFolderByPath } from "../../services/FolderService";
import { BadRequestError } from "../../utils/errors"; import { BadRequestError } from "../../utils/errors";
import { validateRequest } from "../../helpers/validation"; import { validateRequest } from "../../helpers/validation";
@@ -34,6 +28,7 @@ import {
} from "../../ee/services/SecretApprovalService"; } from "../../ee/services/SecretApprovalService";
import { CommitType } from "../../ee/models/secretApprovalRequest"; import { CommitType } from "../../ee/models/secretApprovalRequest";
import { logger } from "../../utils/logging"; import { logger } from "../../utils/logging";
import { createReminder, deleteReminder } from "../../helpers/reminder";
const checkSecretsPermission = async ({ const checkSecretsPermission = async ({
authData, authData,
@@ -197,10 +192,11 @@ export const getSecretsRaw = async (req: Request, res: Response) => {
query: { include_imports: includeImports } query: { include_imports: includeImports }
} = validatedData; } = validatedData;
logger.info(`getSecretsRaw: fetch raw secrets [environment=${environment}] [workspaceId=${workspaceId}] [secretPath=${secretPath}] [includeImports=${includeImports}]`) logger.info(
`getSecretsRaw: fetch raw secrets [environment=${environment}] [workspaceId=${workspaceId}] [secretPath=${secretPath}] [includeImports=${includeImports}]`
);
if (req.authData.authPayload instanceof ServiceTokenData) { if (req.authData.authPayload instanceof ServiceTokenData) {
// if the service token has single scope, it will get all secrets for that scope by default // if the service token has single scope, it will get all secrets for that scope by default
const serviceTokenDetails: IServiceTokenData = req?.serviceTokenData; const serviceTokenDetails: IServiceTokenData = req?.serviceTokenData;
if ( if (
@@ -356,7 +352,9 @@ export const getSecretByNameRaw = async (req: Request, res: Response) => {
params: { secretName } params: { secretName }
} = await validateRequest(reqValidator.GetSecretByNameRawV3, req); } = await validateRequest(reqValidator.GetSecretByNameRawV3, req);
logger.info(`getSecretByNameRaw: fetch raw secret by name [environment=${environment}] [workspaceId=${workspaceId}] [secretPath=${secretPath}] [type=${type}] [include_imports=${include_imports}]`) logger.info(
`getSecretByNameRaw: fetch raw secret by name [environment=${environment}] [workspaceId=${workspaceId}] [secretPath=${secretPath}] [type=${type}] [include_imports=${include_imports}]`
);
await checkSecretsPermission({ await checkSecretsPermission({
authData: req.authData, authData: req.authData,
@@ -481,7 +479,9 @@ export const createSecretRaw = async (req: Request, res: Response) => {
} }
} = await validateRequest(reqValidator.CreateSecretRawV3, req); } = await validateRequest(reqValidator.CreateSecretRawV3, req);
logger.info(`createSecretRaw: create a secret raw by name and value [environment=${environment}] [workspaceId=${workspaceId}] [secretPath=${secretPath}] [type=${type}] [skipMultilineEncoding=${skipMultilineEncoding}]`) logger.info(
`createSecretRaw: create a secret raw by name and value [environment=${environment}] [workspaceId=${workspaceId}] [secretPath=${secretPath}] [type=${type}] [skipMultilineEncoding=${skipMultilineEncoding}]`
);
await checkSecretsPermission({ await checkSecretsPermission({
authData: req.authData, authData: req.authData,
@@ -628,7 +628,9 @@ export const updateSecretByNameRaw = async (req: Request, res: Response) => {
body: { workspaceId, environment, secretValue, secretPath, type, skipMultilineEncoding } body: { workspaceId, environment, secretValue, secretPath, type, skipMultilineEncoding }
} = await validateRequest(reqValidator.UpdateSecretByNameRawV3, req); } = await validateRequest(reqValidator.UpdateSecretByNameRawV3, req);
logger.info(`updateSecretByNameRaw: update raw secret by name [environment=${environment}] [workspaceId=${workspaceId}] [secretPath=${secretPath}] [type=${type}] [skipMultilineEncoding=${skipMultilineEncoding}]`) logger.info(
`updateSecretByNameRaw: update raw secret by name [environment=${environment}] [workspaceId=${workspaceId}] [secretPath=${secretPath}] [type=${type}] [skipMultilineEncoding=${skipMultilineEncoding}]`
);
await checkSecretsPermission({ await checkSecretsPermission({
authData: req.authData, authData: req.authData,
@@ -752,7 +754,9 @@ export const deleteSecretByNameRaw = async (req: Request, res: Response) => {
body: { environment, secretPath, type, workspaceId } body: { environment, secretPath, type, workspaceId }
} = await validateRequest(reqValidator.DeleteSecretByNameRawV3, req); } = await validateRequest(reqValidator.DeleteSecretByNameRawV3, req);
logger.info(`deleteSecretByNameRaw: delete a secret by name [environment=${environment}] [workspaceId=${workspaceId}] [secretPath=${secretPath}] [type=${type}]`) logger.info(
`deleteSecretByNameRaw: delete a secret by name [environment=${environment}] [workspaceId=${workspaceId}] [secretPath=${secretPath}] [type=${type}]`
);
await checkSecretsPermission({ await checkSecretsPermission({
authData: req.authData, authData: req.authData,
@@ -807,7 +811,9 @@ export const getSecrets = async (req: Request, res: Response) => {
query: { secretPath } query: { secretPath }
} = validatedData; } = validatedData;
logger.info(`getSecrets: fetch encrypted secrets [environment=${environment}] [workspaceId=${workspaceId}] [includeImports=${includeImports}]`) logger.info(
`getSecrets: fetch encrypted secrets [environment=${environment}] [workspaceId=${workspaceId}] [includeImports=${includeImports}]`
);
const { authVerifier: permissionCheckFn } = await checkSecretsPermission({ const { authVerifier: permissionCheckFn } = await checkSecretsPermission({
authData: req.authData, authData: req.authData,
@@ -863,7 +869,9 @@ export const getSecretByName = async (req: Request, res: Response) => {
params: { secretName } params: { secretName }
} = await validateRequest(reqValidator.GetSecretByNameV3, req); } = await validateRequest(reqValidator.GetSecretByNameV3, req);
logger.info(`getSecretByName: get a single secret by name [environment=${environment}] [workspaceId=${workspaceId}] [include_imports=${include_imports}] [type=${type}]`) logger.info(
`getSecretByName: get a single secret by name [environment=${environment}] [workspaceId=${workspaceId}] [include_imports=${include_imports}] [type=${type}]`
);
await checkSecretsPermission({ await checkSecretsPermission({
authData: req.authData, authData: req.authData,
@@ -915,7 +923,9 @@ export const createSecret = async (req: Request, res: Response) => {
params: { secretName } params: { secretName }
} = await validateRequest(reqValidator.CreateSecretV3, req); } = await validateRequest(reqValidator.CreateSecretV3, req);
logger.info(`createSecret: create an encrypted secret [environment=${environment}] [workspaceId=${workspaceId}] [skipMultilineEncoding=${skipMultilineEncoding}] [type=${type}]`) logger.info(
`createSecret: create an encrypted secret [environment=${environment}] [workspaceId=${workspaceId}] [skipMultilineEncoding=${skipMultilineEncoding}] [type=${type}]`
);
await checkSecretsPermission({ await checkSecretsPermission({
authData: req.authData, authData: req.authData,
@@ -932,7 +942,11 @@ export const createSecret = async (req: Request, res: Response) => {
}); });
if (membership && type !== "personal") { if (membership && type !== "personal") {
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); const secretApprovalPolicy = await getSecretPolicyOfBoard(
workspaceId,
environment,
secretPath
);
if (secretApprovalPolicy) { if (secretApprovalPolicy) {
const secretApprovalRequest = await generateSecretApprovalRequest({ const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId, workspaceId,
@@ -1024,12 +1038,16 @@ export const updateSecretByName = async (req: Request, res: Response) => {
secretKeyIV, secretKeyIV,
secretKeyTag, secretKeyTag,
secretKeyCiphertext, secretKeyCiphertext,
skipMultilineEncoding skipMultilineEncoding,
secretReminderRepeatDays,
secretReminderNote
}, },
params: { secretName } params: { secretName }
} = await validateRequest(reqValidator.UpdateSecretByNameV3, req); } = await validateRequest(reqValidator.UpdateSecretByNameV3, req);
logger.info(`updateSecretByName: update a encrypted secret by name [environment=${environment}] [workspaceId=${workspaceId}] [skipMultilineEncoding=${skipMultilineEncoding}] [type=${type}]`) logger.info(
`updateSecretByName: update a encrypted secret by name [environment=${environment}] [workspaceId=${workspaceId}] [skipMultilineEncoding=${skipMultilineEncoding}] [type=${type}]`
);
if (newSecretName && (!secretKeyIV || !secretKeyTag || !secretKeyCiphertext)) { if (newSecretName && (!secretKeyIV || !secretKeyTag || !secretKeyCiphertext)) {
throw BadRequestError({ message: "Missing encrypted key" }); throw BadRequestError({ message: "Missing encrypted key" });
@@ -1050,7 +1068,11 @@ export const updateSecretByName = async (req: Request, res: Response) => {
}); });
if (membership && type !== "personal") { if (membership && type !== "personal") {
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); const secretApprovalPolicy = await getSecretPolicyOfBoard(
workspaceId,
environment,
secretPath
);
if (secretApprovalPolicy) { if (secretApprovalPolicy) {
const secretApprovalRequest = await generateSecretApprovalRequest({ const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId, workspaceId,
@@ -1084,6 +1106,41 @@ export const updateSecretByName = async (req: Request, res: Response) => {
} }
} }
if (type !== "personal") {
const existingSecret = await SecretService.getSecret({
secretName,
workspaceId: new Types.ObjectId(workspaceId),
environment,
type,
secretPath,
authData: req.authData
});
if (secretReminderRepeatDays !== undefined) {
if (
(secretReminderRepeatDays &&
existingSecret.secretReminderRepeatDays !== secretReminderRepeatDays) ||
(secretReminderNote && existingSecret.secretReminderNote !== secretReminderNote)
) {
await createReminder(existingSecret, {
_id: existingSecret._id,
secretReminderRepeatDays,
secretReminderNote,
workspace: existingSecret.workspace
});
} else if (
secretReminderRepeatDays === null &&
secretReminderNote === null &&
existingSecret.secretReminderRepeatDays
) {
await deleteReminder({
_id: existingSecret._id,
secretReminderRepeatDays: existingSecret.secretReminderRepeatDays
});
}
}
}
const secret = await SecretService.updateSecret({ const secret = await SecretService.updateSecret({
secretName, secretName,
workspaceId: new Types.ObjectId(workspaceId), workspaceId: new Types.ObjectId(workspaceId),
@@ -1094,6 +1151,8 @@ export const updateSecretByName = async (req: Request, res: Response) => {
newSecretName, newSecretName,
secretValueCiphertext, secretValueCiphertext,
secretValueIV, secretValueIV,
secretReminderRepeatDays,
secretReminderNote,
secretValueTag, secretValueTag,
secretPath, secretPath,
tags, tags,
@@ -1130,7 +1189,9 @@ export const deleteSecretByName = async (req: Request, res: Response) => {
params: { secretName } params: { secretName }
} = await validateRequest(reqValidator.DeleteSecretByNameV3, req); } = await validateRequest(reqValidator.DeleteSecretByNameV3, req);
logger.info(`deleteSecretByName: delete a encrypted secret by name [environment=${environment}] [workspaceId=${workspaceId}] [type=${type}]`) logger.info(
`deleteSecretByName: delete a encrypted secret by name [environment=${environment}] [workspaceId=${workspaceId}] [type=${type}]`
);
await checkSecretsPermission({ await checkSecretsPermission({
authData: req.authData, authData: req.authData,
@@ -1147,7 +1208,11 @@ export const deleteSecretByName = async (req: Request, res: Response) => {
}); });
if (membership && type !== "personal") { if (membership && type !== "personal") {
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); const secretApprovalPolicy = await getSecretPolicyOfBoard(
workspaceId,
environment,
secretPath
);
if (secretApprovalPolicy) { if (secretApprovalPolicy) {
const secretApprovalRequest = await generateSecretApprovalRequest({ const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId, workspaceId,
@@ -1197,7 +1262,9 @@ export const createSecretByNameBatch = async (req: Request, res: Response) => {
body: { secrets, secretPath, environment, workspaceId } body: { secrets, secretPath, environment, workspaceId }
} = await validateRequest(reqValidator.CreateSecretByNameBatchV3, req); } = await validateRequest(reqValidator.CreateSecretByNameBatchV3, req);
logger.info(`createSecretByNameBatch: create a list of secrets by their names [environment=${environment}] [workspaceId=${workspaceId}] [secretsLength=${secrets?.length}]`) logger.info(
`createSecretByNameBatch: create a list of secrets by their names [environment=${environment}] [workspaceId=${workspaceId}] [secretsLength=${secrets?.length}]`
);
await checkSecretsPermission({ await checkSecretsPermission({
authData: req.authData, authData: req.authData,
@@ -1214,7 +1281,11 @@ export const createSecretByNameBatch = async (req: Request, res: Response) => {
}); });
if (membership) { if (membership) {
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); const secretApprovalPolicy = await getSecretPolicyOfBoard(
workspaceId,
environment,
secretPath
);
if (secretApprovalPolicy) { if (secretApprovalPolicy) {
const secretApprovalRequest = await generateSecretApprovalRequest({ const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId, workspaceId,
@@ -1258,7 +1329,9 @@ export const updateSecretByNameBatch = async (req: Request, res: Response) => {
body: { secrets, secretPath, environment, workspaceId } body: { secrets, secretPath, environment, workspaceId }
} = await validateRequest(reqValidator.UpdateSecretByNameBatchV3, req); } = await validateRequest(reqValidator.UpdateSecretByNameBatchV3, req);
logger.info(`updateSecretByNameBatch: update a list of secrets by their names [environment=${environment}] [workspaceId=${workspaceId}] [secretsLength=${secrets?.length}]`) logger.info(
`updateSecretByNameBatch: update a list of secrets by their names [environment=${environment}] [workspaceId=${workspaceId}] [secretsLength=${secrets?.length}]`
);
await checkSecretsPermission({ await checkSecretsPermission({
authData: req.authData, authData: req.authData,
@@ -1275,7 +1348,11 @@ export const updateSecretByNameBatch = async (req: Request, res: Response) => {
}); });
if (membership) { if (membership) {
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); const secretApprovalPolicy = await getSecretPolicyOfBoard(
workspaceId,
environment,
secretPath
);
if (secretApprovalPolicy) { if (secretApprovalPolicy) {
const secretApprovalRequest = await generateSecretApprovalRequest({ const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId, workspaceId,
@@ -1319,7 +1396,9 @@ export const deleteSecretByNameBatch = async (req: Request, res: Response) => {
body: { secrets, secretPath, environment, workspaceId } body: { secrets, secretPath, environment, workspaceId }
} = await validateRequest(reqValidator.DeleteSecretByNameBatchV3, req); } = await validateRequest(reqValidator.DeleteSecretByNameBatchV3, req);
logger.info(`deleteSecretByNameBatch: delete a list of secrets by their names [environment=${environment}] [workspaceId=${workspaceId}] [secretsLength=${secrets?.length}]`) logger.info(
`deleteSecretByNameBatch: delete a list of secrets by their names [environment=${environment}] [workspaceId=${workspaceId}] [secretsLength=${secrets?.length}]`
);
await checkSecretsPermission({ await checkSecretsPermission({
authData: req.authData, authData: req.authData,
@@ -1336,7 +1415,11 @@ export const deleteSecretByNameBatch = async (req: Request, res: Response) => {
}); });
if (membership) { if (membership) {
const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); const secretApprovalPolicy = await getSecretPolicyOfBoard(
workspaceId,
environment,
secretPath
);
if (secretApprovalPolicy) { if (secretApprovalPolicy) {
const secretApprovalRequest = await generateSecretApprovalRequest({ const secretApprovalRequest = await generateSecretApprovalRequest({
workspaceId, workspaceId,
+58
View File
@@ -0,0 +1,58 @@
import { ISecret } from "../models";
import {
createRecurringSecretReminder,
deleteRecurringSecretReminder,
updateRecurringSecretReminder
} from "../queues/reminders/sendSecretReminders";
type TPartialSecret = Pick<
ISecret,
"_id" | "secretReminderRepeatDays" | "secretReminderNote" | "workspace"
>;
type TPartialSecretDeleteReminder = Pick<ISecret, "_id" | "secretReminderRepeatDays">;
export const createReminder = async (oldSecret: TPartialSecret, newSecret: TPartialSecret) => {
if (oldSecret._id !== newSecret._id) {
throw new Error("Secret id's don't match");
}
if (!newSecret.secretReminderRepeatDays) {
throw new Error("No repeat days provided");
}
const secretId = oldSecret._id.toString();
const workspaceId = oldSecret.workspace.toString();
if (oldSecret.secretReminderRepeatDays) {
// This will first delete the existing recurring job, and then create a new one.
await updateRecurringSecretReminder({
workspaceId,
secretId,
repeatDays: newSecret.secretReminderRepeatDays,
note: newSecret.secretReminderNote
});
} else {
// This will create a new recurring job.
await createRecurringSecretReminder({
workspaceId,
secretId,
repeatDays: newSecret.secretReminderRepeatDays,
note: newSecret.secretReminderNote
});
}
};
export const deleteReminder = async (secret: TPartialSecretDeleteReminder) => {
if (!secret._id) {
throw new Error("No secret id provided");
}
if (!secret.secretReminderRepeatDays) {
throw new Error("No repeat days provided");
}
await deleteRecurringSecretReminder({
secretId: secret._id.toString(),
repeatDays: secret.secretReminderRepeatDays
});
};
+6
View File
@@ -717,6 +717,8 @@ export const updateSecretHelper = async ({
secretValueIV, secretValueIV,
secretValueTag, secretValueTag,
secretPath, secretPath,
secretReminderRepeatDays,
secretReminderNote,
tags, tags,
secretCommentCiphertext, secretCommentCiphertext,
secretCommentIV, secretCommentIV,
@@ -781,6 +783,10 @@ export const updateSecretHelper = async ({
secretCommentIV, secretCommentIV,
secretCommentTag, secretCommentTag,
secretCommentCiphertext, secretCommentCiphertext,
secretReminderRepeatDays,
secretReminderNote,
skipMultilineEncoding, skipMultilineEncoding,
secretBlindIndex: newSecretNameBlindIndex, secretBlindIndex: newSecretNameBlindIndex,
secretKeyIV, secretKeyIV,
@@ -58,6 +58,10 @@ export interface UpdateSecretParams {
secretCommentCiphertext?: string; secretCommentCiphertext?: string;
secretCommentIV?: string; secretCommentIV?: string;
secretCommentTag?: string; secretCommentTag?: string;
secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null;
skipMultilineEncoding?: boolean; skipMultilineEncoding?: boolean;
tags?: string[]; tags?: string[];
} }
+19
View File
@@ -27,6 +27,12 @@ export interface ISecret {
secretCommentIV?: string; secretCommentIV?: string;
secretCommentTag?: string; secretCommentTag?: string;
secretCommentHash?: string; secretCommentHash?: string;
// ? NOTE: This works great for workspace-level reminders.
// ? If we want to do it on a user-basis, we should ideally have a seperate model for reminders.
secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null;
skipMultilineEncoding?: boolean; skipMultilineEncoding?: boolean;
algorithm: "aes-256-gcm"; algorithm: "aes-256-gcm";
keyEncoding: "utf8" | "base64"; keyEncoding: "utf8" | "base64";
@@ -118,10 +124,23 @@ const secretSchema = new Schema<ISecret>(
type: String, type: String,
required: false required: false
}, },
secretReminderRepeatDays: {
type: Number,
required: false,
default: null
},
secretReminderNote: {
type: String,
required: false,
default: null
},
skipMultilineEncoding: { skipMultilineEncoding: {
type: Boolean, type: Boolean,
required: false required: false
}, },
algorithm: { algorithm: {
// the encryption algorithm used // the encryption algorithm used
type: String, type: String,
@@ -0,0 +1,83 @@
import Queue, { Job } from "bull";
import { IUser, Membership, Organization, Workspace } from "../../models";
import { Types } from "mongoose";
import { sendMail } from "../../helpers";
type TSendSecretReminders = {
workspaceId: string;
secretId: string;
repeatDays: number;
note: string | undefined | null;
};
type TDeleteSecretReminder = {
secretId: string;
repeatDays: number;
};
const DAY_IN_MS = 86400000;
export const sendSecretReminders = new Queue(
"send-secret-reminders",
process.env.REDIS_URL as string
);
sendSecretReminders.process(async (job: Job<TSendSecretReminders>) => {
const { workspaceId }: TSendSecretReminders = job.data;
const workspace = await Workspace.findById(new Types.ObjectId(workspaceId));
const organization = await Organization.findById(new Types.ObjectId(workspace?.organization));
if (!workspace) {
throw new Error("Workspace for reminder not found");
}
if (!organization) {
throw new Error("Organization for reminder not found");
}
const memberships = await Membership.find({
workspace: workspaceId
}).populate<{ user: IUser }>("user");
await sendMail({
template: "secretReminder.handlebars",
subjectLine: "Infisical secret reminder",
recipients: [...memberships.map((membership) => membership.user.email)],
substitutions: {
reminderNote: job.data.note, // May not be present.
workspaceName: workspace.name,
organizationName: organization.name
}
});
});
export const createRecurringSecretReminder = (jobDetails: TSendSecretReminders) => {
const repeat = jobDetails.repeatDays * DAY_IN_MS;
return sendSecretReminders.add(jobDetails, {
delay: repeat,
repeat: {
every: repeat
},
jobId: `reminder-${jobDetails.secretId}`,
removeOnComplete: true,
removeOnFail: {
count: 20
}
});
};
export const deleteRecurringSecretReminder = (jobDetails: TDeleteSecretReminder) => {
const repeat = jobDetails.repeatDays * DAY_IN_MS;
return sendSecretReminders.removeRepeatable({
every: repeat,
jobId: `reminder-${jobDetails.secretId}`
});
};
export const updateRecurringSecretReminder = async (jobDetails: TSendSecretReminders) => {
// We need to delete the potentially existing reminder job first, or the new one won't be created.
await deleteRecurringSecretReminder(jobDetails);
await createRecurringSecretReminder(jobDetails);
};
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge">
<title>Secret Reminder</title>
</head>
<body>
<h2>Infisical</h2>
<h2>You have a new secret reminder!</h2>
<p>You have a new secret reminder from workspace "{{workspaceName}}", in {{organizationName}}</p>
{{#if reminderNote}}
<p>Here's the note included with the reminder: {{reminderNote}}</p>
{{/if}}
</body>
</html>
+6 -1
View File
@@ -10,7 +10,6 @@ import { AuthData } from "../interfaces/middleware";
import { ActorType } from "../ee/models"; import { ActorType } from "../ee/models";
import { z } from "zod"; import { z } from "zod";
import { SECRET_PERSONAL, SECRET_SHARED } from "../variables"; import { SECRET_PERSONAL, SECRET_SHARED } from "../variables";
/** /**
* Validate authenticated clients for secrets with id [secretId] based * Validate authenticated clients for secrets with id [secretId] based
* on any known permissions. * on any known permissions.
@@ -260,6 +259,7 @@ export const CreateSecretRawV3 = z.object({
.string() .string()
.transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())), .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())),
secretComment: z.string().trim().optional().default(""), secretComment: z.string().trim().optional().default(""),
skipMultilineEncoding: z.boolean().optional(), skipMultilineEncoding: z.boolean().optional(),
type: z.enum([SECRET_SHARED, SECRET_PERSONAL]) type: z.enum([SECRET_SHARED, SECRET_PERSONAL])
}), }),
@@ -275,6 +275,7 @@ export const UpdateSecretByNameRawV3 = z.object({
body: z.object({ body: z.object({
workspaceId: z.string().trim(), workspaceId: z.string().trim(),
environment: z.string().trim(), environment: z.string().trim(),
secretValue: z secretValue: z
.string() .string()
.transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())), .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())),
@@ -360,6 +361,10 @@ export const UpdateSecretByNameV3 = z.object({
secretCommentCiphertext: z.string().trim().optional(), secretCommentCiphertext: z.string().trim().optional(),
secretCommentIV: z.string().trim().optional(), secretCommentIV: z.string().trim().optional(),
secretCommentTag: z.string().trim().optional(), secretCommentTag: z.string().trim().optional(),
secretReminderRepeatDays: z.number().min(1).max(365).optional().nullable(),
secretReminderNote: z.string().trim().nullable().optional(),
tags: z.string().array().optional(), tags: z.string().array().optional(),
skipMultilineEncoding: z.boolean().optional(), skipMultilineEncoding: z.boolean().optional(),
// to update secret name // to update secret name
+1 -1
View File
@@ -63,7 +63,7 @@ representative at an online or offline event.
Instances of abusive, harassing, or otherwise unacceptable behavior may be Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at reported to the community leaders responsible for enforcement at
[INSERT CONTACT METHOD]. [email protected].
All complaints will be reviewed and investigated promptly and fairly. All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the All community leaders are obligated to respect the privacy and security of the
@@ -39,7 +39,7 @@ Other configs can be found [here](../configuration/envars)
</ParamField> </ParamField>
<ParamField query="REDIS_URL" type="string" default="none"> <ParamField query="REDIS_URL" type="string" default="none">
Redis connection string. Only required if you plan to use web integrations. Redis connection string. Only required if you plan to use web integrations or secret reminders.
</ParamField> </ParamField>
@@ -139,6 +139,8 @@ export const useUpdateSecretV3 = ({
latestFileKey, latestFileKey,
tags, tags,
secretComment, secretComment,
secretReminderRepeatDays,
secretReminderNote,
newSecretName, newSecretName,
skipMultilineEncoding skipMultilineEncoding
}) => { }) => {
@@ -157,6 +159,8 @@ export const useUpdateSecretV3 = ({
workspaceId, workspaceId,
environment, environment,
type, type,
secretReminderNote,
secretReminderRepeatDays,
secretPath, secretPath,
secretId, secretId,
...encryptSecret(randomBytes, newSecretName ?? secretName, secretValue, secretComment), ...encryptSecret(randomBytes, newSecretName ?? secretName, secretValue, secretComment),
@@ -69,6 +69,8 @@ export const decryptSecrets = (
value: secretValue, value: secretValue,
tags: encSecret.tags, tags: encSecret.tags,
comment: secretComment, comment: secretComment,
reminderRepeatDays: encSecret.secretReminderRepeatDays,
reminderNote: encSecret.secretReminderNote,
createdAt: encSecret.createdAt, createdAt: encSecret.createdAt,
updatedAt: encSecret.updatedAt, updatedAt: encSecret.updatedAt,
version: encSecret.version, version: encSecret.version,
+6
View File
@@ -20,6 +20,8 @@ export type EncryptedSecret = {
secretCommentCiphertext: string; secretCommentCiphertext: string;
secretCommentIV: string; secretCommentIV: string;
secretCommentTag: string; secretCommentTag: string;
secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null;
tags: WsTag[]; tags: WsTag[];
}; };
@@ -29,6 +31,8 @@ export type DecryptedSecret = {
key: string; key: string;
value: string; value: string;
comment: string; comment: string;
reminderRepeatDays?: number | null;
reminderNote?: string | null;
tags: WsTag[]; tags: WsTag[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -112,6 +116,8 @@ export type TUpdateSecretsV3DTO = {
secretId?: string; secretId?: string;
secretValue: string; secretValue: string;
secretComment?: string; secretComment?: string;
secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null;
tags?: string[]; tags?: string[];
}; };
@@ -0,0 +1,129 @@
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { faClock } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { Button, FormControl, Input, Modal, ModalContent, TextArea } from "@app/components/v2";
const ReminderFormSchema = z.object({
note: z.string().optional(),
days: z
.number()
.min(1, { message: "Must be at least 1 day" })
.max(365, { message: "Must be less than 365 days" })
});
export type TReminderFormSchema = z.infer<typeof ReminderFormSchema>;
interface ReminderFormProps {
isOpen: boolean;
onOpenChange: (isOpen: boolean, data?: TReminderFormSchema) => void;
}
export const CreateReminderForm = ({ isOpen, onOpenChange }: ReminderFormProps) => {
const {
register,
control,
reset,
setValue,
handleSubmit,
formState: { isSubmitting }
} = useForm<TReminderFormSchema>({
resolver: zodResolver(ReminderFormSchema)
});
const handleFormSubmit = async (data: TReminderFormSchema) => {
console.log(data);
onOpenChange(false, data);
};
useEffect(() => {
if (isOpen) {
reset();
}
}, [isOpen]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Create secret reminder"
// ? QUESTION: Should this specifically say its for secret rotation?
// ? Or should we be call it something more generic?
subTitle={
<div>
Set up a reminder for when this secret should be rotated. Everyone with access to this
project will be notified when the reminder is triggered.
</div>
}
>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-2">
<div>
<Controller
control={control}
name="days"
render={({ field, fieldState }) => (
<>
<FormControl
className="mb-0"
label="How many days between"
isError={Boolean(fieldState.error)}
errorText={fieldState.error?.message || ""}
>
<Input
onChange={(el) => setValue("days", parseInt(el.target.value, 10))}
type="number"
placeholder="31"
/>
</FormControl>
<div
className={twMerge(
"mt-2 ml-1 text-xs",
field.value ? "opacity-60" : "opacity-0"
)}
>
Every {field.value > 1 ? `${field.value} days` : "day"}
</div>
</>
)}
/>
</div>
<FormControl label="Note" className="mb-0">
<TextArea
placeholder="Remember to rotate the AWS secret every month."
className="border border-mineshaft-600 text-sm"
rows={8}
reSize="none"
cols={30}
{...register("note")}
/>
</FormControl>
</div>
<div className="mt-7 flex items-center">
<Button
isDisabled={isSubmitting}
isLoading={isSubmitting}
key="layout-create-project-submit"
className="mr-4"
leftIcon={<FontAwesomeIcon icon={faClock} />}
type="submit"
>
Create reminder
</Button>
<Button
key="layout-cancel-create-project"
onClick={() => onOpenChange(false)}
variant="plain"
colorSchema="secondary"
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -1,9 +1,11 @@
/* eslint-disable simple-import-sort/imports */
import { memo, useEffect } from "react"; import { memo, useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form"; import { Controller, useFieldArray, useForm } from "react-hook-form";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons"; import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import { import {
faCheck, faCheck,
faClock,
faClose, faClose,
faCodeBranch, faCodeBranch,
faComment, faComment,
@@ -17,7 +19,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions"; import { ProjectPermissionCan } from "@app/components/permissions";
import { import {
Button, Button,
@@ -49,6 +50,7 @@ import { DecryptedSecret } from "@app/hooks/api/secrets/types";
import { WsTag } from "@app/hooks/api/types"; import { WsTag } from "@app/hooks/api/types";
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils"; import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
import { CreateReminderForm } from "./CreateReminderForm";
type Props = { type Props = {
secret: DecryptedSecret; secret: DecryptedSecret;
@@ -111,6 +113,7 @@ export const SecretItem = memo(
const overrideAction = watch("overrideAction"); const overrideAction = watch("overrideAction");
const hasComment = Boolean(watch("comment")); const hasComment = Boolean(watch("comment"));
const hasReminder = Boolean(watch("reminderRepeatDays"));
const selectedTags = watch("tags", []); const selectedTags = watch("tags", []);
const selectedTagsGroupById = selectedTags.reduce<Record<string, boolean>>( const selectedTagsGroupById = selectedTags.reduce<Record<string, boolean>>(
@@ -123,6 +126,7 @@ export const SecretItem = memo(
}); });
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false); const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
const [createReminderFormOpen, setCreateReminderFormOpen] = useToggle(false);
useEffect(() => { useEffect(() => {
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
if (isSecValueCopied) { if (isSecValueCopied) {
@@ -148,6 +152,10 @@ export const SecretItem = memo(
} }
); );
setValue("valueOverride", secret?.valueOverride, { shouldDirty: !isUnsavedOverride }); setValue("valueOverride", secret?.valueOverride, { shouldDirty: !isUnsavedOverride });
setValue("reminderRepeatDays", secret?.reminderRepeatDays, {
shouldDirty: !isUnsavedOverride
});
setValue("reminderNote", secret?.reminderNote, { shouldDirty: !isUnsavedOverride });
} else { } else {
reset(); reset();
setValue("overrideAction", SecretActionType.Modified, { shouldDirty: true }); setValue("overrideAction", SecretActionType.Modified, { shouldDirty: true });
@@ -181,17 +189,30 @@ export const SecretItem = memo(
}; };
return ( return (
<>
<CreateReminderForm
isOpen={createReminderFormOpen}
onOpenChange={(_, data) => {
setCreateReminderFormOpen.toggle();
if (data) {
setValue("reminderRepeatDays", data.days, { shouldDirty: true });
setValue("reminderNote", data.note, { shouldDirty: true });
}
}}
/>
<form onSubmit={handleSubmit(handleFormSubmit)}> <form onSubmit={handleSubmit(handleFormSubmit)}>
<div <div
className={twMerge( className={twMerge(
"shadow-none border-b border-mineshaft-600 bg-mineshaft-800 hover:bg-mineshaft-700", "border-b border-mineshaft-600 bg-mineshaft-800 shadow-none hover:bg-mineshaft-700",
isDirty && "border-primary-400/50" isDirty && "border-primary-400/50"
)} )}
> >
<div className="flex group"> <div className="group flex">
<div <div
className={twMerge( className={twMerge(
"flex items-center justify-center w-11 px-4 py-3 h-11", "flex h-11 w-11 items-center justify-center px-4 py-3",
isDirty && "text-primary" isDirty && "text-primary"
)} )}
> >
@@ -199,14 +220,14 @@ export const SecretItem = memo(
id={`checkbox-${secret._id}`} id={`checkbox-${secret._id}`}
isChecked={isSelected} isChecked={isSelected}
onCheckedChange={() => onToggleSecretSelect(secret._id)} onCheckedChange={() => onToggleSecretSelect(secret._id)}
className={twMerge("group-hover:flex hidden ml-3", isSelected && "flex")} className={twMerge("ml-3 hidden group-hover:flex", isSelected && "flex")}
/> />
<FontAwesomeIcon <FontAwesomeIcon
icon={faKey} icon={faKey}
className={twMerge("group-hover:hidden block ml-3", isSelected && "hidden")} className={twMerge("ml-3 block group-hover:hidden", isSelected && "hidden")}
/> />
</div> </div>
<div className="w-80 h-11 flex items-center px-4 py-2 flex-shrink-0"> <div className="flex h-11 w-80 flex-shrink-0 items-center px-4 py-2">
<Controller <Controller
name="key" name="key"
control={control} control={control}
@@ -218,13 +239,13 @@ export const SecretItem = memo(
variant="plain" variant="plain"
isDisabled={isOverriden} isDisabled={isOverriden}
{...field} {...field}
className="w-full focus:text-bunker-100 focus:ring-transparent px-0" className="w-full px-0 focus:text-bunker-100 focus:ring-transparent"
/> />
)} )}
/> />
</div> </div>
<div <div
className="flex-grow flex items-center border-x border-mineshaft-600 pl-4 pr-2 py-1" className="flex flex-grow items-center border-x border-mineshaft-600 py-1 pl-4 pr-2"
tabIndex={0} tabIndex={0}
role="button" role="button"
> >
@@ -259,13 +280,13 @@ export const SecretItem = memo(
)} )}
/> />
)} )}
<div key="actions" className="h-8 flex self-start flex-shrink-0 transition-all"> <div key="actions" className="flex h-8 flex-shrink-0 self-start transition-all">
<Tooltip content="Copy secret"> <Tooltip content="Copy secret">
<IconButton <IconButton
ariaLabel="copy-value" ariaLabel="copy-value"
variant="plain" variant="plain"
size="sm" size="sm"
className="w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0" className="w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5"
onClick={copyTokenToClipboard} onClick={copyTokenToClipboard}
> >
<FontAwesomeIcon icon={isSecValueCopied ? faCheck : faCopy} /> <FontAwesomeIcon icon={isSecValueCopied ? faCheck : faCopy} />
@@ -283,7 +304,7 @@ export const SecretItem = memo(
variant="plain" variant="plain"
size="sm" size="sm"
className={twMerge( className={twMerge(
"w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0 data-[state=open]:w-5", "w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-5",
hasTagsApplied && "w-5 text-primary" hasTagsApplied && "w-5 text-primary"
)} )}
isDisabled={!isAllowed} isDisabled={!isAllowed}
@@ -310,7 +331,7 @@ export const SecretItem = memo(
> >
<div className="flex items-center"> <div className="flex items-center">
<div <div
className="w-2 h-2 rounded-full mr-2" className="mr-2 h-2 w-2 rounded-full"
style={{ background: tagColor || "#bec2c8" }} style={{ background: tagColor || "#bec2c8" }}
/> />
{name} {name}
@@ -346,7 +367,7 @@ export const SecretItem = memo(
size="sm" size="sm"
onClick={handleOverrideClick} onClick={handleOverrideClick}
className={twMerge( className={twMerge(
"w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0", "w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5",
isOverriden && "w-5 text-primary" isOverriden && "w-5 text-primary"
)} )}
> >
@@ -354,6 +375,33 @@ export const SecretItem = memo(
</IconButton> </IconButton>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
{!isOverriden && (
<IconButton
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6",
hasReminder && "w-5 text-primary"
)}
variant="plain"
size="md"
ariaLabel="add-reminder"
>
<Tooltip content="Reminder">
<FontAwesomeIcon
onClick={() => {
if (!hasReminder) {
setCreateReminderFormOpen.on();
} else {
setValue("reminderRepeatDays", null, { shouldDirty: true });
setValue("reminderNote", null, { shouldDirty: true });
}
}}
icon={faClock}
/>
</Tooltip>
</IconButton>
)}
<Popover> <Popover>
<ProjectPermissionCan <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
@@ -363,7 +411,7 @@ export const SecretItem = memo(
<PopoverTrigger asChild disabled={!isAllowed}> <PopoverTrigger asChild disabled={!isAllowed}>
<IconButton <IconButton
className={twMerge( className={twMerge(
"overflow-hidden w-0 p-0 group-hover:w-5 group-hover:mr-2 data-[state=open]:w-6", "w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6",
hasComment && "w-5 text-primary" hasComment && "w-5 text-primary"
)} )}
variant="plain" variant="plain"
@@ -398,7 +446,7 @@ export const SecretItem = memo(
{!isDirty ? ( {!isDirty ? (
<motion.div <motion.div
key="options" key="options"
className="h-10 flex items-center space-x-4 flex-shrink-0 px-3" className="flex h-10 flex-shrink-0 items-center space-x-4 px-3"
initial={{ x: 0, opacity: 0 }} initial={{ x: 0, opacity: 0 }}
animate={{ x: 0, opacity: 1 }} animate={{ x: 0, opacity: 1 }}
exit={{ x: 10, opacity: 0 }} exit={{ x: 10, opacity: 0 }}
@@ -408,7 +456,7 @@ export const SecretItem = memo(
ariaLabel="more" ariaLabel="more"
variant="plain" variant="plain"
size="md" size="md"
className="group-hover:opacity-100 opacity-0 p-0" className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => onDetailViewSecret(secret)} onClick={() => onDetailViewSecret(secret)}
> >
<FontAwesomeIcon icon={faEllipsis} size="lg" /> <FontAwesomeIcon icon={faEllipsis} size="lg" />
@@ -426,7 +474,7 @@ export const SecretItem = memo(
variant="plain" variant="plain"
colorSchema="danger" colorSchema="danger"
size="md" size="md"
className="group-hover:opacity-100 opacity-0 p-0" className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => onDeleteSecret(secret)} onClick={() => onDeleteSecret(secret)}
isDisabled={!isAllowed} isDisabled={!isAllowed}
> >
@@ -438,7 +486,7 @@ export const SecretItem = memo(
) : ( ) : (
<motion.div <motion.div
key="options-save" key="options-save"
className="h-10 flex items-center space-x-4 flex-shrink-0 px-3" className="flex h-10 flex-shrink-0 items-center space-x-4 px-3"
initial={{ x: -10, opacity: 0 }} initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }} animate={{ x: 0, opacity: 1 }}
exit={{ x: -10, opacity: 0 }} exit={{ x: -10, opacity: 0 }}
@@ -450,13 +498,13 @@ export const SecretItem = memo(
type="submit" type="submit"
size="md" size="md"
className={twMerge( className={twMerge(
"group-hover:opacity-100 opacity-0 p-0 text-primary", "p-0 text-primary opacity-0 group-hover:opacity-100",
isDirty && "opacity-100" isDirty && "opacity-100"
)} )}
isDisabled={isSubmitting} isDisabled={isSubmitting}
> >
{isSubmitting ? ( {isSubmitting ? (
<Spinner className="w-4 h-4 p-0 m-0" /> <Spinner className="m-0 h-4 w-4 p-0" />
) : ( ) : (
<FontAwesomeIcon icon={faCheck} size="lg" className="text-primary" /> <FontAwesomeIcon icon={faCheck} size="lg" className="text-primary" />
)} )}
@@ -468,7 +516,7 @@ export const SecretItem = memo(
variant="plain" variant="plain"
size="md" size="md"
className={twMerge( className={twMerge(
"group-hover:opacity-100 opacity-0 p-0", "p-0 opacity-0 group-hover:opacity-100",
isDirty && "opacity-100" isDirty && "opacity-100"
)} )}
onClick={() => reset()} onClick={() => reset()}
@@ -483,6 +531,7 @@ export const SecretItem = memo(
</div> </div>
</div> </div>
</form> </form>
</>
); );
} }
); );
@@ -122,6 +122,8 @@ export const SecretListView = ({
{ {
value, value,
comment, comment,
reminderRepeatDays,
reminderNote,
tags, tags,
skipMultilineEncoding, skipMultilineEncoding,
newKey, newKey,
@@ -129,6 +131,8 @@ export const SecretListView = ({
}: Partial<{ }: Partial<{
value: string; value: string;
comment: string; comment: string;
reminderRepeatDays: number | null;
reminderNote: string | null;
tags: string[]; tags: string[];
skipMultilineEncoding: boolean; skipMultilineEncoding: boolean;
newKey: string; newKey: string;
@@ -159,6 +163,8 @@ export const SecretListView = ({
latestFileKey: decryptFileKey, latestFileKey: decryptFileKey,
tags, tags,
secretComment: comment, secretComment: comment,
secretReminderRepeatDays: reminderRepeatDays,
secretReminderNote: reminderNote,
skipMultilineEncoding, skipMultilineEncoding,
newSecretName: newKey newSecretName: newKey
}); });
@@ -188,16 +194,33 @@ export const SecretListView = ({
cb?: () => void cb?: () => void
) => { ) => {
const { key: oldKey } = orgSecret; const { key: oldKey } = orgSecret;
const { key, value, overrideAction, idOverride, valueOverride, tags, comment } = modSecret; const {
key,
value,
overrideAction,
idOverride,
valueOverride,
tags,
comment,
reminderRepeatDays,
reminderNote
} = modSecret;
const hasKeyChanged = oldKey !== key; const hasKeyChanged = oldKey !== key;
const tagIds = tags.map(({ _id }) => _id); const tagIds = tags.map(({ _id }) => _id);
const oldTagIds = orgSecret.tags.map(({ _id }) => _id); const oldTagIds = orgSecret.tags.map(({ _id }) => _id);
const isSameTags = JSON.stringify(tagIds) === JSON.stringify(oldTagIds); const isSameTags = JSON.stringify(tagIds) === JSON.stringify(oldTagIds);
const isSharedSecUnchanged = const isSharedSecUnchanged =
(["key", "value", "comment", "skipMultilineEncoding"] as const).every( (
(el) => orgSecret[el] === modSecret[el] [
) && isSameTags; "key",
"value",
"comment",
"skipMultilineEncoding",
"reminderRepeatDays",
"reminderNote"
] as const
).every((el) => orgSecret[el] === modSecret[el]) && isSameTags;
try { try {
// personal secret change // personal secret change
@@ -222,13 +245,14 @@ export const SecretListView = ({
value, value,
tags: tagIds, tags: tagIds,
comment, comment,
reminderRepeatDays,
reminderNote,
secretId: orgSecret._id, secretId: orgSecret._id,
newKey: hasKeyChanged ? key : undefined, newKey: hasKeyChanged ? key : undefined,
skipMultilineEncoding: modSecret.skipMultilineEncoding skipMultilineEncoding: modSecret.skipMultilineEncoding
}); });
if (cb) cb(); if (cb) cb();
} }
queryClient.invalidateQueries( queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath }) secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
); );
@@ -306,7 +330,7 @@ export const SecretListView = ({
<div className="flex flex-col" key={`${namespace}-${groupedSecrets.length}`}> <div className="flex flex-col" key={`${namespace}-${groupedSecrets.length}`}>
<div <div
className={twMerge( className={twMerge(
"bg-bunker-600 capitalize text-md h-0 transition-all", "text-md h-0 bg-bunker-600 capitalize transition-all",
Boolean(namespace) && Boolean(filteredSecrets.length) && "h-11 py-3 pl-4 " Boolean(namespace) && Boolean(filteredSecrets.length) && "h-11 py-3 pl-4 "
)} )}
key={namespace} key={namespace}
@@ -20,6 +20,15 @@ export const formSchema = z.object({
overrideAction: z.string().trim().optional(), overrideAction: z.string().trim().optional(),
comment: z.string().trim().optional(), comment: z.string().trim().optional(),
skipMultilineEncoding: z.boolean().optional(), skipMultilineEncoding: z.boolean().optional(),
reminderRepeatDays: z
.number()
.min(1, { message: "Days must be between 1 and 365" })
.max(365, { message: "Days must be between 1 and 365" })
.nullable()
.optional(),
reminderNote: z.string().trim().nullable().optional(),
tags: z tags: z
.object({ .object({
_id: z.string(), _id: z.string(),