mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
feat(reminders): specify recipients
This commit is contained in:
10
backend/src/@types/knex.d.ts
vendored
10
backend/src/@types/knex.d.ts
vendored
@@ -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
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Knex } from "knex";
|
||||
|
||||
import { TableName } from "../schemas";
|
||||
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
|
||||
|
||||
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", "projectId"]);
|
||||
});
|
||||
|
||||
await createOnUpdateTrigger(knex, TableName.SecretReminderRecipients);
|
||||
}
|
||||
}
|
||||
|
||||
export async function down(knex: Knex): Promise<void> {
|
||||
const hasSecretReminderRecipientsTable = await knex.schema.hasTable(TableName.SecretReminderRecipients);
|
||||
|
||||
if (hasSecretReminderRecipientsTable) {
|
||||
await knex.schema.dropTableIfExists(TableName.SecretReminderRecipients);
|
||||
await dropOnUpdateTrigger(knex, TableName.SecretReminderRecipients);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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")
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
23
backend/src/db/schemas/secret-reminder-recipients.ts
Normal file
23
backend/src/db/schemas/secret-reminder-recipients.ts
Normal 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>
|
||||
>;
|
||||
@@ -33,6 +33,7 @@ export type TApprovalCreateSecretV2Bridge = {
|
||||
secretComment?: string;
|
||||
reminderNote?: string | null;
|
||||
reminderRepeatDays?: number | null;
|
||||
secretReminderRecipients?: string[] | null;
|
||||
skipMultilineEncoding?: boolean;
|
||||
metadata?: Record<string, string>;
|
||||
secretMetadata?: ResourceMetadataDTO;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TProjectKeyDALFactory, "findLatestProjectKey" | "delete" | "insertMany">;
|
||||
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
|
||||
projectUserAdditionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
|
||||
secretReminderRecipientsDAL: Pick<TSecretReminderRecipientsDALFactory, "delete">;
|
||||
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(
|
||||
{
|
||||
|
||||
@@ -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<typeof secretReminderRecipientsDALFactory>;
|
||||
|
||||
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 };
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -94,6 +94,7 @@ export type TUpdateSecretDTO = TProjectPermission & {
|
||||
skipMultilineEncoding?: boolean;
|
||||
secretReminderRepeatDays?: number | null;
|
||||
secretReminderNote?: string | null;
|
||||
secretReminderRecipients?: string[] | null;
|
||||
metadata?: {
|
||||
source?: string;
|
||||
};
|
||||
|
||||
@@ -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<TOrgServiceFactory, "addGhostUser">;
|
||||
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
|
||||
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
|
||||
secretReminderRecipientsDAL: Pick<
|
||||
TSecretReminderRecipientsDALFactory,
|
||||
"delete" | "findUsersBySecretId" | "insertMany" | "transaction"
|
||||
>;
|
||||
secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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({
|
||||
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 = {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
@@ -74,6 +111,7 @@ export const CreateReminderForm = ({
|
||||
render={({ field, fieldState }) => (
|
||||
<>
|
||||
<FormControl
|
||||
isRequired
|
||||
className="mb-0"
|
||||
label="Reminder Interval (in days)"
|
||||
isError={Boolean(fieldState.error)}
|
||||
@@ -110,6 +148,37 @@ export const CreateReminderForm = ({
|
||||
{...register("note")}
|
||||
/>
|
||||
</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 className="mt-7 flex items-center space-x-4">
|
||||
<Button
|
||||
|
||||
@@ -220,11 +220,12 @@ export const SecretDetailSidebar = ({
|
||||
|
||||
const handleReminderSubmit = async (
|
||||
reminderRepeatDays: number | null | undefined,
|
||||
reminderNote: string | null | undefined
|
||||
reminderNote: string | null | undefined,
|
||||
reminderRecipients: string[] | undefined
|
||||
) => {
|
||||
await onSaveSecret(
|
||||
secret,
|
||||
{ ...secret, reminderRepeatDays, reminderNote, isReminderEvent: true },
|
||||
{ ...secret, reminderRepeatDays, reminderNote, isReminderEvent: true, reminderRecipients },
|
||||
() => {}
|
||||
);
|
||||
};
|
||||
@@ -233,7 +234,7 @@ export const SecretDetailSidebar = ({
|
||||
|
||||
const secretReminderRepeatDays = watch("reminderRepeatDays");
|
||||
const secretReminderNote = watch("reminderNote");
|
||||
|
||||
const secretReminderRecipients = watch("reminderRecipients");
|
||||
const getModifiedByIcon = (userType: string | undefined | null) => {
|
||||
switch (userType) {
|
||||
case ActorType.USER:
|
||||
@@ -290,14 +291,20 @@ export const SecretDetailSidebar = ({
|
||||
<CreateReminderForm
|
||||
repeatDays={secretReminderRepeatDays}
|
||||
note={secretReminderNote}
|
||||
recipients={secretReminderRecipients}
|
||||
isOpen={createReminderFormOpen}
|
||||
onOpenChange={(_, data) => {
|
||||
setCreateReminderFormOpen.toggle();
|
||||
|
||||
if (data) {
|
||||
const recipients = data.recipients?.length
|
||||
? data.recipients.map((recipient) => recipient.value)
|
||||
: undefined;
|
||||
|
||||
setValue("reminderRepeatDays", data.days, { shouldDirty: false });
|
||||
setValue("reminderNote", data.note, { shouldDirty: false });
|
||||
handleReminderSubmit(data.days, data.note);
|
||||
setValue("reminderRecipients", recipients, { shouldDirty: false });
|
||||
handleReminderSubmit(data.days, data.note, recipients);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -85,6 +85,7 @@ export const SecretListView = ({
|
||||
comment,
|
||||
reminderRepeatDays,
|
||||
reminderNote,
|
||||
reminderRecipients,
|
||||
tags,
|
||||
skipMultilineEncoding,
|
||||
newKey,
|
||||
@@ -96,6 +97,7 @@ export const SecretListView = ({
|
||||
comment: string;
|
||||
reminderRepeatDays: number | null;
|
||||
reminderNote: string | null;
|
||||
reminderRecipients?: string[] | null;
|
||||
tags: string[];
|
||||
skipMultilineEncoding: boolean;
|
||||
newKey: string;
|
||||
@@ -131,6 +133,7 @@ export const SecretListView = ({
|
||||
secretComment: comment,
|
||||
secretReminderRepeatDays: reminderRepeatDays,
|
||||
secretReminderNote: reminderNote,
|
||||
secretReminderRecipients: reminderRecipients,
|
||||
skipMultilineEncoding,
|
||||
secretMetadata
|
||||
});
|
||||
@@ -172,6 +175,7 @@ export const SecretListView = ({
|
||||
comment,
|
||||
reminderRepeatDays,
|
||||
reminderNote,
|
||||
reminderRecipients,
|
||||
secretMetadata,
|
||||
isReminderEvent
|
||||
} = modSecret;
|
||||
@@ -189,6 +193,7 @@ export const SecretListView = ({
|
||||
"skipMultilineEncoding",
|
||||
"reminderRepeatDays",
|
||||
"reminderNote",
|
||||
"reminderRecipients",
|
||||
"secretMetadata"
|
||||
] as const
|
||||
).every((el) => orgSecret[el] === modSecret[el]) && isSameTags;
|
||||
@@ -224,6 +229,7 @@ export const SecretListView = ({
|
||||
comment,
|
||||
reminderRepeatDays,
|
||||
reminderNote,
|
||||
reminderRecipients,
|
||||
secretId: orgSecret.id,
|
||||
newKey: hasKeyChanged ? key : undefined,
|
||||
skipMultilineEncoding: modSecret.skipMultilineEncoding,
|
||||
|
||||
@@ -47,6 +47,7 @@ export const formSchema = z.object({
|
||||
.nullable()
|
||||
.optional(),
|
||||
reminderNote: z.string().trim().nullable().optional(),
|
||||
reminderRecipients: z.array(z.string().uuid()).optional(),
|
||||
secretMetadata: z
|
||||
.object({
|
||||
key: z.string().trim().min(1),
|
||||
|
||||
Reference in New Issue
Block a user