diff --git a/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts b/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts index 77986b71919..56fb73f96d6 100644 --- a/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts +++ b/apps/api/src/app/agents/usecases/update-agent/update-agent.usecase.ts @@ -29,8 +29,25 @@ export class UpdateAgent { throw new BadRequestException('At least one field must be provided.'); } - if (command.devBridgeActive === true || (command.devBridgeUrl !== undefined && command.devBridgeUrl !== null)) { - await this.assertNotProductionEnvironment(command.environmentId, command.organizationId); + const hasReadOnlyFields = + command.name !== undefined || + command.description !== undefined || + hasBehaviorFields; + + if (hasReadOnlyFields) { + await this.assertNotProduction( + command.environmentId, + command.organizationId, + 'Only the active status and bridge URL can be modified in production environments.' + ); + } + + if (command.devBridgeActive !== undefined || command.devBridgeUrl !== undefined) { + await this.assertNotProduction( + command.environmentId, + command.organizationId, + 'Dev bridge settings cannot be modified in production environments.' + ); } // The bridge executor `fetch()`s these URLs from inside the API process on every @@ -113,14 +130,14 @@ export class UpdateAgent { return toAgentResponse(updated); } - private async assertNotProductionEnvironment(environmentId: string, organizationId: string): Promise { + private async assertNotProduction(environmentId: string, organizationId: string, message: string): Promise { const environment = await this.environmentRepository.findOne( { _id: environmentId, _organizationId: organizationId }, ['type', 'name'] ); if (environment?.type === EnvironmentTypeEnum.PROD) { - throw new ForbiddenException('Dev bridge cannot be activated on production environments.'); + throw new ForbiddenException(message); } } diff --git a/apps/dashboard/src/api/environments.ts b/apps/dashboard/src/api/environments.ts index 044e0ba6374..275c64d9c6f 100644 --- a/apps/dashboard/src/api/environments.ts +++ b/apps/dashboard/src/api/environments.ts @@ -84,7 +84,7 @@ export interface IEnvironmentPublishResponse { } export type ResourceToPublish = { - resourceType: 'workflow' | 'layout' | 'localization_group' | 'step'; + resourceType: 'workflow' | 'layout' | 'localization_group' | 'step' | 'agent'; resourceId: string; }; diff --git a/apps/dashboard/src/components/agents/agent-details-header.tsx b/apps/dashboard/src/components/agents/agent-details-header.tsx index aeaeb3a2a87..70ccb5a4fb3 100644 --- a/apps/dashboard/src/components/agents/agent-details-header.tsx +++ b/apps/dashboard/src/components/agents/agent-details-header.tsx @@ -10,6 +10,7 @@ import { DropdownMenuTrigger, } from '@/components/primitives/dropdown-menu'; import { Skeleton } from '@/components/primitives/skeleton'; +import { useEnvironment } from '@/context/environment/hooks'; import { useHasPermission } from '@/hooks/use-has-permission'; type AgentDetailsHeaderProps = { @@ -20,6 +21,7 @@ type AgentDetailsHeaderProps = { export function AgentDetailsHeader({ agent, isLoading, onRequestDelete }: AgentDetailsHeaderProps) { const has = useHasPermission(); + const { readOnly } = useEnvironment(); const canWrite = has({ permission: PermissionsEnum.AGENT_WRITE }); if (isLoading || !agent) { @@ -74,6 +76,7 @@ export function AgentDetailsHeader({ agent, isLoading, onRequestDelete }: AgentD { setTimeout(() => onRequestDelete(agent), 0); }} diff --git a/apps/dashboard/src/components/agents/agent-integration-guides/agent-integration-guide-layout.tsx b/apps/dashboard/src/components/agents/agent-integration-guides/agent-integration-guide-layout.tsx index 68504e2ce3f..ba4bde2c56d 100644 --- a/apps/dashboard/src/components/agents/agent-integration-guides/agent-integration-guide-layout.tsx +++ b/apps/dashboard/src/components/agents/agent-integration-guides/agent-integration-guide-layout.tsx @@ -13,6 +13,15 @@ import { } from '@/components/primitives/dropdown-menu'; import { API_HOSTNAME } from '@/config'; +export type AgentIntegrationGuideHeaderProps = { + providerId: string; + providerDisplayName: string; + integrationLink: AgentIntegrationLink; + canRemoveIntegration: boolean; + onRequestRemoveIntegration?: () => void; + isRemovingIntegration?: boolean; +}; + type AgentIntegrationGuideLayoutProps = { providerDisplayName: string; providerId: string; @@ -38,86 +47,67 @@ function buildWebhookUrl(agentId: string, integrationIdentifier: string): string return `${baseUrl}/v1/agents/${agentId}/webhook/${integrationIdentifier}`; } -export function AgentIntegrationGuideLayout({ - providerDisplayName, +export function AgentIntegrationGuideHeader({ providerId, - onBack, - children, - embedded = false, - agent, + providerDisplayName, integrationLink, canRemoveIntegration, onRequestRemoveIntegration, isRemovingIntegration = false, -}: AgentIntegrationGuideLayoutProps) { - const webhookUrlId = useId(); - const isActive = integrationLink?.integration.active ?? false; - const integrationIdentifier = integrationLink?.integration.identifier; - const createdAt = integrationLink?.createdAt; - const webhookUrl = buildWebhookUrl(agent._id, integrationIdentifier ?? 'YOUR_INTEGRATION_IDENTIFIER'); +}: AgentIntegrationGuideHeaderProps) { + const isConnected = Boolean(integrationLink.connectedAt); + const integrationIdentifier = integrationLink.integration.identifier; + const createdAt = integrationLink.createdAt; return ( -
- {!embedded && ( - - Back to integrations - - )} - -
-
-
-
- - {providerDisplayName} -
- {isActive ? ( - - - - - Active - - ) : ( - - - - - Action needed - - )} +
+
+
+
+ + {providerDisplayName}
- - {integrationIdentifier ? ( -
- - {integrationIdentifier} + {isConnected ? ( + + + - {createdAt ? ( - <> - - - Created - {formatCreatedDate(createdAt)} - - - ) : null} -
- ) : null} + Connected + + ) : ( + + + + + Action needed + + )}
- {integrationLink && canRemoveIntegration && onRequestRemoveIntegration ? ( + {integrationIdentifier ? ( +
+ + {integrationIdentifier} + + {createdAt ? ( + <> + + + Created + {formatCreatedDate(createdAt)} + + + ) : null} +
+ ) : null} +
+ +
+ {onRequestRemoveIntegration ? (
+
+
+ ); +} + +export function AgentIntegrationGuideLayout({ + providerDisplayName, + providerId, + onBack, + children, + embedded = false, + agent, + integrationLink, + canRemoveIntegration, + onRequestRemoveIntegration, + isRemovingIntegration = false, +}: AgentIntegrationGuideLayoutProps) { + const webhookUrlId = useId(); + const integrationIdentifier = integrationLink?.integration.identifier; + const webhookUrl = buildWebhookUrl(agent._id, integrationIdentifier ?? 'YOUR_INTEGRATION_IDENTIFIER'); + + return ( +
+ {!embedded && ( + + Back to integrations + + )} + + {integrationLink ? ( + + ) : null}

Agent metadata

diff --git a/apps/dashboard/src/components/agents/agent-integration-guides/resolve-agent-integration-guide.tsx b/apps/dashboard/src/components/agents/agent-integration-guides/resolve-agent-integration-guide.tsx index a81b9d1b943..2680f539b03 100644 --- a/apps/dashboard/src/components/agents/agent-integration-guides/resolve-agent-integration-guide.tsx +++ b/apps/dashboard/src/components/agents/agent-integration-guides/resolve-agent-integration-guide.tsx @@ -1,9 +1,11 @@ import { ChatProviderIdEnum, EmailProviderIdEnum } from '@novu/shared'; import type { AgentIntegrationLink, AgentResponse } from '@/api/agents'; import { EmailSetupGuide } from '@/components/agents/email-setup-guide'; +import { SetupGuideCard } from '@/components/agents/setup-guide-card'; import { SlackSetupGuide } from '@/components/agents/slack-setup-guide'; import { TeamsSetupGuide } from '@/components/agents/teams-setup-guide'; import { WhatsAppSetupGuide } from '@/components/agents/whatsapp-setup-guide'; +import { AgentIntegrationGuideHeader } from './agent-integration-guide-layout'; import { EmailAgentIntegrationGuide } from './email-agent-integration-guide'; import { GenericAgentIntegrationGuide } from './generic-agent-integration-guide'; import { SlackAgentIntegrationGuide } from './slack-agent-integration-guide'; @@ -20,6 +22,60 @@ type ResolveAgentIntegrationGuideProps = { isRemovingIntegration?: boolean; }; +type SetupGuideWrapperProps = { + providerId: string; + providerDisplayName: string; + integrationLink: AgentIntegrationLink; + canRemoveIntegration: boolean; + onRequestRemoveIntegration?: () => void; + isRemovingIntegration?: boolean; + children: React.ReactNode; +}; + +function SetupGuideWithHeader({ + providerId, + providerDisplayName, + integrationLink, + canRemoveIntegration, + onRequestRemoveIntegration, + isRemovingIntegration, + children, +}: SetupGuideWrapperProps) { + const isConnected = Boolean(integrationLink.connectedAt); + + const statusBadge = isConnected ? ( + + + + + Connected + + ) : ( + + + + + Action needed + + ); + + return ( +
+ + + {children} + +
+ ); +} + export function ResolveAgentIntegrationGuide({ integrationLink, onBack, @@ -32,7 +88,18 @@ export function ResolveAgentIntegrationGuide({ const providerId = integrationLink.integration.providerId; if (providerId === ChatProviderIdEnum.Slack && !integrationLink.connectedAt) { - return ; + return ( + + + + ); } if (providerId === ChatProviderIdEnum.Slack) { @@ -50,7 +117,18 @@ export function ResolveAgentIntegrationGuide({ } if (providerId === ChatProviderIdEnum.MsTeams && !integrationLink.connectedAt) { - return ; + return ( + + + + ); } if (providerId === ChatProviderIdEnum.MsTeams) { @@ -68,7 +146,18 @@ export function ResolveAgentIntegrationGuide({ } if (providerId === ChatProviderIdEnum.WhatsAppBusiness && !integrationLink.connectedAt) { - return ; + return ( + + + + ); } if (providerId === ChatProviderIdEnum.WhatsAppBusiness) { @@ -86,7 +175,18 @@ export function ResolveAgentIntegrationGuide({ } if (providerId === EmailProviderIdEnum.NovuAgent && !integrationLink.connectedAt) { - return ; + return ( + + + + ); } if (providerId === EmailProviderIdEnum.NovuAgent) { diff --git a/apps/dashboard/src/components/agents/agent-integrations-tab.tsx b/apps/dashboard/src/components/agents/agent-integrations-tab.tsx index 391629406c0..cb5a77a6eb2 100644 --- a/apps/dashboard/src/components/agents/agent-integrations-tab.tsx +++ b/apps/dashboard/src/components/agents/agent-integrations-tab.tsx @@ -15,6 +15,7 @@ import { NovuApiError } from '@/api/api.client'; import { ProviderIcon } from '@/components/integrations/components/provider-icon'; import { Skeleton } from '@/components/primitives/skeleton'; import { showErrorToast, showSuccessToast } from '@/components/primitives/sonner-helpers'; +import { InlineToast } from '@/components/primitives/inline-toast'; import { requireEnvironment, useEnvironment } from '@/context/environment/hooks'; import { useHasPermission } from '@/hooks/use-has-permission'; import { useTelemetry } from '@/hooks/use-telemetry'; @@ -215,11 +216,11 @@ export function AgentIntegrationsTab({ agent, integrationIdentifier }: AgentInte const navigate = useNavigate(); const location = useLocation(); const queryClient = useQueryClient(); - const { currentEnvironment } = useEnvironment(); + const { currentEnvironment, readOnly, oppositeEnvironment } = useEnvironment(); const has = useHasPermission(); const track = useTelemetry(); - const canRemoveAgentIntegration = has({ permission: PermissionsEnum.AGENT_WRITE }); + const canRemoveAgentIntegration = !readOnly && has({ permission: PermissionsEnum.AGENT_WRITE }); const integrationsHubPath = `${buildRoute(ROUTES.AGENT_DETAILS_TAB, { environmentSlug: currentEnvironment?.slug ?? '', @@ -400,8 +401,25 @@ export function AgentIntegrationsTab({ agent, integrationIdentifier }: AgentInte return (