fix(mcp): привязать OAuth-согласие и завершать чтение логов при обслуживании

This commit is contained in:
Codex committed 2026-11-05 03:56:17 +07:00
1 parent 78b268cc39
commit 6569dbfc60
6 files changed
+173 -8

No files matched your search

+41 -4
View File
@@ -1,6 +1,6 @@
"use client"
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"
import { useSearchParams } from "next/navigation"
import { useTable, type ColumnDef } from "@tanstack/react-table"
import { BotIcon, CopyIcon, PlusIcon, RefreshCwIcon, ShieldIcon, StopCircleIcon } from "lucide-react"
@@ -300,8 +300,44 @@ function RestCoverage({ api }: { api: McpApi }) {
function Consent({ api, id }: { api: McpApi; id: string }) {
const request = useQuery(useCallback(signal => api.consentRequest(id, signal), [api, id])), connections = useQuery(useCallback(signal => api.connections(signal), [api]))
const [selected, setSelected] = useState("")
const confirm = async (approve: boolean) => { try { const result = await api.consent(id, selected, approve); window.location.assign(result.redirectUrl) } catch (error) { toast.error(errorText(error)) } }
return <Panel title="Разрешение OAuth-доступа" description="Выберите подключение с нужными ограничениями; клиент получит только его доступ"><ErrorMessage message={request.error || connections.error} /><p>{request.data?.name} запрашивает доступ к MikrotikManager.</p><p className="break-all text-sm text-muted-foreground">Возврат: {request.data?.redirect_uri}</p><NativeSelect label="Подключение" value={selected} onChange={setSelected}><option value="">Выберите ограниченный доступ</option>{connections.data?.connections.filter(connection => connection.ownerId === getClaims()?.sub && connection.policy.status === "active").map(connection => <option key={connection.id} value={connection.id}>{connection.name} · {modeLabels[connection.policy.mode]}</option>)}</NativeSelect><div className="flex gap-2"><Button disabled={!selected} onClick={() => { void confirm(true) }}>Разрешить</Button><Button variant="outline" disabled={!selected} onClick={() => { void confirm(false) }}>Отклонить</Button></div></Panel>
const [busy, setBusy] = useState(false)
const submitting = useRef(false)
const mounted = useRef(false)
useEffect(() => {
mounted.current = true
return () => { mounted.current = false }
}, [])
const currentRequest = !request.error && request.data?.id === id ? request.data : null
const ownerId = getClaims()?.sub
const eligible = connections.error ? [] : connections.data?.connections.filter(connection => connection.ownerId === ownerId && connection.policy.status === "active") ?? []
const canSubmit = currentRequest !== null && eligible.some(connection => connection.id === selected)
const isCurrentLocation = () => mounted.current && new URLSearchParams(window.location.search).get("oauthRequest") === id
const confirm = async (approve: boolean) => {
if (!canSubmit || submitting.current || !isCurrentLocation()) return
submitting.current = true
setBusy(true)
try {
const result = await api.consent(id, selected, approve)
if (isCurrentLocation()) window.location.assign(result.redirectUrl)
} catch (error) {
if (!mounted.current) return
submitting.current = false
setBusy(false)
if (isCurrentLocation()) toast.error(errorText(error))
}
}
return <Panel title="Разрешение OAuth-доступа" description="Выберите подключение с нужными ограничениями; клиент получит только его доступ">
<ErrorMessage message={request.error || connections.error} />
{currentRequest && <><p>{currentRequest.name} запрашивает доступ к MikrotikManager.</p><p className="break-all text-sm text-muted-foreground">Возврат: {currentRequest.redirect_uri}</p></>}
<NativeSelect label="Подключение" value={selected} onChange={setSelected}>
<option value="">Выберите ограниченный доступ</option>
{eligible.map(connection => <option key={connection.id} value={connection.id}>{connection.name} · {modeLabels[connection.policy.mode]}</option>)}
</NativeSelect>
<div className="flex gap-2">
<Button disabled={!canSubmit || busy} onClick={() => { void confirm(true) }}>Разрешить</Button>
<Button variant="outline" disabled={!canSubmit || busy} onClick={() => { void confirm(false) }}>Отклонить</Button>
</div>
</Panel>
}
export function McpWorkspace() {
const { backendUrl, mode } = useDataSource()
@@ -309,10 +345,11 @@ export function McpWorkspace() {
}
function McpWorkspaceContent({ backendUrl, live }: { backendUrl: string; live: boolean }) {
const params = useSearchParams(), initial = params.get("tab") as Tab | null
const oauthRequestId = params.get("oauthRequest")
const [tab, setTab] = useState<Tab>(initial && initial in tabs ? initial : "overview")
const api = useMemo(() => mcpApi(backendUrl), [backendUrl])
const [operations, setOperations] = useState<McpOperationDescriptor[]>([]), [error, setError] = useState("")
useEffect(() => { if (!live) return; const controller = new AbortController(); void api.catalog(controller.signal).then(catalog => setOperations(catalog.operations), failure => { if (!controller.signal.aborted) setError(errorText(failure)) }); return () => controller.abort() }, [api, live])
const changeTab = (value: unknown) => { const next = String(value) as Tab; setTab(next); const url = new URL(window.location.href); url.searchParams.set("tab", next); window.history.replaceState(null, "", url.pathname + url.search) }
return <><PageHeader crumbs={[{ label: "MCP" }]} /><main className="flex min-w-0 flex-1 flex-col gap-5 p-4 md:p-6"><div className="flex items-center gap-3"><BotIcon className="size-7 text-muted-foreground" /><div><h1 className="text-xl font-semibold">MCP</h1><p className="text-sm text-muted-foreground">Доступ нейросетей, разрешения и контроль изменений</p></div></div>{!live ? <Panel title="Подключите backend"><p>Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.</p></Panel> : <><ErrorMessage message={error} />{params.get("oauthRequest") && <Consent api={api} id={params.get("oauthRequest")!} />}<Tabs value={tab} onValueChange={changeTab}><div className="overflow-x-auto pb-1"><TabsList variant="line">{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => <TabsTrigger key={key} value={key}>{title}</TabsTrigger>)}</TabsList></div><TabsContent value="overview"><Overview api={api} /></TabsContent><TabsContent value="connections"><div className="flex flex-col gap-4"><Connections api={api} operations={operations} />{can("mm:mcp:admin") && <AccessTools api={api} />}</div></TabsContent><TabsContent value="permissions"><Permissions api={api} /></TabsContent><TabsContent value="devices"><DeviceManagement api={api} /></TabsContent><TabsContent value="routing"><div className="flex flex-col gap-4"><RoutingProfiles api={api} /><RoutePathVerification api={api} /></div></TabsContent><TabsContent value="requests"><Records api={api} kind="requests" /></TabsContent><TabsContent value="plans"><div className="flex flex-col gap-4"><Records api={api} kind="plans" /><Records api={api} kind="operations" /><OperationRecovery api={api} /><Records api={api} kind="drift" /></div></TabsContent>{can("mm:mcp:admin") && <TabsContent value="settings"><div className="flex flex-col gap-4"><Settings api={api} /><RestCoverage api={api} /><ManagementChannels api={api} />{can("mm:settings:admin") && <DatabaseRestore api={api} />}</div></TabsContent>}</Tabs></>}</main></>
return <><PageHeader crumbs={[{ label: "MCP" }]} /><main className="flex min-w-0 flex-1 flex-col gap-5 p-4 md:p-6"><div className="flex items-center gap-3"><BotIcon className="size-7 text-muted-foreground" /><div><h1 className="text-xl font-semibold">MCP</h1><p className="text-sm text-muted-foreground">Доступ нейросетей, разрешения и контроль изменений</p></div></div>{!live ? <Panel title="Подключите backend"><p>Для выдачи реального AI-доступа выберите режим «Живые» и настройте авторизацию портала.</p></Panel> : <><ErrorMessage message={error} />{oauthRequestId && <Consent key={oauthRequestId} api={api} id={oauthRequestId} />}<Tabs value={tab} onValueChange={changeTab}><div className="overflow-x-auto pb-1"><TabsList variant="line">{Object.entries(tabs).filter(([key]) => key !== "settings" || can("mm:mcp:admin")).map(([key, title]) => <TabsTrigger key={key} value={key}>{title}</TabsTrigger>)}</TabsList></div><TabsContent value="overview"><Overview api={api} /></TabsContent><TabsContent value="connections"><div className="flex flex-col gap-4"><Connections api={api} operations={operations} />{can("mm:mcp:admin") && <AccessTools api={api} />}</div></TabsContent><TabsContent value="permissions"><Permissions api={api} /></TabsContent><TabsContent value="devices"><DeviceManagement api={api} /></TabsContent><TabsContent value="routing"><div className="flex flex-col gap-4"><RoutingProfiles api={api} /><RoutePathVerification api={api} /></div></TabsContent><TabsContent value="requests"><Records api={api} kind="requests" /></TabsContent><TabsContent value="plans"><div className="flex flex-col gap-4"><Records api={api} kind="plans" /><Records api={api} kind="operations" /><OperationRecovery api={api} /><Records api={api} kind="drift" /></div></TabsContent>{can("mm:mcp:admin") && <TabsContent value="settings"><div className="flex flex-col gap-4"><Settings api={api} /><RestCoverage api={api} /><ManagementChannels api={api} />{can("mm:settings:admin") && <DatabaseRestore api={api} />}</div></TabsContent>}</Tabs></>}</main></>
}