Merge pull request #3453 from Infisical/daniel/reminders

feat(reminders): specify recipients
This commit is contained in:
Daniel Hougaard
2025-04-28 18:14:23 +04:00
committed by GitHub
31 changed files with 467 additions and 59 deletions

View File

@@ -423,6 +423,11 @@ import {
TWorkflowIntegrationsInsert, TWorkflowIntegrationsInsert,
TWorkflowIntegrationsUpdate TWorkflowIntegrationsUpdate
} from "@app/db/schemas"; } from "@app/db/schemas";
import {
TSecretReminderRecipients,
TSecretReminderRecipientsInsert,
TSecretReminderRecipientsUpdate
} from "@app/db/schemas/secret-reminder-recipients";
declare module "knex" { declare module "knex" {
namespace Knex { namespace Knex {
@@ -994,5 +999,10 @@ declare module "knex/types/tables" {
TSecretRotationV2SecretMappingsInsert, TSecretRotationV2SecretMappingsInsert,
TSecretRotationV2SecretMappingsUpdate TSecretRotationV2SecretMappingsUpdate
>; >;
[TableName.SecretReminderRecipients]: KnexOriginal.CompositeTableType<
TSecretReminderRecipients,
TSecretReminderRecipientsInsert,
TSecretReminderRecipientsUpdate
>;
} }
} }

View File

@@ -0,0 +1,34 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
export async function up(knex: Knex): Promise<void> {
const hasSecretReminderRecipientsTable = await knex.schema.hasTable(TableName.SecretReminderRecipients);
if (!hasSecretReminderRecipientsTable) {
await knex.schema.createTable(TableName.SecretReminderRecipients, (table) => {
table.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
table.timestamps(true, true, true);
table.uuid("secretId").notNullable();
table.uuid("userId").notNullable();
table.string("projectId").notNullable();
// Based on userId rather than project membership ID so we can easily extend group support in the future if need be.
// This does however mean we need to manually clean up once a user is removed from a project.
table.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
table.foreign("secretId").references("id").inTable(TableName.SecretV2).onDelete("CASCADE");
table.foreign("projectId").references("id").inTable(TableName.Project).onDelete("CASCADE");
table.index("secretId");
table.unique(["secretId", "userId"]);
});
}
}
export async function down(knex: Knex): Promise<void> {
const hasSecretReminderRecipientsTable = await knex.schema.hasTable(TableName.SecretReminderRecipients);
if (hasSecretReminderRecipientsTable) {
await knex.schema.dropTableIfExists(TableName.SecretReminderRecipients);
}
}

View File

@@ -20,7 +20,7 @@ export const CertificatesSchema = z.object({
notAfter: z.date(), notAfter: z.date(),
revokedAt: z.date().nullable().optional(), revokedAt: z.date().nullable().optional(),
revocationReason: z.number().nullable().optional(), revocationReason: z.number().nullable().optional(),
altNames: z.string().default("").nullable().optional(), altNames: z.string().nullable().optional(),
caCertId: z.string().uuid(), caCertId: z.string().uuid(),
certificateTemplateId: z.string().uuid().nullable().optional(), certificateTemplateId: z.string().uuid().nullable().optional(),
keyUsages: z.string().array().nullable().optional(), keyUsages: z.string().array().nullable().optional(),

View File

@@ -13,7 +13,7 @@ export const KmipOrgServerCertificatesSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
orgId: z.string().uuid(), orgId: z.string().uuid(),
commonName: z.string(), commonName: z.string(),
altNames: z.string(), altNames: z.string().nullable().optional(),
serialNumber: z.string(), serialNumber: z.string(),
keyAlgorithm: z.string(), keyAlgorithm: z.string(),
issuedAt: z.date(), issuedAt: z.date(),

View File

@@ -146,7 +146,8 @@ export enum TableName {
KmipOrgServerCertificates = "kmip_org_server_certificates", KmipOrgServerCertificates = "kmip_org_server_certificates",
KmipClientCertificates = "kmip_client_certificates", KmipClientCertificates = "kmip_client_certificates",
SecretRotationV2 = "secret_rotations_v2", SecretRotationV2 = "secret_rotations_v2",
SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings" SecretRotationV2SecretMapping = "secret_rotation_v2_secret_mappings",
SecretReminderRecipients = "secret_reminder_recipients"
} }
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt"; export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt";

View File

@@ -30,9 +30,9 @@ export const OidcConfigsSchema = z.object({
updatedAt: z.date(), updatedAt: z.date(),
orgId: z.string().uuid(), orgId: z.string().uuid(),
lastUsed: z.date().nullable().optional(), lastUsed: z.date().nullable().optional(),
manageGroupMemberships: z.boolean().default(false),
encryptedOidcClientId: zodBuffer, encryptedOidcClientId: zodBuffer,
encryptedOidcClientSecret: zodBuffer, encryptedOidcClientSecret: zodBuffer,
manageGroupMemberships: z.boolean().default(false),
jwtSignatureAlgorithm: z.string().default("RS256") jwtSignatureAlgorithm: z.string().default("RS256")
}); });

View File

@@ -23,6 +23,7 @@ export const OrganizationsSchema = z.object({
defaultMembershipRole: z.string().default("member"), defaultMembershipRole: z.string().default("member"),
enforceMfa: z.boolean().default(false), enforceMfa: z.boolean().default(false),
selectedMfaMethod: z.string().nullable().optional(), selectedMfaMethod: z.string().nullable().optional(),
secretShareSendToAnyone: z.boolean().default(true).nullable().optional(),
allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(), allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(),
shouldUseNewPrivilegeSystem: z.boolean().default(true), shouldUseNewPrivilegeSystem: z.boolean().default(true),
privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(),

View File

@@ -0,0 +1,23 @@
// Code generated by automation script, DO NOT EDIT.
// Automated by pulling database and generating zod schema
// To update. Just run npm run generate:schema
// Written by akhilmhdh.
import { z } from "zod";
import { TImmutableDBKeys } from "./models";
export const SecretReminderRecipientsSchema = z.object({
id: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date(),
secretId: z.string().uuid(),
userId: z.string().uuid(),
projectId: z.string()
});
export type TSecretReminderRecipients = z.infer<typeof SecretReminderRecipientsSchema>;
export type TSecretReminderRecipientsInsert = Omit<z.input<typeof SecretReminderRecipientsSchema>, TImmutableDBKeys>;
export type TSecretReminderRecipientsUpdate = Partial<
Omit<z.input<typeof SecretReminderRecipientsSchema>, TImmutableDBKeys>
>;

View File

@@ -33,6 +33,7 @@ export type TApprovalCreateSecretV2Bridge = {
secretComment?: string; secretComment?: string;
reminderNote?: string | null; reminderNote?: string | null;
reminderRepeatDays?: number | null; reminderRepeatDays?: number | null;
secretReminderRecipients?: string[] | null;
skipMultilineEncoding?: boolean; skipMultilineEncoding?: boolean;
metadata?: Record<string, string>; metadata?: Record<string, string>;
secretMetadata?: ResourceMetadataDTO; secretMetadata?: ResourceMetadataDTO;

View File

@@ -807,6 +807,8 @@ export const RAW_SECRETS = {
tagIds: "The ID of the tags to be attached to the updated secret.", tagIds: "The ID of the tags to be attached to the updated secret.",
secretReminderRepeatDays: "Interval for secret rotation notifications, measured in days.", secretReminderRepeatDays: "Interval for secret rotation notifications, measured in days.",
secretReminderNote: "Note to be attached in notification email.", secretReminderNote: "Note to be attached in notification email.",
secretReminderRecipients:
"An array of user IDs that will receive the reminder email. If not specified, all project members will receive the reminder email.",
newSecretName: "The new name for the secret." newSecretName: "The new name for the secret."
}, },
DELETE: { DELETE: {

View File

@@ -215,6 +215,7 @@ import { secretFolderServiceFactory } from "@app/services/secret-folder/secret-f
import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal"; import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal";
import { secretImportDALFactory } from "@app/services/secret-import/secret-import-dal"; import { secretImportDALFactory } from "@app/services/secret-import/secret-import-dal";
import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service"; import { secretImportServiceFactory } from "@app/services/secret-import/secret-import-service";
import { secretReminderRecipientsDALFactory } from "@app/services/secret-reminder-recipients/secret-reminder-recipients-dal";
import { secretSharingDALFactory } from "@app/services/secret-sharing/secret-sharing-dal"; import { secretSharingDALFactory } from "@app/services/secret-sharing/secret-sharing-dal";
import { secretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service"; import { secretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service";
import { secretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal"; import { secretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal";
@@ -419,6 +420,7 @@ export const registerRoutes = async (
const orgGatewayConfigDAL = orgGatewayConfigDALFactory(db); const orgGatewayConfigDAL = orgGatewayConfigDALFactory(db);
const gatewayDAL = gatewayDALFactory(db); const gatewayDAL = gatewayDALFactory(db);
const projectGatewayDAL = projectGatewayDALFactory(db); const projectGatewayDAL = projectGatewayDALFactory(db);
const secretReminderRecipientsDAL = secretReminderRecipientsDALFactory(db);
const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL); const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL);
@@ -735,6 +737,7 @@ export const registerRoutes = async (
projectKeyDAL, projectKeyDAL,
projectRoleDAL, projectRoleDAL,
groupProjectDAL, groupProjectDAL,
secretReminderRecipientsDAL,
licenseService licenseService
}); });
const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({
@@ -968,6 +971,7 @@ export const registerRoutes = async (
secretApprovalRequestDAL, secretApprovalRequestDAL,
projectKeyDAL, projectKeyDAL,
projectUserMembershipRoleDAL, projectUserMembershipRoleDAL,
secretReminderRecipientsDAL,
orgService, orgService,
resourceMetadataDAL, resourceMetadataDAL,
secretSyncQueue secretSyncQueue

View File

@@ -1,7 +1,7 @@
import { ForbiddenError } from "@casl/ability"; import { ForbiddenError } from "@casl/ability";
import { z } from "zod"; import { z } from "zod";
import { SecretFoldersSchema, SecretImportsSchema } from "@app/db/schemas"; import { SecretFoldersSchema, SecretImportsSchema, UsersSchema } from "@app/db/schemas";
import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types";
import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission"; import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission";
import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema";
@@ -594,6 +594,12 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => {
.optional(), .optional(),
secrets: secretRawSchema secrets: secretRawSchema
.extend({ .extend({
secretReminderRecipients: z
.object({
user: UsersSchema.pick({ id: true, email: true, username: true }),
id: z.string()
})
.array(),
secretValueHidden: z.boolean(), secretValueHidden: z.boolean(),
secretPath: z.string().optional(), secretPath: z.string().optional(),
secretMetadata: ResourceMetadataSchema.optional(), secretMetadata: ResourceMetadataSchema.optional(),

View File

@@ -662,6 +662,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
.optional() .optional()
.nullable() .nullable()
.describe(RAW_SECRETS.UPDATE.secretReminderRepeatDays), .describe(RAW_SECRETS.UPDATE.secretReminderRepeatDays),
secretReminderRecipients: z.string().array().optional().describe(RAW_SECRETS.UPDATE.secretReminderRecipients),
newSecretName: SecretNameSchema.optional().describe(RAW_SECRETS.UPDATE.newSecretName), newSecretName: SecretNameSchema.optional().describe(RAW_SECRETS.UPDATE.newSecretName),
secretComment: z.string().optional().describe(RAW_SECRETS.UPDATE.secretComment) secretComment: z.string().optional().describe(RAW_SECRETS.UPDATE.secretComment)
}), }),
@@ -692,6 +693,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => {
skipMultilineEncoding: req.body.skipMultilineEncoding, skipMultilineEncoding: req.body.skipMultilineEncoding,
tagIds: req.body.tagIds, tagIds: req.body.tagIds,
secretReminderRepeatDays: req.body.secretReminderRepeatDays, secretReminderRepeatDays: req.body.secretReminderRepeatDays,
secretReminderRecipients: req.body.secretReminderRecipients,
secretReminderNote: req.body.secretReminderNote, secretReminderNote: req.body.secretReminderNote,
metadata: req.body.metadata, metadata: req.body.metadata,
newSecretName: req.body.newSecretName, newSecretName: req.body.newSecretName,

View File

@@ -23,6 +23,7 @@ import { TProjectDALFactory } from "../project/project-dal";
import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal";
import { TProjectKeyDALFactory } from "../project-key/project-key-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
import { TProjectRoleDALFactory } from "../project-role/project-role-dal"; import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
import { TSecretReminderRecipientsDALFactory } from "../secret-reminder-recipients/secret-reminder-recipients-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service"; import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TUserDALFactory } from "../user/user-dal"; import { TUserDALFactory } from "../user/user-dal";
import { TProjectMembershipDALFactory } from "./project-membership-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal";
@@ -53,6 +54,7 @@ type TProjectMembershipServiceFactoryDep = {
projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">; projectKeyDAL: Pick<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">;
licenseService: Pick<TLicenseServiceFactory, "getPlan">; licenseService: Pick<TLicenseServiceFactory, "getPlan">;
projectUserAdditionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">; projectUserAdditionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
secretReminderRecipientsDAL: Pick<TSecretReminderRecipientsDALFactory, "delete">;
groupProjectDAL: TGroupProjectDALFactory; groupProjectDAL: TGroupProjectDALFactory;
}; };
@@ -71,6 +73,7 @@ export const projectMembershipServiceFactory = ({
groupProjectDAL, groupProjectDAL,
projectDAL, projectDAL,
projectKeyDAL, projectKeyDAL,
secretReminderRecipientsDAL,
licenseService licenseService
}: TProjectMembershipServiceFactoryDep) => { }: TProjectMembershipServiceFactoryDep) => {
const getProjectMemberships = async ({ const getProjectMemberships = async ({
@@ -389,6 +392,13 @@ export const projectMembershipServiceFactory = ({
const membership = await projectMembershipDAL.transaction(async (tx) => { const membership = await projectMembershipDAL.transaction(async (tx) => {
const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx); const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx);
await projectKeyDAL.delete({ receiverId: deletedMembership.userId, projectId }, tx); await projectKeyDAL.delete({ receiverId: deletedMembership.userId, projectId }, tx);
await secretReminderRecipientsDAL.delete(
{
projectId,
userId: deletedMembership.userId
},
tx
);
return deletedMembership; return deletedMembership;
}); });
return membership; return membership;
@@ -466,6 +476,16 @@ export const projectMembershipServiceFactory = ({
tx tx
); );
await secretReminderRecipientsDAL.delete(
{
projectId,
$in: {
userId: projectMembers.map(({ user }) => user.id)
}
},
tx
);
// delete project keys belonging to users that are not part of any other groups in the project // delete project keys belonging to users that are not part of any other groups in the project
await projectKeyDAL.delete( await projectKeyDAL.delete(
{ {
@@ -526,6 +546,15 @@ export const projectMembershipServiceFactory = ({
}, },
tx tx
); );
await secretReminderRecipientsDAL.delete(
{
projectId,
userId: actorId
},
tx
);
const membership = ( const membership = (
await projectMembershipDAL.delete( await projectMembershipDAL.delete(
{ {

View File

@@ -0,0 +1,36 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify, selectAllTableCols } from "@app/lib/knex";
export type TSecretReminderRecipientsDALFactory = ReturnType<typeof secretReminderRecipientsDALFactory>;
export const secretReminderRecipientsDALFactory = (db: TDbClient) => {
const secretReminderRecipientsOrm = ormify(db, TableName.SecretReminderRecipients);
const findUsersBySecretId = async (secretId: string, tx?: Knex) => {
const res = await (tx || db.replicaNode())(TableName.SecretReminderRecipients)
.where({ secretId })
.leftJoin(TableName.Users, `${TableName.SecretReminderRecipients}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.Project, `${TableName.SecretReminderRecipients}.projectId`, `${TableName.Project}.id`)
.leftJoin(TableName.OrgMembership, (bd) => {
void bd
.on(`${TableName.OrgMembership}.userId`, "=", `${TableName.SecretReminderRecipients}.userId`)
.andOn(`${TableName.OrgMembership}.orgId`, "=", `${TableName.Project}.orgId`);
})
.where(`${TableName.OrgMembership}.isActive`, true)
.select(selectAllTableCols(TableName.SecretReminderRecipients))
.select(
db.ref("email").withSchema(TableName.Users).as("email"),
db.ref("username").withSchema(TableName.Users).as("username"),
db.ref("firstName").withSchema(TableName.Users).as("firstName"),
db.ref("lastName").withSchema(TableName.Users).as("lastName")
);
return res;
};
return { ...secretReminderRecipientsOrm, findUsersBySecretId };
};

View File

@@ -0,0 +1,8 @@
export type TSecretReminderRecipient = {
user: {
id: string;
username: string;
email?: string | null;
};
id: string;
};

View File

@@ -79,7 +79,17 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
`${TableName.SecretV2}.id`, `${TableName.SecretV2}.id`,
`${TableName.SecretRotationV2SecretMapping}.secretId` `${TableName.SecretRotationV2SecretMapping}.secretId`
) )
.leftJoin(
TableName.SecretReminderRecipients,
`${TableName.SecretV2}.id`,
`${TableName.SecretReminderRecipients}.secretId`
)
.leftJoin(TableName.Users, `${TableName.SecretReminderRecipients}.userId`, `${TableName.Users}.id`)
.select(selectAllTableCols(TableName.SecretV2)) .select(selectAllTableCols(TableName.SecretV2))
.select(db.ref("id").withSchema(TableName.SecretReminderRecipients).as("reminderRecipientId"))
.select(db.ref("username").withSchema(TableName.Users).as("reminderRecipientUsername"))
.select(db.ref("email").withSchema(TableName.Users).as("reminderRecipientEmail"))
.select(db.ref("id").withSchema(TableName.Users).as("reminderRecipientUserId"))
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
@@ -103,6 +113,23 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
slug, slug,
name: slug name: slug
}) })
},
{
key: "reminderRecipientId",
label: "secretReminderRecipients" as const,
mapper: ({
reminderRecipientId,
reminderRecipientUsername,
reminderRecipientEmail,
reminderRecipientUserId
}) => ({
user: {
id: reminderRecipientUserId,
username: reminderRecipientUsername,
email: reminderRecipientEmail
},
id: reminderRecipientId
})
} }
] ]
}); });
@@ -484,6 +511,12 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
`${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`, `${TableName.SecretV2JnTag}.${TableName.SecretTag}Id`,
`${TableName.SecretTag}.id` `${TableName.SecretTag}.id`
) )
.leftJoin(
TableName.SecretReminderRecipients,
`${TableName.SecretV2}.id`,
`${TableName.SecretReminderRecipients}.secretId`
)
.leftJoin(TableName.Users, `${TableName.SecretReminderRecipients}.userId`, `${TableName.Users}.id`)
.leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`) .leftJoin(TableName.ResourceMetadata, `${TableName.SecretV2}.id`, `${TableName.ResourceMetadata}.secretId`)
.leftJoin( .leftJoin(
TableName.SecretRotationV2SecretMapping, TableName.SecretRotationV2SecretMapping,
@@ -512,6 +545,10 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
}) as rank` }) as rank`
) )
) )
.select(db.ref("id").withSchema(TableName.SecretReminderRecipients).as("reminderRecipientId"))
.select(db.ref("username").withSchema(TableName.Users).as("reminderRecipientUsername"))
.select(db.ref("email").withSchema(TableName.Users).as("reminderRecipientEmail"))
.select(db.ref("id").withSchema(TableName.Users).as("reminderRecipientUserId"))
.select(db.ref("id").withSchema(TableName.SecretTag).as("tagId")) .select(db.ref("id").withSchema(TableName.SecretTag).as("tagId"))
.select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor")) .select(db.ref("color").withSchema(TableName.SecretTag).as("tagColor"))
.select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug")) .select(db.ref("slug").withSchema(TableName.SecretTag).as("tagSlug"))
@@ -556,6 +593,23 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
isRotatedSecret: Boolean(el.rotationId) isRotatedSecret: Boolean(el.rotationId)
}), }),
childrenMapper: [ childrenMapper: [
{
key: "reminderRecipientId",
label: "secretReminderRecipients" as const,
mapper: ({
reminderRecipientId,
reminderRecipientUsername,
reminderRecipientEmail,
reminderRecipientUserId
}) => ({
user: {
id: reminderRecipientUserId,
username: reminderRecipientUsername,
email: reminderRecipientEmail
},
id: reminderRecipientId
})
},
{ {
key: "tagId", key: "tagId",
label: "tags" as const, label: "tags" as const,

View File

@@ -12,6 +12,7 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema"; import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema";
import { INFISICAL_SECRET_VALUE_HIDDEN_MASK } from "../secret/secret-fns"; import { INFISICAL_SECRET_VALUE_HIDDEN_MASK } from "../secret/secret-fns";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretReminderRecipient } from "../secret-reminder-recipients/secret-reminder-recipients-types";
import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal"; import { TSecretV2BridgeDALFactory } from "./secret-v2-bridge-dal";
import { TFnSecretBulkDelete, TFnSecretBulkInsert, TFnSecretBulkUpdate } from "./secret-v2-bridge-types"; import { TFnSecretBulkDelete, TFnSecretBulkInsert, TFnSecretBulkUpdate } from "./secret-v2-bridge-types";
@@ -353,7 +354,7 @@ export const fnSecretBulkDelete = async ({
deletedSecrets deletedSecrets
.filter(({ reminderRepeatDays }) => Boolean(reminderRepeatDays)) .filter(({ reminderRepeatDays }) => Boolean(reminderRepeatDays))
.map(({ id, reminderRepeatDays }) => .map(({ id, reminderRepeatDays }) =>
secretQueueService.removeSecretReminder({ secretId: id, repeatDays: reminderRepeatDays as number }) secretQueueService.removeSecretReminder({ secretId: id, repeatDays: reminderRepeatDays as number }, tx)
) )
); );
@@ -684,6 +685,7 @@ export const reshapeBridgeSecret = (
secretMetadata?: ResourceMetadataDTO; secretMetadata?: ResourceMetadataDTO;
isRotatedSecret?: boolean; isRotatedSecret?: boolean;
rotationId?: string; rotationId?: string;
secretReminderRecipients?: TSecretReminderRecipient[];
}, },
secretValueHidden: boolean secretValueHidden: boolean
) => ({ ) => ({
@@ -715,6 +717,7 @@ export const reshapeBridgeSecret = (
updatedAt: secret.updatedAt, updatedAt: secret.updatedAt,
isRotatedSecret: secret.isRotatedSecret, isRotatedSecret: secret.isRotatedSecret,
rotationId: secret.rotationId, rotationId: secret.rotationId,
secretReminderRecipients: secret.secretReminderRecipients || [],
...(secretValueHidden ...(secretValueHidden
? { ? {
secretValue: INFISICAL_SECRET_VALUE_HIDDEN_MASK, secretValue: INFISICAL_SECRET_VALUE_HIDDEN_MASK,

View File

@@ -544,7 +544,12 @@ export const secretV2BridgeServiceFactory = ({
id: updatedSecret[0].id, id: updatedSecret[0].id,
...inputSecret ...inputSecret
}, },
oldSecret: secret, oldSecret: {
id: secret.id,
secretReminderNote: secret.reminderNote,
secretReminderRepeatDays: secret.reminderRepeatDays,
secretReminderRecipients: secret.secretReminderRecipients?.map((el) => el.user.id)
},
projectId projectId
}); });

View File

@@ -94,6 +94,7 @@ export type TUpdateSecretDTO = TProjectPermission & {
skipMultilineEncoding?: boolean; skipMultilineEncoding?: boolean;
secretReminderRepeatDays?: number | null; secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null; secretReminderNote?: string | null;
secretReminderRecipients?: string[] | null;
metadata?: { metadata?: {
source?: string; source?: string;
}; };
@@ -220,7 +221,7 @@ export type TFnSecretBulkDelete = {
tx?: Knex; tx?: Knex;
secretDAL: Pick<TSecretV2BridgeDALFactory, "deleteMany">; secretDAL: Pick<TSecretV2BridgeDALFactory, "deleteMany">;
secretQueueService: { secretQueueService: {
removeSecretReminder: (data: TRemoveSecretReminderDTO) => Promise<void>; removeSecretReminder: (data: TRemoveSecretReminderDTO, tx?: Knex) => Promise<void>;
}; };
}; };

View File

@@ -407,6 +407,7 @@ export const decryptSecretRaw = (
id: secret.id, id: secret.id,
user: secret.userId, user: secret.userId,
tags: secret.tags?.map((el) => ({ ...el, name: el.slug })), tags: secret.tags?.map((el) => ({ ...el, name: el.slug })),
secretReminderRecipients: [],
skipMultilineEncoding: secret.skipMultilineEncoding, skipMultilineEncoding: secret.skipMultilineEncoding,
secretReminderRepeatDays: secret.secretReminderRepeatDays, secretReminderRepeatDays: secret.secretReminderRepeatDays,
secretReminderNote: secret.secretReminderNote, secretReminderNote: secret.secretReminderNote,
@@ -758,7 +759,7 @@ export const fnSecretBulkDelete = async ({
deletedSecrets deletedSecrets
.filter(({ secretReminderRepeatDays }) => Boolean(secretReminderRepeatDays)) .filter(({ secretReminderRepeatDays }) => Boolean(secretReminderRepeatDays))
.map(({ id, secretReminderRepeatDays }) => .map(({ id, secretReminderRepeatDays }) =>
secretQueueService.removeSecretReminder({ secretId: id, repeatDays: secretReminderRepeatDays as number }) secretQueueService.removeSecretReminder({ secretId: id, repeatDays: secretReminderRepeatDays as number }, tx)
) )
); );

View File

@@ -1,11 +1,13 @@
/* eslint-disable no-await-in-loop */ /* eslint-disable no-await-in-loop */
import opentelemetry from "@opentelemetry/api"; import opentelemetry from "@opentelemetry/api";
import { AxiosError } from "axios"; import { AxiosError } from "axios";
import { Knex } from "knex";
import { import {
ProjectMembershipRole, ProjectMembershipRole,
ProjectUpgradeStatus, ProjectUpgradeStatus,
ProjectVersion, ProjectVersion,
SecretType,
TSecretSnapshotSecretsV2, TSecretSnapshotSecretsV2,
TSecretVersionsV2 TSecretVersionsV2
} from "@app/db/schemas"; } from "@app/db/schemas";
@@ -53,6 +55,7 @@ import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-sche
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns"; import { fnSecretsV2FromImports } from "../secret-import/secret-import-fns";
import { TSecretReminderRecipientsDALFactory } from "../secret-reminder-recipients/secret-reminder-recipients-dal";
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal"; import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
import { expandSecretReferencesFactory, getAllSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns"; import { expandSecretReferencesFactory, getAllSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns";
import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal";
@@ -109,6 +112,10 @@ type TSecretQueueFactoryDep = {
orgService: Pick<TOrgServiceFactory, "addGhostUser">; orgService: Pick<TOrgServiceFactory, "addGhostUser">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">; projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">; resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
secretReminderRecipientsDAL: Pick<
TSecretReminderRecipientsDALFactory,
"delete" | "findUsersBySecretId" | "insertMany" | "transaction"
>;
secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">; secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">;
}; };
@@ -170,6 +177,7 @@ export const secretQueueFactory = ({
projectUserMembershipRoleDAL, projectUserMembershipRoleDAL,
projectKeyDAL, projectKeyDAL,
resourceMetadataDAL, resourceMetadataDAL,
secretReminderRecipientsDAL,
secretSyncQueue secretSyncQueue
}: TSecretQueueFactoryDep) => { }: TSecretQueueFactoryDep) => {
const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
@@ -178,7 +186,11 @@ export const secretQueueFactory = ({
unit: "1" unit: "1"
}); });
const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => { const removeSecretReminder = async ({ deleteRecipients = true, ...dto }: TRemoveSecretReminderDTO, tx?: Knex) => {
if (deleteRecipients) {
await secretReminderRecipientsDAL.delete({ secretId: dto.secretId }, tx);
}
const appCfg = getConfig(); const appCfg = getConfig();
await queueService.stopRepeatableJob( await queueService.stopRepeatableJob(
QueueName.SecretReminder, QueueName.SecretReminder,
@@ -224,7 +236,12 @@ export const secretQueueFactory = ({
.replace(":", "-"); .replace(":", "-");
}; };
const addSecretReminder = async ({ oldSecret, newSecret, projectId }: TCreateSecretReminderDTO) => { const addSecretReminder = async ({
oldSecret,
newSecret,
projectId,
deleteRecipients = true
}: TCreateSecretReminderDTO) => {
try { try {
const appCfg = getConfig(); const appCfg = getConfig();
@@ -246,7 +263,8 @@ export const secretQueueFactory = ({
if (oldSecret.secretReminderRepeatDays) { if (oldSecret.secretReminderRepeatDays) {
await removeSecretReminder({ await removeSecretReminder({
repeatDays: oldSecret.secretReminderRepeatDays, repeatDays: oldSecret.secretReminderRepeatDays,
secretId: oldSecret.id secretId: oldSecret.id,
deleteRecipients
}); });
} }
@@ -283,29 +301,57 @@ export const secretQueueFactory = ({
}; };
const handleSecretReminder = async ({ newSecret, oldSecret, projectId }: THandleReminderDTO) => { const handleSecretReminder = async ({ newSecret, oldSecret, projectId }: THandleReminderDTO) => {
const { secretReminderRepeatDays, secretReminderNote } = newSecret; const { secretReminderRepeatDays, secretReminderNote, secretReminderRecipients } = newSecret;
if (newSecret.type !== "personal" && secretReminderRepeatDays !== undefined) { const recipientsUpdated =
if ( secretReminderRecipients?.some(
(secretReminderRepeatDays && oldSecret.secretReminderRepeatDays !== secretReminderRepeatDays) || (newId) => !oldSecret.secretReminderRecipients?.find((oldId) => newId === oldId)
(secretReminderNote && oldSecret.secretReminderNote !== secretReminderNote) ) || secretReminderRecipients?.length !== oldSecret.secretReminderRecipients?.length;
) {
await addSecretReminder({ await secretReminderRecipientsDAL.transaction(async (tx) => {
oldSecret, if (newSecret.type !== SecretType.Personal && secretReminderRepeatDays !== undefined) {
newSecret, if (
projectId (secretReminderRepeatDays && oldSecret.secretReminderRepeatDays !== secretReminderRepeatDays) ||
}); (secretReminderNote && oldSecret.secretReminderNote !== secretReminderNote)
} else if ( ) {
secretReminderRepeatDays === null && await addSecretReminder({
secretReminderNote === null && oldSecret,
oldSecret.secretReminderRepeatDays newSecret,
) { projectId,
await removeSecretReminder({ deleteRecipients: false
secretId: oldSecret.id, });
repeatDays: oldSecret.secretReminderRepeatDays } else if (
}); secretReminderRepeatDays === null &&
secretReminderNote === null &&
oldSecret.secretReminderRepeatDays
) {
await removeSecretReminder({
secretId: oldSecret.id,
repeatDays: oldSecret.secretReminderRepeatDays
});
}
} }
}
if (recipientsUpdated) {
// if no recipients, delete all existing recipients
if (!secretReminderRecipients?.length) {
const existingRecipients = await secretReminderRecipientsDAL.findUsersBySecretId(newSecret.id, tx);
if (existingRecipients) {
await secretReminderRecipientsDAL.delete({ secretId: newSecret.id }, tx);
}
} else {
await secretReminderRecipientsDAL.delete({ secretId: newSecret.id }, tx);
await secretReminderRecipientsDAL.insertMany(
secretReminderRecipients.map((r) => ({
secretId: newSecret.id,
userId: r,
projectId
})),
tx
);
}
}
});
}; };
const createManySecretsRawFn = createManySecretsRawFnFactory({ const createManySecretsRawFn = createManySecretsRawFnFactory({
projectDAL, projectDAL,
@@ -1071,6 +1117,8 @@ export const secretQueueFactory = ({
const secret = await secretV2BridgeDAL.findById(data.secretId); const secret = await secretV2BridgeDAL.findById(data.secretId);
const [folder] = await folderDAL.findSecretPathByFolderIds(project.id, [secret.folderId]); const [folder] = await folderDAL.findSecretPathByFolderIds(project.id, [secret.folderId]);
const recipients = await secretReminderRecipientsDAL.findUsersBySecretId(data.secretId);
if (!organization) { if (!organization) {
logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`); logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`);
return; return;
@@ -1088,10 +1136,14 @@ export const secretQueueFactory = ({
return; return;
} }
const selectedRecipients = recipients?.length
? recipients.map((r) => r.email as string)
: projectMembers.map((m) => m.user.email as string);
await smtpService.sendMail({ await smtpService.sendMail({
template: SmtpTemplates.SecretReminder, template: SmtpTemplates.SecretReminder,
subjectLine: "Infisical secret reminder", subjectLine: "Infisical secret reminder",
recipients: [...projectMembers.map((m) => m.user.email)].filter((email) => email).map((email) => email as string), recipients: selectedRecipients,
substitutions: { substitutions: {
reminderNote: data.note, // May not be present. reminderNote: data.note, // May not be present.
projectName: project.name, projectName: project.name,

View File

@@ -546,10 +546,13 @@ export const secretServiceFactory = ({
for await (const secret of secrets) { for await (const secret of secrets) {
if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) { if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) {
await secretQueueService.removeSecretReminder({ await secretQueueService.removeSecretReminder(
repeatDays: secret.secretReminderRepeatDays, {
secretId: secret.id repeatDays: secret.secretReminderRepeatDays,
}); secretId: secret.id
},
tx
);
} }
} }
@@ -685,6 +688,7 @@ export const secretServiceFactory = ({
...secret, ...secret,
workspace: projectId, workspace: projectId,
environment, environment,
secretReminderRecipients: [],
secretPath: groupedPaths[secret.folderId][0].path secretPath: groupedPaths[secret.folderId][0].path
})) }))
}; };
@@ -1073,10 +1077,13 @@ export const secretServiceFactory = ({
for await (const secret of secrets) { for await (const secret of secrets) {
if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) { if (secret.secretReminderRepeatDays !== null && secret.secretReminderRepeatDays !== undefined) {
await secretQueueService.removeSecretReminder({ await secretQueueService.removeSecretReminder(
repeatDays: secret.secretReminderRepeatDays, {
secretId: secret.id repeatDays: secret.secretReminderRepeatDays,
}); secretId: secret.id
},
tx
);
} }
} }
const secretValueHidden = !hasSecretReadValueOrDescribePermission( const secretValueHidden = !hasSecretReadValueOrDescribePermission(
@@ -1786,6 +1793,7 @@ export const secretServiceFactory = ({
tagIds, tagIds,
secretReminderNote, secretReminderNote,
secretReminderRepeatDays, secretReminderRepeatDays,
secretReminderRecipients,
metadata, metadata,
secretComment, secretComment,
newSecretName, newSecretName,
@@ -1828,6 +1836,7 @@ export const secretServiceFactory = ({
tagIds, tagIds,
reminderNote: secretReminderNote, reminderNote: secretReminderNote,
reminderRepeatDays: secretReminderRepeatDays, reminderRepeatDays: secretReminderRepeatDays,
secretReminderRecipients,
secretMetadata secretMetadata
} }
] ]
@@ -1837,8 +1846,9 @@ export const secretServiceFactory = ({
} }
const secret = await secretV2BridgeService.updateSecret({ const secret = await secretV2BridgeService.updateSecret({
secretReminderRepeatDays, secretReminderRepeatDays,
skipMultilineEncoding,
secretReminderNote, secretReminderNote,
secretReminderRecipients,
skipMultilineEncoding,
tagIds, tagIds,
secretComment, secretComment,
secretPath, secretPath,

View File

@@ -22,9 +22,13 @@ import { SecretUpdateMode } from "../secret-v2-bridge/secret-v2-bridge-types";
import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal";
import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal";
type TPartialSecret = Pick<TSecrets, "id" | "secretReminderRepeatDays" | "secretReminderNote">; type TPartialSecret = Pick<TSecrets, "id" | "secretReminderRepeatDays" | "secretReminderNote"> & {
secretReminderRecipients?: string[] | null;
};
type TPartialInputSecret = Pick<TSecrets, "type" | "secretReminderNote" | "secretReminderRepeatDays" | "id">; type TPartialInputSecret = Pick<TSecrets, "type" | "secretReminderNote" | "secretReminderRepeatDays" | "id"> & {
secretReminderRecipients?: string[] | null;
};
export const FailedIntegrationSyncEmailsPayloadSchema = z.object({ export const FailedIntegrationSyncEmailsPayloadSchema = z.object({
projectId: z.string(), projectId: z.string(),
@@ -258,6 +262,7 @@ export type TUpdateSecretRawDTO = TProjectPermission & {
skipMultilineEncoding?: boolean; skipMultilineEncoding?: boolean;
secretReminderRepeatDays?: number | null; secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null; secretReminderNote?: string | null;
secretReminderRecipients?: string[] | null;
metadata?: { metadata?: {
source?: string; source?: string;
}; };
@@ -374,7 +379,7 @@ export type TFnSecretBulkDelete = {
tx?: Knex; tx?: Knex;
secretDAL: Pick<TSecretDALFactory, "deleteMany">; secretDAL: Pick<TSecretDALFactory, "deleteMany">;
secretQueueService: { secretQueueService: {
removeSecretReminder: (data: TRemoveSecretReminderDTO) => Promise<void>; removeSecretReminder: (data: TRemoveSecretReminderDTO, tx?: Knex) => Promise<void>;
}; };
}; };
@@ -405,11 +410,14 @@ export type TCreateSecretReminderDTO = {
oldSecret: TPartialSecret; oldSecret: TPartialSecret;
newSecret: TPartialSecret; newSecret: TPartialSecret;
projectId: string; projectId: string;
deleteRecipients?: boolean;
}; };
export type TRemoveSecretReminderDTO = { export type TRemoveSecretReminderDTO = {
secretId: string; secretId: string;
repeatDays: number; repeatDays: number;
deleteRecipients?: boolean;
}; };
export type TBackFillSecretReferencesDTO = TProjectPermission; export type TBackFillSecretReferencesDTO = TProjectPermission;

View File

@@ -83,6 +83,7 @@ export const useUpdateSecretV3 = ({
secretComment, secretComment,
secretReminderRepeatDays, secretReminderRepeatDays,
secretReminderNote, secretReminderNote,
secretReminderRecipients,
newSecretName, newSecretName,
skipMultilineEncoding, skipMultilineEncoding,
secretMetadata secretMetadata
@@ -93,6 +94,7 @@ export const useUpdateSecretV3 = ({
type, type,
secretReminderNote, secretReminderNote,
secretReminderRepeatDays, secretReminderRepeatDays,
secretReminderRecipients,
secretPath, secretPath,
skipMultilineEncoding, skipMultilineEncoding,
newSecretName, newSecretName,

View File

@@ -80,6 +80,7 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => {
comment: el.secretComment || "", comment: el.secretComment || "",
reminderRepeatDays: el.secretReminderRepeatDays, reminderRepeatDays: el.secretReminderRepeatDays,
reminderNote: el.secretReminderNote, reminderNote: el.secretReminderNote,
secretReminderRecipients: el.secretReminderRecipients,
createdAt: el.createdAt, createdAt: el.createdAt,
updatedAt: el.updatedAt, updatedAt: el.updatedAt,
version: el.version, version: el.version,

View File

@@ -7,6 +7,14 @@ export enum SecretType {
Personal = "personal" Personal = "personal"
} }
export type SecretReminderRecipient = {
user: {
id: string;
username: string;
email: string;
};
id: string;
};
export type EncryptedSecret = { export type EncryptedSecret = {
id: string; id: string;
version: number; version: number;
@@ -42,6 +50,7 @@ export type SecretV3RawSanitized = {
comment?: string; comment?: string;
reminderRepeatDays?: number | null; reminderRepeatDays?: number | null;
reminderNote?: string | null; reminderNote?: string | null;
reminderRecipients?: string[];
tags?: WsTag[]; tags?: WsTag[];
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -55,6 +64,7 @@ export type SecretV3RawSanitized = {
secretMetadata?: { key: string; value: string }[]; secretMetadata?: { key: string; value: string }[];
isReminderEvent?: boolean; isReminderEvent?: boolean;
isRotatedSecret?: boolean; isRotatedSecret?: boolean;
secretReminderRecipients?: SecretReminderRecipient[];
rotationId?: string; rotationId?: string;
}; };
@@ -80,6 +90,7 @@ export type SecretV3Raw = {
updatedAt: string; updatedAt: string;
isRotatedSecret?: boolean; isRotatedSecret?: boolean;
rotationId?: string; rotationId?: string;
secretReminderRecipients?: SecretReminderRecipient[];
}; };
export type SecretV3RawResponse = { export type SecretV3RawResponse = {
@@ -177,6 +188,7 @@ export type TUpdateSecretsV3DTO = {
secretReminderNote?: string | null; secretReminderNote?: string | null;
tagIds?: string[]; tagIds?: string[];
secretMetadata?: { key: string; value: string }[]; secretMetadata?: { key: string; value: string }[];
secretReminderRecipients?: string[] | null;
}; };
export type TDeleteSecretsV3DTO = { export type TDeleteSecretsV3DTO = {

View File

@@ -6,10 +6,28 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { z } from "zod"; import { z } from "zod";
import { Button, FormControl, Input, Modal, ModalContent, TextArea } from "@app/components/v2"; import {
Button,
FilterableSelect,
FormControl,
Input,
Modal,
ModalContent,
TextArea
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetWorkspaceUsers } from "@app/hooks/api";
const ReminderFormSchema = z.object({ const ReminderFormSchema = z.object({
note: z.string().optional().nullable(), note: z.string().optional().nullable(),
recipients: z
.array(
z.object({
label: z.string(),
value: z.string().uuid()
})
)
.optional(),
days: z days: z
.number() .number()
.min(1, { message: "Must be at least 1 day" }) .min(1, { message: "Must be at least 1 day" })
@@ -22,6 +40,7 @@ interface ReminderFormProps {
isOpen: boolean; isOpen: boolean;
repeatDays?: number | null; repeatDays?: number | null;
note?: string | null; note?: string | null;
recipients?: string[] | null;
onOpenChange: (isOpen: boolean, data?: TReminderFormSchema) => void; onOpenChange: (isOpen: boolean, data?: TReminderFormSchema) => void;
} }
@@ -29,12 +48,16 @@ export const CreateReminderForm = ({
isOpen, isOpen,
onOpenChange, onOpenChange,
repeatDays, repeatDays,
note note,
recipients
}: ReminderFormProps) => { }: ReminderFormProps) => {
const { currentWorkspace } = useWorkspace();
const { data: members = [] } = useGetWorkspaceUsers(currentWorkspace?.id);
const { const {
register, register,
control, control,
reset,
setValue, setValue,
handleSubmit, handleSubmit,
formState: { isSubmitting } formState: { isSubmitting }
@@ -51,13 +74,23 @@ export const CreateReminderForm = ({
}; };
useEffect(() => { useEffect(() => {
if (isOpen) { // On initial load, filter the members to only include the recipients
reset({ if (members.length) {
days: repeatDays || undefined, const filteredMembers = members.filter((m) => recipients?.find((r) => r === m.user.id));
note: note || "" setValue(
}); "recipients",
filteredMembers.map((m) => ({
label: m.user.username || m.user.email,
value: m.user.id
}))
);
} }
}, [isOpen]); }, [members, isOpen, recipients]);
useEffect(() => {
if (repeatDays) setValue("days", repeatDays);
if (note) setValue("note", note);
}, [repeatDays, note]);
return ( return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}> <Modal isOpen={isOpen} onOpenChange={onOpenChange}>
@@ -74,6 +107,7 @@ export const CreateReminderForm = ({
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<> <>
<FormControl <FormControl
isRequired
className="mb-0" className="mb-0"
label="Reminder Interval (in days)" label="Reminder Interval (in days)"
isError={Boolean(fieldState.error)} isError={Boolean(fieldState.error)}
@@ -83,6 +117,7 @@ export const CreateReminderForm = ({
onChange={(el) => setValue("days", parseInt(el.target.value, 10))} onChange={(el) => setValue("days", parseInt(el.target.value, 10))}
type="number" type="number"
placeholder="31" placeholder="31"
defaultValue={repeatDays || undefined}
value={field.value || undefined} value={field.value || undefined}
/> />
</FormControl> </FormControl>
@@ -105,11 +140,43 @@ export const CreateReminderForm = ({
placeholder="Remember to rotate the AWS secret every month." placeholder="Remember to rotate the AWS secret every month."
className="border border-mineshaft-600 text-sm" className="border border-mineshaft-600 text-sm"
rows={8} rows={8}
defaultValue={note || ""}
reSize="none" reSize="none"
cols={30} cols={30}
{...register("note")} {...register("note")}
/> />
</FormControl> </FormControl>
<Controller
control={control}
name="recipients"
render={({ field }) => (
<FormControl
tooltipText={
<div>
Select users to receive reminders.
<br />
<br /> If none are selected, all project members will receive the reminder.
</div>
}
label="Recipients"
className="mb-0"
>
<FilterableSelect
className="w-full"
placeholder="Select reminder recipients..."
isMulti
name="recipients"
options={members.map((member) => ({
label: member.user.username || member.user.email,
value: member.user.id
}))}
value={field.value}
onChange={field.onChange}
/>
</FormControl>
)}
/>
</div> </div>
<div className="mt-7 flex items-center space-x-4"> <div className="mt-7 flex items-center space-x-4">
<Button <Button

View File

@@ -1,3 +1,4 @@
import { 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 { faCircleQuestion, faEye } from "@fortawesome/free-regular-svg-icons"; import { faCircleQuestion, faEye } from "@fortawesome/free-regular-svg-icons";
@@ -220,11 +221,12 @@ export const SecretDetailSidebar = ({
const handleReminderSubmit = async ( const handleReminderSubmit = async (
reminderRepeatDays: number | null | undefined, reminderRepeatDays: number | null | undefined,
reminderNote: string | null | undefined reminderNote: string | null | undefined,
reminderRecipients: string[] | undefined
) => { ) => {
await onSaveSecret( await onSaveSecret(
secret, secret,
{ ...secret, reminderRepeatDays, reminderNote, isReminderEvent: true }, { ...secret, reminderRepeatDays, reminderNote, isReminderEvent: true, reminderRecipients },
() => {} () => {}
); );
}; };
@@ -233,6 +235,16 @@ export const SecretDetailSidebar = ({
const secretReminderRepeatDays = watch("reminderRepeatDays"); const secretReminderRepeatDays = watch("reminderRepeatDays");
const secretReminderNote = watch("reminderNote"); const secretReminderNote = watch("reminderNote");
const secretReminderRecipients = watch("reminderRecipients");
useEffect(() => {
setValue(
"reminderRecipients",
secret?.secretReminderRecipients?.map((el) => el.user.id),
{
shouldDirty: false
}
);
}, [secret?.secretReminderRecipients]);
const getModifiedByIcon = (userType: string | undefined | null) => { const getModifiedByIcon = (userType: string | undefined | null) => {
switch (userType) { switch (userType) {
@@ -294,14 +306,20 @@ export const SecretDetailSidebar = ({
<CreateReminderForm <CreateReminderForm
repeatDays={secretReminderRepeatDays} repeatDays={secretReminderRepeatDays}
note={secretReminderNote} note={secretReminderNote}
recipients={secretReminderRecipients}
isOpen={createReminderFormOpen} isOpen={createReminderFormOpen}
onOpenChange={(_, data) => { onOpenChange={(_, data) => {
setCreateReminderFormOpen.toggle(); setCreateReminderFormOpen.toggle();
if (data) { if (data) {
const recipients = data.recipients?.length
? data.recipients.map((recipient) => recipient.value)
: undefined;
setValue("reminderRepeatDays", data.days, { shouldDirty: false }); setValue("reminderRepeatDays", data.days, { shouldDirty: false });
setValue("reminderNote", data.note, { shouldDirty: false }); setValue("reminderNote", data.note, { shouldDirty: false });
handleReminderSubmit(data.days, data.note); setValue("reminderRecipients", recipients, { shouldDirty: false });
handleReminderSubmit(data.days, data.note, recipients);
} }
}} }}
/> />

View File

@@ -49,6 +49,8 @@ export const SecretListView = ({
isProtectedBranch = false, isProtectedBranch = false,
importedBy importedBy
}: Props) => { }: Props) => {
console.log("secretssssss", secrets);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([ const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"deleteSecret", "deleteSecret",
@@ -85,6 +87,7 @@ export const SecretListView = ({
comment, comment,
reminderRepeatDays, reminderRepeatDays,
reminderNote, reminderNote,
reminderRecipients,
tags, tags,
skipMultilineEncoding, skipMultilineEncoding,
newKey, newKey,
@@ -96,6 +99,7 @@ export const SecretListView = ({
comment: string; comment: string;
reminderRepeatDays: number | null; reminderRepeatDays: number | null;
reminderNote: string | null; reminderNote: string | null;
reminderRecipients?: string[] | null;
tags: string[]; tags: string[];
skipMultilineEncoding: boolean; skipMultilineEncoding: boolean;
newKey: string; newKey: string;
@@ -131,6 +135,7 @@ export const SecretListView = ({
secretComment: comment, secretComment: comment,
secretReminderRepeatDays: reminderRepeatDays, secretReminderRepeatDays: reminderRepeatDays,
secretReminderNote: reminderNote, secretReminderNote: reminderNote,
secretReminderRecipients: reminderRecipients,
skipMultilineEncoding, skipMultilineEncoding,
secretMetadata secretMetadata
}); });
@@ -172,6 +177,7 @@ export const SecretListView = ({
comment, comment,
reminderRepeatDays, reminderRepeatDays,
reminderNote, reminderNote,
reminderRecipients,
secretMetadata, secretMetadata,
isReminderEvent isReminderEvent
} = modSecret; } = modSecret;
@@ -180,6 +186,12 @@ export const SecretListView = ({
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 isSameRecipients =
!reminderRecipients?.some(
(newId) => !orgSecret.secretReminderRecipients?.find((oldId) => newId === oldId.user.id)
) && reminderRecipients?.length === orgSecret.secretReminderRecipients?.length;
const isSharedSecUnchanged = const isSharedSecUnchanged =
( (
[ [
@@ -189,9 +201,12 @@ export const SecretListView = ({
"skipMultilineEncoding", "skipMultilineEncoding",
"reminderRepeatDays", "reminderRepeatDays",
"reminderNote", "reminderNote",
"reminderRecipients",
"secretMetadata" "secretMetadata"
] as const ] as const
).every((el) => orgSecret[el] === modSecret[el]) && isSameTags; ).every((el) => orgSecret[el] === modSecret[el]) &&
isSameTags &&
isSameRecipients;
try { try {
// personal secret change // personal secret change
@@ -224,6 +239,7 @@ export const SecretListView = ({
comment, comment,
reminderRepeatDays, reminderRepeatDays,
reminderNote, reminderNote,
reminderRecipients,
secretId: orgSecret.id, secretId: orgSecret.id,
newKey: hasKeyChanged ? key : undefined, newKey: hasKeyChanged ? key : undefined,
skipMultilineEncoding: modSecret.skipMultilineEncoding, skipMultilineEncoding: modSecret.skipMultilineEncoding,

View File

@@ -47,6 +47,7 @@ export const formSchema = z.object({
.nullable() .nullable()
.optional(), .optional(),
reminderNote: z.string().trim().nullable().optional(), reminderNote: z.string().trim().nullable().optional(),
reminderRecipients: z.array(z.string().uuid()).optional(),
secretMetadata: z secretMetadata: z
.object({ .object({
key: z.string().trim().min(1), key: z.string().trim().min(1),