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:
2026-07-22 15:35:34 +03:00
parent 8cfd49fd9f
commit b3818852df
6 changed files with 438 additions and 282 deletions

View 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>
);
}

View File

@@ -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>

View File

@@ -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={(() => {

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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) {