meeting-integration-card.tsx22.6 KBView on GitHub

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>
  );
}