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