Introduced 1 production defect in 180 days, median 103 days to fix.
'use client';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import type { ProviderId } from '../../../server/src/services/integrations/constants';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { Loader2, Unplug, Eye, EyeOff } from 'lucide-react';
import { useTRPC } from '@/providers/query-provider';
import { MeetingManager } from './meeting-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 { useState, useEffect } from 'react';
import { toast } from 'sonner';
interface MeetingIntegrationCardProps {
providerId: string;
userId?: string;
showManager?: boolean; // Whether to show the meeting manager when connected
userFacing?: boolean; // When true, hides the meeting manager (for user-facing settings page)
}
export function MeetingIntegrationCard({
providerId,
userId,
showManager = true,
userFacing = false,
}: MeetingIntegrationCardProps) {
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 trpc = useTRPC();
const queryClient = useQueryClient();
// Fetch integration status
const { data: integrationsData, isLoading: isIntegrationsLoading, 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 connectionFields = integration?.capabilities?.connectionFields || [];
const isPersonalConnection = (integration as { isPersonalConnection?: boolean } | undefined)
?.isPersonalConnection;
const isOrgManaged = (integration as { isOrgManaged?: boolean } | undefined)?.isOrgManaged;
const apiKeyInstructions = (
integration?.capabilities as { apiKeyInstructions?: string } | undefined
)?.apiKeyInstructions;
const webhookScope = (integration?.capabilities as { webhookScope?: 'org' | 'user' | 'none' } | undefined)
?.webhookScope;
const hasOrgCredentials =
(integration as { orgConfig?: { hasOrgCredentials?: boolean } } | undefined)?.orgConfig
?.hasOrgCredentials ?? false;
const requiredFields = connectionFields;
// Org-scoped api_key providers (Gong, Clari, Caretta) share one org-wide secret entered by an
// org admin — an individual user has no personal credential to type in, so they never see the
// credential form; they either get a plain Connect button (once the org has configured it) or
// a "waiting on your admin" message.
const isOrgScopedApiKey=[redacted] === 'api_key' && webhookScope === 'org';
// An MCP-authenticated recorder redirects to the provider's own authorization server and
// is finished by Cedar's /oauth/:provider/callback, which writes the connection itself.
// Klavis is not involved, so the `connect` round trip below must be skipped: it would ask
// Klavis about a provider that is not in its catalog and fail with a 422.
const usesMcpOAuth = integration?.capabilities?.usesMcpOAuth ?? false;
// Whether to OFFER "authorize instead" beside the credential form. Deliberately not
// `usesMcpOAuth`: that only says the flow would complete. Granola, Fireflies, Gong and
// Clari can complete it and still cannot BACKFILL with the resulting token, because
// their fetch path calls the provider's REST API with a pasted key. Offering it there
// hands the user a connection that looks fine and whose backfill throws.
const offersMcpOAuthAlternative =
(integration?.capabilities as { mcpOAuthAlternative?: boolean } | undefined)
?.mcpOAuthAlternative ?? false;
// Auto-show the credential form for personal api_key providers — no need for an extra button click
useEffect(() => {
if (connectionType === 'api_key' && requiredFields.length > 0 && !isConnected && !isOrgScopedApiKey) {
setShowDirectInputForm(true);
const initialFields: Record<string, string> = {};
requiredFields.forEach((field) => {
initialFields[field.key] = '';
});
setFormFields((prev) => {
// Don't overwrite if already partially filled
const needsInit = requiredFields.some((f) => !(f.key in prev));
return needsInit ? initialFields : prev;
});
}
}, [connectionType, isConnected, requiredFields, isOrgScopedApiKey]);
const { mutateAsync: initiateOAuth } = useMutation(
trpc.integrations.initiateOAuth.mutationOptions(),
);
const { mutateAsync: connect } = useMutation(trpc.integrations.connect.mutationOptions());
const { mutateAsync: deleteConnection } = useMutation(trpc.connections.delete.mutationOptions());
// Handle OAuth callback
useEffect(() => {
const handleWindowFocus = async () => {
const storageKey=[redacted];
const pendingAuth = sessionStorage.getItem(storageKey);
if (pendingAuth && isConnecting) {
console.log(
`[MeetingIntegrationCard] User returned from OAuth tab for ${providerId}, checking connection...`,
);
try {
const pending = JSON.parse(pendingAuth) as {
strataServerUrl?: string;
usesMcpOAuth?: boolean;
};
// What kind of flow this is was settled when the flow started. Falling back to
// the live value covers a key written before this was recorded.
const isMcpFlow = pending.usesMcpOAuth ?? usesMcpOAuth;
if (isMcpFlow) {
// The MCP callback has already persisted the connection server-side, so there
// is nothing to confirm and nobody to confirm it with. Just pick up the new
// row — but keep the pending key until it actually reports connected. A user
// who clicks back to Cedar while the provider's tab is still open would
// otherwise discard the flow, and returning for real afterwards would do
// nothing at all.
const refreshed = await refetchIntegrations();
const isNowConnected =
refreshed.data?.integrations.find((i) => i.id === providerId)?.connected === true;
if (!isNowConnected) return;
sessionStorage.removeItem(storageKey);
setIsConnecting(false);
queryClient.invalidateQueries({ queryKey=[redacted] });
return;
}
await connect({
providerId: providerId as ProviderId,
connectionParams: { strataServerUrl: pending.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(`[MeetingIntegrationCard] 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, usesMcpOAuth]);
// The OAuth half, extracted so the credential form can offer it as an ALTERNATIVE.
// A provider can support both: paste a long-lived key, or authorize against its own
// server and let Cedar hold a refreshable token instead. Granola is the case that
// motivates it, since its API key also requires a Business or Enterprise plan.
const startOAuthFlow = async () => {
try {
setIsConnecting(true);
const { oauthUrl, strataServerUrl } = await initiateOAuth({
integration: providerId,
userId,
});
if (!oauthUrl) {
setIsConnecting(false);
toast.error('Failed to get OAuth URL');
return;
}
const storageKey=[redacted];
// `usesMcpOAuth` is recorded HERE, where the integration row is loaded and the
// answer is known. The focus handler that finishes this flow used to re-derive it
// from live query state, which is undefined while `integrations.list` is loading or
// refetching, and an MCP recorder read as non-MCP there goes through Klavis and
// fails with a 422.
sessionStorage.setItem(storageKey, JSON.stringify({ strataServerUrl, usesMcpOAuth }));
// This runs after an await, so it is outside the click's gesture stack and Safari
// and Firefox block it. Without noticing, `isConnecting` stays true and every
// control on the credential form below is disabled by it, leaving the card inert
// with no way out but a reload.
const popup = window.open(oauthUrl, '_blank');
if (!popup) {
sessionStorage.removeItem(storageKey);
setIsConnecting(false);
toast.error('Your browser blocked the sign-in window. Allow popups for Cedar and retry.');
}
} catch (error) {
console.error(`[MeetingIntegrationCard] Error initiating auth for ${providerId}:`, error);
setIsConnecting(false);
toast.error(`Failed to initiate connection for ${providerId}`);
}
};
const handleConnect = async () => {
// Org-scoped api_key providers have no personal credential to enter — either connect
// directly using the org's shared secret, or point the user at their admin.
if (isOrgScopedApiKey) {
const providerName = providerId.charAt(0).toUpperCase() + providerId.slice(1);
if (!hasOrgCredentials) {
toast.error(
`Ask your organization admin to configure ${providerName} before connecting.`,
);
return;
}
try {
setIsConnecting(true);
await connect({ providerId: providerId as ProviderId, connectionParams: {}, userId });
setIsConnecting(false);
void refetchIntegrations();
queryClient.invalidateQueries({ queryKey=[redacted] });
} catch (error) {
console.error(`[MeetingIntegrationCard] Error connecting ${providerId}:`, error);
setIsConnecting(false);
toast.error(
`Failed to connect ${providerName}: ${error instanceof Error ? error.message : String(error)}`,
);
}
return;
}
// 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) => {
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;
}
// Guard: if integrations haven't loaded yet, don't attempt OAuth
if (!integration) {
toast.error('Integration info not loaded yet. Please wait a moment and try again.');
return;
}
// Otherwise, use OAuth flow (connectionType === 'oauth' or undefined)
await startOAuthFlow();
};
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,
});
setShowDirectInputForm(false);
setFormFields({});
setIsConnecting(false);
void refetchIntegrations();
queryClient.invalidateQueries({ queryKey=[redacted] });
} catch (error) {
console.error(`[MeetingIntegrationCard] Error connecting ${providerId}:`, error);
setIsConnecting(false);
toast.error(
`Failed to connect ${providerName}: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
// 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>
);
};
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(`[MeetingIntegrationCard] Error disconnecting ${providerId}:`, error);
toast.error(`Failed to disconnect ${providerId}`);
} finally {
setIsDisconnecting(false);
}
};
// Icon rendering
const renderIcon = () => {
switch (providerId) {
case 'attio':
return (
<div className="flex h-5 w-5 items-center justify-center rounded bg-[#1A1A1A] text-xs font-bold text-white">
A
</div>
);
case 'fathom':
return (
<div className="flex h-5 w-5 items-center justify-center rounded bg-[#FF4F00] text-xs font-bold text-white">
F
</div>
);
case 'fellow':
return (
<div className="flex h-5 w-5 items-center justify-center rounded bg-[#5B4FE9] text-xs font-bold text-white">
F
</div>
);
case 'fireflies':
return (
<div className="flex h-5 w-5 items-center justify-center rounded bg-[#FF6B35] text-xs font-bold text-white">
🔥
</div>
);
case 'krisp':
return (
<div className="flex h-5 w-5 items-center justify-center rounded bg-[#0A84FF] text-xs font-bold text-white">
K
</div>
);
default:
return <div className="h-5 w-5 rounded-full bg-gray-200" />;
}
};
const providerName = providerId.charAt(0).toUpperCase() + providerId.slice(1);
const buttonText = `Continue with ${providerName}`;
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>
)}
{isConnected ? (
<>
{!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>
)}
</>
) : isOrgScopedApiKey && !hasOrgCredentials ? (
<div className="space-y-2">
{apiKeyInstructions && (
<div className="bg-muted/30 rounded-lg border p-4">
<div className="text-muted-foreground whitespace-pre-line text-xs">
{apiKeyInstructions}
</div>
</div>
)}
<p className="text-muted-foreground text-sm">
Waiting on your organization admin to configure {providerName}.
</p>
</div>
) : showDirectInputForm && connectionType === 'api_key' && requiredFields.length > 0 && !isOrgScopedApiKey ? (
<div className="space-y-4">
{apiKeyInstructions && (
<div className="bg-muted/30 rounded-lg border p-4">
<div className="text-muted-foreground whitespace-pre-line text-xs">
{apiKeyInstructions}
</div>
</div>
)}
{requiredFields.map(renderFormField)}
{offersMcpOAuthAlternative && (
<div className="border-t pt-3">
<p className="text-muted-foreground mb-2 text-xs">
Would rather not hand over a key? Authorize Cedar with {providerName} instead and
we will hold a token we can refresh, not a long-lived secret.
</p>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => void startOAuthFlow()}
disabled={isConnecting}
>
Authorize with {providerName}
</Button>
</div>
)}
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={() => {
setShowDirectInputForm(false);
setFormFields({});
}}
disabled={isConnecting}
>
Cancel
</Button>
<Button
size="lg"
className="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>
</>
) : (
'Connect'
)}
</Button>
</div>
</div>
) : connectionType === 'manual' ? null : isIntegrationsLoading ? (
// Don't show the connect button until we know the connection type
<Button size="lg" className="flex w-full items-center gap-3" disabled>
<Loader2 className="h-5 w-5 animate-spin" />
<span>Loading...</span>
</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>
)}
{/* Meeting Manager - in user-facing mode, only show webhook section */}
{showManager && (
<MeetingManager
providerId={providerId}
userId={userId}
showMeetingsList={!userFacing}
/>
)}
</div>
);
}