diff --git a/backend/src/server/routes/v1/dashboard-router.ts b/backend/src/server/routes/v1/dashboard-router.ts index 029d4a848..48d536c14 100644 --- a/backend/src/server/routes/v1/dashboard-router.ts +++ b/backend/src/server/routes/v1/dashboard-router.ts @@ -2,6 +2,7 @@ import { ForbiddenError } from "@casl/ability"; import { z } from "zod"; import { SecretFoldersSchema, SecretImportsSchema, UsersSchema } from "@app/db/schemas"; +import { RemindersSchema } from "@app/db/schemas/reminders"; import { EventType, UserAgentType } from "@app/ee/services/audit-log/audit-log-types"; import { ProjectPermissionSecretActions } from "@app/ee/services/permission/project-permission"; import { SecretRotationV2Schema } from "@app/ee/services/secret-rotation-v2/secret-rotation-v2-union-schema"; @@ -628,7 +629,10 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { secretValueHidden: z.boolean(), secretPath: z.string().optional(), secretMetadata: ResourceMetadataSchema.optional(), - tags: SanitizedTagSchema.array().optional() + tags: SanitizedTagSchema.array().optional(), + reminder: RemindersSchema.extend({ + recipients: z.string().array().optional() + }).nullish() }) .array() .optional(), @@ -706,7 +710,11 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { let imports: Awaited> | undefined; let folders: Awaited> | undefined; - let secrets: Awaited>["secrets"] | undefined; + let secrets: + | (Awaited>["secrets"][number] & { + reminder: Awaited>[string] | null; + })[] + | undefined; let dynamicSecrets: Awaited> | undefined; let secretRotations: | Awaited> @@ -904,7 +912,7 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { }); if (remainingLimit > 0 && totalSecretCount > adjustedOffset) { - secrets = ( + const rawSecrets = ( await server.services.secret.getSecretsRaw({ actorId: req.permission.id, actor: req.permission.type, @@ -925,6 +933,15 @@ export const registerDashboardRouter = async (server: FastifyZodProvider) => { includeMetadataInSearch: true }) ).secrets; + + const reminders = await server.services.reminder.getRemindersForDashboard( + rawSecrets.map((secret) => secret.id) + ); + + secrets = rawSecrets.map((secret) => ({ + ...secret, + reminder: reminders[secret.id] ?? null + })); } } } catch (error) { diff --git a/backend/src/services/reminder/reminder-dal.ts b/backend/src/services/reminder/reminder-dal.ts index 0e5d7b895..897a75234 100644 --- a/backend/src/services/reminder/reminder-dal.ts +++ b/backend/src/services/reminder/reminder-dal.ts @@ -124,10 +124,35 @@ export const reminderDALFactory = (db: TDbClient) => { return reminders[0] || null; }; + const findSecretReminders = async (secretIds: string[], tx?: Knex) => { + const rawReminders = await (tx || db)(TableName.Reminder) + .whereIn(`${TableName.Reminder}.secretId`, secretIds) + .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; + }; + return { ...reminderOrm, findSecretDailyReminders, findUpcomingReminders, - findSecretReminder + findSecretReminder, + findSecretReminders }; }; diff --git a/backend/src/services/reminder/reminder-service.ts b/backend/src/services/reminder/reminder-service.ts index 181f0cdb9..86788c249 100644 --- a/backend/src/services/reminder/reminder-service.ts +++ b/backend/src/services/reminder/reminder-service.ts @@ -372,6 +372,21 @@ export const reminderServiceFactory = ({ }; }; + const getRemindersForDashboard: TReminderServiceFactory["getRemindersForDashboard"] = async (secretIds) => { + // scott we don't need to check permissions/secret existence because these are the + // secrets from the dashboard that have already gone through these checks + + const reminders = await reminderDAL.findSecretReminders(secretIds); + + const reminderMap: Record = {}; + + reminders.forEach((reminder) => { + if (reminder.secretId) reminderMap[reminder.secretId] = reminder; + }); + + return reminderMap; + }; + return { createReminder, getReminder, @@ -379,6 +394,7 @@ export const reminderServiceFactory = ({ deleteReminder, deleteReminderBySecretId, batchCreateReminders, - createReminderInternal + createReminderInternal, + getRemindersForDashboard }; }; diff --git a/backend/src/services/reminder/reminder-types.ts b/backend/src/services/reminder/reminder-types.ts index 54726ab66..078e049b5 100644 --- a/backend/src/services/reminder/reminder-types.ts +++ b/backend/src/services/reminder/reminder-types.ts @@ -103,4 +103,6 @@ export interface TReminderServiceFactory { id: string; created: boolean; }>; + + getRemindersForDashboard: (secretIds: string[]) => Promise>; } diff --git a/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx b/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx index b8724f5dc..a9542c90c 100644 --- a/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx +++ b/frontend/src/components/secrets/SecretReferenceDetails/SecretReferenceDetails.tsx @@ -125,7 +125,7 @@ export const SecretReferenceTree = ({ secretPath, environment, secretKey }: Prop if (isPending) { return (
- +
); } diff --git a/frontend/src/components/v2/Drawer/Drawer.tsx b/frontend/src/components/v2/Drawer/Drawer.tsx index e74995333..7ebbe1350 100644 --- a/frontend/src/components/v2/Drawer/Drawer.tsx +++ b/frontend/src/components/v2/Drawer/Drawer.tsx @@ -13,6 +13,7 @@ export type DrawerContentProps = DialogPrimitive.DialogContentProps & { subTitle?: ReactNode; footerContent?: ReactNode; onClose?: () => void; + cardBodyClassName?: string; } & VariantProps; const drawerContentVariation = cva( @@ -32,7 +33,17 @@ const drawerContentVariation = cva( export const DrawerContent = forwardRef( ( - { children, title, subTitle, className, footerContent, direction = "right", onClose, ...props }, + { + children, + title, + subTitle, + className, + footerContent, + direction = "right", + onClose, + cardBodyClassName, + ...props + }, forwardedRef ) => ( @@ -47,11 +58,16 @@ export const DrawerContent = forwardRef( > {title && ( - + {title} )} - + {children} {footerContent && {footerContent}}{" "} diff --git a/frontend/src/hooks/api/secrets/queries.tsx b/frontend/src/hooks/api/secrets/queries.tsx index 10796c6e2..443b74df8 100644 --- a/frontend/src/hooks/api/secrets/queries.tsx +++ b/frontend/src/hooks/api/secrets/queries.tsx @@ -88,7 +88,8 @@ export const mergePersonalSecrets = (rawSecrets: SecretV3Raw[]) => { path: el.secretPath, secretMetadata: el.secretMetadata, isRotatedSecret: el.isRotatedSecret, - rotationId: el.rotationId + rotationId: el.rotationId, + reminder: el.reminder }; if (el.type === SecretType.Personal) { diff --git a/frontend/src/hooks/api/secrets/types.ts b/frontend/src/hooks/api/secrets/types.ts index 29da8e578..1b232cfac 100644 --- a/frontend/src/hooks/api/secrets/types.ts +++ b/frontend/src/hooks/api/secrets/types.ts @@ -1,4 +1,5 @@ import { ProjectPermissionActions } from "@app/context"; +import { Reminder } from "@app/hooks/api/reminders/types"; import { PendingAction } from "../secretFolders/types"; import type { WsTag } from "../tags/types"; @@ -69,6 +70,7 @@ export type SecretV3RawSanitized = { rotationId?: string; isPending?: boolean; pendingAction?: PendingAction; + reminder?: Reminder; }; export type SecretV3Raw = { @@ -94,6 +96,7 @@ export type SecretV3Raw = { isRotatedSecret?: boolean; rotationId?: string; secretReminderRecipients?: SecretReminderRecipient[]; + reminder?: Reminder; }; export type SecretV3RawResponse = { diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx index 92565b7ad..d3845fcbc 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretListView/SecretDetailSidebar.tsx @@ -1,15 +1,16 @@ -import { useEffect, useMemo } from "react"; +import { useEffect } from "react"; import { Controller, useFieldArray, useForm } from "react-hook-form"; import { subject } from "@casl/ability"; import { faCircleQuestion, faEye } from "@fortawesome/free-regular-svg-icons"; import { faArrowRotateRight, faCheckCircle, - faClock, faCopy, faDesktop, faEyeSlash, faPlus, + faProjectDiagram, + faSearch, faServer, faShare, faTag, @@ -26,6 +27,7 @@ import { twMerge } from "tailwind-merge"; import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal"; import { createNotification } from "@app/components/notifications"; import { ProjectPermissionCan } from "@app/components/permissions"; +import { hasSecretReference, SecretReferenceTree } from "@app/components/secrets/SecretReferenceDetails"; import { Button, Drawer, @@ -39,30 +41,26 @@ import { FormLabel, IconButton, Input, + Modal, + ModalContent, Switch, Tag, TextArea, Tooltip } from "@app/components/v2"; import { InfisicalSecretInput } from "@app/components/v2/InfisicalSecretInput"; -import { - ProjectPermissionActions, - ProjectPermissionSub, - useProjectPermission, - useWorkspace -} from "@app/context"; +import { ProjectPermissionActions, ProjectPermissionSub, useProjectPermission, useWorkspace } from "@app/context"; + import { ProjectPermissionSecretActions } from "@app/context/ProjectPermissionContext/types"; import { getProjectBaseURL } from "@app/helpers/project"; -import { usePopUp, useToggle } from "@app/hooks"; +import { usePopUp } 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 { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; import { camelCaseToSpaces } from "@app/lib/fn/string"; -import { CreateReminderForm } from "./CreateReminderForm"; import { HIDDEN_SECRET_VALUE } from "./SecretItem"; import { formSchema, SecretActionType, TFormSchema } from "./SecretListView.utils"; @@ -102,7 +100,7 @@ export const SecretDetailSidebar = ({ handleSubmit, setValue, reset, - formState: { isDirty, isSubmitting } + formState: { isDirty } } = useForm({ resolver: zodResolver(formSchema), values: secret, @@ -110,12 +108,12 @@ export const SecretDetailSidebar = ({ }); const { handlePopUpToggle, popUp, handlePopUpOpen } = usePopUp([ - "secretAccessUpgradePlan" + "secretAccessUpgradePlan", + "secretReferenceTree" ] as const); const { permission } = useProjectPermission(); const { currentWorkspace } = useWorkspace(); - const { data: reminderData } = useGetReminder(secret?.id); const tagFields = useFieldArray({ control, @@ -188,25 +186,6 @@ export const SecretDetailSidebar = ({ secretKey }); - const handleOverrideClick = () => { - if (isOverridden) { - // override need not be flagged delete if it was never saved in server - // meaning a new unsaved personal secret but user toggled back later - const isUnsavedOverride = !secret.idOverride; - setValue( - "overrideAction", - isUnsavedOverride ? secret?.overrideAction : SecretActionType.Deleted, - { - shouldDirty: !isUnsavedOverride - } - ); - setValue("valueOverride", secret?.valueOverride, { shouldDirty: !isUnsavedOverride }); - } else { - setValue("overrideAction", SecretActionType.Modified, { shouldDirty: true }); - setValue("valueOverride", "", { shouldDirty: true }); - } - }; - const handleTagSelect = (tag: WsTag) => { if (selectedTagsGroupById?.[tag.id]) { const tagPos = selectedTags.findIndex(({ id }) => id === tag.id); @@ -222,8 +201,6 @@ export const SecretDetailSidebar = ({ await onSaveSecret(secret, { ...secret, ...data }, () => reset()); }; - const [createReminderFormOpen, setCreateReminderFormOpen] = useToggle(false); - useEffect(() => { setValue( "reminderRecipients", @@ -289,34 +266,8 @@ 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 ( <> - { - setCreateReminderFormOpen.toggle(); - }} - workspaceId={currentWorkspace.id} - environment={environment} - secretPath={secretPath} - secretId={secret?.id} - reminder={reminderData} - /> @@ -324,28 +275,125 @@ export const SecretDetailSidebar = ({ } text="Secret access analysis is only available on Infisical's Pro plan and above." /> + + handlePopUpToggle("secretReferenceTree", isSecretRefOpen) + } + > + e.preventDefault()} + > + + + { + onOpenChange={async (state) => { if (isOpen && isDirty) { - if ( - // eslint-disable-next-line no-alert - window.confirm( - "You have edited the secret. Are you sure you want to reset the change?" - ) - ) { - onToggle(false); - reset(); - } else return; + await handleSubmit(handleFormSubmit)(); } onToggle(state); }} isOpen={isOpen} > - -
-
-
-
+ + + + Changes will automatically be applied for commit + + + {(isAllowed) => ( + ( + + + + The value of this secret is hidden because you do not have the read + secret value permission. + +
+ ) : undefined + } + label="Value" + > +
+ + + + +
+ + )} + /> + )} + +
+ ( {(isAllowed) => ( - ( -
- - - - The value of this secret is hidden because you do not have the - read secret value permission. - -
- ) : undefined - } - label="Value" - > -
- - - - -
- -
- )} - /> +
+ + Multi-line encoding + + + + + onChange(isChecked)} + isChecked={value} + onBlur={onBlur} + isDisabled={!isAllowed} + className="items-center justify-between" + /> +
)} -
-
-
-
- ( - - {(isAllowed) => ( -
- - Multi-line encoding - - - - - onChange(isChecked)} - isChecked={value} - onBlur={onBlur} - isDisabled={!isAllowed} - className="items-center justify-between" - /> -
- )} -
- )} - /> -
-
- - {(isAllowed) => ( -
- - Override with a personal value - - - - - -
- )} -
- {isOverridden && ( - ( - - - - )} - /> - )} -
-
-
0 ? "flex-col" : "flex-row"}`} - > -
0 ? "mb-2" : "mt-0.5"}`} - > - Tags -
-
- -
0 ? "pt-2" : ""}`} - > - {tagFields.fields.map(({ tagColor, id: formId, slug }) => ( - { - if (cannotEditSecret) { - createNotification({ type: "error", text: "Access denied" }); - return; - } - - const tag = tags?.find(({ slug: tagSlug }) => slug === tagSlug); - if (tag) handleTagSelect(tag); - }} - > -
-
{slug}
- - ))} - - - {(isAllowed) => ( - - - - - - )} - - - - Add tags to this secret - - {tags.map((tag) => { - const { id: tagId, slug, color } = tag; - - const isSelected = selectedTagsGroupById?.[tagId]; - return ( - handleTagSelect(tag)} - key={tagId} - icon={isSelected && } - iconPos="right" - > -
-
- {slug} -
- - ); - })} - - {(isAllowed) => ( -
- -
- )} -
- - -
- -
-
-
0 || metadataFormFields.fields.length > 0 ? "block" : "hidden"}`} - /> - -
0 ? "flex-col" : "flex-row"}`} - > -
0 ? "mb-2" : "mt-0.5"}`} - > - Metadata -
- -
- {metadataFormFields.fields.map(({ id: metadataFieldId }, i) => ( -
-
- {i === 0 && Key} - ( - - - - )} - /> -
-
- {i === 0 && ( - - )} - ( - - - - )} - /> -
- metadataFormFields.remove(i)} - > - - -
- ))} -
0 ? "pt-2" : ""}`}> - metadataFormFields.append({ key: "", value: "" })} - > - - -
-
-
-
-
- ( - -