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:
GEgorov
2025-10-07 00:04:04 +03:00
commit 8943f5a070
79 changed files with 17032 additions and 0 deletions

136
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,136 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from 'react-hot-toast';
import { useAuthStore } from '@/stores/authStore';
import { authApi } from '@/services/api';
import { useEffect } from 'react';
import Navbar from '@/components/Navbar';
import Sidebar from '@/components/Sidebar';
import Login from '@/pages/Login';
import Dashboard from '@/pages/Dashboard';
import Endpoints from '@/pages/Endpoints';
import ApiKeys from '@/pages/ApiKeys';
import Folders from '@/pages/Folders';
import Logs from '@/pages/Logs';
import Settings from '@/pages/Settings';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
},
});
function PrivateRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />;
}
function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<div className="flex flex-1">
<Sidebar />
<main className="flex-1 p-8 bg-gray-50 overflow-auto">
{children}
</main>
</div>
</div>
);
}
function App() {
const { isAuthenticated, setUser } = useAuthStore();
// Load user data on app start if authenticated
useEffect(() => {
const loadUser = async () => {
if (isAuthenticated) {
try {
const { data } = await authApi.getMe();
setUser(data);
} catch (error) {
console.error('Failed to load user:', error);
}
}
};
loadUser();
}, [isAuthenticated, setUser]);
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/"
element={
<PrivateRoute>
<Layout>
<Dashboard />
</Layout>
</PrivateRoute>
}
/>
<Route
path="/endpoints"
element={
<PrivateRoute>
<Layout>
<Endpoints />
</Layout>
</PrivateRoute>
}
/>
<Route
path="/api-keys"
element={
<PrivateRoute>
<Layout>
<ApiKeys />
</Layout>
</PrivateRoute>
}
/>
<Route
path="/folders"
element={
<PrivateRoute>
<Layout>
<Folders />
</Layout>
</PrivateRoute>
}
/>
<Route
path="/logs"
element={
<PrivateRoute>
<Layout>
<Logs />
</Layout>
</PrivateRoute>
}
/>
<Route
path="/settings"
element={
<PrivateRoute>
<Layout>
<Settings />
</Layout>
</PrivateRoute>
}
/>
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</BrowserRouter>
<Toaster position="top-right" />
</QueryClientProvider>
);
}
export default App;

View File

@@ -0,0 +1,36 @@
import Editor from '@monaco-editor/react';
interface CodeEditorProps {
value: string;
onChange: (value: string) => void;
language: 'javascript' | 'python';
height?: string;
}
export default function CodeEditor({ value, onChange, language, height = '400px' }: CodeEditorProps) {
return (
<div className="border border-gray-300 rounded-lg overflow-hidden">
<Editor
height={height}
defaultLanguage={language}
value={value}
onChange={(value) => onChange(value || '')}
theme="vs-light"
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
wordWrap: 'on',
formatOnPaste: true,
formatOnType: true,
suggestOnTriggerCharacters: true,
quickSuggestions: true,
acceptSuggestionOnEnter: 'on',
}}
/>
</div>
);
}

View File

@@ -0,0 +1,63 @@
import { X } from 'lucide-react';
interface DialogProps {
isOpen: boolean;
onClose: () => void;
title: string;
message: string;
type?: 'alert' | 'confirm';
onConfirm?: () => void;
confirmText?: string;
cancelText?: string;
}
export default function Dialog({
isOpen,
onClose,
title,
message,
type = 'alert',
onConfirm,
confirmText = 'OK',
cancelText = 'Отмена',
}: DialogProps) {
if (!isOpen) return null;
const handleConfirm = () => {
if (onConfirm) {
onConfirm();
}
onClose();
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[100] p-4">
<div className="bg-white rounded-lg max-w-md w-full shadow-xl">
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X size={20} />
</button>
</div>
<div className="p-6">
<p className="text-gray-700 whitespace-pre-wrap">{message}</p>
</div>
<div className="flex gap-3 p-4 border-t border-gray-200 justify-end">
{type === 'confirm' && (
<button onClick={onClose} className="btn btn-secondary">
{cancelText}
</button>
)}
<button onClick={handleConfirm} className="btn btn-primary">
{confirmText}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,718 @@
import { useState } from 'react';
import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
import { endpointsApi, foldersApi } from '@/services/api';
import { Endpoint, EndpointParameter } from '@/types';
import { Plus, Trash2, Play, Edit2 } from 'lucide-react';
import toast from 'react-hot-toast';
import SqlEditor from '@/components/SqlEditor';
import CodeEditor from '@/components/CodeEditor';
interface EndpointModalProps {
endpoint: Endpoint | null;
databases: any[];
folderId?: string | null;
onClose: () => void;
}
export default function EndpointModal({
endpoint,
databases,
folderId,
onClose,
}: EndpointModalProps) {
const queryClient = useQueryClient();
const [formData, setFormData] = useState({
name: endpoint?.name || '',
description: endpoint?.description || '',
method: endpoint?.method || 'GET',
path: endpoint?.path || '',
database_id: endpoint?.database_id || '',
sql_query: endpoint?.sql_query || '',
parameters: endpoint?.parameters || [],
folder_id: endpoint?.folder_id || folderId || '',
is_public: endpoint?.is_public || false,
enable_logging: endpoint?.enable_logging || false,
execution_type: endpoint?.execution_type || 'sql',
script_language: endpoint?.script_language || 'javascript',
script_code: endpoint?.script_code || '',
script_queries: endpoint?.script_queries || [],
});
const [editingQueryIndex, setEditingQueryIndex] = useState<number | null>(null);
const [showScriptCodeEditor, setShowScriptCodeEditor] = useState(false);
const saveMutation = useMutation({
mutationFn: (data: any) =>
endpoint ? endpointsApi.update(endpoint.id, data) : endpointsApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
toast.success(endpoint ? 'Эндпоинт обновлен' : 'Эндпоинт создан');
onClose();
},
onError: () => toast.error('Не удалось сохранить эндпоинт'),
});
const [testParams, setTestParams] = useState<any>({});
const testMutation = useMutation({
mutationFn: () => {
// Собираем тестовые значения параметров в массив в правильном порядке
const paramValues = formData.parameters.map((param: any) => {
const value = testParams[param.name];
if (value === undefined || value === '') return null;
// Преобразуем тип если нужно
switch (param.type) {
case 'number':
return Number(value);
case 'boolean':
return value === 'true' || value === true;
default:
return value;
}
});
if (formData.execution_type === 'script') {
// Для скриптов используем database_id из первого запроса или пустую строку
const firstDbId = formData.script_queries.length > 0 ? formData.script_queries[0].database_id : '';
return endpointsApi.test({
database_id: firstDbId || '',
execution_type: 'script',
script_language: formData.script_language,
script_code: formData.script_code,
script_queries: formData.script_queries,
parameters: paramValues,
endpoint_parameters: formData.parameters,
});
} else {
return endpointsApi.test({
database_id: formData.database_id,
execution_type: 'sql',
sql_query: formData.sql_query,
parameters: paramValues,
endpoint_parameters: formData.parameters,
});
}
},
onSuccess: (response) => {
toast.success(`Запрос выполнен за ${response.data.executionTime}мс. Возвращено строк: ${response.data.rowCount}.`);
},
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-4xl 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">
{endpoint ? 'Редактировать эндпоинт' : 'Создать новый эндпоинт'}
</h2>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<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.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="input w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Метод</label>
<select
value={formData.method}
onChange={(e) => setFormData({ ...formData, method: e.target.value as any })}
className="input w-full"
>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="DELETE">DELETE</option>
<option value="PATCH">PATCH</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Описание</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="input w-full"
rows={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Тип выполнения</label>
<select
value={formData.execution_type}
onChange={(e) => setFormData({ ...formData, execution_type: e.target.value as 'sql' | 'script' })}
className="input w-full"
>
<option value="sql">SQL Запрос</option>
<option value="script">Скрипт (JavaScript/Python)</option>
</select>
</div>
<div className={`grid ${formData.execution_type === 'sql' ? 'grid-cols-3' : '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.path}
onChange={(e) => setFormData({ ...formData, path: e.target.value })}
className="input w-full"
placeholder="/api/v1/users"
/>
</div>
{formData.execution_type === 'sql' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">База данных</label>
<select
required
value={formData.database_id}
onChange={(e) => setFormData({ ...formData, database_id: e.target.value })}
className="input w-full"
>
<option value="">Выберите базу данных</option>
{databases.map((db) => (
<option key={db.id} value={db.id}>{db.name}</option>
))}
</select>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Папка</label>
<FolderSelector
value={formData.folder_id}
onChange={(value) => setFormData({ ...formData, folder_id: value })}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700">
Параметры запроса
<span className="text-xs text-gray-500 ml-2">
(используйте $имяПараметра в SQL запросе)
</span>
</label>
<button
type="button"
onClick={() => {
const newParam: EndpointParameter = {
name: '',
type: 'string' as const,
required: false,
in: 'query' as const,
description: '',
};
setFormData({ ...formData, parameters: [...formData.parameters, newParam] });
}}
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<Plus size={16} />
Добавить параметр
</button>
</div>
{formData.parameters.length > 0 ? (
<div className="space-y-3 mb-4 border border-gray-200 rounded-lg p-4">
{formData.parameters.map((param: any, index: number) => (
<div key={index} className="flex gap-2 items-start bg-gray-50 p-3 rounded">
<div className="flex-1 grid grid-cols-5 gap-2">
<input
type="text"
placeholder="Имя параметра"
value={param.name}
onChange={(e) => {
const newParams = [...formData.parameters];
newParams[index].name = e.target.value;
setFormData({ ...formData, parameters: newParams });
}}
className="input text-sm"
/>
<select
value={param.type}
onChange={(e) => {
const newParams = [...formData.parameters];
newParams[index].type = e.target.value as 'string' | 'number' | 'boolean' | 'date';
setFormData({ ...formData, parameters: newParams });
}}
className="input text-sm"
>
<option value="string">string</option>
<option value="number">number</option>
<option value="boolean">boolean</option>
<option value="date">date</option>
</select>
<select
value={param.in}
onChange={(e) => {
const newParams = [...formData.parameters];
newParams[index].in = e.target.value as 'query' | 'body' | 'path';
setFormData({ ...formData, parameters: newParams });
}}
className="input text-sm"
>
<option value="query">Query</option>
<option value="body">Body</option>
<option value="path">Path</option>
</select>
<input
type="text"
placeholder="Описание"
value={param.description || ''}
onChange={(e) => {
const newParams = [...formData.parameters];
newParams[index].description = e.target.value;
setFormData({ ...formData, parameters: newParams });
}}
className="input text-sm"
/>
<label className="flex items-center gap-1 text-sm">
<input
type="checkbox"
checked={param.required}
onChange={(e) => {
const newParams = [...formData.parameters];
newParams[index].required = e.target.checked;
setFormData({ ...formData, parameters: newParams });
}}
className="rounded"
/>
<span className="text-xs">Обязательный</span>
</label>
</div>
<button
type="button"
onClick={() => {
const newParams = formData.parameters.filter((_: any, i: number) => i !== index);
setFormData({ ...formData, parameters: newParams });
}}
className="p-1 hover:bg-red-50 rounded text-red-600"
title="Удалить параметр"
>
<Trash2 size={16} />
</button>
</div>
))}
</div>
) : (
<div className="text-center py-4 mb-4 border border-gray-200 rounded-lg bg-gray-50">
<p className="text-sm text-gray-500">Нет параметров. Добавьте параметры для динамического запроса.</p>
</div>
)}
</div>
{formData.execution_type === 'sql' ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">SQL Запрос</label>
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700 space-y-1">
<div><strong>Совет:</strong> Используйте <code className="bg-blue-100 px-1 rounded">$имяПараметра</code> для подстановки значений параметров в SQL запрос.</div>
<div>Пример: <code className="bg-blue-100 px-1 rounded">SELECT * FROM users WHERE id = $userId AND status = $status</code></div>
<div className="text-xs text-blue-600 mt-1">
💡 <strong>Query</strong> параметры передаются в URL, <strong>Body</strong> - в теле запроса (для POST/PUT/DELETE можно использовать оба типа)
</div>
</div>
<SqlEditor
value={formData.sql_query}
onChange={(value) => setFormData({ ...formData, sql_query: value })}
databaseId={formData.database_id}
height="300px"
/>
</div>
) : (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Язык скрипта</label>
<select
value={formData.script_language}
onChange={(e) => setFormData({ ...formData, script_language: e.target.value as 'javascript' | 'python' })}
className="input w-full"
>
<option value="javascript">JavaScript</option>
<option value="python">Python</option>
</select>
</div>
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700">
SQL Запросы для скрипта
</label>
<button
type="button"
onClick={() => {
const newQueries = [...formData.script_queries, { name: '', sql: '', database_id: '' }];
setFormData({ ...formData, script_queries: newQueries });
setEditingQueryIndex(newQueries.length - 1);
}}
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<Plus size={16} />
Добавить запрос
</button>
</div>
{formData.script_queries.length > 0 ? (
<div className="space-y-2 mb-4">
{formData.script_queries.map((query: any, idx: number) => (
<div key={idx} className="border border-gray-200 rounded-lg p-4 bg-white hover:shadow-sm transition-shadow">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<code className="text-sm font-semibold text-gray-900">{query.name || 'Безымянный запрос'}</code>
{query.database_id && (
<span className="text-xs text-gray-500">
{databases.find(db => db.id === query.database_id)?.name || 'БД не выбрана'}
</span>
)}
</div>
{query.sql && (
<div className="text-xs text-gray-600 font-mono bg-gray-50 p-2 rounded mt-1 truncate">
{query.sql.substring(0, 100)}{query.sql.length > 100 ? '...' : ''}
</div>
)}
</div>
<div className="flex gap-2 ml-4">
<button
type="button"
onClick={() => setEditingQueryIndex(idx)}
className="p-2 hover:bg-blue-50 rounded text-blue-600"
title="Редактировать запрос"
>
<Edit2 size={16} />
</button>
<button
type="button"
onClick={() => {
const newQueries = formData.script_queries.filter((_: any, i: number) => i !== idx);
setFormData({ ...formData, script_queries: newQueries });
}}
className="p-2 hover:bg-red-50 rounded text-red-600"
title="Удалить запрос"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-6 mb-4 border border-gray-200 rounded-lg bg-gray-50">
<p className="text-sm text-gray-500">Нет SQL запросов. Добавьте запросы для использования в скрипте.</p>
</div>
)}
</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={() => setShowScriptCodeEditor(true)}
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
>
<Edit2 size={16} />
Редактировать код
</button>
</div>
<div className="border border-gray-200 rounded-lg p-4 bg-white">
{formData.script_code ? (
<pre className="text-xs font-mono text-gray-800 whitespace-pre-wrap max-h-32 overflow-y-auto">
{formData.script_code}
</pre>
) : (
<p className="text-sm text-gray-500 italic">Код скрипта не задан. Нажмите "Редактировать код"</p>
)}
</div>
</div>
</>
)}
<div className="flex items-center gap-6">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={formData.is_public}
onChange={(e) => setFormData({ ...formData, is_public: e.target.checked })}
className="rounded"
/>
<span className="text-sm text-gray-700">Публичный эндпоинт (не требует API ключ)</span>
</label>
<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>
{formData.parameters.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Тестовые значения параметров
</label>
<div className="grid grid-cols-2 gap-3 border border-gray-200 rounded-lg p-4 bg-gray-50">
{formData.parameters.map((param: any, index: number) => (
<div key={index}>
<label className="block text-xs font-medium text-gray-700 mb-1">
${param.name} ({param.type})
{param.required && <span className="text-red-500">*</span>}
</label>
{param.type === 'boolean' ? (
<select
value={testParams[param.name] || ''}
onChange={(e) => setTestParams({ ...testParams, [param.name]: e.target.value })}
className="input w-full text-sm"
>
<option value="">Не задано</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
) : (
<input
type={param.type === 'number' ? 'number' : param.type === 'date' ? 'datetime-local' : 'text'}
placeholder={param.description || `Введите ${param.name}`}
value={testParams[param.name] || ''}
onChange={(e) => setTestParams({ ...testParams, [param.name]: e.target.value })}
className="input w-full text-sm"
/>
)}
</div>
))}
</div>
</div>
)}
<div className="flex gap-3 pt-4 border-t border-gray-200">
<button
type="button"
onClick={() => testMutation.mutate()}
disabled={
!formData.database_id ||
(formData.execution_type === 'sql' ? !formData.sql_query : !formData.script_code) ||
testMutation.isPending
}
className="btn btn-secondary flex items-center gap-2"
>
<Play size={18} />
{testMutation.isPending ? 'Тестирование...' : 'Тест запроса'}
</button>
<div className="flex-1"></div>
<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>
{/* Query Editor Modal */}
{editingQueryIndex !== null && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60] p-4">
<div className="bg-white rounded-lg max-w-5xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<h3 className="text-xl font-bold text-gray-900">Редактировать SQL запрос</h3>
</div>
<div className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Имя запроса</label>
<input
type="text"
placeholder="mainQuery"
value={formData.script_queries[editingQueryIndex]?.name || ''}
onChange={(e) => {
const newQueries = [...formData.script_queries];
newQueries[editingQueryIndex].name = e.target.value;
setFormData({ ...formData, script_queries: newQueries });
}}
className="input w-full"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">База данных</label>
<select
required
value={formData.script_queries[editingQueryIndex]?.database_id || ''}
onChange={(e) => {
const newQueries = [...formData.script_queries];
newQueries[editingQueryIndex].database_id = e.target.value;
setFormData({ ...formData, script_queries: newQueries });
}}
className="input w-full"
>
<option value="">Выберите базу данных</option>
{databases.map((db) => (
<option key={db.id} value={db.id}>{db.name}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">SQL Запрос</label>
<div className="mb-2 p-2 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
<div><strong>Совет:</strong> Используйте <code className="bg-blue-100 px-1 rounded">$имяПараметра</code> для параметров из запроса или дополнительных параметров из скрипта.</div>
</div>
<SqlEditor
value={formData.script_queries[editingQueryIndex]?.sql || ''}
onChange={(value) => {
const newQueries = [...formData.script_queries];
newQueries[editingQueryIndex].sql = value;
setFormData({ ...formData, script_queries: newQueries });
}}
databaseId={formData.script_queries[editingQueryIndex]?.database_id || ''}
height="400px"
/>
</div>
</div>
<div className="p-6 border-t border-gray-200 flex gap-3">
<button
type="button"
onClick={() => setEditingQueryIndex(null)}
className="btn btn-primary"
>
Сохранить
</button>
<button
type="button"
onClick={() => setEditingQueryIndex(null)}
className="btn btn-secondary"
>
Закрыть
</button>
</div>
</div>
</div>
)}
{/* Script Code Editor Modal */}
{showScriptCodeEditor && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[60] p-4">
<div className="bg-white rounded-lg max-w-6xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6 border-b border-gray-200">
<h3 className="text-xl font-bold text-gray-900">Редактировать код скрипта</h3>
</div>
<div className="p-6 space-y-4">
<div className="mb-3 p-3 bg-blue-50 border border-blue-200 rounded text-sm text-blue-700 space-y-2">
<div><strong>Доступные функции:</strong></div>
{formData.script_language === 'javascript' ? (
<>
<div> <code className="bg-blue-100 px-1 rounded">params</code> - объект с параметрами из запроса (query/body)</div>
<div> <code className="bg-blue-100 px-1 rounded">await execQuery('queryName', {'{'}additional: 'params'{'}'})</code> - выполнить SQL запрос</div>
<div className="text-xs mt-2">Пример: <code className="bg-blue-100 px-1 rounded">const result = await execQuery('mainQuery', {'{'}diff: 123{'}'}); return result.data;</code></div>
</>
) : (
<>
<div> <code className="bg-blue-100 px-1 rounded">params</code> - словарь с параметрами из запроса (query/body)</div>
<div> <code className="bg-blue-100 px-1 rounded">exec_query('queryName', {'{'}' additional': 'params'{'}'})</code> - выполнить SQL запрос</div>
<div className="text-xs mt-2">Пример: <code className="bg-blue-100 px-1 rounded">result = exec_query('mainQuery', {'{'}'diff': 123{'}'}); return result</code></div>
</>
)}
</div>
<CodeEditor
value={formData.script_code}
onChange={(value) => setFormData({ ...formData, script_code: value })}
language={formData.script_language}
height="500px"
/>
</div>
<div className="p-6 border-t border-gray-200 flex gap-3">
<button
type="button"
onClick={() => setShowScriptCodeEditor(false)}
className="btn btn-primary"
>
Сохранить
</button>
<button
type="button"
onClick={() => setShowScriptCodeEditor(false)}
className="btn btn-secondary"
>
Закрыть
</button>
</div>
</div>
</div>
)}
</>
);
}
// Компонент для выбора папки с иерархией
function FolderSelector({ value, onChange }: { value: string; onChange: (value: string) => void }) {
const { data: folders } = useQuery({
queryKey: ['folders'],
queryFn: () => foldersApi.getAll().then(res => res.data),
});
// Построение списка с иерархией для отображения
const buildFolderList = (): Array<{ id: string; name: string; level: number }> => {
if (!folders) return [];
const folderMap = new Map(folders.map(f => [f.id, { ...f, children: [] }]));
const result: Array<{ id: string; name: string; level: number }> = [];
// Группируем папки по parent_id
folders.forEach(folder => {
if (folder.parent_id && folderMap.has(folder.parent_id)) {
const parent: any = folderMap.get(folder.parent_id)!;
if (!parent.children) parent.children = [];
parent.children.push(folderMap.get(folder.id)!);
}
});
// Рекурсивно добавляем папки в список с учетом уровня вложенности
const addFolderRecursive = (folder: any, level: number) => {
result.push({ id: folder.id, name: folder.name, level });
if (folder.children && folder.children.length > 0) {
folder.children.forEach((child: any) => addFolderRecursive(child, level + 1));
}
};
// Добавляем корневые папки и их детей
folders.forEach(folder => {
if (!folder.parent_id) {
addFolderRecursive(folderMap.get(folder.id)!, 0);
}
});
return result;
};
const folderList = buildFolderList();
return (
<select value={value} onChange={(e) => onChange(e.target.value)} className="input w-full">
<option value="">Без папки</option>
{folderList.map((folder) => (
<option key={folder.id} value={folder.id}>
{'\u00A0'.repeat(folder.level * 4)}{folder.name}
</option>
))}
</select>
);
}

View File

@@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { useAuthStore } from '@/stores/authStore';
import { authApi } from '@/services/api';
import { LogOut, User } from 'lucide-react';
export default function Navbar() {
const { user, logout, setAuth } = useAuthStore();
useEffect(() => {
// Load user data on mount if we have a token
const loadUser = async () => {
const token = localStorage.getItem('auth_token');
if (token && !user) {
try {
const { data } = await authApi.getMe();
setAuth(data, token);
} catch (error) {
console.error('Failed to load user:', error);
logout();
}
}
};
loadUser();
}, [user, setAuth, logout]);
return (
<nav className="bg-white border-b border-gray-200 px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold text-primary-600">KIS API Builder</h1>
</div>
<div className="flex items-center gap-4">
{user && (
<>
<div className="flex items-center gap-2 text-gray-700">
<User size={20} />
<span className="font-medium">{user.username}</span>
<span className="text-xs bg-primary-100 text-primary-700 px-2 py-1 rounded">
{user.role === 'admin' ? 'Администратор' : 'Пользователь'}
</span>
</div>
<button
onClick={logout}
className="btn btn-secondary flex items-center gap-2"
>
<LogOut size={18} />
Выход
</button>
</>
)}
</div>
</div>
</nav>
);
}

View File

@@ -0,0 +1,46 @@
import { NavLink } from 'react-router-dom';
import { Home, Key, Folder, Settings, FileCode, FileText } from 'lucide-react';
import { cn } from '@/utils/cn';
const navItems = [
{ to: '/', icon: Home, label: 'Главная' },
{ to: '/endpoints', icon: FileCode, label: 'Эндпоинты' },
{ to: '/folders', icon: Folder, label: 'Папки' },
{ to: '/api-keys', icon: Key, label: 'API Ключи' },
{ to: '/logs', icon: FileText, label: 'Логи' },
{ to: '/settings', icon: Settings, label: 'Настройки' },
];
export default function Sidebar() {
return (
<aside className="w-64 bg-white border-r border-gray-200 flex flex-col">
<div className="flex-1 py-6">
<nav className="space-y-1 px-3">
{navItems.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-4 py-3 rounded-lg transition-all',
isActive
? 'bg-primary-50 text-primary-700 font-medium'
: 'text-gray-700 hover:bg-gray-50'
)
}
>
<Icon size={20} />
<span>{label}</span>
</NavLink>
))}
</nav>
</div>
<div className="p-4 border-t border-gray-200">
<div className="text-xs text-gray-500">
<p className="font-semibold">KIS API Builder v1.0</p>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,108 @@
import { useRef } from 'react';
import Editor, { Monaco, loader } from '@monaco-editor/react';
import { databasesApi } from '@/services/api';
import * as monacoEditor from 'monaco-editor';
// Configure loader to use local Monaco
loader.config({ monaco: monacoEditor });
interface SqlEditorProps {
value: string;
onChange: (value: string) => void;
databaseId?: string;
height?: string;
}
export default function SqlEditor({ value, onChange, databaseId, height = '400px' }: SqlEditorProps) {
const editorRef = useRef<any>(null);
const monacoRef = useRef<Monaco | null>(null);
const handleEditorDidMount = (editor: any, monaco: Monaco) => {
editorRef.current = editor;
monacoRef.current = monaco;
// Configure SQL language features
monaco.languages.registerCompletionItemProvider('sql', {
provideCompletionItems: async (model, position) => {
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
let suggestions: any[] = [
// SQL Keywords
{ label: 'SELECT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'SELECT ', range },
{ label: 'FROM', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'FROM ', range },
{ label: 'WHERE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'WHERE ', range },
{ label: 'JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'JOIN ', range },
{ label: 'LEFT JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LEFT JOIN ', range },
{ label: 'INNER JOIN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'INNER JOIN ', range },
{ label: 'GROUP BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'GROUP BY ', range },
{ label: 'ORDER BY', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'ORDER BY ', range },
{ label: 'HAVING', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'HAVING ', range },
{ label: 'LIMIT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIMIT ', range },
{ label: 'OFFSET', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'OFFSET ', range },
{ label: 'AND', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'AND ', range },
{ label: 'OR', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'OR ', range },
{ label: 'NOT', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'NOT ', range },
{ label: 'IN', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'IN ', range },
{ label: 'LIKE', kind: monaco.languages.CompletionItemKind.Keyword, insertText: 'LIKE ', range },
{ label: 'COUNT', kind: monaco.languages.CompletionItemKind.Function, insertText: 'COUNT()', range },
{ label: 'SUM', kind: monaco.languages.CompletionItemKind.Function, insertText: 'SUM()', range },
{ label: 'AVG', kind: monaco.languages.CompletionItemKind.Function, insertText: 'AVG()', range },
{ label: 'MAX', kind: monaco.languages.CompletionItemKind.Function, insertText: 'MAX()', range },
{ label: 'MIN', kind: monaco.languages.CompletionItemKind.Function, insertText: 'MIN()', range },
];
// Fetch table names from database if databaseId is provided
if (databaseId) {
try {
const { data } = await databasesApi.getTables(databaseId);
const tableSuggestions = data.tables.map(table => ({
label: table,
kind: monaco.languages.CompletionItemKind.Class,
insertText: table,
detail: 'Table',
range,
}));
suggestions = [...suggestions, ...tableSuggestions];
} catch (error) {
console.error('Failed to fetch table names:', error);
}
}
return { suggestions };
},
});
};
return (
<div className="border border-gray-300 rounded-lg overflow-hidden">
<Editor
height={height}
defaultLanguage="sql"
value={value}
onChange={(value) => onChange(value || '')}
onMount={handleEditorDidMount}
theme="vs-light"
options={{
minimap: { enabled: false },
fontSize: 14,
lineNumbers: 'on',
scrollBeyondLastLine: false,
automaticLayout: true,
tabSize: 2,
wordWrap: 'on',
formatOnPaste: true,
formatOnType: true,
suggestOnTriggerCharacters: true,
quickSuggestions: true,
acceptSuggestionOnEnter: 'on',
}}
/>
</div>
);
}

35
frontend/src/index.css Normal file
View File

@@ -0,0 +1,35 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-gray-50 text-gray-900;
}
}
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200;
}
.btn-primary {
@apply bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800;
}
.btn-secondary {
@apply bg-gray-200 text-gray-800 hover:bg-gray-300 active:bg-gray-400;
}
.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700 active:bg-red-800;
}
.input {
@apply px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
.card {
@apply bg-white rounded-lg shadow-sm border border-gray-200;
}
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

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

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

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

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

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

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

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

View File

@@ -0,0 +1,171 @@
import axios from 'axios';
import { AuthResponse, User, Endpoint, Folder, ApiKey, Database, QueryTestResult } from '@/types';
const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token
api.interceptors.request.use((config) => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('auth_token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
// Auth API
export const authApi = {
login: (username: string, password: string) =>
api.post<AuthResponse>('/auth/login', { username, password }),
getMe: () =>
api.get<User>('/auth/me'),
};
// Users API (superadmin only)
export const usersApi = {
getAll: () =>
api.get<User[]>('/users'),
create: (data: { username: string; password: string; role?: string; is_superadmin?: boolean }) =>
api.post<User>('/users', data),
update: (id: string, data: Partial<User> & { password?: string }) =>
api.put<User>(`/users/${id}`, data),
delete: (id: string) =>
api.delete(`/users/${id}`),
};
// Database Management API (admin only)
// Logs API
export const logsApi = {
getAll: (filters?: any) =>
api.get('/logs', { params: filters }),
getById: (id: string) =>
api.get(`/logs/${id}`),
delete: (id: string) =>
api.delete(`/logs/${id}`),
clear: (data: any) =>
api.post('/logs/clear', data),
};
// Database Management API (admin only)
export const dbManagementApi = {
getAll: () =>
api.get<any[]>('/db-management'),
getById: (id: string) =>
api.get<any>(`/db-management/${id}`),
create: (data: any) =>
api.post<any>('/db-management', data),
update: (id: string, data: any) =>
api.put<any>(`/db-management/${id}`, data),
delete: (id: string) =>
api.delete(`/db-management/${id}`),
test: (id: string) =>
api.get<{ success: boolean; message: string }>(`/db-management/${id}/test`),
};
// Endpoints API
export const endpointsApi = {
getAll: (search?: string, folderId?: string) =>
api.get<Endpoint[]>('/endpoints', { params: { search, folder_id: folderId } }),
getById: (id: string) =>
api.get<Endpoint>(`/endpoints/${id}`),
create: (data: Partial<Endpoint>) =>
api.post<Endpoint>('/endpoints', data),
update: (id: string, data: Partial<Endpoint>) =>
api.put<Endpoint>(`/endpoints/${id}`, data),
delete: (id: string) =>
api.delete(`/endpoints/${id}`),
test: (data: {
database_id: string;
execution_type?: 'sql' | 'script';
sql_query?: string;
parameters?: any[];
endpoint_parameters?: any[];
script_language?: 'javascript' | 'python';
script_code?: string;
script_queries?: any[];
}) =>
api.post<QueryTestResult>('/endpoints/test', data),
};
// Folders API
export const foldersApi = {
getAll: () =>
api.get<Folder[]>('/folders'),
getById: (id: string) =>
api.get<Folder>(`/folders/${id}`),
create: (name: string, parentId?: string) =>
api.post<Folder>('/folders', { name, parent_id: parentId }),
update: (id: string, name: string, parentId?: string) =>
api.put<Folder>(`/folders/${id}`, { name, parent_id: parentId }),
delete: (id: string) =>
api.delete(`/folders/${id}`),
};
// API Keys API
export const apiKeysApi = {
getAll: () =>
api.get<ApiKey[]>('/keys'),
create: (name: string, permissions: string[], expiresAt?: string, enableLogging?: boolean) =>
api.post<ApiKey>('/keys', { name, permissions, expires_at: expiresAt, enable_logging: enableLogging }),
update: (id: string, data: Partial<ApiKey>) =>
api.put<ApiKey>(`/keys/${id}`, data),
delete: (id: string) =>
api.delete(`/keys/${id}`),
};
// Databases API
export const databasesApi = {
getAll: () =>
api.get<Database[]>('/databases'),
test: (databaseId: string) =>
api.get<{ success: boolean; message: string }>(`/databases/${databaseId}/test`),
getTables: (databaseId: string) =>
api.get<{ tables: string[] }>(`/databases/${databaseId}/tables`),
getTableSchema: (databaseId: string, tableName: string) =>
api.get<{ schema: any[] }>(`/databases/${databaseId}/tables/${tableName}/schema`),
};
export default api;

View File

@@ -0,0 +1,31 @@
import { create } from 'zustand';
import { User } from '@/types';
interface AuthState {
user: User | null;
token: string | null;
isAuthenticated: boolean;
setAuth: (user: User, token: string) => void;
setUser: (user: User) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: localStorage.getItem('auth_token'),
isAuthenticated: !!localStorage.getItem('auth_token'),
setAuth: (user, token) => {
localStorage.setItem('auth_token', token);
set({ user, token, isAuthenticated: true });
},
setUser: (user) => {
set({ user });
},
logout: () => {
localStorage.removeItem('auth_token');
set({ user: null, token: null, isAuthenticated: false });
},
}));

View File

@@ -0,0 +1,87 @@
export interface User {
id: string;
username: string;
role: 'admin' | 'user';
is_superadmin: boolean;
}
export interface AuthResponse {
user: User;
token: string;
}
export interface Database {
id: string;
name: string;
type: 'postgresql';
host: string;
port: number;
database: string;
}
export interface Folder {
id: string;
name: string;
parent_id: string | null;
user_id: string;
created_at: string;
updated_at: string;
endpoint_count?: number;
subfolder_count?: number;
}
export interface EndpointParameter {
name: string;
type: 'string' | 'number' | 'boolean' | 'date';
required: boolean;
default_value?: any;
description?: string;
in: 'query' | 'body' | 'path';
}
export interface ScriptQuery {
name: string;
sql: string;
database_id?: string;
}
export interface Endpoint {
id: string;
name: string;
description: string;
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
path: string;
database_id: string;
sql_query: string;
parameters: EndpointParameter[];
folder_id: string | null;
folder_name?: string;
user_id: string;
is_public: boolean;
enable_logging: boolean;
execution_type: 'sql' | 'script';
script_language?: 'javascript' | 'python';
script_code?: string;
script_queries?: ScriptQuery[];
created_at: string;
updated_at: string;
}
export interface ApiKey {
id: string;
name: string;
key: string;
permissions: string[];
is_active: boolean;
enable_logging: boolean;
created_at: string;
expires_at: string | null;
}
export interface QueryTestResult {
success: boolean;
data?: any[];
rowCount?: number;
executionTime?: number;
error?: string;
}

6
frontend/src/utils/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

@@ -0,0 +1,22 @@
import * as monaco from 'monaco-editor';
// Configure Monaco editor worker paths
self.MonacoEnvironment = {
getWorker(_, label) {
if (label === 'json') {
return new Worker(new URL('monaco-editor/esm/vs/language/json/json.worker', import.meta.url), { type: 'module' });
}
if (label === 'css' || label === 'scss' || label === 'less') {
return new Worker(new URL('monaco-editor/esm/vs/language/css/css.worker', import.meta.url), { type: 'module' });
}
if (label === 'html' || label === 'handlebars' || label === 'razor') {
return new Worker(new URL('monaco-editor/esm/vs/language/html/html.worker', import.meta.url), { type: 'module' });
}
if (label === 'typescript' || label === 'javascript') {
return new Worker(new URL('monaco-editor/esm/vs/language/typescript/ts.worker', import.meta.url), { type: 'module' });
}
return new Worker(new URL('monaco-editor/esm/vs/editor/editor.worker', import.meta.url), { type: 'module' });
},
};
export { monaco };