diff --git a/backend/src/lib/fn/object.ts b/backend/src/lib/fn/object.ts index 65d0b7859..6ff7278aa 100644 --- a/backend/src/lib/fn/object.ts +++ b/backend/src/lib/fn/object.ts @@ -53,3 +53,53 @@ export const titleCaseToCamelCase = (obj: unknown): unknown => { return result; }; + +export const deepEqual = (obj1: unknown, obj2: unknown): boolean => { + if (obj1 === obj2) return true; + + if (obj1 === null || obj2 === null || obj1 === undefined || obj2 === undefined) { + return obj1 === obj2; + } + + if (typeof obj1 !== typeof obj2) return false; + + if (typeof obj1 !== "object") return obj1 === obj2; + + if (Array.isArray(obj1) !== Array.isArray(obj2)) return false; + + if (Array.isArray(obj1)) { + const arr1 = obj1 as unknown[]; + const arr2 = obj2 as unknown[]; + if (arr1.length !== arr2.length) return false; + return arr1.every((val, idx) => deepEqual(val, arr2[idx])); + } + + const keys1 = Object.keys(obj1 as Record).sort(); + const keys2 = Object.keys(obj2 as Record).sort(); + + if (keys1.length !== keys2.length) return false; + if (keys1.some((key, idx) => key !== keys2[idx])) return false; + + return keys1.every((key) => + deepEqual((obj1 as Record)[key], (obj2 as Record)[key]) + ); +}; + +export const deepEqualSkipFields = (obj1: unknown, obj2: unknown, skipFields: string[] = []): boolean => { + if (skipFields.length === 0) { + return deepEqual(obj1, obj2); + } + + if (typeof obj1 !== "object" || typeof obj2 !== "object" || obj1 === null || obj2 === null) { + return deepEqual(obj1, obj2); + } + + const filtered1 = Object.fromEntries( + Object.entries(obj1 as Record).filter(([key]) => !skipFields.includes(key)) + ); + const filtered2 = Object.fromEntries( + Object.entries(obj2 as Record).filter(([key]) => !skipFields.includes(key)) + ); + + return deepEqual(filtered1, filtered2); +}; diff --git a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts index 9db3a2013..9da2f4b69 100644 --- a/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts +++ b/backend/src/server/routes/v1/secret-sync-routers/secret-sync-endpoints.ts @@ -425,4 +425,42 @@ export const registerSyncSecretsEndpoints = { + const { destinationConfig, excludeSyncId, projectId } = req.body; + + const result = await server.services.secretSync.checkDuplicateDestination( + { + destinationConfig: destinationConfig as Record, + destination, + excludeSyncId, + projectId + }, + req.permission + ); + + return result; + } + }); }; diff --git a/backend/src/services/secret-sync/secret-sync-dal.ts b/backend/src/services/secret-sync/secret-sync-dal.ts index 57c6581ce..64d2c0bdb 100644 --- a/backend/src/services/secret-sync/secret-sync-dal.ts +++ b/backend/src/services/secret-sync/secret-sync-dal.ts @@ -204,5 +204,19 @@ export const secretSyncDALFactory = ( } }; - return { ...secretSyncOrm, findById, findOne, find, create, updateById }; + const findByDestinationAndOrgId = async (destination: string, orgId: string, tx?: Knex) => { + try { + const response = await (tx || db.replicaNode())(TableName.SecretSync) + .join(TableName.Project, `${TableName.SecretSync}.projectId`, `${TableName.Project}.id`) + .where(`${TableName.SecretSync}.destination`, destination) + .where(`${TableName.Project}.orgId`, orgId) + .select(selectAllTableCols(TableName.SecretSync)); + + return response; + } catch (error) { + throw new DatabaseError({ error, name: "Find By Destination And Org ID - Secret Sync" }); + } + }; + + return { ...secretSyncOrm, findById, findOne, find, create, updateById, findByDestinationAndOrgId }; }; diff --git a/backend/src/services/secret-sync/secret-sync-maps.ts b/backend/src/services/secret-sync/secret-sync-maps.ts index 04e91051d..1fbc66cca 100644 --- a/backend/src/services/secret-sync/secret-sync-maps.ts +++ b/backend/src/services/secret-sync/secret-sync-maps.ts @@ -1,5 +1,6 @@ import { AppConnection } from "@app/services/app-connection/app-connection-enums"; import { SecretSync, SecretSyncPlanType } from "@app/services/secret-sync/secret-sync-enums"; +import { DestinationDuplicateCheckFn } from "@app/services/secret-sync/secret-sync-types"; export const SECRET_SYNC_NAME_MAP: Record = { [SecretSync.AWSParameterStore]: "AWS Parameter Store", @@ -99,3 +100,104 @@ export const SECRET_SYNC_PLAN_MAP: Record = { [SecretSync.Netlify]: SecretSyncPlanType.Regular, [SecretSync.Bitbucket]: SecretSyncPlanType.Regular }; + +export const SECRET_SYNC_SKIP_FIELDS_MAP: Record = { + [SecretSync.AWSParameterStore]: [], + [SecretSync.AWSSecretsManager]: ["mappingBehavior", "secretName"], + [SecretSync.GitHub]: [], + [SecretSync.GCPSecretManager]: [], + [SecretSync.AzureKeyVault]: [], + [SecretSync.AzureAppConfiguration]: ["label"], + [SecretSync.AzureDevOps]: ["devopsProjectName"], + [SecretSync.Databricks]: [], + [SecretSync.Humanitec]: [], + [SecretSync.TerraformCloud]: ["variableSetName", "workspaceName"], + [SecretSync.Camunda]: [], + [SecretSync.Vercel]: ["appName"], + [SecretSync.Windmill]: [], + [SecretSync.HCVault]: [], + [SecretSync.TeamCity]: [], + [SecretSync.OCIVault]: [], + [SecretSync.OnePass]: ["valueLabel"], + [SecretSync.Heroku]: ["appName"], + [SecretSync.Render]: [], + [SecretSync.Flyio]: [], + [SecretSync.GitLab]: [ + "projectName", + "shouldProtectSecrets", + "shouldMaskSecrets", + "shouldHideSecrets", + "targetEnvironment", + "groupName", + "groupId", + "projectId" + ], + [SecretSync.CloudflarePages]: [], + [SecretSync.CloudflareWorkers]: [], + [SecretSync.Supabase]: ["projectName"], + [SecretSync.Zabbix]: ["hostName", "macroType"], + [SecretSync.Railway]: ["projectName", "environmentName", "serviceName"], + [SecretSync.Checkly]: ["groupName", "accountName"], + [SecretSync.DigitalOceanAppPlatform]: ["appName"], + [SecretSync.Netlify]: ["accountName", "siteName"], + [SecretSync.Bitbucket]: [] +}; + +const defaultDuplicateCheck: DestinationDuplicateCheckFn = () => true; + +export const DESTINATION_DUPLICATE_CHECK_MAP: Record = { + [SecretSync.AWSParameterStore]: defaultDuplicateCheck, + [SecretSync.AWSSecretsManager]: defaultDuplicateCheck, + [SecretSync.GitHub]: defaultDuplicateCheck, + [SecretSync.GCPSecretManager]: defaultDuplicateCheck, + [SecretSync.AzureKeyVault]: defaultDuplicateCheck, + [SecretSync.AzureAppConfiguration]: defaultDuplicateCheck, + [SecretSync.AzureDevOps]: defaultDuplicateCheck, + [SecretSync.Databricks]: defaultDuplicateCheck, + [SecretSync.Humanitec]: defaultDuplicateCheck, + [SecretSync.TerraformCloud]: defaultDuplicateCheck, + [SecretSync.Camunda]: defaultDuplicateCheck, + [SecretSync.Vercel]: defaultDuplicateCheck, + [SecretSync.Windmill]: defaultDuplicateCheck, + [SecretSync.HCVault]: defaultDuplicateCheck, + [SecretSync.TeamCity]: defaultDuplicateCheck, + [SecretSync.OCIVault]: defaultDuplicateCheck, + [SecretSync.OnePass]: defaultDuplicateCheck, + [SecretSync.Heroku]: defaultDuplicateCheck, + [SecretSync.Render]: defaultDuplicateCheck, + [SecretSync.Flyio]: defaultDuplicateCheck, + [SecretSync.GitLab]: (existingConfig, newConfig) => { + const existingTargetEnv = existingConfig.targetEnvironment as string | undefined; + const newTargetEnv = newConfig.targetEnvironment as string | undefined; + + const wildcardValues = ["*", ""]; + + if ( + (newConfig.scope as string) === "group" + ? existingConfig.groupId !== newConfig.groupId + : existingConfig.projectId !== newConfig.projectId + ) + return false; + + // If either has wildcard, it conflicts with any targetEnvironment + if ( + !existingTargetEnv || + !newTargetEnv || + wildcardValues.includes(existingTargetEnv) || + wildcardValues.includes(newTargetEnv) + ) { + return true; + } + + return existingTargetEnv === newTargetEnv; + }, + [SecretSync.CloudflarePages]: defaultDuplicateCheck, + [SecretSync.CloudflareWorkers]: defaultDuplicateCheck, + [SecretSync.Supabase]: defaultDuplicateCheck, + [SecretSync.Zabbix]: defaultDuplicateCheck, + [SecretSync.Railway]: defaultDuplicateCheck, + [SecretSync.Checkly]: defaultDuplicateCheck, + [SecretSync.DigitalOceanAppPlatform]: defaultDuplicateCheck, + [SecretSync.Netlify]: defaultDuplicateCheck, + [SecretSync.Bitbucket]: defaultDuplicateCheck +}; diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index ecd7d04a5..6a2f49386 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -12,6 +12,7 @@ import { import { KeyStorePrefixes, TKeyStoreFactory } from "@app/keystore/keystore"; import { DatabaseErrorCode } from "@app/lib/error-codes"; import { BadRequestError, DatabaseError, NotFoundError } from "@app/lib/errors"; +import { deepEqualSkipFields } from "@app/lib/fn/object"; import { OrgServiceActor } from "@app/lib/types"; import { TAppConnectionServiceFactory } from "@app/services/app-connection/app-connection-service"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; @@ -20,6 +21,7 @@ import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; import { enterpriseSyncCheck, listSecretSyncOptions } from "@app/services/secret-sync/secret-sync-fns"; import { SecretSyncStatus, + TCheckDuplicateDestinationDTO, TCreateSecretSyncDTO, TDeleteSecretSyncDTO, TFindSecretSyncByIdDTO, @@ -35,7 +37,12 @@ import { import { TSecretImportDALFactory } from "../secret-import/secret-import-dal"; import { TSecretSyncDALFactory } from "./secret-sync-dal"; -import { SECRET_SYNC_CONNECTION_MAP, SECRET_SYNC_NAME_MAP } from "./secret-sync-maps"; +import { + DESTINATION_DUPLICATE_CHECK_MAP, + SECRET_SYNC_CONNECTION_MAP, + SECRET_SYNC_NAME_MAP, + SECRET_SYNC_SKIP_FIELDS_MAP +} from "./secret-sync-maps"; import { TSecretSyncQueueFactory } from "./secret-sync-queue"; type TSecretSyncServiceFactoryDep = { @@ -696,6 +703,61 @@ export const secretSyncServiceFactory = ({ return updatedSecretSync as TSecretSync; }; + const checkDuplicateDestination = async ( + { destination, destinationConfig, excludeSyncId, projectId }: TCheckDuplicateDestinationDTO, + actor: OrgServiceActor + ) => { + const skipFields = SECRET_SYNC_SKIP_FIELDS_MAP[destination]; + const { permission } = await permissionService.getProjectPermission({ + actor: actor.type, + actorId: actor.id, + actorAuthMethod: actor.authMethod, + actorOrgId: actor.orgId, + actionProjectType: ActionProjectType.SecretManager, + projectId + }); + + ForbiddenError.from(permission).throwUnlessCan( + ProjectPermissionSecretSyncActions.Read, + ProjectPermissionSub.SecretSyncs + ); + + if (!destinationConfig || Object.keys(destinationConfig).length === 0) { + return { hasDuplicate: false, duplicateProjectId: undefined }; + } + + try { + const existingSyncs = await secretSyncDAL.findByDestinationAndOrgId(destination, actor.orgId); + + const duplicates = existingSyncs.filter((sync) => { + if (sync.id === excludeSyncId) { + return false; + } + + try { + const baseFieldsMatch = deepEqualSkipFields(sync.destinationConfig, destinationConfig, skipFields); + if (baseFieldsMatch) { + return DESTINATION_DUPLICATE_CHECK_MAP[destination]( + sync.destinationConfig as Record, + destinationConfig + ); + } + return false; + } catch { + return false; + } + }); + + const hasDuplicate = duplicates.length > 0; + return { + hasDuplicate, + duplicateProjectId: hasDuplicate ? duplicates[0].projectId : undefined + }; + } catch (error) { + return { hasDuplicate: false, duplicateProjectId: undefined }; + } + }; + return { listSecretSyncOptions, listSecretSyncsByProjectId, @@ -707,6 +769,7 @@ export const secretSyncServiceFactory = ({ deleteSecretSync, triggerSecretSyncSyncSecretsById, triggerSecretSyncImportSecretsById, - triggerSecretSyncRemoveSecretsById + triggerSecretSyncRemoveSecretsById, + checkDuplicateDestination }; }; diff --git a/backend/src/services/secret-sync/secret-sync-types.ts b/backend/src/services/secret-sync/secret-sync-types.ts index 6435e19d3..478c44c92 100644 --- a/backend/src/services/secret-sync/secret-sync-types.ts +++ b/backend/src/services/secret-sync/secret-sync-types.ts @@ -324,6 +324,13 @@ export type TDeleteSecretSyncDTO = { removeSecrets: boolean; }; +export type TCheckDuplicateDestinationDTO = { + destination: SecretSync; + destinationConfig: Record; + excludeSyncId?: string; + projectId: string; +}; + export enum SecretSyncStatus { Pending = "pending", Running = "running", @@ -408,3 +415,8 @@ export type TSecretMap = Record< secretMetadata?: ResourceMetadataDTO; } >; + +export type DestinationDuplicateCheckFn = ( + existingConfig: Record, + newConfig: Record +) => boolean; diff --git a/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx b/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx new file mode 100644 index 000000000..82ab06038 --- /dev/null +++ b/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx @@ -0,0 +1,57 @@ +import { Button, Modal, ModalClose, ModalContent } from "@app/components/v2"; + +type Props = { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + onConfirm: () => void; + isLoading?: boolean; + duplicateProjectId?: string; +}; + +export const DuplicateDestinationConfirmationModal = ({ + isOpen, + onOpenChange, + onConfirm, + isLoading, + duplicateProjectId +}: Props) => { + return ( + + +
+

+ Another secret sync in your organization is already configured with the same + destination. Proceeding may cause conflicts or overwrite existing data. +

+ {duplicateProjectId && ( +

+ Duplicate found in project ID:{" "} + + {duplicateProjectId} + +

+ )} +

Are you sure you want to continue?

+
+ +
+ + + + + + +
+
+
+ ); +}; diff --git a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx index dc2ab385a..3c0e4ea17 100644 --- a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from "react"; +import { ReactNode, useCallback, useEffect, useState } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; @@ -6,9 +6,14 @@ import { createNotification } from "@app/components/notifications"; import { SecretSyncEditFields } from "@app/components/secret-syncs/types"; import { Button, ModalClose } from "@app/components/v2"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; -import { TSecretSync, useUpdateSecretSync } from "@app/hooks/api/secretSyncs"; +import { + TSecretSync, + useCheckDuplicateDestination, + useUpdateSecretSync +} from "@app/hooks/api/secretSyncs"; import { SecretSyncOptionsFields } from "./SecretSyncOptionsFields/SecretSyncOptionsFields"; +import { DuplicateDestinationConfirmationModal } from "./DuplicateDestinationConfirmationModal"; import { TSecretSyncForm, UpdateSecretSyncFormSchema } from "./schemas"; import { SecretSyncDestinationFields } from "./SecretSyncDestinationFields"; import { SecretSyncDetailsFields } from "./SecretSyncDetailsFields"; @@ -23,6 +28,8 @@ type Props = { export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => { const updateSecretSync = useUpdateSecretSync(); const { name: destinationName } = SECRET_SYNC_MAP[secretSync.destination]; + const [showDuplicateConfirmation, setShowDuplicateConfirmation] = useState(false); + const [pendingFormData, setPendingFormData] = useState(null); const formMethods = useForm({ resolver: zodResolver(UpdateSecretSyncFormSchema), @@ -35,29 +42,114 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => reValidateMode: "onChange" }); - const onSubmit = async ({ environment, connection, ...formData }: TSecretSyncForm) => { - try { - const updatedSecretSync = await updateSecretSync.mutateAsync({ - syncId: secretSync.id, - ...formData, - environment: environment?.slug, - connectionId: connection.id, - projectId: secretSync.projectId + const [destinationConfigToCheck, setDestinationConfigToCheck] = useState(null); + const [checkDuplicateEnabled, setCheckDuplicateEnabled] = useState(false); + const [storedDuplicateProjectId, setStoredDuplicateProjectId] = useState(); + + const { data: duplicateData, isLoading: isCheckingDuplicate } = useCheckDuplicateDestination( + secretSync.destination, + destinationConfigToCheck, + secretSync.projectId, + secretSync.id, + { enabled: checkDuplicateEnabled && Boolean(destinationConfigToCheck) } + ); + + const performUpdate = useCallback( + async (formData: TSecretSyncForm) => { + try { + const { environment, connection, ...updateData } = formData; + const updatedSecretSync = await updateSecretSync.mutateAsync({ + syncId: secretSync.id, + ...updateData, + environment: environment?.slug, + connectionId: connection.id, + projectId: secretSync.projectId + }); + + createNotification({ + text: `Successfully updated ${destinationName} Sync`, + type: "success" + }); + onComplete(updatedSecretSync); + } catch (err: any) { + console.error(err); + createNotification({ + title: `Failed to update ${destinationName} Sync`, + text: err.message, + type: "error" + }); + } + }, + [updateSecretSync, secretSync.id, secretSync.projectId, destinationName, onComplete] + ); + + useEffect(() => { + if (checkDuplicateEnabled && !isCheckingDuplicate && destinationConfigToCheck) { + if (duplicateData?.hasDuplicate) { + setStoredDuplicateProjectId(duplicateData.duplicateProjectId); + setShowDuplicateConfirmation(true); + } else if (pendingFormData) { + performUpdate(pendingFormData); + setPendingFormData(null); + } + setCheckDuplicateEnabled(false); + setDestinationConfigToCheck(null); + } + }, [ + checkDuplicateEnabled, + isCheckingDuplicate, + duplicateData?.hasDuplicate, + duplicateData?.duplicateProjectId, + destinationConfigToCheck, + pendingFormData, + performUpdate + ]); + + const normalizeConfig = (config: unknown): unknown => { + if (config === null || config === undefined || typeof config !== "object") { + return config; + } + + if (Array.isArray(config)) { + return config.map(normalizeConfig); + } + + const normalized: Record = {}; + Object.keys(config as Record) + .sort() + .forEach((key) => { + normalized[key] = normalizeConfig((config as Record)[key]); }); - createNotification({ - text: `Successfully updated ${destinationName} Sync`, - type: "success" - }); - onComplete(updatedSecretSync); - } catch (err: any) { - console.error(err); - createNotification({ - title: `Failed to update ${destinationName} Sync`, - text: err.message, - type: "error" - }); + return normalized; + }; + + const hasDestinationConfigChanged = (formData: TSecretSyncForm) => { + const originalConfig = normalizeConfig(secretSync.destinationConfig); + const currentConfig = normalizeConfig(formData.destinationConfig); + + return JSON.stringify(originalConfig) !== JSON.stringify(currentConfig); + }; + + const onSubmit = async (formData: TSecretSyncForm) => { + if (fields === SecretSyncEditFields.Destination && hasDestinationConfigChanged(formData)) { + setDestinationConfigToCheck(formData.destinationConfig); + setPendingFormData(formData); + setCheckDuplicateEnabled(true); + return; } + + await performUpdate(formData); + }; + + const handleConfirmDuplicate = async () => { + if (pendingFormData) { + await performUpdate(pendingFormData); + setPendingFormData(null); + } + setShowDuplicateConfirmation(false); + setCheckDuplicateEnabled(false); + setDestinationConfigToCheck(null); }; let Component: ReactNode; @@ -83,24 +175,41 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => formState: { isSubmitting, isDirty } } = formMethods; + const isLoading = isSubmitting || isCheckingDuplicate; + return ( -
- {Component} -
- - + + - - -
-
+ + + + { + setShowDuplicateConfirmation(open); + if (!open) { + setStoredDuplicateProjectId(undefined); + } + }} + onConfirm={handleConfirmDuplicate} + isLoading={updateSecretSync.isPending} + duplicateProjectId={storedDuplicateProjectId} + /> + ); }; diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index eaeab2e66..84801b000 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -1,11 +1,14 @@ import { ReactNode } from "react"; import { useFormContext } from "react-hook-form"; +import { faWarning } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { GenericFieldLabel } from "@app/components/secret-syncs"; import { TSecretSyncForm } from "@app/components/secret-syncs/forms/schemas"; import { Badge } from "@app/components/v2"; +import { useProject } from "@app/context"; import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; -import { SecretSync } from "@app/hooks/api/secretSyncs"; +import { SecretSync, useDuplicateDestinationCheck } from "@app/hooks/api/secretSyncs"; import { AwsParameterStoreDestinationReviewFields, @@ -46,6 +49,7 @@ import { ZabbixSyncReviewFields } from "./ZabbixSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); + const { currentProject } = useProject(); let DestinationFieldsComponent: ReactNode; let AdditionalSyncOptionsFieldsComponent: ReactNode; @@ -63,6 +67,13 @@ export const SecretSyncReviewFields = () => { const destinationName = SECRET_SYNC_MAP[destination].name; + const { hasDuplicate, duplicateProjectId, isChecking } = useDuplicateDestinationCheck({ + destination, + projectId: currentProject?.id || "", + enabled: true, + destinationConfig: watch("destinationConfig") + }); + switch (destination) { case SecretSync.AWSParameterStore: DestinationFieldsComponent = ; @@ -173,9 +184,28 @@ export const SecretSyncReviewFields = () => {
-
+
Destination + {isChecking && Checking...}
+ {hasDuplicate && ( +
+
+ +
+

+ Another secret sync in your organization is already configured with the same + destination. This may lead to conflicts or unexpected behavior. +

+ {duplicateProjectId && ( +

+ Duplicate found in project ID: {duplicateProjectId} +

+ )} +
+
+
+ )}
{connection.name} {DestinationFieldsComponent} diff --git a/frontend/src/hooks/api/secretSyncs/index.ts b/frontend/src/hooks/api/secretSyncs/index.ts index f49a872a5..0050bad8d 100644 --- a/frontend/src/hooks/api/secretSyncs/index.ts +++ b/frontend/src/hooks/api/secretSyncs/index.ts @@ -2,3 +2,4 @@ export * from "./enums"; export * from "./mutations"; export * from "./queries"; export * from "./types"; +export * from "./useDuplicateDestinationCheck"; diff --git a/frontend/src/hooks/api/secretSyncs/queries.tsx b/frontend/src/hooks/api/secretSyncs/queries.tsx index bb0787a7b..250d02ff8 100644 --- a/frontend/src/hooks/api/secretSyncs/queries.tsx +++ b/frontend/src/hooks/api/secretSyncs/queries.tsx @@ -14,7 +14,15 @@ export const secretSyncKeys = { options: () => [...secretSyncKeys.all, "options"] as const, list: (projectId: string) => [...secretSyncKeys.all, "list", projectId] as const, byId: (destination: SecretSync, syncId: string) => - [...secretSyncKeys.all, destination, "by-id", syncId] as const + [...secretSyncKeys.all, destination, "by-id", syncId] as const, + duplicateCheck: (destination: SecretSync, destinationConfig: unknown, excludeSyncId?: string) => + [ + ...secretSyncKeys.all, + destination, + "duplicate-check", + destinationConfig, + excludeSyncId + ] as const }; export const useSecretSyncOptions = ( @@ -88,3 +96,37 @@ export const useGetSecretSync = ( ...options }); }; + +export const useCheckDuplicateDestination = ( + destination: SecretSync, + destinationConfig: unknown, + projectId: string, + excludeSyncId?: string, + options?: Omit< + UseQueryOptions< + { hasDuplicate: boolean; duplicateProjectId?: string }, + unknown, + { hasDuplicate: boolean; duplicateProjectId?: string }, + ReturnType + >, + "queryKey" | "queryFn" + > +) => { + return useQuery({ + queryKey: secretSyncKeys.duplicateCheck(destination, destinationConfig, excludeSyncId), + queryFn: async () => { + const { data } = await apiRequest.post<{ + hasDuplicate: boolean; + duplicateProjectId?: string; + }>(`/api/v1/secret-syncs/${destination}/check-destination`, { + destinationConfig, + excludeSyncId, + projectId + }); + + return data; + }, + enabled: Boolean(destinationConfig) && Object.keys(destinationConfig || {}).length > 0, + ...options + }); +}; diff --git a/frontend/src/hooks/api/secretSyncs/useDuplicateDestinationCheck.ts b/frontend/src/hooks/api/secretSyncs/useDuplicateDestinationCheck.ts new file mode 100644 index 000000000..97985fba9 --- /dev/null +++ b/frontend/src/hooks/api/secretSyncs/useDuplicateDestinationCheck.ts @@ -0,0 +1,51 @@ +import { useMemo } from "react"; + +import { SecretSync, useCheckDuplicateDestination } from "@app/hooks/api/secretSyncs"; + +type UseDuplicateDestinationCheckProps = { + destination: SecretSync; + projectId: string; + excludeSyncId?: string; + enabled?: boolean; + destinationConfig?: unknown; +}; + +export const useDuplicateDestinationCheck = ({ + destination, + projectId, + excludeSyncId, + enabled = true, + destinationConfig +}: UseDuplicateDestinationCheckProps) => { + const hasValidConfig = useMemo(() => { + if (!destinationConfig || typeof destinationConfig !== "object") return false; + + const values = Object.values(destinationConfig); + return ( + values.length > 0 && + values.some((value) => value !== null && value !== undefined && value !== "") + ); + }, [destinationConfig]); + + const shouldCheck = enabled && hasValidConfig; + + const { + data: duplicateData, + isLoading, + error, + refetch + } = useCheckDuplicateDestination(destination, destinationConfig, projectId, excludeSyncId, { + enabled: shouldCheck, + staleTime: 0, + gcTime: 0 + }); + + return { + hasDuplicate: shouldCheck ? Boolean(duplicateData?.hasDuplicate) : false, + duplicateProjectId: duplicateData?.duplicateProjectId, + isChecking: shouldCheck && isLoading, + hasError: Boolean(error), + hasValidConfig, + refetch + }; +};