UI: API/MCP-доступ в карточке бота, legacy-ключи в Settings, бот и канал в истории (этап 3)
- ApiScopesEditor — общий редактор скоупов (режим + формы/справочники) - Bots.tsx: секция «Доступ к API и MCP» при создании и в настройках бота, показ ключа один раз, перевыпуск с подтверждением - Settings.tsx: управление ключами убрано, read-only список legacy-ключей - TaskHistory/TaskDetail: иконка бота + бейдж канала MCP/API у записей аудита - GET /api/bots/:id возвращает apiKey
This commit is contained in:
159
client/src/components/ApiScopesEditor.tsx
Normal file
159
client/src/components/ApiScopesEditor.tsx
Normal file
@@ -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,
|
||||
) => (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">{title}</Label>
|
||||
<RadioGroup
|
||||
value={all ? 'all' : 'selected'}
|
||||
onValueChange={(v) => setAll(v === 'all')}
|
||||
className="flex gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="all" id={`${prefix}-all`} />
|
||||
<Label htmlFor={`${prefix}-all`} className="text-xs font-normal cursor-pointer">{allLabel}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="selected" id={`${prefix}-selected`} />
|
||||
<Label htmlFor={`${prefix}-selected`} className="text-xs font-normal cursor-pointer">Выбранные</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
{!all && (
|
||||
<div className="max-h-32 overflow-y-auto border border-border rounded p-1.5 space-y-1">
|
||||
{items.length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground py-1 text-center">Список пуст</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
id={`${prefix}-${item.id}`}
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onCheckedChange={() => setSelectedIds(toggleId(selectedIds, item.id))}
|
||||
data-testid={`checkbox-${prefix}-${item.id}`}
|
||||
/>
|
||||
<Label htmlFor={`${prefix}-${item.id}`} className="text-xs font-normal cursor-pointer">
|
||||
{item.name}
|
||||
</Label>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">Режим доступа</Label>
|
||||
<RadioGroup value={current.mode} onValueChange={(v) => setMode(v as ApiScopesValue['mode'])} className="space-y-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="read" id={`${idPrefix}-mode-read`} data-testid="radio-mode-read" />
|
||||
<Label htmlFor={`${idPrefix}-mode-read`} className="text-xs font-normal cursor-pointer">Только чтение</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="write" id={`${idPrefix}-mode-write`} data-testid="radio-mode-write" />
|
||||
<Label htmlFor={`${idPrefix}-mode-write`} className="text-xs font-normal cursor-pointer">Чтение и создание</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="full" id={`${idPrefix}-mode-full`} data-testid="radio-mode-full" />
|
||||
<Label htmlFor={`${idPrefix}-mode-full`} className="text-xs font-normal cursor-pointer">Полный доступ</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{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) && (
|
||||
<p className="text-[11px] text-destructive">
|
||||
Выберите хотя бы одну форму и один справочник или переключитесь на «Все»
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, unknown> | null;
|
||||
createdAt: string;
|
||||
}
|
||||
@@ -143,9 +146,18 @@ export function TaskHistory({ taskId }: Props) {
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-foreground leading-tight break-words">{describeAction(entry)}</p>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{entry.botId != null && (
|
||||
<Bot className="w-3 h-3 text-muted-foreground" aria-label="Действие бота" />
|
||||
)}
|
||||
{entry.changedByName && (
|
||||
<span className="text-[10px] text-muted-foreground font-medium">{entry.changedByName}</span>
|
||||
)}
|
||||
{entry.metadata?.source === 'mcp' && (
|
||||
<Badge variant="outline" className="text-[9px] h-3.5 px-1">MCP</Badge>
|
||||
)}
|
||||
{entry.metadata?.source === 'api' && (
|
||||
<Badge variant="outline" className="text-[9px] h-3.5 px-1">API</Badge>
|
||||
)}
|
||||
<span className="text-[10px] text-muted-foreground/60">{formatDate(entry.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<BotWithSubscriptions | null>(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<string | null>(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<ApiScopesValue>(DEFAULT_API_SCOPES);
|
||||
const [editApiEnabled, setEditApiEnabled] = useState(false);
|
||||
const [editApiScopes, setEditApiScopes] = useState<ApiScopesValue>(DEFAULT_API_SCOPES);
|
||||
// Сырой ключ показывается один раз (создание/включение/перевыпуск)
|
||||
const [newApiKey, setNewApiKey] = useState<string | null>(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<ExternalService | null>(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 <Badge className="text-[10px] h-4 bg-blue-100 text-blue-700 border-blue-200">Чтение</Badge>;
|
||||
if (mode === 'write') return <Badge className="text-[10px] h-4 bg-yellow-100 text-yellow-700 border-yellow-200">Создание</Badge>;
|
||||
return <Badge className="text-[10px] h-4 bg-green-100 text-green-700 border-green-200">Полный</Badge>;
|
||||
};
|
||||
|
||||
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 = () => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{newBotCredentials.apiKey && (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">API-ключ (доступ к API и MCP)</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 p-2 bg-muted rounded text-sm font-mono text-xs break-all">
|
||||
{newBotCredentials.apiKey}
|
||||
</code>
|
||||
<Button size="sm" variant="outline" onClick={() => copyToClipboard(newBotCredentials.apiKey!, 'API-ключ')}>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" onClick={() => {
|
||||
@@ -1214,7 +1319,30 @@ export const BotsContent = () => {
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
<Button type="submit" className="w-full" disabled={createBotMutation.isPending} data-testid="button-submit-create-bot">
|
||||
|
||||
{/* Доступ к API и MCP */}
|
||||
<div className="border rounded-lg p-3 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-sm">Доступ к API и MCP</div>
|
||||
<div className="text-xs text-muted-foreground">Выдать боту API-ключ для REST API и MCP-сервера</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={createApiEnabled}
|
||||
onCheckedChange={setCreateApiEnabled}
|
||||
data-testid="switch-create-api-access"
|
||||
/>
|
||||
</div>
|
||||
{createApiEnabled && (
|
||||
<ApiScopesEditor
|
||||
value={createApiScopes}
|
||||
onChange={setCreateApiScopes}
|
||||
idPrefix="create-api"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={createBotMutation.isPending || (createApiEnabled && isApiScopesInvalid(createApiScopes))} data-testid="button-submit-create-bot">
|
||||
{createBotMutation.isPending ? 'Создание...' : 'Создать бота'}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -1449,6 +1577,113 @@ export const BotsContent = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<h4 className="font-medium mb-3">Доступ к API и MCP</h4>
|
||||
<div className="space-y-3">
|
||||
{selectedBot.apiKey && (
|
||||
<div className="flex items-center gap-2 flex-wrap p-3 bg-muted/50 rounded-lg">
|
||||
<code className="text-xs font-mono text-muted-foreground">Ключ: {selectedBot.apiKey.keyPrefix}…</code>
|
||||
{apiModeBadge(selectedBot.apiKey.scopes?.mode)}
|
||||
{!selectedBot.apiKey.isActive && (
|
||||
<Badge variant="outline" className="text-[10px] h-4">отключён</Badge>
|
||||
)}
|
||||
{selectedBot.apiKey.lastUsedAt && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
Использован: {new Date(selectedBot.apiKey.lastUsedAt).toLocaleDateString('ru-RU')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div>
|
||||
<div className="font-medium text-sm">API-ключ</div>
|
||||
<div className="text-xs text-muted-foreground">Доступ бота к REST API и MCP-серверу</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={editApiEnabled}
|
||||
onCheckedChange={setEditApiEnabled}
|
||||
data-testid="switch-api-access"
|
||||
/>
|
||||
</div>
|
||||
{editApiEnabled && (
|
||||
<ApiScopesEditor
|
||||
value={editApiScopes}
|
||||
onChange={setEditApiScopes}
|
||||
idPrefix="edit-api"
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveApiAccessMutation.mutate(selectedBot.id)}
|
||||
disabled={saveApiAccessMutation.isPending || (editApiEnabled && isApiScopesInvalid(editApiScopes))}
|
||||
data-testid="button-save-api-access"
|
||||
>
|
||||
{saveApiAccessMutation.isPending ? 'Сохранение...' : 'Сохранить доступ'}
|
||||
</Button>
|
||||
{selectedBot.apiKey && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setRegenerateKeyConfirmOpen(true)}
|
||||
disabled={regenerateApiKeyMutation.isPending}
|
||||
data-testid="button-regenerate-api-key"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 mr-1 ${regenerateApiKeyMutation.isPending ? 'animate-spin' : ''}`} />
|
||||
Перевыпустить ключ
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Сырой ключ показывается один раз — при создании или перевыпуске. MCP-конфигурация: Настройки → AI / MCP.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Подтверждение перевыпуска API-ключа */}
|
||||
<AlertDialog open={regenerateKeyConfirmOpen} onOpenChange={setRegenerateKeyConfirmOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Перевыпустить API-ключ?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Текущий ключ бота «{selectedBot.name}» перестанет работать. Новый ключ будет показан один раз.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => regenerateApiKeyMutation.mutate(selectedBot.id)}
|
||||
data-testid="button-confirm-regenerate-api-key"
|
||||
>
|
||||
Перевыпустить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* Сырой API-ключ — показывается один раз */}
|
||||
<Dialog open={!!newApiKey} onOpenChange={(open) => { if (!open) setNewApiKey(null); }}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>API-ключ создан</DialogTitle>
|
||||
<DialogDescription>
|
||||
Сохраните ключ — он показывается только один раз
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 p-2 bg-muted rounded text-xs font-mono break-all" data-testid="text-new-api-key">
|
||||
{newApiKey}
|
||||
</code>
|
||||
<Button size="sm" variant="outline" onClick={copyNewApiKey} data-testid="button-copy-new-api-key">
|
||||
{apiKeyCopied ? <CheckCircle2 className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<Button className="w-full" onClick={() => setNewApiKey(null)}>Готово</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="border-t pt-4 mt-4">
|
||||
<h4 className="font-medium mb-3">Политика доступа</h4>
|
||||
<CollapsibleSection title="Доступ к боту" defaultOpen={true} subtitle={(() => {
|
||||
|
||||
@@ -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<ApiKeyScopes['mode']>(apiKey?.scopes?.mode ?? 'full');
|
||||
const [allForms, setAllForms] = useState(apiKey?.scopes?.formIds == null);
|
||||
const [allTables, setAllTables] = useState(apiKey?.scopes?.tableIds == null);
|
||||
const [selectedFormIds, setSelectedFormIds] = useState<number[]>(apiKey?.scopes?.formIds ?? []);
|
||||
const [selectedTableIds, setSelectedTableIds] = useState<number[]>(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,
|
||||
) => (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">{title}</Label>
|
||||
<RadioGroup
|
||||
value={all ? 'all' : 'selected'}
|
||||
onValueChange={(v) => setAll(v === 'all')}
|
||||
className="flex gap-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="all" id={`${testIdPrefix}-all`} />
|
||||
<Label htmlFor={`${testIdPrefix}-all`} className="text-xs font-normal cursor-pointer">{allLabel}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="selected" id={`${testIdPrefix}-selected`} />
|
||||
<Label htmlFor={`${testIdPrefix}-selected`} className="text-xs font-normal cursor-pointer">Выбранные</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
{!all && (
|
||||
<div className="max-h-32 overflow-y-auto border border-border rounded p-1.5 space-y-1">
|
||||
{items.length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground py-1 text-center">Список пуст</div>
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.id} className="flex items-center gap-1.5">
|
||||
<Checkbox
|
||||
id={`${testIdPrefix}-${item.id}`}
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onCheckedChange={() => setSelectedIds(toggleId(selectedIds, item.id))}
|
||||
data-testid={`checkbox-${testIdPrefix}-${item.id}`}
|
||||
/>
|
||||
<Label htmlFor={`${testIdPrefix}-${item.id}`} className="text-xs font-normal cursor-pointer">
|
||||
{item.name}
|
||||
</Label>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-sm">{apiKey ? 'Редактировать API-ключ' : 'Создать API-ключ'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Название</Label>
|
||||
<Input
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder="Название ключа (например: Claude Desktop)"
|
||||
className="h-7 text-xs"
|
||||
data-testid="input-key-label"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium">Режим доступа</Label>
|
||||
<RadioGroup value={mode} onValueChange={(v) => setMode(v as ApiKeyScopes['mode'])} className="space-y-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="read" id="key-mode-read" data-testid="radio-mode-read" />
|
||||
<Label htmlFor="key-mode-read" className="text-xs font-normal cursor-pointer">Только чтение</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="write" id="key-mode-write" data-testid="radio-mode-write" />
|
||||
<Label htmlFor="key-mode-write" className="text-xs font-normal cursor-pointer">Чтение и создание</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<RadioGroupItem value="full" id="key-mode-full" data-testid="radio-mode-full" />
|
||||
<Label htmlFor="key-mode-full" className="text-xs font-normal cursor-pointer">Полный доступ</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{mode !== 'full' && (
|
||||
<>
|
||||
{renderScopePicker('Доступ к формам', 'Все формы', allForms, setAllForms, forms, selectedFormIds, setSelectedFormIds, 'scope-form')}
|
||||
{renderScopePicker('Доступ к справочникам', 'Все справочники', allTables, setAllTables, directories, selectedTableIds, setSelectedTableIds, 'scope-table')}
|
||||
{scopesInvalid && (
|
||||
<p className="text-[11px] text-destructive">
|
||||
Выберите хотя бы одну форму и один справочник или переключитесь на «Все»
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>Отмена</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending || !label.trim() || scopesInvalid}
|
||||
data-testid="button-save-key"
|
||||
>
|
||||
{isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin mr-1" /> : null}
|
||||
{apiKey ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Settings ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1748,11 +1589,6 @@ const Settings = () => {
|
||||
const { toast } = useToast();
|
||||
const { user, isLoading: isAuthLoading } = useAuth();
|
||||
const [location, setLocation] = useLocation();
|
||||
const [createdKey, setCreatedKey] = useState<string | null>(null);
|
||||
const [copiedKey, setCopiedKey] = useState(false);
|
||||
// Диалог создания/редактирования ключа: editingKey === null — создание нового
|
||||
const [keyDialogOpen, setKeyDialogOpen] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<ApiKey | null>(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 (
|
||||
<Layout>
|
||||
@@ -1961,7 +1749,7 @@ const Settings = () => {
|
||||
<div className="p-3 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Позволяет AI-ассистентам (Claude Desktop, Cursor, Cline) управлять задачами через MCP-протокол.
|
||||
Создайте API-ключ ниже и добавьте конфигурацию в ваш AI-клиент.
|
||||
API-ключи выдаются в карточке бота (Настройки → Боты и интеграции). Добавьте ключ в конфигурацию вашего AI-клиента.
|
||||
</p>
|
||||
<div className="bg-muted/50 rounded p-2 space-y-1">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wide">Streamable HTTP (Cursor / Cline)</span>
|
||||
@@ -1995,55 +1783,19 @@ const Settings = () => {
|
||||
|
||||
<div className="border border-border rounded">
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<span className="text-sm font-medium">Создать API-ключ</span>
|
||||
<span className="text-sm font-medium">Ранее созданные ключи</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">Задайте название и права доступа для нового ключа.</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-2.5 text-xs gap-1.5 shrink-0"
|
||||
onClick={() => { setEditingKey(null); setKeyDialogOpen(true); }}
|
||||
data-testid="button-create-key"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Новый ключ
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{createdKey && (
|
||||
<div className="bg-muted/50 border border-border rounded p-2 space-y-1">
|
||||
<span className="text-[10px] text-muted-foreground">Скопируйте ключ — он показывается только один раз</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 text-[11px] font-mono bg-background border border-border rounded px-2 py-1 text-foreground break-all" data-testid="text-created-key">
|
||||
{createdKey}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 shrink-0"
|
||||
onClick={() => handleCopyKey(createdKey)}
|
||||
data-testid="button-copy-key"
|
||||
>
|
||||
{copiedKey ? <Check className="w-3.5 h-3.5 text-green-500" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border rounded">
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<span className="text-sm font-medium">Активные ключи</span>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
API-ключи теперь выдаются в карточке бота (Настройки → Боты и интеграции).
|
||||
Эти ключи созданы ранее и продолжают работать с полным доступом.
|
||||
</p>
|
||||
{isLoadingApiKeys ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : apiKeys.length === 0 ? (
|
||||
<div className="text-center py-4 text-xs text-muted-foreground">Нет активных ключей</div>
|
||||
<div className="text-center py-4 text-xs text-muted-foreground">Нет ранее созданных ключей</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{apiKeys.map((key) => (
|
||||
@@ -2061,15 +1813,6 @@ const Settings = () => {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => { setEditingKey(key); setKeyDialogOpen(true); }}
|
||||
data-testid={`button-edit-key-${key.id}`}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -2088,17 +1831,6 @@ const Settings = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ApiKeyFormDialog
|
||||
key={editingKey ? `edit-${editingKey.id}` : 'new'}
|
||||
open={keyDialogOpen}
|
||||
onOpenChange={setKeyDialogOpen}
|
||||
apiKey={editingKey}
|
||||
forms={formsList}
|
||||
directories={directoriesList}
|
||||
isPending={createApiKeyMutation.isPending || updateApiKeyMutation.isPending}
|
||||
onSubmit={handleKeyDialogSubmit}
|
||||
/>
|
||||
|
||||
<div className="border border-border rounded">
|
||||
<div className="px-3 py-2 border-b border-border">
|
||||
<span className="text-sm font-medium">Доступные MCP-инструменты</span>
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> | null }> }>({
|
||||
queryKey: ['/api/tasks', taskId, 'audit-log'],
|
||||
enabled: !!taskId,
|
||||
staleTime: 30 * 1000,
|
||||
@@ -1837,8 +1840,17 @@ function TaskAccessTab({ taskId, isAdmin }: { taskId: number; isAdmin: boolean }
|
||||
<div key={entry.id} className="border rounded p-2 flex items-start gap-2">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400 mt-0.5 shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs">
|
||||
<p className="text-xs flex items-center gap-1.5 flex-wrap">
|
||||
{entry.botId != null && (
|
||||
<Bot className="w-3 h-3 text-muted-foreground" aria-label="Действие бота" />
|
||||
)}
|
||||
<span className="font-medium">{entry.changedByName || '—'}</span>
|
||||
{entry.metadata?.source === 'mcp' && (
|
||||
<Badge variant="outline" className="text-[9px] h-3.5 px-1">MCP</Badge>
|
||||
)}
|
||||
{entry.metadata?.source === 'api' && (
|
||||
<Badge variant="outline" className="text-[9px] h-3.5 px-1">API</Badge>
|
||||
)}
|
||||
{roleName && (
|
||||
<span className="text-muted-foreground"> · роль: <span className="font-medium text-foreground">{roleName}</span></span>
|
||||
)}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user