From 33fc968055831410c461ee6f4097c384822605e2 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 24 Oct 2023 05:16:39 +0400 Subject: [PATCH] Reminders --- backend/src/helpers/secrets.ts | 6 + .../services/SecretService/index.ts | 4 + backend/src/models/secret.ts | 19 +++ .../queues/reminders/sendSecretReminders.ts | 60 +++++++++ backend/src/validation/secrets.ts | 13 +- frontend/src/hooks/api/secrets/mutations.tsx | 4 + frontend/src/hooks/api/secrets/queries.tsx | 2 + frontend/src/hooks/api/secrets/types.ts | 6 + .../SecretListView/CreateReminderForm.tsx | 114 ++++++++++++++++++ .../components/SecretListView/SecretItem.tsx | 91 ++++++++++---- .../SecretListView/SecretListView.tsx | 13 +- .../SecretListView/SecretListView.utils.ts | 4 + 12 files changed, 310 insertions(+), 26 deletions(-) create mode 100644 backend/src/queues/reminders/sendSecretReminders.ts create mode 100644 frontend/src/views/SecretMainPage/components/SecretListView/CreateReminderForm.tsx diff --git a/backend/src/helpers/secrets.ts b/backend/src/helpers/secrets.ts index a3eb541bf..7dc702eef 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -717,6 +717,8 @@ export const updateSecretHelper = async ({ secretValueIV, secretValueTag, secretPath, + secretReminderCron, + secretReminderNote, tags, secretCommentCiphertext, secretCommentIV, @@ -781,6 +783,10 @@ export const updateSecretHelper = async ({ secretCommentIV, secretCommentTag, secretCommentCiphertext, + + secretReminderCron, + secretReminderNote, + skipMultilineEncoding, secretBlindIndex: newSecretNameBlindIndex, secretKeyIV, diff --git a/backend/src/interfaces/services/SecretService/index.ts b/backend/src/interfaces/services/SecretService/index.ts index 631114a79..172a43dea 100644 --- a/backend/src/interfaces/services/SecretService/index.ts +++ b/backend/src/interfaces/services/SecretService/index.ts @@ -58,6 +58,10 @@ export interface UpdateSecretParams { secretCommentCiphertext?: string; secretCommentIV?: string; secretCommentTag?: string; + + secretReminderCron?: string | null; + secretReminderNote?: string | null ; + skipMultilineEncoding?: boolean; tags?: string[]; } diff --git a/backend/src/models/secret.ts b/backend/src/models/secret.ts index 8592e518a..2202211d4 100644 --- a/backend/src/models/secret.ts +++ b/backend/src/models/secret.ts @@ -27,6 +27,12 @@ export interface ISecret { secretCommentIV?: string; secretCommentTag?: string; secretCommentHash?: string; + + // ? QUESTION: This works great for workspace-level reminders. + // ? If we want to do it on a user-basis, we should ideally have a seperate model for reminders. + secretReminderCron?: string | null; + secretReminderNote?: string | null; + skipMultilineEncoding?: boolean; algorithm: "aes-256-gcm"; keyEncoding: "utf8" | "base64"; @@ -118,10 +124,23 @@ const secretSchema = new Schema( type: String, required: false }, + + secretReminderCron: { + type: String, + required: false, + default: null + }, + secretReminderNote: { + type: String, + required: false, + default: null + }, + skipMultilineEncoding: { type: Boolean, required: false }, + algorithm: { // the encryption algorithm used type: String, diff --git a/backend/src/queues/reminders/sendSecretReminders.ts b/backend/src/queues/reminders/sendSecretReminders.ts new file mode 100644 index 000000000..50bede959 --- /dev/null +++ b/backend/src/queues/reminders/sendSecretReminders.ts @@ -0,0 +1,60 @@ +import Queue, { Job } from "bull"; +import { Secret, Workspace } from "../../models"; +import { Types } from "mongoose"; + + +type TSendSecretReminders = { + workspaceId: string + secretId: string + cron: string + note: string | undefined | null +} + +type TDeleteSecretReminder = { + secretId: string + cron: string +} + +export const sendSecretReminders = new Queue("send-secret-reminders", process.env.REDIS_URL as string); + +sendSecretReminders.process(async (job: Job) => { + const { workspaceId, secretId }: TSendSecretReminders = job.data + const secret = await Secret.findById(new Types.ObjectId(secretId)); + const workspace = await Workspace.findById(new Types.ObjectId(workspaceId)); + + + if(!workspace || !secret) { + throw new Error("Workspace or secret not found") + } + + + // Send email stuff here + + + +}) + +export const createSecretReminderCron = (jobDetails: TSendSecretReminders) => { + return sendSecretReminders.add(jobDetails, { + repeat: { + cron: jobDetails.cron + }, + jobId: `reminder-${jobDetails.secretId}`, + + }) +} + +export const deleteSecretReminderCron = (jobDetails: TDeleteSecretReminder) => { + + return sendSecretReminders.removeRepeatable({ + cron: jobDetails.cron, + "jobId": `reminder-${jobDetails.secretId}`, + }) +} + +export const updateSecretReminderCron = async (jobDetails: TSendSecretReminders) => { + // We need to delete the potentially existing cron job first, or the new one won't be created. + await deleteSecretReminderCron(jobDetails) + + await createSecretReminderCron(jobDetails) +} diff --git a/backend/src/validation/secrets.ts b/backend/src/validation/secrets.ts index 799e9531d..4ed23f546 100644 --- a/backend/src/validation/secrets.ts +++ b/backend/src/validation/secrets.ts @@ -10,7 +10,7 @@ import { AuthData } from "../interfaces/middleware"; import { ActorType } from "../ee/models"; import { z } from "zod"; import { SECRET_PERSONAL, SECRET_SHARED } from "../variables"; - +import { isValidCron } from "cron-validator"; /** * Validate authenticated clients for secrets with id [secretId] based * on any known permissions. @@ -260,6 +260,7 @@ export const CreateSecretRawV3 = z.object({ .string() .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())), secretComment: z.string().trim().optional().default(""), + skipMultilineEncoding: z.boolean().optional(), type: z.enum([SECRET_SHARED, SECRET_PERSONAL]) }), @@ -275,6 +276,7 @@ export const UpdateSecretByNameRawV3 = z.object({ body: z.object({ workspaceId: z.string().trim(), environment: z.string().trim(), + secretValue: z .string() .transform((val) => (val.at(-1) === "\n" ? `${val.trim()}\n` : val.trim())), @@ -360,6 +362,15 @@ export const UpdateSecretByNameV3 = z.object({ secretCommentCiphertext: z.string().trim().optional(), secretCommentIV: z.string().trim().optional(), secretCommentTag: z.string().trim().optional(), + + secretReminderCron: z + .string() + .trim() + .optional() + .nullable() + .refine((val) => val === null || (val && isValidCron(val))), + secretReminderNote: z.string().trim().nullable().optional(), + tags: z.string().array().optional(), skipMultilineEncoding: z.boolean().optional(), // to update secret name diff --git a/frontend/src/hooks/api/secrets/mutations.tsx b/frontend/src/hooks/api/secrets/mutations.tsx index a516589e5..d3f705766 100644 --- a/frontend/src/hooks/api/secrets/mutations.tsx +++ b/frontend/src/hooks/api/secrets/mutations.tsx @@ -139,6 +139,8 @@ export const useUpdateSecretV3 = ({ latestFileKey, tags, secretComment, + secretReminderCron, + secretReminderNote, newSecretName, skipMultilineEncoding }) => { @@ -157,6 +159,8 @@ export const useUpdateSecretV3 = ({ workspaceId, environment, type, + secretReminderNote, + secretReminderCron, secretPath, secretId, ...encryptSecret(randomBytes, newSecretName ?? secretName, secretValue, secretComment), diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index db000f5c8..159189375 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -69,6 +69,8 @@ export const decryptSecrets = ( value: secretValue, tags: encSecret.tags, comment: secretComment, + reminderCron: encSecret.secretReminderCron, + reminderNote: encSecret.secretReminderNote, createdAt: encSecret.createdAt, updatedAt: encSecret.updatedAt, version: encSecret.version, diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index b4c32d16d..604c604b4 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -20,6 +20,8 @@ export type EncryptedSecret = { secretCommentCiphertext: string; secretCommentIV: string; secretCommentTag: string; + secretReminderCron?: string | null; + secretReminderNote?: string | null; tags: WsTag[]; }; @@ -29,6 +31,8 @@ export type DecryptedSecret = { key: string; value: string; comment: string; + reminderCron?: string | null; + reminderNote?: string | null; tags: WsTag[]; createdAt: string; updatedAt: string; @@ -112,6 +116,8 @@ export type TUpdateSecretsV3DTO = { secretId?: string; secretValue: string; secretComment?: string; + secretReminderCron?: string | null; + secretReminderNote?: string | null; tags?: string[]; }; diff --git a/frontend/src/views/SecretMainPage/components/SecretListView/CreateReminderForm.tsx b/frontend/src/views/SecretMainPage/components/SecretListView/CreateReminderForm.tsx new file mode 100644 index 000000000..797651bc4 --- /dev/null +++ b/frontend/src/views/SecretMainPage/components/SecretListView/CreateReminderForm.tsx @@ -0,0 +1,114 @@ +import { useForm } from "react-hook-form"; +import { faClock } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { isValidCron } from "cron-validator"; +import cronstrue from "cronstrue"; +import { z } from "zod"; + +import { Button, FormControl, Input, Modal, ModalContent, TextArea } from "@app/components/v2"; + +interface ReminderFormProps { + isOpen: boolean; + onClose: (data?: {cron: string, note?: string}) => void; +} + + +const ReminderFormSchema = z.object({ + note: z.string().optional(), + cron: z.string().refine(isValidCron, {message: "Invalid cron expression"}) +}); + +type TReminderFormSchema = z.infer; + +export const CreateReminderForm = ({isOpen, onClose}: ReminderFormProps) => { + + const { + register, + watch, + handleSubmit, + formState: { errors, isSubmitting } + } = useForm({ resolver: zodResolver(ReminderFormSchema) }); + + const cronWatch = watch("cron"); + + + const handleFormSubmit = async (data: TReminderFormSchema) => { + return onClose(data); + } + + return ( + !state && onClose()} + > + + Set up a reminder for when this secret should be rotated. +
+ Format is in{" "} + {/* eslint-disable-next-line react/jsx-no-target-blank */} + + + cron format. + + +
+ + } + > +
+
+
+ + + + {!!cronWatch && isValidCron(cronWatch) && ( +
{cronstrue.toString(cronWatch)}
+ )} +
+ + +