Compare commits

...

6 Commits

37 changed files with 1926 additions and 534 deletions

View File

@ -24,6 +24,7 @@ export const mockQueue = (): TQueueServiceFactory => {
events[name] = event;
},
getRepeatableJobs: async () => [],
getDelayedJobs: async () => [],
clearQueue: async () => {},
stopJobById: async () => {},
stopJobByIdPg: async () => {},

View File

@ -93,6 +93,7 @@ import { TProjectEnvServiceFactory } from "@app/services/project-env/project-env
import { TProjectKeyServiceFactory } from "@app/services/project-key/project-key-service";
import { TProjectMembershipServiceFactory } from "@app/services/project-membership/project-membership-service";
import { TProjectRoleServiceFactory } from "@app/services/project-role/project-role-service";
import { TReminderServiceFactory } from "@app/services/reminder/reminder-types";
import { TSecretServiceFactory } from "@app/services/secret/secret-service";
import { TSecretBlindIndexServiceFactory } from "@app/services/secret-blind-index/secret-blind-index-service";
import { TSecretFolderServiceFactory } from "@app/services/secret-folder/secret-folder-service";
@ -285,6 +286,7 @@ declare module "fastify" {
secretScanningV2: TSecretScanningV2ServiceFactory;
internalCertificateAuthority: TInternalCertificateAuthorityServiceFactory;
pkiTemplate: TPkiTemplatesServiceFactory;
reminder: TReminderServiceFactory;
};
// this is exclusive use for middlewares in which we need to inject data
// everywhere else access using service layer

View File

@ -504,6 +504,12 @@ import {
TProjectMicrosoftTeamsConfigsInsert,
TProjectMicrosoftTeamsConfigsUpdate
} from "@app/db/schemas/project-microsoft-teams-configs";
import { TReminders, TRemindersInsert, TRemindersUpdate } from "@app/db/schemas/reminders";
import {
TRemindersRecipients,
TRemindersRecipientsInsert,
TRemindersRecipientsUpdate
} from "@app/db/schemas/reminders-recipients";
import {
TSecretReminderRecipients,
TSecretReminderRecipientsInsert,
@ -1211,5 +1217,11 @@ declare module "knex/types/tables" {
TSecretScanningConfigsInsert,
TSecretScanningConfigsUpdate
>;
[TableName.Reminder]: KnexOriginal.CompositeTableType<TReminders, TRemindersInsert, TRemindersUpdate>;
[TableName.ReminderRecipient]: KnexOriginal.CompositeTableType<
TRemindersRecipients,
TRemindersRecipientsInsert,
TRemindersRecipientsUpdate
>;
}
}

View File

@ -0,0 +1,44 @@
import { Knex } from "knex";
import { TableName } from "../schemas";
import { createOnUpdateTrigger, dropOnUpdateTrigger } from "../utils";
export async function up(knex: Knex): Promise<void> {
if (!(await knex.schema.hasTable(TableName.Reminder))) {
await knex.schema.createTable(TableName.Reminder, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("secretId").nullable();
t.foreign("secretId").references("id").inTable(TableName.SecretV2).onDelete("CASCADE");
t.string("message", 1024).nullable();
t.integer("repeatDays").checkPositive().nullable();
t.timestamp("nextReminderDate").notNullable();
t.timestamps(true, true, true);
t.index("secretId");
t.unique("secretId");
});
}
if (!(await knex.schema.hasTable(TableName.ReminderRecipient))) {
await knex.schema.createTable(TableName.ReminderRecipient, (t) => {
t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid());
t.uuid("reminderId").notNullable();
t.foreign("reminderId").references("id").inTable(TableName.Reminder).onDelete("CASCADE");
t.uuid("userId").notNullable();
t.foreign("userId").references("id").inTable(TableName.Users).onDelete("CASCADE");
t.timestamps(true, true, true);
t.index("reminderId");
t.index("userId");
t.unique(["reminderId", "userId"]);
});
}
await createOnUpdateTrigger(knex, TableName.Reminder);
await createOnUpdateTrigger(knex, TableName.ReminderRecipient);
}
export async function down(knex: Knex): Promise<void> {
await dropOnUpdateTrigger(knex, TableName.Reminder);
await dropOnUpdateTrigger(knex, TableName.ReminderRecipient);
await knex.schema.dropTableIfExists(TableName.ReminderRecipient);
await knex.schema.dropTableIfExists(TableName.Reminder);
}

View File

@ -172,7 +172,10 @@ export enum TableName {
SecretScanningResource = "secret_scanning_resources",
SecretScanningScan = "secret_scanning_scans",
SecretScanningFinding = "secret_scanning_findings",
SecretScanningConfig = "secret_scanning_configs"
SecretScanningConfig = "secret_scanning_configs",
// reminders
Reminder = "reminders",
ReminderRecipient = "reminders_recipients"
}
export type TImmutableDBKeys = "id" | "createdAt" | "updatedAt" | "commitId";

View File

@ -0,0 +1,20 @@
// 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 RemindersRecipientsSchema = z.object({
id: z.string().uuid(),
reminderId: z.string().uuid(),
userId: z.string().uuid(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TRemindersRecipients = z.infer<typeof RemindersRecipientsSchema>;
export type TRemindersRecipientsInsert = Omit<z.input<typeof RemindersRecipientsSchema>, TImmutableDBKeys>;
export type TRemindersRecipientsUpdate = Partial<Omit<z.input<typeof RemindersRecipientsSchema>, TImmutableDBKeys>>;

View File

@ -0,0 +1,22 @@
// 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 RemindersSchema = z.object({
id: z.string().uuid(),
secretId: z.string().uuid().nullable().optional(),
message: z.string().nullable().optional(),
repeatDays: z.number().nullable().optional(),
nextReminderDate: z.date(),
createdAt: z.date(),
updatedAt: z.date()
});
export type TReminders = z.infer<typeof RemindersSchema>;
export type TRemindersInsert = Omit<z.input<typeof RemindersSchema>, TImmutableDBKeys>;
export type TRemindersUpdate = Partial<Omit<z.input<typeof RemindersSchema>, TImmutableDBKeys>>;

View File

@ -467,7 +467,11 @@ export enum EventType {
CREATE_PROJECT = "create-project",
UPDATE_PROJECT = "update-project",
DELETE_PROJECT = "delete-project"
DELETE_PROJECT = "delete-project",
CREATE_SECRET_REMINDER = "create-secret-reminder",
GET_SECRET_REMINDER = "get-secret-reminder",
DELETE_SECRET_REMINDER = "delete-secret-reminder"
}
export const filterableSecretEvents: EventType[] = [
@ -3312,6 +3316,31 @@ interface SecretScanningConfigUpdateEvent {
};
}
interface SecretReminderCreateEvent {
type: EventType.CREATE_SECRET_REMINDER;
metadata: {
secretId: string;
message?: string | null;
repeatDays?: number | null;
nextReminderDate?: string | null;
recipients?: string[] | null;
};
}
interface SecretReminderGetEvent {
type: EventType.GET_SECRET_REMINDER;
metadata: {
secretId: string;
};
}
interface SecretReminderDeleteEvent {
type: EventType.DELETE_SECRET_REMINDER;
metadata: {
secretId: string;
};
}
interface SecretScanningConfigReadEvent {
type: EventType.SECRET_SCANNING_CONFIG_GET;
metadata?: Record<string, never>; // not needed, based off projectId
@ -3674,4 +3703,7 @@ export type Event =
| OrgUpdateEvent
| ProjectCreateEvent
| ProjectUpdateEvent
| ProjectDeleteEvent;
| ProjectDeleteEvent
| SecretReminderCreateEvent
| SecretReminderGetEvent
| SecretReminderDeleteEvent;

View File

@ -63,7 +63,9 @@ export enum QueueName {
FolderTreeCheckpoint = "folder-tree-checkpoint",
InvalidateCache = "invalidate-cache",
SecretScanningV2 = "secret-scanning-v2",
TelemetryAggregatedEvents = "telemetry-aggregated-events"
TelemetryAggregatedEvents = "telemetry-aggregated-events",
DailyReminders = "daily-reminders",
SecretReminderMigration = "secret-reminder-migration"
}
export enum QueueJobs {
@ -103,7 +105,9 @@ export enum QueueJobs {
SecretScanningV2SendNotification = "secret-scanning-v2-notification",
CaOrderCertificateForSubscriber = "ca-order-certificate-for-subscriber",
PkiSubscriberDailyAutoRenewal = "pki-subscriber-daily-auto-renewal",
TelemetryAggregatedEvents = "telemetry-aggregated-events"
TelemetryAggregatedEvents = "telemetry-aggregated-events",
DailyReminders = "daily-reminders",
SecretReminderMigration = "secret-reminder-migration"
}
export type TQueueJobTypes = {
@ -290,6 +294,14 @@ export type TQueueJobTypes = {
caType: CaType;
};
};
[QueueName.DailyReminders]: {
name: QueueJobs.DailyReminders;
payload: undefined;
};
[QueueName.SecretReminderMigration]: {
name: QueueJobs.SecretReminderMigration;
payload: undefined;
};
[QueueName.PkiSubscriber]: {
name: QueueJobs.PkiSubscriberDailyAutoRenewal;
payload: undefined;
@ -389,6 +401,11 @@ export type TQueueServiceFactory = {
startOffset?: number,
endOffset?: number
) => Promise<{ key: string; name: string; id: string | null }[]>;
getDelayedJobs: (
name: QueueName,
startOffset?: number,
endOffset?: number
) => Promise<{ delay: number; timestamp: number; repeatJobKey?: string; data?: unknown }[]>;
};
export const queueServiceFactory = (
@ -535,6 +552,13 @@ export const queueServiceFactory = (
return q.getRepeatableJobs(startOffset, endOffset);
};
const getDelayedJobs: TQueueServiceFactory["getDelayedJobs"] = (name, startOffset, endOffset) => {
const q = queueContainer[name];
if (!q) throw new Error(`Queue '${name}' not initialized`);
return q.getDelayed(startOffset, endOffset);
};
const stopRepeatableJobByJobId: TQueueServiceFactory["stopRepeatableJobByJobId"] = async (name, jobId) => {
const q = queueContainer[name];
const job = await q.getJob(jobId);
@ -581,6 +605,7 @@ export const queueServiceFactory = (
stopJobById,
stopJobByIdPg,
getRepeatableJobs,
getDelayedJobs,
startPg,
queuePg,
schedulePg

View File

@ -245,6 +245,10 @@ import { projectMembershipServiceFactory } from "@app/services/project-membershi
import { projectUserMembershipRoleDALFactory } from "@app/services/project-membership/project-user-membership-role-dal";
import { projectRoleDALFactory } from "@app/services/project-role/project-role-dal";
import { projectRoleServiceFactory } from "@app/services/project-role/project-role-service";
import { reminderDALFactory } from "@app/services/reminder/reminder-dal";
import { dailyReminderQueueServiceFactory } from "@app/services/reminder/reminder-queue";
import { reminderServiceFactory } from "@app/services/reminder/reminder-service";
import { reminderRecipientDALFactory } from "@app/services/reminder-recipients/reminder-recipient-dal";
import { dailyResourceCleanUpQueueServiceFactory } from "@app/services/resource-cleanup/resource-cleanup-queue";
import { resourceMetadataDALFactory } from "@app/services/resource-metadata/resource-metadata-dal";
import { secretDALFactory } from "@app/services/secret/secret-dal";
@ -369,6 +373,9 @@ export const registerRoutes = async (
const secretVersionV2BridgeDAL = secretVersionV2BridgeDALFactory(db);
const secretVersionTagV2BridgeDAL = secretVersionV2TagBridgeDALFactory(db);
const reminderDAL = reminderDALFactory(db);
const reminderRecipientDAL = reminderRecipientDALFactory(db);
const integrationDAL = integrationDALFactory(db);
const integrationAuthDAL = integrationAuthDALFactory(db);
const webhookDAL = webhookDALFactory(db);
@ -732,9 +739,17 @@ export const registerRoutes = async (
const projectBotService = projectBotServiceFactory({ permissionService, projectBotDAL, projectDAL });
const reminderService = reminderServiceFactory({
reminderDAL,
reminderRecipientDAL,
smtpService,
projectMembershipDAL,
permissionService,
secretV2BridgeDAL
});
const orgService = orgServiceFactory({
userAliasDAL,
queueService,
identityMetadataDAL,
secretDAL,
secretV2BridgeDAL,
@ -760,7 +775,8 @@ export const registerRoutes = async (
orgBotDAL,
oidcConfigDAL,
loginService,
projectBotService
projectBotService,
reminderService
});
const signupService = authSignupServiceFactory({
tokenService,
@ -1058,7 +1074,6 @@ export const registerRoutes = async (
secretImportDAL,
projectEnvDAL,
webhookDAL,
orgDAL,
auditLogService,
userDAL,
projectMembershipDAL,
@ -1080,11 +1095,11 @@ export const registerRoutes = async (
secretApprovalRequestDAL,
projectKeyDAL,
projectUserMembershipRoleDAL,
secretReminderRecipientsDAL,
orgService,
resourceMetadataDAL,
folderCommitService,
secretSyncQueue
secretSyncQueue,
reminderService
});
const projectService = projectServiceFactory({
@ -1093,7 +1108,6 @@ export const registerRoutes = async (
projectSshConfigDAL,
secretDAL,
secretV2BridgeDAL,
queueService,
projectQueue: projectQueueService,
projectBotService,
identityProjectDAL,
@ -1130,7 +1144,8 @@ export const registerRoutes = async (
microsoftTeamsIntegrationDAL,
projectTemplateService,
groupProjectDAL,
smtpService
smtpService,
reminderService
});
const projectEnvService = projectEnvServiceFactory({
@ -1229,6 +1244,7 @@ export const registerRoutes = async (
kmsService,
snapshotService,
resourceMetadataDAL,
reminderService,
keyStore
});
@ -1282,7 +1298,8 @@ export const registerRoutes = async (
secretApprovalRequestSecretDAL,
secretV2BridgeService,
secretApprovalRequestService,
licenseService
licenseService,
reminderService
});
const secretSharingService = secretSharingServiceFactory({
@ -1609,7 +1626,6 @@ export const registerRoutes = async (
auditLogDAL,
queueService,
secretVersionDAL,
secretDAL,
secretFolderVersionDAL: folderVersionDAL,
snapshotDAL,
identityAccessTokenDAL,
@ -1620,6 +1636,13 @@ export const registerRoutes = async (
orgService
});
const dailyReminderQueueService = dailyReminderQueueServiceFactory({
reminderService,
queueService,
secretDAL: secretV2BridgeDAL,
secretReminderRecipientsDAL
});
const dailyExpiringPkiItemAlert = dailyExpiringPkiItemAlertQueueServiceFactory({
queueService,
pkiAlertService
@ -1913,6 +1936,8 @@ export const registerRoutes = async (
await telemetryQueue.startTelemetryCheck();
await telemetryQueue.startAggregatedEventsJob();
await dailyResourceCleanUp.startCleanUp();
await dailyReminderQueueService.startDailyRemindersJob();
await dailyReminderQueueService.startSecretReminderMigrationJob();
await dailyExpiringPkiItemAlert.startSendingAlerts();
await pkiSubscriberQueue.startDailyAutoRenewalJob();
await kmsService.startService();
@ -2023,7 +2048,8 @@ export const registerRoutes = async (
assumePrivileges: assumePrivilegeService,
githubOrgSync: githubOrgSyncConfigService,
folderCommit: folderCommitService,
secretScanningV2: secretScanningV2Service
secretScanningV2: secretScanningV2Service,
reminder: reminderService
});
const cronJobs: CronJob[] = [];

View File

@ -42,6 +42,7 @@ import { registerProjectEnvRouter } from "./project-env-router";
import { registerProjectKeyRouter } from "./project-key-router";
import { registerProjectMembershipRouter } from "./project-membership-router";
import { registerProjectRouter } from "./project-router";
import { SECRET_REMINDER_REGISTER_ROUTER_MAP } from "./reminder-routers";
import { registerSecretFolderRouter } from "./secret-folder-router";
import { registerSecretImportRouter } from "./secret-import-router";
import { registerSecretRequestsRouter } from "./secret-requests-router";
@ -172,4 +173,14 @@ export const registerV1Routes = async (server: FastifyZodProvider) => {
},
{ prefix: "/secret-syncs" }
);
await server.register(
async (reminderRouter) => {
// register service specific reminder endpoints (reminders/secret)
for await (const [reminderType, router] of Object.entries(SECRET_REMINDER_REGISTER_ROUTER_MAP)) {
await reminderRouter.register(router, { prefix: `/${reminderType}` });
}
},
{ prefix: "/reminders" }
);
};

View File

@ -0,0 +1,8 @@
import { ReminderType } from "@app/services/reminder/reminder-enums";
import { registerSecretReminderRouter } from "./secret-reminder-router";
export const SECRET_REMINDER_REGISTER_ROUTER_MAP: Record<ReminderType, (server: FastifyZodProvider) => Promise<void>> =
{
[ReminderType.SECRETS]: registerSecretReminderRouter
};

View File

@ -0,0 +1,169 @@
import { z } from "zod";
import { RemindersSchema } from "@app/db/schemas/reminders";
import { EventType } from "@app/ee/services/audit-log/audit-log-types";
import { readLimit, writeLimit } from "@app/server/config/rateLimiter";
import { verifyAuth } from "@app/server/plugins/auth/verify-auth";
import { AuthMode } from "@app/services/auth/auth-type";
export const registerSecretReminderRouter = async (server: FastifyZodProvider) => {
server.route({
url: "/:secretId",
method: "POST",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
secretId: z.string().uuid()
}),
querystring: z.object({
projectId: z.string().uuid()
}),
body: z
.object({
message: z.string().trim().max(1024).optional(),
repeatDays: z.number().min(1).nullable().optional(),
nextReminderDate: z.string().datetime().nullable().optional(),
recipients: z.string().array().optional()
})
.refine((data) => {
return data.repeatDays || data.nextReminderDate;
}, "At least one of repeatDays or nextReminderDate is required"),
response: {
200: z.object({
message: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
await server.services.reminder.createReminder({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
projectId: req.query.projectId,
reminder: {
secretId: req.params.secretId,
message: req.body.message,
repeatDays: req.body.repeatDays,
nextReminderDate: req.body.nextReminderDate,
recipients: req.body.recipients
}
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: req.query.projectId,
event: {
type: EventType.CREATE_SECRET_REMINDER,
metadata: {
secretId: req.params.secretId,
message: req.body.message,
repeatDays: req.body.repeatDays,
nextReminderDate: req.body.nextReminderDate,
recipients: req.body.recipients
}
}
});
return { message: "Successfully created reminder" };
}
});
server.route({
url: "/:secretId",
method: "GET",
config: {
rateLimit: readLimit
},
schema: {
params: z.object({
secretId: z.string().uuid()
}),
querystring: z.object({
projectId: z.string().uuid()
}),
response: {
200: z.object({
reminder: RemindersSchema.extend({
recipients: z.string().array().optional()
})
.optional()
.nullable()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const reminder = await server.services.reminder.getReminder({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
secretId: req.params.secretId,
projectId: req.query.projectId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: req.query.projectId,
event: {
type: EventType.GET_SECRET_REMINDER,
metadata: {
secretId: req.params.secretId
}
}
});
return { reminder };
}
});
server.route({
url: "/:secretId",
method: "DELETE",
config: {
rateLimit: writeLimit
},
schema: {
params: z.object({
secretId: z.string().uuid()
}),
querystring: z.object({
projectId: z.string().uuid()
}),
response: {
200: z.object({
message: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
await server.services.reminder.deleteReminder({
actorId: req.permission.id,
actor: req.permission.type,
actorOrgId: req.permission.orgId,
actorAuthMethod: req.permission.authMethod,
secretId: req.params.secretId,
projectId: req.query.projectId
});
await server.services.auditLog.createAuditLog({
...req.auditLogInfo,
orgId: req.permission.orgId,
projectId: req.query.projectId,
event: {
type: EventType.DELETE_SECRET_REMINDER,
metadata: {
secretId: req.params.secretId
}
}
});
return { message: "Successfully deleted reminder" };
}
});
};

View File

@ -49,7 +49,7 @@ import { groupBy } from "@app/lib/fn";
import { logger } from "@app/lib/logger";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { isDisposableEmail } from "@app/lib/validator";
import { QueueName, TQueueServiceFactory } from "@app/queue";
import { QueueName } from "@app/queue";
import { getDefaultOrgMembershipRoleForUpdateOrg } from "@app/services/org/org-role-fns";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { TUserAliasDALFactory } from "@app/services/user-alias/user-alias-dal";
@ -67,6 +67,7 @@ import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
import { TReminderServiceFactory } from "../reminder/reminder-types";
import { TSecretDALFactory } from "../secret/secret-dal";
import { fnDeleteProjectSecretReminders } from "../secret/secret-fns";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
@ -134,8 +135,8 @@ type TOrgServiceFactoryDep = {
projectBotDAL: Pick<TProjectBotDALFactory, "findOne" | "updateById">;
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "insertMany" | "create">;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
loginService: Pick<TAuthLoginFactory, "generateUserTokens">;
reminderService: Pick<TReminderServiceFactory, "deleteReminderBySecretId">;
};
export type TOrgServiceFactory = ReturnType<typeof orgServiceFactory>;
@ -167,8 +168,8 @@ export const orgServiceFactory = ({
projectUserMembershipRoleDAL,
identityMetadataDAL,
projectBotService,
queueService,
loginService
loginService,
reminderService
}: TOrgServiceFactoryDep) => {
/*
* Get organization details by the organization id
@ -610,7 +611,7 @@ export const orgServiceFactory = ({
await fnDeleteProjectSecretReminders(project.id, {
secretDAL,
secretV2BridgeDAL,
queueService,
reminderService,
projectBotService,
folderDAL
});

View File

@ -32,7 +32,6 @@ import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/
import { groupBy } from "@app/lib/fn";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { TProjectPermission } from "@app/lib/types";
import { TQueueServiceFactory } from "@app/queue";
import { TPkiSubscriberDALFactory } from "@app/services/pki-subscriber/pki-subscriber-dal";
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
@ -60,6 +59,7 @@ import { TProjectMembershipDALFactory } from "../project-membership/project-memb
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
import { TProjectRoleDALFactory } from "../project-role/project-role-dal";
import { getPredefinedRoles } from "../project-role/project-role-fns";
import { TReminderServiceFactory } from "../reminder/reminder-types";
import { TSecretDALFactory } from "../secret/secret-dal";
import { fnDeleteProjectSecretReminders } from "../secret/secret-fns";
import { ROOT_FOLDER_NAME, TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
@ -162,7 +162,6 @@ type TProjectServiceFactoryDep = {
permissionService: TPermissionServiceFactory;
orgService: Pick<TOrgServiceFactory, "addGhostUser">;
licenseService: Pick<TLicenseServiceFactory, "getPlan" | "invalidateGetPlan">;
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
smtpService: Pick<TSmtpService, "sendMail">;
orgDAL: Pick<TOrgDALFactory, "findOne">;
keyStore: Pick<TKeyStoreFactory, "deleteItem">;
@ -179,6 +178,7 @@ type TProjectServiceFactoryDep = {
| "createCipherPairWithDataKey"
>;
projectTemplateService: TProjectTemplateServiceFactory;
reminderService: Pick<TReminderServiceFactory, "deleteReminderBySecretId">;
};
export type TProjectServiceFactory = ReturnType<typeof projectServiceFactory>;
@ -191,7 +191,6 @@ export const projectServiceFactory = ({
projectQueue,
projectKeyDAL,
permissionService,
queueService,
projectBotService,
orgDAL,
userDAL,
@ -226,7 +225,8 @@ export const projectServiceFactory = ({
microsoftTeamsIntegrationDAL,
projectTemplateService,
groupProjectDAL,
smtpService
smtpService,
reminderService
}: TProjectServiceFactoryDep) => {
/*
* Create workspace. Make user the admin
@ -555,7 +555,7 @@ export const projectServiceFactory = ({
await fnDeleteProjectSecretReminders(project.id, {
secretDAL,
secretV2BridgeDAL,
queueService,
reminderService,
projectBotService,
folderDAL
});

View File

@ -0,0 +1,11 @@
import { TDbClient } from "@app/db";
import { TableName } from "@app/db/schemas";
import { ormify } from "@app/lib/knex";
export type TReminderRecipientDALFactory = ReturnType<typeof reminderRecipientDALFactory>;
export const reminderRecipientDALFactory = (db: TDbClient) => {
const reminderRecipientOrm = ormify(db, TableName.ReminderRecipient);
return { ...reminderRecipientOrm };
};

View File

@ -0,0 +1,133 @@
import { Knex } from "knex";
import { TDbClient } from "@app/db";
import {
TableName,
TOrganizations,
TProjectEnvironments,
TProjects,
TSecretFolders,
TSecretsV2,
TUsers
} from "@app/db/schemas";
import { RemindersSchema } from "@app/db/schemas/reminders";
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
export type TReminderDALFactory = ReturnType<typeof reminderDALFactory>;
export const reminderDALFactory = (db: TDbClient) => {
const reminderOrm = ormify(db, TableName.Reminder);
const getTodayDateRange = () => {
const today = new Date();
const year = today.getUTCFullYear();
const month = today.getUTCMonth();
const date = today.getUTCDate();
// Start of day: 00:00:00.000 UTC
const startOfDay = new Date(Date.UTC(year, month, date, 0, 0, 0, 0));
// End of day: 23:59:59.999 UTC
const endOfDay = new Date(Date.UTC(year, month, date, 23, 59, 59, 999));
return {
startOfDay,
endOfDay
};
};
const findSecretDailyReminders = async (tx?: Knex) => {
const { startOfDay, endOfDay } = getTodayDateRange();
const rawReminders = await (tx || db)(TableName.Reminder)
.whereBetween("nextReminderDate", [startOfDay, endOfDay])
.leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`)
.leftJoin<TUsers>(TableName.Users, `${TableName.ReminderRecipient}.userId`, `${TableName.Users}.id`)
.leftJoin<TSecretsV2>(TableName.SecretV2, `${TableName.Reminder}.secretId`, `${TableName.SecretV2}.id`)
.leftJoin<TSecretFolders>(
TableName.SecretFolder,
`${TableName.SecretV2}.folderId`,
`${TableName.SecretFolder}.id`
)
.leftJoin<TProjectEnvironments>(
TableName.Environment,
`${TableName.SecretFolder}.envId`,
`${TableName.Environment}.id`
)
.leftJoin<TProjects>(TableName.Project, `${TableName.Environment}.projectId`, `${TableName.Project}.id`)
.leftJoin<TOrganizations>(TableName.Organization, `${TableName.Project}.orgId`, `${TableName.Organization}.id`)
.select(selectAllTableCols(TableName.Reminder))
.select(db.ref("email").withSchema(TableName.Users))
.select(db.ref("name").withSchema(TableName.Project).as("projectName"))
.select(db.ref("id").withSchema(TableName.Project).as("projectId"))
.select(db.ref("name").withSchema(TableName.Organization).as("organizationName"));
const reminders = sqlNestRelationships({
data: rawReminders,
key: "id",
parentMapper: (el) => ({
_id: el.id,
...RemindersSchema.parse(el),
projectName: el.projectName,
projectId: el.projectId,
organizationName: el.organizationName
}),
childrenMapper: [
{
key: "email",
label: "recipients" as const,
mapper: ({ email }) => ({
email
})
}
]
});
return reminders;
};
const findUpcomingReminders = async (daysAhead: number = 7, tx?: Knex) => {
const { startOfDay } = getTodayDateRange();
const futureDate = new Date(startOfDay);
futureDate.setDate(futureDate.getDate() + daysAhead);
const reminders = await (tx || db)(TableName.Reminder)
.where("nextReminderDate", ">=", startOfDay)
.where("nextReminderDate", "<=", futureDate)
.orderBy("nextReminderDate", "asc")
.leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`)
.select(selectAllTableCols(TableName.Reminder))
.select(db.ref("userId").withSchema(TableName.ReminderRecipient));
return reminders;
};
const findSecretReminder = async (secretId: string, tx?: Knex) => {
const rawReminders = await (tx || db)(TableName.Reminder)
.where(`${TableName.Reminder}.secretId`, secretId)
.leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`)
.select(selectAllTableCols(TableName.Reminder))
.select(db.ref("userId").withSchema(TableName.ReminderRecipient));
const reminders = sqlNestRelationships({
data: rawReminders,
key: "id",
parentMapper: (el) => ({
_id: el.id,
...RemindersSchema.parse(el)
}),
childrenMapper: [
{
key: "userId",
label: "recipients" as const,
mapper: ({ userId }) => userId
}
]
});
return reminders[0] || null;
};
return {
...reminderOrm,
findSecretDailyReminders,
findUpcomingReminders,
findSecretReminder
};
};

View File

@ -0,0 +1,3 @@
export enum ReminderType {
SECRETS = "secrets"
}

View File

@ -0,0 +1,196 @@
/* eslint-disable no-await-in-loop */
import RE2 from "re2";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TSecretReminderRecipientsDALFactory } from "../secret-reminder-recipients/secret-reminder-recipients-dal";
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
import { TReminderServiceFactory } from "./reminder-types";
type TDailyReminderQueueServiceFactoryDep = {
reminderService: TReminderServiceFactory;
queueService: TQueueServiceFactory;
secretDAL: Pick<TSecretV2BridgeDALFactory, "transaction" | "findSecretsWithReminderRecipients">;
secretReminderRecipientsDAL: Pick<TSecretReminderRecipientsDALFactory, "delete">;
};
export type TDailyReminderQueueServiceFactory = ReturnType<typeof dailyReminderQueueServiceFactory>;
const uuidRegex = new RE2(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
export const dailyReminderQueueServiceFactory = ({
reminderService,
queueService,
secretDAL,
secretReminderRecipientsDAL
}: TDailyReminderQueueServiceFactoryDep) => {
queueService.start(QueueName.DailyReminders, async () => {
logger.info(`${QueueName.DailyReminders}: queue task started`);
await reminderService.sendDailyReminders();
logger.info(`${QueueName.DailyReminders}: queue task completed`);
});
queueService.start(QueueName.SecretReminderMigration, async () => {
const REMINDER_PRUNE_BATCH_SIZE = 5_000;
const MAX_RETRY_ON_FAILURE = 3;
let numberOfRetryOnFailure = 0;
let deletedReminderCount = 0;
logger.info(`${QueueName.SecretReminderMigration}: queue task started`);
try {
const repeatableJobs = await queueService.getRepeatableJobs(QueueName.SecretReminder);
const delayedJobs = await queueService.getDelayedJobs(QueueName.SecretReminder);
logger.info(`${QueueName.SecretReminderMigration}: found ${repeatableJobs.length} secret reminder jobs`);
const reminderJobs = repeatableJobs
.map((job) => ({ secretId: job.id?.replace("reminder-", "") as string, jobKey: job.key }))
.filter(Boolean);
const reminderDelayedJobs = delayedJobs.reduce((map, job) => {
const match = uuidRegex.exec(job.repeatJobKey || "");
if (match) {
map.set(match[0], {
timestamp: job.timestamp,
delay: job.delay,
data: job.data
});
}
return map;
}, new Map<string, { timestamp: number; delay: number; data: unknown }>());
if (reminderJobs.length === 0) {
logger.info(`${QueueName.SecretReminderMigration}: no reminder jobs found`);
return;
}
for (let offset = 0; offset < reminderJobs.length; offset += REMINDER_PRUNE_BATCH_SIZE) {
try {
const batch = reminderJobs.slice(offset, offset + REMINDER_PRUNE_BATCH_SIZE);
const batchIds = batch.map((job) => job.secretId);
// Find existing secrets with pagination
// eslint-disable-next-line no-await-in-loop
const secrets = await secretDAL.findSecretsWithReminderRecipients(batchIds, REMINDER_PRUNE_BATCH_SIZE);
const secretsWithReminder = secrets.filter((secret) => secret.reminderRepeatDays);
const foundSecretIds = new Set(secretsWithReminder.map((secret) => secret.id));
// Find IDs that don't exist in either table
const secretIdsNotFound = batchIds.filter((secretId) => !foundSecretIds.has(secretId));
// Delete reminders for non-existent secrets
for (const secretId of secretIdsNotFound) {
const jobKey = reminderJobs.find((r) => r.secretId === secretId)?.jobKey;
if (jobKey) {
// eslint-disable-next-line no-await-in-loop
await queueService.stopRepeatableJobByKey(QueueName.SecretReminder, jobKey);
deletedReminderCount += 1;
}
}
for (const secretId of foundSecretIds) {
const jobKey = reminderJobs.find((r) => r.secretId === secretId)?.jobKey;
if (jobKey) {
await queueService.stopRepeatableJobByKey(QueueName.SecretReminder, jobKey);
deletedReminderCount += 1;
}
}
await secretDAL.transaction(async (tx) => {
await reminderService.batchCreateReminders(
secretsWithReminder.map((secret) => {
const delayedJob = reminderDelayedJobs.get(secret.id);
const projectId = (delayedJob?.data as { projectId?: string })?.projectId;
const nextDate = delayedJob ? new Date(delayedJob.timestamp + delayedJob.delay) : undefined;
return {
secretId: secret.id,
message: secret.reminderNote,
repeatDays: secret.reminderRepeatDays,
nextReminderDate: nextDate,
recipients: secret.recipients || [],
projectId
};
}),
tx
);
await secretReminderRecipientsDAL.delete({ $in: { secretId: secretsWithReminder.map((s) => s.id) } }, tx);
});
numberOfRetryOnFailure = 0;
} catch (error) {
numberOfRetryOnFailure += 1;
logger.error(error, `Failed to process batch at offset ${offset}`);
if (numberOfRetryOnFailure >= MAX_RETRY_ON_FAILURE) {
break;
}
// Retry the current batch
offset -= REMINDER_PRUNE_BATCH_SIZE;
// eslint-disable-next-line no-promise-executor-return, @typescript-eslint/no-loop-func, no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, 500 * numberOfRetryOnFailure));
}
// Small delay between batches
// eslint-disable-next-line no-promise-executor-return, @typescript-eslint/no-loop-func, no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, 10));
}
} catch (error) {
logger.error(error, "Failed to complete secret reminder pruning");
} finally {
logger.info(
`${QueueName.SecretReminderMigration}: secret reminders completed. Deleted ${deletedReminderCount} reminders`
);
}
});
// we do a repeat cron job in utc timezone at 12 Midnight each day
const startDailyRemindersJob = async () => {
// clear previous job
await queueService.stopRepeatableJob(
QueueName.DailyReminders,
QueueJobs.DailyReminders,
{ pattern: "0 0 * * *", utc: true },
QueueName.DailyReminders // just a job id
);
await queueService.queue(QueueName.DailyReminders, QueueJobs.DailyReminders, undefined, {
delay: 5000,
jobId: QueueName.DailyReminders,
repeat: { pattern: "0 0 * * *", utc: true }
});
};
// TODO: remove once all the old reminders in queues are migrated
const startSecretReminderMigrationJob = async () => {
// clear previous job
await queueService.stopRepeatableJob(
QueueName.SecretReminderMigration,
QueueJobs.SecretReminderMigration,
{ pattern: "0 */1 * * *", utc: true },
QueueName.SecretReminderMigration // just a job id
);
await queueService.queue(QueueName.SecretReminderMigration, QueueJobs.SecretReminderMigration, undefined, {
delay: 5000,
jobId: QueueName.SecretReminderMigration,
repeat: { pattern: "0 */1 * * *", utc: true }
});
};
queueService.listen(QueueName.DailyReminders, "failed", (_, err) => {
logger.error(err, `${QueueName.DailyReminders}: daily reminder processing failed`);
});
queueService.listen(QueueName.SecretReminderMigration, "failed", (_, err) => {
logger.error(err, `${QueueName.SecretReminderMigration}: secret reminder migration failed`);
});
return {
startDailyRemindersJob,
startSecretReminderMigrationJob
};
};

View File

@ -0,0 +1,362 @@
/* eslint-disable no-await-in-loop */
import { ForbiddenError } from "@casl/ability";
import { Knex } from "knex";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { ProjectPermissionSecretActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError } from "@app/lib/errors";
import { logger } from "@app/lib/logger";
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TReminderRecipientDALFactory } from "../reminder-recipients/reminder-recipient-dal";
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
import { SmtpTemplates, TSmtpService } from "../smtp/smtp-service";
import { TReminderDALFactory } from "./reminder-dal";
import { TBatchCreateReminderDTO, TCreateReminderDTO, TReminderServiceFactory } from "./reminder-types";
type TReminderServiceFactoryDep = {
reminderDAL: TReminderDALFactory;
reminderRecipientDAL: TReminderRecipientDALFactory;
smtpService: TSmtpService;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers">;
permissionService: Pick<TPermissionServiceFactory, "getProjectPermission">;
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "invalidateSecretCacheByProjectId">;
};
export const reminderServiceFactory = ({
reminderDAL,
reminderRecipientDAL,
smtpService,
projectMembershipDAL,
permissionService,
secretV2BridgeDAL
}: TReminderServiceFactoryDep): TReminderServiceFactory => {
const $addDays = (days: number, fromDate: Date = new Date()): Date => {
const result = new Date(fromDate);
result.setDate(result.getDate() + days);
return result;
};
const $manageReminderRecipients = async (reminderId: string, newRecipients?: string[] | null): Promise<void> => {
if (!newRecipients || newRecipients.length === 0) {
// If no recipients provided, remove all existing recipients
await reminderRecipientDAL.deleteById(reminderId);
return;
}
// Remove duplicates from input
const uniqueRecipients = [...new Set(newRecipients)];
// Get existing recipients
const existingRecipients = await reminderRecipientDAL.find({ reminderId });
const existingUserIds = new Set(existingRecipients.map((r) => r.userId));
const newUserIds = new Set(uniqueRecipients);
// Find recipients to add and remove
const recipientsToAdd = uniqueRecipients.filter((userId) => !existingUserIds.has(userId));
const recipientsToRemove = existingRecipients.filter((r) => !newUserIds.has(r.userId));
// Perform database operations
if (recipientsToRemove.length > 0) {
await reminderRecipientDAL.delete({ $in: { id: recipientsToRemove.map((r) => r.id) } });
}
if (recipientsToAdd.length > 0) {
await reminderRecipientDAL.insertMany(
recipientsToAdd.map((userId) => ({
reminderId,
userId
}))
);
}
};
const createReminderInternal: TReminderServiceFactory["createReminderInternal"] = async ({
secretId,
message,
repeatDays,
nextReminderDate: nextReminderDateInput,
recipients,
projectId
}: {
secretId?: string;
message?: string | null;
repeatDays?: number | null;
nextReminderDate?: string | null;
recipients?: string[] | null;
projectId: string;
}) => {
if (!secretId) {
throw new BadRequestError({ message: "secretId is required" });
}
let nextReminderDate;
if (nextReminderDateInput) {
nextReminderDate = new Date(nextReminderDateInput);
}
if (repeatDays && repeatDays > 0) {
nextReminderDate = $addDays(repeatDays);
}
if (!nextReminderDate) {
throw new BadRequestError({ message: "repeatDays must be a positive number" });
}
const existingReminder = await reminderDAL.findOne({ secretId });
let reminderId: string;
if (existingReminder) {
// Update existing reminder
await reminderDAL.updateById(existingReminder.id, {
message,
repeatDays,
nextReminderDate
});
reminderId = existingReminder.id;
} else {
// Create new reminder
const newReminder = await reminderDAL.create({
secretId,
message,
repeatDays,
nextReminderDate
});
reminderId = newReminder.id;
}
// Manage recipients (add/update/delete as needed)
await $manageReminderRecipients(reminderId, recipients);
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
return { id: reminderId, created: !existingReminder };
};
const createReminder: TReminderServiceFactory["createReminder"] = async ({
actor,
actorId,
actorOrgId,
actorAuthMethod,
projectId,
reminder
}: TCreateReminderDTO) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionSecretActions.Edit, ProjectPermissionSub.Secrets);
const response = await createReminderInternal({
...reminder,
projectId
});
return response;
};
const getReminder: TReminderServiceFactory["getReminder"] = async ({
secretId,
projectId,
actor,
actorId,
actorOrgId,
actorAuthMethod
}: {
secretId: string;
projectId: string;
actor: ActorType;
actorId: string;
actorOrgId: string;
actorAuthMethod: ActorAuthMethod;
}) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
});
ForbiddenError.from(permission).throwUnlessCan(
ProjectPermissionSecretActions.DescribeSecret,
ProjectPermissionSub.Secrets
);
const reminder = await reminderDAL.findSecretReminder(secretId);
return reminder;
};
const sendDailyReminders: TReminderServiceFactory["sendDailyReminders"] = async () => {
const remindersToSend = await reminderDAL.findSecretDailyReminders();
for (const reminder of remindersToSend) {
try {
await reminderDAL.transaction(async (tx) => {
const recipients: string[] = reminder.recipients
.map((r) => r.email)
.filter((email): email is string => Boolean(email));
if (recipients.length === 0) {
const members = await projectMembershipDAL.findAllProjectMembers(reminder.projectId);
recipients.push(...members.map((m) => m.user.email).filter((email): email is string => Boolean(email)));
}
await smtpService.sendMail({
template: SmtpTemplates.SecretReminder,
subjectLine: "Infisical secret reminder",
recipients,
substitutions: {
reminderNote: reminder.message || "",
projectName: reminder.projectName || "",
organizationName: reminder.organizationName || ""
}
});
if (reminder.repeatDays) {
await reminderDAL.updateById(reminder.id, { nextReminderDate: $addDays(reminder.repeatDays) }, tx);
} else {
await reminderDAL.deleteById(reminder.id, tx);
}
});
} catch (error) {
logger.error(
error,
`Failed to send reminder to recipients ${reminder.recipients.map((r) => r.email).join(", ")}`
);
}
}
};
const deleteReminder: TReminderServiceFactory["deleteReminder"] = async ({
actor,
actorId,
actorOrgId,
actorAuthMethod,
secretId,
projectId
}: {
actor: ActorType;
actorId: string;
actorOrgId: string;
actorAuthMethod: ActorAuthMethod;
secretId: string;
projectId: string;
}) => {
const { permission } = await permissionService.getProjectPermission({
actor,
actorId,
projectId,
actorAuthMethod,
actorOrgId
});
ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionSecretActions.Edit, ProjectPermissionSub.Secrets);
await reminderDAL.delete({ secretId });
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
};
const removeReminderRecipients: TReminderServiceFactory["removeReminderRecipients"] = async (
secretId: string,
projectId: string,
tx?: Knex
) => {
const reminder = await reminderDAL.findOne({ secretId }, tx);
if (!reminder) {
return;
}
await reminderRecipientDAL.delete({ reminderId: reminder.id }, tx);
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
};
const deleteReminderBySecretId: TReminderServiceFactory["deleteReminderBySecretId"] = async (
secretId: string,
projectId: string,
tx?: Knex
) => {
await reminderDAL.delete({ secretId }, tx);
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
};
const batchCreateReminders: TReminderServiceFactory["batchCreateReminders"] = async (
remindersData: TBatchCreateReminderDTO,
tx?: Knex
) => {
if (!remindersData || remindersData.length === 0) {
return { created: 0, reminderIds: [] };
}
const processedReminders = remindersData.map(
({ secretId, message, repeatDays, nextReminderDate: nextReminderDateInput, recipients, projectId }) => {
let nextReminderDate;
if (nextReminderDateInput) {
nextReminderDate = new Date(nextReminderDateInput);
}
if (repeatDays && repeatDays > 0 && !nextReminderDate) {
nextReminderDate = $addDays(repeatDays);
}
if (!nextReminderDate) {
throw new BadRequestError({
message: `repeatDays must be a positive number for secretId: ${secretId}`
});
}
return {
secretId,
message,
repeatDays,
nextReminderDate,
recipients: recipients ? [...new Set(recipients)] : [],
projectId
};
}
);
const newReminders = await reminderDAL.insertMany(
processedReminders.map(({ secretId, message, repeatDays, nextReminderDate, projectId }) => ({
secretId,
message,
repeatDays,
nextReminderDate,
projectId
})),
tx
);
const allRecipientInserts: Array<{ reminderId: string; userId: string }> = [];
newReminders.forEach((reminder, index) => {
const { recipients } = processedReminders[index];
if (recipients && recipients.length > 0) {
recipients.forEach((userId) => {
allRecipientInserts.push({
reminderId: reminder.id,
userId
});
});
}
});
if (allRecipientInserts.length > 0) {
await reminderRecipientDAL.insertMany(allRecipientInserts, tx);
}
const projectIds = new Set(processedReminders.map((r) => r.projectId).filter((id): id is string => Boolean(id)));
for (const projectId of projectIds) {
await secretV2BridgeDAL.invalidateSecretCacheByProjectId(projectId);
}
return {
created: newReminders.length,
reminderIds: newReminders.map((r) => r.id)
};
};
return {
createReminder,
getReminder,
sendDailyReminders,
deleteReminder,
removeReminderRecipients,
deleteReminderBySecretId,
batchCreateReminders,
createReminderInternal
};
};

View File

@ -0,0 +1,116 @@
import { Knex } from "knex";
import { ActorAuthMethod, ActorType } from "../auth/auth-type";
export type TReminder = {
id: string;
secretId?: string | null;
message?: string | null;
repeatDays?: number | null;
nextReminderDate: Date;
createdAt: Date;
updatedAt: Date;
};
export type TCreateReminderDTO = {
actor: ActorType;
actorId: string;
actorOrgId: string;
actorAuthMethod: ActorAuthMethod;
projectId: string;
reminder: {
secretId?: string;
message?: string | null;
repeatDays?: number | null;
nextReminderDate?: string | null;
recipients?: string[] | null;
};
};
export type TBatchCreateReminderDTO = {
secretId: string;
message?: string | null;
repeatDays?: number | null;
nextReminderDate?: string | Date | null;
recipients?: string[] | null;
projectId?: string;
}[];
export interface TReminderServiceFactory {
createReminder: ({
actor,
actorId,
actorOrgId,
actorAuthMethod,
projectId,
reminder
}: TCreateReminderDTO) => Promise<{
id: string;
created: boolean;
}>;
getReminder: ({
secretId,
projectId,
actor,
actorId,
actorOrgId,
actorAuthMethod
}: {
secretId: string;
projectId: string;
actor: ActorType;
actorId: string;
actorOrgId: string;
actorAuthMethod: ActorAuthMethod;
}) => Promise<(TReminder & { recipients: string[] }) | null>;
sendDailyReminders: () => Promise<void>;
deleteReminder: ({
actor,
actorId,
actorOrgId,
actorAuthMethod,
secretId,
projectId
}: {
actor: ActorType;
actorId: string;
actorOrgId: string;
actorAuthMethod: ActorAuthMethod;
secretId: string;
projectId: string;
}) => Promise<void>;
removeReminderRecipients: (secretId: string, projectId: string, tx?: Knex) => Promise<void>;
deleteReminderBySecretId: (secretId: string, projectId: string, tx?: Knex) => Promise<void>;
batchCreateReminders: (
remindersData: TBatchCreateReminderDTO,
tx?: Knex
) => Promise<{
created: number;
reminderIds: string[];
}>;
createReminderInternal: ({
secretId,
message,
repeatDays,
nextReminderDate,
recipients,
projectId
}: {
secretId?: string;
message?: string | null;
repeatDays?: number | null;
nextReminderDate?: string | null;
recipients?: string[] | null;
projectId: string;
}) => Promise<{
id: string;
created: boolean;
}>;
}

View File

@ -6,7 +6,6 @@ import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import { TIdentityAccessTokenDALFactory } from "../identity-access-token/identity-access-token-dal";
import { TIdentityUaClientSecretDALFactory } from "../identity-ua/identity-ua-client-secret-dal";
import { TOrgServiceFactory } from "../org/org-service";
import { TSecretDALFactory } from "../secret/secret-dal";
import { TSecretVersionDALFactory } from "../secret/secret-version-dal";
import { TSecretFolderVersionDALFactory } from "../secret-folder/secret-folder-version-dal";
import { TSecretSharingDALFactory } from "../secret-sharing/secret-sharing-dal";
@ -19,7 +18,6 @@ type TDailyResourceCleanUpQueueServiceFactoryDep = {
identityUniversalAuthClientSecretDAL: Pick<TIdentityUaClientSecretDALFactory, "removeExpiredClientSecrets">;
secretVersionDAL: Pick<TSecretVersionDALFactory, "pruneExcessVersions">;
secretVersionV2DAL: Pick<TSecretVersionV2DALFactory, "pruneExcessVersions">;
secretDAL: Pick<TSecretDALFactory, "pruneSecretReminders">;
secretFolderVersionDAL: Pick<TSecretFolderVersionDALFactory, "pruneExcessVersions">;
snapshotDAL: Pick<TSnapshotDALFactory, "pruneExcessSnapshots">;
secretSharingDAL: Pick<TSecretSharingDALFactory, "pruneExpiredSharedSecrets" | "pruneExpiredSecretRequests">;
@ -36,7 +34,6 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
snapshotDAL,
secretVersionDAL,
secretFolderVersionDAL,
secretDAL,
identityAccessTokenDAL,
secretSharingDAL,
secretVersionV2DAL,
@ -46,7 +43,6 @@ export const dailyResourceCleanUpQueueServiceFactory = ({
}: TDailyResourceCleanUpQueueServiceFactoryDep) => {
queueService.start(QueueName.DailyResourceCleanUp, async () => {
logger.info(`${QueueName.DailyResourceCleanUp}: queue task started`);
await secretDAL.pruneSecretReminders(queueService);
await identityAccessTokenDAL.removeExpiredTokens();
await identityUniversalAuthClientSecretDAL.removeExpiredClientSecrets();
await secretSharingDAL.pruneExpiredSharedSecrets();

View File

@ -466,6 +466,7 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
}
};
// This method currently uses too many joins which is not performant, in case we need to add more filters we should consider refactoring this method
const findByFolderIds = async (dto: {
folderIds: string[];
userId?: string;
@ -513,18 +514,15 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
`${TableName.SecretV2JnTag}.${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.SecretRotationV2SecretMapping,
`${TableName.SecretV2}.id`,
`${TableName.SecretRotationV2SecretMapping}.secretId`
)
.leftJoin(TableName.Reminder, `${TableName.SecretV2}.id`, `${TableName.Reminder}.secretId`)
.leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`)
.leftJoin(TableName.Users, `${TableName.ReminderRecipient}.userId`, `${TableName.Users}.id`)
.where((qb) => {
if (filters?.metadataFilter && filters.metadataFilter.length > 0) {
filters.metadataFilter.forEach((meta) => {
@ -547,7 +545,11 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
}) as rank`
)
)
.select(db.ref("id").withSchema(TableName.SecretReminderRecipients).as("reminderRecipientId"))
.select(db.ref("id").withSchema(TableName.Reminder).as("reminderId"))
.select(db.ref("message").withSchema(TableName.Reminder).as("reminderNote"))
.select(db.ref("repeatDays").withSchema(TableName.Reminder).as("reminderRepeatDays"))
.select(db.ref("nextReminderDate").withSchema(TableName.Reminder).as("nextReminderDate"))
.select(db.ref("id").withSchema(TableName.ReminderRecipient).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"))
@ -809,6 +811,44 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
}
};
const findSecretsWithReminderRecipients = async (ids: string[], limit: number, tx?: Knex) => {
try {
// Create a subquery to get limited secret IDs
const limitedSecretIds = (tx || db)(TableName.SecretV2)
.whereIn(`${TableName.SecretV2}.id`, ids)
.limit(limit)
.select("id");
// Join with all recipients for the limited secrets
const docs = await (tx || db)(TableName.SecretV2)
.whereIn(`${TableName.SecretV2}.id`, limitedSecretIds)
.leftJoin(TableName.Reminder, `${TableName.SecretV2}.id`, `${TableName.Reminder}.secretId`)
.leftJoin(TableName.ReminderRecipient, `${TableName.Reminder}.id`, `${TableName.ReminderRecipient}.reminderId`)
.select(selectAllTableCols(TableName.SecretV2))
.select(db.ref("userId").withSchema(TableName.ReminderRecipient).as("reminderRecipientUserId"));
const data = sqlNestRelationships({
data: docs,
key: "id",
parentMapper: (el) => ({
_id: el.id,
...SecretsV2Schema.parse(el)
}),
childrenMapper: [
{
key: "reminderRecipientUserId",
label: "recipients" as const,
mapper: ({ reminderRecipientUserId }) => reminderRecipientUserId
}
]
});
return data;
} catch (error) {
throw new DatabaseError({ error, name: "FindSecretsWithReminderRecipients" });
}
};
return {
...secretOrm,
update,
@ -826,6 +866,7 @@ export const secretV2BridgeDALFactory = ({ db, keyStore }: TSecretV2DalArg) => {
countByFolderIds,
findOne,
find,
invalidateSecretCacheByProjectId
invalidateSecretCacheByProjectId,
findSecretsWithReminderRecipients
};
};

View File

@ -223,20 +223,7 @@ export const fnSecretBulkUpdate = async ({
const actorType = actor?.type || ActorType.PLATFORM;
const sanitizedInputSecrets = inputSecrets.map(
({
filter,
data: {
skipMultilineEncoding,
type,
key,
encryptedValue,
userId,
encryptedComment,
metadata,
reminderNote,
reminderRepeatDays
}
}) => ({
({ filter, data: { skipMultilineEncoding, type, key, encryptedValue, userId, encryptedComment, metadata } }) => ({
filter: { ...filter, folderId },
data: {
skipMultilineEncoding,
@ -245,9 +232,7 @@ export const fnSecretBulkUpdate = async ({
userId,
encryptedComment,
metadata,
reminderNote,
encryptedValue,
reminderRepeatDays
encryptedValue
}
})
);
@ -263,9 +248,7 @@ export const fnSecretBulkUpdate = async ({
encryptedComment,
version,
metadata,
reminderNote,
encryptedValue,
reminderRepeatDays,
id: secretId
}) => ({
skipMultilineEncoding,
@ -275,9 +258,7 @@ export const fnSecretBulkUpdate = async ({
encryptedComment,
version,
metadata: metadata ? JSON.stringify(metadata) : [],
reminderNote,
encryptedValue,
reminderRepeatDays,
folderId,
secretId,
userActorId,
@ -395,7 +376,8 @@ export const fnSecretBulkDelete = async ({
secretDAL,
secretQueueService,
folderCommitService,
secretVersionDAL
secretVersionDAL,
projectId
}: TFnSecretBulkDelete) => {
const deletedSecrets = await secretDAL.deleteMany(
inputSecrets.map(({ type, secretKey }) => ({
@ -407,11 +389,14 @@ export const fnSecretBulkDelete = async ({
tx
);
await Promise.allSettled(
await Promise.all(
deletedSecrets
.filter(({ reminderRepeatDays }) => Boolean(reminderRepeatDays))
.map(({ id, reminderRepeatDays }) =>
secretQueueService.removeSecretReminder({ secretId: id, repeatDays: reminderRepeatDays as number }, tx)
secretQueueService.removeSecretReminder(
{ secretId: id, repeatDays: reminderRepeatDays as number, projectId },
tx
)
)
);

View File

@ -32,6 +32,7 @@ import { TFolderCommitServiceFactory } from "../folder-commit/folder-commit-serv
import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TReminderServiceFactory } from "../reminder/reminder-types";
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
import { TSecretQueueFactory } from "../secret/secret-queue";
import { TGetASecretByIdDTO } from "../secret/secret-types";
@ -108,6 +109,7 @@ type TSecretV2BridgeServiceFactoryDep = {
snapshotService: Pick<TSecretSnapshotServiceFactory, "performSnapshot">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
keyStore: Pick<TKeyStoreFactory, "getItem" | "setExpiry" | "setItemWithExpiry" | "deleteItem">;
reminderService: Pick<TReminderServiceFactory, "createReminder" | "getReminder">;
};
export type TSecretV2BridgeServiceFactory = ReturnType<typeof secretV2BridgeServiceFactory>;
@ -132,7 +134,8 @@ export const secretV2BridgeServiceFactory = ({
secretApprovalRequestSecretDAL,
kmsService,
resourceMetadataDAL,
keyStore
keyStore,
reminderService
}: TSecretV2BridgeServiceFactoryDep) => {
const $validateSecretReferences = async (
projectId: string,
@ -303,7 +306,6 @@ export const secretV2BridgeServiceFactory = ({
{
version: 1,
type,
reminderRepeatDays: inputSecretData.secretReminderRepeatDays,
encryptedComment: setKnexStringValue(
inputSecretData.secretComment,
(value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob
@ -311,7 +313,6 @@ export const secretV2BridgeServiceFactory = ({
encryptedValue: inputSecretData.secretValue
? secretManagerEncryptor({ plainText: Buffer.from(inputSecretData.secretValue) }).cipherTextBlob
: undefined,
reminderNote: inputSecretData.secretReminderNote,
skipMultilineEncoding: inputSecretData.skipMultilineEncoding,
key: secretName,
userId: inputSecret.type === SecretType.Personal ? actorId : null,
@ -337,6 +338,21 @@ export const secretV2BridgeServiceFactory = ({
return createdSecret;
});
if (inputSecret.secretReminderRepeatDays) {
await reminderService.createReminder({
actor,
actorId,
actorOrgId,
actorAuthMethod,
projectId,
reminder: {
secretId: secret.id,
message: inputSecret.secretReminderNote,
repeatDays: inputSecret.secretReminderRepeatDays
}
});
}
await secretDAL.invalidateSecretCacheByProjectId(projectId);
if (inputSecret.type === SecretType.Shared) {
await snapshotService.performSnapshot(folderId);
@ -512,12 +528,10 @@ export const secretV2BridgeServiceFactory = ({
{
filter: { id: secretId },
data: {
reminderRepeatDays: inputSecret.secretReminderRepeatDays,
encryptedComment: setKnexStringValue(
inputSecret.secretComment,
(value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob
),
reminderNote: inputSecret.secretReminderNote,
skipMultilineEncoding: inputSecret.skipMultilineEncoding,
key: inputSecret.newSecretName || secretName,
tags: inputSecret.tagIds,
@ -538,19 +552,21 @@ export const secretV2BridgeServiceFactory = ({
tx
})
);
await secretQueueService.handleSecretReminder({
newSecret: {
id: updatedSecret[0].id,
...inputSecret
},
oldSecret: {
id: secret.id,
secretReminderNote: secret.reminderNote,
secretReminderRepeatDays: secret.reminderRepeatDays,
secretReminderRecipients: secret.secretReminderRecipients?.map((el) => el.user.id)
},
projectId
});
if (inputSecret.secretReminderRepeatDays) {
await reminderService.createReminder({
actor,
actorId,
actorOrgId,
actorAuthMethod,
projectId,
reminder: {
secretId: secret.id,
message: inputSecret.secretReminderNote,
repeatDays: inputSecret.secretReminderRepeatDays,
recipients: inputSecret.secretReminderRecipients
}
});
}
await secretDAL.invalidateSecretCacheByProjectId(projectId);
if (inputSecret.type === SecretType.Shared) {
@ -1868,12 +1884,10 @@ export const secretV2BridgeServiceFactory = ({
return {
filter: { id: originalSecret.id, type: SecretType.Shared },
data: {
reminderRepeatDays: el.secretReminderRepeatDays,
encryptedComment: setKnexStringValue(
el.secretComment,
(value) => secretManagerEncryptor({ plainText: Buffer.from(value) }).cipherTextBlob
),
reminderNote: el.secretReminderNote,
skipMultilineEncoding: el.skipMultilineEncoding,
key: el.newSecretName || el.secretKey,
tags: el.tagIds,
@ -2522,9 +2536,7 @@ export const secretV2BridgeServiceFactory = ({
key: doc.key,
encryptedComment: doc.encryptedComment,
skipMultilineEncoding: doc.skipMultilineEncoding,
reminderNote: doc.reminderNote,
secretMetadata: doc.secretMetadata,
reminderRepeatDays: doc.reminderRepeatDays,
...(doc.encryptedValue
? {
encryptedValue: doc.encryptedValue,
@ -2914,6 +2926,11 @@ export const secretV2BridgeServiceFactory = ({
});
};
const findSecretIdsByFolderIdAndKeys = async ({ folderId, keys }: { folderId: string; keys: string[] }) => {
const secrets = await secretDAL.find({ folderId, $in: { [`${TableName.SecretV2}.key` as "key"]: keys } });
return secrets.map((el) => ({ id: el.id, key: el.key }));
};
return {
createSecret,
deleteSecret,
@ -2933,6 +2950,7 @@ export const secretV2BridgeServiceFactory = ({
getSecretsByFolderMappings,
getSecretById,
getAccessibleSecrets,
getSecretVersionsByIds
getSecretVersionsByIds,
findSecretIdsByFolderIdAndKeys
};
};

View File

@ -246,6 +246,7 @@ export type TCreateSecretReminderDTO = {
export type TRemoveSecretReminderDTO = {
secretId: string;
repeatDays: number;
projectId: string;
};
export type TBackFillSecretReferencesDTO = TProjectPermission;

View File

@ -5,8 +5,6 @@ import { TDbClient } from "@app/db";
import { SecretsSchema, SecretType, TableName, TSecrets, TSecretsUpdate } from "@app/db/schemas";
import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors";
import { ormify, selectAllTableCols, sqlNestRelationships } from "@app/lib/knex";
import { logger } from "@app/lib/logger";
import { QueueName, TQueueServiceFactory } from "@app/queue";
export type TSecretDALFactory = ReturnType<typeof secretDALFactory>;
@ -383,94 +381,6 @@ export const secretDALFactory = (db: TDbClient) => {
}
};
const pruneSecretReminders = async (queueService: TQueueServiceFactory) => {
const REMINDER_PRUNE_BATCH_SIZE = 5_000;
const MAX_RETRY_ON_FAILURE = 3;
let numberOfRetryOnFailure = 0;
let deletedReminderCount = 0;
logger.info(`${QueueName.DailyResourceCleanUp}: secret reminders started`);
try {
const repeatableJobs = await queueService.getRepeatableJobs(QueueName.SecretReminder);
const reminderJobs = repeatableJobs
.map((job) => ({ secretId: job.id?.replace("reminder-", "") as string, jobKey: job.key }))
.filter(Boolean);
if (reminderJobs.length === 0) {
logger.info(`${QueueName.DailyResourceCleanUp}: no reminder jobs found`);
return;
}
for (let offset = 0; offset < reminderJobs.length; offset += REMINDER_PRUNE_BATCH_SIZE) {
try {
const batchIds = reminderJobs.slice(offset, offset + REMINDER_PRUNE_BATCH_SIZE).map((r) => r.secretId);
const payload = {
$in: {
id: batchIds
}
};
const opts = {
limit: REMINDER_PRUNE_BATCH_SIZE
};
// Find existing secrets with pagination
// eslint-disable-next-line no-await-in-loop
const [secrets, secretsV2] = await Promise.all([
ormify(db, TableName.Secret).find(payload, opts),
ormify(db, TableName.SecretV2).find(payload, opts)
]);
const foundSecretIds = new Set([
...secrets.map((secret) => secret.id),
...secretsV2.map((secret) => secret.id)
]);
// Find IDs that don't exist in either table
const secretIdsNotFound = batchIds.filter((secretId) => !foundSecretIds.has(secretId));
// Delete reminders for non-existent secrets
for (const secretId of secretIdsNotFound) {
const jobKey = reminderJobs.find((r) => r.secretId === secretId)?.jobKey;
if (jobKey) {
// eslint-disable-next-line no-await-in-loop
await queueService.stopRepeatableJobByKey(QueueName.SecretReminder, jobKey);
deletedReminderCount += 1;
}
}
numberOfRetryOnFailure = 0;
} catch (error) {
numberOfRetryOnFailure += 1;
logger.error(error, `Failed to process batch at offset ${offset}`);
if (numberOfRetryOnFailure >= MAX_RETRY_ON_FAILURE) {
break;
}
// Retry the current batch
offset -= REMINDER_PRUNE_BATCH_SIZE;
// eslint-disable-next-line no-promise-executor-return, @typescript-eslint/no-loop-func, no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, 500 * numberOfRetryOnFailure));
}
// Small delay between batches
// eslint-disable-next-line no-promise-executor-return, @typescript-eslint/no-loop-func, no-await-in-loop
await new Promise((resolve) => setTimeout(resolve, 10));
}
} catch (error) {
logger.error(error, "Failed to complete secret reminder pruning");
} finally {
logger.info(
`${QueueName.DailyResourceCleanUp}: secret reminders completed. Deleted ${deletedReminderCount} reminders`
);
}
};
return {
...secretOrm,
update,
@ -485,7 +395,6 @@ export const secretDALFactory = (db: TDbClient) => {
upsertSecretReferences,
findReferencedSecretReferences,
findAllProjectSecretValues,
pruneSecretReminders,
findManySecretsWithTags
};
};

View File

@ -20,11 +20,9 @@ import {
decryptSymmetric128BitHexKeyUTF8,
encryptSymmetric128BitHexKeyUTF8
} from "@app/lib/crypto";
import { daysToMillisecond, secondsToMillis } from "@app/lib/dates";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy, unique } from "@app/lib/fn";
import { logger } from "@app/lib/logger";
import { QueueJobs, QueueName, TQueueServiceFactory } from "@app/queue";
import {
fnSecretBulkInsert as fnSecretV2BridgeBulkInsert,
fnSecretBulkUpdate as fnSecretV2BridgeBulkUpdate,
@ -36,6 +34,7 @@ import { KmsDataKey } from "../kms/kms-types";
import { getBotKeyFnFactory } from "../project-bot/project-bot-fns";
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TReminderServiceFactory } from "../reminder/reminder-types";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretV2BridgeDALFactory } from "../secret-v2-bridge/secret-v2-bridge-dal";
import { TSecretDALFactory } from "./secret-dal";
@ -741,7 +740,8 @@ export const fnSecretBulkDelete = async ({
tx,
actorId,
secretDAL,
secretQueueService
secretQueueService,
projectId
}: TFnSecretBulkDelete) => {
const deletedSecrets = await secretDAL.deleteMany(
inputSecrets.map(({ type, secretBlindIndex }) => ({
@ -757,7 +757,10 @@ export const fnSecretBulkDelete = async ({
deletedSecrets
.filter(({ secretReminderRepeatDays }) => Boolean(secretReminderRepeatDays))
.map(({ id, secretReminderRepeatDays }) =>
secretQueueService.removeSecretReminder({ secretId: id, repeatDays: secretReminderRepeatDays as number }, tx)
secretQueueService.removeSecretReminder(
{ secretId: id, repeatDays: secretReminderRepeatDays as number, projectId },
tx
)
)
);
@ -1184,14 +1187,14 @@ export const decryptSecretWithBot = (
type TFnDeleteProjectSecretReminders = {
secretDAL: Pick<TSecretDALFactory, "find">;
secretV2BridgeDAL: Pick<TSecretV2BridgeDALFactory, "find">;
queueService: Pick<TQueueServiceFactory, "stopRepeatableJob">;
reminderService: Pick<TReminderServiceFactory, "deleteReminderBySecretId">;
projectBotService: Pick<TProjectBotServiceFactory, "getBotKey">;
folderDAL: Pick<TSecretFolderDALFactory, "findByProjectId">;
};
export const fnDeleteProjectSecretReminders = async (
projectId: string,
{ secretDAL, secretV2BridgeDAL, queueService, projectBotService, folderDAL }: TFnDeleteProjectSecretReminders
{ secretDAL, secretV2BridgeDAL, reminderService, projectBotService, folderDAL }: TFnDeleteProjectSecretReminders
) => {
const projectFolders = await folderDAL.findByProjectId(projectId);
const { shouldUseSecretV2Bridge } = await projectBotService.getBotKey(projectId, false);
@ -1206,23 +1209,13 @@ export const fnDeleteProjectSecretReminders = async (
$notNull: ["secretReminderRepeatDays"]
});
const appCfg = getConfig();
for await (const secret of projectSecrets) {
const repeatDays = shouldUseSecretV2Bridge
? (secret as { reminderRepeatDays: number }).reminderRepeatDays
: (secret as { secretReminderRepeatDays: number }).secretReminderRepeatDays;
// We're using the queue service directly to get around conflicting imports.
if (repeatDays) {
await queueService.stopRepeatableJob(
QueueName.SecretReminder,
QueueJobs.SecretReminder,
{
// on prod it this will be in days, in development this will be second
every: appCfg.NODE_ENV === "development" ? secondsToMillis(repeatDays) : daysToMillisecond(repeatDays)
},
`reminder-${secret.id}`
);
await reminderService.deleteReminderBySecretId(secret.id, projectId);
}
}
};

View File

@ -20,7 +20,6 @@ import { KeyStorePrefixes, KeyStoreTtls, TKeyStoreFactory } from "@app/keystore/
import { getConfig } from "@app/lib/config/env";
import { decryptSymmetric128BitHexKeyUTF8 } from "@app/lib/crypto";
import { infisicalSymmetricEncypt } from "@app/lib/crypto/encryption";
import { daysToMillisecond, secondsToMillis } from "@app/lib/dates";
import { BadRequestError, NotFoundError } from "@app/lib/errors";
import { getTimeDifferenceInSeconds, groupBy, isSamePath, unique } from "@app/lib/fn";
import { logger } from "@app/lib/logger";
@ -41,7 +40,6 @@ import { TIntegrationAuthServiceFactory } from "../integration-auth/integration-
import { syncIntegrationSecrets } from "../integration-auth/integration-sync-secret";
import { TKmsServiceFactory } from "../kms/kms-service";
import { KmsDataKey } from "../kms/kms-types";
import { TOrgDALFactory } from "../org/org-dal";
import { TOrgServiceFactory } from "../org/org-service";
import { TProjectDALFactory } from "../project/project-dal";
import { createProjectKey } from "../project/project-fns";
@ -50,12 +48,12 @@ import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TProjectKeyDALFactory } from "../project-key/project-key-dal";
import { TProjectMembershipDALFactory } from "../project-membership/project-membership-dal";
import { TProjectUserMembershipRoleDALFactory } from "../project-membership/project-user-membership-role-dal";
import { TReminderServiceFactory } from "../reminder/reminder-types";
import { TResourceMetadataDALFactory } from "../resource-metadata/resource-metadata-dal";
import { ResourceMetadataDTO } from "../resource-metadata/resource-metadata-schema";
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";
@ -93,7 +91,6 @@ type TSecretQueueFactoryDep = {
projectKeyDAL: Pick<TProjectKeyDALFactory, "create">;
projectMembershipDAL: Pick<TProjectMembershipDALFactory, "findAllProjectMembers" | "create">;
smtpService: TSmtpService;
orgDAL: Pick<TOrgDALFactory, "findOrgByProjectId">;
secretVersionDAL: TSecretVersionDALFactory;
secretBlindIndexDAL: TSecretBlindIndexDALFactory;
secretTagDAL: TSecretTagDALFactory;
@ -113,11 +110,11 @@ type TSecretQueueFactoryDep = {
projectUserMembershipRoleDAL: Pick<TProjectUserMembershipRoleDALFactory, "create">;
resourceMetadataDAL: Pick<TResourceMetadataDALFactory, "insertMany" | "delete">;
folderCommitService: Pick<TFolderCommitServiceFactory, "createCommit">;
secretReminderRecipientsDAL: Pick<
TSecretReminderRecipientsDALFactory,
"delete" | "findUsersBySecretId" | "insertMany" | "transaction"
>;
secretSyncQueue: Pick<TSecretSyncQueueFactory, "queueSecretSyncsSyncSecretsByPath">;
reminderService: Pick<
TReminderServiceFactory,
"createReminderInternal" | "deleteReminderBySecretId" | "removeReminderRecipients"
>;
};
export type TGetSecrets = {
@ -155,7 +152,6 @@ export const secretQueueFactory = ({
userDAL,
webhookDAL,
projectEnvDAL,
orgDAL,
smtpService,
projectDAL,
projectBotDAL,
@ -178,9 +174,9 @@ export const secretQueueFactory = ({
projectUserMembershipRoleDAL,
projectKeyDAL,
resourceMetadataDAL,
secretReminderRecipientsDAL,
secretSyncQueue,
folderCommitService
folderCommitService,
reminderService
}: TSecretQueueFactoryDep) => {
const integrationMeter = opentelemetry.metrics.getMeter("Integrations");
const errorHistogram = integrationMeter.createHistogram("integration_secret_sync_errors", {
@ -190,19 +186,8 @@ export const secretQueueFactory = ({
const removeSecretReminder = async ({ deleteRecipients = true, ...dto }: TRemoveSecretReminderDTO, tx?: Knex) => {
if (deleteRecipients) {
await secretReminderRecipientsDAL.delete({ secretId: dto.secretId }, tx);
await reminderService.deleteReminderBySecretId(dto.secretId, dto.projectId, tx);
}
const appCfg = getConfig();
await queueService.stopRepeatableJob(
QueueName.SecretReminder,
QueueJobs.SecretReminder,
{
// on prod it this will be in days, in development this will be second
every: appCfg.NODE_ENV === "development" ? secondsToMillis(dto.repeatDays) : daysToMillisecond(dto.repeatDays)
},
`reminder-${dto.secretId}`
);
};
const $generateActor = async (actorId?: string, isManual?: boolean): Promise<Actor> => {
@ -242,11 +227,9 @@ export const secretQueueFactory = ({
oldSecret,
newSecret,
projectId,
deleteRecipients = true
secretReminderRecipients
}: TCreateSecretReminderDTO) => {
try {
const appCfg = getConfig();
if (oldSecret.id !== newSecret.id) {
throw new BadRequestError({
name: "SecretReminderIdMismatch",
@ -261,38 +244,13 @@ export const secretQueueFactory = ({
});
}
// If the secret already has a reminder, we should remove the existing one first.
if (oldSecret.secretReminderRepeatDays) {
await removeSecretReminder({
repeatDays: oldSecret.secretReminderRepeatDays,
secretId: oldSecret.id,
deleteRecipients
});
}
await queueService.queue(
QueueName.SecretReminder,
QueueJobs.SecretReminder,
{
note: newSecret.secretReminderNote,
projectId,
repeatDays: newSecret.secretReminderRepeatDays,
secretId: newSecret.id
},
{
jobId: `reminder-${newSecret.id}`,
repeat: {
// on prod it this will be in days, in development this will be second
every:
appCfg.NODE_ENV === "development"
? secondsToMillis(newSecret.secretReminderRepeatDays)
: daysToMillisecond(newSecret.secretReminderRepeatDays),
immediately: true
},
removeOnComplete: true,
removeOnFail: true
}
);
await reminderService.createReminderInternal({
secretId: newSecret.id,
message: newSecret.secretReminderNote,
repeatDays: newSecret.secretReminderRepeatDays,
recipients: secretReminderRecipients,
projectId
});
} catch (err) {
logger.error(err, "Failed to create secret reminder.");
throw new BadRequestError({
@ -305,55 +263,30 @@ export const secretQueueFactory = ({
const handleSecretReminder = async ({ newSecret, oldSecret, projectId }: THandleReminderDTO) => {
const { secretReminderRepeatDays, secretReminderNote, secretReminderRecipients } = newSecret;
const recipientsUpdated =
secretReminderRecipients?.some(
(newId) => !oldSecret.secretReminderRecipients?.find((oldId) => newId === oldId)
) || secretReminderRecipients?.length !== oldSecret.secretReminderRecipients?.length;
await secretReminderRecipientsDAL.transaction(async (tx) => {
if (newSecret.type !== SecretType.Personal && secretReminderRepeatDays !== undefined) {
if (
(secretReminderRepeatDays && oldSecret.secretReminderRepeatDays !== secretReminderRepeatDays) ||
(secretReminderNote && oldSecret.secretReminderNote !== secretReminderNote)
) {
await addSecretReminder({
oldSecret,
newSecret,
projectId,
deleteRecipients: false
});
} else if (
secretReminderRepeatDays === null &&
secretReminderNote === null &&
oldSecret.secretReminderRepeatDays
) {
await removeSecretReminder({
secretId: oldSecret.id,
repeatDays: oldSecret.secretReminderRepeatDays
});
}
if (newSecret.type !== SecretType.Personal && secretReminderRepeatDays !== undefined) {
if (
(secretReminderRepeatDays && oldSecret.secretReminderRepeatDays !== secretReminderRepeatDays) ||
(secretReminderNote && oldSecret.secretReminderNote !== secretReminderNote)
) {
await addSecretReminder({
oldSecret,
newSecret,
projectId,
secretReminderRecipients: secretReminderRecipients ?? [],
deleteRecipients: false
});
} else if (
secretReminderRepeatDays === null &&
secretReminderNote === null &&
oldSecret.secretReminderRepeatDays
) {
await removeSecretReminder({
secretId: oldSecret.id,
repeatDays: oldSecret.secretReminderRepeatDays,
projectId
});
}
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({
projectDAL,
@ -1111,62 +1044,9 @@ export const secretQueueFactory = ({
}
});
// TODO(Carlos): remove this queue (needed for queue initialization and perform the migration)
queueService.start(QueueName.SecretReminder, async ({ data }) => {
logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}]`);
const { projectId } = data;
const organization = await orgDAL.findOrgByProjectId(projectId);
const project = await projectDAL.findById(projectId);
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;
}
if (!project) {
logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no project found`);
return;
}
const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId);
if (!projectMembers || !projectMembers.length) {
logger.info(`secretReminderQueue.process: [secretDocument=${data.secretId}] no project members found`);
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: selectedRecipients,
substitutions: {
reminderNote: data.note, // May not be present.
projectName: project.name,
organizationName: organization.name
}
});
await queueService.queue(QueueName.SecretWebhook, QueueJobs.SecWebhook, {
type: WebhookEvents.SecretReminderExpired,
payload: {
projectName: project.name,
projectId: project.id,
secretPath: folder?.path,
environment: folder?.environmentSlug || "",
reminderNote: data.note,
secretName: secret?.key,
secretId: data.secretId
}
});
logger.info(`(deprecated) secretReminderQueue.process: [secretDocument=${data.secretId}]`);
});
const startSecretV2Migration = async (projectId: string) => {

View File

@ -48,6 +48,7 @@ import { ChangeType } from "../folder-commit/folder-commit-service";
import { TProjectDALFactory } from "../project/project-dal";
import { TProjectBotServiceFactory } from "../project-bot/project-bot-service";
import { TProjectEnvDALFactory } from "../project-env/project-env-dal";
import { TReminderServiceFactory } from "../reminder/reminder-types";
import { TSecretBlindIndexDALFactory } from "../secret-blind-index/secret-blind-index-dal";
import { TSecretFolderDALFactory } from "../secret-folder/secret-folder-dal";
import { TSecretImportDALFactory } from "../secret-import/secret-import-dal";
@ -131,6 +132,7 @@ type TSecretServiceFactoryDep = {
"insertMany" | "insertApprovalSecretTags"
>;
licenseService: Pick<TLicenseServiceFactory, "getPlan">;
reminderService: Pick<TReminderServiceFactory, "createReminder">;
};
export type TSecretServiceFactory = ReturnType<typeof secretServiceFactory>;
@ -153,7 +155,8 @@ export const secretServiceFactory = ({
secretApprovalRequestSecretDAL,
secretV2BridgeService,
secretApprovalRequestService,
licenseService
licenseService,
reminderService
}: TSecretServiceFactoryDep) => {
const getSecretReference = async (projectId: string) => {
// if bot key missing means e2e still exist
@ -546,7 +549,8 @@ export const secretServiceFactory = ({
await secretQueueService.removeSecretReminder(
{
repeatDays: secret.secretReminderRepeatDays,
secretId: secret.id
secretId: secret.id,
projectId
},
tx
);
@ -1072,7 +1076,8 @@ export const secretServiceFactory = ({
await secretQueueService.removeSecretReminder(
{
repeatDays: secret.secretReminderRepeatDays,
secretId: secret.id
secretId: secret.id,
projectId
},
tx
);
@ -1660,8 +1665,6 @@ export const secretServiceFactory = ({
secretComment,
secretValue,
tagIds,
reminderNote: secretReminderNote,
reminderRepeatDays: secretReminderRepeatDays,
secretMetadata
}
]
@ -1824,9 +1827,6 @@ export const secretServiceFactory = ({
secretComment,
secretValue,
tagIds,
reminderNote: secretReminderNote,
reminderRepeatDays: secretReminderRepeatDays,
secretReminderRecipients,
secretMetadata
}
]
@ -1835,9 +1835,6 @@ export const secretServiceFactory = ({
return { type: SecretProtectionType.Approval as const, approval };
}
const secret = await secretV2BridgeService.updateSecret({
secretReminderRepeatDays,
secretReminderNote,
secretReminderRecipients,
skipMultilineEncoding,
tagIds,
secretComment,
@ -1855,6 +1852,22 @@ export const secretServiceFactory = ({
secretValue,
secretMetadata
});
if (secretReminderRepeatDays) {
await reminderService.createReminder({
actor,
actorId,
actorOrgId,
actorAuthMethod,
projectId,
reminder: {
secretId: secret.id,
message: secretReminderNote,
repeatDays: secretReminderRepeatDays,
recipients: secretReminderRecipients
}
});
}
return { type: SecretProtectionType.Direct as const, secret };
}
@ -2246,6 +2259,30 @@ export const secretServiceFactory = ({
secrets: inputSecrets,
mode
});
await Promise.all(
inputSecrets
.filter((el) => el.secretReminderRepeatDays)
.map(async (secret) => {
await reminderService.createReminder({
actor,
actorId,
actorOrgId,
actorAuthMethod,
projectId,
reminder: {
secretId: secrets.find(
(el) =>
(el.secretKey === secret.secretKey || el.secretKey === secret.newSecretName) &&
el.secretPath === (secret.secretPath || secretPath)
)?.id,
message: secret.secretReminderNote,
repeatDays: secret.secretReminderRepeatDays
}
});
})
);
return { type: SecretProtectionType.Direct as const, secrets };
}

View File

@ -310,6 +310,7 @@ export type TUpdateManySecretRawDTO = Omit<TProjectPermission, "projectId"> & {
secretMetadata?: ResourceMetadataDTO;
secretReminderRepeatDays?: number | null;
secretReminderNote?: string | null;
secretPath?: string;
}[];
};
@ -410,6 +411,7 @@ export type TCreateSecretReminderDTO = {
oldSecret: TPartialSecret;
newSecret: TPartialSecret;
projectId: string;
secretReminderRecipients: string[];
deleteRecipients?: boolean;
};
@ -417,6 +419,7 @@ export type TCreateSecretReminderDTO = {
export type TRemoveSecretReminderDTO = {
secretId: string;
repeatDays: number;
projectId: string;
deleteRecipients?: boolean;
};

View File

@ -18,6 +18,7 @@ export type DatePickerProps = Omit<DayPickerProps, "selected"> & {
popUpProps: PopoverProps;
popUpContentProps: PopoverContentProps;
dateFormat?: "PPP" | "PP" | "P"; // extend as needed
hideTime?: boolean;
};
// Doc: https://react-day-picker.js.org/
@ -27,6 +28,7 @@ export const DatePicker = ({
popUpProps,
popUpContentProps,
dateFormat = "PPP",
hideTime = false,
...props
}: DatePickerProps) => {
const [timeValue, setTimeValue] = useState<string>(value ? format(value, "HH:mm") : "00:00");
@ -85,14 +87,16 @@ export const DatePicker = ({
}}
/>
</div>
<div className="mx-4 my-4">
<Input
type="time"
value={timeValue}
onChange={handleTimeChange}
className="bg-mineshaft-700 text-white [color-scheme:dark]"
/>
</div>
{!hideTime && (
<div className="mx-4 my-4">
<Input
type="time"
value={timeValue}
onChange={handleTimeChange}
className="bg-mineshaft-700 text-white [color-scheme:dark]"
/>
</div>
)}
</PopoverContent>
</Popover>
);

View File

@ -0,0 +1 @@
export { useCreateReminder, useDeleteReminder, useGetReminder } from "./queries";

View File

@ -0,0 +1,61 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { CreateReminderDTO, DeleteReminderDTO, Reminder } from "./types";
export const reminderKeys = {
getReminder: (secretId: string, projectId: string) =>
["get-reminder", secretId, projectId] as const
};
export const useCreateReminder = (secretId: string, projectId: string) => {
const queryClient = useQueryClient();
return useMutation<Reminder, object, CreateReminderDTO>({
mutationFn: async ({ message, repeatDays, nextReminderDate, recipients }) => {
const { data } = await apiRequest.post<{ reminder: Reminder }>(
`/api/v1/reminders/secrets/${secretId}?projectId=${projectId}`,
{
message,
repeatDays,
nextReminderDate,
recipients
}
);
return data.reminder;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: reminderKeys.getReminder(secretId, projectId) });
}
});
};
export const useDeleteReminder = (secretId: string, projectId: string) => {
const queryClient = useQueryClient();
return useMutation<Reminder, object, DeleteReminderDTO>({
mutationFn: async () => {
const { data } = await apiRequest.delete<{ reminder: Reminder }>(
`/api/v1/reminders/secrets/${secretId}?projectId=${projectId}`
);
return data.reminder;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: reminderKeys.getReminder(secretId, projectId) });
}
});
};
export const useGetReminder = (secretId: string, projectId: string) => {
return useQuery({
queryKey: reminderKeys.getReminder(secretId, projectId),
queryFn: async () => {
const { data } = await apiRequest.get<{ reminder: Reminder }>(
`/api/v1/reminders/secrets/${secretId}?projectId=${projectId}`
);
return data.reminder;
},
enabled: Boolean(secretId && projectId)
});
};

View File

@ -0,0 +1,14 @@
export type CreateReminderDTO = {
message?: string | null;
repeatDays?: number | null;
nextReminderDate?: Date | null;
secretId: string;
recipients?: string[];
};
export type DeleteReminderDTO = {
secretId: string;
reminderId: string;
};
export type Reminder = { id: string } & CreateReminderDTO;

View File

@ -1,25 +1,65 @@
import { useEffect } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { faClock, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQueryClient } from "@tanstack/react-query";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
DatePicker,
FilterableSelect,
FormControl,
Input,
Modal,
ModalContent,
Select,
SelectItem,
TextArea
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { useGetWorkspaceUsers } from "@app/hooks/api";
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
import { useCreateReminder, useDeleteReminder } from "@app/hooks/api/reminders";
import { reminderKeys } from "@app/hooks/api/reminders/queries";
import { Reminder } from "@app/hooks/api/reminders/types";
import { secretKeys } from "@app/hooks/api/secrets/queries";
// Constants
const MIN_REPEAT_DAYS = 1;
const MAX_REPEAT_DAYS = 365;
const DEFAULT_REPEAT_DAYS = 30;
const DEFAULT_TEXTAREA_ROWS = 8;
// Enums
enum ReminderType {
Recurring = "Recurring",
OneTime = "One Time"
}
// Types
interface RecipientOption {
label: string;
value: string;
}
interface ReminderFormProps {
isOpen: boolean;
reminderId?: string;
onOpenChange: () => void;
workspaceId: string;
environment: string;
secretPath: string;
secretId: string;
reminder?: Reminder;
}
// Validation Schema
const ReminderFormSchema = z.object({
note: z.string().optional().nullable(),
message: z.string().optional().nullable(),
recipients: z
.array(
z.object({
@ -28,182 +68,404 @@ const ReminderFormSchema = z.object({
})
)
.optional(),
days: z
repeatDays: z
.number()
.min(1, { message: "Must be at least 1 day" })
.max(365, { message: "Must be less than 365 days" })
.min(MIN_REPEAT_DAYS, { message: `Must be at least ${MIN_REPEAT_DAYS} day` })
.max(MAX_REPEAT_DAYS, { message: `Must be less than ${MAX_REPEAT_DAYS} days` })
.nullable()
.optional(),
nextReminderDate: z.coerce
.date()
.refine((data) => data > new Date(), { message: "Reminder date must be in the future" })
.nullable()
.optional(),
reminderType: z.enum(["Recurring", "One Time"])
});
export type TReminderFormSchema = z.infer<typeof ReminderFormSchema>;
interface ReminderFormProps {
isOpen: boolean;
repeatDays?: number | null;
note?: string | null;
recipients?: string[] | null;
onOpenChange: (isOpen: boolean, data?: TReminderFormSchema) => void;
}
// Custom hook for form state management
const useReminderForm = (reminderData?: Reminder) => {
const { repeatDays, message, nextReminderDate } = reminderData || {};
const isEditMode = Boolean(reminderData);
const defaultValues = useMemo(
() => ({
repeatDays: repeatDays || null,
message: message || "",
nextReminderDate: nextReminderDate || null,
reminderType: repeatDays ? ReminderType.Recurring : ReminderType.OneTime,
recipients: []
}),
[repeatDays, message, nextReminderDate]
);
return {
isEditMode,
reminderData,
defaultValues
};
};
// Custom hook for workspace members
const useWorkspaceMembers = () => {
const { currentWorkspace } = useWorkspace();
const { data: members = [] } = useGetWorkspaceUsers(currentWorkspace?.id);
const memberOptions = useMemo(
(): RecipientOption[] =>
members.map((member) => ({
label: member.user.username || member.user.email,
value: member.user.id
})),
[members]
);
return { members, memberOptions };
};
// Main component
export const CreateReminderForm = ({
isOpen,
onOpenChange,
repeatDays,
note,
recipients
workspaceId,
environment,
secretPath,
secretId,
reminder
}: ReminderFormProps) => {
const { currentWorkspace } = useWorkspace();
const queryClient = useQueryClient();
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
const { data: members = [] } = useGetWorkspaceUsers(currentWorkspace?.id);
// Custom hooks
const { isEditMode, reminderData } = useReminderForm(reminder);
const { memberOptions } = useWorkspaceMembers();
// API mutations
const { mutateAsync: createReminder } = useCreateReminder(secretId, workspaceId);
const { mutateAsync: deleteReminder } = useDeleteReminder(secretId, workspaceId);
// Form setup
const form = useForm<TReminderFormSchema>({
defaultValues: {
repeatDays: reminderData?.repeatDays || null,
message: reminderData?.message || "",
nextReminderDate: reminderData?.nextReminderDate || null,
reminderType: reminderData?.repeatDays ? ReminderType.Recurring : ReminderType.OneTime,
recipients: []
},
resolver: zodResolver(ReminderFormSchema)
});
const {
register,
control,
setValue,
handleSubmit,
reset,
watch,
formState: { isSubmitting }
} = useForm<TReminderFormSchema>({
defaultValues: {
days: repeatDays || undefined,
note: note || ""
},
resolver: zodResolver(ReminderFormSchema)
});
} = form;
const handleFormSubmit = async (data: TReminderFormSchema) => {
onOpenChange(false, data);
// Watch form values
const reminderType = watch("reminderType");
// Invalidate queries helper
const invalidateQueries = () => {
queryClient.invalidateQueries({
queryKey: dashboardKeys.getDashboardSecrets({
projectId: workspaceId,
secretPath
})
});
queryClient.invalidateQueries({
queryKey: secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
});
queryClient.invalidateQueries({
queryKey: reminderKeys.getReminder(secretId, workspaceId)
});
};
useEffect(() => {
// On initial load, filter the members to only include the recipients
if (members.length) {
const filteredMembers = members.filter((m) => recipients?.find((r) => r === m.user.id));
setValue(
"recipients",
filteredMembers.map((m) => ({
label: m.user.username || m.user.email,
value: m.user.id
}))
);
}
}, [members, isOpen, recipients]);
// Form submission handler
const handleFormSubmit = async (data: TReminderFormSchema) => {
try {
await createReminder({
repeatDays: data.repeatDays,
message: data.message,
recipients: data.recipients?.map((r) => r.value) || [],
secretId,
nextReminderDate: data.nextReminderDate
});
invalidateQueries();
createNotification({
type: "success",
text: `Successfully ${isEditMode ? "updated" : "created"} secret reminder`
});
reset();
onOpenChange();
} catch (error) {
console.error("Failed to save reminder:", error);
createNotification({
type: "error",
text: "Failed to save reminder. Please try again."
});
}
};
// Delete reminder handler
const handleDeleteReminder = async () => {
try {
await deleteReminder({ reminderId: reminder?.id || "", secretId });
invalidateQueries();
reset();
onOpenChange();
createNotification({
type: "success",
text: "Successfully deleted reminder"
});
} catch (error) {
console.error("Failed to delete reminder:", error);
createNotification({
type: "error",
text: "Failed to delete reminder. Please try again."
});
}
};
// Handle reminder type change
const handleReminderTypeChange = useCallback(
(newType: string) => {
if (newType === ReminderType.Recurring) {
setValue("repeatDays", DEFAULT_REPEAT_DAYS);
setValue("nextReminderDate", null);
} else if (newType === ReminderType.OneTime) {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
setValue("nextReminderDate", tomorrow);
setValue("repeatDays", null);
}
},
[setValue]
);
// Initialize form with existing data
useEffect(() => {
if (repeatDays) setValue("days", repeatDays);
if (note) setValue("note", note);
}, [repeatDays, note]);
if (!reminderData) return;
const {
repeatDays: repeatDaysInitial,
message,
recipients,
nextReminderDate: nextReminderDateInitial
} = reminderData;
if (repeatDaysInitial) {
setValue("repeatDays", repeatDaysInitial);
setValue("reminderType", ReminderType.Recurring);
} else {
setValue("reminderType", ReminderType.OneTime);
}
if (message) setValue("message", message);
if (nextReminderDateInitial) setValue("nextReminderDate", nextReminderDateInitial);
// Set recipients
if (recipients?.length && memberOptions.length) {
const selectedRecipients = memberOptions.filter((option) =>
recipients.includes(option.value)
);
setValue("recipients", selectedRecipients);
}
}, [reminderData, memberOptions, setValue]);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title={`${repeatDays ? "Update" : "Create"} reminder`}
title={`${isEditMode ? "Update" : "Create"} reminder`}
subTitle="Set up a reminder for when this secret should be rotated. Everyone with access to this project will be notified when the reminder is triggered."
>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-2">
<div>
<Controller
control={control}
name="days"
render={({ field, fieldState }) => (
<>
<FormControl
isRequired
className="mb-0"
label="Reminder Interval (in days)"
isError={Boolean(fieldState.error)}
errorText={fieldState.error?.message || ""}
>
<Input
onChange={(el) => setValue("days", parseInt(el.target.value, 10))}
type="number"
placeholder="31"
defaultValue={repeatDays || undefined}
value={field.value || undefined}
/>
</FormControl>
<div
className={twMerge(
"ml-1 mt-2 text-xs",
field.value ? "opacity-60" : "opacity-0"
)}
>
A reminder will be sent every{" "}
{field.value && field.value > 1 ? `${field.value} days` : "day"}
</div>
</>
)}
/>
</div>
<FormControl label="Note" className="mb-0">
<TextArea
placeholder="Remember to rotate the AWS secret every month."
className="border border-mineshaft-600 text-sm"
rows={8}
defaultValue={note || ""}
reSize="none"
cols={30}
{...register("note")}
/>
</FormControl>
<form onSubmit={handleSubmit(handleFormSubmit)} className="space-y-6">
{/* Reminder Type Selection */}
<Controller
name="reminderType"
control={control}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
errorText={error?.message}
isError={Boolean(error?.message)}
label="Recurrence"
>
<Select
value={value}
onValueChange={(val) => {
onChange(val);
handleReminderTypeChange(val);
}}
className="w-full border border-mineshaft-500 capitalize"
position="popper"
placeholder="Select reminder recurrence"
dropdownContainerClassName="max-w-none"
>
{Object.values(ReminderType).map((type) => (
<SelectItem value={type} key={type}>
{type}
</SelectItem>
))}
</Select>
</FormControl>
)}
/>
{/* Conditional Fields Based on Reminder Type */}
{reminderType === ReminderType.Recurring ? (
<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>
name="repeatDays"
render={({ field, fieldState }) => (
<div>
<FormControl
isRequired
className="mb-0"
label="Reminder Interval (in days)"
isError={Boolean(fieldState.error)}
errorText={fieldState.error?.message || ""}
>
<Input
onChange={(el) => {
const value = parseInt(el.target.value, 10);
setValue("repeatDays", Number.isNaN(value) ? null : value);
}}
type="number"
placeholder={DEFAULT_REPEAT_DAYS.toString()}
value={field.value || ""}
min={MIN_REPEAT_DAYS}
max={MAX_REPEAT_DAYS}
/>
</FormControl>
{/* Interval description */}
<div
className={twMerge(
"ml-1 mt-2 text-xs",
field.value ? "opacity-60" : "opacity-0"
)}
>
A reminder will be sent every{" "}
{field.value && field.value > 1 ? `${field.value} days` : "day"}
</div>
</div>
)}
/>
</div>
<div className="mt-7 flex items-center space-x-4">
) : (
<Controller
control={control}
name="nextReminderDate"
render={({ field, fieldState }) => (
<div>
<FormControl
isRequired
className="mb-0"
label="Reminder Date"
isError={Boolean(fieldState.error)}
errorText={fieldState.error?.message || ""}
>
<DatePicker
value={field.value || undefined}
className="w-full"
onChange={field.onChange}
dateFormat="P"
popUpProps={{
open: isDatePickerOpen,
onOpenChange: setIsDatePickerOpen
}}
popUpContentProps={{}}
hideTime
hidden={{ before: new Date(Date.now() + 86400000) }}
/>
</FormControl>
</div>
)}
/>
)}
{/* Message/Note Field */}
<FormControl label="Note" className="mb-0">
<TextArea
placeholder="Remember to rotate the AWS secret every month."
className="border border-mineshaft-600 text-sm"
rows={DEFAULT_TEXTAREA_ROWS}
reSize="none"
cols={30}
{...register("message")}
/>
</FormControl>
{/* Recipients Selection */}
<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
menuPlacement="top"
className="w-full"
placeholder="Select reminder recipients..."
isMulti
name="recipients"
options={memberOptions}
value={field.value}
onChange={field.onChange}
/>
</FormControl>
)}
/>
{/* Action Buttons */}
<div className="flex items-center space-x-4 pt-4">
<Button
isDisabled={isSubmitting}
isLoading={isSubmitting}
key="layout-create-project-submit"
className=""
leftIcon={<FontAwesomeIcon icon={faClock} />}
type="submit"
>
{repeatDays ? "Update" : "Create"} reminder
{isEditMode ? "Update" : "Create"} reminder
</Button>
{repeatDays && (
{isEditMode && (
<Button
key="layout-cancel-create-project"
onClick={() => onOpenChange(false, { days: null, note: null })}
onClick={handleDeleteReminder}
colorSchema="danger"
variant="outline"
leftIcon={<FontAwesomeIcon icon={faTrash} />}
type="button"
isDisabled={isSubmitting}
>
Delete reminder
</Button>
)}
<Button
key="layout-cancel-create-project"
onClick={() => onOpenChange(false)}
onClick={onOpenChange}
variant="plain"
colorSchema="secondary"
type="button"
isDisabled={isSubmitting}
>
Cancel
</Button>

View File

@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import { faCircleQuestion, faEye } from "@fortawesome/free-regular-svg-icons";
@ -55,6 +55,7 @@ import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionCo
import { usePopUp, useToggle } from "@app/hooks";
import { useGetSecretVersion } from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
import { useGetReminder } from "@app/hooks/api/reminders";
import { useGetSecretAccessList } from "@app/hooks/api/secrets/queries";
import { SecretV3RawSanitized, WsTag } from "@app/hooks/api/types";
import { ProjectType } from "@app/hooks/api/workspace/types";
@ -114,6 +115,7 @@ export const SecretDetailSidebar = ({
const { permission } = useProjectPermission();
const { currentWorkspace } = useWorkspace();
const { data: reminderData } = useGetReminder(secret?.id, currentWorkspace.id);
const tagFields = useFieldArray({
control,
@ -220,23 +222,8 @@ export const SecretDetailSidebar = ({
await onSaveSecret(secret, { ...secret, ...data }, () => reset());
};
const handleReminderSubmit = async (
reminderRepeatDays: number | null | undefined,
reminderNote: string | null | undefined,
reminderRecipients: string[] | undefined
) => {
await onSaveSecret(
secret,
{ ...secret, reminderRepeatDays, reminderNote, isReminderEvent: true, reminderRecipients },
() => {}
);
};
const [createReminderFormOpen, setCreateReminderFormOpen] = useToggle(false);
const secretReminderRepeatDays = watch("reminderRepeatDays");
const secretReminderNote = watch("reminderNote");
const secretReminderRecipients = watch("reminderRecipients");
useEffect(() => {
setValue(
"reminderRecipients",
@ -302,27 +289,33 @@ export const SecretDetailSidebar = ({
}
};
const getDaysUntilReminder = useMemo(() => {
return (): string => {
const now = new Date();
now.setHours(0, 0, 0, 0);
const target = new Date(reminderData?.nextReminderDate || "");
target.setHours(0, 0, 0, 0);
const diffTime = target.getTime() - now.getTime();
const daysRemaining = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return `Days until next reminder: ${daysRemaining}`;
};
}, [reminderData]);
return (
<>
<CreateReminderForm
repeatDays={secretReminderRepeatDays}
note={secretReminderNote}
recipients={secretReminderRecipients}
isOpen={createReminderFormOpen}
onOpenChange={(_, data) => {
onOpenChange={() => {
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 });
setValue("reminderRecipients", recipients, { shouldDirty: false });
handleReminderSubmit(data.days, data.note, recipients);
}
}}
workspaceId={currentWorkspace.id}
environment={environment}
secretPath={secretPath}
secretId={secret?.id}
reminder={reminderData}
/>
<UpgradePlanModal
isOpen={popUp.secretAccessUpgradePlan.isOpen}
@ -734,14 +727,11 @@ export const SecretDetailSidebar = ({
)}
/>
<FormControl>
{secretReminderRepeatDays && secretReminderRepeatDays > 0 ? (
{reminderData && reminderData.nextReminderDate ? (
<div className="flex items-center justify-between px-2">
<div className="flex items-center space-x-2">
<FontAwesomeIcon className="text-primary-500" icon={faClock} />
<span className="text-sm text-bunker-300">
Reminder every {secretReminderRepeatDays}{" "}
{secretReminderRepeatDays > 1 ? "days" : "day"}
</span>
<span className="text-sm text-bunker-300">{getDaysUntilReminder()}</span>
</div>
<div>
<Button