add-system-dialog.tsx8.0 KBView on GitHub
'use client';

import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from '@/components/ui/dialog';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { INSTRUCTIONS_HELP, type SystemEntry } from './types';
import { KeyRound, Loader2, Server } from 'lucide-react';
import { useTRPC } from '@/providers/query-provider';
import { Textarea } from '@/components/ui/textarea';
import { CredentialForm } from './credential-form';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { AddMcpStep } from './add-mcp-step';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';

type Step = 'choose' | 'mcp' | 'credential';

/**
 * Add or edit an entry in "Systems and credentials".
 *
 * Step 1 asks which of the two things the user has, because everything after that
 * differs: an MCP server is interrogated and then connected, while a credential bag
 * is typed in. Both paths end on the same required question, "when should the agent
 * use this?", which is the field that makes an entry findable at all.
 */
export function AddSystemDialog({
  open,
  onOpenChange,
  editing,
  isOrgAdmin,
  defaultScope = 'user',
  initialBackgroundAgents,
  onOAuthOwnershipChange,
  userId,
}: {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  editing?: SystemEntry;
  isOrgAdmin: boolean;
  defaultScope?: 'user' | 'org';
  /** Pre-flipped background-agent toggle, when the user opened this from the row switch. */
  initialBackgroundAgents?: boolean;
  /**
   * Forwarded to `AddMcpStep`, which is the only thing in this dialog that listens for
   * the OAuth callback message. Passed through rather than raised here because the
   * dialog is open for several steps and only that one claims the message.
   */
  onOAuthOwnershipChange?: (owned: boolean) => void;
  userId?: string;
}) {
  const [step, setStep] = useState<Step>('choose');

  useEffect(() => {
    if (!open) return;
    setStep(editing ? editing.kind : 'choose');
  }, [open, editing]);

  const close = () => onOpenChange(false);

  const title = editing
    ? editing.kind === 'mcp'
      ? `Edit ${editing.name}`
      : `Edit ${editing.name} credentials`
    : step === 'choose'
      ? 'Add a system'
      : step === 'mcp'
        ? 'Connect an MCP server'
        : 'Store credentials for a system';

  const description = editing
    ? 'Everything here is visible to the agent. Stored secret values are never shown back.'
    : step === 'choose'
      ? 'Two kinds of thing live here: servers the agent talks to, and credentials it uses.'
      : step === 'mcp'
        ? 'Pick a system below or paste any server URL, and Cedar will work out what connecting to it takes.'
        : 'For a system Cedar has no built-in support for. Values are encrypted and never shown back.';

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent size="form" className="max-h-[85vh] overflow-y-auto">
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          <DialogDescription>{description}</DialogDescription>
        </DialogHeader>

        {step === 'choose' && (
          <div className="space-y-3">
            <button
              type="button"
              onClick={() => setStep('mcp')}
              className="hover:border-primary/40 flex w-full cursor-pointer items-start gap-3 rounded-lg border p-4 text-left transition-colors"
            >
              <div className="bg-muted flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg">
                <Server className="text-muted-foreground h-4 w-4" />
              </div>
              <div className="min-w-0">
                <p className="text-sm font-medium">Connect an MCP server</p>
                <p className="text-muted-foreground text-xs">
                  A server that exposes tools to the agent. Paste its URL and Cedar handles the
                  rest.
                </p>
              </div>
            </button>

            <button
              type="button"
              onClick={() => setStep('credential')}
              className="hover:border-primary/40 flex w-full cursor-pointer items-start gap-3 rounded-lg border p-4 text-left transition-colors"
            >
              <div className="bg-muted flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg">
                <KeyRound className="text-muted-foreground h-4 w-4" />
              </div>
              <div className="min-w-0">
                <p className="text-sm font-medium">Store credentials for a system</p>
                <p className="text-muted-foreground text-xs">
                  An API key, a token, or an OAuth client pair for a system Cedar has no built-in
                  support for.
                </p>
              </div>
            </button>
          </div>
        )}

        {step === 'mcp' &&
          (editing ? (
            <McpSettingsForm entry={editing} userId={userId} onDone={close} />
          ) : (
            <AddMcpStep
              userId={userId}
              isOrgAdmin={isOrgAdmin}
              defaultScope={defaultScope}
              {...(onOAuthOwnershipChange ? { onOAuthOwnershipChange } : {})}
              onDone={close}
            />
          ))}

        {step === 'credential' && (
          <CredentialForm
            {...(editing ? { editing } : {})}
            {...(initialBackgroundAgents !== undefined ? { initialBackgroundAgents } : {})}
            isOrgAdmin={isOrgAdmin}
            defaultScope={defaultScope}
            onDone={close}
          />
        )}
      </DialogContent>
    </Dialog>
  );
}

/**
 * Editing an MCP connection edits its description and nothing else.
 *
 * Changing the URL or rotating a token goes through the write path that REBUILDS
 * the connection's metadata, which would throw away the refresh token and client
 * registration of an OAuth connection. Until that path merges instead of replacing,
 * removing and re-adding is the honest way to change those, so this form does not
 * offer a button that can quietly cost someone their connection.
 */
function McpSettingsForm({
  entry,
  userId,
  onDone,
}: {
  entry: SystemEntry;
  userId?: string;
  onDone: () => void;
}) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const [instructions, setInstructions] = useState(entry.instructions);

  const { mutateAsync: updateSettings, isPending } = useMutation(
    trpc.integrations.updateMcpConnectionSettings.mutationOptions(),
  );

  const handleSave = async () => {
    if (!instructions.trim()) {
      toast.error('Tell the agent when to use this before saving');
      return;
    }
    try {
      await updateSettings({ connectionId: entry.id, instructions: instructions.trim() });
      void queryClient.invalidateQueries({
        queryKey=[redacted] ? { userId } : undefined),
      });
      onDone();
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Could not save that description');
    }
  };

  return (
    <div className="space-y-4">
      {entry.serverUrl && (
        <div className="space-y-1">
          <Label>Server</Label>
          <p className="text-muted-foreground truncate text-xs">{entry.serverUrl}</p>
        </div>
      )}

      <div className="space-y-2">
        <Label htmlFor="mcp-edit-instructions">When should the agent use this?</Label>
        <p className="text-muted-foreground text-xs">{INSTRUCTIONS_HELP}</p>
        <Textarea
          id="mcp-edit-instructions"
          rows={3}
          value={instructions}
          onChange={(e) => setInstructions(e.target.value)}
        />
      </div>

      <p className="text-muted-foreground text-xs">
        To change the server URL or replace its token, remove this connection and add it again.
      </p>

      <Button size="sm" onClick={handleSave} disabled={isPending}>
        {isPending && <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />}
        Save changes
      </Button>
    </div>
  );
}