crm-integration-card.tsx123.2 KBView on GitHub 'use client';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
DATE_FORMAT_OPTIONS,
type FieldMapping,
isSimpleMapping,
isComplexMapping,
normalizeFieldMappings,
extractPicklistValues,
extractPicklistOptions,
} from './field-mapping-utils';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Loader2,
Unplug,
Eye,
EyeOff,
ChevronDown,
ChevronUp,
X,
Plus,
RefreshCw,
} from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FieldRows, SelectField } from '@/components/ui/field';
import type { ProviderId } from '../../../server/src/services/integrations/constants';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { useTRPC, trpcClient } from '@/providers/query-provider';
import { useSession } from '@/modules/auth/utils/auth-client';
import { getAvailableCedarFields } from '@/modules/crm/utils';
import { Check, ChevronsUpDown } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { Checkbox } from '@/components/ui/checkbox';
import { CrmDealManager } from './crm-deal-manager';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { useAOPs } from '@/modules/aop';
import { resolveDealsAopId } from '@/modules/aop/utils/deals-aop';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
interface CrmIntegrationCardProps {
providerId: 'hubspot' | 'attio' | 'salesforce' | 'copper';
userId?: string;
showDealManager?: boolean;
userFacing?: boolean; // When true, hides the deal manager (for user-facing settings page)
}
export function CrmIntegrationCard({
providerId,
userId,
showDealManager = false,
userFacing = false,
}: CrmIntegrationCardProps) {
const [isConnecting, setIsConnecting] = useState(false);
const [isDisconnecting, setIsDisconnecting] = useState(false);
const [showDirectInputForm, setShowDirectInputForm] = useState(false);
const [formFields, setFormFields] = useState<Record<string, string>>({});
const [showApiKey, setShowApiKey] = useState(false);
const [showFieldSelection, setShowFieldSelection] = useState(false);
const [fieldMappings, setFieldMappings] = useState<Array<FieldMapping>>([]);
// Which AOP field mappings/filters/stage-requirements below are being configured FOR — the
// customer only ever picks an AOP by name; the CRM object it's bound to (Opportunity, a
// Lead, a Partner record, ...) is resolved server-side from AOP.boundObjectApiName. See
// docs/design/crm-custom-object-linkage-leads.md's "aopId, not objectApiName" principle.
const [selectedAopId, setSelectedAopId] = useState<string | null>(null);
const [isSavingFields, setIsSavingFields] = useState(false);
// Store raw input values for allowed values to preserve cursor position
const [allowedValuesInputs, setAllowedValuesInputs] = useState<Record<string, string>>({});
// Deal sync filter state - filter deals based on a CRM field value
const [dealSyncFilter, setDealSyncFilter] = useState<Record<string, string>>({});
const [showFilterConfig, setShowFilterConfig] = useState(false);
const [stageRequiredFields, setStageRequiredFields] = useState<Record<string, string[]>>({});
const [showStageRequiredConfig, setShowStageRequiredConfig] = useState(false);
const [stageSourceField, setStageSourceField] = useState<string | null>(null);
// Salesforce custom object config (admin-only, shown when userId is provided)
const [customObjectObjectName, setCustomObjectObjectName] = useState('');
const [customObjectFilterField, setCustomObjectFilterField] = useState('');
const [customObjectFieldsInput, setCustomObjectFieldsInput] = useState('');
const [customObjectLimit, setCustomObjectLimit] = useState('');
const [showCustomObjectConfig, setShowCustomObjectConfig] = useState(false);
const [isSavingCustomObjectConfig, setIsSavingCustomObjectConfig] = useState(false);
const trpc = useTRPC();
const queryClient = useQueryClient();
const { data: session } = useSession();
// Fetch integration status
const { data: integrationsData, refetch: refetchIntegrations } = useQuery(
trpc.integrations.list.queryOptions(userId ? { userId } : undefined),
);
const integration = integrationsData?.integrations.find((i) => i.id === providerId);
const isConnected = integration?.connected || false;
const connectionId = integration?.connectionId;
const connectionType = integration?.capabilities?.connectionType;
const isPersonalConnection = (integration as { isPersonalConnection?: boolean } | undefined)
?.isPersonalConnection;
const isOrgManaged = (integration as { isOrgManaged?: boolean } | undefined)?.isOrgManaged;
const connectionFields = useMemo(
() => integration?.capabilities?.connectionFields || [],
[integration?.capabilities?.connectionFields],
);
const apiKeyInstructions = (
integration?.capabilities as { apiKeyInstructions?: string } | undefined
)?.apiKeyInstructions;
const requiredFields = connectionFields;
const hasUpdateOpportunityFields =
(integration?.capabilities as { hasUpdateOpportunityFields?: boolean } | undefined)
?.hasUpdateOpportunityFields || false;
const defaultWriteFieldMappings = useMemo(
() =>
(
integration?.capabilities as
| {
defaultWriteFieldMappings?: Array<FieldMapping>;
}
| undefined
)?.defaultWriteFieldMappings || [],
[integration?.capabilities],
);
// Fetch AOPs — both for custom field definitions in the Cedar field dropdowns, and as the
// AOP picker's candidate list below (which object a mapping group targets).
const { data: aopsData } = useAOPs(userId);
// Default to whichever AOP looks like "the deals one" — same heuristic /statistics and the
// home rail already use (see resolveDealsAopId's own doc comment) — so a customer who never
// touches the picker keeps configuring the mapping group they always have.
useEffect(() => {
if (selectedAopId !== null || !aopsData?.aops) return;
const [firstAop] = aopsData.aops;
if (!firstAop) return;
setSelectedAopId(resolveDealsAopId(aopsData.aops) ?? firstAop.id);
}, [aopsData, selectedAopId]);
const selectedAop = useMemo(
() => aopsData?.aops?.find((aop) => aop.id === selectedAopId) ?? null,
[aopsData?.aops, selectedAopId],
);
// Read-only, internal: never rendered as a pickable option — the picker above only ever
// shows AOP names. "Opportunity" is treated the same as unset (the default Deal group).
const selectedObjectApiName =
selectedAop?.boundObjectApiName && selectedAop.boundObjectApiName !== 'Opportunity'
? selectedAop.boundObjectApiName
: undefined;
// Fetch CRM properties when connected (allow configuring mappings even if writes aren't enabled)
const shouldFetchProperties = isConnected && hasUpdateOpportunityFields && !!userId;
const {
data: propertiesData,
refetch: refetchProperties,
isFetching: isFetchingProperties,
} = useQuery({
queryKey: ['integrations', 'crm', 'getProperties', { providerId, userId: userId!, aopId: selectedAopId }],
queryFn: () =>
trpcClient.integrations.crm.getProperties.query({
providerId,
userId: userId!,
aopId: selectedAopId ?? undefined,
}),
enabled: shouldFetchProperties,
});
// Fetch current field mappings from connection metadata
const { data: connectionData } = useQuery({
queryKey: ['connections', connectionId],
queryFn: () =>
connectionId
? trpcClient.connections.get.query({ connectionId, userId })
: Promise.resolve(null),
enabled: !!connectionId && !!userId,
});
// Fetch org defaults for CRM settings
const { data: orgDefaultsData } = useQuery({
queryKey: ['integrations', 'crm', 'getOrgDefaults', { providerId }],
queryFn: () => trpcClient.integrations.crm.getOrgDefaults.query({ providerId }),
enabled: isConnected && !!userId,
});
// Load Salesforce custom object config (admin-only)
const { data: customObjectConfigData, refetch: refetchCustomObjectConfig } = useQuery({
queryKey: ['admin', 'customObjectConfig', userId, providerId],
queryFn: () =>
trpcClient.admin.crmSync.getCustomObjectConfig.query({ userId: userId!, provider: providerId }),
enabled: providerId === 'salesforce' && isConnected && !!userId,
});
useEffect(() => {
if (customObjectConfigData?.config) {
setCustomObjectObjectName(customObjectConfigData.config.object_name ?? '');
setCustomObjectFilterField(customObjectConfigData.config.filter_field ?? '');
setCustomObjectFieldsInput((customObjectConfigData.config.fields ?? []).join(', '));
setCustomObjectLimit(customObjectConfigData.config.limit ? String(customObjectConfigData.config.limit) : '');
}
}, [customObjectConfigData]);
const { mutateAsync: updateWriteSettings } = useMutation(
trpc.integrations.crm.updateWriteSettings.mutationOptions(),
);
const { mutateAsync: updateCrmSettings } = useMutation(
trpc.connections.updateCrmSettings.mutationOptions(),
);
// Get current settings from connection metadata
const currentSettings = useMemo(() => {
if (!connectionData?.metadata)
return {
periodicDealSyncEnabled: false,
externalCrmPushEnabled: false,
requireLinkedDealForAgentOutputs: false,
fetchUnownedDeals: false,
};
const metadata = connectionData.metadata as Record<string, unknown>;
const settings = (metadata.settings as Record<string, unknown>) || {};
return {
periodicDealSyncEnabled: (settings.periodicDealSyncEnabled as boolean) ?? false,
externalCrmPushEnabled: (settings.externalCrmPushEnabled as boolean) ?? false,
requireLinkedDealForAgentOutputs:
(settings.requireLinkedDealForAgentOutputs as boolean | undefined) ?? false,
fetchUnownedDeals: (settings.fetchUnownedDeals as boolean | undefined) ?? false,
};
}, [connectionData]);
// Check if settings differ from org defaults
const isCustomizedFromOrgDefaults = useMemo(() => {
if (!orgDefaultsData) return { periodicSync: false, crmWrites: false };
const orgSyncDefault = orgDefaultsData.defaultPeriodicDealSyncEnabled ?? false;
const orgWritesDefault = orgDefaultsData.defaultExternalCrmPushEnabled ?? false;
return {
periodicSync: currentSettings.periodicDealSyncEnabled !== orgSyncDefault,
crmWrites: currentSettings.externalCrmPushEnabled !== orgWritesDefault,
};
}, [orgDefaultsData, currentSettings]);
const handleSettingsChange = async (
setting:
| 'periodicDealSyncEnabled'
| 'externalCrmPushEnabled'
| 'requireLinkedDealForAgentOutputs'
| 'fetchUnownedDeals',
value: boolean,
) => {
if (!connectionId) {
toast.error('Connection ID not found');
return;
}
try {
await updateCrmSettings({
connectionId,
settings: { [setting]: value },
userId,
});
queryClient.invalidateQueries({ queryKey: ['connections', connectionId] });
} catch (error) {
console.error('[CrmIntegrationCard] Error updating settings:', error);
toast.error(
`Failed to update settings: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
// Initialize field mappings and deal sync filter from connection metadata or driver defaults.
// Reads the DEFAULT (flat) metadata group for the Deals AOP, or
// `metadata.objectSettings[selectedObjectApiName]` for any other AOP the picker selects — the
// SAME per-object namespace the connection settings toggles above (`updateCrmSettings`) and
// the pull-sync read path (`getEffectiveCrmToCedarMappings`) already use — and re-runs on
// every AOP switch, so switching away from a Lead mapping group back to Deals doesn't leave
// the Lead group's values sitting in the form.
useEffect(() => {
if (isConnected && hasUpdateOpportunityFields && connectionData) {
const metadata = (connectionData.metadata as Record<string, unknown>) || {};
const objectSettings = metadata.objectSettings as
| Record<string, Record<string, unknown>>
| undefined;
const scoped = selectedObjectApiName ? objectSettings?.[selectedObjectApiName] ?? {} : metadata;
const rawMappings = Array.isArray(scoped.fieldMappings) ? scoped.fieldMappings : [];
const mappings = normalizeFieldMappings(rawMappings);
if (mappings.length > 0) {
setFieldMappings(mappings);
} else if (defaultWriteFieldMappings.length > 0 && !selectedObjectApiName) {
// Driver-provided defaults are Deal-shaped only — never applied to another object's
// (empty) mapping group, which should read as genuinely unconfigured instead.
const normalizedDefaults = normalizeFieldMappings(
defaultWriteFieldMappings as Array<unknown>,
);
setFieldMappings(normalizedDefaults);
} else {
setFieldMappings([]);
}
// `scoped` is a raw JSONB blob (`connection.metadata`, typed `unknown` at this boundary) —
// dealSyncFilter/stageRequiredFields/stageSourceField below are cast, not validated,
// because the ONLY writers of this slot (`updateWriteSettings` in
// trpc/routes/integrations.ts, `computeUpdatedSyncSettings` in connection-config.ts) already
// validate these shapes (DealSyncFilterSchema, etc.) before persisting — this read side
// trusts that contract rather than re-validating it.
const savedFilter =
typeof scoped.dealSyncFilter === 'object' && scoped.dealSyncFilter !== null
? (scoped.dealSyncFilter as Record<string, string>)
: undefined;
setDealSyncFilter(savedFilter ?? {});
const savedStageRequired =
typeof scoped.stageRequiredFields === 'object' && scoped.stageRequiredFields !== null
? (scoped.stageRequiredFields as Record<string, string[]>)
: undefined;
setStageRequiredFields(
savedStageRequired && Object.keys(savedStageRequired).length > 0 ? savedStageRequired : {},
);
const savedStageField =
typeof scoped.stageSourceField === 'string' ? scoped.stageSourceField : undefined;
setStageSourceField(savedStageField ?? null);
} else if (
isConnected &&
hasUpdateOpportunityFields &&
defaultWriteFieldMappings.length > 0 &&
!selectedObjectApiName
) {
const normalizedDefaults = normalizeFieldMappings(
defaultWriteFieldMappings as Array<unknown>,
);
setFieldMappings(normalizedDefaults);
}
}, [
isConnected,
hasUpdateOpportunityFields,
connectionData,
defaultWriteFieldMappings,
selectedObjectApiName,
]);
// When the user picks a stage source field, populate rows from that field's picklist values
const handleStageSourceFieldChange = (fieldName: string) => {
setStageSourceField(fieldName);
const raw = propertiesData?.picklist_fields?.[fieldName];
const values: string[] = !raw
? []
: (raw as Array<string | { label: string; value: string }>).map((v) =>
typeof v === 'string' ? v : v.label,
);
setStageRequiredFields((prev) => {
const updated: Record<string, string[]> = {};
for (const v of values) {
updated[v] = prev[v] ?? [];
}
return updated;
});
};
// Fetch credentials for API key providers when connected
const isApiKeyProvider = connectionType === 'api_key';
const shouldFetchCredentials = isConnected && isApiKeyProvider && !!userId;
const { data: credentialsData } = useQuery({
queryKey: ['integrations', 'crm', 'getCredentials', { providerId, userId: userId! }],
queryFn: () =>
trpcClient.integrations.crm.getCredentials.query({ providerId, userId: userId! }),
enabled: shouldFetchCredentials,
});
const { mutateAsync: initiateOAuth } = useMutation(
trpc.integrations.initiateOAuth.mutationOptions(),
);
const { mutateAsync: connect } = useMutation(trpc.integrations.connect.mutationOptions());
const { mutateAsync: deleteConnection } = useMutation(trpc.connections.delete.mutationOptions());
// Populate form fields with credentials when API key provider is connected
useEffect(() => {
if (
isConnected &&
connectionType === 'api_key' &&
credentialsData &&
typeof credentialsData === 'object' &&
credentialsData !== null
) {
setFormFields(credentialsData as Record<string, string>);
}
}, [isConnected, connectionType, credentialsData]);
// Populate email field with user's email if empty (for API key providers with userEmail field)
// This runs when the form is shown and email is empty
useEffect(() => {
if (
connectionType === 'api_key' &&
session?.user?.email &&
(showDirectInputForm || (isConnected && connectionType === 'api_key'))
) {
const hasEmailField = connectionFields.some((field) => field.key === 'userEmail');
if (hasEmailField) {
setFormFields((prev) => {
// Only set email if it's not already set or is empty
if (!prev.userEmail || prev.userEmail.trim() === '') {
return { ...prev, userEmail: session.user.email };
}
return prev;
});
}
}
}, [connectionType, session?.user?.email, showDirectInputForm, isConnected, connectionFields]);
// Handle OAuth callback
useEffect(() => {
const handleWindowFocus = async () => {
const storageKey=[redacted];
const pendingAuth = sessionStorage.getItem(storageKey);
if (pendingAuth && isConnecting) {
console.log(
`[CrmIntegrationCard] User returned from OAuth tab for ${providerId}, checking connection...`,
);
try {
const { strataServerUrl } = JSON.parse(pendingAuth);
await connect({
providerId: providerId as ProviderId,
connectionParams: { strataServerUrl },
userId,
});
sessionStorage.removeItem(storageKey);
setIsConnecting(false);
void refetchIntegrations();
queryClient.invalidateQueries({ queryKey=[redacted] });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Ignore expected auth-in-progress errors
const isExpectedError =
errorMessage.includes('not authenticated') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('Please complete OAuth flow');
if (!isExpectedError) {
console.error(`[CrmIntegrationCard] Error connecting ${providerId}:`, error);
sessionStorage.removeItem(storageKey);
setIsConnecting(false);
toast.error(`Failed to connect ${providerId}: ${errorMessage}`);
}
}
}
};
window.addEventListener('focus', handleWindowFocus);
return () => window.removeEventListener('focus', handleWindowFocus);
}, [isConnecting, connect, providerId, refetchIntegrations, trpc, userId, queryClient]);
const handleConnect = async () => {
// Check if this provider requires direct API key input (connectionType === 'api_key')
if (connectionType === 'api_key' && requiredFields.length > 0) {
setShowDirectInputForm(true);
// Initialize form fields
const initialFields: Record<string, string> = {};
requiredFields.forEach((field) => {
// Populate email from session if available and field is userEmail
if (field.key === 'userEmail' && session?.user?.email) {
initialFields[field.key] = session.user.email;
} else {
initialFields[field.key] = '';
}
});
setFormFields(initialFields);
return;
}
// For manual connection type, show message
if (connectionType === 'manual') {
toast.info('This provider requires manual setup. The Cedar team will walk you through it :)');
return;
}
// Otherwise, use OAuth flow (connectionType === 'oauth' or undefined)
try {
setIsConnecting(true);
const { oauthUrl, strataServerUrl } = await initiateOAuth({
integration: providerId,
userId,
});
if (!oauthUrl) {
setIsConnecting(false);
toast.error('Failed to get OAuth URL');
return;
}
sessionStorage.setItem(`${providerId}_oauth_pending`, JSON.stringify({ strataServerUrl }));
window.open(oauthUrl, '_blank');
} catch (error) {
console.error(`[CrmIntegrationCard] Error initiating auth for ${providerId}:`, error);
setIsConnecting(false);
toast.error(`Failed to initiate connection for ${providerId}`);
}
};
const handleDirectInputConnect = async () => {
const providerName = providerId.charAt(0).toUpperCase() + providerId.slice(1);
// Validate all required fields are filled
const missingFields = requiredFields.filter((field) => !formFields[field.key]?.trim());
if (missingFields.length > 0) {
toast.error(
`Please fill in all required fields: ${missingFields.map((f) => f.label).join(', ')}`,
);
return;
}
try {
setIsConnecting(true);
await connect({
providerId: providerId as ProviderId,
connectionParams: formFields,
userId,
});
// Only reset form for non-update flows (when not connected)
if (!isUpdate) {
setShowDirectInputForm(false);
setFormFields({});
}
setIsConnecting(false);
void refetchIntegrations();
queryClient.invalidateQueries({ queryKey=[redacted] });
} catch (error) {
console.error(
`[CrmIntegrationCard] Error ${isUpdate ? 'updating' : 'connecting'} ${providerId}:`,
error,
);
setIsConnecting(false);
toast.error(
`Failed to connect ${providerName}: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
const handleDisconnect = async () => {
try {
setIsDisconnecting(true);
if (connectionId) {
await deleteConnection({
connectionId,
userId,
});
void refetchIntegrations();
queryClient.invalidateQueries({ queryKey=[redacted] });
} else {
toast.error('Could not find connection ID to disconnect.');
}
} catch (error) {
console.error(`[CrmIntegrationCard] Error disconnecting ${providerId}:`, error);
toast.error(`Failed to disconnect ${providerId}`);
} finally {
setIsDisconnecting(false);
}
};
// Icon rendering
const renderIcon = () => {
if (providerId === 'hubspot') {
return (
<div className="flex h-5 w-5 items-center justify-center rounded bg-[#FF7A59] text-xs font-bold text-white">
H
</div>
);
} else if (providerId === 'copper') {
return (
<div className="flex h-5 w-5 items-center justify-center rounded bg-[#B87333] text-xs font-bold text-white">
C
</div>
);
} else if (providerId === 'salesforce') {
return (
<div className="flex h-5 w-5 items-center justify-center rounded bg-[#00A1E0] text-xs font-bold text-white">
S
</div>
);
} else {
return (
<div className="flex h-5 w-5 items-center justify-center rounded bg-black text-xs font-bold text-white">
A
</div>
);
}
};
const providerName = providerId.charAt(0).toUpperCase() + providerId.slice(1);
const buttonText = `Continue with ${providerName}`;
const isUpdate = isApiKeyProvider && isConnected;
const shouldShowForm =
isApiKeyProvider && isConnected
? true
: showDirectInputForm && connectionType === 'api_key' && requiredFields.length > 0;
// Convert field name to human-readable label (e.g., "NextStep" -> "Next Step")
const formatFieldLabel = (fieldName: string): string => {
// Convert camelCase/PascalCase to space-separated
return fieldName
.replace(/([A-Z])/g, ' $1')
.replace(/^./, (str) => str.toUpperCase())
.trim();
};
// Get available Cedar fields (core + custom + common values)
const availableCedarFields = useMemo(() => {
return getAvailableCedarFields(aopsData?.aops);
}, [aopsData?.aops]);
const handleSimpleMappingChange = (
crmFieldName: string,
cedarFieldName: string,
allowedValues?: string[],
) => {
setFieldMappings((prev) => {
const existing = prev.find((m) => m.crmFieldName === crmFieldName);
if (existing) {
// Update existing mapping, preserve allowedValues
const currentAllowedValues =
isSimpleMapping(existing) || isComplexMapping(existing)
? existing.allowedValues
: undefined;
return prev.map((m) =>
m.crmFieldName === crmFieldName
? { crmFieldName, cedarFieldName, allowedValues: allowedValues ?? currentAllowedValues }
: m,
);
} else {
// Add new mapping
return [...prev, { crmFieldName, cedarFieldName, allowedValues }];
}
});
};
const handleAllowedValuesChange = (crmFieldName: string, allowedValues: string[]) => {
setFieldMappings((prev) => {
return prev.map((m) => {
if (m.crmFieldName !== crmFieldName) return m;
if (isSimpleMapping(m)) {
return { ...m, allowedValues: allowedValues.length > 0 ? allowedValues : undefined };
} else if (isComplexMapping(m)) {
return { ...m, allowedValues: allowedValues.length > 0 ? allowedValues : undefined };
}
return m;
});
});
};
const handleComplexMappingChange = (
crmFieldName: string,
template: string,
transformations?: Record<string, { type: 'date' | 'number' | 'string'; format?: string }>,
allowedValues?: string[],
prefix?: string,
) => {
setFieldMappings((prev) => {
const existing = prev.find((m) => m.crmFieldName === crmFieldName);
if (existing) {
// Update existing mapping, preserve allowedValues and prefix
const currentAllowedValues =
isSimpleMapping(existing) || isComplexMapping(existing)
? existing.allowedValues
: undefined;
const currentPrefix = isComplexMapping(existing) ? existing.prefix : undefined;
return prev.map((m) =>
m.crmFieldName === crmFieldName
? {
crmFieldName,
template,
prefix: prefix !== undefined ? prefix : currentPrefix,
transformations,
allowedValues: allowedValues ?? currentAllowedValues,
}
: m,
);
} else {
// Add new mapping
return [...prev, { crmFieldName, template, prefix, transformations, allowedValues }];
}
});
};
const handleToggleMappingType = (crmFieldName: string, isSimple: boolean) => {
setFieldMappings((prev) => {
return prev.map((m) => {
if (m.crmFieldName !== crmFieldName) return m;
// Preserve allowedValues when toggling
const allowedValues =
isSimpleMapping(m) || isComplexMapping(m) ? m.allowedValues : undefined;
if (isSimple) {
// Convert complex to simple - extract first field from template if possible
if (isComplexMapping(m)) {
const placeholders = m.template.match(/\{([^}]+)\}/g);
const firstField = placeholders?.[0]?.replace(/[{}]/g, '') || '';
return { crmFieldName, cedarFieldName: firstField || '', allowedValues };
}
return m;
} else {
// Convert simple to complex - create template from cedarFieldName
if (isSimpleMapping(m)) {
return {
crmFieldName,
template: `{${m.cedarFieldName}}`,
transformations: {},
allowedValues,
};
}
return m;
}
});
});
};
const handleRemoveMapping = (crmFieldName: string) => {
setFieldMappings((prev) => prev.filter((m) => m.crmFieldName !== crmFieldName));
};
const handleCrmFieldNameChange = (oldCrmFieldName: string, newCrmFieldName: string) => {
// Check if the new CRM field name is already mapped
const mappedCrmFields = new Set(fieldMappings.map((m) => m.crmFieldName));
if (mappedCrmFields.has(newCrmFieldName) && newCrmFieldName !== oldCrmFieldName) {
toast.error(`Field "${newCrmFieldName}" already has a mapping`);
return;
}
// Auto-populate allowedValues if the new field has picklist values
const newAllowedValues = extractPicklistValues(
propertiesData?.picklist_fields?.[newCrmFieldName],
);
setFieldMappings((prev) => {
return prev.map((m) => {
if (m.crmFieldName !== oldCrmFieldName) return m;
// Update the CRM field name and allowedValues
if (isSimpleMapping(m)) {
return {
...m,
crmFieldName: newCrmFieldName,
allowedValues: newAllowedValues,
};
} else if (isComplexMapping(m)) {
return {
...m,
allowedValues: newAllowedValues,
crmFieldName: newCrmFieldName,
};
}
return m;
});
});
};
const handleAddMapping = (selectedCrmField?: string) => {
if (!selectedCrmField) return;
// Check if this CRM field already has a mapping
const mappedCrmFields = new Set(fieldMappings.map((m) => m.crmFieldName));
if (mappedCrmFields.has(selectedCrmField)) {
toast.error(`Field "${selectedCrmField}" already has a mapping`);
return;
}
// Auto-populate allowedValues if this field has picklist values
const picklistValues = extractPicklistValues(
propertiesData?.picklist_fields?.[selectedCrmField],
);
setFieldMappings((prev) => [
...prev,
{
crmFieldName: selectedCrmField,
cedarFieldName: '',
allowedValues: picklistValues,
},
]);
};
const handleSaveCustomObjectConfig = async () => {
if (!userId) return;
if (!customObjectObjectName.trim() || !customObjectFilterField.trim()) {
toast.error('Object name and filter field are required');
return;
}
setIsSavingCustomObjectConfig(true);
try {
const fields = customObjectFieldsInput
.split(',')
.map((f) => f.trim())
.filter(Boolean);
const limit = customObjectLimit ? parseInt(customObjectLimit, 10) : undefined;
await trpcClient.admin.crmSync.updateCustomObjectConfig.mutate({
userId,
provider: 'salesforce',
config: {
object_name: customObjectObjectName.trim(),
filter_field: customObjectFilterField.trim(),
fields: fields.length > 0 ? fields : undefined,
limit: limit && !isNaN(limit) ? limit : undefined,
},
});
await refetchCustomObjectConfig();
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to save config');
} finally {
setIsSavingCustomObjectConfig(false);
}
};
const handleSaveFields = async () => {
if (!connectionId) {
toast.error('Connection ID not found');
return;
}
// Filter out invalid mappings
const validMappings = fieldMappings.filter((m) => {
if (isSimpleMapping(m)) {
return !!m.cedarFieldName && m.cedarFieldName.trim().length > 0;
} else if (isComplexMapping(m)) {
return !!m.template && m.template.trim().length > 0;
}
return false;
});
try {
setIsSavingFields(true);
await updateWriteSettings({
connectionId,
fieldMappings: validMappings,
dealSyncFilter: Object.keys(dealSyncFilter).length > 0 ? dealSyncFilter : {},
stageRequiredFields: Object.keys(stageRequiredFields).length > 0 ? stageRequiredFields : undefined,
stageSourceField: stageSourceField ?? undefined,
userId,
aopId: selectedAopId ?? undefined,
});
void refetchIntegrations();
queryClient.invalidateQueries({ queryKey: ['connections', connectionId] });
} catch (error) {
console.error('[CrmIntegrationCard] Error saving settings:', error);
toast.error(
`Failed to save settings: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
setIsSavingFields(false);
}
};
// Render form field with eye toggle for password fields
const renderFormField = (field: {
key=[redacted];
label: string;
type: string;
placeholder: string;
}) => {
const isPasswordField = field.type === 'password';
const isApiKeyField = field.key === 'apiKey';
return (
<div key=[redacted] className="space-y-2">
<Label htmlFor={`${providerId}-${field.key}`}>{field.label}</Label>
<div className="relative">
<Input
id={`${providerId}-${field.key}`}
type={isPasswordField && !showApiKey ? 'password' : 'text'}
placeholder={field.placeholder}
value={formFields[field.key] || ''}
onChange={(e) => setFormFields((prev) => ({ ...prev, [field.key]: e.target.value }))}
disabled={isConnecting}
className={isPasswordField ? 'pr-10' : ''}
/>
{isPasswordField && isApiKeyField && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowApiKey(!showApiKey)}
>
{showApiKey ? (
<EyeOff className="h-4 w-4 text-gray-500" />
) : (
<Eye className="h-4 w-4 text-gray-500" />
)}
</Button>
)}
</div>
</div>
);
};
return (
<div className="space-y-4">
{/* Personal connection badge - shown when user has a legacy connection not configured by their org */}
{isPersonalConnection && isConnected && (
<Badge
variant="outline"
className="bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/30 dark:text-amber-300 dark:border-amber-800"
>
Personal connection (not configured by organization)
</Badge>
)}
{/* Org-managed badge - shown when connection is an org admin install */}
{isOrgManaged && isConnected && (
<Badge
variant="outline"
className="bg-green-50 text-green-700 border-green-200 dark:bg-green-950/30 dark:text-green-300 dark:border-green-800"
>
Connected by organization
</Badge>
)}
{apiKeyInstructions && connectionType === 'api_key' && !isConnected && (
<div className="bg-muted/30 rounded-lg border p-4">
<div className="text-muted-foreground whitespace-pre-line text-xs">
{apiKeyInstructions}
</div>
</div>
)}
{isConnected && connectionType !== 'api_key' ? (
<>
{!isOrgManaged && (
<Dialog>
<DialogTrigger asChild>
<Button
size="lg"
className="!flex w-full items-center gap-3 !border !border-gray-200 !bg-white !text-gray-800 !shadow-sm hover:!bg-gray-50"
disabled={isDisconnecting}
>
{isDisconnecting ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
<span>Disconnecting...</span>
</>
) : (
<>
<Unplug className="h-5 w-5" />
<span>Disconnect {providerName}</span>
</>
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Disconnect {providerName}</DialogTitle>
<DialogDescription>
Are you sure you want to disconnect {providerName}?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-4">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={handleDisconnect}>Disconnect</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
)}
{/* Reconnect — refreshes OAuth token without disconnecting (safe: upserts token only) */}
{/* connectionType is already known to be non-'api_key' inside this branch. */}
{!isOrgManaged && (
<Button
variant="outline"
size="lg"
className="!flex w-full items-center gap-3 !border !border-gray-200 !bg-white !text-gray-800 !shadow-sm hover:!bg-gray-50"
onClick={handleConnect}
disabled={isConnecting}
>
{isConnecting ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
<span>Reconnecting...</span>
</>
) : (
<>
<RefreshCw className="h-5 w-5" />
<span>Reconnect {providerName}</span>
</>
)}
</Button>
)}
{/* CRM Settings */}
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
<div className="text-sm font-medium">CRM Settings</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id={`${providerId}-periodic-sync`}
checked={currentSettings.periodicDealSyncEnabled}
onCheckedChange={(checked) =>
handleSettingsChange('periodicDealSyncEnabled', checked === true)
}
/>
<Label
htmlFor={`${providerId}-periodic-sync`}
className="text-sm font-normal cursor-pointer"
>
<span className="flex items-center gap-2">
Enable periodic deal sync
{isCustomizedFromOrgDefaults.periodicSync && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-300">
Customized
</span>
)}
</span>
<div className="text-muted-foreground text-xs mt-1">
Automatically sync deals from {providerName} on a schedule
</div>
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`${providerId}-crm-push`}
checked={currentSettings.externalCrmPushEnabled}
onCheckedChange={(checked) =>
handleSettingsChange('externalCrmPushEnabled', checked === true)
}
/>
<Label
htmlFor={`${providerId}-crm-push`}
className="text-sm font-normal cursor-pointer"
>
<span className="flex items-center gap-2">
Enable CRM writes
{isCustomizedFromOrgDefaults.crmWrites && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-300">
Customized
</span>
)}
</span>
<div className="text-muted-foreground text-xs mt-1">
Allow writing updates back to {providerName}
</div>
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`${providerId}-require-linked-deal-agent-outputs`}
checked={currentSettings.requireLinkedDealForAgentOutputs}
onCheckedChange={(checked) =>
handleSettingsChange('requireLinkedDealForAgentOutputs', checked === true)
}
/>
<Label
htmlFor={`${providerId}-require-linked-deal-agent-outputs`}
className="text-sm font-normal cursor-pointer"
>
Require linked deal for agent outputs
<div className="text-muted-foreground text-xs mt-1">
Only create drafts, Slack messages, and tasks for conversations linked to a deal
in {providerName}. High-signal meetings can be exceptions, and CRM opportunity
tasks are allowed so the agent can create or link the deal.
</div>
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`${providerId}-fetch-unowned-deals`}
checked={currentSettings.fetchUnownedDeals}
onCheckedChange={(checked) =>
handleSettingsChange('fetchUnownedDeals', checked === true)
}
/>
<Label
htmlFor={`${providerId}-fetch-unowned-deals`}
className="text-sm font-normal cursor-pointer"
>
Fetch deals with no owner
<div className="text-muted-foreground text-xs mt-1">
Also sync deals in {providerName} that have no assigned owner, treating them as
belonging to you in Cedar
</div>
</Label>
</div>
</div>
</div>
{/* AOP picker — scopes everything below (field mappings, deal sync filter, stage
requirements) to a specific AOP's bound CRM object. Only the AOP is picked here;
which raw object it's bound to is resolved server-side, never shown as an option. */}
{hasUpdateOpportunityFields && (aopsData?.aops?.length ?? 0) > 1 && (
<FieldRows bare dividers={false} density="compact" className="rounded-lg border bg-muted/30 p-3">
<SelectField
label="Configuring mappings for"
options={(aopsData?.aops ?? []).map((aop) => ({ value: aop.id, label: aop.name }))}
value={selectedAopId ?? undefined}
onValueChange={(value) => setSelectedAopId(value)}
placeholder="Select an AOP…"
triggerClassName="w-auto min-w-[160px]"
/>
</FieldRows>
)}
{/* Deal Sync Filter - filter which deals are synced based on a CRM field value */}
{hasUpdateOpportunityFields && propertiesData?.field_names && propertiesData.field_names.length > 0 && (
<div className="space-y-3">
<div className="rounded-lg border bg-muted/30 p-4">
<div className="mb-2 flex items-center justify-between">
<div>
<div className="text-sm font-medium">Deal Sync Filter</div>
<p className="text-muted-foreground text-xs mt-1">
Only sync deals matching specific criteria
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowFilterConfig(!showFilterConfig)}
>
{showFilterConfig ? (
<>
<ChevronUp className="mr-1 h-4 w-4" />
Hide
</>
) : (
<>
<ChevronDown className="mr-1 h-4 w-4" />
Configure
</>
)}
</Button>
</div>
{showFilterConfig && (
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
<div className="text-muted-foreground text-xs">
Filter deals based on a specific field value. Only deals matching the filter
will be synced from {providerName}.
</div>
{Object.keys(dealSyncFilter).length > 0 ? (
<div className="space-y-2">
{Object.entries(dealSyncFilter).map(([fieldName, fieldValue]) => {
const picklistValues = propertiesData?.picklist_fields?.[fieldName];
const options = extractPicklistOptions(picklistValues);
return (
<div
key=[redacted]
className="flex items-center gap-2 rounded-lg border p-3"
>
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-8 w-full justify-between text-xs"
>
{formatFieldLabel(fieldName)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search fields..." />
<CommandList>
<CommandEmpty>No field found.</CommandEmpty>
<CommandGroup>
{propertiesData?.field_names
?.filter(
(f) => !!propertiesData.picklist_fields?.[f],
)
.map((field) => {
const isSelected = fieldName === field;
return (
<CommandItem
key=[redacted]
value={field}
onSelect={() => {
// Update the filter with new field, clear the value
const newFilter = { ...dealSyncFilter };
delete newFilter[fieldName];
newFilter[field] = '';
setDealSyncFilter(newFilter);
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0',
)}
/>
{formatFieldLabel(field)}
<span className="ml-2 text-xs text-blue-500">
(picklist)
</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<span className="text-muted-foreground text-sm">=</span>
<div className="flex-1">
{options && options.length > 0 ? (
<Select
value={fieldValue}
onValueChange={(value) => {
setDealSyncFilter((prev) => ({
...prev,
[fieldName]: value,
}));
}}
>
<SelectTrigger >
<SelectValue placeholder="Select value..." />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key=[redacted] value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
value={fieldValue}
onChange={(e) => {
setDealSyncFilter((prev) => ({
...prev,
[fieldName]: e.target.value,
}));
}}
placeholder="Enter value..."
className="h-8 text-xs"
/>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => {
const newFilter = { ...dealSyncFilter };
delete newFilter[fieldName];
setDealSyncFilter(newFilter);
}}
className="h-8 w-8"
>
<X className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
) : (
<div className="text-muted-foreground text-sm text-center py-4">
No filter configured. All deals will be synced.
</div>
)}
{/* Add filter button - only show picklist fields */}
{(() => {
const picklistFields = propertiesData?.field_names?.filter(
(f) =>
!!propertiesData.picklist_fields?.[f] &&
!Object.keys(dealSyncFilter).includes(f),
);
if (!picklistFields || picklistFields.length === 0) return null;
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full">
<Plus className="mr-2 h-4 w-4" />
Add Filter
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search picklist fields..." />
<CommandList>
<CommandEmpty>No picklist field found.</CommandEmpty>
<CommandGroup>
{picklistFields.map((field) => (
<CommandItem
key=[redacted]
value={field}
onSelect={() => {
setDealSyncFilter((prev) => ({
...prev,
[field]: '',
}));
}}
>
{formatFieldLabel(field)}
<span className="ml-2 text-xs text-blue-500">(picklist)</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
})()}
<Button
size="sm"
onClick={handleSaveFields}
disabled={isSavingFields}
className="w-full"
>
{isSavingFields ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save Filter'
)}
</Button>
</div>
)}
</div>
</div>
)}
{/* Stage Required Fields */}
{hasUpdateOpportunityFields && (
<div className="space-y-3">
<div className="rounded-lg border bg-muted/30 p-4">
<div className="mb-2 flex items-center justify-between">
<div>
<div className="text-sm font-medium">Stage Required Fields</div>
<p className="text-muted-foreground mt-1 text-xs">
Fields required when moving a deal to each stage.
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowStageRequiredConfig(!showStageRequiredConfig)}
>
{showStageRequiredConfig ? (
<><ChevronUp className="mr-1 h-4 w-4" />Hide</>
) : (
<><ChevronDown className="mr-1 h-4 w-4" />Configure</>
)}
</Button>
</div>
{showStageRequiredConfig && (
<div className="space-y-3">
{/* Stage field picker */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground shrink-0">Stage field:</span>
<Popover>
<PopoverTrigger asChild>
<button type="button" className="flex items-center gap-1 rounded border px-2 py-1 text-xs hover:bg-muted min-w-[140px] justify-between">
<span className="font-mono truncate">{stageSourceField ?? 'Pick a field…'}</span>
<ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0">
<Command>
<CommandInput placeholder="Search fields..." />
<CommandList className="max-h-[200px]">
<CommandEmpty>No fields found.</CommandEmpty>
<CommandGroup>
{(propertiesData?.field_names ?? [])
.filter((f) => !!propertiesData?.picklist_fields?.[f])
.map((field) => (
<CommandItem
key=[redacted]
value={field}
onSelect={() => handleStageSourceFieldChange(field)}
>
<span className="font-mono text-xs">{field}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{Object.keys(stageRequiredFields).length === 0 ? (
<p className="text-muted-foreground py-2 text-center text-xs">
{stageSourceField ? 'No values found for this field.' : 'Pick a field above to see its stages.'}
</p>
) : (
<div className="space-y-2">
{(() => {
const raw = stageSourceField
? propertiesData?.picklist_fields?.[stageSourceField]
: null;
const orderedKeys = raw
? (raw as Array<string | { label: string; value: string }>).map((v) =>
typeof v === 'string' ? v : v.label,
)
: [];
const entries =
orderedKeys.length > 0
? orderedKeys
.filter((k) => k in stageRequiredFields)
.map((k) => [k, stageRequiredFields[k]!] as [string, string[]])
: Object.entries(stageRequiredFields);
return entries.map(([stageName, requiredFields]) => (
<div key=[redacted] className="rounded-lg border bg-background p-2 space-y-1.5">
<div className="text-xs font-medium text-muted-foreground">
To enter: <span className="text-foreground">{stageName}</span>
</div>
<div className="flex flex-wrap gap-1 min-h-[24px]">
{requiredFields.map((field) => (
<span
key=[redacted]
className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 font-mono text-xs"
>
{field}
<button
type="button"
onClick={() =>
setStageRequiredFields((prev) => ({
...prev,
[stageName]: prev[stageName]!.filter((f) => f !== field),
}))
}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-2.5 w-2.5" />
</button>
</span>
))}
<Popover>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-0.5 rounded border border-dashed px-1.5 py-0.5 text-xs text-muted-foreground hover:bg-muted"
>
<Plus className="h-2.5 w-2.5" />
Add field
</button>
</PopoverTrigger>
<PopoverContent className="w-[260px] p-0">
<Command>
<CommandInput placeholder="Search fields..." />
<CommandList className="max-h-[200px]">
<CommandEmpty>No fields found.</CommandEmpty>
<CommandGroup>
{(propertiesData?.field_names ?? [])
.filter((f) => !requiredFields.includes(f))
.map((field) => (
<CommandItem
key=[redacted]
value={field}
onSelect={() =>
setStageRequiredFields((prev) => ({
...prev,
[stageName]: [...(prev[stageName] ?? []), field],
}))
}
>
<span className="font-mono text-xs">{field}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
));
})()}
</div>
)}
<Button
size="sm"
onClick={handleSaveFields}
disabled={isSavingFields}
className="w-full"
>
{isSavingFields ? <Loader2 className="mr-2 h-3 w-3 animate-spin" /> : null}
Save Stage Requirements
</Button>
</div>
)}
</div>
</div>
)}
{/* Field Selection - shown when provider supports field mappings (can configure even if writes aren't enabled) */}
{hasUpdateOpportunityFields && propertiesData?.field_names && propertiesData.field_names.length > 0 && (
<div className="space-y-3">
<div className="rounded-lg border bg-muted/30 p-4">
<div className="mb-2 flex items-center justify-between">
<div>
<div className="text-sm font-medium">
Field Mappings{selectedObjectApiName && selectedAop ? ` (${selectedAop.name})` : ''}
</div>
{!currentSettings.externalCrmPushEnabled && (
<p className="text-muted-foreground text-xs mt-1">
Configure mappings now. They will be used when CRM writes are enabled.
</p>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await refetchProperties();
} catch (error) {
toast.error(
`Failed to refresh field names: ${error instanceof Error ? error.message : String(error)}`,
);
}
}}
disabled={isFetchingProperties}
>
{isFetchingProperties ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowFieldSelection(!showFieldSelection)}
>
{showFieldSelection ? (
<>
<ChevronUp className="mr-1 h-4 w-4" />
Hide
</>
) : (
<>
<ChevronDown className="mr-1 h-4 w-4" />
Configure
</>
)}
</Button>
</div>
</div>
{showFieldSelection && (
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
<div className="text-muted-foreground text-xs">
Map each CRM field to a Cedar field. When Cedar fields are updated, they will
automatically sync to the mapped CRM fields.
</div>
<div className="space-y-3">
{fieldMappings.length > 0 ? (
fieldMappings.map((mapping) => {
const isSimple = isSimpleMapping(mapping);
return (
<div
key=[redacted]
className="space-y-2 rounded-lg border p-3"
>
<div className="flex items-center justify-between">
<div className="flex-1 mr-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs font-medium"
>
{formatFieldLabel(mapping.crmFieldName)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search CRM fields..." />
<CommandList>
<CommandEmpty>No CRM field found.</CommandEmpty>
<CommandGroup>
{propertiesData?.field_names?.map((field) => {
const isMapped = fieldMappings.some(
(m) =>
m.crmFieldName === field &&
m.crmFieldName !== mapping.crmFieldName,
);
const isSelected = mapping.crmFieldName === field;
const hasPicklist =
!!propertiesData.picklist_fields?.[field];
return (
<CommandItem
key=[redacted]
value={field}
disabled={isMapped}
onSelect={() => {
if (!isMapped) {
handleCrmFieldNameChange(
mapping.crmFieldName,
field,
);
}
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
isSelected ? 'opacity-100' : 'opacity-0',
)}
/>
{formatFieldLabel(field)}
{hasPicklist && (
<span className="ml-2 text-xs text-blue-500">
(picklist)
</span>
)}
{isMapped && ' (already mapped)'}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="flex items-center gap-2">
<ToggleGroup
type="single"
value={isSimple ? 'simple' : 'advanced'}
onValueChange={(value) => {
if (value === 'simple' || value === 'advanced') {
handleToggleMappingType(
mapping.crmFieldName,
value === 'simple',
);
}
}}
className="h-7"
>
<ToggleGroupItem
value="simple"
aria-label="Simple mapping"
size="sm"
>
Simple
</ToggleGroupItem>
<ToggleGroupItem
value="advanced"
aria-label="Advanced mapping"
size="sm"
>
Advanced
</ToggleGroupItem>
</ToggleGroup>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveMapping(mapping.crmFieldName)}
className="h-7 w-7"
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{isSimple ? (
<div className="space-y-2">
<Select
value={mapping.cedarFieldName || undefined}
onValueChange={(value) =>
handleSimpleMappingChange(
mapping.crmFieldName,
value,
mapping.allowedValues,
)
}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select Cedar field" />
</SelectTrigger>
<SelectContent>
{availableCedarFields.map((field) => (
<SelectItem key=[redacted] value={field.value}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">
Allowed Values (optional, comma-separated)
</Label>
<Input
id={`allowed-values-simple-${mapping.crmFieldName}`}
value={
allowedValuesInputs[`simple-${mapping.crmFieldName}`] ??
(mapping.allowedValues && mapping.allowedValues.length > 0
? // Detect format preference from existing data or default to ", "
mapping.allowedValues.join(', ')
: '') ??
''
}
onChange={(e) => {
const rawValue = e.target.value;
setAllowedValuesInputs((prev) => ({
...prev,
[`simple-${mapping.crmFieldName}`]: rawValue,
}));
}}
onBlur={() => {
const rawValue =
allowedValuesInputs[`simple-${mapping.crmFieldName}`] ??
mapping.allowedValues?.join(',') ??
'';
// Detect if user prefers ", " or "," format
const hasSpacesAfterCommas = rawValue.includes(', ');
const separator = hasSpacesAfterCommas ? ', ' : ',';
const values = rawValue
.split(',')
.map((v) => v.trim())
.filter((v) => v.length > 0);
handleAllowedValuesChange(mapping.crmFieldName, values);
// Preserve the user's formatting preference
const normalizedRaw = values.join(separator);
setAllowedValuesInputs((prev) => ({
...prev,
[`simple-${mapping.crmFieldName}`]: normalizedRaw,
}));
}}
placeholder="Prospecting, Qualification, Closed Won"
className="text-xs"
/>
<div className="text-muted-foreground text-xs mt-1">
Only values in this list will be written to the CRM (useful
for enum fields like deal stage)
</div>
</div>
</div>
) : (
<div className="space-y-2 mt-2">
<div>
<Label className="text-xs text-muted-foreground mb-1 block">
Template (use {'{fieldName}'} for placeholders)
</Label>
<div className="flex gap-2">
<Input
id={`template-${mapping.crmFieldName}`}
value={mapping.template}
onChange={(e) =>
handleComplexMappingChange(
mapping.crmFieldName,
e.target.value,
mapping.transformations,
mapping.allowedValues,
mapping.prefix,
)
}
placeholder="{nextStepDate} – {nextSteps}"
className="text-xs font-mono flex-1"
/>
<Select
onValueChange={(value) => {
const input = document.getElementById(
`template-${mapping.crmFieldName}`,
) as HTMLInputElement;
if (input) {
const start = input.selectionStart || 0;
const end = input.selectionEnd || 0;
const currentValue = mapping.template;
const newValue =
currentValue.slice(0, start) +
`{${value}}` +
currentValue.slice(end);
handleComplexMappingChange(
mapping.crmFieldName,
newValue,
mapping.transformations,
mapping.allowedValues,
mapping.prefix,
);
// Set cursor position after inserted variable
setTimeout(() => {
input.focus();
const newCursorPos = start + value.length + 2; // +2 for { and }
input.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
}
}}
>
<SelectTrigger className="w-[140px]">
<Plus className="h-3 w-3 mr-1" />
<span>Insert Variable</span>
</SelectTrigger>
<SelectContent>
{availableCedarFields.map((field) => (
<SelectItem key=[redacted] value={field.value}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">
Prefix (optional, use {'{variable}'} for placeholders)
</Label>
<div className="flex gap-2">
<Input
id={`prefix-${mapping.crmFieldName}`}
value={mapping.prefix || ''}
onChange={(e) =>
handleComplexMappingChange(
mapping.crmFieldName,
mapping.template,
mapping.transformations,
mapping.allowedValues,
e.target.value || undefined,
)
}
placeholder="MS: or {status}: "
className="text-xs font-mono flex-1"
/>
<Select
onValueChange={(value) => {
const input = document.getElementById(
`prefix-${mapping.crmFieldName}`,
) as HTMLInputElement;
if (input) {
const start = input.selectionStart || 0;
const end = input.selectionEnd || 0;
const currentValue = mapping.prefix || '';
const newValue =
currentValue.slice(0, start) +
`{${value}}` +
currentValue.slice(end);
handleComplexMappingChange(
mapping.crmFieldName,
mapping.template,
mapping.transformations,
mapping.allowedValues,
newValue || undefined,
);
// Set cursor position after inserted variable
setTimeout(() => {
input.focus();
const newCursorPos = start + value.length + 2; // +2 for { and }
input.setSelectionRange(newCursorPos, newCursorPos);
}, 0);
}
}}
>
<SelectTrigger className="w-[140px]">
<Plus className="h-3 w-3 mr-1" />
<span>Insert Variable</span>
</SelectTrigger>
<SelectContent>
{availableCedarFields.map((field) => (
<SelectItem key=[redacted] value={field.value}>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="text-muted-foreground text-xs mt-1">
Prefix will be added to the value only if it's not
already present (case-insensitive)
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground mb-1 block">
Allowed Values (optional, comma-separated)
</Label>
<Input
id={`allowed-values-complex-${mapping.crmFieldName}`}
value={
allowedValuesInputs[`complex-${mapping.crmFieldName}`] ??
(mapping.allowedValues && mapping.allowedValues.length > 0
? // Detect format preference from existing data or default to ", "
mapping.allowedValues.join(', ')
: '') ??
''
}
onChange={(e) => {
const rawValue = e.target.value;
setAllowedValuesInputs((prev) => ({
...prev,
[`complex-${mapping.crmFieldName}`]: rawValue,
}));
}}
onBlur={() => {
const rawValue =
allowedValuesInputs[`complex-${mapping.crmFieldName}`] ??
mapping.allowedValues?.join(',') ??
'';
// Detect if user prefers ", " or "," format
const hasSpacesAfterCommas = rawValue.includes(', ');
const separator = hasSpacesAfterCommas ? ', ' : ',';
const values = rawValue
.split(',')
.map((v) => v.trim())
.filter((v) => v.length > 0);
handleAllowedValuesChange(mapping.crmFieldName, values);
// Preserve the user's formatting preference
const normalizedRaw = values.join(separator);
setAllowedValuesInputs((prev) => ({
...prev,
[`complex-${mapping.crmFieldName}`]: normalizedRaw,
}));
}}
placeholder="Prospecting, Qualification, Closed Won"
className="text-xs"
/>
<div className="text-muted-foreground text-xs mt-1">
Only values in this list will be written to the CRM (useful
for enum fields like deal stage)
</div>
</div>
{(mapping.template || mapping.prefix) && (
<div className="space-y-2">
{(() => {
// Extract placeholders from both template and prefix
const templatePlaceholders =
mapping.template?.match(/\{([^}]+)\}/g) || [];
const prefixPlaceholders =
mapping.prefix?.match(/\{([^}]+)\}/g) || [];
const allPlaceholders = [
...new Set([
...templatePlaceholders,
...prefixPlaceholders,
]),
];
return allPlaceholders.map((placeholder) => {
const fieldName = placeholder.replace(/[{}]/g, '');
const transformation =
mapping.transformations?.[fieldName];
return (
<div
key=[redacted]
className="flex items-center gap-2 text-xs"
>
<Label className="text-muted-foreground min-w-[100px]">
{fieldName}:
</Label>
<Select
value={transformation?.type || 'none'}
onValueChange={(value) => {
const newTransformations: Record<
string,
{
type: 'date' | 'number' | 'string';
format?: string;
}
> = {};
if (
value !== 'none' &&
(value === 'date' ||
value === 'number' ||
value === 'string')
) {
Object.keys(
mapping.transformations || {},
).forEach((key) => {
if (
key !== fieldName &&
mapping.transformations?.[key]
) {
const trans = mapping.transformations[key];
if (
trans.type === 'date' ||
trans.type === 'number' ||
trans.type === 'string'
) {
newTransformations[key] = {
type: trans.type,
format: trans.format,
};
}
}
});
newTransformations[fieldName] = {
type: value,
format: transformation?.format,
};
} else {
// Keep other transformations, just remove this one
Object.keys(
mapping.transformations || {},
).forEach((key) => {
if (
key !== fieldName &&
mapping.transformations?.[key]
) {
const trans = mapping.transformations[key];
if (
trans.type === 'date' ||
trans.type === 'number' ||
trans.type === 'string'
) {
newTransformations[key] = {
type: trans.type,
format: trans.format,
};
}
}
});
}
handleComplexMappingChange(
mapping.crmFieldName,
mapping.template,
Object.keys(newTransformations).length > 0
? (newTransformations as Record<
string,
{
type: 'date' | 'number' | 'string';
format?: string;
}
>)
: undefined,
mapping.allowedValues,
mapping.prefix,
);
}}
>
<SelectTrigger size="sm" >
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">
No transformation
</SelectItem>
<SelectItem value="date">Date</SelectItem>
<SelectItem value="number">Number</SelectItem>
<SelectItem value="string">String</SelectItem>
</SelectContent>
</Select>
{transformation?.type === 'date' && (
<div className="flex gap-1">
<Select
value={
transformation.format &&
DATE_FORMAT_OPTIONS.find(
(opt) =>
opt.value === transformation.format,
)
? transformation.format
: 'custom'
}
onValueChange={(value) => {
if (value === 'custom') return;
const newTransformations: Record<
string,
{
type: 'date' | 'number' | 'string';
format?: string;
}
> = {};
// Copy existing transformations
if (mapping.transformations) {
for (const [key, trans] of Object.entries(
mapping.transformations,
)) {
if (
trans.type === 'date' ||
trans.type === 'number' ||
trans.type === 'string'
) {
newTransformations[key] = {
type: trans.type,
format: trans.format,
};
}
}
}
// Update or add the current field's transformation
newTransformations[fieldName] = {
type: 'date' as const,
format: value,
};
handleComplexMappingChange(
mapping.crmFieldName,
mapping.template,
newTransformations,
mapping.allowedValues,
mapping.prefix,
);
}}
>
<SelectTrigger size="sm" className="w-32">
<SelectValue placeholder="Format" />
</SelectTrigger>
<SelectContent>
{DATE_FORMAT_OPTIONS.map((option) => (
<SelectItem
key=[redacted]
value={option.value}
>
<div className="flex items-center gap-2">
<span className="font-mono text-xs">
{option.value}
</span>
<span className="text-muted-foreground text-xs">
{option.example}
</span>
</div>
</SelectItem>
))}
<SelectItem value="custom">
Custom...
</SelectItem>
</SelectContent>
</Select>
<Input
value={transformation.format || ''}
onChange={(e) => {
const newTransformations: Record<
string,
{
type: 'date' | 'number' | 'string';
format?: string;
}
> = {};
// Copy existing transformations
if (mapping.transformations) {
for (const [key, trans] of Object.entries(
mapping.transformations,
)) {
if (
trans.type === 'date' ||
trans.type === 'number' ||
trans.type === 'string'
) {
newTransformations[key] = {
type: trans.type,
format: trans.format,
};
}
}
}
// Update or add the current field's transformation
newTransformations[fieldName] = {
type: 'date' as const,
format: e.target.value,
};
handleComplexMappingChange(
mapping.crmFieldName,
mapping.template,
newTransformations,
mapping.allowedValues,
mapping.prefix,
);
}}
placeholder="yyyy-MM-dd"
className="h-7 text-xs font-mono w-32"
/>
</div>
)}
</div>
);
});
})()}
</div>
)}
</div>
)}
</div>
);
})
) : (
<div className="text-muted-foreground text-sm text-center py-4">
No field mappings configured. Click "Add Field Mapping" to get
started.
</div>
)}
{propertiesData?.field_names && propertiesData.field_names.length > 0 && (
<div className="space-y-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
>
{fieldMappings.length === 0
? 'Select CRM field to map...'
: 'Add another CRM field mapping...'}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search CRM fields..." />
<CommandList>
<CommandEmpty>No CRM field found.</CommandEmpty>
<CommandGroup>
{propertiesData.field_names.map((field) => {
const isMapped = fieldMappings.some(
(m) => m.crmFieldName === field,
);
const hasPicklist =
!!propertiesData.picklist_fields?.[field];
return (
<CommandItem
key=[redacted]
value={field}
disabled={isMapped}
onSelect={() => {
if (!isMapped) {
handleAddMapping(field);
}
}}
>
<Check className={cn('mr-2 h-4 w-4', 'opacity-0')} />
{formatFieldLabel(field)}
{hasPicklist && (
<span className="ml-2 text-xs text-blue-500">
(picklist)
</span>
)}
{isMapped && ' (already mapped)'}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</div>
<Button
size="sm"
onClick={handleSaveFields}
disabled={isSavingFields}
className="w-full"
>
{isSavingFields ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save Field Mappings'
)}
</Button>
</div>
)}
</div>
</div>
)}
{/* Salesforce custom object config — admin-only (userId required) */}
{providerId === 'salesforce' && !!userId && (
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium">Custom Object Sync</div>
<div className="text-muted-foreground text-xs mt-0.5">
Configure a Salesforce custom object to sync per conversation via Account ID.
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowCustomObjectConfig(!showCustomObjectConfig)}
>
{showCustomObjectConfig ? (
<><ChevronUp className="mr-1 h-4 w-4" />Hide</>
) : (
<><ChevronDown className="mr-1 h-4 w-4" />Configure</>
)}
</Button>
</div>
{customObjectConfigData?.config && !showCustomObjectConfig && (
<div className="flex flex-wrap gap-1.5 text-xs">
<Badge variant="secondary">{customObjectConfigData.config.object_name}</Badge>
<Badge variant="secondary">filter: {customObjectConfigData.config.filter_field}</Badge>
{(customObjectConfigData.config.fields?.length ?? 0) > 0 && (
<Badge variant="secondary">{customObjectConfigData.config.fields!.length} fields</Badge>
)}
</div>
)}
{showCustomObjectConfig && (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs">Object Name *</Label>
<Input
placeholder="e.g. Cursor_Team__c"
value={customObjectObjectName}
onChange={(e) => setCustomObjectObjectName(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">Filter Field *</Label>
<Input
placeholder="e.g. Account__c"
value={customObjectFilterField}
onChange={(e) => setCustomObjectFilterField(e.target.value)}
className="h-8 text-xs"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Fields (optional, comma-separated)</Label>
<Input
placeholder="e.g. Id, Name, Team_ID__c"
value={customObjectFieldsInput}
onChange={(e) => setCustomObjectFieldsInput(e.target.value)}
className="h-8 text-xs"
/>
</div>
<div className="flex items-end gap-3">
<div className="w-24 space-y-1">
<Label className="text-xs">Limit</Label>
<Input
type="number"
placeholder="50"
value={customObjectLimit}
onChange={(e) => setCustomObjectLimit(e.target.value)}
className="h-8 text-xs"
/>
</div>
<Button
size="sm"
onClick={handleSaveCustomObjectConfig}
disabled={isSavingCustomObjectConfig}
>
{isSavingCustomObjectConfig ? (
<><Loader2 className="mr-2 h-4 w-4 animate-spin" />Saving...</>
) : (
'Save'
)}
</Button>
</div>
</div>
)}
</div>
)}
{/* Deal Manager - shown in playground/admin mode, hidden in user-facing mode */}
{showDealManager && !userFacing && (
<CrmDealManager providerId={providerId} userId={userId} />
)}
</>
) : isConnected && connectionType === 'api_key' ? (
<>
{/* CRM Settings for API key connections */}
<div className="space-y-3 rounded-lg border bg-muted/30 p-4">
<div className="text-sm font-medium">CRM Settings</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id={`${providerId}-periodic-sync-api`}
checked={currentSettings.periodicDealSyncEnabled}
onCheckedChange={(checked) =>
handleSettingsChange('periodicDealSyncEnabled', checked === true)
}
/>
<Label
htmlFor={`${providerId}-periodic-sync-api`}
className="text-sm font-normal cursor-pointer"
>
<span className="flex items-center gap-2">
Enable periodic deal sync
{isCustomizedFromOrgDefaults.periodicSync && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-300">
Customized
</span>
)}
</span>
<div className="text-muted-foreground text-xs mt-1">
Automatically sync deals from {providerName} on a schedule
</div>
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`${providerId}-crm-push-api`}
checked={currentSettings.externalCrmPushEnabled}
onCheckedChange={(checked) =>
handleSettingsChange('externalCrmPushEnabled', checked === true)
}
/>
<Label
htmlFor={`${providerId}-crm-push-api`}
className="text-sm font-normal cursor-pointer"
>
<span className="flex items-center gap-2">
Enable CRM writes
{isCustomizedFromOrgDefaults.crmWrites && (
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-950 dark:text-blue-300">
Customized
</span>
)}
</span>
<div className="text-muted-foreground text-xs mt-1">
Allow writing updates back to {providerName}
</div>
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`${providerId}-require-linked-deal-agent-outputs-api`}
checked={currentSettings.requireLinkedDealForAgentOutputs}
onCheckedChange={(checked) =>
handleSettingsChange('requireLinkedDealForAgentOutputs', checked === true)
}
/>
<Label
htmlFor={`${providerId}-require-linked-deal-agent-outputs-api`}
className="text-sm font-normal cursor-pointer"
>
Require linked deal for agent outputs
<div className="text-muted-foreground text-xs mt-1">
Only create drafts, Slack messages, and tasks for conversations linked to a deal
in {providerName}. High-signal meetings can be exceptions, and CRM opportunity
tasks are allowed so the agent can create or link the deal.
</div>
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`${providerId}-fetch-unowned-deals-api`}
checked={currentSettings.fetchUnownedDeals}
onCheckedChange={(checked) =>
handleSettingsChange('fetchUnownedDeals', checked === true)
}
/>
<Label
htmlFor={`${providerId}-fetch-unowned-deals-api`}
className="text-sm font-normal cursor-pointer"
>
Fetch deals with no owner
<div className="text-muted-foreground text-xs mt-1">
Also sync deals in {providerName} that have no assigned owner, treating them as
belonging to you in Cedar
</div>
</Label>
</div>
</div>
</div>
{/* Deal Manager - shown in playground/admin mode, hidden in user-facing mode */}
{showDealManager && !userFacing && (
<CrmDealManager providerId={providerId} userId={userId} />
)}
</>
) : shouldShowForm && connectionType === 'api_key' && requiredFields.length > 0 ? (
<div className="space-y-4">
{requiredFields.map(renderFormField)}
<div className="flex gap-2">
{!isUpdate && (
<Button
variant="outline"
size="lg"
className="flex-1"
onClick={() => {
setShowDirectInputForm(false);
setFormFields({});
}}
disabled={isConnecting}
>
Cancel
</Button>
)}
{isApiKeyProvider && isConnected && (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="lg" className="flex-1" disabled={isDisconnecting}>
{isDisconnecting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Disconnecting...
</>
) : (
<>
<Unplug className="mr-2 h-4 w-4" />
Disconnect
</>
)}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Disconnect {providerName}</DialogTitle>
<DialogDescription>
Are you sure you want to disconnect {providerName}?
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-4">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button onClick={handleDisconnect}>Disconnect</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
)}
<Button
size="lg"
className={`${isApiKeyProvider && isConnected ? 'flex-1' : 'flex-1'} border border-gray-200 bg-white text-gray-800 shadow-sm hover:bg-gray-50`}
onClick={handleDirectInputConnect}
disabled={isConnecting}
>
{isConnecting ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
<span>Connecting...</span>
</>
) : isApiKeyProvider && isConnected ? (
'Update'
) : (
'Connect'
)}
</Button>
</div>
{/* Deal Manager - shown in playground/admin mode, hidden in user-facing mode */}
{isApiKeyProvider && isConnected && showDealManager && !userFacing && (
<CrmDealManager providerId={providerId} userId={userId} />
)}
</div>
) : connectionType === 'manual' ? null : ( // Manual providers shouldn't show connect button
<Button
size="lg"
className="flex w-full items-center gap-3 border border-gray-200 bg-white text-gray-800 shadow-sm hover:bg-gray-50"
onClick={handleConnect}
disabled={isConnecting}
>
{isConnecting ? (
<>
<Loader2 className="h-5 w-5 animate-spin" />
<span>Connecting...</span>
</>
) : (
<>
{renderIcon()}
<span>{buttonText}</span>
</>
)}
</Button>
)}
</div>
);
}