From b3818852dfb4e8594b9584a923ef231b608435b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Wed, 22 Jul 2026 15:35:34 +0300 Subject: [PATCH] =?UTF-8?q?UI:=20API/MCP-=D0=B4=D0=BE=D1=81=D1=82=D1=83?= =?UTF-8?q?=D0=BF=20=D0=B2=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=B5=20=D0=B1=D0=BE=D1=82=D0=B0,=20legacy-=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B8=20=D0=B2=20Settings,=20=D0=B1=D0=BE=D1=82=20=D0=B8?= =?UTF-8?q?=20=D0=BA=D0=B0=D0=BD=D0=B0=D0=BB=20=D0=B2=20=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=B8=20(=D1=8D=D1=82=D0=B0=D0=BF=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ApiScopesEditor — общий редактор скоупов (режим + формы/справочники) - Bots.tsx: секция «Доступ к API и MCP» при создании и в настройках бота, показ ключа один раз, перевыпуск с подтверждением - Settings.tsx: управление ключами убрано, read-only список legacy-ключей - TaskHistory/TaskDetail: иконка бота + бейдж канала MCP/API у записей аудита - GET /api/bots/:id возвращает apiKey --- client/src/components/ApiScopesEditor.tsx | 159 ++++++++++++ client/src/components/TaskHistory.tsx | 14 +- client/src/pages/Bots.tsx | 239 +++++++++++++++++- client/src/pages/Settings.tsx | 284 +--------------------- client/src/pages/TaskDetail.tsx | 18 +- server/routes/bots-crud.routes.ts | 6 + 6 files changed, 438 insertions(+), 282 deletions(-) create mode 100644 client/src/components/ApiScopesEditor.tsx diff --git a/client/src/components/ApiScopesEditor.tsx b/client/src/components/ApiScopesEditor.tsx new file mode 100644 index 0000000..5db6c80 --- /dev/null +++ b/client/src/components/ApiScopesEditor.tsx @@ -0,0 +1,159 @@ +import { useQuery } from '@tanstack/react-query'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Label } from '@/components/ui/label'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; + +// Скоупы API-ключа: режим + ограничения по формам/справочникам (null = все) +export interface ApiScopesValue { + mode: 'read' | 'write' | 'full'; + formIds: number[] | null; + tableIds: number[] | null; +} + +// Дефолт для нового ключа: чтение, всё доступно +export const DEFAULT_API_SCOPES: ApiScopesValue = { mode: 'read', formIds: null, tableIds: null }; + +// Валидация: при выборочном доступе нужна хотя бы одна форма и один справочник +export function isApiScopesInvalid(value: ApiScopesValue): boolean { + if (value.mode === 'full') return false; + return (value.formIds !== null && value.formIds.length === 0) + || (value.tableIds !== null && value.tableIds.length === 0); +} + +type ScopeItem = { id: number; name: string }; + +type ApiScopesEditorProps = { + value: ApiScopesValue | null; // null — полный доступ (legacy) + onChange: (value: ApiScopesValue) => void; + idPrefix?: string; // префикс id/htmlFor элементов (при нескольких редакторах на странице) +}; + +// Редактор скоупов API-ключа: радиогруппа режима доступа и (при read/write) +// блоки «Доступ к формам» / «Доступ к справочникам» («Все / Выбранные» + чекбоксы). +// Списки форм и справочников загружает сам. +export function ApiScopesEditor({ value, onChange, idPrefix = 'scopes' }: ApiScopesEditorProps) { + const current: ApiScopesValue = value ?? { mode: 'full', formIds: null, tableIds: null }; + const allForms = current.formIds == null; + const allTables = current.tableIds == null; + + const { data: formsData } = useQuery<{ success: boolean; forms: ScopeItem[] }>({ + queryKey: ['/api/forms'], + }); + const { data: directoriesData } = useQuery<{ tables: ScopeItem[] }>({ + queryKey: ['/api/directories'], + }); + const forms = formsData?.forms ?? []; + const directories = directoriesData?.tables ?? []; + + const setMode = (mode: ApiScopesValue['mode']) => { + if (mode === 'full') { + onChange({ mode, formIds: null, tableIds: null }); + } else { + onChange({ ...current, mode }); + } + }; + + const toggleId = (list: number[], id: number) => + list.includes(id) ? list.filter((x) => x !== id) : [...list, id]; + + // Блок выбора «Все / Выбранные» со скроллируемым списком чекбоксов + const renderScopePicker = ( + title: string, + allLabel: string, + all: boolean, + setAll: (v: boolean) => void, + items: ScopeItem[], + selectedIds: number[], + setSelectedIds: (ids: number[]) => void, + prefix: string, + ) => ( +
+ + setAll(v === 'all')} + className="flex gap-3" + > +
+ + +
+
+ + +
+
+ {!all && ( +
+ {items.length === 0 ? ( +
Список пуст
+ ) : ( + items.map((item) => ( +
+ setSelectedIds(toggleId(selectedIds, item.id))} + data-testid={`checkbox-${prefix}-${item.id}`} + /> + +
+ )) + )} +
+ )} +
+ ); + + return ( +
+
+ + setMode(v as ApiScopesValue['mode'])} className="space-y-1"> +
+ + +
+
+ + +
+
+ + +
+
+
+ + {current.mode !== 'full' && ( + <> + {renderScopePicker( + 'Доступ к формам', 'Все формы', + allForms, + (v) => onChange({ ...current, formIds: v ? null : [] }), + forms, + current.formIds ?? [], + (ids) => onChange({ ...current, formIds: ids }), + `${idPrefix}-form`, + )} + {renderScopePicker( + 'Доступ к справочникам', 'Все справочники', + allTables, + (v) => onChange({ ...current, tableIds: v ? null : [] }), + directories, + current.tableIds ?? [], + (ids) => onChange({ ...current, tableIds: ids }), + `${idPrefix}-table`, + )} + {isApiScopesInvalid(current) && ( +

+ Выберите хотя бы одну форму и один справочник или переключитесь на «Все» +

+ )} + + )} +
+ ); +} diff --git a/client/src/components/TaskHistory.tsx b/client/src/components/TaskHistory.tsx index 9db2339..a1b3f6c 100644 --- a/client/src/components/TaskHistory.tsx +++ b/client/src/components/TaskHistory.tsx @@ -1,5 +1,6 @@ import { useQuery } from '@tanstack/react-query'; -import { AlertCircle, Clock, FileText, ArrowRightLeft, Edit3, Plus, Trash2, CheckCircle } from 'lucide-react'; +import { AlertCircle, Clock, FileText, ArrowRightLeft, Edit3, Plus, Trash2, CheckCircle, Bot } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; interface AuditEntry { id: number; @@ -12,6 +13,8 @@ interface AuditEntry { newValue: unknown; changedBy: number | null; changedByName: string | null; + // Атрибуция бота (действие выполнено по API-ключу бота); null — действие пользователя + botId?: number | null; metadata: Record | null; createdAt: string; } @@ -143,9 +146,18 @@ export function TaskHistory({ taskId }: Props) {

{describeAction(entry)}

+ {entry.botId != null && ( + + )} {entry.changedByName && ( {entry.changedByName} )} + {entry.metadata?.source === 'mcp' && ( + MCP + )} + {entry.metadata?.source === 'api' && ( + API + )} {formatDate(entry.createdAt)}
diff --git a/client/src/pages/Bots.tsx b/client/src/pages/Bots.tsx index 945d0a1..65c29ed 100644 --- a/client/src/pages/Bots.tsx +++ b/client/src/pages/Bots.tsx @@ -19,6 +19,17 @@ import { apiRequest, queryClient } from '@/lib/queryClient'; import { Bot, Plus, Trash2, RefreshCw, Copy, Eye, EyeOff, Link, Settings, CheckCircle2, XCircle, Key, Pencil, Puzzle, Download, Upload, Code2, Play, ChevronDown, ChevronUp, FileCode, ExternalLink } from 'lucide-react'; import Editor from '@/components/LazyMonacoEditor'; import { JsComponentRenderer } from '@/components/tabs/JsComponentRenderer'; +import { ApiScopesEditor, DEFAULT_API_SCOPES, isApiScopesInvalid, type ApiScopesValue } from '@/components/ApiScopesEditor'; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; + +// Скоупы API-ключа бота (совпадает с backend ApiKeyScopes) +interface BotApiKeyInfo { + id: number; + keyPrefix: string; + scopes: ApiScopesValue | null; // null — полный доступ + isActive: boolean; + lastUsedAt?: string | null; +} interface McpServer { url: string; @@ -53,6 +64,7 @@ interface BotData { type: 'webhook' | 'ai_assistant'; ragEnabled?: boolean; mcpServers?: Array<{ url: string; name?: string }> | null; + apiKey?: BotApiKeyInfo | null; // привязанный API-ключ (1:1), null — доступ не выдан } interface BotSubscription { @@ -257,7 +269,7 @@ export const BotsContent = () => { const [createDialogOpen, setCreateDialogOpen] = useState(false); const [selectedBot, setSelectedBot] = useState(null); const [showPassword, setShowPassword] = useState(false); - const [newBotCredentials, setNewBotCredentials] = useState<{ login: string; password: string; webhookSecret: string; type: 'webhook' | 'ai_assistant' } | null>(null); + const [newBotCredentials, setNewBotCredentials] = useState<{ login: string; password: string; webhookSecret: string; type: 'webhook' | 'ai_assistant'; apiKey?: string | null } | null>(null); const [lastRegeneratedPassword, setLastRegeneratedPassword] = useState(null); const [showBotPassword, setShowBotPassword] = useState(false); @@ -292,6 +304,16 @@ export const BotsContent = () => { const [editAiSendChatHistory, setEditAiSendChatHistory] = useState(true); const [editAiMcpEnabled, setEditAiMcpEnabled] = useState(false); + // Доступ к API и MCP (ключ бота 1:1) + const [createApiEnabled, setCreateApiEnabled] = useState(false); + const [createApiScopes, setCreateApiScopes] = useState(DEFAULT_API_SCOPES); + const [editApiEnabled, setEditApiEnabled] = useState(false); + const [editApiScopes, setEditApiScopes] = useState(DEFAULT_API_SCOPES); + // Сырой ключ показывается один раз (создание/включение/перевыпуск) + const [newApiKey, setNewApiKey] = useState(null); + const [regenerateKeyConfirmOpen, setRegenerateKeyConfirmOpen] = useState(false); + const [apiKeyCopied, setApiKeyCopied] = useState(false); + const [createServiceDialogOpen, setCreateServiceDialogOpen] = useState(false); const [editServiceDialogOpen, setEditServiceDialogOpen] = useState(false); const [selectedService, setSelectedService] = useState(null); @@ -514,6 +536,9 @@ export const BotsContent = () => { setEditAiSendCardInfo(bot.sendCardInfo ?? true); setEditAiSendChatHistory(bot.sendChatHistory ?? true); setEditMcpTestResults(null); + // Доступ к API и MCP: включён, если у бота есть активный ключ + setEditApiEnabled(bot.apiKey?.isActive ?? false); + setEditApiScopes(bot.apiKey?.scopes ?? DEFAULT_API_SCOPES); // Auto-ping MCP servers so status is visible without manual action if (bot.type === 'ai_assistant' && servers.length > 0) { testMcpMutation.mutate({ id: bot.id, servers }); @@ -604,6 +629,10 @@ export const BotsContent = () => { if (data.webhookUrl) payload.webhookUrl = data.webhookUrl; payload.accessPolicy = aiAccessPolicy; } + // Доступ к API и MCP: при включении — со скоупами из редактора + payload.apiAccess = createApiEnabled + ? { enabled: true, ...createApiScopes } + : { enabled: false }; const response = await apiRequest('POST', '/api/bots', payload); return response.json(); }, @@ -615,6 +644,7 @@ export const BotsContent = () => { password: data.bot.password, webhookSecret: data.bot.webhookSecret, type: data.bot.type ?? 'webhook', + apiKey: data.apiKey?.key ?? null, }); createBotForm.reset(); setCreateBotType('webhook'); @@ -625,6 +655,8 @@ export const BotsContent = () => { setAiAccessPolicy({ mode: 'all' }); setAiLlmProviderId(''); setAiLlmModel(''); + setCreateApiEnabled(false); + setCreateApiScopes(DEFAULT_API_SCOPES); toast({ title: 'Бот создан', description: 'Сохраните учётные данные - пароль показывается только один раз' @@ -724,6 +756,66 @@ export const BotsContent = () => { } }); + // Сохранение доступа к API и MCP (apiAccess): включение/выключение и скоупы ключа + const saveApiAccessMutation = useMutation({ + mutationFn: async (botId: number) => { + const response = await apiRequest('PUT', `/api/bots/${botId}`, { + apiAccess: editApiEnabled ? { enabled: true, ...editApiScopes } : { enabled: false }, + }); + return response.json(); + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['/api/bots'] }); + queryClient.invalidateQueries({ queryKey: ['/api/bots', selectedBot?.id] }); + // Сырой ключ при создании — показываем один раз + if (data.apiKey?.key) { + setApiKeyCopied(false); + setNewApiKey(data.apiKey.key); + } + toast({ title: 'Доступ к API сохранён' }); + }, + onError: () => { + toast({ title: 'Ошибка', description: 'Не удалось сохранить доступ к API', variant: 'destructive' }); + } + }); + + // Перевыпуск API-ключа бота (старый перестаёт работать) + const regenerateApiKeyMutation = useMutation({ + mutationFn: async (botId: number) => { + const response = await apiRequest('POST', `/api/bots/${botId}/api-key/regenerate`); + return response.json(); + }, + onSuccess: (data) => { + queryClient.invalidateQueries({ queryKey: ['/api/bots'] }); + queryClient.invalidateQueries({ queryKey: ['/api/bots', selectedBot?.id] }); + if (data.apiKey?.key) { + setApiKeyCopied(false); + setNewApiKey(data.apiKey.key); + } + }, + onError: () => { + toast({ title: 'Ошибка', description: 'Не удалось перевыпустить ключ', variant: 'destructive' }); + } + }); + + // Бейдж режима доступа ключа бота + const apiModeBadge = (mode: ApiScopesValue['mode'] | undefined) => { + if (mode === 'read') return Чтение; + if (mode === 'write') return Создание; + return Полный; + }; + + const copyNewApiKey = async () => { + if (!newApiKey) return; + try { + await navigator.clipboard.writeText(newApiKey); + setApiKeyCopied(true); + setTimeout(() => setApiKeyCopied(false), 2000); + } catch { + toast({ title: 'Не удалось скопировать', variant: 'destructive' }); + } + }; + const regenerateWebhookSecretMutation = useMutation({ mutationFn: async (id: number) => { const response = await apiRequest('POST', `/api/bots/${id}/regenerate-webhook-secret`); @@ -927,6 +1019,19 @@ export const BotsContent = () => { )} + {newBotCredentials.apiKey && ( +
+ +
+ + {newBotCredentials.apiKey} + + +
+
+ )} @@ -1449,6 +1577,113 @@ export const BotsContent = () => { +
+

Доступ к API и MCP

+
+ {selectedBot.apiKey && ( +
+ Ключ: {selectedBot.apiKey.keyPrefix}… + {apiModeBadge(selectedBot.apiKey.scopes?.mode)} + {!selectedBot.apiKey.isActive && ( + отключён + )} + {selectedBot.apiKey.lastUsedAt && ( + + Использован: {new Date(selectedBot.apiKey.lastUsedAt).toLocaleDateString('ru-RU')} + + )} +
+ )} +
+
+
API-ключ
+
Доступ бота к REST API и MCP-серверу
+
+ +
+ {editApiEnabled && ( + + )} +
+ + {selectedBot.apiKey && ( + + )} +
+

+ Сырой ключ показывается один раз — при создании или перевыпуске. MCP-конфигурация: Настройки → AI / MCP. +

+
+
+ + {/* Подтверждение перевыпуска API-ключа */} + + + + Перевыпустить API-ключ? + + Текущий ключ бота «{selectedBot.name}» перестанет работать. Новый ключ будет показан один раз. + + + + Отмена + regenerateApiKeyMutation.mutate(selectedBot.id)} + data-testid="button-confirm-regenerate-api-key" + > + Перевыпустить + + + + + + {/* Сырой API-ключ — показывается один раз */} + { if (!open) setNewApiKey(null); }}> + + + API-ключ создан + + Сохраните ключ — он показывается только один раз + + +
+
+ + {newApiKey} + + +
+ +
+
+
+

Политика доступа

{ diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index aac0adf..fc2f96b 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -1582,165 +1582,6 @@ function LlmProvidersContent() { ); } -// ── ApiKeyFormDialog ────────────────────────────────────────────────────────── -// Диалог создания/редактирования API-ключа: название, режим доступа и скоупы -// по формам и справочникам. Компоненту передаётся key={...} со стороны родителя, -// чтобы состояние сбрасывалось при открытии для другого ключа. - -type ApiKeyFormDialogProps = { - open: boolean; - onOpenChange: (open: boolean) => void; - apiKey: ApiKey | null; // null — создание нового ключа - forms: { id: number; name: string }[]; - directories: { id: number; name: string }[]; - isPending: boolean; - onSubmit: (label: string, scopes: ApiKeyScopes) => void; -}; - -function ApiKeyFormDialog({ open, onOpenChange, apiKey, forms, directories, isPending, onSubmit }: ApiKeyFormDialogProps) { - // Для legacy-ключей (scopes === null) показываем полный доступ со всеми формами и справочниками - const [label, setLabel] = useState(apiKey?.label ?? ''); - const [mode, setMode] = useState(apiKey?.scopes?.mode ?? 'full'); - const [allForms, setAllForms] = useState(apiKey?.scopes?.formIds == null); - const [allTables, setAllTables] = useState(apiKey?.scopes?.tableIds == null); - const [selectedFormIds, setSelectedFormIds] = useState(apiKey?.scopes?.formIds ?? []); - const [selectedTableIds, setSelectedTableIds] = useState(apiKey?.scopes?.tableIds ?? []); - - const toggleId = (list: number[], id: number) => - list.includes(id) ? list.filter((x) => x !== id) : [...list, id]; - - // При выборочном доступе нужно выбрать хотя бы одну форму и один справочник - const scopesInvalid = - mode !== 'full' && - ((!allForms && selectedFormIds.length === 0) || (!allTables && selectedTableIds.length === 0)); - - const handleSubmit = () => { - const scopes: ApiKeyScopes = - mode === 'full' - ? { mode, formIds: null, tableIds: null } - : { - mode, - formIds: allForms ? null : selectedFormIds, - tableIds: allTables ? null : selectedTableIds, - }; - onSubmit(label.trim(), scopes); - }; - - // Блок выбора «Все / Выбранные» со скроллируемым списком чекбоксов - const renderScopePicker = ( - title: string, - allLabel: string, - all: boolean, - setAll: (v: boolean) => void, - items: { id: number; name: string }[], - selectedIds: number[], - setSelectedIds: (ids: number[]) => void, - testIdPrefix: string, - ) => ( -
- - setAll(v === 'all')} - className="flex gap-3" - > -
- - -
-
- - -
-
- {!all && ( -
- {items.length === 0 ? ( -
Список пуст
- ) : ( - items.map((item) => ( -
- setSelectedIds(toggleId(selectedIds, item.id))} - data-testid={`checkbox-${testIdPrefix}-${item.id}`} - /> - -
- )) - )} -
- )} -
- ); - - return ( - - - - {apiKey ? 'Редактировать API-ключ' : 'Создать API-ключ'} - -
-
- - setLabel(e.target.value)} - placeholder="Название ключа (например: Claude Desktop)" - className="h-7 text-xs" - data-testid="input-key-label" - /> -
- -
- - setMode(v as ApiKeyScopes['mode'])} className="space-y-1"> -
- - -
-
- - -
-
- - -
-
-
- - {mode !== 'full' && ( - <> - {renderScopePicker('Доступ к формам', 'Все формы', allForms, setAllForms, forms, selectedFormIds, setSelectedFormIds, 'scope-form')} - {renderScopePicker('Доступ к справочникам', 'Все справочники', allTables, setAllTables, directories, selectedTableIds, setSelectedTableIds, 'scope-table')} - {scopesInvalid && ( -

- Выберите хотя бы одну форму и один справочник или переключитесь на «Все» -

- )} - - )} -
- - - - -
-
- ); -} // ── Main Settings ────────────────────────────────────────────────────────────── @@ -1748,11 +1589,6 @@ const Settings = () => { const { toast } = useToast(); const { user, isLoading: isAuthLoading } = useAuth(); const [location, setLocation] = useLocation(); - const [createdKey, setCreatedKey] = useState(null); - const [copiedKey, setCopiedKey] = useState(false); - // Диалог создания/редактирования ключа: editingKey === null — создание нового - const [keyDialogOpen, setKeyDialogOpen] = useState(false); - const [editingKey, setEditingKey] = useState(null); useEffect(() => { if (!isAuthLoading && !user) { @@ -1771,7 +1607,7 @@ const Settings = () => { }); const apiKeys = apiKeysData?.keys ?? []; - // Формы и справочники — для выбора скоупов в диалоге ключа + // Формы и справочники — для описания ограничений legacy-ключей const { data: formsData } = useQuery<{ success: boolean; forms: { id: number; name: string }[] }>({ queryKey: ['/api/forms'], enabled: !!user && isAdminUser, @@ -1784,44 +1620,6 @@ const Settings = () => { }); const directoriesList = directoriesData?.tables ?? []; - const createApiKeyMutation = useMutation({ - mutationFn: async ({ label, scopes }: { label: string; scopes: ApiKeyScopes }) => { - const res = await apiRequest('POST', '/api/mcp-keys', { label, scopes }); - return res.json(); - }, - onSuccess: (data) => { - queryClient.invalidateQueries({ queryKey: ['/api/mcp-keys'] }); - setCreatedKey(data.key); - setKeyDialogOpen(false); - }, - onError: () => { - toast({ title: 'Ошибка создания ключа', variant: 'destructive' }); - }, - }); - - const updateApiKeyMutation = useMutation({ - mutationFn: async ({ id, label, scopes }: { id: number; label: string; scopes: ApiKeyScopes }) => { - const res = await apiRequest('PATCH', `/api/mcp-keys/${id}`, { label, scopes }); - return res.json(); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['/api/mcp-keys'] }); - setKeyDialogOpen(false); - toast({ title: 'Ключ обновлён' }); - }, - onError: () => { - toast({ title: 'Ошибка обновления ключа', variant: 'destructive' }); - }, - }); - - const handleKeyDialogSubmit = (label: string, scopes: ApiKeyScopes) => { - if (editingKey) { - updateApiKeyMutation.mutate({ id: editingKey.id, label, scopes }); - } else { - createApiKeyMutation.mutate({ label, scopes }); - } - }; - // Бейдж режима доступа ключа; scopes === null — legacy-ключ с полным доступом const scopeModeBadge = (scopes: ApiKeyScopes | null) => { const mode = scopes?.mode ?? 'full'; @@ -1850,16 +1648,6 @@ const Settings = () => { }, }); - const handleCopyKey = async (key: string) => { - try { - await navigator.clipboard.writeText(key); - setCopiedKey(true); - setTimeout(() => setCopiedKey(false), 2000); - } catch { - toast({ title: 'Не удалось скопировать', variant: 'destructive' }); - } - }; - if (isAuthLoading || !user) { return ( @@ -1961,7 +1749,7 @@ const Settings = () => {

Позволяет AI-ассистентам (Claude Desktop, Cursor, Cline) управлять задачами через MCP-протокол. - Создайте API-ключ ниже и добавьте конфигурацию в ваш AI-клиент. + API-ключи выдаются в карточке бота (Настройки → Боты и интеграции). Добавьте ключ в конфигурацию вашего AI-клиента.

Streamable HTTP (Cursor / Cline) @@ -1995,55 +1783,19 @@ const Settings = () => {
- Создать API-ключ + Ранее созданные ключи
-
-

Задайте название и права доступа для нового ключа.

- -
- - {createdKey && ( -
- Скопируйте ключ — он показывается только один раз -
- - {createdKey} - - -
-
- )} -
-
- -
-
- Активные ключи -
-
+

+ API-ключи теперь выдаются в карточке бота (Настройки → Боты и интеграции). + Эти ключи созданы ранее и продолжают работать с полным доступом. +

{isLoadingApiKeys ? (
) : apiKeys.length === 0 ? ( -
Нет активных ключей
+
Нет ранее созданных ключей
) : (
{apiKeys.map((key) => ( @@ -2061,15 +1813,6 @@ const Settings = () => {
-
- -
Доступные MCP-инструменты diff --git a/client/src/pages/TaskDetail.tsx b/client/src/pages/TaskDetail.tsx index 69d13de..0da9903 100644 --- a/client/src/pages/TaskDetail.tsx +++ b/client/src/pages/TaskDetail.tsx @@ -11,7 +11,7 @@ import { Checkbox } from '@/components/ui/checkbox'; import { Textarea } from '@/components/ui/textarea'; import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea'; import { Badge } from '@/components/ui/badge'; -import { ArrowLeft, MessageSquare, ChevronLeft, ChevronRight, ListTree, Link2, Table, ExternalLink, PlusCircle, LinkIcon, Building2, Bell, Trash2, Plus, Search, X, Users, User, History, Check, Pencil, PencilOff, Maximize2, Minimize2, Upload, FileText, Download, Clock, Lock, AlertCircle, Shield, UserCheck, UserX, CheckCircle, MapPin, Navigation, FileSignature, EyeOff, Eye } from 'lucide-react'; +import { ArrowLeft, MessageSquare, ChevronLeft, ChevronRight, ListTree, Link2, Table, ExternalLink, PlusCircle, LinkIcon, Building2, Bell, Trash2, Plus, Search, X, Users, User, History, Check, Pencil, PencilOff, Maximize2, Minimize2, Upload, FileText, Download, Clock, Lock, AlertCircle, Shield, UserCheck, UserX, CheckCircle, MapPin, Navigation, FileSignature, EyeOff, Eye, Bot } from 'lucide-react'; import { Link } from 'wouter'; import { apiRequest, queryClient, OfflineQueuedError, ConflictError, BadRequestError, taskUpdateRequest } from '@/lib/queryClient'; import { useToast } from '@/hooks/use-toast'; @@ -1539,6 +1539,8 @@ interface TransitionApprovalEntry { id: number; changedBy: number | null; changedByName: string | null; + // Атрибуция бота (действие выполнено по API-ключу бота); null — действие пользователя + botId?: number | null; createdAt: string; metadata: { statusName?: string; @@ -1546,6 +1548,7 @@ interface TransitionApprovalEntry { roleId?: number | null; statusId?: number; actionText?: string; + source?: string; } | null; } @@ -1578,7 +1581,7 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean } staleTime: 30 * 1000, }); - const { data: auditLogData } = useQuery<{ success: boolean; log: Array<{ id: number; action: string; changedBy: number | null; changedByName: string | null; createdAt: string; metadata: Record | null }> }>({ + const { data: auditLogData } = useQuery<{ success: boolean; log: Array<{ id: number; action: string; changedBy: number | null; changedByName: string | null; botId?: number | null; createdAt: string; metadata: Record | null }> }>({ queryKey: ['/api/tasks', taskId, 'audit-log'], enabled: !!taskId, staleTime: 30 * 1000, @@ -1837,8 +1840,17 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean }
-

+

+ {entry.botId != null && ( + + )} {entry.changedByName || '—'} + {entry.metadata?.source === 'mcp' && ( + MCP + )} + {entry.metadata?.source === 'api' && ( + API + )} {roleName && ( · роль: {roleName} )} diff --git a/server/routes/bots-crud.routes.ts b/server/routes/bots-crud.routes.ts index 53a8e7c..a60cdca 100644 --- a/server/routes/bots-crud.routes.ts +++ b/server/routes/bots-crud.routes.ts @@ -175,6 +175,9 @@ export function registerBotCrudRoutes(app: import("express").Express): void { return res.status(404).json({ success: false, error: 'Бот не найден' }); } + // Привязанный API-ключ бота (без сырого ключа и keyHash) + const botApiKey = await storage.getApiKeyByBotId(botId, req.organizationId!); + res.json({ success: true, bot: { @@ -202,6 +205,9 @@ export function registerBotCrudRoutes(app: import("express").Express): void { sendSystemPrompt: bot.sendSystemPrompt ?? true, sendCardInfo: bot.sendCardInfo ?? true, sendChatHistory: bot.sendChatHistory ?? true, + apiKey: botApiKey + ? { id: botApiKey.id, keyPrefix: botApiKey.keyPrefix, scopes: botApiKey.scopes, isActive: botApiKey.isActive, lastUsedAt: botApiKey.lastUsedAt } + : null, } }); } catch (error) {