system-row.tsx8.8 KBView on GitHub 'use client';
import {
AlertTriangle,
ExternalLink,
KeyRound,
Pencil,
RefreshCw,
Server,
Trash2,
} from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { McpPermissionsSection } from '../mcp-permissions-section';
import type { SystemEntry } from './types';
import { cn } from '@/lib/utils';
/**
* One entry in "Systems and credentials", whichever kind it is.
*
* The type badge is the only thing that varies with `kind`. That is deliberate: a
* user reasoning about what the agent can reach should not have to learn that an MCP
* server and a stored API key are different objects in the database.
*/
export function SystemRow({
entry,
readOnly,
canReconnect,
onEdit,
onRemove,
onReconnect,
onToggleBackgroundAgents,
userId,
isBusy = false,
className,
}: {
entry: SystemEntry;
/**
* No actions at all. True for an org entry the caller may not write: the server
* refuses those anyway, and a button that always fails is worse than no button.
*/
readOnly: boolean;
/**
* Whether to offer Reconnect. Separate from `readOnly` because the two answer
* different questions on an org row: editing and removing belong to whoever
* authorized it, while repairing an expired org sign-in belongs to an admin. A
* member whose agent run tripped the expiry gets the name to chase instead of a
* button the server would refuse.
*/
canReconnect: boolean;
onEdit: (entry: SystemEntry) => void;
onRemove: (entry: SystemEntry) => void;
onReconnect: (entry: SystemEntry) => void;
onToggleBackgroundAgents: (entry: SystemEntry, enabled: boolean) => void;
/** Cedar-staff impersonation passthrough; the permissions read is scoped by it. */
userId?: string;
isBusy?: boolean;
className?: string;
}) {
const Icon = entry.kind === 'mcp' ? Server : KeyRound;
const toggleId = `bg-agents-${entry.kind}-${entry.id}`;
/**
* Who acts on a broken entry, when it is not the person reading this.
*
* An admin repairing a colleague's org MCP connection signs in as THEMSELVES, and the
* OAuth callback keys a connection on its owner, so the repair lands on a new row
* beside the dead one rather than replacing it. Saying so is the difference between an
* admin cleaning up and an organization quietly ending up with two connections of the
* same name, one of which does not work. A credential bag has no such problem: it is
* keyed by name and repaired by re-entering the secret, so it is replaced in place.
*/
const reconnectNote = (() => {
if (entry.scope !== 'org') return '';
if (!canReconnect) {
// Naming the authorizer is only useful when it is somebody else. A non-admin who
// authorized this row themselves would otherwise be told to go and ask themselves.
return entry.authorizedByEmail && !entry.isOwnedByCaller
? `Ask ${entry.authorizedByEmail}, or another organization admin, to reconnect it.`
: 'Ask an organization admin to reconnect it.';
}
if (entry.kind !== 'mcp' || entry.isOwnedByCaller) return '';
return entry.authorizedByEmail
? `Reconnecting signs in as you and adds a second entry, so ask ${entry.authorizedByEmail} to remove theirs once yours works.`
: 'Reconnecting signs in as you and adds a second entry, so remove the old one once yours works.';
})();
return (
<div className={cn('bg-card space-y-3 rounded-lg border p-4', className)}>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-start gap-3">
<div className="bg-muted mt-0.5 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg">
<Icon className="text-muted-foreground h-4 w-4" />
</div>
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm font-medium">{entry.name}</span>
<Badge variant="secondary" className="text-xs">
{entry.kind === 'mcp' ? 'MCP' : 'Credential'}
</Badge>
<Badge variant="outline" className="text-xs">
{entry.scope === 'org' ? 'From your organization' : 'Mine'}
</Badge>
{entry.needsAttention && (
<Badge variant="destructive" className="gap-1 text-xs">
<AlertTriangle className="h-2.5 w-2.5" />
Needs attention
</Badge>
)}
</div>
{entry.serverUrl && (
<a
href={entry.serverUrl}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground flex items-center gap-1 truncate text-xs hover:underline"
>
{entry.serverUrl}
<ExternalLink className="h-3 w-3 flex-shrink-0" />
</a>
)}
{/*
* An org connection runs on ONE person's sign-in, so naming them is what
* makes the row accountable: it says whose token the whole organization is
* borrowing, and who to ask about it.
*/}
{entry.scope === 'org' && entry.authorizedByEmail && (
<p className="text-muted-foreground text-xs">
Authorized by {entry.isOwnedByCaller ? 'you' : entry.authorizedByEmail}
</p>
)}
{entry.credentialFields && entry.credentialFields.length > 0 && (
<p className="text-muted-foreground text-xs">
Holds {entry.credentialFields.join(', ')}. Cedar never shows stored values back.
</p>
)}
{entry.instructions && (
<p className="text-muted-foreground text-xs">{entry.instructions}</p>
)}
</div>
</div>
<div className="flex flex-shrink-0 items-center gap-1">
{entry.needsAttention && canReconnect && (
<Button
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => onReconnect(entry)}
disabled={isBusy}
>
<RefreshCw className="h-3.5 w-3.5" />
Reconnect
</Button>
)}
{!readOnly && (
<>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-primary"
onClick={() => onEdit(entry)}
disabled={isBusy}
title="Edit"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-destructive"
onClick={() => onRemove(entry)}
disabled={isBusy}
title="Remove"
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
</div>
{entry.needsAttention && entry.needsAttentionReason && (
<p className="rounded-md border border-amber-500/30 bg-amber-500/[0.06] px-2.5 py-2 text-xs text-amber-700 dark:text-amber-500">
{entry.needsAttentionReason}
{reconnectNote ? ` ${reconnectNote}` : ''}
</p>
)}
<div className="flex items-start justify-between gap-3 border-t pt-3">
{/* Only phrasing content inside a label: a <p> here is invalid HTML. */}
<label htmlFor={toggleId} className="min-w-0 space-y-0.5">
<span className="block text-sm font-medium">Available to background agents</span>
<span className="text-muted-foreground block text-xs">
Off means chat only: Cedar uses this when you ask for it, and never in a scheduled or
event-triggered run nobody is watching.
</span>
</label>
<Switch
id={toggleId}
checked={entry.availableToBackgroundAgents}
disabled={readOnly || isBusy}
onCheckedChange={(checked) => onToggleBackgroundAgents(entry, checked)}
/>
</div>
{/* Which of this server's TOOLS may ever be called — the ceiling every agent's
own `mcp_servers:` grant is intersected against. Only an MCP entry has one; a
credential bag is a secret, not a tool surface. Collapsed by default because
opening it does a live `tools/list` against the server, which is not something
to do for every row on page load. */}
{entry.kind === 'mcp' && (
<McpPermissionsSection
connectionId={entry.id}
policy={entry.toolPolicy}
{...(userId ? { userId } : {})}
/>
)}
</div>
);
}