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