mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-10-06 02:47:54 +00:00
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:
parent
e8ed80f768
commit
2600b946ea
6 changed files with 480 additions and 3 deletions
4
apps/web/app/(app)/configure/tools/[slug]/page.tsx
Normal file
4
apps/web/app/(app)/configure/tools/[slug]/page.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Shell renders in ../../layout.tsx; ConfigureView reads the slug from the path.
|
||||
export default function ConfigureToolPage() {
|
||||
return null
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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={() =>
|
||||
|
|
|
|||
352
apps/web/components/settings/company-brain-tool-approvals.tsx
Normal file
352
apps/web/components/settings/company-brain-tool-approvals.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
apps/web/hooks/use-tool-approvals.ts
Normal file
86
apps/web/hooks/use-tool-approvals.ts
Normal 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"),
|
||||
})
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue