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