feat(web): per-tool approval settings for connected apps

Adds /configure/tools/<slug>, 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 <noreply@anthropic.com>
This commit is contained in:
Sreeram Sreedhar 2026-07-31 16:16:22 +05:30
parent e8ed80f768
commit 2600b946ea
6 changed files with 480 additions and 3 deletions

View file

@ -0,0 +1,4 @@
// Shell renders in ../../layout.tsx; ConfigureView reads the slug from the path.
export default function ConfigureToolPage() {
return null
}

View file

@ -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/<slug> 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() {
</nav>
<div className="min-w-0 flex-1">
<header className="mb-5">
<header className={cn("mb-5", toolSlug && "sr-only")}>
<h2
id="configure-section-title"
className="text-[14px] font-semibold tracking-[-0.1px] text-[#FAFAFA]"
@ -129,14 +133,16 @@ export function ConfigureView() {
</header>
<ErrorBoundary
key={activeSection}
key={toolSlug ?? activeSection}
fallback={
<p className="py-6 text-center text-[13px] text-[#8B929E]">
Something went wrong loading this section.
</p>
}
>
{activeSection === "tools" ? (
{toolSlug ? (
<CompanyBrainToolApprovals serverSlug={toolSlug} />
) : activeSection === "tools" ? (
<CompanyBrainConnections />
) : activeSection === "models" ? (
<CompanyBrainModels showHeading={false} />

View file

@ -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 ? (
<Link
href={toolsHref}
className={cn(
dmSans125ClassName(),
"shrink-0 text-[12px] font-medium text-[#8B929E] underline-offset-2 transition-colors hover:text-[#FAFAFA] hover:underline",
)}
>
Tools
</Link>
) : null}
</div>
{adminMenu ? (
<DropdownMenu>
@ -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={() =>

View file

@ -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 (
<div className="flex items-center gap-0.5 rounded-full bg-[#0D121A] p-0.5 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]">
{options.map((option) => (
<button
key={option.id}
type="button"
aria-pressed={value === option.id}
disabled={disabled}
onClick={() => {
if (value !== option.id) onChange(option.id)
}}
className={cn(
dmSans125ClassName(),
"h-7 cursor-pointer rounded-full px-3 text-[11.5px] font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50",
value === option.id
? "bg-white/[0.10] text-[#FAFAFA]"
: "text-[#8B929E] hover:text-[#FAFAFA]",
)}
>
{option.label}
</button>
))}
</div>
)
}
function ToolRow({
tool,
disabled,
onChange,
}: {
tool: ToolApprovalEntry
disabled: boolean
onChange: (decision: ToolApprovalDecision | null) => void
}) {
const isRead = tool.toolClass === "read"
return (
<div className="flex min-h-[52px] items-center justify-between gap-3 border-white/[0.06] border-b px-4 py-2">
<div className="min-w-0">
<p
className={cn(
dmSans125ClassName(),
"flex items-center gap-2 truncate text-[13px] font-medium text-[#FAFAFA]",
)}
>
{tool.name}
{tool.toolClass === "dangerous" ? (
<span className="shrink-0 rounded-full bg-[#3A1D1D] px-2 py-0.5 text-[10px] font-medium text-[#FF9B9B]">
Raw access
</span>
) : null}
</p>
{tool.description ? (
<p
className={cn(
dmSans125ClassName(),
"mt-0.5 line-clamp-1 text-[12px] text-[#737B87]",
)}
>
{tool.description}
</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
{isRead ? (
<span
className={cn(dmSans125ClassName(), "text-[12px] text-[#737373]")}
>
Reads never ask
</span>
) : (
<Segmented
value={tool.decision}
disabled={disabled}
options={[
{ id: "ask", label: "Ask" },
{ id: "allow", label: "Allow" },
]}
onChange={(id) => onChange(id as ToolApprovalDecision)}
/>
)}
</div>
</div>
)
}
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<HTMLDivElement>(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 (
<section className="flex w-full max-w-3xl flex-col gap-4 px-1">
<Link
href="/configure"
className={cn(
dmSans125ClassName(),
"flex w-fit items-center gap-1.5 text-[12px] text-[#8B929E] transition-colors hover:text-[#FAFAFA]",
)}
>
<ArrowLeft className="size-3.5" />
Integrations
</Link>
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-[10px] bg-[#080B0F] shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.6)]">
{brainConnectorIcon(serverSlug, serverSlug)}
</div>
<div className="min-w-0">
<h3
className={cn(
dmSans125ClassName(),
"truncate font-semibold text-[14px] tracking-[-0.15px] text-[#FAFAFA]",
)}
>
{titleCase(serverSlug)}
</h3>
<p
className={cn(
dmSans125ClassName(),
"mt-0.5 text-[12px] text-[#737B87]",
)}
>
{data?.status === "ready"
? `${data.tools.length} tools · ${writeCount} can make changes · ${allowedCount} always allowed`
: "Loading tools…"}
</p>
</div>
</div>
{query.isError ? (
<p className={cn(dmSans125ClassName(), "text-[13px] text-red-400")}>
Couldn't load this app's tools.
</p>
) : data?.status === "not_connected" ? (
<p className={cn(dmSans125ClassName(), "text-[13px] text-[#8B929E]")}>
You aren't connected to this app. Connect it from Integrations to set
tool permissions.
</p>
) : data?.status === "warming" || query.isLoading ? (
<div className="flex items-center gap-2 text-[13px] text-[#9A9A9A]">
<Loader2 className="size-4 animate-spin" />
Fetching this app's tool list…
</div>
) : (
<>
<div className="flex flex-col gap-2">
<span className={fieldLabel}>Actions that change things</span>
<div className="flex items-center justify-between gap-3 rounded-xl bg-[#14161A] px-4 py-3 shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
<p
className={cn(
dmSans125ClassName(),
"text-[13px] text-[#FAFAFA]",
)}
>
Ask before running
<span className="ml-2 text-[12px] text-[#737B87]">
Applies to any tool without its own setting
</span>
</p>
<Segmented
value={data?.defaultWriteApproval ?? "ask"}
disabled={disabled}
options={[
{ id: "ask", label: "Ask" },
{ id: "allow", label: "Allow" },
]}
onChange={(id) =>
update.mutate({
defaultWriteApproval: id as "ask" | "allow",
})
}
/>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<span className={fieldLabel}>Tools</span>
<div className="flex h-8 min-w-0 max-w-[240px] flex-1 items-center gap-1.5 rounded-full bg-[#0D121A] px-3 shadow-[inset_1.5px_1.5px_4.5px_rgba(0,0,0,0.5)]">
<Search className="size-3.5 shrink-0 text-[#A1A1AA]" />
<input
type="text"
value={search}
onChange={(event) => 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 ? (
<button
type="button"
aria-label="Clear search"
onClick={() => setSearch("")}
className="shrink-0 text-[#737373] transition-colors hover:text-[#FAFAFA]"
>
<X className="size-3.5" />
</button>
) : null}
</div>
</div>
<div className="overflow-hidden rounded-xl bg-[#14161A] shadow-[inset_2.42px_2.42px_4.263px_rgba(11,15,21,0.7)]">
{tools.length === 0 ? (
<p
className={cn(
dmSans125ClassName(),
"px-4 py-6 text-center text-[13px] text-[#737373]",
)}
>
No tools match "{search}".
</p>
) : (
<div
ref={scrollRef}
className="max-h-[520px] overflow-y-auto"
// A connected server can expose hundreds of tools.
>
<div
style={{
height: virtualizer.getTotalSize(),
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((row) => {
const tool = tools[row.index]
if (!tool) return null
return (
<div
key={tool.name}
ref={virtualizer.measureElement}
data-index={row.index}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${row.start}px)`,
}}
>
<ToolRow
tool={tool}
disabled={disabled}
onChange={(decision) =>
update.mutate({
rules: { [tool.name]: decision },
})
}
/>
</div>
)
})}
</div>
</div>
)}
</div>
</div>
{readOnlyConnection ? (
<div className="flex items-center gap-1.5 text-[12px] text-[#737373]">
<Lock className="size-3.5" />
This is the workspace connection, which can only read. Connect
your own account to make changes.
</div>
) : null}
</>
)}
</section>
)
}

View file

@ -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<string, ToolApprovalDecision | null>
}
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<ToolApprovalSettings> => {
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<ToolApprovalSettings>
},
onSuccess: (data) => {
queryClient.setQueryData(
["brain", "tool-approvals", serverSlug, org?.id],
data,
)
},
onError: (err) =>
toast.error(err instanceof Error ? err.message : "Failed to save"),
})
}

View file

@ -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/<serverSlug> — 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
}