From 93a1725da6d0a8683061c3bf5de8846a54af66c0 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 24 Oct 2023 05:15:01 +0400 Subject: [PATCH 01/15] Package --- backend/package-lock.json | 11 +++++++++++ backend/package.json | 1 + 2 files changed, 12 insertions(+) diff --git a/backend/package-lock.json b/backend/package-lock.json index 3429181a2..53745ab14 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -28,6 +28,7 @@ "bigint-conversion": "^2.4.0", "cookie-parser": "^1.4.6", "cors": "^2.8.5", + "cron-validator": "^1.3.1", "crypto-js": "^4.1.1", "dotenv": "^16.0.1", "express": "^4.18.1", @@ -7483,6 +7484,11 @@ "node": ">=12.0.0" } }, + "node_modules/cron-validator": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cron-validator/-/cron-validator-1.3.1.tgz", + "integrity": "sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A==" + }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -22651,6 +22657,11 @@ "luxon": "^3.2.1" } }, + "cron-validator": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cron-validator/-/cron-validator-1.3.1.tgz", + "integrity": "sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A==" + }, "cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index cd38aa20f..fb4433e82 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,6 +19,7 @@ "bigint-conversion": "^2.4.0", "cookie-parser": "^1.4.6", "cors": "^2.8.5", + "cron-validator": "^1.3.1", "crypto-js": "^4.1.1", "dotenv": "^16.0.1", "express": "^4.18.1", From 80ce69535502c640df611fb5c129eb1bd4ccc00b Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 24 Oct 2023 05:15:40 +0400 Subject: [PATCH 02/15] Added secret reminder handler on updates --- .../src/controllers/v3/secretsController.ts | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/backend/src/controllers/v3/secretsController.ts b/backend/src/controllers/v3/secretsController.ts index 2e0c8514a..d4f8f4674 100644 --- a/backend/src/controllers/v3/secretsController.ts +++ b/backend/src/controllers/v3/secretsController.ts @@ -32,6 +32,7 @@ import { } from "../../ee/services/SecretApprovalService"; import { CommitType } from "../../ee/models/secretApprovalRequest"; import { IRole } from "../../ee/models/role"; +import { createReminder, deleteReminder } from "../../helpers/reminder"; const checkSecretsPermission = async ({ authData, @@ -361,7 +362,14 @@ export const createSecretRaw = async (req: Request, res: Response) => { export const updateSecretByNameRaw = async (req: Request, res: Response) => { const { params: { secretName }, - body: { secretValue, environment, secretPath, type, workspaceId, skipMultilineEncoding } + body: { + secretValue, + environment, + secretPath, + type, + workspaceId, + skipMultilineEncoding, + } } = await validateRequest(reqValidator.UpdateSecretByNameRawV3, req); await checkSecretsPermission({ @@ -686,7 +694,9 @@ export const updateSecretByName = async (req: Request, res: Response) => { secretKeyIV, secretKeyTag, secretKeyCiphertext, - skipMultilineEncoding + skipMultilineEncoding, + secretReminderCron, + secretReminderNote, }, params: { secretName } } = await validateRequest(reqValidator.UpdateSecretByNameV3, req); @@ -706,6 +716,9 @@ export const updateSecretByName = async (req: Request, res: Response) => { if (membership && type !== "personal") { const secretApprovalPolicy = await getSecretPolicyOfBoard(workspaceId, environment, secretPath); if (secretApprovalPolicy) { + + // ? QUESTION + // ? Here we could also expand upon the reminders feature, by adding it to the approval process. const secretApprovalRequest = await generateSecretApprovalRequest({ workspaceId, environment, @@ -737,6 +750,32 @@ export const updateSecretByName = async (req: Request, res: Response) => { } } + if(type !== "personal") { + const existingSecret = await SecretService.getSecret({ + secretName, + workspaceId: new Types.ObjectId(workspaceId), + environment, + type, + secretPath, + authData: req.authData + }); + + if((secretReminderCron && existingSecret.secretReminderCron !== secretReminderCron) || (secretReminderNote && existingSecret.secretReminderNote !== secretReminderNote)) { + await createReminder(existingSecret, { + _id: existingSecret._id, + secretReminderCron, + secretReminderNote, + workspace: existingSecret.workspace, + }) + } else if(secretReminderCron === null && secretReminderNote === null && existingSecret.secretReminderCron) { + await deleteReminder({ + _id: existingSecret._id, + secretReminderCron: existingSecret.secretReminderCron, + }) + } + } + + const secret = await SecretService.updateSecret({ secretName, workspaceId: new Types.ObjectId(workspaceId), @@ -747,6 +786,8 @@ export const updateSecretByName = async (req: Request, res: Response) => { newSecretName, secretValueCiphertext, secretValueIV, + secretReminderCron, + secretReminderNote, secretValueTag, secretPath, tags, From 96db649cbc49b8882cd7f7e250420645992b51b5 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 24 Oct 2023 05:16:04 +0400 Subject: [PATCH 03/15] Reminder helpers for creating and deleting crons --- backend/src/helpers/reminder.ts | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 backend/src/helpers/reminder.ts diff --git a/backend/src/helpers/reminder.ts b/backend/src/helpers/reminder.ts new file mode 100644 index 000000000..ba4cb9fed --- /dev/null +++ b/backend/src/helpers/reminder.ts @@ -0,0 +1,55 @@ + +import { ISecret } from "../models" +import { createSecretReminderCron, deleteSecretReminderCron, updateSecretReminderCron } from "../queues/reminders/sendSecretReminders" + + +type TPartialSecret = Pick +type TPartialSecretDeleteReminder = Pick + + + +export const createReminder = async (oldSecret: TPartialSecret, newSecret: TPartialSecret) => { + if(oldSecret._id !== newSecret._id) { + throw new Error("Secret id's don't match") + } + + if(!newSecret.secretReminderCron) { + throw new Error("No cron provided") + } + + const secretId = oldSecret._id.toString() + const workspaceId = oldSecret.workspace.toString() + + if(oldSecret.secretReminderCron) { + // This will first delete the existing cron job, and then create a new one. + await updateSecretReminderCron({ + workspaceId, + secretId, + cron: newSecret.secretReminderCron, + note: newSecret.secretReminderNote + }) + } else { + // This will create a new cron job. + await createSecretReminderCron({ + workspaceId, + secretId, + cron: newSecret.secretReminderCron, + note: newSecret.secretReminderNote + }) + } +} + +export const deleteReminder = async (secret: TPartialSecretDeleteReminder) => { + if(!secret._id) { + throw new Error("No secret id provided") + } + + if(!secret.secretReminderCron) { + throw new Error("No cron provided") + } + + await deleteSecretReminderCron({ + secretId: secret._id.toString(), + cron: secret.secretReminderCron, + }) +} \ No newline at end of file From b97dc7f599be38fbc29ac07aa4edae57f90cc0e4 Mon Sep 17 00:00:00 2001 From: Daniel Hougaard <62331820+DanielHougaard@users.noreply.github.com> Date: Tue, 24 Oct 2023 05:16:19 +0400 Subject: [PATCH 04/15] Packages --- frontend/package-lock.json | 36 ++++++++++++++++++++++++++++++++++++ frontend/package.json | 3 +++ 2 files changed, 39 insertions(+) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6a8026255..a48fa7db2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -47,8 +47,11 @@ "axios": "^0.27.2", "axios-auth-refresh": "^3.3.6", "base64-loader": "^1.0.0", + "chrono": "^1.0.5", "classnames": "^2.3.1", "cookies": "^0.8.0", + "cron-validator": "^1.3.1", + "cronstrue": "^2.41.0", "cva": "npm:class-variance-authority@^0.4.0", "date-fns": "^2.30.0", "file-saver": "^2.0.5", @@ -10568,6 +10571,11 @@ "node": ">=6.0" } }, + "node_modules/chrono": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/chrono/-/chrono-1.0.5.tgz", + "integrity": "sha512-gjmpLxZa8iRaQXpV/sEV3ZkDuvwg0FqM1YrhaxdybN/DQJyfs1fG6UkJXkkOKLk/Hb712hsVV5EGR8f8A5sBdA==" + }, "node_modules/ci-info": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", @@ -11136,6 +11144,19 @@ "sha.js": "^2.4.8" } }, + "node_modules/cron-validator": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cron-validator/-/cron-validator-1.3.1.tgz", + "integrity": "sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A==" + }, + "node_modules/cronstrue": { + "version": "2.41.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.41.0.tgz", + "integrity": "sha512-3ZS3eMJaxMRBGmDauKCKbyIRgVcph6uSpkhSbbZvvJWkelHiSTzGJbBqmu8io7Hspd2F45bQKnC1kzoNvtku2g==", + "bin": { + "cronstrue": "bin/cli.js" + } + }, "node_modules/cross-fetch": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.6.tgz", @@ -31194,6 +31215,11 @@ "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==", "dev": true }, + "chrono": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/chrono/-/chrono-1.0.5.tgz", + "integrity": "sha512-gjmpLxZa8iRaQXpV/sEV3ZkDuvwg0FqM1YrhaxdybN/DQJyfs1fG6UkJXkkOKLk/Hb712hsVV5EGR8f8A5sBdA==" + }, "ci-info": { "version": "3.8.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.8.0.tgz", @@ -31664,6 +31690,16 @@ "sha.js": "^2.4.8" } }, + "cron-validator": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/cron-validator/-/cron-validator-1.3.1.tgz", + "integrity": "sha512-C1HsxuPCY/5opR55G5/WNzyEGDWFVG+6GLrA+fW/sCTcP6A6NTjUP2AK7B8n2PyFs90kDG2qzwm8LMheADku6A==" + }, + "cronstrue": { + "version": "2.41.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.41.0.tgz", + "integrity": "sha512-3ZS3eMJaxMRBGmDauKCKbyIRgVcph6uSpkhSbbZvvJWkelHiSTzGJbBqmu8io7Hspd2F45bQKnC1kzoNvtku2g==" + }, "cross-fetch": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.1.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3fe6b617a..0cc4cfb38 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,8 +55,11 @@ "axios": "^0.27.2", "axios-auth-refresh": "^3.3.6", "base64-loader": "^1.0.0", + "chrono": "^1.0.5", "classnames": "^2.3.1", "cookies": "^0.8.0", + "cron-validator": "^1.3.1", + "cronstrue": "^2.41.0", "cva": "npm:class-variance-authority@^0.4.0", "date-fns": "^2.30.0", "file-saver": "^2.0.5", From 6df678067c26253766169888a7a9b820c9627f30 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 05/15] 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 82a98d1e9..f1be8e8c4 100644 --- a/backend/src/helpers/secrets.ts +++ b/backend/src/helpers/secrets.ts @@ -802,6 +802,8 @@ export const updateSecretHelper = async ({ secretValueIV, secretValueTag, secretPath, + secretReminderCron, + secretReminderNote, tags, secretCommentCiphertext, secretCommentIV, @@ -866,6 +868,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 a2d9c5bb5..e6f9e1673 100644 --- a/backend/src/interfaces/services/SecretService/index.ts +++ b/backend/src/interfaces/services/SecretService/index.ts @@ -59,6 +59,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 174ffa791..cf80fbcf0 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. @@ -261,6 +261,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]) }), @@ -276,6 +277,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())), @@ -362,6 +364,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)}
+ )} +
+ + +