Права доступа API-ключей: скоупы read/write/full и выборочный доступ к формам и справочникам
- organization_api_keys.scopes (jsonb, NULL = полный доступ для legacy-ключей) - MCP: фильтрация инструментов по режиму и проверка formIds в handler'ах - PATCH /api/mcp-keys/:id, валидация скоупов в server/utils/api-key.ts - UI Settings: диалог создания/редактирования ключа с режимом и мультивыбором форм/справочников
This commit is contained in:
@@ -19,6 +19,16 @@ import { BotsContent } from '@/pages/Bots';
|
||||
import { AutomationsContent } from '@/pages/Automations';
|
||||
import { AppearanceSettings } from '@/pages/AppearanceSettings';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
// Скоупы прав API-ключа: режим доступа и ограничения по формам/справочникам.
|
||||
// null в formIds/tableIds означает «все формы» / «все справочники».
|
||||
type ApiKeyScopes = {
|
||||
mode: 'read' | 'write' | 'full'; // read = только чтение; write = чтение + создание; full = всё
|
||||
formIds: number[] | null;
|
||||
tableIds: number[] | null;
|
||||
};
|
||||
|
||||
type ApiKey = {
|
||||
id: number;
|
||||
@@ -28,6 +38,7 @@ type ApiKey = {
|
||||
createdAt: string | null;
|
||||
lastUsedAt: string | null;
|
||||
isActive: boolean;
|
||||
scopes: ApiKeyScopes | null; // null = полный доступ (legacy-ключи)
|
||||
};
|
||||
|
||||
type OrgInfo = { id: number; displayName: string; name: string } | null;
|
||||
@@ -1571,15 +1582,177 @@ 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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
const Settings = () => {
|
||||
const { toast } = useToast();
|
||||
const { user, isLoading: isAuthLoading } = useAuth();
|
||||
const [location, setLocation] = useLocation();
|
||||
const [newKeyLabel, setNewKeyLabel] = useState('');
|
||||
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) {
|
||||
@@ -1598,21 +1771,72 @@ const Settings = () => {
|
||||
});
|
||||
const apiKeys = apiKeysData?.keys ?? [];
|
||||
|
||||
// Формы и справочники — для выбора скоупов в диалоге ключа
|
||||
const { data: formsData } = useQuery<{ success: boolean; forms: { id: number; name: string }[] }>({
|
||||
queryKey: ['/api/forms'],
|
||||
enabled: !!user && isAdminUser,
|
||||
});
|
||||
const formsList = formsData?.forms ?? [];
|
||||
|
||||
const { data: directoriesData } = useQuery<{ tables: { id: number; name: string }[] }>({
|
||||
queryKey: ['/api/directories'],
|
||||
enabled: !!user && isAdminUser,
|
||||
});
|
||||
const directoriesList = directoriesData?.tables ?? [];
|
||||
|
||||
const createApiKeyMutation = useMutation({
|
||||
mutationFn: async (label: string) => {
|
||||
const res = await apiRequest('POST', '/api/mcp-keys', { label });
|
||||
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);
|
||||
setNewKeyLabel('');
|
||||
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';
|
||||
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>;
|
||||
};
|
||||
|
||||
// Мелкое описание ограничений: «Формы: N из M · Справочники: K из L» или «Все …»
|
||||
const scopeDetailsText = (scopes: ApiKeyScopes | null) => {
|
||||
const formsText = scopes?.formIds == null ? 'Все формы' : `Формы: ${scopes.formIds.length} из ${formsList.length}`;
|
||||
const tablesText = scopes?.tableIds == null ? 'Все справочники' : `Справочники: ${scopes.tableIds.length} из ${directoriesList.length}`;
|
||||
return `${formsText} · ${tablesText}`;
|
||||
};
|
||||
|
||||
const deleteApiKeyMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
return apiRequest('DELETE', `/api/mcp-keys/${id}`);
|
||||
@@ -1774,23 +1998,16 @@ const Settings = () => {
|
||||
<span className="text-sm font-medium">Создать API-ключ</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newKeyLabel}
|
||||
onChange={(e) => setNewKeyLabel(e.target.value)}
|
||||
placeholder="Название ключа (например: Claude Desktop)"
|
||||
className="h-7 text-xs flex-1"
|
||||
onKeyDown={(e) => e.key === 'Enter' && newKeyLabel.trim() && createApiKeyMutation.mutate(newKeyLabel.trim())}
|
||||
data-testid="input-key-label"
|
||||
/>
|
||||
<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"
|
||||
onClick={() => newKeyLabel.trim() && createApiKeyMutation.mutate(newKeyLabel.trim())}
|
||||
disabled={!newKeyLabel.trim() || createApiKeyMutation.isPending}
|
||||
className="h-7 px-2.5 text-xs gap-1.5 shrink-0"
|
||||
onClick={() => { setEditingKey(null); setKeyDialogOpen(true); }}
|
||||
data-testid="button-create-key"
|
||||
>
|
||||
{createApiKeyMutation.isPending ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Key className="w-3.5 h-3.5" />}
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Новый ключ
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1832,23 +2049,38 @@ const Settings = () => {
|
||||
{apiKeys.map((key) => (
|
||||
<div key={key.id} className="flex items-center justify-between p-2 rounded border border-border" data-testid={`api-key-row-${key.id}`}>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-xs font-medium">{key.label}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-xs font-medium">{key.label}</span>
|
||||
{scopeModeBadge(key.scopes)}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono">{key.keyPrefix}…</div>
|
||||
<div className="text-[10px] text-muted-foreground">{scopeDetailsText(key.scopes)}</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
Создан: {key.createdAt ? new Date(key.createdAt).toLocaleDateString('ru-RU') : '—'}
|
||||
{key.lastUsedAt && ` · Использован: ${new Date(key.lastUsedAt).toLocaleDateString('ru-RU')}`}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => deleteApiKeyMutation.mutate(key.id)}
|
||||
disabled={deleteApiKeyMutation.isPending}
|
||||
data-testid={`button-delete-key-${key.id}`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<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"
|
||||
className="h-7 px-2 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => deleteApiKeyMutation.mutate(key.id)}
|
||||
disabled={deleteApiKeyMutation.isPending}
|
||||
data-testid={`button-delete-key-${key.id}`}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -1856,6 +2088,17 @@ 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>
|
||||
|
||||
Reference in New Issue
Block a user