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,308 +189,349 @@ export const SecretItem = memo(
}; };
return ( return (
<form onSubmit={handleSubmit(handleFormSubmit)}> <>
<div <CreateReminderForm
className={twMerge( isOpen={createReminderFormOpen}
"shadow-none border-b border-mineshaft-600 bg-mineshaft-800 hover:bg-mineshaft-700", onOpenChange={(_, data) => {
isDirty && "border-primary-400/50" setCreateReminderFormOpen.toggle();
)}
> if (data) {
<div className="flex group"> setValue("reminderRepeatDays", data.days, { shouldDirty: true });
<div setValue("reminderNote", data.note, { shouldDirty: true });
className={twMerge( }
"flex items-center justify-center w-11 px-4 py-3 h-11", }}
isDirty && "text-primary" />
)}
> <form onSubmit={handleSubmit(handleFormSubmit)}>
<Checkbox <div
id={`checkbox-${secret._id}`} className={twMerge(
isChecked={isSelected} "border-b border-mineshaft-600 bg-mineshaft-800 shadow-none hover:bg-mineshaft-700",
onCheckedChange={() => onToggleSecretSelect(secret._id)} isDirty && "border-primary-400/50"
className={twMerge("group-hover:flex hidden ml-3", isSelected && "flex")} )}
/> >
<FontAwesomeIcon <div className="group flex">
icon={faKey} <div
className={twMerge("group-hover:hidden block ml-3", isSelected && "hidden")} className={twMerge(
/> "flex h-11 w-11 items-center justify-center px-4 py-3",
</div> isDirty && "text-primary"
<div className="w-80 h-11 flex items-center px-4 py-2 flex-shrink-0"> )}
<Controller >
name="key" <Checkbox
control={control} id={`checkbox-${secret._id}`}
render={({ field }) => ( isChecked={isSelected}
<Input onCheckedChange={() => onToggleSecretSelect(secret._id)}
autoComplete="off" className={twMerge("ml-3 hidden group-hover:flex", isSelected && "flex")}
isReadOnly={isReadOnly} />
autoCapitalization={currentWorkspace?.autoCapitalization} <FontAwesomeIcon
variant="plain" icon={faKey}
isDisabled={isOverriden} className={twMerge("ml-3 block group-hover:hidden", isSelected && "hidden")}
{...field} />
className="w-full focus:text-bunker-100 focus:ring-transparent px-0" </div>
<div className="flex h-11 w-80 flex-shrink-0 items-center px-4 py-2">
<Controller
name="key"
control={control}
render={({ field }) => (
<Input
autoComplete="off"
isReadOnly={isReadOnly}
autoCapitalization={currentWorkspace?.autoCapitalization}
variant="plain"
isDisabled={isOverriden}
{...field}
className="w-full px-0 focus:text-bunker-100 focus:ring-transparent"
/>
)}
/>
</div>
<div
className="flex flex-grow items-center border-x border-mineshaft-600 py-1 pl-4 pr-2"
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="flex h-8 flex-shrink-0 self-start transition-all">
</div> <Tooltip content="Copy secret">
<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 <IconButton
ariaLabel="override-value" ariaLabel="copy-value"
isDisabled={!isAllowed}
variant="plain" variant="plain"
size="sm" size="sm"
onClick={handleOverrideClick} className="w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5"
className={twMerge( onClick={copyTokenToClipboard}
"w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0",
isOverriden && "w-5 text-primary"
)}
> >
<FontAwesomeIcon icon={faCodeBranch} /> <FontAwesomeIcon icon={isSecValueCopied ? faCheck : faCopy} />
</IconButton> </IconButton>
)} </Tooltip>
</ProjectPermissionCan> <DropdownMenu>
<Popover> <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 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 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="mr-2 h-2 w-2 rounded-full"
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 <ProjectPermissionCan
I={ProjectPermissionActions.Edit} I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })} 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 renderTooltip
allowedLabel="Delete" allowedLabel="Override"
> >
{(isAllowed) => ( {(isAllowed) => (
<IconButton <IconButton
ariaLabel="delete-value" ariaLabel="override-value"
variant="plain"
colorSchema="danger"
size="md"
className="group-hover:opacity-100 opacity-0 p-0"
onClick={() => onDeleteSecret(secret)}
isDisabled={!isAllowed} isDisabled={!isAllowed}
variant="plain"
size="sm"
onClick={handleOverrideClick}
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5",
isOverriden && "w-5 text-primary"
)}
> >
<FontAwesomeIcon icon={faClose} size="lg" /> <FontAwesomeIcon icon={faCodeBranch} />
</IconButton> </IconButton>
)} )}
</ProjectPermissionCan> </ProjectPermissionCan>
</motion.div>
) : ( {!isOverriden && (
<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 <IconButton
ariaLabel="more"
variant="plain"
type="submit"
size="md"
className={twMerge( className={twMerge(
"group-hover:opacity-100 opacity-0 p-0 text-primary", "w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6",
isDirty && "opacity-100" hasReminder && "w-5 text-primary"
)} )}
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" variant="plain"
size="md" size="md"
className={twMerge( ariaLabel="add-reminder"
"group-hover:opacity-100 opacity-0 p-0",
isDirty && "opacity-100"
)}
onClick={() => reset()}
isDisabled={isSubmitting}
> >
<FontAwesomeIcon icon={faClose} size="lg" /> <Tooltip content="Reminder">
<FontAwesomeIcon
onClick={() => {
if (!hasReminder) {
setCreateReminderFormOpen.on();
} else {
setValue("reminderRepeatDays", null, { shouldDirty: true });
setValue("reminderNote", null, { shouldDirty: true });
}
}}
icon={faClock}
/>
</Tooltip>
</IconButton> </IconButton>
</Tooltip> )}
</motion.div>
)} <Popover>
</AnimatePresence> <ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={subject(ProjectPermissionSub.Secrets, { environment, secretPath })}
>
{(isAllowed) => (
<PopoverTrigger asChild disabled={!isAllowed}>
<IconButton
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 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="flex h-10 flex-shrink-0 items-center space-x-4 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="p-0 opacity-0 group-hover:opacity-100"
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="p-0 opacity-0 group-hover:opacity-100"
onClick={() => onDeleteSecret(secret)}
isDisabled={!isAllowed}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
)}
</ProjectPermissionCan>
</motion.div>
) : (
<motion.div
key="options-save"
className="flex h-10 flex-shrink-0 items-center space-x-4 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(
"p-0 text-primary opacity-0 group-hover:opacity-100",
isDirty && "opacity-100"
)}
isDisabled={isSubmitting}
>
{isSubmitting ? (
<Spinner className="m-0 h-4 w-4 p-0" />
) : (
<FontAwesomeIcon icon={faCheck} size="lg" className="text-primary" />
)}
</IconButton>
</Tooltip>
<Tooltip content="Cancel">
<IconButton
ariaLabel="more"
variant="plain"
size="md"
className={twMerge(
"p-0 opacity-0 group-hover:opacity-100",
isDirty && "opacity-100"
)}
onClick={() => reset()}
isDisabled={isSubmitting}
>
<FontAwesomeIcon icon={faClose} size="lg" />
</IconButton>
</Tooltip>
</motion.div>
)}
</AnimatePresence>
</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(),