Merge pull request #3926 from Infisical/feat/remindersImprovement

feat(secret-reminders): rework secret reminders logic
This commit is contained in:
carlosmonastyrski
2025-07-23 16:07:04 -03:00
committed by GitHub
37 changed files with 1885 additions and 532 deletions
@@ -20,6 +20,7 @@ export type DatePickerProps = Omit<DayPickerProps, "selected"> & {
popUpProps: PopoverProps;
popUpContentProps: PopoverContentProps;
dateFormat?: "PPP" | "PP" | "P"; // extend as needed
hideTime?: boolean;
buttonClassName?: string;
timezone?: Timezone;
};
@@ -55,6 +56,7 @@ export const DatePicker = ({
popUpProps,
popUpContentProps,
dateFormat = "PPP",
hideTime = false,
buttonClassName,
timezone,
...props
@@ -124,14 +126,16 @@ export const DatePicker = ({
}}
/>
</div>
<div className="mx-4 my-4">
<Input
type="time"
value={displayUtc ? localTimeToUTC(timeValue) : timeValue}
onChange={handleTimeChange}
className="bg-mineshaft-700 text-white [color-scheme:dark]"
/>
</div>
{!hideTime && (
<div className="mx-4 my-4">
<Input
type="time"
value={displayUtc ? localTimeToUTC(timeValue) : timeValue}
onChange={handleTimeChange}
className="bg-mineshaft-700 text-white [color-scheme:dark]"
/>
</div>
)}
</PopoverContent>
</Popover>
);
@@ -0,0 +1 @@
export { useCreateReminder, useDeleteReminder, useGetReminder } from "./queries";
@@ -0,0 +1,60 @@
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) => ["get-reminder", secretId] as const
};
export const useCreateReminder = (secretId: 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}`,
{
message,
repeatDays,
nextReminderDate,
recipients
}
);
return data.reminder;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: reminderKeys.getReminder(secretId) });
}
});
};
export const useDeleteReminder = (secretId: string) => {
const queryClient = useQueryClient();
return useMutation<Reminder, object, DeleteReminderDTO>({
mutationFn: async () => {
const { data } = await apiRequest.delete<{ reminder: Reminder }>(
`/api/v1/reminders/secrets/${secretId}`
);
return data.reminder;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: reminderKeys.getReminder(secretId) });
}
});
};
export const useGetReminder = (secretId: string) => {
return useQuery({
queryKey: reminderKeys.getReminder(secretId),
queryFn: async () => {
const { data } = await apiRequest.get<{ reminder: Reminder }>(
`/api/v1/reminders/secrets/${secretId}`
);
return data.reminder;
},
enabled: Boolean(secretId)
});
};
+14
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;
@@ -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);
const { mutateAsync: deleteReminder } = useDeleteReminder(secretId);
// 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)
});
};
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>
@@ -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";
@@ -56,6 +56,7 @@ import { getProjectBaseURL } from "@app/helpers/project";
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";
@@ -115,6 +116,7 @@ export const SecretDetailSidebar = ({
const { permission } = useProjectPermission();
const { currentWorkspace } = useWorkspace();
const { data: reminderData } = useGetReminder(secret?.id);
const tagFields = useFieldArray({
control,
@@ -221,23 +223,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",
@@ -303,27 +290,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}
@@ -735,14 +728,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