slack-integration-card.tsx18.3 KBView on GitHub
'use client';

import {
  Dialog,
  DialogClose,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from '@/components/ui/dialog';
import { Field, FIELD_CONTROL, FieldRows, FormActions } from '@/components/ui/field';
import { Loader2, RefreshCw, Unplug, Slack, Plus, Trash2 } from 'lucide-react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { AnimatePresence, motion } from 'motion/react';
import { useTRPC } from '@/providers/query-provider';
import { useState, useEffect, useMemo } from 'react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { toast } from 'sonner';

interface SlackIntegrationCardProps {
  userId?: string;
  /**
   * The caller already draws a surface (the connections dialog, the playground's
   * provider card), so the row list must not sit in a second box. Onboarding renders
   * this straight onto the page background and takes the card.
   */
  bare?: boolean;
}

type PatternPair = { startsWith: string; endsWith: string };

const emptyPair = (): PatternPair => ({ startsWith: '', endsWith: '' });

/**
 * What `sessionStorage` was handed back after the OAuth tab closed.
 *
 * `JSON.parse` returns `any`, and the value came from a previous page load rather than
 * from the server, so it is narrowed rather than asserted — a stale or hand-edited entry
 * must fail here, not inside the connect mutation.
 */
function readPendingOAuth(raw: string): { strataServerUrl?: string } {
  const parsed: unknown = JSON.parse(raw);
  if (parsed && typeof parsed === 'object' && 'strataServerUrl' in parsed) {
    const url = parsed.strataServerUrl;
    if (typeof url === 'string') return { strataServerUrl: url };
  }
  return {};
}

export function SlackIntegrationCard({ userId, bare }: SlackIntegrationCardProps) {
  const [isConnecting, setIsConnecting] = useState(false);
  const [isReinstalling, setIsReinstalling] = useState(false);
  const [isDisconnecting, setIsDisconnecting] = useState(false);
  const trpc = useTRPC();
  const queryClient = useQueryClient();

  const { data: integrationsData, refetch: refetchIntegrations } = useQuery(
    trpc.integrations.list.queryOptions(userId ? { userId } : undefined),
  );

  const integration = integrationsData?.integrations.find((i) => i.id === 'slack');
  const isConnected = integration?.connected || false;
  const connectionId = integration?.connectionId;

  const { data: connectionsData, refetch: refetchConnections } = useQuery(
    trpc.connections.list.queryOptions(userId ? { userId } : undefined),
  );

  const slackConnection = connectionsData?.slackConnections?.[0];
  const periodicSlackSyncEnabled = slackConnection?.periodicSlackSyncEnabled ?? false;
  const workspaceId = slackConnection?.externalId ?? '';

  const { mutateAsync: updateSlackSettings, isPending: isUpdatingSlackSettings } = useMutation(
    trpc.connections.updateSlackSettings.mutationOptions(),
  );

  const { mutateAsync: updateConfig, isPending: isSavingPatterns } = useMutation(
    trpc.integrations.slack.updateConfiguration.mutationOptions(),
  );

  const { data: configData } = useQuery({
    ...trpc.integrations.slack.getConfiguration.queryOptions({
      workspaceId: workspaceId || '',
      ...(userId && { userId }),
    }),
    enabled: !!workspaceId,
  });

  const [patterns, setPatterns] = useState<PatternPair[]>([emptyPair()]);
  const [autoSyncNewChannels, setAutoSyncNewChannels] = useState(false);

  // The route parses the stored blob through `SlackConfigSchema` — including the
  // bare-object-to-array coercion — so what arrives here is already the array shape.
  // Only the "no patterns yet" case needs filling in, with the one blank row the
  // editor starts from.
  const savedPatterns = useMemo((): PatternPair[] => {
    const saved = configData?.config.autoSelectPatterns ?? [];
    if (saved.length === 0) return [emptyPair()];
    return saved.map((p) => ({ startsWith: p.startsWith ?? '', endsWith: p.endsWith ?? '' }));
  }, [configData]);

  const savedAutoSync = configData?.config.autoSyncNewChannels ?? false;

  useEffect(() => {
    setPatterns(savedPatterns);
    setAutoSyncNewChannels(savedAutoSync);
  }, [savedPatterns, savedAutoSync]);

  const hasPatternsChanged = useMemo(() => {
    if (autoSyncNewChannels !== savedAutoSync) return true;
    if (patterns.length !== savedPatterns.length) return true;
    return patterns.some(
      (p, i) =>
        p.startsWith !== (savedPatterns[i]?.startsWith ?? '') ||
        p.endsWith !== (savedPatterns[i]?.endsWith ?? ''),
    );
  }, [patterns, autoSyncNewChannels, savedPatterns, savedAutoSync]);

  const hasAnyPattern = patterns.some((p) => p.startsWith || p.endsWith);

  const updatePattern = (index: number, field: keyof PatternPair, value: string) =>
    setPatterns((prev) => prev.map((p, i) => (i === index ? { ...p, [field]: value } : p)));

  const addPattern = () => setPatterns((prev) => [...prev, emptyPair()]);

  const removePattern = (index: number) =>
    setPatterns((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : [emptyPair()]));

  const discardPatterns = () => {
    setPatterns(savedPatterns);
    setAutoSyncNewChannels(savedAutoSync);
  };

  const handleSavePatterns = async () => {
    if (!workspaceId || !configData) {
      toast.error('Workspace ID not found');
      return;
    }
    try {
      const existingConfig = configData.config;
      const nonEmptyPatterns = patterns
        .map((p) => ({
          ...(p.startsWith ? { startsWith: p.startsWith } : {}),
          ...(p.endsWith ? { endsWith: p.endsWith } : {}),
        }))
        .filter((p) => p.startsWith !== undefined || p.endsWith !== undefined);
      await updateConfig({
        workspaceId,
        config: {
          selectedChannelIds: existingConfig.selectedChannelIds,
          blockedChannelIds: existingConfig.blockedChannelIds,
          autoSelectPatterns: nonEmptyPatterns,
          autoSyncNewChannels: nonEmptyPatterns.length === 0 ? false : autoSyncNewChannels,
          // Carried through rather than omitted: the schema defaults it to `true`, so
          // leaving it out would silently re-admit external DMs for anyone who turned
          // them off, every time they edited a channel pattern.
          autoSyncExternalDms: existingConfig.autoSyncExternalDms,
        },
        ...(userId && { userId }),
      });
      await queryClient.invalidateQueries({
        queryKey=[redacted]
          workspaceId,
          ...(userId && { userId }),
        }),
      });
    } catch (error) {
      toast.error(
        `Failed to save patterns: ${error instanceof Error ? error.message : String(error)}`,
      );
    }
  };

  const handlePeriodicSyncChange = async (checked: boolean) => {
    if (!connectionId) {
      toast.error('Connection ID not found');
      return;
    }
    try {
      await updateSlackSettings({
        connectionId,
        settings: { periodicSlackSyncEnabled: checked },
        userId,
      });
      queryClient.invalidateQueries({ queryKey=[redacted] });
    } catch (error) {
      toast.error(
        `Failed to update settings: ${error instanceof Error ? error.message : String(error)}`,
      );
    }
  };

  const { mutateAsync: initiateOAuth } = useMutation(
    trpc.integrations.initiateOAuth.mutationOptions(),
  );

  const { mutateAsync: connect } = useMutation(trpc.integrations.connect.mutationOptions());

  const { mutateAsync: deleteConnection } = useMutation(trpc.connections.delete.mutationOptions());

  const handleReinstall = async () => {
    try {
      setIsReinstalling(true);
      const { oauthUrl, strataServerUrl } = await initiateOAuth({
        integration: 'slack',
        userId,
      });

      if (!oauthUrl) {
        setIsReinstalling(false);
        toast.error('Failed to get OAuth URL');
        return;
      }

      sessionStorage.setItem('slack_oauth_reinstall_pending', JSON.stringify({ strataServerUrl }));
      window.open(oauthUrl, '_blank');
    } catch (error) {
      console.error('[SlackIntegrationCard] Error reinstalling Slack app:', error);
      setIsReinstalling(false);
      toast.error('Failed to reinstall Slack app');
    }
  };

  // Handle OAuth callback
  useEffect(() => {
    const handleWindowFocus = async () => {
      // Handle reinstall callback
      const reinstallPending = sessionStorage.getItem('slack_oauth_reinstall_pending');
      if (reinstallPending && isReinstalling) {
        try {
          const { strataServerUrl } = readPendingOAuth(reinstallPending);
          await connect({
            providerId: 'slack',
            connectionParams: { strataServerUrl },
            userId,
          });
          sessionStorage.removeItem('slack_oauth_reinstall_pending');
          setIsReinstalling(false);
          void refetchIntegrations();
          void refetchConnections();
          queryClient.invalidateQueries({ queryKey=[redacted] });
          queryClient.invalidateQueries({ queryKey=[redacted] });
        } catch (error) {
          const errorMessage = error instanceof Error ? error.message : String(error);
          const isExpectedError =
            errorMessage.includes('not authenticated') ||
            errorMessage.includes('Unauthorized') ||
            errorMessage.includes('Please complete OAuth flow');
          if (!isExpectedError) {
            sessionStorage.removeItem('slack_oauth_reinstall_pending');
            setIsReinstalling(false);
            toast.error(`Failed to reinstall Slack: ${errorMessage}`);
          }
        }
        return;
      }

      const storageKey=[redacted];
      const pendingAuth = sessionStorage.getItem(storageKey);

      if (pendingAuth && isConnecting) {
        try {
          const { strataServerUrl } = readPendingOAuth(pendingAuth);

          await connect({
            providerId: 'slack',
            connectionParams: { strataServerUrl },
            userId,
          });

          sessionStorage.removeItem(storageKey);
          setIsConnecting(false);
          void refetchIntegrations();
          void refetchConnections();
          queryClient.invalidateQueries({ queryKey=[redacted] });
          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('[SlackIntegrationCard] Error connecting Slack:', error);
            sessionStorage.removeItem(storageKey);
            setIsConnecting(false);
            toast.error(`Failed to connect Slack: ${errorMessage}`);
          }
        }
      }
    };

    window.addEventListener('focus', handleWindowFocus);
    return () => window.removeEventListener('focus', handleWindowFocus);
  }, [
    isConnecting,
    isReinstalling,
    connect,
    refetchIntegrations,
    refetchConnections,
    trpc,
    userId,
    queryClient,
  ]);

  const handleConnect = async () => {
    try {
      setIsConnecting(true);
      const { oauthUrl, strataServerUrl } = await initiateOAuth({
        integration: 'slack',
        userId,
      });

      if (!oauthUrl) {
        setIsConnecting(false);
        toast.error('Failed to get OAuth URL');
        return;
      }

      sessionStorage.setItem('slack_oauth_pending', JSON.stringify({ strataServerUrl }));

      window.open(oauthUrl, '_blank');
    } catch (error) {
      console.error('[SlackIntegrationCard] Error initiating auth for Slack:', error);
      setIsConnecting(false);
      toast.error('Failed to initiate connection for Slack');
    }
  };

  const handleDisconnect = async () => {
    try {
      setIsDisconnecting(true);
      if (connectionId) {
        await deleteConnection({
          connectionId,
          userId,
        });
        void refetchIntegrations();
        void refetchConnections();
        queryClient.invalidateQueries({ queryKey=[redacted] });
        queryClient.invalidateQueries({ queryKey=[redacted] });
      } else {
        toast.error('Could not find connection ID to disconnect.');
      }
    } catch (error) {
      console.error('[SlackIntegrationCard] Error disconnecting Slack:', error);
      toast.error('Failed to disconnect Slack');
    } finally {
      setIsDisconnecting(false);
    }
  };

  if (!isConnected) {
    return (
      <div className="flex flex-col items-start gap-3">
        <p className="text-muted-foreground text-sm">
          Cedar syncs the channels you choose and surfaces what was said in them on the deals they
          belong to.
        </p>
        <Button onClick={handleConnect} disabled={isConnecting}>
          {isConnecting ? <Loader2 className="animate-spin" /> : <Slack />}
          {isConnecting ? 'Connecting…' : 'Connect Slack'}
        </Button>
      </div>
    );
  }

  return (
    <FieldRows {...(bare ? { bare: true } : {})}>
      <Field
        label="Periodic message sync"
        hint="Keeps synced channels up to date in the background."
      >
        <Switch
          checked={periodicSlackSyncEnabled}
          disabled={isUpdatingSlackSettings}
          onCheckedChange={handlePeriodicSyncChange}
          aria-label="Periodic message sync"
        />
      </Field>

      <Field
        label="Channel patterns"
        stacked
        hint="Match a channel name by prefix, suffix, or both — “ext-”, “-cedar”."
      >
        <div className="flex flex-col items-start gap-2">
          {patterns.map((pair, index) => (
            <div key=[redacted] className="flex w-full items-center gap-2">
              <Input
                value={pair.startsWith}
                onChange={(e) => updatePattern(index, 'startsWith', e.target.value)}
                placeholder="Starts with"
                aria-label={`Pattern ${index + 1} starts with`}
                className={`${FIELD_CONTROL} flex-1`}
              />
              <Input
                value={pair.endsWith}
                onChange={(e) => updatePattern(index, 'endsWith', e.target.value)}
                placeholder="Ends with"
                aria-label={`Pattern ${index + 1} ends with`}
                className={`${FIELD_CONTROL} flex-1`}
              />
              <Button
                type="button"
                variant="ghost"
                size="icon"
                onClick={() => removePattern(index)}
                className="text-muted-foreground hover:text-destructive shrink-0"
                aria-label={`Remove pattern ${index + 1}`}
              >
                <Trash2 className="h-3.5 w-3.5" />
              </Button>
            </div>
          ))}
          <Button type="button" variant="ghost" size="sm" onClick={addPattern}>
            <Plus className="h-3.5 w-3.5" />
            Add pattern
          </Button>
        </div>
      </Field>

      <Field
        label="Auto-sync new channels"
        hint="Channels created later that match a pattern start syncing on their own."
      >
        <Switch
          checked={autoSyncNewChannels}
          onCheckedChange={setAutoSyncNewChannels}
          disabled={!hasAnyPattern}
          aria-label="Auto-sync new channels"
        />
      </Field>

      {/* The row appears and disappears as the pattern editor goes dirty, so it grows
          into place rather than shunting the two rows below it down in one frame. */}
      <AnimatePresence initial={false}>
        {hasPatternsChanged && (
          <motion.div
            initial={{ height: 0, opacity: 0 }}
            animate={{ height: 'auto', opacity: 1 }}
            exit={{ height: 0, opacity: 0 }}
            transition={{ duration: 0.18 }}
            className="overflow-hidden"
          >
            <FormActions className="px-4 pb-4 pt-3">
              <Button size="sm" onClick={handleSavePatterns} disabled={isSavingPatterns}>
                {isSavingPatterns && <Loader2 className="animate-spin" />}
                {isSavingPatterns ? 'Saving…' : 'Save changes'}
              </Button>
              <Button
                size="sm"
                variant="ghost"
                onClick={discardPatterns}
                disabled={isSavingPatterns}
              >
                Discard
              </Button>
            </FormActions>
          </motion.div>
        )}
      </AnimatePresence>

      <Field
        label="Reinstall Slack app"
        hint="Re-runs the install to grant new permissions or repair a broken connection."
      >
        <Button
          variant="outline"
          size="sm"
          onClick={handleReinstall}
          disabled={isReinstalling || isDisconnecting}
        >
          {isReinstalling ? <Loader2 className="animate-spin" /> : <RefreshCw />}
          {isReinstalling ? 'Reinstalling…' : 'Reinstall'}
        </Button>
      </Field>

      <Field label="Disconnect Slack" hint="Cedar stops syncing this workspace.">
        <Dialog>
          <DialogTrigger asChild>
            <Button variant="outline" size="sm" disabled={isDisconnecting || isReinstalling}>
              {isDisconnecting ? <Loader2 className="animate-spin" /> : <Unplug />}
              {isDisconnecting ? 'Disconnecting…' : 'Disconnect'}
            </Button>
          </DialogTrigger>
          <DialogContent>
            <DialogHeader>
              <DialogTitle>Disconnect Slack</DialogTitle>
              <DialogDescription>
                Cedar stops syncing this workspace. Messages already synced stay on the
                conversations they were attached to.
              </DialogDescription>
            </DialogHeader>
            <FormActions>
              <DialogClose asChild>
                <Button variant="destructive" onClick={handleDisconnect}>
                  Disconnect
                </Button>
              </DialogClose>
              <DialogClose asChild>
                <Button variant="ghost">Cancel</Button>
              </DialogClose>
            </FormActions>
          </DialogContent>
        </Dialog>
      </Field>
    </FieldRows>
  );
}