From 2600b946eac6d16753220a1d1a35a121bf86df7c Mon Sep 17 00:00:00 2001 From: Sreeram Sreedhar Date: Fri, 31 Jul 2026 16:16:22 +0530 Subject: [PATCH] feat(web): per-tool approval settings for connected apps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /configure/tools/, where a connected app's tools are listed with an Ask/Allow control on each one that can make changes. Reads are shown but not configurable — they never prompt. Backed by the tool-approvals endpoint, so the page shows the decision the runtime would actually reach rather than a separate client-side guess. Routing: pathToConfigureSection already gated viewMode through isConfigurePath, and its regex matched a single segment only, so a nested path would have silently fallen through to the dashboard. Widening it there means view-mode-context needs no change. /configure/tools with no slug still redirects to /configure, since that two-segment path keeps matching the existing [section] route. A connected server can expose hundreds of tools — a leased PostHog connection indexed around 520 — so the list is virtualised with @tanstack/react-virtual (already a dependency, previously unused) and has a search box, with change-making tools sorted first. The workspace connection renders read-only: org-shared credentials cannot write at all, so there is nothing to configure on one. Connector cards get a "Tools" link rather than becoming clickable, since each card already contains a dropdown and buttons and nesting interactives would break them. Co-Authored-By: Claude --- .../app/(app)/configure/tools/[slug]/page.tsx | 4 + apps/web/components/configure-view.tsx | 12 +- .../settings/company-brain-connections.tsx | 17 + .../settings/company-brain-tool-approvals.tsx | 352 ++++++++++++++++++ apps/web/hooks/use-tool-approvals.ts | 86 +++++ apps/web/lib/configure-routes.ts | 12 + 6 files changed, 480 insertions(+), 3 deletions(-) create mode 100644 apps/web/app/(app)/configure/tools/[slug]/page.tsx create mode 100644 apps/web/components/settings/company-brain-tool-approvals.tsx create mode 100644 apps/web/hooks/use-tool-approvals.ts diff --git a/apps/web/app/(app)/configure/tools/[slug]/page.tsx b/apps/web/app/(app)/configure/tools/[slug]/page.tsx new file mode 100644 index 00000000..fe6a40d4 --- /dev/null +++ b/apps/web/app/(app)/configure/tools/[slug]/page.tsx @@ -0,0 +1,4 @@ +// Shell renders in ../../layout.tsx; ConfigureView reads the slug from the path. +export default function ConfigureToolPage() { + return null +} diff --git a/apps/web/components/configure-view.tsx b/apps/web/components/configure-view.tsx index 98be696b..6b234e0e 100644 --- a/apps/web/components/configure-view.tsx +++ b/apps/web/components/configure-view.tsx @@ -7,6 +7,7 @@ import { usePathname } from "next/navigation" import CompanyBrainConnections from "@/components/settings/company-brain-connections" import CompanyBrainModels from "@/components/settings/company-brain-models" import CompanyBrainProactivity from "@/components/settings/company-brain-proactivity" +import CompanyBrainToolApprovals from "@/components/settings/company-brain-tool-approvals" import Proactiveness from "@/components/settings/proactiveness" import { ProactivenessIcon } from "@/components/settings/proactiveness-icon" import { WorkspacePrompt } from "@/components/settings/workspace-prompt" @@ -17,6 +18,7 @@ import { configureSectionToPath, DEFAULT_CONFIGURE_SECTION, pathToConfigureSection, + pathToConfigureToolSlug, } from "@/lib/configure-routes" import { dmSans125ClassName } from "@/lib/fonts" @@ -69,6 +71,8 @@ export function ConfigureView() { // Reachable via ?view=configure too, where the path carries no section. const activeSection = pathToConfigureSection(pathname) ?? DEFAULT_CONFIGURE_SECTION + // /configure/tools/ nests under the tools section. + const toolSlug = pathToConfigureToolSlug(pathname) const active = SECTIONS.find((section) => section.id === activeSection) if (!active) return null @@ -116,7 +120,7 @@ export function ConfigureView() {
-
+

Something went wrong loading this section.

} > - {activeSection === "tools" ? ( + {toolSlug ? ( + + ) : activeSection === "tools" ? ( ) : activeSection === "models" ? ( diff --git a/apps/web/components/settings/company-brain-connections.tsx b/apps/web/components/settings/company-brain-connections.tsx index d7969d3e..e989b587 100644 --- a/apps/web/components/settings/company-brain-connections.tsx +++ b/apps/web/components/settings/company-brain-connections.tsx @@ -4,6 +4,7 @@ import { useOrgMemberRole } from "@/hooks/use-org-member-role" import { cn } from "@lib/utils" import * as DialogPrimitive from "@radix-ui/react-dialog" import { ChevronDown, Loader2, Plus, XIcon } from "lucide-react" +import Link from "next/link" import { useCallback, useEffect, useState } from "react" import { Dialog, @@ -18,6 +19,7 @@ import { DropdownMenuTrigger, } from "@ui/components/dropdown-menu" import { toast } from "sonner" +import { configureToolPath } from "@/lib/configure-routes" import { dmSans125ClassName } from "@/lib/fonts" import { useHasCompanyBrain } from "@/hooks/use-company-brain" import { brainConnectorIcon, SlackMark } from "../brain-connector-icons" @@ -103,6 +105,7 @@ function AppCard({ isAdmin, personalOnly, busy, + toolsHref, onConnect, onDisconnect, }: { @@ -114,6 +117,7 @@ function AppCard({ isAdmin: boolean personalOnly?: boolean busy: boolean + toolsHref?: string onConnect: (shared: boolean) => void onDisconnect: (shared: boolean) => void }) { @@ -161,6 +165,17 @@ function AppCard({ ) : null} )} + {toolsHref && anyConnected ? ( + + Tools + + ) : null}

{adminMenu ? ( @@ -634,6 +649,7 @@ export default function CompanyBrainConnections() { userConnected={isConnected(entry.slug, false)} orgConnected={isConnected(entry.slug, true)} isAdmin={isAdmin} + toolsHref={configureToolPath(entry.slug)} busy={busy?.startsWith(`${entry.slug}:`) ?? false} onConnect={(shared) => connect(entry, shared)} onDisconnect={(shared) => disconnect(entry, shared)} @@ -649,6 +665,7 @@ export default function CompanyBrainConnections() { orgConnected={false} isAdmin={false} personalOnly + toolsHref={configureToolPath(row.serverSlug)} busy={busy === `${row.serverSlug}:user`} onConnect={() => {}} onDisconnect={() => diff --git a/apps/web/components/settings/company-brain-tool-approvals.tsx b/apps/web/components/settings/company-brain-tool-approvals.tsx new file mode 100644 index 00000000..741a3d41 --- /dev/null +++ b/apps/web/components/settings/company-brain-tool-approvals.tsx @@ -0,0 +1,352 @@ +"use client" + +import { cn } from "@lib/utils" +import { useVirtualizer } from "@tanstack/react-virtual" +import { ArrowLeft, Loader2, Lock, Search, X } from "lucide-react" +import Link from "next/link" +import { useMemo, useRef, useState } from "react" +import { + type ToolApprovalDecision, + type ToolApprovalEntry, + useToolApprovals, + useUpdateToolApprovals, +} from "@/hooks/use-tool-approvals" +import { useHasCompanyBrain } from "@/hooks/use-company-brain" +import { dmSans125ClassName } from "@/lib/fonts" +import { brainConnectorIcon } from "../brain-connector-icons" + +const fieldLabel = cn( + dmSans125ClassName(), + "text-[11px] font-medium uppercase tracking-[0.06em] text-[#5B6675]", +) + +function titleCase(value: string) { + return value.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()) +} + +function Segmented({ + value, + options, + disabled, + onChange, +}: { + value: string + options: { id: string; label: string }[] + disabled?: boolean + onChange: (id: string) => void +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ) +} + +function ToolRow({ + tool, + disabled, + onChange, +}: { + tool: ToolApprovalEntry + disabled: boolean + onChange: (decision: ToolApprovalDecision | null) => void +}) { + const isRead = tool.toolClass === "read" + return ( +
+
+

+ {tool.name} + {tool.toolClass === "dangerous" ? ( + + Raw access + + ) : null} +

+ {tool.description ? ( +

+ {tool.description} +

+ ) : null} +
+
+ {isRead ? ( + + Reads never ask + + ) : ( + onChange(id as ToolApprovalDecision)} + /> + )} +
+
+ ) +} + +export default function CompanyBrainToolApprovals({ + serverSlug, +}: { + serverSlug: string +}) { + const isCompanyBrain = useHasCompanyBrain() + const query = useToolApprovals(serverSlug, isCompanyBrain) + const update = useUpdateToolApprovals(serverSlug) + const [search, setSearch] = useState("") + const scrollRef = useRef(null) + + const data = query.data + const tools = useMemo(() => { + const all = data?.tools ?? [] + const term = search.trim().toLowerCase() + const matching = term + ? all.filter( + (tool) => + tool.name.toLowerCase().includes(term) || + tool.description.toLowerCase().includes(term), + ) + : all + // Writes first: they are the only rows with a decision to make. + return [...matching].sort((left, right) => { + const rank = (tool: ToolApprovalEntry) => + tool.toolClass === "read" ? 1 : 0 + return rank(left) - rank(right) || left.name.localeCompare(right.name) + }) + }, [data?.tools, search]) + + const virtualizer = useVirtualizer({ + count: tools.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => 60, + overscan: 8, + }) + + if (!isCompanyBrain) return null + + const readOnlyConnection = data?.accessScope === "organization" + const disabled = readOnlyConnection || query.isLoading || update.isPending + const writeCount = (data?.tools ?? []).filter( + (tool) => tool.toolClass !== "read", + ).length + const allowedCount = (data?.tools ?? []).filter( + (tool) => tool.toolClass !== "read" && tool.decision === "allow", + ).length + + return ( +
+ + + Integrations + + +
+
+ {brainConnectorIcon(serverSlug, serverSlug)} +
+
+

+ {titleCase(serverSlug)} +

+

+ {data?.status === "ready" + ? `${data.tools.length} tools · ${writeCount} can make changes · ${allowedCount} always allowed` + : "Loading tools…"} +

+
+
+ + {query.isError ? ( +

+ Couldn't load this app's tools. +

+ ) : data?.status === "not_connected" ? ( +

+ You aren't connected to this app. Connect it from Integrations to set + tool permissions. +

+ ) : data?.status === "warming" || query.isLoading ? ( +
+ + Fetching this app's tool list… +
+ ) : ( + <> +
+ Actions that change things +
+

+ Ask before running + + Applies to any tool without its own setting + +

+ + update.mutate({ + defaultWriteApproval: id as "ask" | "allow", + }) + } + /> +
+
+ +
+
+ Tools +
+ + setSearch(event.target.value)} + placeholder="Search tools" + className={cn( + dmSans125ClassName(), + "min-w-0 flex-1 bg-transparent text-[12px] text-[#FAFAFA] placeholder:text-[#525D6E] focus:outline-none", + )} + /> + {search ? ( + + ) : null} +
+
+ +
+ {tools.length === 0 ? ( +

+ No tools match "{search}". +

+ ) : ( +
+
+ {virtualizer.getVirtualItems().map((row) => { + const tool = tools[row.index] + if (!tool) return null + return ( +
+ + update.mutate({ + rules: { [tool.name]: decision }, + }) + } + /> +
+ ) + })} +
+
+ )} +
+
+ + {readOnlyConnection ? ( +
+ + This is the workspace connection, which can only read. Connect + your own account to make changes. +
+ ) : null} + + )} +
+ ) +} diff --git a/apps/web/hooks/use-tool-approvals.ts b/apps/web/hooks/use-tool-approvals.ts new file mode 100644 index 00000000..4bd6b695 --- /dev/null +++ b/apps/web/hooks/use-tool-approvals.ts @@ -0,0 +1,86 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" +import { useAuth } from "@lib/auth-context" + +const BACKEND = + process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai" + +export type ToolApprovalDecision = "allow" | "ask" | "deny" +export type ToolApprovalDefault = "ask" | "allow" +export type ToolClass = "read" | "write" | "dangerous" | "disallowed" + +export type ToolApprovalEntry = { + name: string + description: string + toolClass: ToolClass + decision: ToolApprovalDecision +} + +export type ToolApprovalSettings = { + // "warming" means the tool list is still being fetched from the server. + status: "ready" | "warming" | "not_connected" + serverSlug: string + accessScope: "personal" | "organization" | null + defaultWriteApproval: ToolApprovalDefault + tools: ToolApprovalEntry[] +} + +// null clears a rule, falling that tool back to the connection default. +export type ToolApprovalPatch = { + defaultWriteApproval?: ToolApprovalDefault + rules?: Record +} + +function url(serverSlug: string) { + return `${BACKEND}/brain/mcp-connections/${serverSlug}/tool-approvals` +} + +export function useToolApprovals(serverSlug: string, enabled: boolean) { + const { org } = useAuth() + return useQuery({ + queryKey: ["brain", "tool-approvals", serverSlug, org?.id], + queryFn: async (): Promise => { + const res = await fetch(url(serverSlug), { credentials: "include" }) + if (!res.ok) throw new Error("Failed to load tools") + return res.json() + }, + enabled: enabled && Boolean(serverSlug), + staleTime: 60_000, + // A cold tool list is fetched in the background, so poll until it lands. + refetchInterval: (query) => + query.state.data?.status === "warming" ? 2_000 : false, + }) +} + +export function useUpdateToolApprovals(serverSlug: string) { + const { org } = useAuth() + const queryClient = useQueryClient() + return useMutation({ + mutationFn: async (patch: ToolApprovalPatch) => { + const res = await fetch(url(serverSlug), { + method: "PUT", + credentials: "include", + headers: { "Content-Type": "application/json", "X-App-Source": "nova" }, + body: JSON.stringify(patch), + }) + if (res.status === 403) + throw new Error("Only admins can change workspace connections.") + if (!res.ok) { + const b = (await res.json().catch(() => ({}))) as { + message?: string + error?: string + } + throw new Error(b.message ?? b.error ?? "Failed to save") + } + return res.json() as Promise + }, + onSuccess: (data) => { + queryClient.setQueryData( + ["brain", "tool-approvals", serverSlug, org?.id], + data, + ) + }, + onError: (err) => + toast.error(err instanceof Error ? err.message : "Failed to save"), + }) +} diff --git a/apps/web/lib/configure-routes.ts b/apps/web/lib/configure-routes.ts index cdef17dd..c490a75f 100644 --- a/apps/web/lib/configure-routes.ts +++ b/apps/web/lib/configure-routes.ts @@ -29,9 +29,21 @@ export function pathToConfigureSection( if (trimmed === "/configure") return DEFAULT_CONFIGURE_SECTION const slug = trimmed.match(/^\/configure\/([^/]+)$/)?.[1] if (slug && isConfigureSection(slug)) return slug + // Server detail pages live under the tools section. + if (pathToConfigureToolSlug(trimmed)) return "tools" return null } +// /configure/tools/ — the per-server tool approval page. +export function configureToolPath(serverSlug: string): string { + return `/configure/tools/${serverSlug}` +} + +export function pathToConfigureToolSlug(pathname: string): string | null { + const trimmed = pathname.replace(/\/$/, "") + return trimmed.match(/^\/configure\/tools\/([^/]+)$/)?.[1] ?? null +} + export function isConfigurePath(pathname: string): boolean { return pathToConfigureSection(pathname) !== null }