mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-07 11:27:32 +00:00
Merge pull request #1198 from DanielHougaard/daniel/assignment
(feat): Secret reminders
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user