Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/api/src/app/agents/agents.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import {
UseInterceptors,
} from '@nestjs/common';
import { ApiExcludeController, ApiOperation } from '@nestjs/swagger';
import { RequirePermissions } from '@novu/application-generic';
import { ApiRateLimitCategoryEnum, DirectionEnum, PermissionsEnum, UserSessionData } from '@novu/shared';
import { ProductFeature, RequirePermissions } from '@novu/application-generic';
import { ApiRateLimitCategoryEnum, DirectionEnum, PermissionsEnum, ProductFeatureKeyEnum, UserSessionData } from '@novu/shared';
import { RequireAuthentication } from '../auth/framework/auth.decorator';
import { ExternalApiAccessible } from '../auth/framework/external-api.decorator';
import { ThrottlerCategory } from '../rate-limiting/guards';
Expand Down Expand Up @@ -262,6 +262,7 @@ export class AgentsController {

@Post('/:identifier/test-email')
@HttpCode(HttpStatus.OK)
@ProductFeature(ProductFeatureKeyEnum.AGENT_EMAIL_INTEGRATION)
@ApiOperation({
summary: 'Send a test email to the agent inbound address',
description:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { randomBytes } from 'node:crypto';
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { ConflictException, HttpException, HttpStatus, Injectable, NotFoundException } from '@nestjs/common';
import { encryptSecret } from '@novu/application-generic';
import { AgentIntegrationRepository, AgentRepository, IntegrationRepository } from '@novu/dal';
import { EmailProviderIdEnum } from '@novu/shared';
import { AgentIntegrationRepository, AgentRepository, CommunityOrganizationRepository, IntegrationRepository } from '@novu/dal';
import { ApiServiceLevelEnum, EmailProviderIdEnum, FeatureNameEnum, getFeatureForTierAsBoolean } from '@novu/shared';

import type { AgentIntegrationResponseDto } from '../../dtos';
import { toAgentIntegrationResponse } from '../../mappers/agent-response.mapper';
Expand All @@ -13,7 +13,8 @@ export class AddAgentIntegration {
constructor(
private readonly agentRepository: AgentRepository,
private readonly integrationRepository: IntegrationRepository,
private readonly agentIntegrationRepository: AgentIntegrationRepository
private readonly agentIntegrationRepository: AgentIntegrationRepository,
private readonly organizationRepository: CommunityOrganizationRepository
) {}

async execute(command: AddAgentIntegrationCommand): Promise<AgentIntegrationResponseDto> {
Expand Down Expand Up @@ -58,6 +59,7 @@ export class AddAgentIntegration {
}

if (integration.providerId === EmailProviderIdEnum.NovuAgent) {
await this.enforceEmailTier(command.organizationId);
await this.prepareNovuEmailIntegration(agent._id, integration._id, command);
}

Expand All @@ -71,6 +73,16 @@ export class AddAgentIntegration {
return toAgentIntegrationResponse(link, integration);
}

private async enforceEmailTier(organizationId: string): Promise<void> {
const organization = await this.organizationRepository.findById(organizationId);
const tier = organization?.apiServiceLevel ?? ApiServiceLevelEnum.FREE;
const allowed = getFeatureForTierAsBoolean(FeatureNameEnum.AGENT_EMAIL_INTEGRATION, tier);

if (!allowed) {
throw new HttpException('Payment Required', HttpStatus.PAYMENT_REQUIRED);
}
}

/**
* Enforces the singleton constraint (one NovuAgent email integration per
* agent) and seeds the `secretKey` credential the email adapter needs for
Expand Down
136 changes: 112 additions & 24 deletions apps/dashboard/src/components/agents/provider-dropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ import {
} from '@novu/shared';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { RiAddLine, RiExpandUpDownLine, RiLoader4Line, RiSearchLine } from 'react-icons/ri';
import { RiAddLine, RiExpandUpDownLine, RiExternalLinkLine, RiLoader4Line, RiLockStarLine, RiSearchLine } from 'react-icons/ri';
import { useNavigate } from 'react-router-dom';
import { addAgentIntegration, getAgentDetailQueryKey, getAgentIntegrationsQueryKey } from '@/api/agents';
import { NovuApiError } from '@/api/api.client';
import { createIntegration } from '@/api/integrations';
Expand All @@ -23,15 +24,21 @@ import {
} from '@/components/primitives/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/primitives/popover';
import { showErrorToast, showSuccessToast } from '@/components/primitives/sonner-helpers';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/primitives/tooltip';
import { IS_SELF_HOSTED, SELF_HOSTED_UPGRADE_REDIRECT_URL } from '@/config';
import { requireEnvironment, useEnvironment } from '@/context/environment/hooks';
import { useFetchIntegrations } from '@/hooks/use-fetch-integrations';
import { useIsAgentEmailAvailable } from '@/hooks/use-is-agent-email-available';
import { QueryKeys } from '@/utils/query-keys';
import { ROUTES } from '@/utils/routes';
import { openInNewTab } from '@/utils/url';
import { cn } from '@/utils/ui';

type DropdownItem = {
providerId: string;
displayName: string;
comingSoon: boolean;
requiresBusinessTier: boolean;
integration?: IIntegration;
};

Expand Down Expand Up @@ -72,6 +79,7 @@ function buildDropdownItems(
providerId: cp.providerId,
displayName: cp.displayName,
comingSoon: true,
requiresBusinessTier: false,
});
continue;
}
Expand All @@ -84,6 +92,7 @@ function buildDropdownItems(
providerId: cp.providerId,
displayName: integration.name || providerConfig?.displayName || cp.displayName,
comingSoon: false,
requiresBusinessTier: cp.requiresBusinessTier ?? false,
integration,
});
}
Expand All @@ -95,6 +104,7 @@ function buildDropdownItems(
providerId: cp.providerId,
displayName: providerConfig?.displayName || cp.displayName,
comingSoon: false,
requiresBusinessTier: cp.requiresBusinessTier ?? false,
});
}
}
Expand Down Expand Up @@ -132,6 +142,8 @@ export function ProviderDropdown({
const { integrations } = useFetchIntegrations();
const { currentEnvironment } = useEnvironment();
const queryClient = useQueryClient();
const navigate = useNavigate();
const isAgentEmailAvailable = useIsAgentEmailAvailable();

const { supported: allSupported, comingSoon } = useMemo(
() => buildDropdownItems(CONVERSATIONAL_PROVIDERS, integrations),
Expand Down Expand Up @@ -225,6 +237,10 @@ export function ProviderDropdown({
return;
}

if (item.requiresBusinessTier && !isAgentEmailAvailable) {
return;
}

const environment = currentEnvironment;

if (!environment?._id) {
Expand Down Expand Up @@ -353,41 +369,113 @@ export function ProviderDropdown({
{supported.map((item, index) => {
const itemKey = getSupportedItemKey(item, index);
const isRowPending = pendingItemKey === itemKey;
const isLocked = item.requiresBusinessTier && !isAgentEmailAvailable;

const rowContent = (
<div className="flex w-full items-center gap-1">
<ProviderIcon
providerId={item.providerId}
providerDisplayName={item.displayName}
className="size-4 shrink-0"
/>
<span className="text-text-sub text-label-xs flex-1 font-medium leading-4">{item.displayName}</span>

{isRowPending && (
<RiLoader4Line className="text-text-soft size-3 shrink-0 animate-spin" aria-hidden />
)}
{!isRowPending && isLocked && (
<div className="flex items-center gap-1 rounded bg-red-50 px-1.5 py-0.5">
<RiLockStarLine className="size-2.5 text-pink-600" />
<span
className="text-[9px] font-semibold uppercase leading-none"
style={{
background: 'linear-gradient(225deg, #FF884D 23.17%, #E300BD 80.17%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
Team+
</span>
</div>
)}
{!isRowPending && !isLocked && item.integration && item.providerId !== EmailProviderIdEnum.NovuAgent && (
<span className="font-code text-text-sub shrink-0 text-[10px] leading-[15px] tracking-[-0.2px]">
{item.integration.identifier}
</span>
)}
{!isRowPending && !isLocked && !item.integration && (
<RiAddLine className="text-text-soft size-3 shrink-0" />
)}
</div>
);

return (
<CommandItem
key={itemKey}
value={`${item.displayName} ${item.providerId}${item.integration ? ` ${item.integration.identifier}` : ''}`}
disabled={isBusy}
disabled={isBusy || isLocked}
onSelect={() => {
void handleSelect(item, index);
}}
className={cn(
'flex items-center gap-2 rounded-md p-1',
item.integration?._id === selectedIntegrationId && 'bg-bg-muted'
item.integration?._id === selectedIntegrationId && 'bg-bg-muted',
isLocked && '!pointer-events-auto opacity-60'
)}
>
<div className="flex flex-1 items-center gap-1">
<ProviderIcon
providerId={item.providerId}
providerDisplayName={item.displayName}
className="size-4 shrink-0"
/>
<span className="text-text-sub text-label-xs flex-1 font-medium leading-4">
{item.displayName}
</span>
</div>

{isRowPending && (
<RiLoader4Line className="text-text-soft size-3 shrink-0 animate-spin" aria-hidden />
)}
{!isRowPending && item.integration && item.providerId !== EmailProviderIdEnum.NovuAgent && (
<span className="font-code text-text-sub shrink-0 text-[10px] leading-[15px] tracking-[-0.2px]">
{item.integration.identifier}
</span>
)}
{!isRowPending && !item.integration && (
<RiAddLine className="text-text-soft size-3 shrink-0" />
{isLocked ? (
<Tooltip>
<TooltipTrigger asChild>
{rowContent}
</TooltipTrigger>
<TooltipContent
side="right"
align="start"
variant="light"
size="lg"
className="flex w-64 flex-col items-start gap-3 border border-neutral-100 p-2 shadow-md"
>
<div className="flex items-center gap-1 rounded bg-red-50 px-2 py-1">
<RiLockStarLine className="h-3 w-3 text-pink-600" />
<span
className="text-[10px] font-medium uppercase leading-normal"
style={{
background: 'linear-gradient(225deg, #FF884D 23.17%, #E300BD 80.17%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
}}
>
Team feature
</span>
</div>
<div className="flex flex-col items-start gap-3">
<p className="text-xs text-neutral-500">
Agent email requires the Team plan. Upgrade to connect an inbound email address.
</p>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setOpen(false);
if (IS_SELF_HOSTED) {
openInNewTab(
`${SELF_HOSTED_UPGRADE_REDIRECT_URL}?utm_campaign=agent_email_integration`
);
} else {
navigate(`${ROUTES.SETTINGS_BILLING}?utm_source=agent_provider_dropdown`);
}
}}
className="flex items-center gap-1 text-xs font-medium text-neutral-900 hover:underline"
>
Upgrade plan <RiExternalLinkLine className="h-3 w-3" />
</button>
</div>
</TooltipContent>
</Tooltip>
) : (
rowContent
)}
</CommandItem>
Comment on lines 413 to 480
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keyboard/a11y: locked rows may be unreachable, hiding the upgrade CTA.

Disabled CommandItems are typically skipped by cmdk's keyboard navigation, which means keyboard-only users won't be able to focus the locked row, trigger the tooltip, or reach the "Upgrade plan" button. The !pointer-events-auto class lets mouse hover work but does not help keyboard access.

Consider one of:

  • Keep the row focusable (not disabled) and handle the "no-op" in onSelect/handleSelect only, so the tooltip opens on focus and the upgrade button is reachable via Tab.
  • Or render a standalone always-visible "Upgrade" link/button next to the locked provider (outside the tooltip), so the CTA is always reachable regardless of input modality.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/src/components/agents/provider-dropdown.tsx` around lines 413
- 480, The locked rows are rendered with disabled={isBusy || isLocked}, which
makes them unreachable to keyboard users; remove disabling for the isLocked case
on the CommandItem (keep disabled only for isBusy) and instead handle the locked
case in the onSelect handler: if isLocked, prevent selection (no-op) and
open/focus the tooltip/upgrade CTA (e.g., call setOpen(true) or focus the
TooltipTrigger) so the tooltip and its "Upgrade plan" button become reachable
via keyboard; update handleSelect/onSelect logic to early-return for locked
items and ensure the TooltipTrigger wrapping rowContent remains focusable.

);
Expand Down
11 changes: 11 additions & 0 deletions apps/dashboard/src/hooks/use-is-agent-email-available.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ApiServiceLevelEnum, FeatureNameEnum, getFeatureForTierAsBoolean } from '@novu/shared';
import { useFetchSubscription } from '@/hooks/use-fetch-subscription';

export function useIsAgentEmailAvailable(): boolean {
const { subscription } = useFetchSubscription();

return getFeatureForTierAsBoolean(
FeatureNameEnum.AGENT_EMAIL_INTEGRATION,
subscription?.apiServiceLevel ?? ApiServiceLevelEnum.FREE
);
}
10 changes: 10 additions & 0 deletions packages/shared/src/consts/feature-tiers-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ export enum FeatureNameEnum {

// Domains Features
DOMAINS_BOOLEAN = 'domainsBoolean',

// Agent Features
AGENT_EMAIL_INTEGRATION = 'agentEmailIntegration',
}

export type FeatureValue = string | number | null | boolean | DetailedPriceListItem;
Expand Down Expand Up @@ -491,6 +494,13 @@ const novuServiceTiers: Record<FeatureNameEnum, Record<ApiServiceLevelEnum, Feat
[ApiServiceLevelEnum.ENTERPRISE]: { label: 'Custom domains', value: true },
[ApiServiceLevelEnum.UNLIMITED]: { label: 'Custom domains', value: true },
},
[FeatureNameEnum.AGENT_EMAIL_INTEGRATION]: {
[ApiServiceLevelEnum.FREE]: { label: 'Agent email integration', value: false },
[ApiServiceLevelEnum.PRO]: { label: 'Agent email integration', value: false },
[ApiServiceLevelEnum.BUSINESS]: { label: 'Agent email integration', value: true },
[ApiServiceLevelEnum.ENTERPRISE]: { label: 'Agent email integration', value: true },
[ApiServiceLevelEnum.UNLIMITED]: { label: 'Agent email integration', value: true },
},
};

export function isDetailedPriceListItem(item: FeatureValue): item is DetailedPriceListItem {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ const featureAccessAtoFeatureNameMapping: Record<ProductFeatureKeyEnum, FeatureN
[ProductFeatureKeyEnum.TRANSLATIONS]: FeatureNameEnum.AUTO_TRANSLATIONS,
[ProductFeatureKeyEnum.MANAGE_ENVIRONMENTS]: FeatureNameEnum.CUSTOM_ENVIRONMENTS_BOOLEAN,
[ProductFeatureKeyEnum.WEBHOOKS]: FeatureNameEnum.WEBHOOKS,
[ProductFeatureKeyEnum.AGENT_EMAIL_INTEGRATION]: FeatureNameEnum.AGENT_EMAIL_INTEGRATION,
} as const;

function createProductFeatureMap(): Record<ProductFeatureKeyEnum, ApiServiceLevelEnum[]> {
const productFeatures: Record<ProductFeatureKeyEnum, ApiServiceLevelEnum[]> = {
[ProductFeatureKeyEnum.TRANSLATIONS]: [],
[ProductFeatureKeyEnum.MANAGE_ENVIRONMENTS]: [],
[ProductFeatureKeyEnum.WEBHOOKS]: [],
[ProductFeatureKeyEnum.AGENT_EMAIL_INTEGRATION]: [],
};

for (const apiServiceLevel of Object.values(ApiServiceLevelEnum)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ export type ConversationalProvider = {
providerId: string;
displayName: string;
comingSoon?: boolean;
requiresBusinessTier?: boolean;
};

export const CONVERSATIONAL_PROVIDERS: ConversationalProvider[] = [
{ providerId: ChatProviderIdEnum.Slack, displayName: 'Slack' },
{ providerId: ChatProviderIdEnum.MsTeams, displayName: 'MS Teams' },
{ providerId: ChatProviderIdEnum.WhatsAppBusiness, displayName: 'WhatsApp Business' },
{ providerId: EmailProviderIdEnum.NovuAgent, displayName: 'Novu Email' },
{ providerId: EmailProviderIdEnum.NovuAgent, displayName: 'Novu Email', requiresBusinessTier: true },
{ providerId: 'telegram', displayName: 'Telegram', comingSoon: true },
{ providerId: 'google-chat', displayName: 'Google Chat', comingSoon: true },
{ providerId: 'linear', displayName: 'Linear', comingSoon: true },
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/types/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ export enum ProductFeatureKeyEnum {
TRANSLATIONS = 'TRANSLATIONS',
MANAGE_ENVIRONMENTS = 'MANAGE_ENVIRONMENTS',
WEBHOOKS = 'WEBHOOKS',
AGENT_EMAIL_INTEGRATION = 'AGENT_EMAIL_INTEGRATION',
}
Loading