diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index dc0e5ee67..dbb302da1 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -423,6 +423,11 @@ import { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate } from "@app/db/schemas"; +import { + TSecretReminderRecipients, + TSecretReminderRecipientsInsert, + TSecretReminderRecipientsUpdate +} from "@app/db/schemas/secret-reminder-recipients"; declare module "knex" { namespace Knex { @@ -994,5 +999,10 @@ declare module "knex/types/tables" { TSecretRotationV2SecretMappingsInsert, TSecretRotationV2SecretMappingsUpdate >; + [TableName.SecretReminderRecipients]: KnexOriginal.CompositeTableType< + TSecretReminderRecipients, + TSecretReminderRecipientsInsert, + TSecretReminderRecipientsUpdate + >; } } diff --git a/backend/src/db/migrations/20250419004044_secret-reminder-recipients.ts b/backend/src/db/migrations/20250419004044_secret-reminder-recipients.ts new file mode 100644 index 000000000..127101d22 --- /dev/null +++ b/backend/src/db/migrations/20250419004044_secret-reminder-recipients.ts @@ -0,0 +1,38 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + 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", "projectId"]); + }); + + await createOnUpdateTrigger(knex, TableName.SecretReminderRecipients); + } +} + +export async function down(knex: Knex): Promise { + const hasSecretReminderRecipientsTable = await knex.schema.hasTable(TableName.SecretReminderRecipients); + + if (hasSecretReminderRecipientsTable) { + await knex.schema.dropTableIfExists(TableName.SecretReminderRecipients); + await dropOnUpdateTrigger(knex, TableName.SecretReminderRecipients); + } +} diff --git a/backend/src/db/schemas/certificates.ts b/backend/src/db/schemas/certificates.ts index bde35002f..533f9b898 100644 --- a/backend/src/db/schemas/certificates.ts +++ b/backend/src/db/schemas/certificates.ts @@ -20,7 +20,7 @@ export const CertificatesSchema = z.object({ notAfter: z.date(), revokedAt: z.date().nullable().optional(), revocationReason: z.number().nullable().optional(), - altNames: z.string().default("").nullable().optional(), + altNames: z.string().nullable().optional(), caCertId: z.string().uuid(), certificateTemplateId: z.string().uuid().nullable().optional(), keyUsages: z.string().array().nullable().optional(), diff --git a/backend/src/db/schemas/kmip-org-server-certificates.ts b/backend/src/db/schemas/kmip-org-server-certificates.ts index 66e5dcbd6..c23da626b 100644 --- a/backend/src/db/schemas/kmip-org-server-certificates.ts +++ b/backend/src/db/schemas/kmip-org-server-certificates.ts @@ -13,7 +13,7 @@ export const KmipOrgServerCertificatesSchema = z.object({ id: z.string().uuid(), orgId: z.string().uuid(), commonName: z.string(), - altNames: z.string(), + altNames: z.string().nullable().optional(), serialNumber: z.string(), keyAlgorithm: z.string(), issuedAt: z.date(), diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 95561c14a..be80d5ca4 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -146,7 +146,8 @@ export enum TableName { KmipOrgServerCertificates = "kmip_org_server_certificates", KmipClientCertificates = "kmip_client_certificates", 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"; diff --git a/backend/src/db/schemas/oidc-configs.ts b/backend/src/db/schemas/oidc-configs.ts index 181df25f0..216b50847 100644 --- a/backend/src/db/schemas/oidc-configs.ts +++ b/backend/src/db/schemas/oidc-configs.ts @@ -30,9 +30,9 @@ export const OidcConfigsSchema = z.object({ updatedAt: z.date(), orgId: z.string().uuid(), lastUsed: z.date().nullable().optional(), + manageGroupMemberships: z.boolean().default(false), encryptedOidcClientId: zodBuffer, encryptedOidcClientSecret: zodBuffer, - manageGroupMemberships: z.boolean().default(false), jwtSignatureAlgorithm: z.string().default("RS256") }); diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index eea1808e0..902c564a7 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -23,6 +23,7 @@ export const OrganizationsSchema = z.object({ defaultMembershipRole: z.string().default("member"), enforceMfa: z.boolean().default(false), selectedMfaMethod: z.string().nullable().optional(), + secretShareSendToAnyone: z.boolean().default(true).nullable().optional(), allowSecretSharingOutsideOrganization: z.boolean().default(true).nullable().optional(), shouldUseNewPrivilegeSystem: z.boolean().default(true), privilegeUpgradeInitiatedByUsername: z.string().nullable().optional(), diff --git a/backend/src/db/schemas/secret-reminder-recipients.ts b/backend/src/db/schemas/secret-reminder-recipients.ts new file mode 100644 index 000000000..3a132b367 --- /dev/null +++ b/backend/src/db/schemas/secret-reminder-recipients.ts @@ -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; +export type TSecretReminderRecipientsInsert = Omit, TImmutableDBKeys>; +export type TSecretReminderRecipientsUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts index 5d6358072..839833a9c 100644 --- a/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts +++ b/backend/src/ee/services/secret-approval-request/secret-approval-request-types.ts @@ -33,6 +33,7 @@ export type TApprovalCreateSecretV2Bridge = { secretComment?: string; reminderNote?: string | null; reminderRepeatDays?: number | null; + secretReminderRecipients?: string[] | null; skipMultilineEncoding?: boolean; metadata?: Record; secretMetadata?: ResourceMetadataDTO; diff --git a/backend/src/lib/api-docs/constants.ts b/backend/src/lib/api-docs/constants.ts index ff07940bc..72e38f066 100644 --- a/backend/src/lib/api-docs/constants.ts +++ b/backend/src/lib/api-docs/constants.ts @@ -762,6 +762,8 @@ export const RAW_SECRETS = { tagIds: "The ID of the tags to be attached to the updated secret.", secretReminderRepeatDays: "Interval for secret rotation notifications, measured in days.", 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." }, DELETE: { diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index c9f2811b5..e2b313063 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -214,6 +214,7 @@ import { secretFolderServiceFactory } from "@app/services/secret-folder/secret-f import { secretFolderVersionDALFactory } from "@app/services/secret-folder/secret-folder-version-dal"; import { secretImportDALFactory } from "@app/services/secret-import/secret-import-dal"; 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 { secretSharingServiceFactory } from "@app/services/secret-sharing/secret-sharing-service"; import { secretSyncDALFactory } from "@app/services/secret-sync/secret-sync-dal"; @@ -417,6 +418,7 @@ export const registerRoutes = async ( const orgGatewayConfigDAL = orgGatewayConfigDALFactory(db); const gatewayDAL = gatewayDALFactory(db); const projectGatewayDAL = projectGatewayDALFactory(db); + const secretReminderRecipientsDAL = secretReminderRecipientsDALFactory(db); const secretRotationV2DAL = secretRotationV2DALFactory(db, folderDAL); @@ -721,6 +723,7 @@ export const registerRoutes = async ( projectKeyDAL, projectRoleDAL, groupProjectDAL, + secretReminderRecipientsDAL, licenseService }); const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ @@ -954,6 +957,7 @@ export const registerRoutes = async ( secretApprovalRequestDAL, projectKeyDAL, projectUserMembershipRoleDAL, + secretReminderRecipientsDAL, orgService, resourceMetadataDAL, secretSyncQueue diff --git a/backend/src/server/routes/v3/secret-router.ts b/backend/src/server/routes/v3/secret-router.ts index 0f53b777a..0ef050317 100644 --- a/backend/src/server/routes/v3/secret-router.ts +++ b/backend/src/server/routes/v3/secret-router.ts @@ -648,6 +648,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { .optional() .nullable() .describe(RAW_SECRETS.UPDATE.secretReminderRepeatDays), + secretReminderRecipients: z.string().array().optional().describe(RAW_SECRETS.UPDATE.secretReminderRecipients), newSecretName: SecretNameSchema.optional().describe(RAW_SECRETS.UPDATE.newSecretName), secretComment: z.string().optional().describe(RAW_SECRETS.UPDATE.secretComment) }), @@ -678,6 +679,7 @@ export const registerSecretRouter = async (server: FastifyZodProvider) => { skipMultilineEncoding: req.body.skipMultilineEncoding, tagIds: req.body.tagIds, secretReminderRepeatDays: req.body.secretReminderRepeatDays, + secretReminderRecipients: req.body.secretReminderRecipients, secretReminderNote: req.body.secretReminderNote, metadata: req.body.metadata, newSecretName: req.body.newSecretName, diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 1d6ba969a..2e3d6c23b 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -23,6 +23,7 @@ import { TProjectDALFactory } from "../project/project-dal"; import { TProjectBotDALFactory } from "../project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "../project-key/project-key-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 { TUserDALFactory } from "../user/user-dal"; import { TProjectMembershipDALFactory } from "./project-membership-dal"; @@ -53,6 +54,7 @@ type TProjectMembershipServiceFactoryDep = { projectKeyDAL: Pick; licenseService: Pick; projectUserAdditionalPrivilegeDAL: Pick; + secretReminderRecipientsDAL: Pick; groupProjectDAL: TGroupProjectDALFactory; }; @@ -71,6 +73,7 @@ export const projectMembershipServiceFactory = ({ groupProjectDAL, projectDAL, projectKeyDAL, + secretReminderRecipientsDAL, licenseService }: TProjectMembershipServiceFactoryDep) => { const getProjectMemberships = async ({ @@ -388,6 +391,13 @@ export const projectMembershipServiceFactory = ({ const membership = await projectMembershipDAL.transaction(async (tx) => { const [deletedMembership] = await projectMembershipDAL.delete({ projectId, id: membershipId }, tx); await projectKeyDAL.delete({ receiverId: deletedMembership.userId, projectId }, tx); + await secretReminderRecipientsDAL.delete( + { + projectId, + userId: deletedMembership.userId + }, + tx + ); return deletedMembership; }); return membership; @@ -465,6 +475,16 @@ export const projectMembershipServiceFactory = ({ 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 await projectKeyDAL.delete( { @@ -525,6 +545,15 @@ export const projectMembershipServiceFactory = ({ }, tx ); + + await secretReminderRecipientsDAL.delete( + { + projectId, + userId: actorId + }, + tx + ); + const membership = ( await projectMembershipDAL.delete( { diff --git a/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts new file mode 100644 index 000000000..5e8f388f9 --- /dev/null +++ b/backend/src/services/secret-reminder-recipients/secret-reminder-recipients-dal.ts @@ -0,0 +1,35 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify, selectAllTableCols } from "@app/lib/knex"; + +export type TSecretReminderRecipientsDALFactory = ReturnType; + +export const secretReminderRecipientsDALFactory = (db: TDbClient) => { + const secretReminderRecipientsOrm = ormify(db, TableName.SecretReminderRecipients); + + const findUsersBySecretId = async (secretId: string) => { + const res = await 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 }; +}; diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts index 596ebb5a1..7e232491a 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-service.ts @@ -536,7 +536,11 @@ export const secretV2BridgeServiceFactory = ({ id: updatedSecret[0].id, ...inputSecret }, - oldSecret: secret, + oldSecret: { + id: secret.id, + secretReminderNote: secret.reminderNote, + secretReminderRepeatDays: secret.reminderRepeatDays + }, projectId }); diff --git a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts index 11149c605..d3e4464fb 100644 --- a/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts +++ b/backend/src/services/secret-v2-bridge/secret-v2-bridge-types.ts @@ -94,6 +94,7 @@ export type TUpdateSecretDTO = TProjectPermission & { skipMultilineEncoding?: boolean; secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; + secretReminderRecipients?: string[] | null; metadata?: { source?: string; }; diff --git a/backend/src/services/secret/secret-queue.ts b/backend/src/services/secret/secret-queue.ts index 0d36250fb..bcdd34cbc 100644 --- a/backend/src/services/secret/secret-queue.ts +++ b/backend/src/services/secret/secret-queue.ts @@ -6,6 +6,7 @@ import { ProjectMembershipRole, ProjectUpgradeStatus, ProjectVersion, + SecretType, TSecretSnapshotSecretsV2, TSecretVersionsV2 } from "@app/db/schemas"; @@ -53,6 +54,7 @@ import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-sche import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal"; import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; 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 { expandSecretReferencesFactory, getAllSecretReferences } from "../secret-v2-bridge/secret-v2-bridge-fns"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; @@ -109,6 +111,10 @@ type TSecretQueueFactoryDep = { orgService: Pick; projectUserMembershipRoleDAL: Pick; resourceMetadataDAL: Pick; + secretReminderRecipientsDAL: Pick< + TSecretReminderRecipientsDALFactory, + "delete" | "findUsersBySecretId" | "insertMany" | "transaction" + >; secretSyncQueue: Pick; }; @@ -170,6 +176,7 @@ export const secretQueueFactory = ({ projectUserMembershipRoleDAL, projectKeyDAL, resourceMetadataDAL, + secretReminderRecipientsDAL, secretSyncQueue }: TSecretQueueFactoryDep) => { const integrationMeter = opentelemetry.metrics.getMeter("Integrations"); @@ -179,6 +186,8 @@ export const secretQueueFactory = ({ }); const removeSecretReminder = async (dto: TRemoveSecretReminderDTO) => { + await secretReminderRecipientsDAL.delete({ secretId: dto.secretId }); + const appCfg = getConfig(); await queueService.stopRepeatableJob( QueueName.SecretReminder, @@ -224,7 +233,7 @@ export const secretQueueFactory = ({ .replace(":", "-"); }; - const addSecretReminder = async ({ oldSecret, newSecret, projectId }: TCreateSecretReminderDTO) => { + const addSecretReminder = async ({ oldSecret, newSecret, projectId, recipients }: TCreateSecretReminderDTO) => { try { const appCfg = getConfig(); @@ -250,6 +259,20 @@ export const secretQueueFactory = ({ }); } + if (recipients) { + await secretReminderRecipientsDAL.transaction(async (tx) => { + await secretReminderRecipientsDAL.delete({ secretId: newSecret.id }, tx); + await secretReminderRecipientsDAL.insertMany( + recipients.map((r) => ({ + secretId: newSecret.id, + userId: r, + projectId + })), + tx + ); + }); + } + await queueService.queue( QueueName.SecretReminder, QueueJobs.SecretReminder, @@ -285,7 +308,7 @@ export const secretQueueFactory = ({ const handleSecretReminder = async ({ newSecret, oldSecret, projectId }: THandleReminderDTO) => { const { secretReminderRepeatDays, secretReminderNote } = newSecret; - if (newSecret.type !== "personal" && secretReminderRepeatDays !== undefined) { + if (newSecret.type !== SecretType.Personal && secretReminderRepeatDays !== undefined) { if ( (secretReminderRepeatDays && oldSecret.secretReminderRepeatDays !== secretReminderRepeatDays) || (secretReminderNote && oldSecret.secretReminderNote !== secretReminderNote) @@ -293,7 +316,8 @@ export const secretQueueFactory = ({ await addSecretReminder({ oldSecret, newSecret, - projectId + projectId, + recipients: newSecret.secretReminderRecipients }); } else if ( secretReminderRepeatDays === null && @@ -1072,6 +1096,8 @@ export const secretQueueFactory = ({ const secret = await secretV2BridgeDAL.findById(data.secretId); const [folder] = await folderDAL.findSecretPathByFolderIds(project.id, [secret.folderId]); + const recipients = await secretReminderRecipientsDAL.findUsersBySecretId(data.secretId); + if (!organization) { logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no organization found`); return; @@ -1089,10 +1115,14 @@ export const secretQueueFactory = ({ return; } + const selectedRecipients = recipients?.length + ? recipients.map((r) => r.email as string) + : projectMembers.map((m) => m.user.email as string); + await smtpService.sendMail({ template: SmtpTemplates.SecretReminder, subjectLine: "Infisical secret reminder", - recipients: [...projectMembers.map((m) => m.user.email)].filter((email) => email).map((email) => email as string), + recipients: selectedRecipients, substitutions: { reminderNote: data.note, // May not be present. projectName: project.name, diff --git a/backend/src/services/secret/secret-service.ts b/backend/src/services/secret/secret-service.ts index a82b04833..662919f70 100644 --- a/backend/src/services/secret/secret-service.ts +++ b/backend/src/services/secret/secret-service.ts @@ -1786,6 +1786,7 @@ export const secretServiceFactory = ({ tagIds, secretReminderNote, secretReminderRepeatDays, + secretReminderRecipients, metadata, secretComment, newSecretName, @@ -1828,6 +1829,7 @@ export const secretServiceFactory = ({ tagIds, reminderNote: secretReminderNote, reminderRepeatDays: secretReminderRepeatDays, + secretReminderRecipients, secretMetadata } ] @@ -1837,8 +1839,9 @@ export const secretServiceFactory = ({ } const secret = await secretV2BridgeService.updateSecret({ secretReminderRepeatDays, - skipMultilineEncoding, secretReminderNote, + secretReminderRecipients, + skipMultilineEncoding, tagIds, secretComment, secretPath, diff --git a/backend/src/services/secret/secret-types.ts b/backend/src/services/secret/secret-types.ts index be036cab8..82df6023f 100644 --- a/backend/src/services/secret/secret-types.ts +++ b/backend/src/services/secret/secret-types.ts @@ -22,9 +22,13 @@ import { SecretUpdateMode } from "../secret-v2-bridge/secret-v2-bridge-types"; import { TSecretVersionV2DALFactory } from "../secret-v2-bridge/secret-version-dal"; import { TSecretVersionV2TagDALFactory } from "../secret-v2-bridge/secret-version-tag-dal"; -type TPartialSecret = Pick; +type TPartialSecret = Pick & { + secretReminderRecipients?: string[] | null; +}; -type TPartialInputSecret = Pick; +type TPartialInputSecret = Pick & { + secretReminderRecipients?: string[] | null; +}; export const FailedIntegrationSyncEmailsPayloadSchema = z.object({ projectId: z.string(), @@ -258,6 +262,7 @@ export type TUpdateSecretRawDTO = TProjectPermission & { skipMultilineEncoding?: boolean; secretReminderRepeatDays?: number | null; secretReminderNote?: string | null; + secretReminderRecipients?: string[] | null; metadata?: { source?: string; }; @@ -405,6 +410,7 @@ export type TCreateSecretReminderDTO = { oldSecret: TPartialSecret; newSecret: TPartialSecret; projectId: string; + recipients?: string[] | null; }; export type TRemoveSecretReminderDTO = { diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index 68453ecdd..3862d1f8d 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -83,6 +83,7 @@ export const useUpdateSecretV3 = ({ secretComment, secretReminderRepeatDays, secretReminderNote, + secretReminderRecipients, newSecretName, skipMultilineEncoding, secretMetadata @@ -93,6 +94,7 @@ export const useUpdateSecretV3 = ({ type, secretReminderNote, secretReminderRepeatDays, + secretReminderRecipients, secretPath, skipMultilineEncoding, newSecretName, diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 187ff684c..e7947af69 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -42,6 +42,7 @@ export type SecretV3RawSanitized = { comment?: string; reminderRepeatDays?: number | null; reminderNote?: string | null; + reminderRecipients?: string[] | null; tags?: WsTag[]; createdAt: string; updatedAt: string; @@ -177,6 +178,7 @@ export type TUpdateSecretsV3DTO = { secretReminderNote?: string | null; tagIds?: string[]; secretMetadata?: { key: string; value: string }[]; + secretReminderRecipients?: string[] | null; }; export type TDeleteSecretsV3DTO = { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx index 26cc20ab4..5ac5bca3e 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/CreateReminderForm.tsx @@ -6,10 +6,28 @@ 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"; +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({ note: z.string().optional().nullable(), + recipients: z + .array( + z.object({ + label: z.string(), + value: z.string().uuid() + }) + ) + .optional(), days: z .number() .min(1, { message: "Must be at least 1 day" }) @@ -22,6 +40,7 @@ interface ReminderFormProps { isOpen: boolean; repeatDays?: number | null; note?: string | null; + recipients?: string[] | null; onOpenChange: (isOpen: boolean, data?: TReminderFormSchema) => void; } @@ -29,8 +48,13 @@ export const CreateReminderForm = ({ isOpen, onOpenChange, repeatDays, - note + note, + recipients }: ReminderFormProps) => { + const { currentWorkspace } = useWorkspace(); + + const { data: members = [] } = useGetWorkspaceUsers(currentWorkspace?.id); + const { register, control, @@ -59,6 +83,19 @@ export const CreateReminderForm = ({ } }, [isOpen]); + useEffect(() => { + // On initial load, filter the members to only include the recipients + if (members.length) { + const filteredMembers = members.filter((m) => recipients?.includes(m.id)); + reset({ + recipients: filteredMembers.map((m) => ({ + label: m.user.username || m.user.email, + value: m.user.id + })) + }); + } + }, [members, isOpen]); + return ( ( <> + + ( + + Select users to receive reminders. +
+
If none are selected, all project members will receive the reminder. + + } + label="Recipients" + className="mb-0" + > + ({ + label: member.user.username || member.user.email, + value: member.user.id + }))} + value={field.value} + onChange={field.onChange} + /> +
+ )} + />