Reminders

This commit is contained in:
Daniel Hougaard
2023-10-24 05:16:39 +04:00
parent f289b99cf1
commit 33fc968055
12 changed files with 310 additions and 26 deletions

View File

@@ -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,

View File

@@ -58,6 +58,10 @@ export interface UpdateSecretParams {
secretCommentCiphertext?: string;
secretCommentIV?: string;
secretCommentTag?: string;
secretReminderCron?: string | null;
secretReminderNote?: string | null ;
skipMultilineEncoding?: boolean;
tags?: string[];
}

View File

@@ -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<ISecret>(
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,

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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),

View File

@@ -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,

View File

@@ -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[];
};

View File

@@ -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<typeof ReminderFormSchema>;
export const CreateReminderForm = ({isOpen, onClose}: ReminderFormProps) => {
const {
register,
watch,
handleSubmit,
formState: { errors, isSubmitting }
} = useForm<TReminderFormSchema>({ resolver: zodResolver(ReminderFormSchema) });
const cronWatch = watch("cron");
const handleFormSubmit = async (data: TReminderFormSchema) => {
return onClose(data);
}
return (
<Modal
isOpen={isOpen}
onOpenChange={(state) => !state && onClose()}
>
<ModalContent
title="Create secret reminder"
// ? QUESTION: Should this specifically say its for secret rotation?
// ? Or should we be call it something more generic?
subTitle={
<div>
Set up a reminder for when this secret should be rotated.
<div>
Format is in{" "}
{/* eslint-disable-next-line react/jsx-no-target-blank */}
<a target='_blank' href="https://crontab.guru/every-month">
<span className="text-primary-400 hover:text-primary-500 cursor-pointer">
cron format.
</span>
</a>
</div>
</div>
}
>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="space-y-4">
<div>
<FormControl className="mb-0" label="How often" isError={Boolean(errors?.cron)} errorText={errors?.cron?.message}>
<Input
{...register("cron")}
placeholder="0 0 1 * *"
/>
</FormControl>
{!!cronWatch && isValidCron(cronWatch) && (
<div className="text-xs opacity-60 mt-2 ml-1">{cronstrue.toString(cronWatch)}</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}
reSize='none'
cols={30}
{...register("note")}
/>
</FormControl>
</div>
<div className="mt-7 flex items-center">
<Button
isDisabled={isSubmitting}
isLoading={isSubmitting}
key="layout-create-project-submit"
className="mr-4"
leftIcon={<FontAwesomeIcon icon={faClock}/>}
type="submit"
>
Create reminder
</Button>
<Button
key="layout-cancel-create-project"
onClick={() => onClose()}
variant="plain"
colorSchema="secondary"
>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
}

View File

@@ -1,9 +1,11 @@
/* eslint-disable simple-import-sort/imports */
import { memo, useEffect } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { subject } from "@casl/ability";
import { faCheckCircle } from "@fortawesome/free-regular-svg-icons";
import {
faCheck,
faClock,
faClose,
faCodeBranch,
faComment,
@@ -17,7 +19,6 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { AnimatePresence, motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { ProjectPermissionCan } from "@app/components/permissions";
import {
Button,
@@ -49,6 +50,8 @@ import { DecryptedSecret } from "@app/hooks/api/secrets/types";
import { WsTag } from "@app/hooks/api/types";
import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils";
import { CreateReminderForm } from "./CreateReminderForm";
type Props = {
secret: DecryptedSecret;
@@ -68,6 +71,8 @@ type Props = {
secretPath: string;
};
export const SecretItem = memo(
({
secret,
@@ -111,6 +116,7 @@ export const SecretItem = memo(
const overrideAction = watch("overrideAction");
const hasComment = Boolean(watch("comment"));
const hasReminder = Boolean(watch("reminderCron"));
const selectedTags = watch("tags", []);
const selectedTagsGroupById = selectedTags.reduce<Record<string, boolean>>(
@@ -123,6 +129,7 @@ export const SecretItem = memo(
});
const [isSecValueCopied, setIsSecValueCopied] = useToggle(false);
const [createReminderFormOpen, setCreateReminderFormOpen] = useToggle(false);
useEffect(() => {
let timer: NodeJS.Timeout;
if (isSecValueCopied) {
@@ -181,17 +188,30 @@ export const SecretItem = memo(
};
return (
<>
<CreateReminderForm
isOpen={createReminderFormOpen}
onClose={data => {
if(data) {
setValue("reminderCron", data.cron, {shouldDirty: true});
setValue("reminderNote", data.note, {shouldDirty: true});
}
setCreateReminderFormOpen.off();
}}
/>
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div
className={twMerge(
"shadow-none border-b border-mineshaft-600 bg-mineshaft-800 hover:bg-mineshaft-700",
"border-b border-mineshaft-600 bg-mineshaft-800 shadow-none hover:bg-mineshaft-700",
isDirty && "border-primary-400/50"
)}
>
<div className="flex group">
<div className="group flex">
<div
className={twMerge(
"flex items-center justify-center w-11 px-4 py-3 h-11",
"flex h-11 w-11 items-center justify-center px-4 py-3",
isDirty && "text-primary"
)}
>
@@ -199,14 +219,14 @@ export const SecretItem = memo(
id={`checkbox-${secret._id}`}
isChecked={isSelected}
onCheckedChange={() => onToggleSecretSelect(secret._id)}
className={twMerge("group-hover:flex hidden ml-3", isSelected && "flex")}
className={twMerge("ml-3 hidden group-hover:flex", isSelected && "flex")}
/>
<FontAwesomeIcon
icon={faKey}
className={twMerge("group-hover:hidden block ml-3", isSelected && "hidden")}
className={twMerge("ml-3 block group-hover:hidden", isSelected && "hidden")}
/>
</div>
<div className="w-80 h-11 flex items-center px-4 py-2 flex-shrink-0">
<div className="flex h-11 w-80 flex-shrink-0 items-center px-4 py-2">
<Controller
name="key"
control={control}
@@ -218,13 +238,13 @@ export const SecretItem = memo(
variant="plain"
isDisabled={isOverriden}
{...field}
className="w-full focus:text-bunker-100 focus:ring-transparent px-0"
className="w-full px-0 focus:text-bunker-100 focus:ring-transparent"
/>
)}
/>
</div>
<div
className="flex-grow flex items-center border-x border-mineshaft-600 pl-4 pr-2 py-1"
className="flex flex-grow items-center border-x border-mineshaft-600 py-1 pl-4 pr-2"
tabIndex={0}
role="button"
>
@@ -259,13 +279,13 @@ export const SecretItem = memo(
)}
/>
)}
<div key="actions" className="h-8 flex self-start flex-shrink-0 transition-all">
<div key="actions" className="flex h-8 flex-shrink-0 self-start transition-all">
<Tooltip content="Copy secret">
<IconButton
ariaLabel="copy-value"
variant="plain"
size="sm"
className="w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0"
className="w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5"
onClick={copyTokenToClipboard}
>
<FontAwesomeIcon icon={isSecValueCopied ? faCheck : faCopy} />
@@ -283,7 +303,7 @@ export const SecretItem = memo(
variant="plain"
size="sm"
className={twMerge(
"w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0 data-[state=open]:w-5",
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-5",
hasTagsApplied && "w-5 text-primary"
)}
isDisabled={!isAllowed}
@@ -310,7 +330,7 @@ export const SecretItem = memo(
>
<div className="flex items-center">
<div
className="w-2 h-2 rounded-full mr-2"
className="mr-2 h-2 w-2 rounded-full"
style={{ background: tagColor || "#bec2c8" }}
/>
{name}
@@ -346,7 +366,7 @@ export const SecretItem = memo(
size="sm"
onClick={handleOverrideClick}
className={twMerge(
"w-0 group-hover:w-5 group-hover:mr-2 overflow-hidden p-0",
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5",
isOverriden && "w-5 text-primary"
)}
>
@@ -354,6 +374,32 @@ export const SecretItem = memo(
</IconButton>
)}
</ProjectPermissionCan>
<IconButton
className={twMerge(
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6",
hasReminder && "w-5 text-primary"
)}
variant="plain"
size="md"
ariaLabel="add-reminder"
>
<Tooltip content="Reminder">
<FontAwesomeIcon
onClick={() => {
if(!hasReminder) {
setCreateReminderFormOpen.on();
}
else {
setValue("reminderCron", null, {shouldDirty: true});
setValue("reminderNote", null, {shouldDirty: true});
}
}}
icon={faClock} />
</Tooltip>
</IconButton>
<Popover>
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
@@ -363,7 +409,7 @@ export const SecretItem = memo(
<PopoverTrigger asChild disabled={!isAllowed}>
<IconButton
className={twMerge(
"overflow-hidden w-0 p-0 group-hover:w-5 group-hover:mr-2 data-[state=open]:w-6",
"w-0 overflow-hidden p-0 group-hover:mr-2 group-hover:w-5 data-[state=open]:w-6",
hasComment && "w-5 text-primary"
)}
variant="plain"
@@ -398,7 +444,7 @@ export const SecretItem = memo(
{!isDirty ? (
<motion.div
key="options"
className="h-10 flex items-center space-x-4 flex-shrink-0 px-3"
className="flex h-10 flex-shrink-0 items-center space-x-4 px-3"
initial={{ x: 0, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 10, opacity: 0 }}
@@ -408,7 +454,7 @@ export const SecretItem = memo(
ariaLabel="more"
variant="plain"
size="md"
className="group-hover:opacity-100 opacity-0 p-0"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => onDetailViewSecret(secret)}
>
<FontAwesomeIcon icon={faEllipsis} size="lg" />
@@ -426,7 +472,7 @@ export const SecretItem = memo(
variant="plain"
colorSchema="danger"
size="md"
className="group-hover:opacity-100 opacity-0 p-0"
className="p-0 opacity-0 group-hover:opacity-100"
onClick={() => onDeleteSecret(secret)}
isDisabled={!isAllowed}
>
@@ -438,7 +484,7 @@ export const SecretItem = memo(
) : (
<motion.div
key="options-save"
className="h-10 flex items-center space-x-4 flex-shrink-0 px-3"
className="flex h-10 flex-shrink-0 items-center space-x-4 px-3"
initial={{ x: -10, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: -10, opacity: 0 }}
@@ -450,13 +496,13 @@ export const SecretItem = memo(
type="submit"
size="md"
className={twMerge(
"group-hover:opacity-100 opacity-0 p-0 text-primary",
"p-0 text-primary opacity-0 group-hover:opacity-100",
isDirty && "opacity-100"
)}
isDisabled={isSubmitting}
>
{isSubmitting ? (
<Spinner className="w-4 h-4 p-0 m-0" />
<Spinner className="m-0 h-4 w-4 p-0" />
) : (
<FontAwesomeIcon icon={faCheck} size="lg" className="text-primary" />
)}
@@ -468,7 +514,7 @@ export const SecretItem = memo(
variant="plain"
size="md"
className={twMerge(
"group-hover:opacity-100 opacity-0 p-0",
"p-0 opacity-0 group-hover:opacity-100",
isDirty && "opacity-100"
)}
onClick={() => reset()}
@@ -483,6 +529,7 @@ export const SecretItem = memo(
</div>
</div>
</form>
</>
);
}
);

View File

@@ -122,6 +122,8 @@ export const SecretListView = ({
{
value,
comment,
reminderCron,
reminderNote,
tags,
skipMultilineEncoding,
newKey,
@@ -129,6 +131,8 @@ export const SecretListView = ({
}: Partial<{
value: string;
comment: string;
reminderCron: string | null;
reminderNote: string | null;
tags: string[];
skipMultilineEncoding: boolean;
newKey: string;
@@ -159,6 +163,8 @@ export const SecretListView = ({
latestFileKey: decryptFileKey,
tags,
secretComment: comment,
secretReminderCron: reminderCron,
secretReminderNote: reminderNote,
skipMultilineEncoding,
newSecretName: newKey
});
@@ -188,14 +194,14 @@ export const SecretListView = ({
cb?: () => void
) => {
const { key: oldKey } = orgSecret;
const { key, value, overrideAction, idOverride, valueOverride, tags, comment } = modSecret;
const { key, value, overrideAction, idOverride, valueOverride, tags, comment, reminderCron, reminderNote } = modSecret;
const hasKeyChanged = oldKey !== key;
const tagIds = tags.map(({ _id }) => _id);
const oldTagIds = orgSecret.tags.map(({ _id }) => _id);
const isSameTags = JSON.stringify(tagIds) === JSON.stringify(oldTagIds);
const isSharedSecUnchanged =
(["key", "value", "comment", "skipMultilineEncoding"] as const).every(
(["key", "value", "comment", "skipMultilineEncoding", "reminderCron", "reminderNote"] as const).every(
(el) => orgSecret[el] === modSecret[el]
) && isSameTags;
@@ -222,13 +228,14 @@ export const SecretListView = ({
value,
tags: tagIds,
comment,
reminderCron,
reminderNote,
secretId: orgSecret._id,
newKey: hasKeyChanged ? key : undefined,
skipMultilineEncoding: modSecret.skipMultilineEncoding
});
if (cb) cb();
}
queryClient.invalidateQueries(
secretKeys.getProjectSecret({ workspaceId, environment, secretPath })
);

View File

@@ -20,6 +20,10 @@ export const formSchema = z.object({
overrideAction: z.string().trim().optional(),
comment: z.string().trim().optional(),
skipMultilineEncoding: z.boolean().optional(),
reminderCron: z.string().trim().nullable().optional(),
reminderNote: z.string().trim().nullable().optional(),
tags: z
.object({
_id: z.string(),