import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { ChevronRightIcon } from "lucide-react"; import type { ConnectorToolPermission, ConnectorToolPermissionEntry, ConnectorToolPermissionsResponse, UpdateConnectorToolPermissionsRequest, } from "@opengeni/contracts"; import { useAppContext } from "../../app-context"; import { isPermissionDenied, userErrorText } from "../../../ui/api-error"; import { Button } from "../../../ui/button"; import { Notice } from "../../../ui/notice"; import { SelectMenu } from "../../../ui/select-menu"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../ui/collapsible"; import type { ConnectionHealth } from "../../lib/capabilities"; import type { CapabilityCatalogItem } from "../../types"; const groups = [ ["read", "Read-only tools"], ["write", "Tools that make changes"], ["other", "Other tools"], ] as const; /** A workspace-enabled connector need not have an account for this viewer. */ export function hasConnectorToolPermissionTarget( item: CapabilityCatalogItem, health: ConnectionHealth, ): boolean { if ( !item.enabled || item.kind !== "mcp" || item.source === "built_in" || item.surfaceType === "codex_apps" || item.connectionRef?.authoritySource === "host" ) return false; if (item.connectionRef || item.authKind === "oauth2" || item.authKind === "api_key") { return ( (health.state === "connected" || health.state === "attention") && health.connection !== null && health.connection.status === "active" ); } return true; } const sentenceCase = (text: string) => text.charAt(0).toUpperCase() + text.slice(1); /** Why this tool has its current choice: the setting that actually decides it. */ export function permissionSourceText(tool: ConnectorToolPermissionEntry): string { if (tool.resetReason) return "Action changed · reset to Ask first"; if (tool.source === "conflict") return "Two settings conflict, so it's blocked"; if (tool.conditional) return "Different choices for individual actions"; if (tool.source === "recommended") return "Recommended"; if (tool.source === "connector_default" || tool.inherited) return "Uses default choice"; return "Your choice"; } export function PermissionSelect({ label, value, disabled, onChange, resetLabel = "Use default", }: { label: string; value: ConnectorToolPermission | "mixed" | "default"; disabled?: boolean | undefined; onChange: (value: ConnectorToolPermission | null) => void; resetLabel?: string | undefined; }) { return ( { if (next !== "mixed") onChange(next === "default" ? null : next); }} options={[ ...(value === "mixed" ? [{ value: "mixed" as const, label: "Mixed", disabled: true }] : []), { value: "allow", label: "Allow", description: "Run without asking." }, { value: "ask", label: "Ask first", description: "Review each action before it runs." }, { value: "block", label: "Block", description: "Do not run this action." }, { value: "default", label: resetLabel }, ]} /> ); } export function ConnectorToolPermissions({ workspaceId, capabilityId, bare = false, connectionId, }: { workspaceId: string; capabilityId: string; /** Lock this view to one explicitly selected account. */ connectionId?: string | undefined; /** Inside a page section that already names it: drop the frame and heading. */ bare?: boolean | undefined; }) { const { client } = useAppContext(); const [data, setData] = useState(null); // `denied`: the viewer may not see these permissions. Say who can help, no Retry. const [error, setError] = useState<{ text: string; denied: boolean } | null>(null); const [busy, setBusy] = useState(false); const [generation, setGeneration] = useState(0); const [saved, setSaved] = useState(false); const [expandedGroups, setExpandedGroups] = useState([]); const basePath = `${workspaceId}:${capabilityId}`; const [account, setAccount] = useState<{ path: string; connectionId: string; instanceKey?: string | undefined; } | null>(null); const selectedAccount = useMemo( () => connectionId ? { path: basePath, connectionId } : account?.path === basePath ? account : null, [connectionId, basePath, account], ); const path = `${basePath}:${selectedAccount?.connectionId ?? ""}:${selectedAccount?.instanceKey ?? ""}`; const scope = useRef({ path, epoch: 0 }); const mutationController = useRef(null); useLayoutEffect(() => { scope.current = { path, epoch: scope.current.epoch + 1 }; setBusy(false); setSaved(false); setData(null); setError(null); return () => { scope.current.epoch += 1; mutationController.current?.abort(); mutationController.current = null; }; }, [path, client]); useEffect(() => { const controller = new AbortController(); setData(null); setError(null); void client .getConnectorToolPermissions(workspaceId, capabilityId, { signal: controller.signal, ...(selectedAccount ? { connectionId: selectedAccount.connectionId, instanceKey: selectedAccount.instanceKey } : {}), }) .then((result) => { if (!controller.signal.aborted) setData(result); }) .catch((failure) => { if (controller.signal.aborted) return; const connectionRequired = failure && typeof failure === "object" && "details" in failure && (failure.details as { code?: string | undefined } | undefined)?.code === "connector_connection_required"; setError( connectionRequired ? { text: "Connect or reconnect an account above to manage its approvals.", denied: true, } : isPermissionDenied(failure) ? { text: "You can't see this connector's tool permissions. Ask a workspace admin for access.", denied: true, } : { text: `Couldn't load tool permissions. ${userErrorText(failure)}`, denied: false, }, ); }); return () => controller.abort(); }, [path, generation, client, workspaceId, capabilityId, selectedAccount]); async function update( selection: | { target: "default" } | { target: "tools"; toolNames: string[] } | { target: "action"; toolName: string; actionName: string }, permission: ConnectorToolPermission | null, ) { if (!data || busy || mutationController.current) return; const currentScope = { ...scope.current }; if (currentScope.path !== path) return; const isCurrent = () => scope.current.path === currentScope.path && scope.current.epoch === currentScope.epoch; const controller = new AbortController(); mutationController.current = controller; setBusy(true); setError(null); setSaved(false); try { await client.updateConnectorToolPermissions( workspaceId, capabilityId, { connectionId: data.connectionId, ...(data.revision ? { expectedRevision: data.revision } : {}), ...(data.instanceKey ? { instanceKey: data.instanceKey } : {}), ...selection, permission, } satisfies UpdateConnectorToolPermissionsRequest, { signal: controller.signal }, ); if (!isCurrent()) return; const refreshed = await client.getConnectorToolPermissions(workspaceId, capabilityId, { signal: controller.signal, connectionId: data.connectionId, ...(data.instanceKey ? { instanceKey: data.instanceKey } : {}), }); if (!isCurrent()) return; setData(refreshed); setSaved(true); } catch (failure) { // Aborting only discards this view's response. A write may have committed; // the next mount always reloads the authoritative saved permissions. if (isCurrent() && !controller.signal.aborted) setError({ text: `Couldn't save tool permissions. ${userErrorText(failure)}`, denied: false, }); } finally { if (isCurrent()) { setBusy(false); mutationController.current = null; } } } return (
{bare ? null : (

Tool permissions

Choose which actions run automatically and which need your review.

)} {error ? {error.text} : null} {!data && !error ? (

Loading tools…

) : null} {data ? ( <> {!connectionId && (data.accounts?.length ?? 0) > 1 ? (

Account

({ value: JSON.stringify([item.connectionId, item.instanceKey ?? ""]), label: item.label, }))} onValueChange={(value) => { const chosen = data.accounts!.find( (item) => JSON.stringify([item.connectionId, item.instanceKey ?? ""]) === value, ); if (chosen) setAccount({ path: basePath, connectionId: chosen.connectionId, ...(chosen.instanceKey ? { instanceKey: chosen.instanceKey } : {}), }); }} />
) : data.accountLabel ? (

{data.accountLabel}

) : null}
Default choice void update({ target: "default" }, value)} />

Applies to tools without an individual choice, including newly added tools.

{data.tools.some((tool) => tool.resetReason) ? ( Some actions changed since you allowed them. Those actions now ask first; review their choices below. ) : null} {data.discoveryError ? {data.discoveryError} : null} {groups.map(([key, title]) => { const tools = data.tools.filter((tool) => tool.group === key); if (!tools.length) return null; const first = tools[0]!.permission; const groupValue = tools.every((tool) => tool.permission === first && !tool.conditional) ? first : "mixed"; return ( setExpandedGroups((current) => open ? [...current, key] : current.filter((value) => value !== key), ) } >
void update( { target: "tools", toolNames: tools.map((tool) => tool.name) }, value, ) } />
{tools.map((tool) => (
{tool.title ?? sentenceCase(tool.name.replaceAll("_", " "))}

{permissionSourceText(tool)}

void update({ target: "tools", toolNames: [tool.name] }, value) } />
{tool.actionPermissions?.map((action) => (
{sentenceCase(action.actionName.replaceAll("_", " "))} void update( { target: "action", toolName: tool.name, actionName: action.actionName, }, permission, ) } />
))}
))}
); })} {!data.discoveryError && data.tools.length === 0 ? (

This connector currently exposes no tools.

) : null}

Saved choices apply when work next starts or resumes. A review already waiting for you keeps its original choice.

{!data.canManage ? (

You can view these choices. A workspace admin can change them.

) : null} {busy ? (

Saving choices…

) : null} {saved ? (

Permissions saved.

) : null} ) : null} {(error && !error.denied) || data?.discoveryError ? ( ) : null}
); }