diff --git a/backend/src/db/migrations/20251023123213_block-duplicate-sync-destinations-setting.ts b/backend/src/db/migrations/20251023123213_block-duplicate-sync-destinations-setting.ts new file mode 100644 index 000000000..7675eb3e1 --- /dev/null +++ b/backend/src/db/migrations/20251023123213_block-duplicate-sync-destinations-setting.ts @@ -0,0 +1,27 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasOrgBlockDuplicateColumn = await knex.schema.hasColumn( + TableName.Organization, + "blockDuplicateSecretSyncDestinations" + ); + if (!hasOrgBlockDuplicateColumn) { + await knex.schema.table(TableName.Organization, (table) => { + table.boolean("blockDuplicateSecretSyncDestinations").notNullable().defaultTo(false); + }); + } +} + +export async function down(knex: Knex): Promise { + const hasOrgBlockDuplicateColumn = await knex.schema.hasColumn( + TableName.Organization, + "blockDuplicateSecretSyncDestinations" + ); + if (hasOrgBlockDuplicateColumn) { + await knex.schema.table(TableName.Organization, (table) => { + table.dropColumn("blockDuplicateSecretSyncDestinations"); + }); + } +} diff --git a/backend/src/db/schemas/organizations.ts b/backend/src/db/schemas/organizations.ts index a1c01151f..3cc7fe858 100644 --- a/backend/src/db/schemas/organizations.ts +++ b/backend/src/db/schemas/organizations.ts @@ -40,7 +40,8 @@ export const OrganizationsSchema = z.object({ googleSsoAuthEnforced: z.boolean().default(false), googleSsoAuthLastUsed: z.date().nullable().optional(), parentOrgId: z.string().uuid().nullable().optional(), - rootOrgId: z.string().uuid().nullable().optional() + rootOrgId: z.string().uuid().nullable().optional(), + blockDuplicateSecretSyncDestinations: z.boolean().default(false) }); export type TOrganizations = z.infer; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index c135db83e..31d860201 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -1958,6 +1958,8 @@ export const registerRoutes = async ( secretImportDAL, permissionService, appConnectionService, + projectDAL, + orgDAL, folderDAL, secretSyncQueue, projectBotService, diff --git a/backend/src/server/routes/v1/organization-router.ts b/backend/src/server/routes/v1/organization-router.ts index 76b3eae51..d4b0058f9 100644 --- a/backend/src/server/routes/v1/organization-router.ts +++ b/backend/src/server/routes/v1/organization-router.ts @@ -323,7 +323,11 @@ export const registerOrgRouter = async (server: FastifyZodProvider) => { .min(1, "Max Shared Secret view count cannot be lower than 1") .max(1000, "Max Shared Secret view count cannot exceed 1000") .nullable() + .optional(), + blockDuplicateSecretSyncDestinations: z + .boolean() .optional() + .describe("Block duplicate secret sync destinations across the organization") }), response: { 200: z.object({ diff --git a/backend/src/services/org/org-schema.ts b/backend/src/services/org/org-schema.ts index 4a3bdb06e..be5c300b5 100644 --- a/backend/src/services/org/org-schema.ts +++ b/backend/src/services/org/org-schema.ts @@ -27,5 +27,6 @@ export const sanitizedOrganizationSchema = OrganizationsSchema.pick({ scannerProductEnabled: true, shareSecretsProductEnabled: true, maxSharedSecretLifetime: true, - maxSharedSecretViewLimit: true + maxSharedSecretViewLimit: true, + blockDuplicateSecretSyncDestinations: true }); diff --git a/backend/src/services/org/org-service.ts b/backend/src/services/org/org-service.ts index 51d907e05..26de92743 100644 --- a/backend/src/services/org/org-service.ts +++ b/backend/src/services/org/org-service.ts @@ -405,7 +405,8 @@ export const orgServiceFactory = ({ scannerProductEnabled, shareSecretsProductEnabled, maxSharedSecretLifetime, - maxSharedSecretViewLimit + maxSharedSecretViewLimit, + blockDuplicateSecretSyncDestinations } }: TUpdateOrgDTO) => { const appCfg = getConfig(); @@ -589,7 +590,8 @@ export const orgServiceFactory = ({ scannerProductEnabled, shareSecretsProductEnabled, maxSharedSecretLifetime, - maxSharedSecretViewLimit + maxSharedSecretViewLimit, + blockDuplicateSecretSyncDestinations }); if (!org) throw new NotFoundError({ message: `Organization with ID '${orgId}' not found` }); return org; diff --git a/backend/src/services/org/org-types.ts b/backend/src/services/org/org-types.ts index 48680456c..2587045bb 100644 --- a/backend/src/services/org/org-types.ts +++ b/backend/src/services/org/org-types.ts @@ -90,6 +90,7 @@ export type TUpdateOrgDTO = { shareSecretsProductEnabled: boolean; maxSharedSecretLifetime: number; maxSharedSecretViewLimit: number | null; + blockDuplicateSecretSyncDestinations: boolean; }>; } & TOrgPermission; diff --git a/backend/src/services/secret-sync/secret-sync-service.ts b/backend/src/services/secret-sync/secret-sync-service.ts index 6a2f49386..a4e2fc467 100644 --- a/backend/src/services/secret-sync/secret-sync-service.ts +++ b/backend/src/services/secret-sync/secret-sync-service.ts @@ -15,6 +15,8 @@ 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 { TOrgDALFactory } from "@app/services/org/org-dal"; +import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotServiceFactory } from "@app/services/project-bot/project-bot-service"; import { TSecretFolderDALFactory } from "@app/services/secret-folder/secret-folder-dal"; import { SecretSync } from "@app/services/secret-sync/secret-sync-enums"; @@ -50,6 +52,8 @@ type TSecretSyncServiceFactoryDep = { secretImportDAL: TSecretImportDALFactory; appConnectionService: Pick; permissionService: Pick; + projectDAL: Pick; + orgDAL: Pick; projectBotService: Pick; folderDAL: Pick; keyStore: Pick; @@ -68,6 +72,8 @@ export const secretSyncServiceFactory = ({ secretImportDAL, permissionService, appConnectionService, + projectDAL, + orgDAL, projectBotService, secretSyncQueue, keyStore, @@ -225,6 +231,61 @@ export const secretSyncServiceFactory = ({ return secretSync 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 }; + } + }; + const createSecretSync = async ( { projectId, secretPath, environment, ...params }: TCreateSecretSyncDTO, actor: OrgServiceActor @@ -271,6 +332,30 @@ export const secretSyncServiceFactory = ({ message: `Could not find folder with path "${secretPath}" in environment "${environment}" for project with ID "${projectId}"` }); + const project = await projectDAL.findById(projectId); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + + const organization = await orgDAL.findById(project.orgId); + if (organization?.blockDuplicateSecretSyncDestinations) { + const duplicateCheck = await checkDuplicateDestination( + { + destination: params.destination, + destinationConfig: params.destinationConfig, + projectId + }, + actor + ); + if (duplicateCheck.hasDuplicate) { + throw new BadRequestError({ + message: `A secret sync with this destination already exists${ + duplicateCheck.duplicateProjectId ? ` in project ${duplicateCheck.duplicateProjectId}` : "" + }.` + }); + } + } + const destinationApp = SECRET_SYNC_CONNECTION_MAP[params.destination]; // validates permission to connect and app is valid for sync destination @@ -369,6 +454,33 @@ export const secretSyncServiceFactory = ({ let { folderId } = secretSync; + if (params.destinationConfig) { + const project = await projectDAL.findById(secretSync.projectId); + if (!project) { + throw new NotFoundError({ message: "Project not found" }); + } + const organization = await orgDAL.findById(project.orgId); + + if (organization?.blockDuplicateSecretSyncDestinations) { + const duplicateCheck = await checkDuplicateDestination( + { + destination, + destinationConfig: params.destinationConfig, + projectId: secretSync.projectId, + excludeSyncId: secretSync.id + }, + actor + ); + if (duplicateCheck.hasDuplicate) { + throw new BadRequestError({ + message: `A secret sync with this destination already exists${ + duplicateCheck.duplicateProjectId ? ` in project ${duplicateCheck.duplicateProjectId}` : "" + }.` + }); + } + } + } + if (params.connectionId) { const destinationApp = SECRET_SYNC_CONNECTION_MAP[secretSync.destination as SecretSync]; @@ -703,61 +815,6 @@ 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, diff --git a/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx index 45e0297bd..ebad86cbd 100644 --- a/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/CreateSecretSyncForm.tsx @@ -8,13 +8,14 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { Button, FormControl, Switch } from "@app/components/v2"; -import { useProject } from "@app/context"; +import { useOrganization, useProject } from "@app/context"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; import { SecretSync, SecretSyncInitialSyncBehavior, TSecretSync, useCreateSecretSync, + useDuplicateDestinationCheck, useSecretSyncOption } from "@app/hooks/api/secretSyncs"; @@ -48,6 +49,7 @@ export const CreateSecretSyncForm = ({ }: Props) => { const createSecretSync = useCreateSecretSync(); const { currentProject } = useProject(); + const { currentOrg } = useOrganization(); const { name: destinationName } = SECRET_SYNC_MAP[destination]; const [showConfirmation, setShowConfirmation] = useState(false); @@ -106,11 +108,20 @@ export const CreateSecretSyncForm = ({ setSelectedTabIndex((prev) => prev - 1); }; - const { handleSubmit, trigger, control } = formMethods; + const { handleSubmit, trigger, control, watch } = formMethods; + + const { hasDuplicate } = useDuplicateDestinationCheck({ + destination, + projectId: currentProject?.id || "", + enabled: true, + destinationConfig: watch("destinationConfig") + }); const isStepValid = async (index: number) => trigger(FORM_TABS[index].fields); const isFinalStep = selectedTabIndex === FORM_TABS.length - 1; + const isCreateButtonDisabled = + isFinalStep && hasDuplicate && currentOrg?.blockDuplicateSecretSyncDestinations; const handleNext = async () => { if (isFinalStep) { @@ -245,7 +256,7 @@ export const CreateSecretSyncForm = ({
- {selectedTabIndex > 0 && ( diff --git a/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx b/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx index d80dbc640..59871e880 100644 --- a/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx +++ b/frontend/src/components/secret-syncs/forms/DuplicateDestinationConfirmationModal.tsx @@ -6,6 +6,7 @@ type Props = { onConfirm: () => void; isLoading?: boolean; duplicateProjectId?: string; + isDisabled?: boolean; }; export const DuplicateDestinationConfirmationModal = ({ @@ -13,7 +14,8 @@ export const DuplicateDestinationConfirmationModal = ({ onOpenChange, onConfirm, isLoading, - duplicateProjectId + duplicateProjectId, + isDisabled }: Props) => { return ( @@ -21,7 +23,12 @@ export const DuplicateDestinationConfirmationModal = ({

Another secret sync in your organization is already configured with the same - destination. Proceeding may cause conflicts or overwrite existing data. + destination.{" "} + + {isDisabled + ? "Your organization does not allow duplicate destination configurations." + : "Proceeding may cause conflicts or overwrite existing data."} +

{duplicateProjectId && (

@@ -31,26 +38,28 @@ export const DuplicateDestinationConfirmationModal = ({

)} -

Are you sure you want to continue?

+ {!isDisabled &&

Are you sure you want to continue?

}
-
- - - - - - -
+ {!isDisabled && ( +
+ + + + + + +
+ )}
); diff --git a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx index 3c0e4ea17..2085afe9c 100644 --- a/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx +++ b/frontend/src/components/secret-syncs/forms/EditSecretSyncForm.tsx @@ -5,6 +5,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { createNotification } from "@app/components/notifications"; import { SecretSyncEditFields } from "@app/components/secret-syncs/types"; import { Button, ModalClose } from "@app/components/v2"; +import { useOrganization } from "@app/context"; import { SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; import { TSecretSync, @@ -30,6 +31,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => const { name: destinationName } = SECRET_SYNC_MAP[secretSync.destination]; const [showDuplicateConfirmation, setShowDuplicateConfirmation] = useState(false); const [pendingFormData, setPendingFormData] = useState(null); + const { currentOrg } = useOrganization(); const formMethods = useForm({ resolver: zodResolver(UpdateSecretSyncFormSchema), @@ -209,6 +211,7 @@ export const EditSecretSyncForm = ({ secretSync, fields, onComplete }: Props) => onConfirm={handleConfirmDuplicate} isLoading={updateSecretSync.isPending} duplicateProjectId={storedDuplicateProjectId} + isDisabled={currentOrg?.blockDuplicateSecretSyncDestinations} /> ); diff --git a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx index 44d35cd2c..5c04fb308 100644 --- a/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx +++ b/frontend/src/components/secret-syncs/forms/SecretSyncReviewFields/SecretSyncReviewFields.tsx @@ -6,7 +6,7 @@ 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 { useOrganization, useProject } from "@app/context"; import { SECRET_SYNC_INITIAL_SYNC_BEHAVIOR_MAP, SECRET_SYNC_MAP } from "@app/helpers/secretSyncs"; import { SecretSync, useDuplicateDestinationCheck } from "@app/hooks/api/secretSyncs"; @@ -51,6 +51,7 @@ import { ZabbixSyncReviewFields } from "./ZabbixSyncReviewFields"; export const SecretSyncReviewFields = () => { const { watch } = useFormContext(); const { currentProject } = useProject(); + const { currentOrg } = useOrganization(); let DestinationFieldsComponent: ReactNode; let AdditionalSyncOptionsFieldsComponent: ReactNode; @@ -193,18 +194,50 @@ export const SecretSyncReviewFields = () => { {isChecking && Checking...}
{hasDuplicate && ( -
-
- +
+
+

- Another secret sync in your organization is already configured with the same - destination. This may lead to conflicts or unexpected behavior. + {currentOrg?.blockDuplicateSecretSyncDestinations + ? "Another secret sync in your organization is already configured with the same destination. Your organization does not allow duplicate destination configurations." + : "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}

diff --git a/frontend/src/hooks/api/organization/queries.tsx b/frontend/src/hooks/api/organization/queries.tsx index bbf73dd25..4340f9718 100644 --- a/frontend/src/hooks/api/organization/queries.tsx +++ b/frontend/src/hooks/api/organization/queries.tsx @@ -125,7 +125,8 @@ export const useUpdateOrg = () => { scannerProductEnabled, shareSecretsProductEnabled, maxSharedSecretLifetime, - maxSharedSecretViewLimit + maxSharedSecretViewLimit, + blockDuplicateSecretSyncDestinations }) => { return apiRequest.patch(`/api/v1/organization/${orgId}`, { name, @@ -146,7 +147,8 @@ export const useUpdateOrg = () => { scannerProductEnabled, shareSecretsProductEnabled, maxSharedSecretLifetime, - maxSharedSecretViewLimit + maxSharedSecretViewLimit, + blockDuplicateSecretSyncDestinations }); }, onSuccess: () => { diff --git a/frontend/src/hooks/api/organization/types.ts b/frontend/src/hooks/api/organization/types.ts index 867450830..b366277f1 100644 --- a/frontend/src/hooks/api/organization/types.ts +++ b/frontend/src/hooks/api/organization/types.ts @@ -29,6 +29,7 @@ export type Organization = { shareSecretsProductEnabled: boolean; maxSharedSecretLifetime: number; maxSharedSecretViewLimit: number | null; + blockDuplicateSecretSyncDestinations: boolean; }; export type UpdateOrgDTO = { @@ -52,6 +53,7 @@ export type UpdateOrgDTO = { shareSecretsProductEnabled?: boolean; maxSharedSecretViewLimit?: number | null; maxSharedSecretLifetime?: number; + blockDuplicateSecretSyncDestinations?: boolean; }; export type BillingDetails = { diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/OrgProductSettingsTab.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/OrgProductSettingsTab.tsx new file mode 100644 index 000000000..e4d935996 --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/OrgProductSettingsTab.tsx @@ -0,0 +1,72 @@ +import { useState } from "react"; + +import { createNotification } from "@app/components/notifications"; +import { OrgPermissionCan } from "@app/components/permissions"; +import { Switch } from "@app/components/v2"; +import { OrgPermissionActions, OrgPermissionSubjects, useOrganization } from "@app/context"; +import { useUpdateOrg } from "@app/hooks/api/organization/queries"; + +export const OrgProductSettingsTab = () => { + const { currentOrg } = useOrganization(); + const { mutateAsync: updateOrg } = useUpdateOrg(); + + const [isLoading, setIsLoading] = useState(false); + + const handleToggle = async (state: boolean) => { + setIsLoading(true); + + try { + if (!currentOrg?.id) { + setIsLoading(false); + return; + } + + await updateOrg({ + orgId: currentOrg.id, + blockDuplicateSecretSyncDestinations: state + }); + + createNotification({ + text: `Successfully ${state ? "enabled" : "disabled"} blocking duplicate secret sync destinations for this organization`, + type: "success" + }); + } catch (err) { + console.error(err); + createNotification({ + text: "Failed to update blocking duplicate secret sync destinations setting for this organization", + type: "error" + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

Secrets Management

+
+
+
+

+ Unique Secret Sync Destination Policy +

+

+ When enabled, ensures each destination can only be used by one secret sync + configuration, preventing potential conflicts or overwrites. +

+
+ + {(isAllowed) => ( + handleToggle(state as boolean)} + /> + )} + +
+
+ ); +}; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/index.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/index.tsx new file mode 100644 index 000000000..c2b12e31f --- /dev/null +++ b/frontend/src/pages/organization/SettingsPage/components/OrgProductSettingsTab/index.tsx @@ -0,0 +1 @@ +export { OrgProductSettingsTab } from "./OrgProductSettingsTab"; diff --git a/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx b/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx index 10eb0f6c0..85d4b259c 100644 --- a/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/OrgTabGroup/OrgTabGroup.tsx @@ -10,6 +10,7 @@ import { ExternalMigrationsTab } from "../ExternalMigrationsTab"; import { KmipTab } from "../KmipTab/OrgKmipTab"; import { OrgEncryptionTab } from "../OrgEncryptionTab"; import { OrgGeneralTab } from "../OrgGeneralTab"; +import { OrgProductSettingsTab } from "../OrgProductSettingsTab"; import { OrgProvisioningTab } from "../OrgProvisioningTab"; import { OrgSecurityTab } from "../OrgSecurityTab"; import { OrgSsoTab } from "../OrgSsoTab"; @@ -63,7 +64,12 @@ export const OrgTabGroup = () => { key: "project-templates", component: ProjectTemplatesTab }, - { name: "KMIP", key: "kmip", component: KmipTab } + { name: "KMIP", key: "kmip", component: KmipTab }, + { + name: "Product Enforcements", + key: "product-enforcements", + component: OrgProductSettingsTab + } ]; const [selectedTab, setSelectedTab] = useState(search.selectedTab || tabs[0].key); diff --git a/frontend/src/pages/organization/SettingsPage/components/index.tsx b/frontend/src/pages/organization/SettingsPage/components/index.tsx index 648613642..82909f799 100644 --- a/frontend/src/pages/organization/SettingsPage/components/index.tsx +++ b/frontend/src/pages/organization/SettingsPage/components/index.tsx @@ -1 +1,2 @@ +export { OrgProductSettingsTab } from "./OrgProductSettingsTab"; export { OrgTabGroup } from "./OrgTabGroup";