provider-config-card.tsx119.6 KBView on GitHub 'use client';
/**
* Provider Configuration Card
*
* A reusable component for configuring provider credentials.
* Supports both admin mode (org-level credentials) and user mode (personal connections).
*/
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Check,
CheckCircle,
ChevronDown,
ChevronsUpDown,
ChevronUp,
Copy,
Eye,
EyeOff,
ExternalLink,
Loader2,
Plus,
Settings,
Trash2,
X,
} from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import type { ProviderId } from '../../../server/src/services/integrations/constants';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { IntegrationInfo } from '@/modules/aop/constants';
import { useSession } from '@/modules/auth/utils/auth-client';
import { useTRPC } from '@/providers/query-provider';
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router';
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 { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { getAvailableCedarFields } from '@/modules/crm/utils';
import {
DATE_FORMAT_OPTIONS,
type FieldMapping,
isSimpleMapping,
isComplexMapping,
normalizeFieldMappings,
extractPicklistValues,
extractPicklistOptions,
} from './field-mapping-utils';
/**
* Minimal shape of an org default AOP config consumed by this card — only
* `customFieldDefinitions` is read, to derive the available Cedar field list.
*/
type OrgDefaultAopConfig = {
customFieldDefinitions?: Record<string, unknown> | null;
};
interface ConnectionField {
key=[redacted];
label: string;
type: 'text' | 'password';
placeholder: string;
}
interface ProviderConfigCardProps {
/** Provider ID (e.g., 'gong', 'hubspot') */
providerId: string;
/** Integration display info */
integrationInfo: IntegrationInfo;
/** Mode: admin for org-level config, user for personal connections */
mode: 'admin' | 'user';
/** Current org provider config (admin mode only) */
orgConfig?: {
type: 'api_key' | 'oauth' | 'service_account' | 'manual';
hasCredentials?: boolean;
isInstalled?: boolean;
isEnabled?: boolean; // For manual connection types
adminUserId?: string;
installedAt?: string;
userMustAuthenticate?: boolean;
displayName?: string;
// CRM-specific defaults
defaultFieldMappings?: FieldMapping[];
defaultPeriodicDealSyncEnabled?: boolean;
defaultExternalCrmPushEnabled?: boolean;
defaultDealSyncFilter?: Record<string, string>;
defaultCustomObjectConfig?: {
object_name: string;
filter_field: string;
fields?: string[];
limit?: number;
} | null;
};
/** Integration capabilities from integrations.list */
capabilities?: {
connectionType?: 'oauth' | 'api_key' | 'manual';
connectionFields?: ConnectionField[];
apiKeyInstructions?: string;
webhookScope?: 'org' | 'user' | 'none';
webhookSetupInstructions?: string;
hasCreateWebhook?: boolean;
// CRM-specific
hasUpdateOpportunityFields?: boolean;
defaultWriteFieldMappings?: FieldMapping[];
};
/** Integration type (crm, meeting, communication) */
integrationType?: 'crm' | 'meeting' | 'communication';
/** Whether this provider is connected (user mode) */
isConnected?: boolean;
/** Callback when configuration is saved */
onConfigured?: () => void;
/** Callback when configuration is removed */
onRemoved?: () => void;
/** Organization ID for super admin mode - when set, uses cedarAdmin mutations */
superAdminOrgId?: string;
/** Organization default AOP configs for extracting custom fields (admin mode only) */
orgDefaultAopConfigs?: OrgDefaultAopConfig[];
/** Use full page for configuration instead of dialog (admin mode only) */
useFullPageConfig?: boolean;
/** Brand color for styling buttons (admin mode only) */
brandColor?: string;
}
export function ProviderConfigCard({
providerId,
integrationInfo,
mode,
orgConfig,
capabilities,
integrationType,
isConnected,
onConfigured,
onRemoved,
superAdminOrgId,
orgDefaultAopConfigs,
useFullPageConfig = false,
brandColor,
}: ProviderConfigCardProps) {
const trpc = useTRPC();
// Helper to determine if text should be white or black based on background color
const getContrastTextColor = (hexColor: string): string => {
// Remove # if present
const hex = hexColor.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
// Calculate relative luminance - use 0.6 threshold to prefer white text on medium colors
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.6 ? '#000000' : '#ffffff';
};
const queryClient = useQueryClient();
const { data: session } = useSession();
const navigate = useNavigate();
// UI State
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [showInstructions, setShowInstructions] = useState(false);
const [showPasswords, setShowPasswords] = useState<Record<string, boolean>>({});
const [isSaving, setIsSaving] = useState(false);
const [isRemoving, setIsRemoving] = useState(false);
const [isInstalling, setIsInstalling] = useState(false);
const [copiedWebhookUrl, setCopiedWebhookUrl] = useState(false);
const [isCreatingWebhook, setIsCreatingWebhook] = useState(false);
// Form state
const [formFields, setFormFields] = useState<Record<string, string>>({});
// CRM settings state
const [showCrmSettings, setShowCrmSettings] = useState(false);
const [crmPeriodicSyncEnabled, setCrmPeriodicSyncEnabled] = useState(false);
const [crmWritesEnabled, setCrmWritesEnabled] = useState(false);
const [crmFieldMappings, setCrmFieldMappings] = useState<FieldMapping[]>([]);
// Deal sync filter state - filter deals based on a CRM field value
const [crmDealSyncFilter, setCrmDealSyncFilter] = useState<Record<string, string>>({});
// Stage required fields state - maps stage values to required CRM field names
const [stageRequiredFields, setStageRequiredFields] = useState<Record<string, string[]>>({});
// Custom object config state (Salesforce only)
const [customObjectName, setCustomObjectName] = useState('');
const [customObjectFilterField, setCustomObjectFilterField] = useState('');
const [customObjectFieldsInput, setCustomObjectFieldsInput] = useState('');
const [customObjectLimit, setCustomObjectLimit] = useState('');
// Derive connection type and fields from capabilities (loaded from server)
const connectionType = capabilities?.connectionType;
const connectionFields = useMemo(
() => capabilities?.connectionFields || [],
[capabilities?.connectionFields],
);
const apiKeyInstructions = capabilities?.apiKeyInstructions;
const webhookScope = capabilities?.webhookScope;
const webhookSetupInstructions = capabilities?.webhookSetupInstructions;
const hasCreateWebhook = capabilities?.hasCreateWebhook;
const webhookUrl = `https://api.mail.cedarcopilot.com/webhooks/meeting-notes/${providerId}`;
// CRM-specific capabilities
const isCrmProvider = integrationType === 'crm';
const hasUpdateOpportunityFields = capabilities?.hasUpdateOpportunityFields;
const defaultWriteFieldMappings = useMemo(
() => capabilities?.defaultWriteFieldMappings || [],
[capabilities?.defaultWriteFieldMappings],
);
// Get available Cedar fields (core + custom from org AOPs + common values)
const availableCedarFields = useMemo(() => {
// Narrow the opaque customFieldDefinitions values to the label shape the util reads.
const aops = orgDefaultAopConfigs as Array<{
customFieldDefinitions?: Record<string, { label?: string }> | null;
}> | undefined;
return getAvailableCedarFields(aops);
}, [orgDefaultAopConfigs]);
// Copy webhook URL to clipboard
const handleCopyWebhookUrl = async () => {
try {
await navigator.clipboard.writeText(webhookUrl);
setCopiedWebhookUrl(true);
setTimeout(() => setCopiedWebhookUrl(false), 2000);
} catch {
toast.error('Failed to copy to clipboard');
}
};
// Field mapping handlers
const handleAddMapping = (selectedCrmField?: string) => {
if (!selectedCrmField) return;
const mappedCrmFields = new Set(crmFieldMappings.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(
crmPropertiesData?.picklist_fields?.[selectedCrmField],
);
setCrmFieldMappings((prev) => [
...prev,
{
crmFieldName: selectedCrmField,
cedarFieldName: '',
allowedValues: picklistValues,
},
]);
};
const handleRemoveMapping = (crmFieldName: string) => {
setCrmFieldMappings((prev) => prev.filter((m) => m.crmFieldName !== crmFieldName));
};
const handleSimpleMappingChange = (
crmFieldName: string,
cedarFieldName: string,
allowedValues?: string[],
) => {
setCrmFieldMappings((prev) => {
const existing = prev.find((m) => m.crmFieldName === crmFieldName);
if (existing) {
const currentAllowedValues =
isSimpleMapping(existing) || isComplexMapping(existing)
? existing.allowedValues
: undefined;
return prev.map((m) =>
m.crmFieldName === crmFieldName
? { crmFieldName, cedarFieldName, allowedValues: allowedValues ?? currentAllowedValues }
: m,
);
}
return [...prev, { crmFieldName, cedarFieldName, allowedValues }];
});
};
const handleComplexMappingChange = (
crmFieldName: string,
template: string,
transformations?: Record<string, { type: 'date' | 'number' | 'string'; format?: string }>,
allowedValues?: string[],
prefix?: string,
) => {
setCrmFieldMappings((prev) => {
const existing = prev.find((m) => m.crmFieldName === crmFieldName);
if (existing) {
const currentAllowedValues =
isSimpleMapping(existing) || isComplexMapping(existing)
? existing.allowedValues
: undefined;
const currentPrefix = isComplexMapping(existing) ? existing.prefix : undefined;
const currentTransformations = isComplexMapping(existing)
? existing.transformations
: undefined;
return prev.map((m) =>
m.crmFieldName === crmFieldName
? {
crmFieldName,
template,
prefix: prefix !== undefined ? prefix : currentPrefix,
transformations:
transformations !== undefined ? transformations : currentTransformations,
allowedValues: allowedValues ?? currentAllowedValues,
}
: m,
);
}
return [...prev, { crmFieldName, template, prefix, transformations, allowedValues }];
});
};
const handleToggleMappingType = (crmFieldName: string, isSimple: boolean) => {
setCrmFieldMappings((prev) => {
return prev.map((m) => {
if (m.crmFieldName !== crmFieldName) return m;
const allowedValues =
isSimpleMapping(m) || isComplexMapping(m) ? m.allowedValues : undefined;
if (isSimple) {
// Convert complex to simple
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
if (isSimpleMapping(m)) {
return {
crmFieldName,
template: m.cedarFieldName ? `{${m.cedarFieldName}}` : '',
transformations: {},
allowedValues,
};
}
return m;
}
});
});
};
const handleCrmFieldNameChange = (oldCrmFieldName: string, newCrmFieldName: string) => {
const mappedCrmFields = new Set(crmFieldMappings.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(
crmPropertiesData?.picklist_fields?.[newCrmFieldName],
);
setCrmFieldMappings((prev) => {
return prev.map((m) => {
if (m.crmFieldName !== oldCrmFieldName) return m;
if (isSimpleMapping(m)) {
return { ...m, crmFieldName: newCrmFieldName, allowedValues: newAllowedValues };
} else if (isComplexMapping(m)) {
return { ...m, crmFieldName: newCrmFieldName, allowedValues: newAllowedValues };
}
return m;
});
});
};
const handleAllowedValuesChange = (crmFieldName: string, allowedValues: string[]) => {
setCrmFieldMappings((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;
});
});
};
// Intelligent default for userMustAuthenticate based on connection type
const defaultUserMustAuth = useMemo(() => {
const connType = connectionType || orgConfig?.type;
if (connType === 'oauth') return true;
if (connType === 'manual') return true;
return false; // api_key providers default to auto-provision
}, [connectionType, orgConfig?.type]);
// Initialize form state when dialog opens or orgConfig changes
useEffect(() => {
if (isDialogOpen && mode === 'admin') {
// Initialize form fields (empty - we don't show existing credentials)
const initialFields: Record<string, string> = {};
connectionFields.forEach((field) => {
initialFields[field.key] = '';
});
setFormFields(initialFields);
// Initialize CRM settings from orgConfig or driver defaults
if (isCrmProvider) {
setCrmPeriodicSyncEnabled(orgConfig?.defaultPeriodicDealSyncEnabled ?? false);
setCrmWritesEnabled(orgConfig?.defaultExternalCrmPushEnabled ?? false);
const rawMappings = orgConfig?.defaultFieldMappings ?? defaultWriteFieldMappings;
const mappings = normalizeFieldMappings(rawMappings as Array<unknown>);
setCrmFieldMappings(mappings);
// Initialize deal sync filter from orgConfig
const savedFilter = orgConfig?.defaultDealSyncFilter;
if (savedFilter && typeof savedFilter === 'object') {
setCrmDealSyncFilter(savedFilter);
} else {
setCrmDealSyncFilter({});
}
// Initialize custom object config from orgConfig
const savedCustomObject = orgConfig?.defaultCustomObjectConfig;
setCustomObjectName(savedCustomObject?.object_name ?? '');
setCustomObjectFilterField(savedCustomObject?.filter_field ?? '');
setCustomObjectFieldsInput((savedCustomObject?.fields ?? []).join(', '));
setCustomObjectLimit(savedCustomObject?.limit ? String(savedCustomObject.limit) : '');
}
}
}, [
isDialogOpen,
mode,
orgConfig,
connectionFields,
isCrmProvider,
defaultWriteFieldMappings,
]);
// Admin mutations (regular org admin)
const { mutateAsync: setProviderConfig } = useMutation(
trpc.orgAdmin.setProviderConfig.mutationOptions(),
);
const { mutateAsync: deleteProviderConfig } = useMutation(
trpc.orgAdmin.deleteProviderConfig.mutationOptions(),
);
// Super admin mutations (for Cedar admins managing any org)
const { mutateAsync: superAdminSetProviderConfig } = useMutation(
trpc.cedarAdmin.setOrgProviderConfig.mutationOptions(),
);
const { mutateAsync: superAdminDeleteProviderConfig } = useMutation(
trpc.cedarAdmin.deleteOrgProviderConfig.mutationOptions(),
);
// OAuth mutations (for admin install flow)
const { mutateAsync: initiateOAuth } = useMutation(
trpc.integrations.initiateOAuth.mutationOptions(),
);
const { mutateAsync: connect } = useMutation(trpc.integrations.connect.mutationOptions());
// Webhook status query (only for meeting integrations that support programmatic webhook creation)
const {
data: webhookStatus,
refetch: refetchWebhookStatus,
} = useQuery({
...trpc.integrations.meetings.getWebhookStatus.queryOptions({
providerId,
}),
// Only enable for meeting integrations with org-level webhooks and programmatic creation support
enabled: mode === 'admin' && webhookScope === 'org' && hasCreateWebhook === true && isDialogOpen,
});
// CRM properties query (for field mappings)
// In admin mode, use the admin's connection (created when they OAuth install)
const adminUserId = orgConfig?.adminUserId;
const { data: crmPropertiesData, isFetching: isFetchingCrmProperties } = useQuery({
...trpc.integrations.crm.getProperties.queryOptions({
providerId: providerId as 'hubspot' | 'attio' | 'salesforce' | 'copper',
// In admin mode, pass the adminUserId to fetch properties using their connection
userId: mode === 'admin' && adminUserId ? adminUserId : undefined,
}),
// Enable when:
// - It's a CRM provider with field mapping support
// - Dialog is open
// - In admin mode: require adminUserId (connection must exist)
// - In user mode: always enable
enabled:
isCrmProvider &&
hasUpdateOpportunityFields === true &&
isDialogOpen &&
(mode !== 'admin' || !!adminUserId),
});
// Admin connection query — resolve admin's connectionId for updateWriteSettings
const { data: adminIntegrationsData } = useQuery({
...trpc.integrations.list.queryOptions(adminUserId ? { userId: adminUserId } : undefined),
enabled: mode === 'admin' && isCrmProvider && hasUpdateOpportunityFields === true && !!adminUserId && isDialogOpen,
});
const adminConnectionId = useMemo(() => {
const integration = adminIntegrationsData?.integrations.find(
(i) => i.id === providerId && (i as { connected?: boolean }).connected,
);
return (integration as { connectionId?: string } | undefined)?.connectionId;
}, [adminIntegrationsData, providerId]);
const { data: adminConnectionData } = useQuery({
...trpc.connections.get.queryOptions({ connectionId: adminConnectionId!, userId: adminUserId }),
enabled: !!adminConnectionId && !!adminUserId && isDialogOpen,
});
const { mutateAsync: updateWriteSettings } = useMutation(
trpc.integrations.crm.updateWriteSettings.mutationOptions(),
);
// Initialize stageRequiredFields from admin connection metadata (fallback to Superglue data)
useEffect(() => {
if (!isDialogOpen || !isCrmProvider) return;
const meta = (adminConnectionData?.metadata as Record<string, unknown> | undefined) ?? {};
const saved = meta.stageRequiredFields as Record<string, string[]> | undefined;
if (saved && Object.keys(saved).length > 0) {
setStageRequiredFields(saved);
} else if (crmPropertiesData?.stage_required_fields && Object.keys(crmPropertiesData.stage_required_fields).length > 0) {
setStageRequiredFields(crmPropertiesData.stage_required_fields);
} else {
setStageRequiredFields({});
}
}, [isDialogOpen, isCrmProvider, adminConnectionData, crmPropertiesData?.stage_required_fields]);
// Create webhook mutation
const { mutateAsync: createWebhook } = useMutation({
...trpc.integrations.meetings.createWebhook.mutationOptions(),
onSuccess: (data) => {
if (data.success) {
refetchWebhookStatus();
} else {
toast.error(data.error || 'Failed to create webhook');
}
setIsCreatingWebhook(false);
},
onError: (error) => {
toast.error(error.message || 'Failed to create webhook');
setIsCreatingWebhook(false);
},
});
const handleCreateWebhook = async () => {
setIsCreatingWebhook(true);
await createWebhook({ providerId });
};
// Status checks - OAuth providers use isInstalled, API key providers use hasCredentials, manual use isEnabled
// When connectionType is undefined (capabilities not loaded), check orgConfig.type as fallback
const effectiveConnectionType = connectionType || orgConfig?.type;
const isConfigured =
mode === 'admin'
? effectiveConnectionType === 'oauth'
? orgConfig?.isInstalled
: effectiveConnectionType === 'manual'
? orgConfig?.isEnabled
: orgConfig?.hasCredentials
: isConnected;
// Handle OAuth install for admin mode (follows same pattern as CrmIntegrationCard)
const handleOAuthInstall = async () => {
if (mode !== 'admin') return;
try {
setIsInstalling(true);
// Step 1: Get OAuth URL (same as user flow)
const { oauthUrl, strataServerUrl } = await initiateOAuth({
integration: providerId,
});
if (!oauthUrl) {
setIsInstalling(false);
toast.error('Failed to get OAuth URL');
return;
}
// Step 2: Store strataServerUrl for verification (same pattern as user flow)
sessionStorage.setItem(
`${providerId}_oauth_admin_pending`,
JSON.stringify({ strataServerUrl }),
);
// Step 3: Open OAuth in new tab
window.open(oauthUrl, '_blank');
} catch (error) {
console.error(`[ProviderConfigCard] Error initiating OAuth for ${providerId}:`, error);
toast.error(
`Failed to initiate OAuth: ${error instanceof Error ? error.message : String(error)}`,
);
setIsInstalling(false);
}
};
// Window focus listener - follows same pattern as CrmIntegrationCard
useEffect(() => {
const handleWindowFocus = async () => {
const storageKey=[redacted];
const pendingAuth = sessionStorage.getItem(storageKey);
if (pendingAuth && isInstalling) {
console.log(
`[ProviderConfigCard] Admin returned from OAuth tab for ${providerId}, checking connection...`,
);
try {
const { strataServerUrl } = JSON.parse(pendingAuth);
// Step 4: Verify OAuth succeeded by calling connect
// Mark as admin install so this connection is NOT treated as a user connection
// The admin will need to go through user onboarding to create their own user connection
await connect({
providerId: providerId as ProviderId,
connectionParams: { strataServerUrl },
isAdminInstall: true,
});
// Step 5: After verification, mark provider as installed at org level
await setProviderConfig({
providerId,
config: {
type: 'oauth',
isInstalled: true,
adminUserId: session?.user?.id,
installedAt: new Date().toISOString(),
userMustAuthenticate: true,
},
});
sessionStorage.removeItem(storageKey);
setIsInstalling(false);
setIsDialogOpen(false);
// Invalidate queries to refresh org config (which includes adminUserId)
queryClient.invalidateQueries({ queryKey=[redacted] });
// Also invalidate CRM properties query so it refetches with the new adminUserId
if (isCrmProvider && hasUpdateOpportunityFields && session?.user?.id) {
queryClient.invalidateQueries({
queryKey: [
['integrations', 'crm', 'getProperties'],
{
providerId: providerId as 'hubspot' | 'attio' | 'salesforce' | 'copper',
userId: session.user.id,
},
],
});
}
onConfigured?.();
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Ignore expected auth-in-progress errors (user may not have completed OAuth yet)
const isExpectedError =
errorMessage.includes('not authenticated') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('Please complete OAuth flow');
if (!isExpectedError) {
console.error(`[ProviderConfigCard] Error installing ${providerId}:`, error);
sessionStorage.removeItem(storageKey);
setIsInstalling(false);
toast.error(`Failed to install ${integrationInfo.name}: ${errorMessage}`);
}
}
}
};
window.addEventListener('focus', handleWindowFocus);
return () => window.removeEventListener('focus', handleWindowFocus);
}, [
isInstalling,
providerId,
session?.user?.id,
connect,
setProviderConfig,
onConfigured,
integrationInfo.name,
isCrmProvider,
hasUpdateOpportunityFields,
queryClient,
trpc.orgAdmin.getProviderConfigs,
]);
// Handle save for admin mode
const handleSaveConfig = async () => {
if (mode !== 'admin') return;
// For API key providers, validate required fields
if (effectiveConnectionType === 'api_key') {
const missingFields = connectionFields.filter(
(field) => field.type === 'password' && !formFields[field.key]?.trim(),
);
// Only require fields if this is a new config (not updating)
if (!isConfigured && missingFields.length > 0) {
toast.error(
`Please fill in required fields: ${missingFields.map((f) => f.label).join(', ')}`,
);
return;
}
}
try {
setIsSaving(true);
// Build credentials object from form fields (only non-empty values)
const credentials: Record<string, string> = {};
for (const [key, value] of Object.entries(formFields)) {
if (value.trim()) {
credentials[key] = value.trim();
}
}
// Build CRM settings if applicable
const customObjectFields = customObjectFieldsInput
.split(',')
.map((f) => f.trim())
.filter(Boolean);
const customObjectLimitParsed = customObjectLimit ? parseInt(customObjectLimit, 10) : undefined;
const customObjectConfig =
customObjectName.trim() && customObjectFilterField.trim()
? {
object_name: customObjectName.trim(),
filter_field: customObjectFilterField.trim(),
fields: customObjectFields.length > 0 ? customObjectFields : undefined,
limit: customObjectLimitParsed && !isNaN(customObjectLimitParsed) ? customObjectLimitParsed : undefined,
}
: undefined;
const crmSettings = isCrmProvider
? {
defaultFieldMappings: crmFieldMappings.length > 0 ? crmFieldMappings : undefined,
defaultPeriodicDealSyncEnabled: crmPeriodicSyncEnabled,
defaultExternalCrmPushEnabled: crmWritesEnabled,
defaultDealSyncFilter:
Object.keys(crmDealSyncFilter).length > 0 ? crmDealSyncFilter : undefined,
defaultCustomObjectConfig: customObjectConfig,
}
: {};
const configPayload = {
type: (effectiveConnectionType || 'api_key') as 'api_key' | 'oauth' | 'service_account' | 'manual',
credentials: Object.keys(credentials).length > 0 ? credentials : undefined,
userMustAuthenticate: defaultUserMustAuth,
...crmSettings,
};
// Use super admin mutation if managing another org
if (superAdminOrgId) {
await superAdminSetProviderConfig({
organizationId: superAdminOrgId,
providerId,
config: configPayload,
});
queryClient.invalidateQueries({
queryKey=[redacted] organizationId: superAdminOrgId }),
});
} else {
await setProviderConfig({
providerId,
config: configPayload,
});
queryClient.invalidateQueries({ queryKey=[redacted] });
}
// Also persist stageRequiredFields to admin's connection metadata
if (isCrmProvider && adminConnectionId && Object.keys(stageRequiredFields).length > 0) {
await updateWriteSettings({
connectionId: adminConnectionId,
stageRequiredFields: Object.keys(stageRequiredFields).length > 0 ? stageRequiredFields : undefined,
userId: adminUserId,
});
queryClient.invalidateQueries({ queryKey=[redacted] connectionId: adminConnectionId, userId: adminUserId }) });
}
setIsDialogOpen(false);
setFormFields({});
onConfigured?.();
} catch (error) {
console.error('[ProviderConfigCard] Error saving config:', error);
toast.error(
`Failed to save configuration: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
setIsSaving(false);
}
};
// Handle remove for admin mode
const handleRemoveConfig = async () => {
if (mode !== 'admin') return;
try {
setIsRemoving(true);
// Use super admin mutation if managing another org
if (superAdminOrgId) {
await superAdminDeleteProviderConfig({
organizationId: superAdminOrgId,
providerId,
});
queryClient.invalidateQueries({
queryKey=[redacted] organizationId: superAdminOrgId }),
});
} else {
await deleteProviderConfig({ providerId });
queryClient.invalidateQueries({ queryKey=[redacted] });
}
setIsDialogOpen(false);
onRemoved?.();
} catch (error) {
console.error('[ProviderConfigCard] Error removing config:', error);
toast.error(
`Failed to remove configuration: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
setIsRemoving(false);
}
};
// Handle enable/disable for manual connection types
const [isTogglingEnabled, setIsTogglingEnabled] = useState(false);
const handleToggleEnabled = async () => {
if (mode !== 'admin') return;
try {
setIsTogglingEnabled(true);
const newEnabled = !isConfigured;
if (newEnabled) {
// Enable the integration
await setProviderConfig({
providerId,
config: {
type: 'manual' as const,
isEnabled: true,
},
});
onConfigured?.();
} else {
// Disable the integration
await deleteProviderConfig({ providerId });
onRemoved?.();
}
// Invalidate queries
queryClient.invalidateQueries({ queryKey=[redacted] });
} catch (error) {
console.error('[ProviderConfigCard] Error toggling enabled:', error);
toast.error(
`Failed to ${isConfigured ? 'disable' : 'enable'}: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
setIsTogglingEnabled(false);
}
};
// Render a form field
const renderFormField = (field: ConnectionField) => {
const isPassword=[redacted] === 'password';
const showPassword=[redacted] || false;
return (
<div key=[redacted] className="space-y-2">
<Label htmlFor={`${providerId}-${field.key}`} className="text-sm font-medium">
{field.label}
</Label>
<div className="relative">
<Input
id={`${providerId}-${field.key}`}
type={isPassword && !showPassword ? 'password' : 'text'}
placeholder={isConfigured ? '••••••••' : field.placeholder}
value={formFields[field.key] || ''}
onChange={(e) => setFormFields((prev) => ({ ...prev, [field.key]: e.target.value }))}
disabled={isSaving}
className={isPassword ? 'pr-10' : ''}
/>
{isPassword && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowPasswords((prev) => ({ ...prev, [field.key]: !showPassword }))}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-muted-foreground" />
) : (
<Eye className="h-4 w-4 text-muted-foreground" />
)}
</Button>
)}
</div>
{isConfigured && isPassword && (
<p className="text-xs text-muted-foreground">Leave blank to keep existing value</p>
)}
</div>
);
};
return (
<div
className={`relative rounded-lg border p-4 transition-colors ${
isConfigured
? 'border-green-200 bg-green-50/50 dark:border-green-800 dark:bg-green-950/20'
: 'hover:bg-muted/50'
}`}
>
{/* Header */}
<div className="flex items-start gap-3">
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-sm font-bold text-white"
style={{ backgroundColor: integrationInfo.iconColor }}
>
{integrationInfo.iconLetter}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4 className="font-medium">{integrationInfo.name}</h4>
{isConfigured && (
<Badge
variant="secondary"
className="bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"
>
<Check className="mr-1 h-3 w-3" />
{mode === 'admin'
? effectiveConnectionType === 'oauth'
? 'Installed'
: effectiveConnectionType === 'manual'
? 'Enabled'
: 'Configured'
: 'Connected'}
</Badge>
)}
</div>
<p className="text-muted-foreground mt-1 text-xs">{integrationInfo.description}</p>
</div>
</div>
{/* Configure/Edit Button or Enable Toggle */}
{mode === 'admin' && (
<div className="mt-3 flex gap-2">
{/* Manual connection types with user-level webhooks - simple enable toggle */}
{effectiveConnectionType === 'manual' && webhookScope !== 'org' ? (
<Button
variant={isConfigured ? 'secondary' : brandColor ? 'default' : 'outline'}
size="sm"
className="flex-1"
style={brandColor && !isConfigured ? {
backgroundColor: brandColor,
borderColor: brandColor,
color: getContrastTextColor(brandColor),
} : undefined}
onClick={handleToggleEnabled}
disabled={isTogglingEnabled}
>
{isTogglingEnabled ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
{isConfigured ? 'Disabling...' : 'Enabling...'}
</>
) : isConfigured ? (
<>
<Check className="mr-2 h-3 w-3" />
Enabled
</>
) : (
'Enable'
)}
</Button>
) : useFullPageConfig ? (
/* Full page configuration - navigate to integration config page */
<Button
variant={brandColor ? 'default' : 'outline'}
size="sm"
className="flex-1"
style={brandColor ? {
backgroundColor: brandColor,
borderColor: brandColor,
color: getContrastTextColor(brandColor),
} : undefined}
onClick={() => navigate(`/admin/integration?provider=${providerId}`)}
>
{isConfigured ? (
<>
<Settings className="mr-2 h-3 w-3" />
{effectiveConnectionType === 'oauth' ? 'Manage' : effectiveConnectionType === 'manual' ? 'Manage' : 'Edit Configuration'}
</>
) : (
<>
<ExternalLink className="mr-2 h-3 w-3" />
Configure
</>
)}
</Button>
) : (
/* API Key, OAuth, or Manual with org-level webhooks - dialog configuration */
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
variant={brandColor ? 'default' : 'outline'}
size="sm"
className="flex-1"
style={brandColor ? {
backgroundColor: brandColor,
borderColor: brandColor,
color: getContrastTextColor(brandColor),
} : undefined}
>
{isConfigured ? (
<>
<Settings className="mr-2 h-3 w-3" />
{effectiveConnectionType === 'oauth' ? 'Manage' : effectiveConnectionType === 'manual' ? 'Manage' : 'Edit Configuration'}
</>
) : (
<>
<ExternalLink className="mr-2 h-3 w-3" />
Configure
</>
)}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<div
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-xs font-bold text-white"
style={{ backgroundColor: integrationInfo.iconColor }}
>
{integrationInfo.iconLetter}
</div>
Configure {integrationInfo.name}
</DialogTitle>
<DialogDescription>
{effectiveConnectionType === 'oauth'
? `Install ${integrationInfo.name} to make it available for your team during onboarding.`
: effectiveConnectionType === 'manual'
? `Enable ${integrationInfo.name} and configure the webhook for your organization.`
: `Enter your ${integrationInfo.name} API credentials to enable this integration.`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* API Key Instructions */}
{effectiveConnectionType === 'api_key' && apiKeyInstructions && (
<Collapsible open={showInstructions} onOpenChange={setShowInstructions}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full justify-between">
<span className="text-sm">How to get your API key</span>
{showInstructions ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 rounded-lg border bg-muted/30 p-3">
<pre className="whitespace-pre-wrap font-sans text-xs text-muted-foreground">
{apiKeyInstructions}
</pre>
</div>
</CollapsibleContent>
</Collapsible>
)}
{/* Form Fields for API Key providers */}
{effectiveConnectionType === 'api_key' && connectionFields.length > 0 && (
<div className="space-y-3">{connectionFields.map(renderFormField)}</div>
)}
{/* OAuth providers - Install flow */}
{effectiveConnectionType === 'oauth' && (
<div className="space-y-4">
{isConfigured ? (
<div className="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-800 dark:bg-green-950/30">
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
<p className="font-medium text-green-700 dark:text-green-300">
Installed
</p>
</div>
<p className="mt-1 text-sm text-green-600 dark:text-green-400">
{orgConfig?.installedAt
? `Installed on ${new Date(orgConfig.installedAt).toLocaleDateString()}`
: 'This integration is available for your team.'}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Team members will be prompted to connect their own {integrationInfo.name}{' '}
accounts during onboarding.
</p>
</div>
) : (
<div className="rounded-lg border bg-muted/30 p-4">
<p className="mb-4 text-sm text-muted-foreground">
You must install this integration before team members can connect. Click
below to authenticate with {integrationInfo.name} and approve the app for
your organization.
</p>
<Button
onClick={handleOAuthInstall}
disabled={isInstalling}
className="w-full"
>
{isInstalling ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Installing...
</>
) : (
<>
<ExternalLink className="mr-2 h-4 w-4" />
Install {integrationInfo.name}
</>
)}
</Button>
</div>
)}
</div>
)}
{/* Webhook Configuration Info (for meeting integrations) */}
{webhookScope && webhookScope !== 'none' && (
<div className="rounded-lg border bg-muted/30 p-3">
<Label className="text-sm font-medium">Webhook Configuration</Label>
{webhookScope === 'org' ? (
<div className="mt-2 space-y-3">
<p className="text-xs text-muted-foreground">
Webhook setup is done once at the organization level.
</p>
{/* Programmatic webhook creation (e.g., Fathom) */}
{hasCreateWebhook ? (
<div className="space-y-3">
{webhookStatus?.hasWebhook ? (
<div className="rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-800 dark:bg-green-950/30">
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
<p className="text-sm font-medium text-green-700 dark:text-green-300">
Webhook configured
</p>
</div>
{webhookStatus?.webhookUrl && (
<p className="mt-1 font-mono text-xs text-green-600 dark:text-green-400">
{webhookStatus.webhookUrl}
</p>
)}
</div>
) : (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
Click the button below to automatically create the webhook.
</p>
<Button
onClick={handleCreateWebhook}
disabled={isCreatingWebhook || !isConfigured}
className="w-full"
size="sm"
>
{isCreatingWebhook ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating Webhook...
</>
) : (
'Create Webhook'
)}
</Button>
{!isConfigured && (
<p className="text-xs text-amber-600 dark:text-amber-400">
Save credentials first before creating the webhook.
</p>
)}
</div>
)}
</div>
) : (
<>
{/* Manual webhook setup - show URL and instructions */}
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">Webhook URL</Label>
<div className="flex items-center gap-2">
<Input
readOnly
value={webhookUrl}
className="font-mono text-xs"
/>
<Button
variant="outline"
size="icon"
className="shrink-0"
onClick={handleCopyWebhookUrl}
>
{copiedWebhookUrl ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
{webhookSetupInstructions && (
<Collapsible>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="w-full justify-between">
<span className="text-sm">Setup instructions</span>
<ChevronDown className="h-4 w-4" />
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="mt-2 rounded-lg border bg-background p-3">
<pre className="whitespace-pre-wrap font-sans text-xs text-muted-foreground">
{webhookSetupInstructions}
</pre>
</div>
</CollapsibleContent>
</Collapsible>
)}
</>
)}
</div>
) : (
<p className="mt-2 text-xs text-muted-foreground">
Each user will need to configure their own webhook during onboarding.
</p>
)}
</div>
)}
{/* CRM Settings (for CRM integrations) */}
{isCrmProvider && hasUpdateOpportunityFields && (
<div className="space-y-3 rounded-lg border bg-muted/30 p-3">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">CRM Default Settings</Label>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCrmSettings(!showCrmSettings)}
>
{showCrmSettings ? (
<>
<ChevronUp className="mr-1 h-4 w-4" />
Hide
</>
) : (
<>
<ChevronDown className="mr-1 h-4 w-4" />
Configure
</>
)}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Set default sync and write settings for your team. Users can customize these.
</p>
{showCrmSettings && (
<div className="space-y-4 pt-2">
{/* Sync and Write toggles */}
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id={`${providerId}-default-periodic-sync`}
checked={crmPeriodicSyncEnabled}
onCheckedChange={(checked) =>
setCrmPeriodicSyncEnabled(checked === true)
}
/>
<Label
htmlFor={`${providerId}-default-periodic-sync`}
className="cursor-pointer text-sm font-normal"
>
Enable periodic deal sync by default
<div className="mt-1 text-xs text-muted-foreground">
Automatically sync deals from {integrationInfo.name} on a schedule
</div>
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id={`${providerId}-default-crm-writes`}
checked={crmWritesEnabled}
onCheckedChange={(checked) => setCrmWritesEnabled(checked === true)}
/>
<Label
htmlFor={`${providerId}-default-crm-writes`}
className="cursor-pointer text-sm font-normal"
>
Enable CRM writes by default
<div className="mt-1 text-xs text-muted-foreground">
Allow writing updates back to {integrationInfo.name}
</div>
</Label>
</div>
</div>
{/* Deal Sync Filter */}
<div className="space-y-3 border-t pt-3">
<Label className="text-xs font-medium">Default Deal Sync Filter</Label>
<p className="text-xs text-muted-foreground">
Only sync deals matching specific criteria. Users can customize this
filter in their settings.
</p>
{Object.keys(crmDealSyncFilter).length > 0 ? (
<div className="space-y-2">
{Object.entries(crmDealSyncFilter).map(([fieldName, fieldValue]) => {
const picklistValues = crmPropertiesData?.picklist_fields?.[fieldName];
const options = extractPicklistOptions(picklistValues);
return (
<div
key=[redacted]
className="flex items-center gap-2 rounded-lg border bg-background p-2"
>
<div className="flex-1">
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 w-full justify-between text-xs"
>
<span className="font-mono">{fieldName}</span>
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] p-0">
<Command>
<CommandInput placeholder="Search fields..." />
<CommandList>
<CommandEmpty>No field found.</CommandEmpty>
<CommandGroup>
{crmPropertiesData?.field_names
?.filter((f) => !!crmPropertiesData.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 = { ...crmDealSyncFilter };
delete newFilter[fieldName];
newFilter[field] = '';
setCrmDealSyncFilter(newFilter);
}}
>
<Check
className={`mr-2 h-4 w-4 ${
isSelected ? 'opacity-100' : 'opacity-0'
}`}
/>
<span className="font-mono text-xs">{field}</span>
<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) => {
setCrmDealSyncFilter((prev) => ({
...prev,
[fieldName]: value,
}));
}}
>
<SelectTrigger size="sm" >
<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) => {
setCrmDealSyncFilter((prev) => ({
...prev,
[fieldName]: e.target.value,
}));
}}
placeholder="Enter value..."
className="h-7 text-xs"
/>
)}
</div>
<Button
variant="ghost"
size="icon"
onClick={() => {
const newFilter = { ...crmDealSyncFilter };
delete newFilter[fieldName];
setCrmDealSyncFilter(newFilter);
}}
className="h-7 w-7"
>
<X className="h-3 w-3" />
</Button>
</div>
);
})}
</div>
) : (
<p className="text-center text-xs text-muted-foreground py-2">
No filter configured. All deals will be synced.
</p>
)}
{/* Add filter button - only show picklist fields */}
{(() => {
const picklistFields = crmPropertiesData?.field_names?.filter(
(f) =>
!!crmPropertiesData.picklist_fields?.[f] &&
!Object.keys(crmDealSyncFilter).includes(f),
);
if (!picklistFields || picklistFields.length === 0) return null;
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<Plus className="mr-2 h-3 w-3" />
Add Filter
</Button>
</PopoverTrigger>
<PopoverContent className="w-[250px] 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={() => {
setCrmDealSyncFilter((prev) => ({
...prev,
[field]: '',
}));
}}
>
<span className="font-mono text-xs">{field}</span>
<span className="ml-2 text-xs text-blue-500">(picklist)</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
})()}
</div>
{/* Stage Required Fields */}
<div className="space-y-3 border-t pt-3">
<Label className="text-xs font-medium">Stage Required Fields</Label>
<p className="text-xs text-muted-foreground">
Specify which CRM fields are required when moving a deal to each stage.
</p>
{Object.keys(stageRequiredFields).length > 0 ? (
<div className="space-y-2">
{Object.entries(stageRequiredFields).map(([stageName, requiredFields]) => (
<div
key=[redacted]
className="flex items-start gap-2 rounded-lg border bg-background p-2"
>
<div className="min-w-0 flex-1 space-y-1">
<span className="font-mono text-xs font-medium">{stageName}</span>
<div className="flex flex-wrap gap-1">
{requiredFields.map((field) => (
<Badge key=[redacted] variant="secondary" className="font-mono text-xs">
{field}
</Badge>
))}
</div>
<Input
value={requiredFields.join(', ')}
onChange={(e) => {
const fields = e.target.value
.split(',')
.map((f) => f.trim())
.filter(Boolean);
setStageRequiredFields((prev) => ({
...prev,
[stageName]: fields,
}));
}}
placeholder="e.g. CloseDate, Amount__c"
className="h-7 text-xs font-mono"
/>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => {
const updated = { ...stageRequiredFields };
delete updated[stageName];
setStageRequiredFields(updated);
}}
className="h-7 w-7 shrink-0"
>
<X className="h-3 w-3" />
</Button>
</div>
))}
</div>
) : (
<p className="text-center text-xs text-muted-foreground py-2">
No stage requirements configured.
</p>
)}
{/* Add stage requirement */}
{(() => {
// Suggest stages from Superglue-detected data; user can also type any stage name.
const stageOptions = Object.keys(crmPropertiesData?.stage_required_fields ?? {})
.filter((s) => !Object.keys(stageRequiredFields).includes(s));
return (
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="w-full">
<Plus className="mr-2 h-3 w-3" />
Add stage requirement
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search or enter stage name..." />
<CommandList>
<CommandEmpty>
<Button
variant="ghost"
size="sm"
className="w-full text-xs"
onClick={() => {
const input = document.querySelector('[cmdk-input]') as HTMLInputElement | null;
const stageName = input?.value?.trim();
if (stageName && !stageRequiredFields[stageName]) {
setStageRequiredFields((prev) => ({
...prev,
[stageName]: [],
}));
}
}}
>
Add as stage
</Button>
</CommandEmpty>
<CommandGroup>
{stageOptions.map((stage) => (
<CommandItem
key=[redacted]
value={stage}
onSelect={() => {
setStageRequiredFields((prev) => ({
...prev,
[stage]: [],
}));
}}
>
<span className="font-mono text-xs">{stage}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
})()}
</div>
{/* Custom Object Config — Salesforce only */}
{integrationInfo.id === 'salesforce' && (
<div className="space-y-3 border-t pt-3">
<Label className="text-xs font-medium">Default Custom Object Sync</Label>
<p className="text-xs text-muted-foreground">
Salesforce custom object to fetch per deal conversation using the linked
Account ID as the filter value. Stamped onto user connections at
connect time.
</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">Object Name</Label>
<Input
placeholder="e.g. Cursor_Team__c"
value={customObjectName}
onChange={(e) => setCustomObjectName(e.target.value)}
className="h-7 text-xs font-mono"
/>
</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-7 text-xs font-mono"
/>
</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, Num_Active_Core_Users_L30D__c"
value={customObjectFieldsInput}
onChange={(e) => setCustomObjectFieldsInput(e.target.value)}
className="h-7 text-xs font-mono"
/>
</div>
<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-7 text-xs"
/>
</div>
</div>
)}
{/* Field Mappings - Full Editor */}
<div className="space-y-3 border-t pt-3">
<div className="flex items-center justify-between">
<Label className="text-xs font-medium">
Default Field Mappings
{isFetchingCrmProperties && (
<Loader2 className="ml-2 inline h-3 w-3 animate-spin" />
)}
</Label>
</div>
<p className="text-xs text-muted-foreground">
Configure which Cedar fields map to {integrationInfo.name} fields. Users
can customize these mappings in their settings.
</p>
{/* Existing Mappings */}
<div className="max-h-[400px] space-y-3 overflow-y-auto rounded border bg-background p-3">
{crmFieldMappings.length > 0 ? (
crmFieldMappings.map((mapping) => {
const isSimple = isSimpleMapping(mapping);
return (
<div
key=[redacted]
className="space-y-2 rounded-lg border bg-muted/30 p-3"
>
<div className="flex items-center justify-between">
{/* CRM Field Selector */}
<div className="flex-1 mr-2">
{crmPropertiesData?.field_names && crmPropertiesData.field_names.length > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 w-full justify-between font-mono text-xs"
>
{mapping.crmFieldName || 'Select CRM field...'}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search CRM fields..." />
<CommandList>
<CommandEmpty>No CRM field found.</CommandEmpty>
<CommandGroup>
{crmPropertiesData.field_names.map((field) => {
const isMapped = crmFieldMappings.some(
(m) =>
m.crmFieldName === field &&
m.crmFieldName !== mapping.crmFieldName,
);
const isSelected = mapping.crmFieldName === field;
const hasPicklist =
!!crmPropertiesData.picklist_fields?.[field];
return (
<CommandItem
key=[redacted]
value={field}
disabled={isMapped}
onSelect={() => {
handleCrmFieldNameChange(
mapping.crmFieldName,
field,
);
}}
className={
isMapped ? 'opacity-50' : undefined
}
>
<Check
className={`mr-2 h-4 w-4 ${
isSelected ? 'opacity-100' : 'opacity-0'
}`}
/>
<span className="font-mono text-xs">
{field}
</span>
{hasPicklist && (
<span className="ml-2 text-xs text-blue-500">
(picklist)
</span>
)}
{isMapped && (
<span className="ml-2 text-xs text-muted-foreground">
(mapped)
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Input
value={mapping.crmFieldName}
onChange={(e) =>
handleCrmFieldNameChange(
mapping.crmFieldName,
e.target.value,
)
}
className="h-8 font-mono text-xs"
placeholder="CRM field name"
/>
)}
</div>
{/* Mapping Type Toggle */}
<ToggleGroup
type="single"
value={isSimple ? 'simple' : 'template'}
onValueChange={(value) => {
if (value)
handleToggleMappingType(
mapping.crmFieldName,
value === 'simple',
);
}}
className="mr-2"
>
<ToggleGroupItem
value="simple"
size="sm"
className="h-8 px-2 text-xs"
>
Direct
</ToggleGroupItem>
<ToggleGroupItem
value="template"
size="sm"
className="h-8 px-2 text-xs"
>
Template
</ToggleGroupItem>
</ToggleGroup>
{/* Remove Button */}
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
onClick={() => handleRemoveMapping(mapping.crmFieldName)}
>
<X className="h-4 w-4" />
</Button>
</div>
{/* Mapping Value */}
{isSimple ? (
<Select
value={
isSimpleMapping(mapping) ? mapping.cedarFieldName : ''
}
onValueChange={(value) =>
handleSimpleMappingChange(mapping.crmFieldName, value)
}
>
<SelectTrigger >
<SelectValue placeholder="Select Cedar field..." />
</SelectTrigger>
<SelectContent>
{availableCedarFields.map((field) => (
<SelectItem
key=[redacted]
value={field.value}
className="text-xs"
>
{field.label}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<div className="space-y-3">
<div>
<Label className="text-xs text-muted-foreground mb-1 block">
Template (use {'{variable}'} for placeholders)
</Label>
<div className="flex gap-2">
<Input
id={`template-${mapping.crmFieldName}`}
value={isComplexMapping(mapping) ? mapping.template : ''}
onChange={(e) =>
handleComplexMappingChange(
mapping.crmFieldName,
e.target.value,
isComplexMapping(mapping)
? mapping.transformations
: undefined,
isComplexMapping(mapping)
? mapping.allowedValues
: undefined,
isComplexMapping(mapping) ? mapping.prefix : undefined,
)
}
placeholder="{nextStepDate} – {nextSteps}"
className="text-xs font-mono flex-1 h-8"
/>
<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 = isComplexMapping(mapping)
? mapping.template
: '';
const newValue =
currentValue.slice(0, start) +
`{${value}}` +
currentValue.slice(end);
handleComplexMappingChange(
mapping.crmFieldName,
newValue,
isComplexMapping(mapping)
? mapping.transformations
: undefined,
isComplexMapping(mapping)
? mapping.allowedValues
: undefined,
isComplexMapping(mapping) ? mapping.prefix : 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>
<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={isComplexMapping(mapping) ? mapping.prefix || '' : ''}
onChange={(e) =>
handleComplexMappingChange(
mapping.crmFieldName,
isComplexMapping(mapping) ? mapping.template : '',
isComplexMapping(mapping)
? mapping.transformations
: undefined,
isComplexMapping(mapping)
? mapping.allowedValues
: undefined,
e.target.value || undefined,
)
}
placeholder="MS: or {status}: "
className="text-xs font-mono flex-1 h-8"
/>
<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 = isComplexMapping(mapping)
? mapping.prefix || ''
: '';
const newValue =
currentValue.slice(0, start) +
`{${value}}` +
currentValue.slice(end);
handleComplexMappingChange(
mapping.crmFieldName,
isComplexMapping(mapping) ? mapping.template : '',
isComplexMapping(mapping)
? mapping.transformations
: undefined,
isComplexMapping(mapping)
? mapping.allowedValues
: undefined,
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>
{/* Transformations UI */}
{isComplexMapping(mapping) &&
(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
: 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' && (
<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;
}
> = {};
Object.keys(
mapping.transformations || {},
).forEach((key) => {
if (mapping.transformations?.[key]) {
const trans = mapping.transformations[key];
if (
trans.type === 'date' ||
trans.type === 'number' ||
trans.type === 'string'
) {
newTransformations[key] = {
type: trans.type,
format:
key === fieldName ? value : trans.format,
};
}
}
});
handleComplexMappingChange(
mapping.crmFieldName,
mapping.template,
Object.keys(newTransformations).length > 0
? newTransformations
: undefined,
mapping.allowedValues,
mapping.prefix,
);
}}
>
<SelectTrigger size="sm" className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{DATE_FORMAT_OPTIONS.map((opt) => (
<SelectItem key=[redacted] value={opt.value}>
{opt.value} ({opt.example})
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
);
});
})()}
</div>
)}
</div>
)}
{/* Allowed Values */}
<div className="pt-1">
<Label className="text-xs text-muted-foreground">
Allowed values (optional, comma-separated):
</Label>
<Input
value={
(isSimpleMapping(mapping) || isComplexMapping(mapping)
? mapping.allowedValues
: undefined
)?.join(', ') || ''
}
onChange={(e) => {
const values = e.target.value
.split(',')
.map((v) => v.trim())
.filter(Boolean);
handleAllowedValuesChange(mapping.crmFieldName, values);
}}
placeholder="value1, value2, value3"
className="mt-1 h-7 text-xs"
/>
</div>
</div>
);
})
) : (
<p className="py-4 text-center text-xs text-muted-foreground">
No field mappings configured. Click below to add a mapping.
</p>
)}
</div>
{/* Add New Mapping */}
{crmPropertiesData?.field_names && crmPropertiesData.field_names.length > 0 ? (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2"
>
<Plus className="h-4 w-4" />
Add field mapping
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search CRM fields..." />
<CommandList>
<CommandEmpty>No CRM field found.</CommandEmpty>
<CommandGroup>
{crmPropertiesData.field_names.map((field) => {
const isMapped = crmFieldMappings.some(
(m) => m.crmFieldName === field,
);
const hasPicklist =
!!crmPropertiesData.picklist_fields?.[field];
return (
<CommandItem
key=[redacted]
value={field}
disabled={isMapped}
onSelect={() => handleAddMapping(field)}
className={isMapped ? 'opacity-50' : undefined}
>
<span className="font-mono text-xs">{field}</span>
{hasPicklist && (
<span className="ml-2 text-xs text-blue-500">
(picklist)
</span>
)}
{isMapped && (
<span className="ml-2 text-xs text-muted-foreground">
(already mapped)
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<Button
variant="outline"
size="sm"
className="w-full justify-start gap-2"
onClick={() => handleAddMapping('new_field')}
disabled={isFetchingCrmProperties}
>
{isFetchingCrmProperties ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Plus className="h-4 w-4" />
)}
{isFetchingCrmProperties
? 'Loading CRM fields...'
: 'Add field mapping'}
</Button>
)}
</div>
</div>
)}
</div>
)}
</div>
{/* Dialog Footer - show for API key, manual with org webhooks, CRM, or when configured (for remove) */}
{(effectiveConnectionType === 'api_key' || effectiveConnectionType === 'manual' || isCrmProvider || isConfigured) && (
<DialogFooter className="flex-row gap-2 sm:justify-between">
{isConfigured && effectiveConnectionType !== 'manual' && (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="text-red-600">
<Trash2 className="mr-2 h-3 w-3" />
Remove
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Configuration</DialogTitle>
<DialogDescription>
Are you sure you want to remove the {integrationInfo.name}{' '}
configuration? Team members will need to connect their own accounts.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-4">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
variant="destructive"
onClick={handleRemoveConfig}
disabled={isRemoving}
>
{isRemoving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Removing...
</>
) : (
'Remove'
)}
</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
)}
{/* Manual types with org webhooks - Enable/Disable button */}
{effectiveConnectionType === 'manual' && (
<div className="flex flex-1 justify-end gap-2">
<DialogClose asChild>
<Button variant="outline" size="sm">
Cancel
</Button>
</DialogClose>
<Button
size="sm"
variant={isConfigured ? 'destructive' : 'default'}
onClick={handleToggleEnabled}
disabled={isTogglingEnabled}
>
{isTogglingEnabled ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
{isConfigured ? 'Disabling...' : 'Enabling...'}
</>
) : isConfigured ? (
'Disable'
) : (
'Enable'
)}
</Button>
</div>
)}
{(effectiveConnectionType === 'api_key' || (isCrmProvider && effectiveConnectionType !== 'manual')) && (
<div className="flex flex-1 justify-end gap-2">
<DialogClose asChild>
<Button
variant="outline"
size="sm"
onClick={() => setFormFields({})}
disabled={isSaving}
>
Cancel
</Button>
</DialogClose>
<Button size="sm" onClick={handleSaveConfig} disabled={isSaving}>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save Configuration'
)}
</Button>
</div>
)}
</DialogFooter>
)}
</DialogContent>
</Dialog>
)}
{/* Quick remove button on card (only when configured) */}
{isConfigured && effectiveConnectionType !== 'manual' && (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm" className="px-2">
<Trash2 className="h-3 w-3 text-red-500" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove Configuration</DialogTitle>
<DialogDescription>
Are you sure you want to remove the {integrationInfo.name} configuration? Team
members will need to connect their own accounts.
</DialogDescription>
</DialogHeader>
<div className="flex justify-end gap-4">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
variant="destructive"
onClick={handleRemoveConfig}
disabled={isRemoving}
>
{isRemoving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Removing...
</>
) : (
'Remove'
)}
</Button>
</DialogClose>
</div>
</DialogContent>
</Dialog>
)}
</div>
)}
</div>
);
}