systems-and-credentials-section.tsx14.0 KBView on GitHub
'use client';

import {
  isTrustedOAuthMessageOrigin,
  toSystemEntryFromCredential,
  toSystemEntryFromMcp,
  type SystemEntry,
} from './types';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AddSystemDialog } from './add-system-dialog';
import { useTRPC } from '@/providers/query-provider';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { SystemRow } from './system-row';
import { Plus } from 'lucide-react';
import { toast } from 'sonner';

type ScopeTab = 'mine' | 'org';

/**
 * "Systems and credentials": everything the agent can reach that Cedar ships no
 * schema for. MCP servers and stored credential bags, in one list.
 *
 * Two things this fixes rather than decorates:
 *  - `needsReauth` is written into the database on a permanent refresh failure and
 *    was displayed nowhere, so a user's first news of a dead connection was an agent
 *    run failing. It is a visible state here, with the action that repairs it.
 *  - background-agent access was a stored flag with no way to see or set it.
 *
 * The scope split is a view, not a filter on capability: an org entry applies to a
 * user unless they have their own of the same name, so org rows appear in "Mine"
 * too, badged and read-only.
 */
export function SystemsAndCredentialsSection({ userId }: { userId?: string }) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();

  const [tab, setTab] = useState<ScopeTab>('mine');
  const [dialogOpen, setDialogOpen] = useState(false);
  const [editing, setEditing] = useState<SystemEntry | undefined>(undefined);
  const [pendingBackgroundAgents, setPendingBackgroundAgents] = useState<boolean | undefined>(
    undefined,
  );
  const [busyId, setBusyId] = useState<string | null>(null);

  const mcpListOptions = trpc.integrations.listMcpConnections.queryOptions(
    userId ? { userId } : undefined,
  );
  const credentialListOptions = trpc.credentialVault.list.queryOptions();

  const { data: mcpData, isLoading: isLoadingMcp } = useQuery(mcpListOptions);
  const { data: credentialData, isLoading: isLoadingCredentials } = useQuery(credentialListOptions);
  const { data: adminAccess } = useQuery(trpc.orgAdmin.checkAdminAccess.queryOptions());
  const isOrgAdmin = adminAccess?.isAdmin === true;

  const { mutateAsync: updateMcpSettings } = useMutation(
    trpc.integrations.updateMcpConnectionSettings.mutationOptions(),
  );
  const { mutateAsync: deleteMcpConnection } = useMutation(
    trpc.integrations.deleteMcpConnection.mutationOptions(),
  );
  const { mutateAsync: deleteCredential } = useMutation(
    trpc.credentialVault.delete.mutationOptions(),
  );
  const { mutateAsync: deleteOrgCredential } = useMutation(
    trpc.credentialVault.deleteOrg.mutationOptions(),
  );
  const { mutateAsync: probeServer } = useMutation(
    trpc.integrations.probeMcpServer.mutationOptions(),
  );
  const { mutateAsync: initiateOAuth } = useMutation(
    trpc.integrations.initiateOAuth.mutationOptions(),
  );

  const invalidate = () => {
    void queryClient.invalidateQueries({ queryKey=[redacted] });
    void queryClient.invalidateQueries({ queryKey=[redacted] });
  };

  /**
   * Everything the message listener below needs, read through refs.
   *
   * The listener is registered once for the life of the component, so anything it
   * closes over directly is frozen at first render. `invalidate` is built from
   * `mcpListOptions`, which is built from the `userId` PROP, so a userId change without
   * a remount would have made a reconnect invalidate the previous user's query key.
   */
  const invalidateRef = useRef(invalidate);
  useEffect(() => {
    invalidateRef.current = invalidate;
  });

  /**
   * Whether the add dialog has a sign-in of its own in flight, and therefore owns the
   * next OAuth callback message.
   *
   * Its MCP step reports this itself, when it opens a popup and when that flow ends.
   * Inferring it from `dialogOpen` stood this listener down for the credential step and
   * for an edit as well, neither of which starts a sign-in, so a Reconnect running
   * behind an open dialog had its completion swallowed: nothing refetched, and the row
   * still read "Needs attention".
   */
  const addStepOwnsOAuthRef = useRef(false);
  const handleOAuthOwnershipChange = useCallback((owned: boolean) => {
    addStepOwnsOAuthRef.current = owned;
  }, []);

  /**
   * A Reconnect opens the provider's consent screen in a popup, which reports back
   * by postMessage from the OAuth callback page. Without this the repaired row would
   * keep showing "Needs attention" until the user reloaded the page, which reads as
   * the reconnect having failed.
   */
  useEffect(() => {
    const handleMessage = (event: MessageEvent) => {
      // `data.error` is rendered straight into a toast, so an unchecked listener puts
      // text from any page the user has open on Cedar's screen.
      if (!isTrustedOAuthMessageOrigin(event.origin)) return;
      const data = event.data as { type?: string; success?: boolean; error?: string } | undefined;
      if (data?.type !== 'mcp_oauth_connected') return;
      // The add dialog's MCP step registers a listener of its own, and it owns the
      // sign-in it started: it applies the typed instructions to the new row and closes
      // itself. One postMessage reaching both means a doubled refetch and, on failure,
      // the same error toasted twice, so this one stands down while that flow is live.
      if (addStepOwnsOAuthRef.current) return;
      if (data.success) invalidateRef.current();
      else toast.error(`Could not reconnect: ${data.error ?? 'Unknown error'}`);
    };
    window.addEventListener('message', handleMessage);
    return () => window.removeEventListener('message', handleMessage);
  }, []);

  const entries = useMemo(() => {
    const rows: SystemEntry[] = [
      ...(mcpData?.connections ?? []).map(toSystemEntryFromMcp),
      ...(credentialData ?? []).map(toSystemEntryFromCredential),
    ];
    // Anything broken first: this list is also the place a user finds out something
    // stopped working.
    return rows.sort((a, b) => {
      if (a.needsAttention !== b.needsAttention) return a.needsAttention ? -1 : 1;
      return a.name.localeCompare(b.name);
    });
  }, [mcpData, credentialData]);

  const visible = tab === 'org' ? entries.filter((e) => e.scope === 'org') : entries;

  /**
   * An org entry is writable only by whoever authorized it. For an MCP row the API
   * says so outright; for a vault entry it does not, and the server's own rule is
   * "an admin, and only entries this admin created", so admin is the closest the UI
   * can get without promising more than the server will do.
   */
  const isReadOnly = (entry: SystemEntry) => {
    if (entry.scope !== 'org') return false;
    return entry.kind === 'mcp' ? !entry.isOwnedByCaller : !isOrgAdmin;
  };

  /**
   * Repairing an expired ORG sign-in is an admin's job, not the job of whichever member
   * happened to be the one whose agent run tripped the expiry. That is a different
   * question from `isReadOnly`, which asks who may edit or delete the entry, so the two
   * are computed separately: an admin who did not authorize an org row still cannot
   * rename or remove it, but is exactly who should be able to reconnect it.
   */
  const canReconnect = (entry: SystemEntry) =>
    entry.scope === 'org' ? isOrgAdmin : !isReadOnly(entry);

  const openDialog = (entry?: SystemEntry, backgroundAgents?: boolean) => {
    setEditing(entry);
    setPendingBackgroundAgents(backgroundAgents);
    setDialogOpen(true);
  };

  const handleToggleBackgroundAgents = async (entry: SystemEntry, enabled: boolean) => {
    if (entry.kind === 'credential') {
      // The vault replaces a secret bag rather than patching it, and reads never
      // return the values, so this flag cannot move without the user re-entering
      // them. Sending them into the form with the switch already flipped is the
      // shortest honest path.
      openDialog(entry, enabled);
      return;
    }
    setBusyId(entry.id);
    try {
      await updateMcpSettings({ connectionId: entry.id, availableToBackgroundAgents: enabled });
      invalidate();
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Could not change background access');
    } finally {
      setBusyId(null);
    }
  };

  const handleRemove = async (entry: SystemEntry) => {
    setBusyId(entry.id);
    try {
      if (entry.kind === 'mcp') {
        await deleteMcpConnection({ connectionId: entry.id, ...(userId ? { userId } : {}) });
      } else if (entry.scope === 'org') {
        await deleteOrgCredential({ name: entry.name });
      } else {
        await deleteCredential({ name: entry.name });
      }
      invalidate();
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Could not remove that entry');
    } finally {
      setBusyId(null);
    }
  };

  const handleReconnect = async (entry: SystemEntry) => {
    if (entry.kind === 'credential' || !entry.serverUrl) {
      // A dead vault credential is repaired by supplying the secret again.
      openDialog(entry);
      return;
    }
    setBusyId(entry.id);
    try {
      // The row stores a server URL, not a provider id, so the provider is resolved
      // the same way the add flow resolves it.
      const probe = await probeServer({ serverUrl: entry.serverUrl });
      if (!probe.providerId) {
        toast.error(
          'Cedar cannot restart sign-in for this server. Remove it and add it again with a fresh token.',
        );
        return;
      }
      const { oauthUrl } = await initiateOAuth({
        integration: probe.providerId,
        // A repaired org connection has to stay org-scoped, otherwise the fix quietly
        // demotes a shared server to the reconnecting admin's own. The server re-runs
        // the admin guard on this, so it is a request, not a grant.
        scope: entry.scope,
        ...(userId ? { userId } : {}),
      });
      if (!oauthUrl) {
        toast.error('That server did not return a sign-in URL');
        return;
      }
      // A blocked popup returns null and nothing else happens, so without this the
      // Reconnect button simply does nothing and the row stays broken with no reason
      // given.
      if (!window.open(oauthUrl, '_blank', 'width=600,height=700')) {
        toast.error('Your browser blocked the sign-in window. Allow popups for Cedar and retry.');
      }
    } catch (err) {
      toast.error(err instanceof Error ? err.message : 'Could not start sign-in');
    } finally {
      setBusyId(null);
    }
  };

  const isLoading = isLoadingMcp || isLoadingCredentials;
  const canAdd = tab === 'mine' || isOrgAdmin;

  return (
    <div className="space-y-4">
      <div className="space-y-1">
        <h3 className="text-sm font-medium">Systems and credentials</h3>
        <p className="text-muted-foreground text-xs">
          Servers and secrets for systems Cedar has no built-in support for. Everything listed here
          is something the agent can reach.
        </p>
      </div>

      <Tabs value={tab} onValueChange={(value) => setTab(value === 'org' ? 'org' : 'mine')}>
        <div className="flex items-center justify-between gap-2">
          <TabsList>
            <TabsTrigger value="mine">Mine</TabsTrigger>
            <TabsTrigger value="org">Organization</TabsTrigger>
          </TabsList>
          {canAdd && (
            <Button
              variant="outline"
              size="sm"
              className="gap-1.5"
              onClick={() => openDialog(undefined, undefined)}
            >
              <Plus className="h-3.5 w-3.5" />
              Add
            </Button>
          )}
        </div>

        <TabsContent value={tab} className="space-y-3 pt-4">
          {tab === 'org' && !isOrgAdmin && (
            <p className="text-muted-foreground rounded-md border border-dashed px-3 py-2 text-xs">
              Your organization&apos;s entries are shown here so you know what you already have.
              Only an organization admin can change them.
            </p>
          )}

          {isLoading ? (
            <div className="space-y-2">
              <Skeleton className="h-24 w-full rounded-lg" />
              <Skeleton className="h-24 w-full rounded-lg" />
            </div>
          ) : visible.length === 0 ? (
            <div className="text-muted-foreground rounded-lg border border-dashed p-6 text-center text-sm">
              {tab === 'org'
                ? 'Your organization has not shared any systems yet.'
                : 'Nothing here yet. Add an MCP server or store credentials for a system.'}
            </div>
          ) : (
            visible.map((entry) => (
              <SystemRow
                key=[redacted]
                entry={entry}
                readOnly={isReadOnly(entry)}
                canReconnect={canReconnect(entry)}
                isBusy={busyId === entry.id}
                onEdit={(target) => openDialog(target)}
                onRemove={handleRemove}
                onReconnect={handleReconnect}
                onToggleBackgroundAgents={handleToggleBackgroundAgents}
                {...(userId ? { userId } : {})}
              />
            ))
          )}
        </TabsContent>
      </Tabs>

      <AddSystemDialog
        open={dialogOpen}
        onOpenChange={(open) => {
          setDialogOpen(open);
          if (!open) {
            setEditing(undefined);
            setPendingBackgroundAgents(undefined);
          }
        }}
        {...(editing ? { editing } : {})}
        {...(pendingBackgroundAgents !== undefined
          ? { initialBackgroundAgents: pendingBackgroundAgents }
          : {})}
        isOrgAdmin={isOrgAdmin}
        defaultScope={tab === 'org' ? 'org' : 'user'}
        onOAuthOwnershipChange={handleOAuthOwnershipChange}
        {...(userId ? { userId } : {})}
      />
    </div>
  );
}