meeting-recorder-step.tsx25.4 KBView on GitHub 'use client';
import type { ProviderId } from '../../../../server/src/services/integrations/constants';
import { MeetingIntegrationCard } from '@/modules/integrations/meeting-integration-card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2, Copy, Check, Eye, EyeOff } from 'lucide-react';
import { useTRPC } from '@/providers/query-provider';
import { useState, useEffect, useMemo } from 'react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';
interface MeetingRecorderOnboardingStepProps {
integrationsData:
| {
integrations: Array<{
id: string;
name: string;
type: string;
connected: boolean;
capabilities?: unknown;
orgConfig?: {
type: 'api_key' | 'oauth' | 'manual' | 'service_account';
userMustAuthenticate: boolean;
hasOrgCredentials: boolean;
};
isPersonalConnection?: boolean;
}>;
}
| undefined;
session: { user?: { id: string } } | null;
copyToClipboard: (value: string, id: string) => void;
copiedValue: string | null;
selectedProviderId?: string;
}
export function MeetingRecorderOnboardingStep({
integrationsData,
session,
copyToClipboard,
copiedValue,
selectedProviderId,
}: MeetingRecorderOnboardingStepProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [formFields, setFormFields] = useState<Record<string, Record<string, string>>>({});
const [isConnecting, setIsConnecting] = useState<Record<string, boolean>>({});
const [isActivating, setIsActivating] = useState<Record<string, boolean>>({});
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
const { mutateAsync: connect } = useMutation(trpc.integrations.connect.mutationOptions());
const { mutateAsync: activateOrgIntegration } = useMutation(
trpc.integrations.activateOrgIntegration.mutationOptions(),
);
// Filter to only meeting type integrations (optionally narrowed to one provider)
const meetingProviders = useMemo(() => {
const all = integrationsData?.integrations.filter((i) => i.type === 'meeting') || [];
return selectedProviderId ? all.filter((i) => i.id === selectedProviderId) : all;
}, [integrationsData?.integrations, selectedProviderId]);
// Initialize form fields for API key providers
useEffect(() => {
meetingProviders.forEach((provider) => {
const capabilities = provider.capabilities as
| {
connectionType?: 'oauth' | 'api_key' | 'manual';
connectionFields?: Array<{
key=[redacted];
label: string;
type: 'text' | 'password';
placeholder: string;
}>;
}
| undefined;
if (capabilities?.connectionType === 'api_key' && capabilities.connectionFields) {
setFormFields((prev) => {
if (!prev[provider.id]) {
const initialFields: Record<string, string> = {};
capabilities.connectionFields!.forEach((field) => {
initialFields[field.key] = '';
});
return { ...prev, [provider.id]: initialFields };
}
return prev;
});
}
});
}, [meetingProviders]);
// Get first provider as default tab
const defaultTab = meetingProviders[0]?.id || '';
if (meetingProviders.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<p className="text-muted-foreground text-sm">No meeting providers available</p>
</div>
);
}
const handleDirectInputConnect = async (
providerId: string,
connectionFields: Array<{
key=[redacted];
label: string;
type: 'text' | 'password';
placeholder: string;
}>,
) => {
const providerName = providerId.charAt(0).toUpperCase() + providerId.slice(1);
const fields = formFields[providerId] || {};
// Validate all required fields are filled
const missingFields = connectionFields.filter((field) => !fields[field.key]?.trim());
if (missingFields.length > 0) {
toast.error(
`Please fill in all required fields: ${missingFields.map((f) => f.label).join(', ')}`,
);
return;
}
try {
setIsConnecting((prev) => ({ ...prev, [providerId]: true }));
await connect({
providerId: providerId as ProviderId,
connectionParams: fields,
userId: session?.user?.id,
});
setFormFields((prev) => {
const next = { ...prev };
delete next[providerId];
return next;
});
setIsConnecting((prev) => ({ ...prev, [providerId]: false }));
queryClient.invalidateQueries({ queryKey=[redacted] });
} catch (error) {
console.error(`[MeetingRecorderOnboardingStep] Error connecting ${providerId}:`, error);
setIsConnecting((prev) => ({ ...prev, [providerId]: false }));
toast.error(
`Failed to connect ${providerName}: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
// Handle activation of org-provisioned integrations
const handleActivateOrgIntegration = async (providerId: string) => {
const providerName = providerId.charAt(0).toUpperCase() + providerId.slice(1);
try {
setIsActivating((prev) => ({ ...prev, [providerId]: true }));
const result = await activateOrgIntegration({ providerId });
if (result.alreadyConnected) {
toast.info(`${providerName} is already connected`);
}
queryClient.invalidateQueries({ queryKey=[redacted] });
} catch (error) {
console.error(`[MeetingRecorderOnboardingStep] Error activating ${providerId}:`, error);
toast.error(
`Failed to activate ${providerName}: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
setIsActivating((prev) => ({ ...prev, [providerId]: false }));
}
};
const renderFormField = (
providerId: string,
field: {
key=[redacted];
label: string;
type: 'text' | 'password';
placeholder: string;
},
) => {
const isPasswordField = field.type === 'password';
const isApiKeyField = field.key === 'apiKey';
const fieldValue = formFields[providerId]?.[field.key] || '';
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[providerId] ? 'password' : 'text'}
placeholder={field.placeholder}
value={fieldValue}
onChange={(e) =>
setFormFields((prev) => ({
...prev,
[providerId]: { ...prev[providerId], [field.key]: e.target.value },
}))
}
disabled={isConnecting[providerId]}
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((prev) => ({ ...prev, [providerId]: !prev[providerId] }))
}
>
{showApiKey[providerId] ? (
<EyeOff className="h-4 w-4 text-gray-500" />
) : (
<Eye className="h-4 w-4 text-gray-500" />
)}
</Button>
)}
</div>
</div>
);
};
if (selectedProviderId) {
const provider = meetingProviders[0];
if (!provider) return null;
const capabilities = provider.capabilities as
| {
apiKeyInstructions?: string;
webhookSetupInstructions?: string;
webhookScope?: 'org' | 'user' | 'none';
connectionType?: 'oauth' | 'api_key' | 'manual';
connectionFields?: Array<{
key=[redacted];
label: string;
type: 'text' | 'password';
placeholder: string;
}>;
}
| undefined;
const isConnected = provider.connected || false;
const webhookUrl = `https://api.mail.cedarcopilot.com/webhooks/meeting-notes/${provider.id}`;
return (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Connect {provider.name}</h3>
<div className="flex items-center gap-2">
{provider.isPersonalConnection && isConnected && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
Personal
</span>
)}
{isConnected && (
<span className="flex items-center gap-2 text-sm font-medium text-green-600">
✓ Connected
</span>
)}
</div>
</div>
<p className="text-muted-foreground text-sm">
{capabilities?.connectionType === 'api_key'
? "We'll sync your meetings and create a webhook to capture all your future meetings."
: 'Add this webhook to your account to sync meeting notes.'}
</p>
</div>
{capabilities?.webhookSetupInstructions &&
capabilities?.connectionType === 'manual' &&
capabilities?.webhookScope === 'user' && (
<div className="bg-muted/30 space-y-4 rounded-lg border p-4">
<div className="text-muted-foreground whitespace-pre-line text-xs">
{capabilities.webhookSetupInstructions}
</div>
{provider.id === 'granola' && (
<div className="space-y-2">
<Label htmlFor={`user-id-${provider.id}`}>Your User ID</Label>
<div className="flex items-center gap-2">
<Input id={`user-id-${provider.id}`} readOnly value={session?.user?.id || 'Loading...'} className="font-mono text-xs" />
<Button variant="outline" size="icon" disabled={!session?.user?.id} onClick={() => session?.user?.id && copyToClipboard(session.user.id, `userid-${provider.id}`)}>
{copiedValue === `userid-${provider.id}` ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
)}
<div className="space-y-2">
<Label htmlFor={`webhook-url-${provider.id}`}>Webhook URL</Label>
<div className="flex items-center gap-2">
<Input id={`webhook-url-${provider.id}`} readOnly value={webhookUrl} className="font-mono text-xs" />
<Button variant="outline" size="icon" onClick={() => copyToClipboard(webhookUrl, `webhook-${provider.id}`)}>
{copiedValue === `webhook-${provider.id}` ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
)}
{!isConnected && capabilities?.connectionType === 'api_key' && provider.orgConfig?.userMustAuthenticate === false && provider.orgConfig?.hasOrgCredentials && (
<div className="space-y-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
<span className="rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">Provided by your organization</span>
<p className="mt-2 text-sm text-blue-700 dark:text-blue-300">Your organization has configured {provider.name}. Click below to activate your account.</p>
</div>
<Button size="lg" className="w-full" onClick={() => handleActivateOrgIntegration(provider.id)} disabled={isActivating[provider.id]}>
{isActivating[provider.id] ? <><Loader2 className="mr-2 h-5 w-5 animate-spin" /><span>Activating...</span></> : `Activate ${provider.name}`}
</Button>
</div>
)}
{!isConnected && capabilities?.connectionType === 'api_key' && capabilities.connectionFields && capabilities.connectionFields.length > 0 && (provider.orgConfig?.userMustAuthenticate !== false || !provider.orgConfig?.hasOrgCredentials) && (
<div className="space-y-4">
{capabilities.apiKeyInstructions && <div className="bg-muted/30 rounded-lg border p-4"><div className="text-muted-foreground whitespace-pre-line text-xs">{capabilities.apiKeyInstructions}</div></div>}
{capabilities.connectionFields.map((field) => renderFormField(provider.id, field))}
<Button size="lg" className="w-full border border-gray-200 bg-white text-gray-800 shadow-sm hover:bg-gray-50" onClick={() => handleDirectInputConnect(provider.id, capabilities.connectionFields!)} disabled={isConnecting[provider.id]}>
{isConnecting[provider.id] ? <><Loader2 className="mr-2 h-5 w-5 animate-spin" /><span>Connecting...</span></> : `Connect ${provider.name}`}
</Button>
</div>
)}
{isConnected && capabilities?.connectionType === 'api_key' && capabilities?.webhookSetupInstructions && capabilities?.webhookScope === 'user' && (
<div className="bg-muted/30 space-y-4 rounded-lg border p-4">
<div className="text-muted-foreground whitespace-pre-line text-xs">{capabilities.webhookSetupInstructions}</div>
<div className="space-y-2">
<Label htmlFor={`webhook-url-${provider.id}`}>Webhook URL</Label>
<div className="flex items-center gap-2">
<Input id={`webhook-url-${provider.id}`} readOnly value={webhookUrl} className="font-mono text-xs" />
<Button variant="outline" size="icon" onClick={() => copyToClipboard(webhookUrl, `webhook-${provider.id}`)}>
{copiedValue === `webhook-${provider.id}` ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</div>
</div>
)}
{(capabilities?.connectionType !== 'api_key' || isConnected) && <MeetingIntegrationCard providerId={provider.id} userId={session?.user?.id} showManager={false} />}
</div>
);
}
return (
<div className="space-y-4">
<Tabs defaultValue={defaultTab} className="w-full">
<div className="overflow-x-auto">
<TabsList>
{meetingProviders.map((provider) => (
<TabsTrigger key=[redacted] value={provider.id}>
{provider.name}
</TabsTrigger>
))}
</TabsList>
</div>
{meetingProviders.map((provider) => {
const capabilities = provider.capabilities as
| {
apiKeyInstructions?: string;
webhookSetupInstructions?: string;
webhookScope?: 'org' | 'user' | 'none';
connectionType?: 'oauth' | 'api_key' | 'manual';
connectionFields?: Array<{
key=[redacted];
label: string;
type: 'text' | 'password';
placeholder: string;
}>;
}
| undefined;
const isConnected = provider.connected || false;
const webhookUrl = `https://api.mail.cedarcopilot.com/webhooks/meeting-notes/${provider.id}`;
return (
<TabsContent key=[redacted] value={provider.id} className="space-y-4 pt-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Connect {provider.name}</h3>
<div className="flex items-center gap-2">
{provider.isPersonalConnection && isConnected && (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
Personal
</span>
)}
{isConnected && (
<span className="flex items-center gap-2 text-sm font-medium text-green-600">
✓ Connected
</span>
)}
</div>
</div>
<p className="text-muted-foreground text-sm">
{capabilities?.connectionType === 'api_key'
? "We'll sync your meetings and create a webhook to capture all your future meetings."
: 'Add this webhook to your account to sync meeting notes.'}
</p>
</div>
{/* Webhook Setup Instructions (for manual providers with user-level webhooks like Granola) */}
{capabilities?.webhookSetupInstructions &&
capabilities?.connectionType === 'manual' &&
capabilities?.webhookScope === 'user' && (
<div className="bg-muted/30 space-y-4 rounded-lg border p-4">
<div className="text-muted-foreground whitespace-pre-line text-xs">
{capabilities.webhookSetupInstructions}
</div>
{provider.id === 'granola' && (
<div className="space-y-2">
<Label htmlFor={`user-id-${provider.id}`}>Your User ID</Label>
<div className="flex items-center gap-2">
<Input
id={`user-id-${provider.id}`}
readOnly
value={session?.user?.id || 'Loading...'}
className="font-mono text-xs"
/>
<Button
variant="outline"
size="icon"
disabled={!session?.user?.id}
onClick={() =>
session?.user?.id &&
copyToClipboard(session.user.id, `userid-${provider.id}`)
}
>
{copiedValue === `userid-${provider.id}` ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
)}
{/* Webhook URL - shown with webhook instructions */}
<div className="space-y-2">
<Label htmlFor={`webhook-url-${provider.id}`}>Webhook URL</Label>
<div className="flex items-center gap-2">
<Input
id={`webhook-url-${provider.id}`}
readOnly
value={webhookUrl}
className="font-mono text-xs"
/>
<Button
variant="outline"
size="icon"
onClick={() => copyToClipboard(webhookUrl, `webhook-${provider.id}`)}
>
{copiedValue === `webhook-${provider.id}` ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
)}
{/* API Key - Org Provisioned (userMustAuthenticate === false) */}
{!isConnected &&
capabilities?.connectionType === 'api_key' &&
provider.orgConfig?.userMustAuthenticate === false &&
provider.orgConfig?.hasOrgCredentials && (
<div className="space-y-4">
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
<div className="flex items-center gap-2">
<span className="rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">
Provided by your organization
</span>
</div>
<p className="mt-2 text-sm text-blue-700 dark:text-blue-300">
Your organization has configured {provider.name}. Click below to activate
your account.
</p>
</div>
<Button
size="lg"
className="w-full"
onClick={() => handleActivateOrgIntegration(provider.id)}
disabled={isActivating[provider.id]}
>
{isActivating[provider.id] ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
<span>Activating...</span>
</>
) : (
`Activate ${provider.name}`
)}
</Button>
</div>
)}
{/* API Key Form - shown for API key providers when user must authenticate */}
{!isConnected &&
capabilities?.connectionType === 'api_key' &&
capabilities.connectionFields &&
capabilities.connectionFields.length > 0 &&
(provider.orgConfig?.userMustAuthenticate !== false ||
!provider.orgConfig?.hasOrgCredentials) && (
<div className="space-y-4">
{capabilities.apiKeyInstructions && (
<div className="bg-muted/30 rounded-lg border p-4">
<div className="text-muted-foreground whitespace-pre-line text-xs">
{capabilities.apiKeyInstructions}
</div>
</div>
)}
{capabilities.connectionFields.map((field) =>
renderFormField(provider.id, field),
)}
<Button
size="lg"
className="w-full border border-gray-200 bg-white text-gray-800 shadow-sm hover:bg-gray-50"
onClick={() =>
handleDirectInputConnect(provider.id, capabilities.connectionFields!)
}
disabled={isConnecting[provider.id]}
>
{isConnecting[provider.id] ? (
<>
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
<span>Connecting...</span>
</>
) : (
`Connect ${provider.name}`
)}
</Button>
</div>
)}
{/* Webhook Setup Instructions for API key providers with user-level webhooks - shown after connection */}
{isConnected &&
capabilities?.connectionType === 'api_key' &&
capabilities?.webhookSetupInstructions &&
capabilities?.webhookScope === 'user' && (
<div className="bg-muted/30 space-y-4 rounded-lg border p-4">
<div className="text-muted-foreground whitespace-pre-line text-xs">
{capabilities.webhookSetupInstructions}
</div>
<div className="space-y-2">
<Label htmlFor={`webhook-url-${provider.id}`}>Webhook URL</Label>
<div className="flex items-center gap-2">
<Input
id={`webhook-url-${provider.id}`}
readOnly
value={webhookUrl}
className="font-mono text-xs"
/>
<Button
variant="outline"
size="icon"
onClick={() => copyToClipboard(webhookUrl, `webhook-${provider.id}`)}
>
{copiedValue === `webhook-${provider.id}` ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
)}
{/* Show MeetingIntegrationCard for OAuth providers or when API key provider is connected */}
{(capabilities?.connectionType !== 'api_key' || isConnected) && (
<MeetingIntegrationCard
providerId={provider.id}
userId={session?.user?.id}
showManager={false}
/>
)}
</TabsContent>
);
})}
</Tabs>
</div>
);
}