new file: .claude/settings.local.json
new file: .gitignore new file: backend/.env.example new file: backend/.gitignore new file: backend/ecosystem.config.js new file: backend/nodemon.json new file: backend/package-lock.json new file: backend/package.json new file: backend/src/config/database.ts new file: backend/src/config/dynamicSwagger.ts new file: backend/src/config/environment.ts new file: backend/src/config/swagger.ts new file: backend/src/controllers/apiKeyController.ts new file: backend/src/controllers/authController.ts new file: backend/src/controllers/databaseController.ts new file: backend/src/controllers/databaseManagementController.ts new file: backend/src/controllers/dynamicApiController.ts new file: backend/src/controllers/endpointController.ts new file: backend/src/controllers/folderController.ts new file: backend/src/controllers/logsController.ts new file: backend/src/controllers/userController.ts new file: backend/src/middleware/apiKey.ts new file: backend/src/middleware/auth.ts new file: backend/src/middleware/logging.ts new file: backend/src/migrations/001_initial_schema.sql new file: backend/src/migrations/002_add_logging.sql new file: backend/src/migrations/003_add_scripting.sql new file: backend/src/migrations/004_add_superadmin.sql new file: backend/src/migrations/run.ts new file: backend/src/migrations/seed.ts new file: backend/src/routes/apiKeys.ts new file: backend/src/routes/auth.ts new file: backend/src/routes/databaseManagement.ts new file: backend/src/routes/databases.ts new file: backend/src/routes/dynamic.ts new file: backend/src/routes/endpoints.ts new file: backend/src/routes/folders.ts new file: backend/src/routes/logs.ts new file: backend/src/routes/users.ts new file: backend/src/server.ts new file: backend/src/services/DatabasePoolManager.ts new file: backend/src/services/ScriptExecutor.ts new file: backend/src/services/SqlExecutor.ts new file: backend/src/types/index.ts new file: backend/tsconfig.json new file: frontend/.gitignore new file: frontend/index.html new file: frontend/nginx.conf new file: frontend/package-lock.json new file: frontend/package.json new file: frontend/postcss.config.js new file: frontend/src/App.tsx new file: frontend/src/components/CodeEditor.tsx
This commit is contained in:
571
frontend/src/pages/ApiKeys.tsx
Normal file
571
frontend/src/pages/ApiKeys.tsx
Normal file
@@ -0,0 +1,571 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiKeysApi, endpointsApi, foldersApi } from '@/services/api';
|
||||
import { Plus, Copy, Trash2, Eye, EyeOff, Edit2, Folder as FolderIcon, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { format } from 'date-fns';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export default function ApiKeys() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingApiKey, setEditingApiKey] = useState<any | null>(null);
|
||||
const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set());
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
});
|
||||
|
||||
const { data: apiKeys, isLoading } = useQuery({
|
||||
queryKey: ['apiKeys'],
|
||||
queryFn: () => apiKeysApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: endpoints } = useQuery({
|
||||
queryKey: ['endpoints'],
|
||||
queryFn: () => endpointsApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: folders } = useQuery({
|
||||
queryKey: ['folders'],
|
||||
queryFn: () => foldersApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiKeysApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
|
||||
toast.success('API ключ удален');
|
||||
},
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, is_active }: { id: string; is_active: boolean }) =>
|
||||
apiKeysApi.update(id, { is_active }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
|
||||
toast.success('API ключ обновлен');
|
||||
},
|
||||
});
|
||||
|
||||
const copyToClipboard = (key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
toast.success('API ключ скопирован в буфер обмена');
|
||||
};
|
||||
|
||||
const toggleReveal = (id: string) => {
|
||||
setRevealedKeys(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingApiKey(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (apiKey: any) => {
|
||||
setEditingApiKey(apiKey);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">API Ключи</h1>
|
||||
<p className="text-gray-600">Управление API ключами и правами доступа</p>
|
||||
</div>
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Сгенерировать API ключ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{apiKeys?.map((apiKey) => (
|
||||
<div key={apiKey.id} className="card p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-semibold text-gray-900">{apiKey.name}</h3>
|
||||
<span className={`px-3 py-1 text-xs font-semibold rounded ${
|
||||
apiKey.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{apiKey.is_active ? 'Активен' : 'Неактивен'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<code className="text-sm bg-gray-100 px-3 py-1 rounded text-gray-800 flex-1">
|
||||
{revealedKeys.has(apiKey.id) ? apiKey.key : '•'.repeat(40)}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => toggleReveal(apiKey.id)}
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
title={revealedKeys.has(apiKey.id) ? 'Скрыть' : 'Показать'}
|
||||
>
|
||||
{revealedKeys.has(apiKey.id) ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyToClipboard(apiKey.key)}
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
title="Копировать"
|
||||
>
|
||||
<Copy size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>Создан: {format(new Date(apiKey.created_at), 'PPP')}</p>
|
||||
{apiKey.expires_at && (
|
||||
<p>Истекает: {format(new Date(apiKey.expires_at), 'PPP')}</p>
|
||||
)}
|
||||
<p>Права: {apiKey.permissions.length === 0 ? 'Нет' :
|
||||
apiKey.permissions.includes('*') ? 'Все эндпоинты' :
|
||||
`${apiKey.permissions.filter((p: string) => !p.startsWith('folder:')).length} эндпоинт(ов), ${apiKey.permissions.filter((p: string) => p.startsWith('folder:')).length} папок`}</p>
|
||||
<p>Логгирование: {apiKey.enable_logging ? 'Включено' : 'Выключено'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(apiKey)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit2 size={18} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleMutation.mutate({ id: apiKey.id, is_active: !apiKey.is_active })}
|
||||
className={`btn ${apiKey.is_active ? 'btn-secondary' : 'btn-primary'}`}
|
||||
>
|
||||
{apiKey.is_active ? 'Деактивировать' : 'Активировать'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Удалить этот API ключ?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(apiKey.id);
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<ApiKeyModal
|
||||
apiKey={editingApiKey}
|
||||
endpoints={endpoints || []}
|
||||
folders={folders || []}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
type={dialog.type}
|
||||
onConfirm={dialog.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyModal({ apiKey, endpoints, folders, onClose }: { apiKey: any | null; endpoints: any[]; folders: any[]; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
name: apiKey?.name || '',
|
||||
permissions: apiKey?.permissions || [] as string[],
|
||||
expires_at: apiKey?.expires_at ? new Date(apiKey.expires_at).toISOString().slice(0, 16) : '',
|
||||
enable_logging: apiKey?.enable_logging || false,
|
||||
});
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiKey
|
||||
? apiKeysApi.update(apiKey.id, {
|
||||
name: formData.name,
|
||||
permissions: formData.permissions,
|
||||
expires_at: formData.expires_at || undefined,
|
||||
enable_logging: formData.enable_logging,
|
||||
})
|
||||
: apiKeysApi.create(formData.name, formData.permissions, formData.expires_at || undefined, formData.enable_logging),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
|
||||
toast.success(apiKey ? 'API ключ обновлен' : 'API ключ создан');
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const toggleFolder = (folderId: string) => {
|
||||
setExpandedFolders(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(folderId)) {
|
||||
newSet.delete(folderId);
|
||||
} else {
|
||||
newSet.add(folderId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const getAllDescendantEndpoints = (folderId: string): string[] => {
|
||||
const descendants: string[] = [];
|
||||
|
||||
// Get all endpoints in this folder
|
||||
const folderEndpoints = endpoints.filter(e => e.folder_id === folderId);
|
||||
descendants.push(...folderEndpoints.map(e => e.id));
|
||||
|
||||
// Get all subfolders
|
||||
const subfolders = folders.filter(f => f.parent_id === folderId);
|
||||
subfolders.forEach(subfolder => {
|
||||
descendants.push(...getAllDescendantEndpoints(subfolder.id));
|
||||
});
|
||||
|
||||
return descendants;
|
||||
};
|
||||
|
||||
const toggleFolderPermission = (folderId: string) => {
|
||||
const folderKey = `folder:${folderId}`;
|
||||
|
||||
setFormData(prev => {
|
||||
const newPermissions = [...prev.permissions];
|
||||
const hasFolder = newPermissions.includes(folderKey);
|
||||
|
||||
if (hasFolder) {
|
||||
// Remove folder permission
|
||||
return {
|
||||
...prev,
|
||||
permissions: newPermissions.filter(p => p !== folderKey),
|
||||
};
|
||||
} else {
|
||||
// Add folder permission and remove any individual endpoint permissions for this folder
|
||||
const descendantEndpoints = getAllDescendantEndpoints(folderId);
|
||||
return {
|
||||
...prev,
|
||||
permissions: [
|
||||
...newPermissions.filter(p => !descendantEndpoints.includes(p)),
|
||||
folderKey,
|
||||
],
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const togglePermission = (endpointId: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
permissions: prev.permissions.includes(endpointId)
|
||||
? prev.permissions.filter((id: string) => id !== endpointId)
|
||||
: [...prev.permissions, endpointId],
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleAllPermissions = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
permissions: prev.permissions.includes('*') ? [] : ['*'],
|
||||
}));
|
||||
};
|
||||
|
||||
// Build folder tree
|
||||
const buildTree = () => {
|
||||
const folderMap = new Map(folders.map(f => [f.id, { ...f, children: [], endpoints: [] }]));
|
||||
const tree: any[] = [];
|
||||
|
||||
// Group folders by parent_id
|
||||
folders.forEach(folder => {
|
||||
const node = folderMap.get(folder.id)!;
|
||||
if (folder.parent_id && folderMap.has(folder.parent_id)) {
|
||||
folderMap.get(folder.parent_id)!.children.push(node);
|
||||
} else {
|
||||
tree.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// Add endpoints to folders
|
||||
endpoints.forEach(endpoint => {
|
||||
if (endpoint.folder_id && folderMap.has(endpoint.folder_id)) {
|
||||
folderMap.get(endpoint.folder_id)!.endpoints.push(endpoint);
|
||||
}
|
||||
});
|
||||
|
||||
return tree;
|
||||
};
|
||||
|
||||
// Check if endpoint should appear checked (either directly or via folder permission)
|
||||
const isEndpointChecked = (endpointId: string, folderId?: string): boolean => {
|
||||
if (formData.permissions.includes('*')) return true;
|
||||
if (formData.permissions.includes(endpointId)) return true;
|
||||
|
||||
// Check if any parent folder has permission
|
||||
if (folderId) {
|
||||
let currentFolderId: string | null | undefined = folderId;
|
||||
while (currentFolderId) {
|
||||
if (formData.permissions.includes(`folder:${currentFolderId}`)) return true;
|
||||
const folder = folders.find(f => f.id === currentFolderId);
|
||||
currentFolderId = folder?.parent_id;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check if folder should appear checked
|
||||
const isFolderChecked = (folderId: string): boolean => {
|
||||
if (formData.permissions.includes('*')) return true;
|
||||
return formData.permissions.includes(`folder:${folderId}`);
|
||||
};
|
||||
|
||||
const tree = buildTree();
|
||||
const rootEndpoints = endpoints.filter(e => !e.folder_id);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{apiKey ? 'Редактировать API ключ' : 'Сгенерировать API ключ'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); saveMutation.mutate(); }} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Название ключа</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Мой API ключ"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Истекает (необязательно)</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.expires_at}
|
||||
onChange={(e) => setFormData({ ...formData, expires_at: e.target.value })}
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Права доступа</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllPermissions}
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{formData.permissions.includes('*') ? 'Снять все' : 'Выбрать все'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-gray-300 rounded-lg p-4 max-h-96 overflow-y-auto">
|
||||
{/* Root folders */}
|
||||
{tree.map((folder) => (
|
||||
<PermissionTreeNode
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
level={0}
|
||||
expandedFolders={expandedFolders}
|
||||
isFolderChecked={isFolderChecked}
|
||||
isEndpointChecked={isEndpointChecked}
|
||||
toggleFolder={toggleFolder}
|
||||
toggleFolderPermission={toggleFolderPermission}
|
||||
togglePermission={togglePermission}
|
||||
disabled={formData.permissions.includes('*')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Root endpoints (without folder) */}
|
||||
{rootEndpoints.map((endpoint) => (
|
||||
<PermissionEndpointNode
|
||||
key={endpoint.id}
|
||||
endpoint={endpoint}
|
||||
level={0}
|
||||
isChecked={isEndpointChecked(endpoint.id)}
|
||||
togglePermission={togglePermission}
|
||||
disabled={formData.permissions.includes('*')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tree.length === 0 && rootEndpoints.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 text-sm">
|
||||
Нет доступных папок и эндпоинтов
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.enable_logging}
|
||||
onChange={(e) => setFormData({ ...formData, enable_logging: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Логгировать запросы</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">
|
||||
{saveMutation.isPending ? 'Сохранение...' : (apiKey ? 'Сохранить изменения' : 'Сгенерировать ключ')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionTreeNode({
|
||||
folder,
|
||||
level,
|
||||
expandedFolders,
|
||||
isFolderChecked,
|
||||
isEndpointChecked,
|
||||
toggleFolder,
|
||||
toggleFolderPermission,
|
||||
togglePermission,
|
||||
disabled,
|
||||
}: any) {
|
||||
const isExpanded = expandedFolders.has(folder.id);
|
||||
const hasChildren = folder.children.length > 0 || folder.endpoints.length > 0;
|
||||
const checked = isFolderChecked(folder.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-2 hover:bg-gray-50 rounded transition-colors"
|
||||
style={{ paddingLeft: `${level * 20}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
className="p-0.5 hover:bg-gray-200 rounded flex-shrink-0"
|
||||
type="button"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-5" />
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleFolderPermission(folder.id)}
|
||||
disabled={disabled}
|
||||
className="rounded flex-shrink-0"
|
||||
/>
|
||||
<FolderIcon size={16} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-900">{folder.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
({folder.endpoints.length} эндпоинт(ов))
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{/* Subfolders */}
|
||||
{folder.children.map((child: any) => (
|
||||
<PermissionTreeNode
|
||||
key={child.id}
|
||||
folder={child}
|
||||
level={level + 1}
|
||||
expandedFolders={expandedFolders}
|
||||
isFolderChecked={isFolderChecked}
|
||||
isEndpointChecked={isEndpointChecked}
|
||||
toggleFolder={toggleFolder}
|
||||
toggleFolderPermission={toggleFolderPermission}
|
||||
togglePermission={togglePermission}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Endpoints in this folder */}
|
||||
{folder.endpoints.map((endpoint: any) => (
|
||||
<PermissionEndpointNode
|
||||
key={endpoint.id}
|
||||
endpoint={endpoint}
|
||||
level={level + 1}
|
||||
isChecked={isEndpointChecked(endpoint.id, folder.id)}
|
||||
togglePermission={togglePermission}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionEndpointNode({
|
||||
endpoint,
|
||||
level,
|
||||
isChecked,
|
||||
togglePermission,
|
||||
disabled,
|
||||
}: any) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 py-2 hover:bg-gray-50 rounded transition-colors"
|
||||
style={{ paddingLeft: `${level * 20 + 24}px` }}
|
||||
>
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => togglePermission(endpoint.id)}
|
||||
disabled={disabled}
|
||||
className="rounded flex-shrink-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{endpoint.name} ({endpoint.method} {endpoint.path})
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
frontend/src/pages/Dashboard.tsx
Normal file
119
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { endpointsApi, foldersApi, apiKeysApi } from '@/services/api';
|
||||
import { FileCode, Folder, Key, Database } from 'lucide-react';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: endpoints } = useQuery({
|
||||
queryKey: ['endpoints'],
|
||||
queryFn: () => endpointsApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: folders } = useQuery({
|
||||
queryKey: ['folders'],
|
||||
queryFn: () => foldersApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: apiKeys } = useQuery({
|
||||
queryKey: ['apiKeys'],
|
||||
queryFn: () => apiKeysApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Всего эндпоинтов',
|
||||
value: endpoints?.length || 0,
|
||||
icon: FileCode,
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
label: 'Папки',
|
||||
value: folders?.length || 0,
|
||||
icon: Folder,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
label: 'API Ключи',
|
||||
value: apiKeys?.length || 0,
|
||||
icon: Key,
|
||||
color: 'bg-purple-500',
|
||||
},
|
||||
{
|
||||
label: 'Активные ключи',
|
||||
value: apiKeys?.filter(k => k.is_active).length || 0,
|
||||
icon: Database,
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Панель управления</h1>
|
||||
<p className="text-gray-600">Обзор системы KIS API Builder</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`${stat.color} p-3 rounded-lg text-white`}>
|
||||
<stat.icon size={24} />
|
||||
</div>
|
||||
<span className="text-3xl font-bold text-gray-900">{stat.value}</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-600">{stat.label}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="card p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Последние эндпоинты</h2>
|
||||
<div className="space-y-3">
|
||||
{endpoints?.slice(0, 5).map((endpoint) => (
|
||||
<div key={endpoint.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{endpoint.name}</p>
|
||||
<p className="text-sm text-gray-500">{endpoint.path}</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 text-xs font-semibold rounded ${
|
||||
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
|
||||
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||||
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(!endpoints || endpoints.length === 0) && (
|
||||
<p className="text-gray-500 text-sm text-center py-4">Нет созданных эндпоинтов</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Быстрые действия</h2>
|
||||
<div className="space-y-3">
|
||||
<a href="/endpoints" className="block p-4 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors">
|
||||
<h3 className="font-semibold text-primary-700">Создать новый эндпоинт</h3>
|
||||
<p className="text-sm text-primary-600">Создайте новый API эндпоинт с SQL запросом</p>
|
||||
</a>
|
||||
<a href="/api-keys" className="block p-4 bg-green-50 hover:bg-green-100 rounded-lg transition-colors">
|
||||
<h3 className="font-semibold text-green-700">Сгенерировать API ключ</h3>
|
||||
<p className="text-sm text-green-600">Создайте новый API ключ для внешних систем</p>
|
||||
</a>
|
||||
<a href="http://localhost:3000/api-docs" target="_blank" rel="noopener noreferrer" className="block p-4 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors">
|
||||
<h3 className="font-semibold text-blue-700">📚 Swagger документация</h3>
|
||||
<p className="text-sm text-blue-600">Документация API для пользователей с API ключами</p>
|
||||
</a>
|
||||
<a href="/settings" className="block p-4 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors">
|
||||
<h3 className="font-semibold text-purple-700">Настройки системы</h3>
|
||||
<p className="text-sm text-purple-600">Управление профилем и глобальными настройками</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
377
frontend/src/pages/Databases.tsx
Normal file
377
frontend/src/pages/Databases.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { dbManagementApi } from '@/services/api';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { Database, Plus, Edit2, Trash2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export default function Databases() {
|
||||
const { user } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingDatabase, setEditingDatabase] = useState<any | null>(null);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
});
|
||||
|
||||
const { data: databases, isLoading } = useQuery({
|
||||
queryKey: ['databases'],
|
||||
queryFn: () => dbManagementApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => dbManagementApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||
toast.success('База данных удалена');
|
||||
},
|
||||
onError: () => toast.error('Не удалось удалить базу данных'),
|
||||
});
|
||||
|
||||
const testConnection = async (databaseId: string) => {
|
||||
try {
|
||||
const { data } = await dbManagementApi.test(databaseId);
|
||||
if (data.success) {
|
||||
toast.success('Подключение успешно!');
|
||||
} else {
|
||||
toast.error('Ошибка подключения');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Ошибка тестирования подключения');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (database: any) => {
|
||||
setEditingDatabase(database);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingDatabase(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Вы уверены, что хотите удалить эту базу данных?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Базы данных</h1>
|
||||
<p className="text-gray-600">Управление подключениями к базам данных</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Добавить базу данных
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{databases?.map((db) => (
|
||||
<div key={db.id} className="card p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary-100 p-3 rounded-lg">
|
||||
<Database size={24} className="text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">{db.name}</h3>
|
||||
<p className="text-sm text-gray-500">{db.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(db)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit2 size={18} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(db.id)}
|
||||
className="p-2 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={18} className="text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Хост:</span>
|
||||
<span className="font-medium">{db.host}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Порт:</span>
|
||||
<span className="font-medium">{db.port}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">База данных:</span>
|
||||
<span className="font-medium">{db.database_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Пользователь:</span>
|
||||
<span className="font-medium">{db.username}</span>
|
||||
</div>
|
||||
{db.is_active !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Статус:</span>
|
||||
<span className={`font-medium ${db.is_active ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{db.is_active ? 'Активна' : 'Неактивна'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => testConnection(db.id)}
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
Тест подключения
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{databases?.length === 0 && (
|
||||
<div className="col-span-2 text-center py-12">
|
||||
<p className="text-gray-500">
|
||||
{isAdmin
|
||||
? 'Базы данных не настроены. Добавьте первую базу данных.'
|
||||
: 'Базы данных не настроены.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && !isLoading && databases && databases.length > 0 && (
|
||||
<div className="card p-6 mt-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Информация</h2>
|
||||
<p className="text-gray-600">
|
||||
Базы данных управляются через интерфейс администратора.
|
||||
Вы можете добавлять, редактировать и удалять подключения к базам данных.
|
||||
Каждый эндпоинт использует одно из этих подключений для выполнения SQL запросов.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<DatabaseModal
|
||||
database={editingDatabase}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
type={dialog.type}
|
||||
onConfirm={dialog.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DatabaseModal({
|
||||
database,
|
||||
onClose,
|
||||
}: {
|
||||
database: any | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
name: database?.name || '',
|
||||
type: database?.type || 'postgresql',
|
||||
host: database?.host || 'localhost',
|
||||
port: database?.port || 5432,
|
||||
database_name: database?.database_name || '',
|
||||
username: database?.username || '',
|
||||
password: database?.password || '',
|
||||
ssl: database?.ssl || false,
|
||||
is_active: database?.is_active !== undefined ? database.is_active : true,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
database ? dbManagementApi.update(database.id, data) : dbManagementApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||
toast.success(database ? 'База данных обновлена' : 'База данных создана');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Не удалось сохранить базу данных');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{database ? 'Редактировать базу данных' : 'Добавить базу данных'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Основная база PostgreSQL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Тип</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="postgresql">PostgreSQL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Порт</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
value={formData.port}
|
||||
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) })}
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Хост</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.host}
|
||||
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="localhost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Имя базы данных</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.database_name}
|
||||
onChange={(e) => setFormData({ ...formData, database_name: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="my_database"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Пользователь</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="postgres"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
required={!database}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder={database ? '••••••••' : 'Введите пароль'}
|
||||
/>
|
||||
{database && (
|
||||
<p className="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы не менять пароль</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.ssl}
|
||||
onChange={(e) => setFormData({ ...formData, ssl: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Использовать SSL</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Активна</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">
|
||||
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
frontend/src/pages/Endpoints.tsx
Normal file
186
frontend/src/pages/Endpoints.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { endpointsApi, databasesApi } from '@/services/api';
|
||||
import { Endpoint } from '@/types';
|
||||
import { Plus, Search, Edit2, Trash2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import EndpointModal from '@/components/EndpointModal';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export default function Endpoints() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingEndpoint, setEditingEndpoint] = useState<Endpoint | null>(null);
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
});
|
||||
|
||||
const { data: endpoints, isLoading } = useQuery({
|
||||
queryKey: ['endpoints', search],
|
||||
queryFn: () => endpointsApi.getAll(search).then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: databases } = useQuery({
|
||||
queryKey: ['databases'],
|
||||
queryFn: () => databasesApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => endpointsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
|
||||
toast.success('Эндпоинт успешно удален');
|
||||
},
|
||||
onError: () => toast.error('Не удалось удалить эндпоинт'),
|
||||
});
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Вы уверены, что хотите удалить этот эндпоинт?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (endpoint: Endpoint) => {
|
||||
setEditingEndpoint(endpoint);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingEndpoint(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">API Эндпоинты</h1>
|
||||
<p className="text-gray-600">Управление динамическими API эндпоинтами</p>
|
||||
</div>
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Новый эндпоинт
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Search size={20} className="text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск эндпоинтов по имени, пути или SQL запросу..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Загрузка эндпоинтов...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{endpoints?.map((endpoint) => (
|
||||
<div key={endpoint.id} className="card p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-semibold text-gray-900">{endpoint.name}</h3>
|
||||
<span className={`px-3 py-1 text-xs font-semibold rounded ${
|
||||
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
|
||||
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||||
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
{endpoint.is_public && (
|
||||
<span className="px-3 py-1 text-xs font-semibold rounded bg-purple-100 text-purple-700">
|
||||
Публичный
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 mb-2">{endpoint.description}</p>
|
||||
<code className="text-sm bg-gray-100 px-3 py-1 rounded text-gray-800">
|
||||
{endpoint.path}
|
||||
</code>
|
||||
{endpoint.folder_name && (
|
||||
<span className="ml-2 text-sm text-gray-500">📁 {endpoint.folder_name}</span>
|
||||
)}
|
||||
{endpoint.parameters && endpoint.parameters.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<span className="text-xs text-gray-500">Параметры: </span>
|
||||
{endpoint.parameters.map((param: any, idx: number) => (
|
||||
<span key={idx} className="inline-block text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded mr-1 mb-1">
|
||||
{param.name} ({param.type}){param.required && '*'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(endpoint)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit2 size={18} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(endpoint.id)}
|
||||
className="p-2 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={18} className="text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{endpoints?.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">Эндпоинты не найдены. Создайте первый эндпоинт!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<EndpointModal
|
||||
endpoint={editingEndpoint}
|
||||
databases={databases || []}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
type={dialog.type}
|
||||
onConfirm={dialog.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
488
frontend/src/pages/Folders.tsx
Normal file
488
frontend/src/pages/Folders.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { foldersApi, endpointsApi, databasesApi } from '@/services/api';
|
||||
import { Folder, Endpoint } from '@/types';
|
||||
import { Plus, Edit2, Trash2, Folder as FolderIcon, FolderOpen, FileCode, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import EndpointModal from '@/components/EndpointModal';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export default function Folders() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showFolderModal, setShowFolderModal] = useState(false);
|
||||
const [showEndpointModal, setShowEndpointModal] = useState(false);
|
||||
const [editingFolder, setEditingFolder] = useState<Folder | null>(null);
|
||||
const [editingEndpoint, setEditingEndpoint] = useState<Endpoint | null>(null);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
});
|
||||
|
||||
const { data: folders, isLoading: foldersLoading } = useQuery({
|
||||
queryKey: ['folders'],
|
||||
queryFn: () => foldersApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: endpoints, isLoading: endpointsLoading } = useQuery({
|
||||
queryKey: ['endpoints'],
|
||||
queryFn: () => endpointsApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: databases } = useQuery({
|
||||
queryKey: ['databases'],
|
||||
queryFn: () => databasesApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteFolderMutation = useMutation({
|
||||
mutationFn: (id: string) => foldersApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
|
||||
toast.success('Папка удалена');
|
||||
},
|
||||
onError: () => toast.error('Ошибка удаления папки'),
|
||||
});
|
||||
|
||||
const deleteEndpointMutation = useMutation({
|
||||
mutationFn: (id: string) => endpointsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
|
||||
toast.success('Эндпоинт удален');
|
||||
},
|
||||
onError: () => toast.error('Ошибка удаления эндпоинта'),
|
||||
});
|
||||
|
||||
const handleCreateFolder = (parentId?: string) => {
|
||||
setSelectedFolderId(parentId || null);
|
||||
setEditingFolder(null);
|
||||
setShowFolderModal(true);
|
||||
};
|
||||
|
||||
const handleEditFolder = (folder: Folder) => {
|
||||
setEditingFolder(folder);
|
||||
setShowFolderModal(true);
|
||||
};
|
||||
|
||||
const handleCreateEndpoint = (folderId?: string) => {
|
||||
setSelectedFolderId(folderId || null);
|
||||
setEditingEndpoint(null);
|
||||
setShowEndpointModal(true);
|
||||
};
|
||||
|
||||
const handleEditEndpoint = (endpoint: Endpoint) => {
|
||||
setEditingEndpoint(endpoint);
|
||||
setShowEndpointModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteFolder = (id: string) => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Удалить папку? Все вложенные папки и эндпоинты будут перемещены в корень.',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteFolderMutation.mutate(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteEndpoint = (id: string) => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Удалить этот эндпоинт?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteEndpointMutation.mutate(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleFolder = (folderId: string) => {
|
||||
setExpandedFolders(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(folderId)) {
|
||||
newSet.delete(folderId);
|
||||
} else {
|
||||
newSet.add(folderId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Построение дерева папок
|
||||
const buildTree = () => {
|
||||
if (!folders || !endpoints) return [];
|
||||
|
||||
const folderMap = new Map(folders.map(f => [f.id, { ...f, children: [], endpoints: [] }]));
|
||||
const tree: any[] = [];
|
||||
|
||||
// Группируем папки по parent_id
|
||||
folders.forEach(folder => {
|
||||
const node: any = folderMap.get(folder.id)!;
|
||||
if (folder.parent_id && folderMap.has(folder.parent_id)) {
|
||||
(folderMap.get(folder.parent_id) as any)!.children.push(node);
|
||||
} else {
|
||||
tree.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// Добавляем эндпоинты в папки
|
||||
endpoints.forEach(endpoint => {
|
||||
if (endpoint.folder_id && folderMap.has(endpoint.folder_id)) {
|
||||
(folderMap.get(endpoint.folder_id) as any)!.endpoints.push(endpoint);
|
||||
}
|
||||
});
|
||||
|
||||
return tree;
|
||||
};
|
||||
|
||||
// Эндпоинты без папки
|
||||
const rootEndpoints = endpoints?.filter(e => !e.folder_id) || [];
|
||||
|
||||
const tree = buildTree();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Структура проекта</h1>
|
||||
<p className="text-gray-600">Древовидное представление папок и эндпоинтов</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleCreateEndpoint()} className="btn btn-secondary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Новый эндпоинт
|
||||
</button>
|
||||
<button onClick={() => handleCreateFolder()} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Новая папка
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{foldersLoading || endpointsLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Загрузка...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card p-6">
|
||||
<div className="space-y-1">
|
||||
{/* Корневые папки */}
|
||||
{tree.map(folder => (
|
||||
<TreeNode
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
level={0}
|
||||
expandedFolders={expandedFolders}
|
||||
onToggle={toggleFolder}
|
||||
onEditFolder={handleEditFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onCreateSubfolder={handleCreateFolder}
|
||||
onCreateEndpoint={handleCreateEndpoint}
|
||||
onEditEndpoint={handleEditEndpoint}
|
||||
onDeleteEndpoint={handleDeleteEndpoint}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Корневые эндпоинты (без папки) */}
|
||||
{rootEndpoints.map(endpoint => (
|
||||
<EndpointNode
|
||||
key={endpoint.id}
|
||||
endpoint={endpoint}
|
||||
level={0}
|
||||
onEdit={handleEditEndpoint}
|
||||
onDelete={handleDeleteEndpoint}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tree.length === 0 && rootEndpoints.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>Нет папок и эндпоинтов.</p>
|
||||
<p className="text-sm mt-2">Создайте первую папку или эндпоинт!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFolderModal && (
|
||||
<FolderModal
|
||||
folder={editingFolder}
|
||||
parentId={selectedFolderId}
|
||||
folders={folders || []}
|
||||
onClose={() => setShowFolderModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEndpointModal && (
|
||||
<EndpointModal
|
||||
endpoint={editingEndpoint}
|
||||
folderId={selectedFolderId}
|
||||
databases={databases || []}
|
||||
onClose={() => setShowEndpointModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
type={dialog.type}
|
||||
onConfirm={dialog.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
folder,
|
||||
level,
|
||||
expandedFolders,
|
||||
onToggle,
|
||||
onEditFolder,
|
||||
onDeleteFolder,
|
||||
onCreateSubfolder,
|
||||
onCreateEndpoint,
|
||||
onEditEndpoint,
|
||||
onDeleteEndpoint,
|
||||
}: any) {
|
||||
const isExpanded = expandedFolders.has(folder.id);
|
||||
const hasChildren = folder.children.length > 0 || folder.endpoints.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 transition-colors group`}
|
||||
style={{ paddingLeft: `${level * 24 + 12}px` }}
|
||||
>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={() => onToggle(folder.id)}
|
||||
className="p-0.5 hover:bg-gray-200 rounded"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
)}
|
||||
{!hasChildren && <div className="w-5" />}
|
||||
|
||||
{isExpanded ? (
|
||||
<FolderOpen size={18} className="text-yellow-600 flex-shrink-0" />
|
||||
) : (
|
||||
<FolderIcon size={18} className="text-yellow-600 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<span className="font-medium text-gray-900 flex-1">{folder.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{folder.endpoints.length} эндпоинт(ов)
|
||||
</span>
|
||||
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onCreateEndpoint(folder.id)}
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
title="Добавить эндпоинт"
|
||||
>
|
||||
<Plus size={14} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onCreateSubfolder(folder.id)}
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
title="Добавить подпапку"
|
||||
>
|
||||
<FolderIcon size={14} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEditFolder(folder)}
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit2 size={14} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteFolder(folder.id)}
|
||||
className="p-1.5 hover:bg-red-100 rounded"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={14} className="text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{/* Подпапки */}
|
||||
{folder.children.map((child: any) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
folder={child}
|
||||
level={level + 1}
|
||||
expandedFolders={expandedFolders}
|
||||
onToggle={onToggle}
|
||||
onEditFolder={onEditFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onCreateSubfolder={onCreateSubfolder}
|
||||
onCreateEndpoint={onCreateEndpoint}
|
||||
onEditEndpoint={onEditEndpoint}
|
||||
onDeleteEndpoint={onDeleteEndpoint}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Эндпоинты в папке */}
|
||||
{folder.endpoints.map((endpoint: Endpoint) => (
|
||||
<EndpointNode
|
||||
key={endpoint.id}
|
||||
endpoint={endpoint}
|
||||
level={level + 1}
|
||||
onEdit={onEditEndpoint}
|
||||
onDelete={onDeleteEndpoint}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EndpointNode({ endpoint, level, onEdit, onDelete }: any) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 transition-colors group`}
|
||||
style={{ paddingLeft: `${level * 24 + 36}px` }}
|
||||
>
|
||||
<FileCode size={16} className="text-blue-600 flex-shrink-0" />
|
||||
<span className="text-sm text-gray-900 flex-1">{endpoint.name}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
|
||||
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
|
||||
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||||
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
<code className="text-xs text-gray-600">{endpoint.path}</code>
|
||||
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onEdit(endpoint)}
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit2 size={14} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(endpoint.id)}
|
||||
className="p-1.5 hover:bg-red-100 rounded"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={14} className="text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderModal({
|
||||
folder,
|
||||
parentId,
|
||||
folders,
|
||||
onClose,
|
||||
}: {
|
||||
folder: Folder | null;
|
||||
parentId: string | null;
|
||||
folders: Folder[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
name: folder?.name || '',
|
||||
parent_id: folder?.parent_id || parentId || '',
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
folder
|
||||
? foldersApi.update(folder.id, data.name, data.parent_id || undefined)
|
||||
: foldersApi.create(data.name, data.parent_id || undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] });
|
||||
toast.success(folder ? 'Папка обновлена' : 'Папка создана');
|
||||
onClose();
|
||||
},
|
||||
onError: () => toast.error('Ошибка сохранения папки'),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const availableFolders = folders.filter(f => f.id !== folder?.id);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-md w-full">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{folder ? 'Редактировать папку' : 'Создать папку'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Название папки
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Название папки"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Родительская папка
|
||||
</label>
|
||||
<select
|
||||
value={formData.parent_id}
|
||||
onChange={(e) => setFormData({ ...formData, parent_id: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="">Корневая папка</option>
|
||||
{availableFolders.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary flex-1">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary flex-1">
|
||||
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/pages/Login.tsx
Normal file
88
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { authApi } from '@/services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { LogIn } from 'lucide-react';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { data } = await authApi.login(formData.username, formData.password);
|
||||
setAuth(data.user, data.token);
|
||||
toast.success('Вход выполнен успешно!');
|
||||
navigate('/');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Ошибка входа');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100">
|
||||
<div className="card w-full max-w-md p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-primary-600 mb-2">KIS API Builder</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Логин
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Введите логин"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Введите пароль"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
{loading ? 'Вход...' : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 text-center text-sm text-gray-600">
|
||||
<p>Первый вход: admin / admin</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Смените пароль после входа</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
342
frontend/src/pages/Logs.tsx
Normal file
342
frontend/src/pages/Logs.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { logsApi, endpointsApi, apiKeysApi } from '@/services/api';
|
||||
import { Trash2, Eye, Filter, X } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { format } from 'date-fns';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export default function Logs() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedLog, setSelectedLog] = useState<any | null>(null);
|
||||
const [filters, setFilters] = useState({
|
||||
endpoint_id: '',
|
||||
api_key_id: '',
|
||||
});
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
});
|
||||
|
||||
const { data: logs, isLoading } = useQuery({
|
||||
queryKey: ['logs', filters],
|
||||
queryFn: () => logsApi.getAll(filters).then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: endpoints } = useQuery({
|
||||
queryKey: ['endpoints'],
|
||||
queryFn: () => endpointsApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: apiKeys } = useQuery({
|
||||
queryKey: ['apiKeys'],
|
||||
queryFn: () => apiKeysApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => logsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
toast.success('Лог удален');
|
||||
},
|
||||
onError: () => toast.error('Ошибка удаления лога'),
|
||||
});
|
||||
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: (data: any) => logsApi.clear(data),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
toast.success(`Удалено ${response.data.deleted} лог(ов)`);
|
||||
},
|
||||
onError: () => toast.error('Ошибка очистки логов'),
|
||||
});
|
||||
|
||||
const handleClearAll = () => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Вы уверены, что хотите очистить все логи?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
clearMutation.mutate({});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearFiltered = () => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Вы уверены, что хотите очистить логи с текущими фильтрами?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
clearMutation.mutate(filters);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Логи запросов</h1>
|
||||
<p className="text-gray-600">История запросов к API эндпоинтам</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{Object.values(filters).some(v => v) && (
|
||||
<button onClick={handleClearFiltered} className="btn btn-secondary">
|
||||
Очистить отфильтрованные
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleClearAll} className="btn btn-danger">
|
||||
Очистить все логи
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Filter size={20} className="text-gray-400" />
|
||||
<h3 className="font-semibold">Фильтры</h3>
|
||||
{Object.values(filters).some(v => v) && (
|
||||
<button
|
||||
onClick={() => setFilters({ endpoint_id: '', api_key_id: '' })}
|
||||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||
>
|
||||
<X size={16} />
|
||||
Сбросить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Эндпоинт</label>
|
||||
<select
|
||||
value={filters.endpoint_id}
|
||||
onChange={(e) => setFilters({ ...filters, endpoint_id: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="">Все эндпоинты</option>
|
||||
{endpoints?.map((ep) => (
|
||||
<option key={ep.id} value={ep.id}>{ep.name} ({ep.method} {ep.path})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Ключ</label>
|
||||
<select
|
||||
value={filters.api_key_id}
|
||||
onChange={(e) => setFilters({ ...filters, api_key_id: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="">Все ключи</option>
|
||||
{apiKeys?.map((key) => (
|
||||
<option key={key.id} value={key.id}>{key.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{logs?.map((log: any) => (
|
||||
<div key={log.id} className="card p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className={`px-2 py-1 text-xs font-semibold rounded ${
|
||||
log.response_status >= 200 && log.response_status < 300
|
||||
? 'bg-green-100 text-green-700'
|
||||
: log.response_status >= 400
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{log.response_status}
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900">{log.method}</span>
|
||||
<span className="text-gray-600">{log.path}</span>
|
||||
{log.endpoint_name && (
|
||||
<span className="text-sm text-gray-500">→ {log.endpoint_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span>⏱ {log.execution_time}мс</span>
|
||||
<span>📅 {format(new Date(log.created_at), 'dd.MM.yyyy HH:mm:ss')}</span>
|
||||
{log.api_key_name && (
|
||||
<span>🔑 {log.api_key_name}</span>
|
||||
)}
|
||||
{log.ip_address && log.ip_address !== 'unknown' && (
|
||||
<span>📍 {log.ip_address}</span>
|
||||
)}
|
||||
</div>
|
||||
{log.error_message && (
|
||||
<div className="mt-2 p-2 bg-red-50 rounded text-sm text-red-700">
|
||||
❌ {log.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Детали"
|
||||
>
|
||||
<Eye size={18} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Удалить этот лог?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(log.id);
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="p-2 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={18} className="text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{logs?.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">Логи не найдены</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog && (
|
||||
<LogDetailModal log={selectedLog} onClose={() => setSelectedLog(null)} />
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
type={dialog.type}
|
||||
onConfirm={dialog.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogDetailModal({ log, onClose }: { log: any; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Детали лога</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Метод</label>
|
||||
<p className="text-gray-900">{log.method}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Путь</label>
|
||||
<p className="text-gray-900">{log.path}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Статус</label>
|
||||
<p className="text-gray-900">{log.response_status}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Время выполнения</label>
|
||||
<p className="text-gray-900">{log.execution_time}мс</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Эндпоинт</label>
|
||||
<p className="text-gray-900">{log.endpoint_name || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">API Ключ</label>
|
||||
<p className="text-gray-900">{log.api_key_name || 'Без ключа'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">IP адрес</label>
|
||||
<p className="text-gray-900">{log.ip_address}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Дата/время</label>
|
||||
<p className="text-gray-900">{format(new Date(log.created_at), 'dd.MM.yyyy HH:mm:ss')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{log.request_params && Object.keys(log.request_params).length > 0 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-2 block">Параметры запроса</label>
|
||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{JSON.stringify(log.request_params, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.request_body && Object.keys(log.request_body).length > 0 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-2 block">Тело запроса</label>
|
||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{JSON.stringify(log.request_body, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-2 block">Ответ</label>
|
||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{JSON.stringify(log.response_data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{log.error_message && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-red-700 mb-2 block">Ошибка</label>
|
||||
<div className="bg-red-50 border border-red-200 rounded p-4 text-red-700">
|
||||
{log.error_message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.user_agent && log.user_agent !== 'unknown' && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">User Agent</label>
|
||||
<p className="text-sm text-gray-600">{log.user_agent}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-200">
|
||||
<button onClick={onClose} className="btn btn-secondary">
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
868
frontend/src/pages/Settings.tsx
Normal file
868
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,868 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { usersApi, dbManagementApi } from '@/services/api';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import toast from 'react-hot-toast';
|
||||
import { User, Lock, UserCircle, Database, Plus, Edit2, Trash2, Eye, EyeOff, Users } from 'lucide-react';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export default function Settings() {
|
||||
const { user } = useAuthStore();
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'global'>('profile');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Настройки</h1>
|
||||
<p className="text-gray-600">Управление профилем и настройками</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-4 px-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`py-4 px-2 border-b-2 font-medium transition-colors ${
|
||||
activeTab === 'profile'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<User className="inline mr-2" size={18} />
|
||||
Профиль
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('password')}
|
||||
className={`py-4 px-2 border-b-2 font-medium transition-colors ${
|
||||
activeTab === 'password'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Lock className="inline mr-2" size={18} />
|
||||
Смена пароля
|
||||
</button>
|
||||
{user?.is_superadmin && (
|
||||
<button
|
||||
onClick={() => setActiveTab('global')}
|
||||
className={`py-4 px-2 border-b-2 font-medium transition-colors ${
|
||||
activeTab === 'global'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Database className="inline mr-2" size={18} />
|
||||
Глобальные настройки
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === 'profile' && user && <ProfileTab currentUser={user} />}
|
||||
{activeTab === 'password' && user && <PasswordTab currentUser={user} />}
|
||||
{activeTab === 'global' && user?.is_superadmin && <GlobalSettingsTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileTab({ currentUser }: { currentUser: any }) {
|
||||
const { setUser } = useAuthStore();
|
||||
const [formData, setFormData] = useState({
|
||||
username: currentUser?.username || '',
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => usersApi.update(currentUser?.id, data),
|
||||
onSuccess: (response) => {
|
||||
toast.success('Профиль обновлен');
|
||||
setUser(response.data);
|
||||
},
|
||||
onError: () => toast.error('Ошибка обновления профиля'),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateMutation.mutate(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="bg-primary-100 p-4 rounded-full">
|
||||
<UserCircle size={48} className="text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">{currentUser?.username}</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{currentUser?.is_superadmin ? 'Супер-администратор' : currentUser?.role === 'admin' ? 'Администратор' : 'Пользователь'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<User className="inline mr-2" size={16} />
|
||||
Логин
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Логин"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{updateMutation.isPending ? 'Сохранение...' : 'Сохранить изменения'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordTab({ currentUser }: { currentUser: any }) {
|
||||
const [formData, setFormData] = useState({
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => usersApi.update(currentUser?.id, data),
|
||||
onSuccess: () => {
|
||||
toast.success('Пароль изменен');
|
||||
setFormData({ newPassword: '', confirmPassword: '' });
|
||||
},
|
||||
onError: () => toast.error('Ошибка смены пароля'),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (formData.newPassword !== formData.confirmPassword) {
|
||||
toast.error('Пароли не совпадают');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.newPassword.length < 4) {
|
||||
toast.error('Пароль должен быть минимум 4 символа');
|
||||
return;
|
||||
}
|
||||
|
||||
updateMutation.mutate({ password: formData.newPassword });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800">
|
||||
⚠️ После смены пароля вам потребуется войти заново
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Lock className="inline mr-2" size={16} />
|
||||
Новый пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.newPassword}
|
||||
onChange={(e) => setFormData({ ...formData, newPassword: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Введите новый пароль"
|
||||
minLength={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Lock className="inline mr-2" size={16} />
|
||||
Подтверждение пароля
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Повторите новый пароль"
|
||||
minLength={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{updateMutation.isPending ? 'Сохранение...' : 'Изменить пароль'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalSettingsTab() {
|
||||
const [subTab, setSubTab] = useState<'databases' | 'users'>('databases');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setSubTab('databases')}
|
||||
className={`py-3 px-2 border-b-2 font-medium transition-colors ${
|
||||
subTab === 'databases'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Database className="inline mr-2" size={16} />
|
||||
Базы данных
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSubTab('users')}
|
||||
className={`py-3 px-2 border-b-2 font-medium transition-colors ${
|
||||
subTab === 'users'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Users className="inline mr-2" size={16} />
|
||||
Пользователи
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{subTab === 'databases' && <DatabasesSubTab />}
|
||||
{subTab === 'users' && <UsersSubTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DatabasesSubTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingDatabase, setEditingDatabase] = useState<any | null>(null);
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
});
|
||||
|
||||
const { data: databases, isLoading } = useQuery({
|
||||
queryKey: ['databases'],
|
||||
queryFn: () => dbManagementApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => dbManagementApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||
toast.success('База данных удалена');
|
||||
},
|
||||
onError: () => toast.error('Не удалось удалить базу данных'),
|
||||
});
|
||||
|
||||
const testConnection = async (databaseId: string) => {
|
||||
try {
|
||||
const { data } = await dbManagementApi.test(databaseId);
|
||||
if (data.success) {
|
||||
toast.success('Подключение успешно!');
|
||||
} else {
|
||||
toast.error('Ошибка подключения');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Ошибка тестирования подключения');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (database: any) => {
|
||||
setEditingDatabase(database);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingDatabase(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Вы уверены, что хотите удалить эту базу данных?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">Базы данных</h3>
|
||||
<p className="text-sm text-gray-600">Управление подключениями к базам данных</p>
|
||||
</div>
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Добавить базу данных
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{databases?.map((db) => (
|
||||
<div key={db.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Database size={20} className="text-primary-600" />
|
||||
<h4 className="font-semibold text-gray-900">{db.name}</h4>
|
||||
<span className="text-xs bg-gray-100 px-2 py-1 rounded">{db.type}</span>
|
||||
{db.is_active && (
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Активна</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-gray-600 ml-8">
|
||||
<div>Хост: <span className="font-medium text-gray-900">{db.host}:{db.port}</span></div>
|
||||
<div>База: <span className="font-medium text-gray-900">{db.database_name}</span></div>
|
||||
<div>Пользователь: <span className="font-medium text-gray-900">{db.username}</span></div>
|
||||
<div>Пароль: <span className="font-medium text-gray-900">••••••••</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => testConnection(db.id)}
|
||||
className="btn btn-secondary text-sm"
|
||||
>
|
||||
Тест
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(db)}
|
||||
className="btn btn-secondary text-sm flex items-center gap-1"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(db.id)}
|
||||
className="p-2 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={18} className="text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{databases?.length === 0 && (
|
||||
<div className="text-center py-12 border border-gray-200 rounded-lg">
|
||||
<p className="text-gray-500">Базы данных не настроены. Добавьте первую базу данных.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<DatabaseModal
|
||||
database={editingDatabase}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
type={dialog.type}
|
||||
onConfirm={dialog.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DatabaseModal({
|
||||
database,
|
||||
onClose,
|
||||
}: {
|
||||
database: any | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: database?.name || '',
|
||||
type: database?.type || 'postgresql',
|
||||
host: database?.host || 'localhost',
|
||||
port: database?.port || 5432,
|
||||
database_name: database?.database_name || '',
|
||||
username: database?.username || '',
|
||||
password: '',
|
||||
ssl: database?.ssl || false,
|
||||
is_active: database?.is_active !== undefined ? database.is_active : true,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
const payload = { ...data };
|
||||
// Если редактируем и пароль пустой, удаляем его из payload
|
||||
if (database && !payload.password) {
|
||||
delete payload.password;
|
||||
}
|
||||
return database ? dbManagementApi.update(database.id, payload) : dbManagementApi.create(payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||
toast.success(database ? 'База данных обновлена' : 'База данных создана');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Не удалось сохранить базу данных');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{database ? 'Редактировать базу данных' : 'Добавить базу данных'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Основная база PostgreSQL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Тип</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="postgresql">PostgreSQL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Порт</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
value={formData.port}
|
||||
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) })}
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Хост</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.host}
|
||||
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="localhost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Имя базы данных</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.database_name}
|
||||
onChange={(e) => setFormData({ ...formData, database_name: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="my_database"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Пользователь</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="postgres"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required={!database}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="input w-full pr-10"
|
||||
placeholder={database ? 'Оставьте пустым, чтобы не менять' : 'Введите пароль'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{database && (
|
||||
<p className="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы не менять пароль</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.ssl}
|
||||
onChange={(e) => setFormData({ ...formData, ssl: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Использовать SSL</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Активна</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">
|
||||
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersSubTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<any | null>(null);
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
});
|
||||
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => usersApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => usersApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success('Пользователь удален');
|
||||
},
|
||||
onError: () => toast.error('Не удалось удалить пользователя'),
|
||||
});
|
||||
|
||||
const handleEdit = (user: any) => {
|
||||
setEditingUser(user);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingUser(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Вы уверены, что хотите удалить этого пользователя?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">Пользователи</h3>
|
||||
<p className="text-sm text-gray-600">Управление пользователями системы</p>
|
||||
</div>
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Добавить пользователя
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{users?.map((user: any) => (
|
||||
<div key={user.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<UserCircle size={20} className="text-primary-600" />
|
||||
<h4 className="font-semibold text-gray-900">{user.username}</h4>
|
||||
{user.is_superadmin && (
|
||||
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded">Супер-администратор</span>
|
||||
)}
|
||||
{!user.is_superadmin && user.role === 'admin' && (
|
||||
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">Администратор</span>
|
||||
)}
|
||||
{!user.is_superadmin && user.role !== 'admin' && (
|
||||
<span className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded">Пользователь</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 ml-8">
|
||||
Создан: {new Date(user.created_at).toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(user)}
|
||||
className="btn btn-secondary text-sm flex items-center gap-1"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
{!user.is_superadmin && (
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="p-2 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={18} className="text-red-600" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{users?.length === 0 && (
|
||||
<div className="text-center py-12 border border-gray-200 rounded-lg">
|
||||
<p className="text-gray-500">Пользователи не найдены.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<UserModal
|
||||
user={editingUser}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
type={dialog.type}
|
||||
onConfirm={dialog.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserModal({
|
||||
user,
|
||||
onClose,
|
||||
}: {
|
||||
user: any | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
username: user?.username || '',
|
||||
password: '',
|
||||
role: user?.role || 'admin',
|
||||
is_superadmin: user?.is_superadmin || false,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
const payload = { ...data };
|
||||
// Если редактируем и пароль пустой, удаляем его из payload
|
||||
if (user && !payload.password) {
|
||||
delete payload.password;
|
||||
}
|
||||
return user ? usersApi.update(user.id, payload) : usersApi.create(payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success(user ? 'Пользователь обновлен' : 'Пользователь создан');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Не удалось сохранить пользователя');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-lg w-full">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{user ? 'Редактировать пользователя' : 'Добавить пользователя'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Логин</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required={!user}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="input w-full pr-10"
|
||||
placeholder={user ? 'Оставьте пустым, чтобы не менять' : 'Введите пароль'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{user && (
|
||||
<p className="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы не менять пароль</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Роль</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="admin">Администратор</option>
|
||||
<option value="user">Пользователь</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_superadmin"
|
||||
checked={formData.is_superadmin}
|
||||
onChange={(e) => setFormData({ ...formData, is_superadmin: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="is_superadmin" className="text-sm text-gray-700">
|
||||
Сделать суперадмином (полный доступ к системе)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">
|
||||
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user