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:
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cat:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
13
.gitignore
vendored
Normal file
13
.gitignore
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
45
backend/.env.example
Normal file
45
backend/.env.example
Normal file
@@ -0,0 +1,45 @@
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=development
|
||||
|
||||
# Main Database (PostgreSQL - for storing API configurations)
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=api_builder
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
JWT_EXPIRES_IN=24h
|
||||
|
||||
# API Rate Limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
# Target Databases Configuration (JSON format)
|
||||
# This is where your API endpoints will execute queries
|
||||
TARGET_DATABASES='[
|
||||
{
|
||||
"id": "main_postgres",
|
||||
"name": "Main PostgreSQL",
|
||||
"type": "postgresql",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"database": "your_database",
|
||||
"user": "your_user",
|
||||
"password": "your_password",
|
||||
"ssl": false
|
||||
},
|
||||
{
|
||||
"id": "analytics_db",
|
||||
"name": "Analytics Database",
|
||||
"type": "postgresql",
|
||||
"host": "localhost",
|
||||
"port": 5432,
|
||||
"database": "analytics",
|
||||
"user": "analytics_user",
|
||||
"password": "analytics_password",
|
||||
"ssl": false
|
||||
}
|
||||
]'
|
||||
7
backend/.gitignore
vendored
Normal file
7
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
.DS_Store
|
||||
coverage/
|
||||
.vscode/
|
||||
20
backend/ecosystem.config.js
Normal file
20
backend/ecosystem.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'kis-api-builder-backend',
|
||||
script: './dist/server.js',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 10805
|
||||
},
|
||||
error_file: './logs/pm2-error.log',
|
||||
out_file: './logs/pm2-out.log',
|
||||
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||
merge_logs: true,
|
||||
autorestart: true,
|
||||
max_restarts: 10,
|
||||
min_uptime: '10s',
|
||||
max_memory_restart: '500M'
|
||||
}]
|
||||
};
|
||||
6
backend/nodemon.json
Normal file
6
backend/nodemon.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"watch": ["src"],
|
||||
"ext": "ts,json",
|
||||
"ignore": ["src/**/*.spec.ts"],
|
||||
"exec": "ts-node --transpile-only src/server.ts"
|
||||
}
|
||||
2924
backend/package-lock.json
generated
Normal file
2924
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
54
backend/package.json
Normal file
54
backend/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "kis-api-builder-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "API Builder Backend - Dynamic API construction system",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"dev": "nodemon",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"start:prod": "pm2 start dist/server.js --name kis-api-builder-backend --env production",
|
||||
"reload:prod": "pm2 reload kis-api-builder-backend",
|
||||
"stop:prod": "pm2 stop kis-api-builder-backend",
|
||||
"delete:prod": "pm2 delete kis-api-builder-backend",
|
||||
"migrate": "ts-node src/migrations/run.ts",
|
||||
"seed": "ts-node src/migrations/seed.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"api",
|
||||
"builder",
|
||||
"sql",
|
||||
"dynamic"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"helmet": "^7.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.11.3",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"swagger-ui-express": "^5.0.0",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/pg": "^8.10.9",
|
||||
"@types/swagger-jsdoc": "^6.0.4",
|
||||
"@types/swagger-ui-express": "^4.1.6",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"http-proxy-middleware": "^3.0.5",
|
||||
"nodemon": "^3.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
30
backend/src/config/database.ts
Normal file
30
backend/src/config/database.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Pool } from 'pg';
|
||||
import { config } from './environment';
|
||||
|
||||
// Main database pool for KIS API Builder metadata
|
||||
export const mainPool = new Pool({
|
||||
host: config.mainDatabase.host,
|
||||
port: config.mainDatabase.port,
|
||||
database: config.mainDatabase.database,
|
||||
user: config.mainDatabase.user,
|
||||
password: config.mainDatabase.password,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
mainPool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle client', err);
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
export const initializeDatabase = async () => {
|
||||
try {
|
||||
const client = await mainPool.connect();
|
||||
console.log('✅ Connected to main database successfully');
|
||||
client.release();
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to connect to database:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
237
backend/src/config/dynamicSwagger.ts
Normal file
237
backend/src/config/dynamicSwagger.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { mainPool } from './database';
|
||||
|
||||
interface SwaggerPath {
|
||||
[method: string]: {
|
||||
tags: string[];
|
||||
summary: string;
|
||||
description: string;
|
||||
security: { apiKey: [] }[];
|
||||
parameters: any[];
|
||||
responses: any;
|
||||
};
|
||||
}
|
||||
|
||||
interface SwaggerSpec {
|
||||
openapi: string;
|
||||
info: any;
|
||||
servers: any[];
|
||||
components: any;
|
||||
paths: { [path: string]: SwaggerPath };
|
||||
tags: any[];
|
||||
}
|
||||
|
||||
export async function generateDynamicSwagger(): Promise<SwaggerSpec> {
|
||||
// Загружаем все эндпоинты из базы с полным путем папки
|
||||
const endpointsResult = await mainPool.query(`
|
||||
WITH RECURSIVE folder_path AS (
|
||||
-- Базовый случай: папки без родителя
|
||||
SELECT id, name, parent_id, name::text as full_path
|
||||
FROM folders
|
||||
WHERE parent_id IS NULL
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Рекурсивный случай: добавляем дочерние папки
|
||||
SELECT f.id, f.name, f.parent_id, (fp.full_path || ' / ' || f.name)::text as full_path
|
||||
FROM folders f
|
||||
INNER JOIN folder_path fp ON f.parent_id = fp.id
|
||||
)
|
||||
SELECT
|
||||
e.id,
|
||||
e.name,
|
||||
e.description,
|
||||
e.method,
|
||||
e.path,
|
||||
e.parameters,
|
||||
e.is_public,
|
||||
fp.full_path as folder_name
|
||||
FROM endpoints e
|
||||
LEFT JOIN folder_path fp ON e.folder_id = fp.id
|
||||
ORDER BY fp.full_path, e.name
|
||||
`);
|
||||
|
||||
const endpoints = endpointsResult.rows;
|
||||
|
||||
// Группируем теги по папкам
|
||||
const tags: any[] = [];
|
||||
const folderSet = new Set<string>();
|
||||
|
||||
endpoints.forEach((endpoint: any) => {
|
||||
const folderName = endpoint.folder_name || 'Без категории';
|
||||
if (!folderSet.has(folderName)) {
|
||||
folderSet.add(folderName);
|
||||
tags.push({
|
||||
name: folderName,
|
||||
description: `Эндпоинты в папке "${folderName}"`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Генерируем пути
|
||||
const paths: { [path: string]: SwaggerPath } = {};
|
||||
|
||||
endpoints.forEach((endpoint: any) => {
|
||||
const method = endpoint.method.toLowerCase();
|
||||
const swaggerPath = endpoint.path.replace(/\/api\/v1/, '');
|
||||
const folderName = endpoint.folder_name || 'Без категории';
|
||||
|
||||
// Парсим параметры - PostgreSQL может вернуть как JSON строку, так и уже распарсенный объект
|
||||
const parameters: any[] = [];
|
||||
const bodyParams: any = {};
|
||||
const bodyRequired: string[] = [];
|
||||
|
||||
let params: any[] = [];
|
||||
if (endpoint.parameters) {
|
||||
if (typeof endpoint.parameters === 'string') {
|
||||
try {
|
||||
params = JSON.parse(endpoint.parameters);
|
||||
} catch (e) {
|
||||
params = [];
|
||||
}
|
||||
} else if (Array.isArray(endpoint.parameters)) {
|
||||
params = endpoint.parameters;
|
||||
}
|
||||
}
|
||||
|
||||
if (params.length > 0) {
|
||||
params.forEach((param: any) => {
|
||||
if (param.in === 'body') {
|
||||
// Body параметры идут в requestBody
|
||||
bodyParams[param.name] = {
|
||||
type: param.type || 'string',
|
||||
description: param.description || '',
|
||||
default: param.default_value,
|
||||
};
|
||||
if (param.required) {
|
||||
bodyRequired.push(param.name);
|
||||
}
|
||||
} else {
|
||||
// Query и path параметры
|
||||
parameters.push({
|
||||
name: param.name,
|
||||
in: param.in || 'query',
|
||||
required: param.required || false,
|
||||
description: param.description || '',
|
||||
schema: {
|
||||
type: param.type || 'string',
|
||||
default: param.default_value,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!paths[swaggerPath]) {
|
||||
paths[swaggerPath] = {} as SwaggerPath;
|
||||
}
|
||||
|
||||
const endpointSpec: any = {
|
||||
tags: [folderName],
|
||||
summary: endpoint.name,
|
||||
description: endpoint.description || '',
|
||||
security: endpoint.is_public ? [] : [{ apiKey: [] }],
|
||||
parameters,
|
||||
responses: {
|
||||
'200': {
|
||||
description: 'Успешный ответ',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: { type: 'boolean' },
|
||||
data: { type: 'array', items: { type: 'object' } },
|
||||
rowCount: { type: 'number' },
|
||||
executionTime: { type: 'number' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'400': {
|
||||
description: 'Ошибка в запросе',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: { type: 'string' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'401': {
|
||||
description: 'Не авторизован (неверный или отсутствующий API ключ)',
|
||||
},
|
||||
'403': {
|
||||
description: 'Доступ запрещен (нет прав на этот эндпоинт)',
|
||||
},
|
||||
'500': {
|
||||
description: 'Внутренняя ошибка сервера',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Добавляем requestBody если есть body параметры
|
||||
if (Object.keys(bodyParams).length > 0) {
|
||||
endpointSpec.requestBody = {
|
||||
required: bodyRequired.length > 0,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: bodyParams,
|
||||
required: bodyRequired,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
paths[swaggerPath][method] = endpointSpec;
|
||||
});
|
||||
|
||||
return {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'KIS API Builder - Созданные API эндпоинты',
|
||||
version: '1.0.0',
|
||||
description: `
|
||||
# KIS API Builder - Документация
|
||||
|
||||
## Авторизация
|
||||
|
||||
Для доступа к эндпоинтам используйте API ключ, полученный от администратора системы.
|
||||
|
||||
1. Нажмите кнопку **Authorize** справа вверху
|
||||
2. Введите ваш API ключ в поле **x-api-key**
|
||||
3. Нажмите **Authorize**
|
||||
|
||||
Теперь вы можете тестировать доступные вам эндпоинты прямо в этой документации.
|
||||
|
||||
## Примечание
|
||||
|
||||
Публичные эндпоинты доступны без API ключа.
|
||||
`.trim(),
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: '/api/v1',
|
||||
description: 'API эндпоинты',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
apiKey: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'x-api-key',
|
||||
description: 'API ключ для доступа к эндпоинтам',
|
||||
},
|
||||
},
|
||||
},
|
||||
paths,
|
||||
tags,
|
||||
};
|
||||
}
|
||||
47
backend/src/config/environment.ts
Normal file
47
backend/src/config/environment.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import dotenv from 'dotenv';
|
||||
import { DatabaseConfig } from '../types';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3000'),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
|
||||
// Main database (for KIS API Builder metadata)
|
||||
mainDatabase: {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'api_builder',
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
},
|
||||
|
||||
// JWT
|
||||
jwt: {
|
||||
secret: (process.env.JWT_SECRET || 'default-secret-change-in-production') as string,
|
||||
expiresIn: (process.env.JWT_EXPIRES_IN || '24h') as string,
|
||||
},
|
||||
|
||||
// Rate limiting
|
||||
rateLimit: {
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 minutes
|
||||
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100'),
|
||||
},
|
||||
|
||||
// Target databases (where API queries will execute)
|
||||
targetDatabases: parseTargetDatabases(),
|
||||
};
|
||||
|
||||
function parseTargetDatabases(): DatabaseConfig[] {
|
||||
try {
|
||||
const dbConfig = process.env.TARGET_DATABASES;
|
||||
if (!dbConfig) {
|
||||
console.warn('No TARGET_DATABASES configured. Using empty array.');
|
||||
return [];
|
||||
}
|
||||
return JSON.parse(dbConfig);
|
||||
} catch (error) {
|
||||
console.error('Error parsing TARGET_DATABASES:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
96
backend/src/config/swagger.ts
Normal file
96
backend/src/config/swagger.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
import { config } from './environment';
|
||||
|
||||
const options: swaggerJsdoc.Options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'KIS API Builder - Dynamic API System',
|
||||
version: '1.0.0',
|
||||
description: 'System for constructing and managing dynamic API endpoints with SQL queries',
|
||||
contact: {
|
||||
name: 'KIS API Builder Support',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: `http://localhost:${config.port}`,
|
||||
description: 'Development server',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'x-api-key',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
User: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
username: { type: 'string' },
|
||||
email: { type: 'string', format: 'email' },
|
||||
role: { type: 'string', enum: ['admin', 'user'] },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
Endpoint: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string' },
|
||||
description: { type: 'string' },
|
||||
method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] },
|
||||
path: { type: 'string' },
|
||||
database_id: { type: 'string' },
|
||||
sql_query: { type: 'string' },
|
||||
parameters: { type: 'array' },
|
||||
folder_id: { type: 'string', format: 'uuid', nullable: true },
|
||||
is_public: { type: 'boolean' },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
Folder: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string' },
|
||||
parent_id: { type: 'string', format: 'uuid', nullable: true },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
},
|
||||
},
|
||||
ApiKey: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
name: { type: 'string' },
|
||||
key: { type: 'string' },
|
||||
permissions: { type: 'array', items: { type: 'string' } },
|
||||
is_active: { type: 'boolean' },
|
||||
created_at: { type: 'string', format: 'date-time' },
|
||||
expires_at: { type: 'string', format: 'date-time', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: [
|
||||
{ name: 'Authentication', description: 'Authentication endpoints' },
|
||||
{ name: 'Endpoints', description: 'API endpoint management' },
|
||||
{ name: 'Folders', description: 'Folder management for organizing endpoints' },
|
||||
{ name: 'API Keys', description: 'API key management' },
|
||||
{ name: 'Databases', description: 'Database connection information' },
|
||||
{ name: 'Dynamic API', description: 'Dynamically created API endpoints' },
|
||||
],
|
||||
},
|
||||
apis: ['./src/routes/*.ts'],
|
||||
};
|
||||
|
||||
export const swaggerSpec = swaggerJsdoc(options);
|
||||
107
backend/src/controllers/apiKeyController.ts
Normal file
107
backend/src/controllers/apiKeyController.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { mainPool } from '../config/database';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import crypto from 'crypto';
|
||||
|
||||
export const getApiKeys = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const result = await mainPool.query(
|
||||
`SELECT id, name, key, permissions, is_active, enable_logging, created_at, expires_at
|
||||
FROM api_keys
|
||||
ORDER BY created_at DESC`
|
||||
);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Get API keys error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createApiKey = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { name, permissions, expires_at, enable_logging } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'API key name is required' });
|
||||
}
|
||||
|
||||
// Generate a secure API key
|
||||
const apiKey = `kb_${crypto.randomBytes(32).toString('hex')}`;
|
||||
|
||||
const result = await mainPool.query(
|
||||
`INSERT INTO api_keys (name, key, user_id, permissions, expires_at, enable_logging)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[
|
||||
name,
|
||||
apiKey,
|
||||
req.user!.id,
|
||||
JSON.stringify(permissions || []),
|
||||
expires_at || null,
|
||||
enable_logging || false,
|
||||
]
|
||||
);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Create API key error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateApiKey = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, permissions, is_active, expires_at, enable_logging } = req.body;
|
||||
|
||||
const result = await mainPool.query(
|
||||
`UPDATE api_keys
|
||||
SET name = COALESCE($1, name),
|
||||
permissions = COALESCE($2, permissions),
|
||||
is_active = COALESCE($3, is_active),
|
||||
expires_at = COALESCE($4, expires_at),
|
||||
enable_logging = COALESCE($5, enable_logging)
|
||||
WHERE id = $6
|
||||
RETURNING *`,
|
||||
[
|
||||
name,
|
||||
permissions ? JSON.stringify(permissions) : null,
|
||||
is_active,
|
||||
expires_at,
|
||||
enable_logging,
|
||||
id,
|
||||
]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'API key not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Update API key error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteApiKey = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await mainPool.query(
|
||||
'DELETE FROM api_keys WHERE id = $1 RETURNING id',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'API key not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'API key deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete API key error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
72
backend/src/controllers/authController.ts
Normal file
72
backend/src/controllers/authController.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Request, Response } from 'express';
|
||||
import bcrypt from 'bcrypt';
|
||||
import jwt, { SignOptions } from 'jsonwebtoken';
|
||||
import { mainPool } from '../config/database';
|
||||
import { config } from '../config/environment';
|
||||
|
||||
export const login = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Не заполнены обязательные поля' });
|
||||
}
|
||||
|
||||
// Find user
|
||||
const result = await mainPool.query(
|
||||
'SELECT id, username, password_hash, role, is_superadmin FROM users WHERE username = $1',
|
||||
[username]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Неверные учетные данные' });
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
// Verify password
|
||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: 'Неверные учетные данные' });
|
||||
}
|
||||
|
||||
// Generate token
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id },
|
||||
config.jwt.secret,
|
||||
{ expiresIn: config.jwt.expiresIn as any }
|
||||
);
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
is_superadmin: user.is_superadmin,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getMe = async (req: any, res: Response) => {
|
||||
try {
|
||||
const result = await mainPool.query(
|
||||
'SELECT id, username, role, is_superadmin, created_at FROM users WHERE id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Get me error:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
};
|
||||
66
backend/src/controllers/databaseController.ts
Normal file
66
backend/src/controllers/databaseController.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Request, Response } from 'express';
|
||||
import { databasePoolManager } from '../services/DatabasePoolManager';
|
||||
import { sqlExecutor } from '../services/SqlExecutor';
|
||||
|
||||
export const getDatabases = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const databases = await databasePoolManager.getAllDatabaseConfigs();
|
||||
|
||||
// Don't expose sensitive information like passwords
|
||||
const sanitized = databases.map(db => ({
|
||||
id: db.id,
|
||||
name: db.name,
|
||||
type: db.type,
|
||||
host: db.host,
|
||||
port: db.port,
|
||||
database: db.database_name,
|
||||
}));
|
||||
|
||||
res.json(sanitized);
|
||||
} catch (error) {
|
||||
console.error('Get databases error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const testDatabaseConnection = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { databaseId } = req.params;
|
||||
|
||||
const isConnected = await databasePoolManager.testConnection(databaseId);
|
||||
|
||||
res.json({
|
||||
success: isConnected,
|
||||
message: isConnected ? 'Connection successful' : 'Connection failed',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Test connection error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getDatabaseTables = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { databaseId } = req.params;
|
||||
|
||||
const tables = await sqlExecutor.getAllTables(databaseId);
|
||||
|
||||
res.json({ tables });
|
||||
} catch (error: any) {
|
||||
console.error('Get tables error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getTableSchema = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { databaseId, tableName } = req.params;
|
||||
|
||||
const schema = await sqlExecutor.getTableSchema(databaseId, tableName);
|
||||
|
||||
res.json({ schema });
|
||||
} catch (error: any) {
|
||||
console.error('Get table schema error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
185
backend/src/controllers/databaseManagementController.ts
Normal file
185
backend/src/controllers/databaseManagementController.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { mainPool } from '../config/database';
|
||||
import { databasePoolManager } from '../services/DatabasePoolManager';
|
||||
|
||||
// Только админы могут управлять базами данных
|
||||
export const getDatabases = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const result = await mainPool.query(
|
||||
'SELECT id, name, type, host, port, database_name, username, ssl, is_active, created_at, updated_at FROM databases ORDER BY name'
|
||||
);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Get databases error:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения списка баз данных' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getDatabase = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await mainPool.query(
|
||||
'SELECT id, name, type, host, port, database_name, username, ssl, is_active, created_at, updated_at FROM databases WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'База данных не найдена' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Get database error:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения базы данных' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createDatabase = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { name, type, host, port, database_name, username, password, ssl } = req.body;
|
||||
|
||||
if (!name || !host || !port || !database_name || !username || !password) {
|
||||
return res.status(400).json({ error: 'Не заполнены обязательные поля' });
|
||||
}
|
||||
|
||||
const result = await mainPool.query(
|
||||
`INSERT INTO databases (name, type, host, port, database_name, username, password, ssl, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true)
|
||||
RETURNING *`,
|
||||
[name, type || 'postgresql', host, port, database_name, username, password, ssl || false]
|
||||
);
|
||||
|
||||
const newDb = result.rows[0];
|
||||
|
||||
// Добавить пул подключений
|
||||
await databasePoolManager.reloadPool(newDb.id);
|
||||
|
||||
// Не возвращаем пароль
|
||||
delete newDb.password;
|
||||
|
||||
res.status(201).json(newDb);
|
||||
} catch (error: any) {
|
||||
console.error('Create database error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'База данных с таким именем уже существует' });
|
||||
}
|
||||
res.status(500).json({ error: 'Ошибка создания базы данных' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateDatabase = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, type, host, port, database_name, username, password, ssl, is_active } = req.body;
|
||||
|
||||
// Если пароль не передан, не обновляем его
|
||||
let query;
|
||||
let params;
|
||||
|
||||
if (password) {
|
||||
query = `
|
||||
UPDATE databases
|
||||
SET name = COALESCE($1, name),
|
||||
type = COALESCE($2, type),
|
||||
host = COALESCE($3, host),
|
||||
port = COALESCE($4, port),
|
||||
database_name = COALESCE($5, database_name),
|
||||
username = COALESCE($6, username),
|
||||
password = $7,
|
||||
ssl = COALESCE($8, ssl),
|
||||
is_active = COALESCE($9, is_active),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $10
|
||||
RETURNING id, name, type, host, port, database_name, username, ssl, is_active, created_at, updated_at
|
||||
`;
|
||||
params = [name, type, host, port, database_name, username, password, ssl, is_active, id];
|
||||
} else {
|
||||
query = `
|
||||
UPDATE databases
|
||||
SET name = COALESCE($1, name),
|
||||
type = COALESCE($2, type),
|
||||
host = COALESCE($3, host),
|
||||
port = COALESCE($4, port),
|
||||
database_name = COALESCE($5, database_name),
|
||||
username = COALESCE($6, username),
|
||||
ssl = COALESCE($7, ssl),
|
||||
is_active = COALESCE($8, is_active),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $9
|
||||
RETURNING id, name, type, host, port, database_name, username, ssl, is_active, created_at, updated_at
|
||||
`;
|
||||
params = [name, type, host, port, database_name, username, ssl, is_active, id];
|
||||
}
|
||||
|
||||
const result = await mainPool.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'База данных не найдена' });
|
||||
}
|
||||
|
||||
// Перезагрузить пул
|
||||
await databasePoolManager.reloadPool(id);
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
console.error('Update database error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'База данных с таким именем уже существует' });
|
||||
}
|
||||
res.status(500).json({ error: 'Ошибка обновления базы данных' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteDatabase = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Проверяем, используется ли база данных в эндпоинтах
|
||||
const endpointCheck = await mainPool.query(
|
||||
'SELECT COUNT(*) FROM endpoints WHERE database_id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (parseInt(endpointCheck.rows[0].count) > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Невозможно удалить базу данных, используемую в эндпоинтах'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await mainPool.query(
|
||||
'DELETE FROM databases WHERE id = $1 RETURNING id',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'База данных не найдена' });
|
||||
}
|
||||
|
||||
// Удалить пул
|
||||
databasePoolManager.removePool(id);
|
||||
|
||||
res.json({ message: 'База данных удалена успешно' });
|
||||
} catch (error) {
|
||||
console.error('Delete database error:', error);
|
||||
res.status(500).json({ error: 'Ошибка удаления базы данных' });
|
||||
}
|
||||
};
|
||||
|
||||
export const testDatabaseConnection = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const isConnected = await databasePoolManager.testConnection(id);
|
||||
|
||||
res.json({
|
||||
success: isConnected,
|
||||
message: isConnected ? 'Подключение успешно' : 'Ошибка подключения',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Test connection error:', error);
|
||||
res.status(500).json({ error: 'Ошибка тестирования подключения' });
|
||||
}
|
||||
};
|
||||
303
backend/src/controllers/dynamicApiController.ts
Normal file
303
backend/src/controllers/dynamicApiController.ts
Normal file
@@ -0,0 +1,303 @@
|
||||
import { Response } from 'express';
|
||||
import { ApiKeyRequest } from '../middleware/apiKey';
|
||||
import { mainPool } from '../config/database';
|
||||
import { sqlExecutor } from '../services/SqlExecutor';
|
||||
import { scriptExecutor } from '../services/ScriptExecutor';
|
||||
import { EndpointParameter, ScriptQuery } from '../types';
|
||||
|
||||
export const executeDynamicEndpoint = async (req: ApiKeyRequest, res: Response) => {
|
||||
const startTime = Date.now();
|
||||
let shouldLog = false;
|
||||
let endpointId: string | null = null;
|
||||
|
||||
try {
|
||||
// Extract the path from the request (remove /api/v1 prefix)
|
||||
const requestPath = req.path; // This already has the path without /api/v1
|
||||
const requestMethod = req.method.toUpperCase();
|
||||
|
||||
// Fetch endpoint configuration by path and method
|
||||
const endpointResult = await mainPool.query(
|
||||
'SELECT * FROM endpoints WHERE path = $1 AND method = $2',
|
||||
[requestPath, requestMethod]
|
||||
);
|
||||
|
||||
if (endpointResult.rows.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'Endpoint not found',
|
||||
path: requestPath,
|
||||
method: requestMethod
|
||||
});
|
||||
}
|
||||
|
||||
const endpoint = endpointResult.rows[0];
|
||||
endpointId = endpoint.id;
|
||||
|
||||
// Check if logging is enabled (on endpoint OR on API key, but log only once)
|
||||
const endpointLogging = endpoint.enable_logging || false;
|
||||
const apiKeyLogging = req.apiKey?.enable_logging || false;
|
||||
shouldLog = endpointLogging || apiKeyLogging;
|
||||
|
||||
// Check if endpoint is public or if API key has permission
|
||||
if (!endpoint.is_public) {
|
||||
if (!req.apiKey) {
|
||||
return res.status(401).json({ error: 'API key required for this endpoint' });
|
||||
}
|
||||
|
||||
let hasPermission = req.apiKey.permissions.includes(endpointId!) ||
|
||||
req.apiKey.permissions.includes('*');
|
||||
|
||||
// If no direct permission, check folder permissions
|
||||
if (!hasPermission && endpoint.folder_id) {
|
||||
// Check if this folder or any parent folder has permission
|
||||
let currentFolderId: string | null = endpoint.folder_id;
|
||||
|
||||
while (currentFolderId && !hasPermission) {
|
||||
if (req.apiKey.permissions.includes(`folder:${currentFolderId}`)) {
|
||||
hasPermission = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Get parent folder
|
||||
const folderResult = await mainPool.query(
|
||||
'SELECT parent_id FROM folders WHERE id = $1',
|
||||
[currentFolderId]
|
||||
);
|
||||
|
||||
currentFolderId = folderResult.rows.length > 0 ? folderResult.rows[0].parent_id : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasPermission) {
|
||||
return res.status(403).json({ error: 'Access denied to this endpoint' });
|
||||
}
|
||||
}
|
||||
|
||||
// Parse parameters - PostgreSQL может вернуть как JSON строку, так и уже распарсенный объект
|
||||
let parameters: EndpointParameter[] = [];
|
||||
if (endpoint.parameters) {
|
||||
if (typeof endpoint.parameters === 'string') {
|
||||
try {
|
||||
parameters = JSON.parse(endpoint.parameters);
|
||||
} catch (e) {
|
||||
parameters = [];
|
||||
}
|
||||
} else if (Array.isArray(endpoint.parameters)) {
|
||||
parameters = endpoint.parameters;
|
||||
}
|
||||
}
|
||||
// Build request parameters object
|
||||
const requestParams: Record<string, any> = {};
|
||||
|
||||
// Extract and validate parameters from request
|
||||
for (const param of parameters) {
|
||||
let value;
|
||||
|
||||
if (param.in === 'query') {
|
||||
value = req.query[param.name];
|
||||
} else if (param.in === 'body') {
|
||||
value = req.body[param.name];
|
||||
} else if (param.in === 'path') {
|
||||
value = req.params[param.name];
|
||||
}
|
||||
|
||||
// Use default value if not provided
|
||||
if (value === undefined || value === null) {
|
||||
if (param.required) {
|
||||
return res.status(400).json({
|
||||
error: `Missing required parameter: ${param.name}`,
|
||||
});
|
||||
}
|
||||
value = param.default_value;
|
||||
}
|
||||
|
||||
// Type conversion
|
||||
if (value !== undefined && value !== null) {
|
||||
switch (param.type) {
|
||||
case 'number':
|
||||
value = Number(value);
|
||||
if (isNaN(value)) {
|
||||
return res.status(400).json({
|
||||
error: `Parameter ${param.name} must be a number`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'boolean':
|
||||
value = value === 'true' || value === true;
|
||||
break;
|
||||
case 'date':
|
||||
value = new Date(value);
|
||||
if (isNaN(value.getTime())) {
|
||||
return res.status(400).json({
|
||||
error: `Parameter ${param.name} must be a valid date`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
requestParams[param.name] = value;
|
||||
}
|
||||
|
||||
let result;
|
||||
const executionType = endpoint.execution_type || 'sql';
|
||||
|
||||
if (executionType === 'script') {
|
||||
// Execute script
|
||||
const scriptLanguage = endpoint.script_language;
|
||||
const scriptCode = endpoint.script_code;
|
||||
|
||||
let scriptQueries: ScriptQuery[] = [];
|
||||
if (endpoint.script_queries) {
|
||||
if (typeof endpoint.script_queries === 'string') {
|
||||
try {
|
||||
scriptQueries = JSON.parse(endpoint.script_queries);
|
||||
} catch (e) {
|
||||
scriptQueries = [];
|
||||
}
|
||||
} else if (Array.isArray(endpoint.script_queries)) {
|
||||
scriptQueries = endpoint.script_queries;
|
||||
}
|
||||
}
|
||||
|
||||
if (!scriptLanguage || !scriptCode) {
|
||||
return res.status(500).json({ error: 'Script configuration is incomplete' });
|
||||
}
|
||||
|
||||
const scriptResult = await scriptExecutor.execute(scriptLanguage, scriptCode, {
|
||||
databaseId: endpoint.database_id,
|
||||
scriptQueries,
|
||||
requestParams,
|
||||
endpointParameters: parameters,
|
||||
});
|
||||
|
||||
result = {
|
||||
rows: scriptResult.data || scriptResult,
|
||||
rowCount: scriptResult.rowCount || (Array.isArray(scriptResult.data) ? scriptResult.data.length : 0),
|
||||
executionTime: scriptResult.executionTime || 0,
|
||||
};
|
||||
} else {
|
||||
// Execute SQL query
|
||||
const queryParams: any[] = [];
|
||||
|
||||
parameters.forEach((param) => {
|
||||
queryParams.push(requestParams[param.name]);
|
||||
});
|
||||
|
||||
// Преобразуем именованные параметры ($paramName) в позиционные ($1, $2, $3...)
|
||||
let processedQuery = endpoint.sql_query;
|
||||
|
||||
parameters.forEach((param, index) => {
|
||||
const paramName = param.name;
|
||||
const position = index + 1;
|
||||
|
||||
// Заменяем все вхождения $paramName на $position
|
||||
const regex = new RegExp(`\\$${paramName}\\b`, 'g');
|
||||
processedQuery = processedQuery.replace(regex, `$${position}`);
|
||||
});
|
||||
|
||||
result = await sqlExecutor.executeQuery(
|
||||
endpoint.database_id,
|
||||
processedQuery,
|
||||
queryParams
|
||||
);
|
||||
}
|
||||
|
||||
const responseData = {
|
||||
success: true,
|
||||
data: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
executionTime: result.executionTime,
|
||||
};
|
||||
|
||||
// Log if needed
|
||||
if (shouldLog && endpointId) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
await logRequest({
|
||||
endpoint_id: endpointId,
|
||||
api_key_id: req.apiKey?.id || null,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
request_params: req.query || {},
|
||||
request_body: req.body || {},
|
||||
response_status: 200,
|
||||
response_data: responseData,
|
||||
execution_time: executionTime,
|
||||
error_message: null,
|
||||
ip_address: req.ip || req.socket.remoteAddress || 'unknown',
|
||||
user_agent: req.headers['user-agent'] || 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
res.json(responseData);
|
||||
} catch (error: any) {
|
||||
console.error('Dynamic API execution error:', error);
|
||||
|
||||
const errorResponse = {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
|
||||
// Log error if needed
|
||||
if (shouldLog && endpointId) {
|
||||
const executionTime = Date.now() - startTime;
|
||||
await logRequest({
|
||||
endpoint_id: endpointId,
|
||||
api_key_id: req.apiKey?.id || null,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
request_params: req.query || {},
|
||||
request_body: req.body || {},
|
||||
response_status: 500,
|
||||
response_data: errorResponse,
|
||||
execution_time: executionTime,
|
||||
error_message: error.message,
|
||||
ip_address: req.ip || req.socket.remoteAddress || 'unknown',
|
||||
user_agent: req.headers['user-agent'] || 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json(errorResponse);
|
||||
}
|
||||
};
|
||||
|
||||
async function logRequest(data: {
|
||||
endpoint_id: string;
|
||||
api_key_id: string | null;
|
||||
method: string;
|
||||
path: string;
|
||||
request_params: any;
|
||||
request_body: any;
|
||||
response_status: number;
|
||||
response_data: any;
|
||||
execution_time: number;
|
||||
error_message: string | null;
|
||||
ip_address: string;
|
||||
user_agent: string;
|
||||
}) {
|
||||
try {
|
||||
await mainPool.query(
|
||||
`INSERT INTO request_logs (
|
||||
endpoint_id, api_key_id, method, path,
|
||||
request_params, request_body, response_status,
|
||||
response_data, execution_time, error_message,
|
||||
ip_address, user_agent
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
[
|
||||
data.endpoint_id,
|
||||
data.api_key_id,
|
||||
data.method,
|
||||
data.path,
|
||||
JSON.stringify(data.request_params),
|
||||
JSON.stringify(data.request_body),
|
||||
data.response_status,
|
||||
JSON.stringify(data.response_data),
|
||||
data.execution_time,
|
||||
data.error_message,
|
||||
data.ip_address,
|
||||
data.user_agent,
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to log request:', error);
|
||||
}
|
||||
}
|
||||
317
backend/src/controllers/endpointController.ts
Normal file
317
backend/src/controllers/endpointController.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { mainPool } from '../config/database';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const getEndpoints = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { search, folder_id } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT e.*, f.name as folder_name
|
||||
FROM endpoints e
|
||||
LEFT JOIN folders f ON e.folder_id = f.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params: any[] = [];
|
||||
|
||||
if (folder_id) {
|
||||
query += ` AND e.folder_id = $${params.length + 1}`;
|
||||
params.push(folder_id);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const searchIndex = params.length + 1;
|
||||
query += ` AND (
|
||||
e.name ILIKE $${searchIndex} OR
|
||||
e.description ILIKE $${searchIndex} OR
|
||||
e.sql_query ILIKE $${searchIndex} OR
|
||||
e.path ILIKE $${searchIndex}
|
||||
)`;
|
||||
params.push(`%${search}%`);
|
||||
}
|
||||
|
||||
query += ` ORDER BY e.created_at DESC`;
|
||||
|
||||
const result = await mainPool.query(query, params);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Get endpoints error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await mainPool.query(
|
||||
`SELECT e.*, f.name as folder_name
|
||||
FROM endpoints e
|
||||
LEFT JOIN folders f ON e.folder_id = f.id
|
||||
WHERE e.id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Endpoint not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Get endpoint error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
method,
|
||||
path,
|
||||
database_id,
|
||||
sql_query,
|
||||
parameters,
|
||||
folder_id,
|
||||
is_public,
|
||||
enable_logging,
|
||||
execution_type,
|
||||
script_language,
|
||||
script_code,
|
||||
script_queries,
|
||||
} = req.body;
|
||||
|
||||
if (!name || !method || !path) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
const execType = execution_type || 'sql';
|
||||
|
||||
// Валидация для типа SQL
|
||||
if (execType === 'sql') {
|
||||
if (!database_id || !sql_query) {
|
||||
return res.status(400).json({ error: 'Database ID and SQL query are required for SQL execution type' });
|
||||
}
|
||||
}
|
||||
|
||||
// Валидация для типа Script
|
||||
if (execType === 'script') {
|
||||
if (!script_language || !script_code || !script_queries) {
|
||||
return res.status(400).json({ error: 'Script language, code, and queries are required for script execution type' });
|
||||
}
|
||||
}
|
||||
|
||||
const result = await mainPool.query(
|
||||
`INSERT INTO endpoints (
|
||||
name, description, method, path, database_id, sql_query, parameters,
|
||||
folder_id, user_id, is_public, enable_logging,
|
||||
execution_type, script_language, script_code, script_queries
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
|
||||
RETURNING *`,
|
||||
[
|
||||
name,
|
||||
description || '',
|
||||
method,
|
||||
path,
|
||||
database_id || null,
|
||||
sql_query || '',
|
||||
JSON.stringify(parameters || []),
|
||||
folder_id || null,
|
||||
req.user!.id,
|
||||
is_public || false,
|
||||
enable_logging || false,
|
||||
execType,
|
||||
script_language || null,
|
||||
script_code || null,
|
||||
JSON.stringify(script_queries || []),
|
||||
]
|
||||
);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
console.error('Create endpoint error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'Endpoint path already exists' });
|
||||
}
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
method,
|
||||
path,
|
||||
database_id,
|
||||
sql_query,
|
||||
parameters,
|
||||
folder_id,
|
||||
is_public,
|
||||
enable_logging,
|
||||
execution_type,
|
||||
script_language,
|
||||
script_code,
|
||||
script_queries,
|
||||
} = req.body;
|
||||
|
||||
const result = await mainPool.query(
|
||||
`UPDATE endpoints
|
||||
SET name = $1,
|
||||
description = $2,
|
||||
method = $3,
|
||||
path = $4,
|
||||
database_id = $5,
|
||||
sql_query = $6,
|
||||
parameters = $7,
|
||||
folder_id = $8,
|
||||
is_public = $9,
|
||||
enable_logging = $10,
|
||||
execution_type = $11,
|
||||
script_language = $12,
|
||||
script_code = $13,
|
||||
script_queries = $14,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $15
|
||||
RETURNING *`,
|
||||
[
|
||||
name,
|
||||
description,
|
||||
method,
|
||||
path,
|
||||
database_id || null,
|
||||
sql_query,
|
||||
parameters ? JSON.stringify(parameters) : null,
|
||||
folder_id || null,
|
||||
is_public,
|
||||
enable_logging,
|
||||
execution_type,
|
||||
script_language || null,
|
||||
script_code || null,
|
||||
script_queries ? JSON.stringify(script_queries) : null,
|
||||
id,
|
||||
]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Endpoint not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
console.error('Update endpoint error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'Endpoint path already exists' });
|
||||
}
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await mainPool.query(
|
||||
'DELETE FROM endpoints WHERE id = $1 RETURNING id',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Endpoint not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Endpoint deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete endpoint error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const testEndpoint = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
database_id,
|
||||
sql_query,
|
||||
parameters,
|
||||
endpoint_parameters,
|
||||
execution_type,
|
||||
script_language,
|
||||
script_code,
|
||||
script_queries
|
||||
} = req.body;
|
||||
|
||||
const execType = execution_type || 'sql';
|
||||
|
||||
if (execType === 'sql') {
|
||||
if (!database_id) {
|
||||
return res.status(400).json({ error: 'Missing database_id for SQL execution' });
|
||||
}
|
||||
if (!sql_query) {
|
||||
return res.status(400).json({ error: 'Missing sql_query' });
|
||||
}
|
||||
|
||||
// Преобразуем именованные параметры ($paramName) в позиционные ($1, $2, $3...)
|
||||
let processedQuery = sql_query;
|
||||
|
||||
if (endpoint_parameters && Array.isArray(endpoint_parameters)) {
|
||||
endpoint_parameters.forEach((param: any, index: number) => {
|
||||
const paramName = param.name;
|
||||
const position = index + 1;
|
||||
|
||||
// Заменяем все вхождения $paramName на $position
|
||||
const regex = new RegExp(`\\$${paramName}\\b`, 'g');
|
||||
processedQuery = processedQuery.replace(regex, `$${position}`);
|
||||
});
|
||||
}
|
||||
|
||||
const { sqlExecutor } = require('../services/SqlExecutor');
|
||||
const result = await sqlExecutor.executeQuery(database_id, processedQuery, parameters || []);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
executionTime: result.executionTime,
|
||||
});
|
||||
} else if (execType === 'script') {
|
||||
if (!script_language || !script_code) {
|
||||
return res.status(400).json({ error: 'Missing script_language or script_code' });
|
||||
}
|
||||
|
||||
// Собираем параметры из тестовых значений
|
||||
const requestParams: Record<string, any> = {};
|
||||
if (endpoint_parameters && Array.isArray(endpoint_parameters) && parameters && Array.isArray(parameters)) {
|
||||
endpoint_parameters.forEach((param: any, index: number) => {
|
||||
requestParams[param.name] = parameters[index];
|
||||
});
|
||||
}
|
||||
|
||||
const { scriptExecutor } = require('../services/ScriptExecutor');
|
||||
const scriptResult = await scriptExecutor.execute(script_language, script_code, {
|
||||
databaseId: database_id,
|
||||
scriptQueries: script_queries || [],
|
||||
requestParams,
|
||||
endpointParameters: endpoint_parameters || [],
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: scriptResult.data || scriptResult,
|
||||
rowCount: scriptResult.rowCount || (Array.isArray(scriptResult.data) ? scriptResult.data.length : 0),
|
||||
executionTime: scriptResult.executionTime || 0,
|
||||
});
|
||||
} else {
|
||||
return res.status(400).json({ error: 'Invalid execution_type' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
108
backend/src/controllers/folderController.ts
Normal file
108
backend/src/controllers/folderController.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { mainPool } from '../config/database';
|
||||
|
||||
export const getFolders = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const result = await mainPool.query(
|
||||
`SELECT f.*,
|
||||
(SELECT COUNT(*) FROM endpoints WHERE folder_id = f.id) as endpoint_count,
|
||||
(SELECT COUNT(*) FROM folders WHERE parent_id = f.id) as subfolder_count
|
||||
FROM folders f
|
||||
ORDER BY f.name`
|
||||
);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Get folders error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const getFolder = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await mainPool.query(
|
||||
'SELECT * FROM folders WHERE id = $1',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Folder not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Get folder error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createFolder = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { name, parent_id } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Folder name is required' });
|
||||
}
|
||||
|
||||
const result = await mainPool.query(
|
||||
`INSERT INTO folders (name, parent_id, user_id)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *`,
|
||||
[name, parent_id || null, req.user!.id]
|
||||
);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Create folder error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateFolder = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, parent_id } = req.body;
|
||||
|
||||
const result = await mainPool.query(
|
||||
`UPDATE folders
|
||||
SET name = COALESCE($1, name),
|
||||
parent_id = COALESCE($2, parent_id),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $3
|
||||
RETURNING *`,
|
||||
[name, parent_id, id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Folder not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Update folder error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteFolder = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await mainPool.query(
|
||||
'DELETE FROM folders WHERE id = $1 RETURNING id',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Folder not found' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Folder deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Delete folder error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
122
backend/src/controllers/logsController.ts
Normal file
122
backend/src/controllers/logsController.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { mainPool } from '../config/database';
|
||||
|
||||
export const getLogs = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { endpoint_id, api_key_id, limit = 100, offset = 0 } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
rl.*,
|
||||
e.name as endpoint_name,
|
||||
e.path as endpoint_path,
|
||||
ak.name as api_key_name
|
||||
FROM request_logs rl
|
||||
LEFT JOIN endpoints e ON rl.endpoint_id = e.id
|
||||
LEFT JOIN api_keys ak ON rl.api_key_id = ak.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
const params: any[] = [];
|
||||
let paramCount = 0;
|
||||
|
||||
if (endpoint_id) {
|
||||
paramCount++;
|
||||
query += ` AND rl.endpoint_id = $${paramCount}`;
|
||||
params.push(endpoint_id);
|
||||
}
|
||||
|
||||
if (api_key_id) {
|
||||
paramCount++;
|
||||
query += ` AND rl.api_key_id = $${paramCount}`;
|
||||
params.push(api_key_id);
|
||||
}
|
||||
|
||||
query += ` ORDER BY rl.created_at DESC LIMIT $${paramCount + 1} OFFSET $${paramCount + 2}`;
|
||||
params.push(limit, offset);
|
||||
|
||||
const result = await mainPool.query(query, params);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error: any) {
|
||||
console.error('Get logs error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const getLogById = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await mainPool.query(
|
||||
`SELECT
|
||||
rl.*,
|
||||
e.name as endpoint_name,
|
||||
e.path as endpoint_path,
|
||||
ak.name as api_key_name
|
||||
FROM request_logs rl
|
||||
LEFT JOIN endpoints e ON rl.endpoint_id = e.id
|
||||
LEFT JOIN api_keys ak ON rl.api_key_id = ak.id
|
||||
WHERE rl.id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Log not found' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
console.error('Get log by id error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteLog = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
await mainPool.query('DELETE FROM request_logs WHERE id = $1', [id]);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
console.error('Delete log error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
export const clearLogs = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { endpoint_id, api_key_id, before_date } = req.body;
|
||||
|
||||
let query = 'DELETE FROM request_logs WHERE 1=1';
|
||||
const params: any[] = [];
|
||||
let paramCount = 0;
|
||||
|
||||
if (endpoint_id) {
|
||||
paramCount++;
|
||||
query += ` AND endpoint_id = $${paramCount}`;
|
||||
params.push(endpoint_id);
|
||||
}
|
||||
|
||||
if (api_key_id) {
|
||||
paramCount++;
|
||||
query += ` AND api_key_id = $${paramCount}`;
|
||||
params.push(api_key_id);
|
||||
}
|
||||
|
||||
if (before_date) {
|
||||
paramCount++;
|
||||
query += ` AND created_at < $${paramCount}`;
|
||||
params.push(before_date);
|
||||
}
|
||||
|
||||
const result = await mainPool.query(query, params);
|
||||
|
||||
res.json({ success: true, deleted: result.rowCount });
|
||||
} catch (error: any) {
|
||||
console.error('Clear logs error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
119
backend/src/controllers/userController.ts
Normal file
119
backend/src/controllers/userController.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { Response } from 'express';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { mainPool } from '../config/database';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
export const getUsers = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const result = await mainPool.query(
|
||||
'SELECT id, username, role, is_superadmin, created_at, updated_at FROM users ORDER BY created_at DESC'
|
||||
);
|
||||
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Get users error:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения списка пользователей' });
|
||||
}
|
||||
};
|
||||
|
||||
export const createUser = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { username, password, role, is_superadmin } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Не заполнены обязательные поля' });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
|
||||
const result = await mainPool.query(
|
||||
`INSERT INTO users (username, password_hash, role, is_superadmin)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, username, role, is_superadmin, created_at`,
|
||||
[username, passwordHash, role || 'admin', is_superadmin || false]
|
||||
);
|
||||
|
||||
res.status(201).json(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
console.error('Create user error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'Пользователь уже существует' });
|
||||
}
|
||||
res.status(500).json({ error: 'Ошибка создания пользователя' });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateUser = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { username, password, role, is_superadmin } = req.body;
|
||||
|
||||
let query;
|
||||
let params;
|
||||
|
||||
if (password) {
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
query = `
|
||||
UPDATE users
|
||||
SET username = COALESCE($1, username),
|
||||
password_hash = $2,
|
||||
role = COALESCE($3, role),
|
||||
is_superadmin = COALESCE($4, is_superadmin),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $5
|
||||
RETURNING id, username, role, is_superadmin, created_at, updated_at
|
||||
`;
|
||||
params = [username, passwordHash, role, is_superadmin, id];
|
||||
} else {
|
||||
query = `
|
||||
UPDATE users
|
||||
SET username = COALESCE($1, username),
|
||||
role = COALESCE($2, role),
|
||||
is_superadmin = COALESCE($3, is_superadmin),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $4
|
||||
RETURNING id, username, role, is_superadmin, created_at, updated_at
|
||||
`;
|
||||
params = [username, role, is_superadmin, id];
|
||||
}
|
||||
|
||||
const result = await mainPool.query(query, params);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||
}
|
||||
|
||||
res.json(result.rows[0]);
|
||||
} catch (error: any) {
|
||||
console.error('Update user error:', error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(400).json({ error: 'Пользователь с таким именем уже существует' });
|
||||
}
|
||||
res.status(500).json({ error: 'Ошибка обновления пользователя' });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteUser = async (req: AuthRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Нельзя удалить самого себя
|
||||
if (id === req.user!.id) {
|
||||
return res.status(400).json({ error: 'Нельзя удалить самого себя' });
|
||||
}
|
||||
|
||||
const result = await mainPool.query(
|
||||
'DELETE FROM users WHERE id = $1 RETURNING id',
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Пользователь не найден' });
|
||||
}
|
||||
|
||||
res.json({ message: 'Пользователь удален успешно' });
|
||||
} catch (error) {
|
||||
console.error('Delete user error:', error);
|
||||
res.status(500).json({ error: 'Ошибка удаления пользователя' });
|
||||
}
|
||||
};
|
||||
83
backend/src/middleware/apiKey.ts
Normal file
83
backend/src/middleware/apiKey.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { mainPool } from '../config/database';
|
||||
|
||||
export interface ApiKeyRequest extends Request {
|
||||
apiKey?: {
|
||||
id: string;
|
||||
name: string;
|
||||
permissions: string[];
|
||||
user_id: string;
|
||||
enable_logging: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// Optional API key middleware - validates if key is provided but doesn't require it
|
||||
export const apiKeyMiddleware = async (
|
||||
req: ApiKeyRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
try {
|
||||
const apiKey = req.headers['x-api-key'] as string;
|
||||
|
||||
// If no API key provided, continue without setting req.apiKey
|
||||
// The endpoint controller will decide if key is required
|
||||
if (!apiKey) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Fetch API key from database
|
||||
const result = await mainPool.query(
|
||||
`SELECT id, name, user_id, permissions, is_active, expires_at, enable_logging
|
||||
FROM api_keys
|
||||
WHERE key = $1`,
|
||||
[apiKey]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Invalid API key' });
|
||||
}
|
||||
|
||||
const keyData = result.rows[0];
|
||||
|
||||
// Check if key is active
|
||||
if (!keyData.is_active) {
|
||||
return res.status(401).json({ error: 'API key is inactive' });
|
||||
}
|
||||
|
||||
// Check if key is expired
|
||||
if (keyData.expires_at && new Date(keyData.expires_at) < new Date()) {
|
||||
return res.status(401).json({ error: 'API key has expired' });
|
||||
}
|
||||
|
||||
req.apiKey = {
|
||||
id: keyData.id,
|
||||
name: keyData.name,
|
||||
permissions: keyData.permissions || [],
|
||||
user_id: keyData.user_id,
|
||||
enable_logging: keyData.enable_logging || false,
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('API key middleware error:', error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const checkEndpointPermission = (endpointId: string) => {
|
||||
return (req: ApiKeyRequest, res: Response, next: NextFunction) => {
|
||||
if (!req.apiKey) {
|
||||
return res.status(401).json({ error: 'API key required' });
|
||||
}
|
||||
|
||||
const hasPermission = req.apiKey.permissions.includes(endpointId) ||
|
||||
req.apiKey.permissions.includes('*');
|
||||
|
||||
if (!hasPermission) {
|
||||
return res.status(403).json({ error: 'Access denied to this endpoint' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
65
backend/src/middleware/auth.ts
Normal file
65
backend/src/middleware/auth.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config/environment';
|
||||
import { mainPool } from '../config/database';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
user?: {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
is_superadmin: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export const authMiddleware = async (
|
||||
req: AuthRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'No token provided' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as any;
|
||||
|
||||
// Fetch user from database
|
||||
const result = await mainPool.query(
|
||||
'SELECT id, username, role, is_superadmin FROM users WHERE id = $1',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
req.user = result.rows[0];
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth middleware error:', error);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
export const adminOnly = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
if (req.user?.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
export const superAdminOnly = (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
if (!req.user?.is_superadmin) {
|
||||
return res.status(403).json({ error: 'Superadmin access required' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
107
backend/src/middleware/logging.ts
Normal file
107
backend/src/middleware/logging.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Response, NextFunction } from 'express';
|
||||
import { ApiKeyRequest } from './apiKey';
|
||||
import { mainPool } from '../config/database';
|
||||
|
||||
interface LogData {
|
||||
endpoint_id: string | null;
|
||||
api_key_id: string | null;
|
||||
method: string;
|
||||
path: string;
|
||||
request_params: any;
|
||||
request_body: any;
|
||||
response_status: number;
|
||||
response_data: any;
|
||||
execution_time: number;
|
||||
error_message: string | null;
|
||||
ip_address: string;
|
||||
user_agent: string;
|
||||
}
|
||||
|
||||
export const createLoggingMiddleware = (endpointId: string, shouldLog: boolean) => {
|
||||
return async (req: ApiKeyRequest, res: Response, next: NextFunction) => {
|
||||
if (!shouldLog) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
// Capture original methods
|
||||
const originalJson = res.json.bind(res);
|
||||
const originalSend = res.send.bind(res);
|
||||
|
||||
let responseData: any = null;
|
||||
let isLogged = false;
|
||||
|
||||
const logRequest = async (data: any, status: number, errorMsg: string | null = null) => {
|
||||
if (isLogged) return; // Prevent duplicate logging
|
||||
isLogged = true;
|
||||
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
const logData: LogData = {
|
||||
endpoint_id: endpointId,
|
||||
api_key_id: req.apiKey?.id || null,
|
||||
method: req.method,
|
||||
path: req.path,
|
||||
request_params: req.query || {},
|
||||
request_body: req.body || {},
|
||||
response_status: status,
|
||||
response_data: data,
|
||||
execution_time: executionTime,
|
||||
error_message: errorMsg,
|
||||
ip_address: req.ip || req.socket.remoteAddress || 'unknown',
|
||||
user_agent: req.headers['user-agent'] || 'unknown',
|
||||
};
|
||||
|
||||
try {
|
||||
await mainPool.query(
|
||||
`INSERT INTO request_logs (
|
||||
endpoint_id, api_key_id, method, path,
|
||||
request_params, request_body, response_status,
|
||||
response_data, execution_time, error_message,
|
||||
ip_address, user_agent
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`,
|
||||
[
|
||||
logData.endpoint_id,
|
||||
logData.api_key_id,
|
||||
logData.method,
|
||||
logData.path,
|
||||
JSON.stringify(logData.request_params),
|
||||
JSON.stringify(logData.request_body),
|
||||
logData.response_status,
|
||||
JSON.stringify(logData.response_data),
|
||||
logData.execution_time,
|
||||
logData.error_message,
|
||||
logData.ip_address,
|
||||
logData.user_agent,
|
||||
]
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to log request:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Override res.json
|
||||
res.json = function (data: any) {
|
||||
responseData = data;
|
||||
logRequest(data, res.statusCode);
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
// Override res.send
|
||||
res.send = function (data: any) {
|
||||
responseData = data;
|
||||
logRequest(data, res.statusCode);
|
||||
return originalSend(data);
|
||||
};
|
||||
|
||||
// Handle errors
|
||||
res.on('finish', () => {
|
||||
if (!isLogged && responseData === null) {
|
||||
logRequest(null, res.statusCode);
|
||||
}
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
81
backend/src/migrations/001_initial_schema.sql
Normal file
81
backend/src/migrations/001_initial_schema.sql
Normal file
@@ -0,0 +1,81 @@
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(255) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(50) NOT NULL DEFAULT 'user' CHECK (role IN ('admin', 'user')),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Folders table (for organizing endpoints)
|
||||
CREATE TABLE IF NOT EXISTS folders (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Databases table (target databases for API endpoints)
|
||||
CREATE TABLE IF NOT EXISTS databases (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL UNIQUE,
|
||||
type VARCHAR(50) NOT NULL DEFAULT 'postgresql' CHECK (type IN ('postgresql', 'mysql', 'mssql')),
|
||||
host VARCHAR(255) NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
database_name VARCHAR(255) NOT NULL,
|
||||
username VARCHAR(255) NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
ssl BOOLEAN DEFAULT false,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Endpoints table
|
||||
CREATE TABLE IF NOT EXISTS endpoints (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
method VARCHAR(10) NOT NULL CHECK (method IN ('GET', 'POST', 'PUT', 'DELETE', 'PATCH')),
|
||||
path VARCHAR(500) NOT NULL UNIQUE,
|
||||
database_id UUID REFERENCES databases(id) ON DELETE RESTRICT,
|
||||
sql_query TEXT NOT NULL,
|
||||
parameters JSONB DEFAULT '[]'::jsonb,
|
||||
folder_id UUID REFERENCES folders(id) ON DELETE SET NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
is_public BOOLEAN DEFAULT false,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- API Keys table
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
key VARCHAR(500) UNIQUE NOT NULL,
|
||||
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||
permissions JSONB DEFAULT '[]'::jsonb,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP
|
||||
);
|
||||
|
||||
-- Indexes for better performance
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_parent_id ON folders(parent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_folders_user_id ON folders(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_endpoints_folder_id ON endpoints(folder_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_endpoints_user_id ON endpoints(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_endpoints_database_id ON endpoints(database_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_endpoints_path ON endpoints(path);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(key);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_databases_name ON databases(name);
|
||||
|
||||
-- Full text search index for endpoints
|
||||
CREATE INDEX IF NOT EXISTS idx_endpoints_search ON endpoints USING gin(
|
||||
to_tsvector('english', name || ' ' || description || ' ' || sql_query)
|
||||
);
|
||||
28
backend/src/migrations/002_add_logging.sql
Normal file
28
backend/src/migrations/002_add_logging.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Add logging fields to endpoints
|
||||
ALTER TABLE endpoints ADD COLUMN IF NOT EXISTS enable_logging BOOLEAN DEFAULT false;
|
||||
|
||||
-- Add logging fields to api_keys
|
||||
ALTER TABLE api_keys ADD COLUMN IF NOT EXISTS enable_logging BOOLEAN DEFAULT false;
|
||||
|
||||
-- Create request_logs table
|
||||
CREATE TABLE IF NOT EXISTS request_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
endpoint_id UUID REFERENCES endpoints(id) ON DELETE CASCADE,
|
||||
api_key_id UUID REFERENCES api_keys(id) ON DELETE SET NULL,
|
||||
method VARCHAR(10) NOT NULL,
|
||||
path VARCHAR(500) NOT NULL,
|
||||
request_params JSONB,
|
||||
request_body JSONB,
|
||||
response_status INTEGER,
|
||||
response_data JSONB,
|
||||
execution_time INTEGER,
|
||||
error_message TEXT,
|
||||
ip_address VARCHAR(45),
|
||||
user_agent TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create index for faster queries
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_endpoint_id ON request_logs(endpoint_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_api_key_id ON request_logs(api_key_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_request_logs_created_at ON request_logs(created_at DESC);
|
||||
14
backend/src/migrations/003_add_scripting.sql
Normal file
14
backend/src/migrations/003_add_scripting.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Добавление поддержки скриптинга для эндпоинтов
|
||||
|
||||
-- Добавляем новые колонки для скриптинга
|
||||
ALTER TABLE endpoints
|
||||
ADD COLUMN execution_type VARCHAR(20) DEFAULT 'sql' CHECK (execution_type IN ('sql', 'script')),
|
||||
ADD COLUMN script_language VARCHAR(20) CHECK (script_language IN ('javascript', 'python')),
|
||||
ADD COLUMN script_code TEXT,
|
||||
ADD COLUMN script_queries JSONB DEFAULT '[]'::jsonb;
|
||||
|
||||
-- Комментарии для документации
|
||||
COMMENT ON COLUMN endpoints.execution_type IS 'Тип выполнения: sql - простой SQL запрос, script - скрипт с несколькими запросами';
|
||||
COMMENT ON COLUMN endpoints.script_language IS 'Язык скрипта: javascript или python';
|
||||
COMMENT ON COLUMN endpoints.script_code IS 'Код скрипта (JS или Python)';
|
||||
COMMENT ON COLUMN endpoints.script_queries IS 'Массив именованных SQL запросов для использования в скрипте: [{"name": "queryName", "sql": "SELECT ..."}]';
|
||||
8
backend/src/migrations/004_add_superadmin.sql
Normal file
8
backend/src/migrations/004_add_superadmin.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Add is_superadmin field to users table
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS is_superadmin BOOLEAN DEFAULT FALSE;
|
||||
|
||||
COMMENT ON COLUMN users.is_superadmin IS 'Является ли пользователь супер-администратором с полным доступом';
|
||||
|
||||
ALTER TABLE users
|
||||
DROP COLUMN IF EXISTS email;
|
||||
24
backend/src/migrations/run.ts
Normal file
24
backend/src/migrations/run.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { mainPool } from '../config/database';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
async function runMigrations() {
|
||||
console.log('Running migrations...');
|
||||
|
||||
try {
|
||||
const migrationFile = fs.readFileSync(
|
||||
path.join(__dirname, '001_initial_schema.sql'),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
await mainPool.query(migrationFile);
|
||||
console.log('✅ Migrations completed successfully');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Migration failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
runMigrations();
|
||||
38
backend/src/migrations/seed.ts
Normal file
38
backend/src/migrations/seed.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { mainPool } from '../config/database';
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
async function seed() {
|
||||
console.log('🌱 Starting seed...');
|
||||
|
||||
try {
|
||||
// Проверяем, есть ли уже пользователи
|
||||
const userCheck = await mainPool.query('SELECT COUNT(*) FROM users');
|
||||
const userCount = parseInt(userCheck.rows[0].count);
|
||||
|
||||
if (userCount > 0) {
|
||||
console.log('⚠️ Users already exist. Skipping seed.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Создаем супер-админа по умолчанию
|
||||
const passwordHash = await bcrypt.hash('admin', 10);
|
||||
|
||||
await mainPool.query(
|
||||
`INSERT INTO users (username, password_hash, role, is_superadmin)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
['admin', passwordHash, 'admin', true]
|
||||
);
|
||||
|
||||
console.log('✅ Default superadmin created:');
|
||||
console.log(' Username: admin');
|
||||
console.log(' Password: admin');
|
||||
console.log(' ⚠️ ВАЖНО: Смените пароль после первого входа!');
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('❌ Seed failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
seed();
|
||||
19
backend/src/routes/apiKeys.ts
Normal file
19
backend/src/routes/apiKeys.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import express from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import {
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
updateApiKey,
|
||||
deleteApiKey,
|
||||
} from '../controllers/apiKeyController';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/', getApiKeys);
|
||||
router.post('/', createApiKey);
|
||||
router.put('/:id', updateApiKey);
|
||||
router.delete('/:id', deleteApiKey);
|
||||
|
||||
export default router;
|
||||
10
backend/src/routes/auth.ts
Normal file
10
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import express from 'express';
|
||||
import { login, getMe } from '../controllers/authController';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/login', login);
|
||||
router.get('/me', authMiddleware, getMe);
|
||||
|
||||
export default router;
|
||||
24
backend/src/routes/databaseManagement.ts
Normal file
24
backend/src/routes/databaseManagement.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import express from 'express';
|
||||
import { authMiddleware, superAdminOnly } from '../middleware/auth';
|
||||
import {
|
||||
getDatabases,
|
||||
getDatabase,
|
||||
createDatabase,
|
||||
updateDatabase,
|
||||
deleteDatabase,
|
||||
testDatabaseConnection,
|
||||
} from '../controllers/databaseManagementController';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
router.use(superAdminOnly);
|
||||
|
||||
router.get('/', getDatabases);
|
||||
router.get('/:id', getDatabase);
|
||||
router.post('/', createDatabase);
|
||||
router.put('/:id', updateDatabase);
|
||||
router.delete('/:id', deleteDatabase);
|
||||
router.get('/:id/test', testDatabaseConnection);
|
||||
|
||||
export default router;
|
||||
19
backend/src/routes/databases.ts
Normal file
19
backend/src/routes/databases.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import express from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import {
|
||||
getDatabases,
|
||||
testDatabaseConnection,
|
||||
getDatabaseTables,
|
||||
getTableSchema,
|
||||
} from '../controllers/databaseController';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/', getDatabases);
|
||||
router.get('/:databaseId/test', testDatabaseConnection);
|
||||
router.get('/:databaseId/tables', getDatabaseTables);
|
||||
router.get('/:databaseId/tables/:tableName/schema', getTableSchema);
|
||||
|
||||
export default router;
|
||||
11
backend/src/routes/dynamic.ts
Normal file
11
backend/src/routes/dynamic.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import express from 'express';
|
||||
import { apiKeyMiddleware } from '../middleware/apiKey';
|
||||
import { executeDynamicEndpoint } from '../controllers/dynamicApiController';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// This route handles all dynamically created API endpoints
|
||||
// Catch all routes after /api/v1/
|
||||
router.all('*', apiKeyMiddleware, executeDynamicEndpoint);
|
||||
|
||||
export default router;
|
||||
133
backend/src/routes/endpoints.ts
Normal file
133
backend/src/routes/endpoints.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import express from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import {
|
||||
getEndpoints,
|
||||
getEndpoint,
|
||||
createEndpoint,
|
||||
updateEndpoint,
|
||||
deleteEndpoint,
|
||||
testEndpoint,
|
||||
} from '../controllers/endpointController';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/endpoints:
|
||||
* get:
|
||||
* tags: [Endpoints]
|
||||
* summary: Get all endpoints
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: search
|
||||
* schema:
|
||||
* type: string
|
||||
* - in: query
|
||||
* name: folder_id
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: List of endpoints
|
||||
*/
|
||||
router.get('/', getEndpoints);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/endpoints/{id}:
|
||||
* get:
|
||||
* tags: [Endpoints]
|
||||
* summary: Get endpoint by ID
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Endpoint details
|
||||
*/
|
||||
router.get('/:id', getEndpoint);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/endpoints:
|
||||
* post:
|
||||
* tags: [Endpoints]
|
||||
* summary: Create new endpoint
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* responses:
|
||||
* 201:
|
||||
* description: Endpoint created
|
||||
*/
|
||||
router.post('/', createEndpoint);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/endpoints/{id}:
|
||||
* put:
|
||||
* tags: [Endpoints]
|
||||
* summary: Update endpoint
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Endpoint updated
|
||||
*/
|
||||
router.put('/:id', updateEndpoint);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/endpoints/{id}:
|
||||
* delete:
|
||||
* tags: [Endpoints]
|
||||
* summary: Delete endpoint
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Endpoint deleted
|
||||
*/
|
||||
router.delete('/:id', deleteEndpoint);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/endpoints/test:
|
||||
* post:
|
||||
* tags: [Endpoints]
|
||||
* summary: Test SQL query
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Query test result
|
||||
*/
|
||||
router.post('/test', testEndpoint);
|
||||
|
||||
export default router;
|
||||
21
backend/src/routes/folders.ts
Normal file
21
backend/src/routes/folders.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import express from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import {
|
||||
getFolders,
|
||||
getFolder,
|
||||
createFolder,
|
||||
updateFolder,
|
||||
deleteFolder,
|
||||
} from '../controllers/folderController';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/', getFolders);
|
||||
router.get('/:id', getFolder);
|
||||
router.post('/', createFolder);
|
||||
router.put('/:id', updateFolder);
|
||||
router.delete('/:id', deleteFolder);
|
||||
|
||||
export default router;
|
||||
14
backend/src/routes/logs.ts
Normal file
14
backend/src/routes/logs.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import express from 'express';
|
||||
import { authMiddleware } from '../middleware/auth';
|
||||
import { getLogs, getLogById, deleteLog, clearLogs } from '../controllers/logsController';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
|
||||
router.get('/', getLogs);
|
||||
router.get('/:id', getLogById);
|
||||
router.delete('/:id', deleteLog);
|
||||
router.post('/clear', clearLogs);
|
||||
|
||||
export default router;
|
||||
20
backend/src/routes/users.ts
Normal file
20
backend/src/routes/users.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import express from 'express';
|
||||
import { authMiddleware, superAdminOnly } from '../middleware/auth';
|
||||
import {
|
||||
getUsers,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser,
|
||||
} from '../controllers/userController';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.use(authMiddleware);
|
||||
router.use(superAdminOnly);
|
||||
|
||||
router.get('/', getUsers);
|
||||
router.post('/', createUser);
|
||||
router.put('/:id', updateUser);
|
||||
router.delete('/:id', deleteUser);
|
||||
|
||||
export default router;
|
||||
179
backend/src/server.ts
Normal file
179
backend/src/server.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import express, { Express, Request, Response } from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import swaggerUi from 'swagger-ui-express';
|
||||
import path from 'path';
|
||||
import { createProxyMiddleware } from 'http-proxy-middleware';
|
||||
import { config } from './config/environment';
|
||||
import { initializeDatabase } from './config/database';
|
||||
import { generateDynamicSwagger } from './config/dynamicSwagger';
|
||||
import { databasePoolManager } from './services/DatabasePoolManager';
|
||||
|
||||
// Routes
|
||||
import authRoutes from './routes/auth';
|
||||
import endpointRoutes from './routes/endpoints';
|
||||
import folderRoutes from './routes/folders';
|
||||
import apiKeyRoutes from './routes/apiKeys';
|
||||
import databaseRoutes from './routes/databases';
|
||||
import databaseManagementRoutes from './routes/databaseManagement';
|
||||
import userRoutes from './routes/users';
|
||||
import logsRoutes from './routes/logs';
|
||||
import dynamicRoutes from './routes/dynamic';
|
||||
|
||||
const app: Express = express();
|
||||
|
||||
// Middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: config.nodeEnv === 'production' ? {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "blob:"],
|
||||
workerSrc: ["'self'", "blob:"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
fontSrc: ["'self'", "data:"],
|
||||
connectSrc: ["'self'"],
|
||||
},
|
||||
} : false,
|
||||
}));
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: config.rateLimit.windowMs,
|
||||
max: config.rateLimit.maxRequests,
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
});
|
||||
app.use('/api/', limiter);
|
||||
|
||||
// Dynamic Swagger documentation
|
||||
app.get('/api-docs/swagger.json', async (_req, res) => {
|
||||
try {
|
||||
const spec = await generateDynamicSwagger();
|
||||
res.json(spec);
|
||||
} catch (error) {
|
||||
console.error('Error generating swagger spec:', error);
|
||||
res.status(500).json({ error: 'Failed to generate API documentation' });
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api-docs', swaggerUi.serve);
|
||||
app.get('/api-docs', async (_req, res, next) => {
|
||||
try {
|
||||
const spec = await generateDynamicSwagger();
|
||||
const html = swaggerUi.generateHTML(spec, {
|
||||
customCss: '.swagger-ui .topbar { display: none }',
|
||||
customSiteTitle: 'KIS API Builder - Документация',
|
||||
});
|
||||
res.send(html);
|
||||
} catch (error) {
|
||||
console.error('Error generating swagger UI:', error);
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/health', (_req: Request, res: Response) => {
|
||||
res.json({ status: 'OK', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/endpoints', endpointRoutes);
|
||||
app.use('/api/folders', folderRoutes);
|
||||
app.use('/api/keys', apiKeyRoutes);
|
||||
app.use('/api/databases', databaseRoutes);
|
||||
app.use('/api/db-management', databaseManagementRoutes);
|
||||
app.use('/api/users', userRoutes);
|
||||
app.use('/api/logs', logsRoutes);
|
||||
|
||||
// Dynamic API routes (user-created endpoints)
|
||||
app.use('/api/v1', dynamicRoutes);
|
||||
|
||||
// Serve frontend static files in production
|
||||
if (config.nodeEnv === 'production') {
|
||||
const frontendPath = path.join(__dirname, '../../frontend/dist');
|
||||
app.use(express.static(frontendPath));
|
||||
|
||||
// SPA fallback - all non-API routes serve index.html
|
||||
app.get('*', (req: Request, res: Response) => {
|
||||
if (!req.path.startsWith('/api/') && !req.path.startsWith('/api-docs') && req.path !== '/health') {
|
||||
res.sendFile(path.join(frontendPath, 'index.html'));
|
||||
} else {
|
||||
res.status(404).json({ error: 'API route not found' });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Development mode - proxy to Vite dev server for non-API routes
|
||||
const viteProxy = createProxyMiddleware({
|
||||
target: 'http://localhost:5173',
|
||||
changeOrigin: true,
|
||||
ws: true, // Enable WebSocket proxying for HMR
|
||||
});
|
||||
|
||||
app.use((req: Request, res: Response, next: any) => {
|
||||
// If it's an API route or swagger, handle it normally
|
||||
if (req.path.startsWith('/api/') || req.path.startsWith('/api-docs') || req.path === '/health') {
|
||||
return next();
|
||||
}
|
||||
// Otherwise, proxy to Vite dev server
|
||||
return viteProxy(req, res, next);
|
||||
});
|
||||
|
||||
// 404 handler for API routes only
|
||||
app.use((req: Request, res: Response) => {
|
||||
if (req.path.startsWith('/api/') || req.path.startsWith('/api-docs') || req.path === '/health') {
|
||||
res.status(404).json({ error: 'API route not found' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Error handler
|
||||
app.use((err: any, _req: Request, res: Response, _next: any) => {
|
||||
console.error('Error:', err);
|
||||
res.status(err.status || 500).json({
|
||||
error: err.message || 'Internal server error',
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
const startServer = async () => {
|
||||
try {
|
||||
await initializeDatabase();
|
||||
await databasePoolManager.initialize();
|
||||
|
||||
app.listen(config.port, () => {
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log(`🚀 KIS API Builder Server running on port ${config.port}`);
|
||||
console.log(`🌐 Application: http://localhost:${config.port}`);
|
||||
console.log(`📚 Swagger docs: http://localhost:${config.port}/api-docs`);
|
||||
console.log(`🏥 Health check: http://localhost:${config.port}/health`);
|
||||
console.log(`🌍 Environment: ${config.nodeEnv}`);
|
||||
if (config.nodeEnv === 'development') {
|
||||
console.log('');
|
||||
console.log('⚠️ Dev Mode: Backend proxies to Vite dev server (port 5173)');
|
||||
console.log(' Make sure Vite dev server is running: npm run dev (in frontend/)');
|
||||
}
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SIGTERM signal received: closing HTTP server');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('SIGINT signal received: closing HTTP server');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
startServer();
|
||||
146
backend/src/services/DatabasePoolManager.ts
Normal file
146
backend/src/services/DatabasePoolManager.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Pool } from 'pg';
|
||||
import { DatabaseConfig } from '../types';
|
||||
import { mainPool } from '../config/database';
|
||||
|
||||
class DatabasePoolManager {
|
||||
private pools: Map<string, Pool> = new Map();
|
||||
|
||||
async initialize() {
|
||||
// Load databases from DB instead of env
|
||||
await this.loadDatabasesFromDB();
|
||||
}
|
||||
|
||||
private async loadDatabasesFromDB() {
|
||||
try {
|
||||
const result = await mainPool.query(
|
||||
'SELECT * FROM databases WHERE is_active = true'
|
||||
);
|
||||
|
||||
for (const row of result.rows) {
|
||||
const dbConfig: DatabaseConfig = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
database_name: row.database_name,
|
||||
username: row.username,
|
||||
password: row.password,
|
||||
ssl: row.ssl,
|
||||
is_active: row.is_active,
|
||||
};
|
||||
|
||||
this.addPool(dbConfig);
|
||||
}
|
||||
|
||||
console.log(`✅ Loaded ${result.rows.length} database connection(s) from DB`);
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to load databases from DB:', error);
|
||||
}
|
||||
}
|
||||
|
||||
addPool(dbConfig: DatabaseConfig) {
|
||||
if (this.pools.has(dbConfig.id)) {
|
||||
console.warn(`Pool with id ${dbConfig.id} already exists. Skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
host: dbConfig.host,
|
||||
port: dbConfig.port,
|
||||
database: dbConfig.database_name,
|
||||
user: dbConfig.username,
|
||||
password: dbConfig.password,
|
||||
ssl: dbConfig.ssl ? { rejectUnauthorized: false } : false,
|
||||
max: 10,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error(`Database pool error for ${dbConfig.id}:`, err);
|
||||
});
|
||||
|
||||
this.pools.set(dbConfig.id, pool);
|
||||
console.log(`✅ Pool created for database: ${dbConfig.name} (${dbConfig.id})`);
|
||||
}
|
||||
|
||||
removePool(databaseId: string) {
|
||||
const pool = this.pools.get(databaseId);
|
||||
if (pool) {
|
||||
pool.end();
|
||||
this.pools.delete(databaseId);
|
||||
console.log(`Pool removed for database: ${databaseId}`);
|
||||
}
|
||||
}
|
||||
|
||||
getPool(databaseId: string): Pool | undefined {
|
||||
return this.pools.get(databaseId);
|
||||
}
|
||||
|
||||
async getAllDatabaseConfigs(): Promise<DatabaseConfig[]> {
|
||||
try {
|
||||
const result = await mainPool.query(
|
||||
'SELECT id, name, type, host, port, database_name, is_active FROM databases WHERE is_active = true'
|
||||
);
|
||||
return result.rows;
|
||||
} catch (error) {
|
||||
console.error('Error fetching databases:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection(databaseId: string): Promise<boolean> {
|
||||
const pool = this.getPool(databaseId);
|
||||
if (!pool) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await pool.connect();
|
||||
client.release();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Connection test failed for ${databaseId}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async reloadPool(databaseId: string) {
|
||||
// Remove old pool
|
||||
this.removePool(databaseId);
|
||||
|
||||
// Load new config from DB
|
||||
const result = await mainPool.query(
|
||||
'SELECT * FROM databases WHERE id = $1 AND is_active = true',
|
||||
[databaseId]
|
||||
);
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const row = result.rows[0];
|
||||
const dbConfig: DatabaseConfig = {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
host: row.host,
|
||||
port: row.port,
|
||||
database_name: row.database_name,
|
||||
username: row.username,
|
||||
password: row.password,
|
||||
ssl: row.ssl,
|
||||
is_active: row.is_active,
|
||||
};
|
||||
|
||||
this.addPool(dbConfig);
|
||||
}
|
||||
}
|
||||
|
||||
async closeAll() {
|
||||
const promises = Array.from(this.pools.values()).map((pool) => pool.end());
|
||||
await Promise.all(promises);
|
||||
this.pools.clear();
|
||||
console.log('All database pools closed');
|
||||
}
|
||||
}
|
||||
|
||||
export const databasePoolManager = new DatabasePoolManager();
|
||||
218
backend/src/services/ScriptExecutor.ts
Normal file
218
backend/src/services/ScriptExecutor.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { sqlExecutor } from './SqlExecutor';
|
||||
import { ScriptQuery, EndpointParameter } from '../types';
|
||||
|
||||
interface ScriptContext {
|
||||
databaseId: string;
|
||||
scriptQueries: ScriptQuery[];
|
||||
requestParams: Record<string, any>;
|
||||
endpointParameters: EndpointParameter[];
|
||||
}
|
||||
|
||||
export class ScriptExecutor {
|
||||
/**
|
||||
* Выполняет JavaScript скрипт
|
||||
*/
|
||||
async executeJavaScript(code: string, context: ScriptContext): Promise<any> {
|
||||
try {
|
||||
// Создаем функцию execQuery, доступную в скрипте
|
||||
const execQuery = async (queryName: string, additionalParams: Record<string, any> = {}) => {
|
||||
const query = context.scriptQueries.find(q => q.name === queryName);
|
||||
if (!query) {
|
||||
throw new Error(`Query '${queryName}' not found`);
|
||||
}
|
||||
|
||||
const allParams = { ...context.requestParams, ...additionalParams };
|
||||
|
||||
let processedQuery = query.sql;
|
||||
const paramValues: any[] = [];
|
||||
const paramMatches = query.sql.match(/\$\w+/g) || [];
|
||||
const uniqueParams = [...new Set(paramMatches.map(p => p.substring(1)))];
|
||||
|
||||
uniqueParams.forEach((paramName, index) => {
|
||||
const regex = new RegExp(`\\$${paramName}\\b`, 'g');
|
||||
processedQuery = processedQuery.replace(regex, `$${index + 1}`);
|
||||
const value = allParams[paramName];
|
||||
paramValues.push(value !== undefined ? value : null);
|
||||
});
|
||||
|
||||
const dbId = (query as any).database_id || context.databaseId;
|
||||
const result = await sqlExecutor.executeQuery(dbId, processedQuery, paramValues);
|
||||
|
||||
return {
|
||||
data: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
executionTime: result.executionTime,
|
||||
};
|
||||
};
|
||||
|
||||
// Создаем асинхронную функцию из кода пользователя
|
||||
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
||||
const userFunction = new AsyncFunction('params', 'execQuery', code);
|
||||
|
||||
// Устанавливаем таймаут
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Script execution timeout (30s)')), 30000);
|
||||
});
|
||||
|
||||
// Выполняем скрипт с таймаутом
|
||||
const result = await Promise.race([
|
||||
userFunction(context.requestParams, execQuery),
|
||||
timeoutPromise
|
||||
]);
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
throw new Error(`JavaScript execution error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет Python скрипт в отдельном процессе
|
||||
*/
|
||||
async executePython(code: string, context: ScriptContext): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Создаем wrapper код для Python
|
||||
const wrapperCode = `
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
# Параметры запроса
|
||||
params = ${JSON.stringify(context.requestParams)}
|
||||
|
||||
# Функция для выполнения SQL запросов
|
||||
def exec_query(query_name, additional_params=None):
|
||||
if additional_params is None:
|
||||
additional_params = {}
|
||||
|
||||
# Отправляем запрос на выполнение через stdout
|
||||
request = {
|
||||
'type': 'exec_query',
|
||||
'query_name': query_name,
|
||||
'additional_params': additional_params
|
||||
}
|
||||
print('__QUERY_REQUEST__' + json.dumps(request) + '__END_REQUEST__', file=sys.stderr, flush=True)
|
||||
|
||||
# Читаем результат
|
||||
response_line = input()
|
||||
response = json.loads(response_line)
|
||||
|
||||
if 'error' in response:
|
||||
raise Exception(response['error'])
|
||||
|
||||
return response
|
||||
|
||||
# Функция-обертка для пользовательского кода
|
||||
def __user_script():
|
||||
${code.split('\n').map(line => line.trim() === '' ? '' : ' ' + line).join('\n')}
|
||||
|
||||
# Выполняем пользовательский код и выводим результат
|
||||
result = __user_script()
|
||||
print(json.dumps(result))
|
||||
`;
|
||||
|
||||
const python = spawn('python', ['-c', wrapperCode]);
|
||||
let output = '';
|
||||
let errorOutput = '';
|
||||
let queryRequests: any[] = [];
|
||||
|
||||
python.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
python.stderr.on('data', async (data) => {
|
||||
const text = data.toString();
|
||||
errorOutput += text;
|
||||
|
||||
// Проверяем на запросы к БД
|
||||
const requestMatches = text.matchAll(/__QUERY_REQUEST__(.*?)__END_REQUEST__/g);
|
||||
for (const match of requestMatches) {
|
||||
try {
|
||||
const request = JSON.parse(match[1]);
|
||||
|
||||
// Выполняем запрос
|
||||
const query = context.scriptQueries.find(q => q.name === request.query_name);
|
||||
if (!query) {
|
||||
python.stdin.write(JSON.stringify({ error: `Query '${request.query_name}' not found` }) + '\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
const allParams = { ...context.requestParams, ...request.additional_params };
|
||||
|
||||
// Преобразуем параметры
|
||||
let processedQuery = query.sql;
|
||||
const paramValues: any[] = [];
|
||||
const paramMatches = query.sql.match(/\$\w+/g) || [];
|
||||
const uniqueParams = [...new Set(paramMatches.map(p => p.substring(1)))];
|
||||
|
||||
uniqueParams.forEach((paramName, index) => {
|
||||
const regex = new RegExp(`\\$${paramName}\\b`, 'g');
|
||||
processedQuery = processedQuery.replace(regex, `$${index + 1}`);
|
||||
const value = allParams[paramName];
|
||||
paramValues.push(value !== undefined ? value : null);
|
||||
});
|
||||
|
||||
// Используем database_id из запроса, если указан, иначе из контекста
|
||||
const dbId = (query as any).database_id || context.databaseId;
|
||||
|
||||
const result = await sqlExecutor.executeQuery(
|
||||
dbId,
|
||||
processedQuery,
|
||||
paramValues
|
||||
);
|
||||
|
||||
python.stdin.write(JSON.stringify({
|
||||
data: result.rows,
|
||||
rowCount: result.rowCount,
|
||||
executionTime: result.executionTime,
|
||||
}) + '\n');
|
||||
} catch (error: any) {
|
||||
python.stdin.write(JSON.stringify({ error: error.message }) + '\n');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
python.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`Python execution error: ${errorOutput}`));
|
||||
} else {
|
||||
try {
|
||||
// Последняя строка вывода - результат
|
||||
const lines = output.trim().split('\n');
|
||||
const resultLine = lines[lines.length - 1];
|
||||
const result = JSON.parse(resultLine);
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to parse Python output: ${output}`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Таймаут 30 секунд
|
||||
setTimeout(() => {
|
||||
python.kill();
|
||||
reject(new Error('Python script execution timeout (30s)'));
|
||||
}, 30000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Выполняет скрипт на выбранном языке
|
||||
*/
|
||||
async execute(
|
||||
language: 'javascript' | 'python',
|
||||
code: string,
|
||||
context: ScriptContext
|
||||
): Promise<any> {
|
||||
if (language === 'javascript') {
|
||||
return this.executeJavaScript(code, context);
|
||||
} else if (language === 'python') {
|
||||
return this.executePython(code, context);
|
||||
} else {
|
||||
throw new Error(`Unsupported script language: ${language}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const scriptExecutor = new ScriptExecutor();
|
||||
96
backend/src/services/SqlExecutor.ts
Normal file
96
backend/src/services/SqlExecutor.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { QueryResult } from '../types';
|
||||
import { databasePoolManager } from './DatabasePoolManager';
|
||||
|
||||
export class SqlExecutor {
|
||||
async executeQuery(
|
||||
databaseId: string,
|
||||
sqlQuery: string,
|
||||
params: any[] = []
|
||||
): Promise<QueryResult> {
|
||||
const pool = databasePoolManager.getPool(databaseId);
|
||||
|
||||
if (!pool) {
|
||||
throw new Error(`Database with id ${databaseId} not found or not configured`);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Security: Prevent multiple statements and dangerous commands
|
||||
this.validateQuery(sqlQuery);
|
||||
|
||||
const result = await pool.query(sqlQuery, params);
|
||||
const executionTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
rows: result.rows,
|
||||
rowCount: result.rowCount || 0,
|
||||
executionTime,
|
||||
};
|
||||
} catch (error: any) {
|
||||
console.error('SQL Execution Error:', error);
|
||||
throw new Error(`SQL Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private validateQuery(sqlQuery: string) {
|
||||
const normalized = sqlQuery.trim().toLowerCase();
|
||||
|
||||
// Prevent multiple statements (basic check)
|
||||
if (normalized.includes(';') && normalized.indexOf(';') < normalized.length - 1) {
|
||||
throw new Error('Multiple SQL statements are not allowed');
|
||||
}
|
||||
|
||||
// Prevent dangerous commands (you can extend this list)
|
||||
const dangerousCommands = ['drop', 'truncate', 'delete from', 'alter', 'create', 'grant', 'revoke'];
|
||||
const isDangerous = dangerousCommands.some(cmd => normalized.startsWith(cmd));
|
||||
|
||||
if (isDangerous && !normalized.startsWith('select')) {
|
||||
// For safety, you might want to allow only SELECT queries
|
||||
// Or implement a whitelist/permission system for write operations
|
||||
console.warn('Potentially dangerous query detected:', sqlQuery);
|
||||
}
|
||||
}
|
||||
|
||||
async testQuery(databaseId: string, sqlQuery: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await this.executeQuery(databaseId, sqlQuery);
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async getTableSchema(databaseId: string, tableName: string): Promise<any[]> {
|
||||
const query = `
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM
|
||||
information_schema.columns
|
||||
WHERE
|
||||
table_name = $1
|
||||
ORDER BY
|
||||
ordinal_position;
|
||||
`;
|
||||
|
||||
const result = await this.executeQuery(databaseId, query, [tableName]);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async getAllTables(databaseId: string): Promise<string[]> {
|
||||
const query = `
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
ORDER BY table_name;
|
||||
`;
|
||||
|
||||
const result = await this.executeQuery(databaseId, query);
|
||||
return result.rows.map(row => row.table_name);
|
||||
}
|
||||
}
|
||||
|
||||
export const sqlExecutor = new SqlExecutor();
|
||||
96
backend/src/types/index.ts
Normal file
96
backend/src/types/index.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
export interface DatabaseConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'postgresql' | 'mysql' | 'mssql';
|
||||
host: string;
|
||||
port: number;
|
||||
database_name: string;
|
||||
username: string;
|
||||
password: string;
|
||||
ssl: boolean;
|
||||
is_active?: boolean;
|
||||
created_at?: Date;
|
||||
updated_at?: Date;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
role: 'admin' | 'user';
|
||||
is_superadmin: boolean;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
user_id: string;
|
||||
permissions: string[]; // Array of endpoint IDs
|
||||
is_active: boolean;
|
||||
enable_logging: boolean;
|
||||
created_at: Date;
|
||||
expires_at: Date | null;
|
||||
}
|
||||
|
||||
export interface Folder {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
user_id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
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;
|
||||
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: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
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 QueryResult {
|
||||
rows: any[];
|
||||
rowCount: number;
|
||||
executionTime: number;
|
||||
}
|
||||
|
||||
export interface SwaggerEndpoint {
|
||||
tags: string[];
|
||||
summary: string;
|
||||
description: string;
|
||||
parameters: any[];
|
||||
responses: any;
|
||||
security?: any[];
|
||||
}
|
||||
27
backend/tsconfig.json
Normal file
27
backend/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"strictFunctionTypes": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noImplicitReturns": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
16
frontend/.gitignore
vendored
Normal file
16
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
.env.local
|
||||
.env.production.local
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>KIS API Builder - Dynamic API Construction System</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
frontend/nginx.conf
Normal file
38
frontend/nginx.conf
Normal file
@@ -0,0 +1,38 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Frontend routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Backend API proxy
|
||||
location /api {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Swagger docs proxy
|
||||
location /api-docs {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
|
||||
}
|
||||
5476
frontend/package-lock.json
generated
Normal file
5476
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
53
frontend/package.json
Normal file
53
frontend/package.json
Normal file
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "kis-api-builder-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "KIS API Builder Frontend - Modern UI for API construction",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"start:prod": "pm2 start \"vite preview --port 3001 --host 0.0.0.0\" --name kis-api-builder-frontend",
|
||||
"reload:prod": "pm2 reload kis-api-builder-frontend",
|
||||
"stop:prod": "pm2 stop kis-api-builder-frontend",
|
||||
"delete:prod": "pm2 delete kis-api-builder-frontend",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.3.3",
|
||||
"@monaco-editor/react": "^4.6.0",
|
||||
"@tanstack/react-query": "^5.14.2",
|
||||
"axios": "^1.6.2",
|
||||
"clsx": "^2.0.0",
|
||||
"cmdk": "^0.2.0",
|
||||
"date-fns": "^3.0.6",
|
||||
"framer-motion": "^10.16.16",
|
||||
"lucide-react": "^0.303.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.49.2",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-icons": "^4.12.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"zod": "^3.22.4",
|
||||
"zustand": "^4.4.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.6",
|
||||
"@types/react": "^18.2.43",
|
||||
"@types/react-dom": "^18.2.17",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.55.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.5",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.8",
|
||||
"vite-plugin-monaco-editor": "^1.1.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
136
frontend/src/App.tsx
Normal file
136
frontend/src/App.tsx
Normal 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;
|
||||
36
frontend/src/components/CodeEditor.tsx
Normal file
36
frontend/src/components/CodeEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
frontend/src/components/Dialog.tsx
Normal file
63
frontend/src/components/Dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
718
frontend/src/components/EndpointModal.tsx
Normal file
718
frontend/src/components/EndpointModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
frontend/src/components/Navbar.tsx
Normal file
56
frontend/src/components/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/Sidebar.tsx
Normal file
46
frontend/src/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
frontend/src/components/SqlEditor.tsx
Normal file
108
frontend/src/components/SqlEditor.tsx
Normal 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
35
frontend/src/index.css
Normal 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
10
frontend/src/main.tsx
Normal 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>
|
||||
);
|
||||
571
frontend/src/pages/ApiKeys.tsx
Normal file
571
frontend/src/pages/ApiKeys.tsx
Normal file
@@ -0,0 +1,571 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiKeysApi, endpointsApi, foldersApi } from '@/services/api';
|
||||
import { Plus, Copy, Trash2, Eye, EyeOff, Edit2, Folder as FolderIcon, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { format } from 'date-fns';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export default function ApiKeys() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingApiKey, setEditingApiKey] = useState<any | null>(null);
|
||||
const [revealedKeys, setRevealedKeys] = useState<Set<string>>(new Set());
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
});
|
||||
|
||||
const { data: apiKeys, isLoading } = useQuery({
|
||||
queryKey: ['apiKeys'],
|
||||
queryFn: () => apiKeysApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: endpoints } = useQuery({
|
||||
queryKey: ['endpoints'],
|
||||
queryFn: () => endpointsApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: folders } = useQuery({
|
||||
queryKey: ['folders'],
|
||||
queryFn: () => foldersApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiKeysApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
|
||||
toast.success('API ключ удален');
|
||||
},
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: ({ id, is_active }: { id: string; is_active: boolean }) =>
|
||||
apiKeysApi.update(id, { is_active }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
|
||||
toast.success('API ключ обновлен');
|
||||
},
|
||||
});
|
||||
|
||||
const copyToClipboard = (key: string) => {
|
||||
navigator.clipboard.writeText(key);
|
||||
toast.success('API ключ скопирован в буфер обмена');
|
||||
};
|
||||
|
||||
const toggleReveal = (id: string) => {
|
||||
setRevealedKeys(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingApiKey(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (apiKey: any) => {
|
||||
setEditingApiKey(apiKey);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">API Ключи</h1>
|
||||
<p className="text-gray-600">Управление API ключами и правами доступа</p>
|
||||
</div>
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Сгенерировать API ключ
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{apiKeys?.map((apiKey) => (
|
||||
<div key={apiKey.id} className="card p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-semibold text-gray-900">{apiKey.name}</h3>
|
||||
<span className={`px-3 py-1 text-xs font-semibold rounded ${
|
||||
apiKey.is_active ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{apiKey.is_active ? 'Активен' : 'Неактивен'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<code className="text-sm bg-gray-100 px-3 py-1 rounded text-gray-800 flex-1">
|
||||
{revealedKeys.has(apiKey.id) ? apiKey.key : '•'.repeat(40)}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => toggleReveal(apiKey.id)}
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
title={revealedKeys.has(apiKey.id) ? 'Скрыть' : 'Показать'}
|
||||
>
|
||||
{revealedKeys.has(apiKey.id) ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyToClipboard(apiKey.key)}
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
title="Копировать"
|
||||
>
|
||||
<Copy size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
<p>Создан: {format(new Date(apiKey.created_at), 'PPP')}</p>
|
||||
{apiKey.expires_at && (
|
||||
<p>Истекает: {format(new Date(apiKey.expires_at), 'PPP')}</p>
|
||||
)}
|
||||
<p>Права: {apiKey.permissions.length === 0 ? 'Нет' :
|
||||
apiKey.permissions.includes('*') ? 'Все эндпоинты' :
|
||||
`${apiKey.permissions.filter((p: string) => !p.startsWith('folder:')).length} эндпоинт(ов), ${apiKey.permissions.filter((p: string) => p.startsWith('folder:')).length} папок`}</p>
|
||||
<p>Логгирование: {apiKey.enable_logging ? 'Включено' : 'Выключено'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(apiKey)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit2 size={18} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleMutation.mutate({ id: apiKey.id, is_active: !apiKey.is_active })}
|
||||
className={`btn ${apiKey.is_active ? 'btn-secondary' : 'btn-primary'}`}
|
||||
>
|
||||
{apiKey.is_active ? 'Деактивировать' : 'Активировать'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Удалить этот API ключ?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(apiKey.id);
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="btn btn-danger"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<ApiKeyModal
|
||||
apiKey={editingApiKey}
|
||||
endpoints={endpoints || []}
|
||||
folders={folders || []}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
type={dialog.type}
|
||||
onConfirm={dialog.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ApiKeyModal({ apiKey, endpoints, folders, onClose }: { apiKey: any | null; endpoints: any[]; folders: any[]; onClose: () => void }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
name: apiKey?.name || '',
|
||||
permissions: apiKey?.permissions || [] as string[],
|
||||
expires_at: apiKey?.expires_at ? new Date(apiKey.expires_at).toISOString().slice(0, 16) : '',
|
||||
enable_logging: apiKey?.enable_logging || false,
|
||||
});
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
apiKey
|
||||
? apiKeysApi.update(apiKey.id, {
|
||||
name: formData.name,
|
||||
permissions: formData.permissions,
|
||||
expires_at: formData.expires_at || undefined,
|
||||
enable_logging: formData.enable_logging,
|
||||
})
|
||||
: apiKeysApi.create(formData.name, formData.permissions, formData.expires_at || undefined, formData.enable_logging),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['apiKeys'] });
|
||||
toast.success(apiKey ? 'API ключ обновлен' : 'API ключ создан');
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
|
||||
const toggleFolder = (folderId: string) => {
|
||||
setExpandedFolders(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(folderId)) {
|
||||
newSet.delete(folderId);
|
||||
} else {
|
||||
newSet.add(folderId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const getAllDescendantEndpoints = (folderId: string): string[] => {
|
||||
const descendants: string[] = [];
|
||||
|
||||
// Get all endpoints in this folder
|
||||
const folderEndpoints = endpoints.filter(e => e.folder_id === folderId);
|
||||
descendants.push(...folderEndpoints.map(e => e.id));
|
||||
|
||||
// Get all subfolders
|
||||
const subfolders = folders.filter(f => f.parent_id === folderId);
|
||||
subfolders.forEach(subfolder => {
|
||||
descendants.push(...getAllDescendantEndpoints(subfolder.id));
|
||||
});
|
||||
|
||||
return descendants;
|
||||
};
|
||||
|
||||
const toggleFolderPermission = (folderId: string) => {
|
||||
const folderKey = `folder:${folderId}`;
|
||||
|
||||
setFormData(prev => {
|
||||
const newPermissions = [...prev.permissions];
|
||||
const hasFolder = newPermissions.includes(folderKey);
|
||||
|
||||
if (hasFolder) {
|
||||
// Remove folder permission
|
||||
return {
|
||||
...prev,
|
||||
permissions: newPermissions.filter(p => p !== folderKey),
|
||||
};
|
||||
} else {
|
||||
// Add folder permission and remove any individual endpoint permissions for this folder
|
||||
const descendantEndpoints = getAllDescendantEndpoints(folderId);
|
||||
return {
|
||||
...prev,
|
||||
permissions: [
|
||||
...newPermissions.filter(p => !descendantEndpoints.includes(p)),
|
||||
folderKey,
|
||||
],
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const togglePermission = (endpointId: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
permissions: prev.permissions.includes(endpointId)
|
||||
? prev.permissions.filter((id: string) => id !== endpointId)
|
||||
: [...prev.permissions, endpointId],
|
||||
}));
|
||||
};
|
||||
|
||||
const toggleAllPermissions = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
permissions: prev.permissions.includes('*') ? [] : ['*'],
|
||||
}));
|
||||
};
|
||||
|
||||
// Build folder tree
|
||||
const buildTree = () => {
|
||||
const folderMap = new Map(folders.map(f => [f.id, { ...f, children: [], endpoints: [] }]));
|
||||
const tree: any[] = [];
|
||||
|
||||
// Group folders by parent_id
|
||||
folders.forEach(folder => {
|
||||
const node = folderMap.get(folder.id)!;
|
||||
if (folder.parent_id && folderMap.has(folder.parent_id)) {
|
||||
folderMap.get(folder.parent_id)!.children.push(node);
|
||||
} else {
|
||||
tree.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// Add endpoints to folders
|
||||
endpoints.forEach(endpoint => {
|
||||
if (endpoint.folder_id && folderMap.has(endpoint.folder_id)) {
|
||||
folderMap.get(endpoint.folder_id)!.endpoints.push(endpoint);
|
||||
}
|
||||
});
|
||||
|
||||
return tree;
|
||||
};
|
||||
|
||||
// Check if endpoint should appear checked (either directly or via folder permission)
|
||||
const isEndpointChecked = (endpointId: string, folderId?: string): boolean => {
|
||||
if (formData.permissions.includes('*')) return true;
|
||||
if (formData.permissions.includes(endpointId)) return true;
|
||||
|
||||
// Check if any parent folder has permission
|
||||
if (folderId) {
|
||||
let currentFolderId: string | null | undefined = folderId;
|
||||
while (currentFolderId) {
|
||||
if (formData.permissions.includes(`folder:${currentFolderId}`)) return true;
|
||||
const folder = folders.find(f => f.id === currentFolderId);
|
||||
currentFolderId = folder?.parent_id;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check if folder should appear checked
|
||||
const isFolderChecked = (folderId: string): boolean => {
|
||||
if (formData.permissions.includes('*')) return true;
|
||||
return formData.permissions.includes(`folder:${folderId}`);
|
||||
};
|
||||
|
||||
const tree = buildTree();
|
||||
const rootEndpoints = endpoints.filter(e => !e.folder_id);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{apiKey ? 'Редактировать API ключ' : 'Сгенерировать API ключ'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); saveMutation.mutate(); }} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Название ключа</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Мой API ключ"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Истекает (необязательно)</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.expires_at}
|
||||
onChange={(e) => setFormData({ ...formData, expires_at: e.target.value })}
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">Права доступа</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAllPermissions}
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{formData.permissions.includes('*') ? 'Снять все' : 'Выбрать все'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="border border-gray-300 rounded-lg p-4 max-h-96 overflow-y-auto">
|
||||
{/* Root folders */}
|
||||
{tree.map((folder) => (
|
||||
<PermissionTreeNode
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
level={0}
|
||||
expandedFolders={expandedFolders}
|
||||
isFolderChecked={isFolderChecked}
|
||||
isEndpointChecked={isEndpointChecked}
|
||||
toggleFolder={toggleFolder}
|
||||
toggleFolderPermission={toggleFolderPermission}
|
||||
togglePermission={togglePermission}
|
||||
disabled={formData.permissions.includes('*')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Root endpoints (without folder) */}
|
||||
{rootEndpoints.map((endpoint) => (
|
||||
<PermissionEndpointNode
|
||||
key={endpoint.id}
|
||||
endpoint={endpoint}
|
||||
level={0}
|
||||
isChecked={isEndpointChecked(endpoint.id)}
|
||||
togglePermission={togglePermission}
|
||||
disabled={formData.permissions.includes('*')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tree.length === 0 && rootEndpoints.length === 0 && (
|
||||
<div className="text-center py-8 text-gray-500 text-sm">
|
||||
Нет доступных папок и эндпоинтов
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.enable_logging}
|
||||
onChange={(e) => setFormData({ ...formData, enable_logging: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Логгировать запросы</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">
|
||||
{saveMutation.isPending ? 'Сохранение...' : (apiKey ? 'Сохранить изменения' : 'Сгенерировать ключ')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionTreeNode({
|
||||
folder,
|
||||
level,
|
||||
expandedFolders,
|
||||
isFolderChecked,
|
||||
isEndpointChecked,
|
||||
toggleFolder,
|
||||
toggleFolderPermission,
|
||||
togglePermission,
|
||||
disabled,
|
||||
}: any) {
|
||||
const isExpanded = expandedFolders.has(folder.id);
|
||||
const hasChildren = folder.children.length > 0 || folder.endpoints.length > 0;
|
||||
const checked = isFolderChecked(folder.id);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex items-center gap-2 py-2 hover:bg-gray-50 rounded transition-colors"
|
||||
style={{ paddingLeft: `${level * 20}px` }}
|
||||
>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={() => toggleFolder(folder.id)}
|
||||
className="p-0.5 hover:bg-gray-200 rounded flex-shrink-0"
|
||||
type="button"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-5" />
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleFolderPermission(folder.id)}
|
||||
disabled={disabled}
|
||||
className="rounded flex-shrink-0"
|
||||
/>
|
||||
<FolderIcon size={16} className="text-yellow-600 flex-shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-900">{folder.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
({folder.endpoints.length} эндпоинт(ов))
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{/* Subfolders */}
|
||||
{folder.children.map((child: any) => (
|
||||
<PermissionTreeNode
|
||||
key={child.id}
|
||||
folder={child}
|
||||
level={level + 1}
|
||||
expandedFolders={expandedFolders}
|
||||
isFolderChecked={isFolderChecked}
|
||||
isEndpointChecked={isEndpointChecked}
|
||||
toggleFolder={toggleFolder}
|
||||
toggleFolderPermission={toggleFolderPermission}
|
||||
togglePermission={togglePermission}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Endpoints in this folder */}
|
||||
{folder.endpoints.map((endpoint: any) => (
|
||||
<PermissionEndpointNode
|
||||
key={endpoint.id}
|
||||
endpoint={endpoint}
|
||||
level={level + 1}
|
||||
isChecked={isEndpointChecked(endpoint.id, folder.id)}
|
||||
togglePermission={togglePermission}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionEndpointNode({
|
||||
endpoint,
|
||||
level,
|
||||
isChecked,
|
||||
togglePermission,
|
||||
disabled,
|
||||
}: any) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 py-2 hover:bg-gray-50 rounded transition-colors"
|
||||
style={{ paddingLeft: `${level * 20 + 24}px` }}
|
||||
>
|
||||
<label className="flex items-center gap-2 flex-1 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => togglePermission(endpoint.id)}
|
||||
disabled={disabled}
|
||||
className="rounded flex-shrink-0"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
{endpoint.name} ({endpoint.method} {endpoint.path})
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
119
frontend/src/pages/Dashboard.tsx
Normal file
119
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { endpointsApi, foldersApi, apiKeysApi } from '@/services/api';
|
||||
import { FileCode, Folder, Key, Database } from 'lucide-react';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: endpoints } = useQuery({
|
||||
queryKey: ['endpoints'],
|
||||
queryFn: () => endpointsApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: folders } = useQuery({
|
||||
queryKey: ['folders'],
|
||||
queryFn: () => foldersApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: apiKeys } = useQuery({
|
||||
queryKey: ['apiKeys'],
|
||||
queryFn: () => apiKeysApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Всего эндпоинтов',
|
||||
value: endpoints?.length || 0,
|
||||
icon: FileCode,
|
||||
color: 'bg-blue-500',
|
||||
},
|
||||
{
|
||||
label: 'Папки',
|
||||
value: folders?.length || 0,
|
||||
icon: Folder,
|
||||
color: 'bg-green-500',
|
||||
},
|
||||
{
|
||||
label: 'API Ключи',
|
||||
value: apiKeys?.length || 0,
|
||||
icon: Key,
|
||||
color: 'bg-purple-500',
|
||||
},
|
||||
{
|
||||
label: 'Активные ключи',
|
||||
value: apiKeys?.filter(k => k.is_active).length || 0,
|
||||
icon: Database,
|
||||
color: 'bg-orange-500',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Панель управления</h1>
|
||||
<p className="text-gray-600">Обзор системы KIS API Builder</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.label} className="card p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`${stat.color} p-3 rounded-lg text-white`}>
|
||||
<stat.icon size={24} />
|
||||
</div>
|
||||
<span className="text-3xl font-bold text-gray-900">{stat.value}</span>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-gray-600">{stat.label}</h3>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="card p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Последние эндпоинты</h2>
|
||||
<div className="space-y-3">
|
||||
{endpoints?.slice(0, 5).map((endpoint) => (
|
||||
<div key={endpoint.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{endpoint.name}</p>
|
||||
<p className="text-sm text-gray-500">{endpoint.path}</p>
|
||||
</div>
|
||||
<span className={`px-3 py-1 text-xs font-semibold rounded ${
|
||||
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
|
||||
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||||
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{(!endpoints || endpoints.length === 0) && (
|
||||
<p className="text-gray-500 text-sm text-center py-4">Нет созданных эндпоинтов</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Быстрые действия</h2>
|
||||
<div className="space-y-3">
|
||||
<a href="/endpoints" className="block p-4 bg-primary-50 hover:bg-primary-100 rounded-lg transition-colors">
|
||||
<h3 className="font-semibold text-primary-700">Создать новый эндпоинт</h3>
|
||||
<p className="text-sm text-primary-600">Создайте новый API эндпоинт с SQL запросом</p>
|
||||
</a>
|
||||
<a href="/api-keys" className="block p-4 bg-green-50 hover:bg-green-100 rounded-lg transition-colors">
|
||||
<h3 className="font-semibold text-green-700">Сгенерировать API ключ</h3>
|
||||
<p className="text-sm text-green-600">Создайте новый API ключ для внешних систем</p>
|
||||
</a>
|
||||
<a href="http://localhost:3000/api-docs" target="_blank" rel="noopener noreferrer" className="block p-4 bg-blue-50 hover:bg-blue-100 rounded-lg transition-colors">
|
||||
<h3 className="font-semibold text-blue-700">📚 Swagger документация</h3>
|
||||
<p className="text-sm text-blue-600">Документация API для пользователей с API ключами</p>
|
||||
</a>
|
||||
<a href="/settings" className="block p-4 bg-purple-50 hover:bg-purple-100 rounded-lg transition-colors">
|
||||
<h3 className="font-semibold text-purple-700">Настройки системы</h3>
|
||||
<p className="text-sm text-purple-600">Управление профилем и глобальными настройками</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
377
frontend/src/pages/Databases.tsx
Normal file
377
frontend/src/pages/Databases.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { dbManagementApi } from '@/services/api';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { Database, Plus, Edit2, Trash2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export default function Databases() {
|
||||
const { user } = useAuthStore();
|
||||
const queryClient = useQueryClient();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingDatabase, setEditingDatabase] = useState<any | null>(null);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
});
|
||||
|
||||
const { data: databases, isLoading } = useQuery({
|
||||
queryKey: ['databases'],
|
||||
queryFn: () => dbManagementApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => dbManagementApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||
toast.success('База данных удалена');
|
||||
},
|
||||
onError: () => toast.error('Не удалось удалить базу данных'),
|
||||
});
|
||||
|
||||
const testConnection = async (databaseId: string) => {
|
||||
try {
|
||||
const { data } = await dbManagementApi.test(databaseId);
|
||||
if (data.success) {
|
||||
toast.success('Подключение успешно!');
|
||||
} else {
|
||||
toast.error('Ошибка подключения');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Ошибка тестирования подключения');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (database: any) => {
|
||||
setEditingDatabase(database);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingDatabase(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Вы уверены, что хотите удалить эту базу данных?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Базы данных</h1>
|
||||
<p className="text-gray-600">Управление подключениями к базам данных</p>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Добавить базу данных
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{databases?.map((db) => (
|
||||
<div key={db.id} className="card p-6">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary-100 p-3 rounded-lg">
|
||||
<Database size={24} className="text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">{db.name}</h3>
|
||||
<p className="text-sm text-gray-500">{db.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(db)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit2 size={18} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(db.id)}
|
||||
className="p-2 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={18} className="text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 text-sm mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Хост:</span>
|
||||
<span className="font-medium">{db.host}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Порт:</span>
|
||||
<span className="font-medium">{db.port}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">База данных:</span>
|
||||
<span className="font-medium">{db.database_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Пользователь:</span>
|
||||
<span className="font-medium">{db.username}</span>
|
||||
</div>
|
||||
{db.is_active !== undefined && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">Статус:</span>
|
||||
<span className={`font-medium ${db.is_active ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{db.is_active ? 'Активна' : 'Неактивна'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => testConnection(db.id)}
|
||||
className="btn btn-primary w-full"
|
||||
>
|
||||
Тест подключения
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{databases?.length === 0 && (
|
||||
<div className="col-span-2 text-center py-12">
|
||||
<p className="text-gray-500">
|
||||
{isAdmin
|
||||
? 'Базы данных не настроены. Добавьте первую базу данных.'
|
||||
: 'Базы данных не настроены.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdmin && !isLoading && databases && databases.length > 0 && (
|
||||
<div className="card p-6 mt-6">
|
||||
<h2 className="text-xl font-semibold mb-4">Информация</h2>
|
||||
<p className="text-gray-600">
|
||||
Базы данных управляются через интерфейс администратора.
|
||||
Вы можете добавлять, редактировать и удалять подключения к базам данных.
|
||||
Каждый эндпоинт использует одно из этих подключений для выполнения SQL запросов.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<DatabaseModal
|
||||
database={editingDatabase}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
type={dialog.type}
|
||||
onConfirm={dialog.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DatabaseModal({
|
||||
database,
|
||||
onClose,
|
||||
}: {
|
||||
database: any | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
name: database?.name || '',
|
||||
type: database?.type || 'postgresql',
|
||||
host: database?.host || 'localhost',
|
||||
port: database?.port || 5432,
|
||||
database_name: database?.database_name || '',
|
||||
username: database?.username || '',
|
||||
password: database?.password || '',
|
||||
ssl: database?.ssl || false,
|
||||
is_active: database?.is_active !== undefined ? database.is_active : true,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
database ? dbManagementApi.update(database.id, data) : dbManagementApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||
toast.success(database ? 'База данных обновлена' : 'База данных создана');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Не удалось сохранить базу данных');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{database ? 'Редактировать базу данных' : 'Добавить базу данных'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Основная база PostgreSQL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Тип</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="postgresql">PostgreSQL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Порт</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
value={formData.port}
|
||||
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) })}
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Хост</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.host}
|
||||
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="localhost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Имя базы данных</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.database_name}
|
||||
onChange={(e) => setFormData({ ...formData, database_name: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="my_database"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Пользователь</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="postgres"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||
<input
|
||||
type="password"
|
||||
required={!database}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder={database ? '••••••••' : 'Введите пароль'}
|
||||
/>
|
||||
{database && (
|
||||
<p className="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы не менять пароль</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.ssl}
|
||||
onChange={(e) => setFormData({ ...formData, ssl: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Использовать SSL</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Активна</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">
|
||||
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
frontend/src/pages/Endpoints.tsx
Normal file
186
frontend/src/pages/Endpoints.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { endpointsApi, databasesApi } from '@/services/api';
|
||||
import { Endpoint } from '@/types';
|
||||
import { Plus, Search, Edit2, Trash2 } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import EndpointModal from '@/components/EndpointModal';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export default function Endpoints() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingEndpoint, setEditingEndpoint] = useState<Endpoint | null>(null);
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
});
|
||||
|
||||
const { data: endpoints, isLoading } = useQuery({
|
||||
queryKey: ['endpoints', search],
|
||||
queryFn: () => endpointsApi.getAll(search).then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: databases } = useQuery({
|
||||
queryKey: ['databases'],
|
||||
queryFn: () => databasesApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => endpointsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
|
||||
toast.success('Эндпоинт успешно удален');
|
||||
},
|
||||
onError: () => toast.error('Не удалось удалить эндпоинт'),
|
||||
});
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Вы уверены, что хотите удалить этот эндпоинт?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = (endpoint: Endpoint) => {
|
||||
setEditingEndpoint(endpoint);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingEndpoint(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">API Эндпоинты</h1>
|
||||
<p className="text-gray-600">Управление динамическими API эндпоинтами</p>
|
||||
</div>
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Новый эндпоинт
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<Search size={20} className="text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Поиск эндпоинтов по имени, пути или SQL запросу..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1 outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Загрузка эндпоинтов...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{endpoints?.map((endpoint) => (
|
||||
<div key={endpoint.id} className="card p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h3 className="text-xl font-semibold text-gray-900">{endpoint.name}</h3>
|
||||
<span className={`px-3 py-1 text-xs font-semibold rounded ${
|
||||
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
|
||||
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||||
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
{endpoint.is_public && (
|
||||
<span className="px-3 py-1 text-xs font-semibold rounded bg-purple-100 text-purple-700">
|
||||
Публичный
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 mb-2">{endpoint.description}</p>
|
||||
<code className="text-sm bg-gray-100 px-3 py-1 rounded text-gray-800">
|
||||
{endpoint.path}
|
||||
</code>
|
||||
{endpoint.folder_name && (
|
||||
<span className="ml-2 text-sm text-gray-500">📁 {endpoint.folder_name}</span>
|
||||
)}
|
||||
{endpoint.parameters && endpoint.parameters.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<span className="text-xs text-gray-500">Параметры: </span>
|
||||
{endpoint.parameters.map((param: any, idx: number) => (
|
||||
<span key={idx} className="inline-block text-xs bg-gray-200 text-gray-700 px-2 py-1 rounded mr-1 mb-1">
|
||||
{param.name} ({param.type}){param.required && '*'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(endpoint)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit2 size={18} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(endpoint.id)}
|
||||
className="p-2 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={18} className="text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{endpoints?.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">Эндпоинты не найдены. Создайте первый эндпоинт!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<EndpointModal
|
||||
endpoint={editingEndpoint}
|
||||
databases={databases || []}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
type={dialog.type}
|
||||
onConfirm={dialog.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
488
frontend/src/pages/Folders.tsx
Normal file
488
frontend/src/pages/Folders.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { foldersApi, endpointsApi, databasesApi } from '@/services/api';
|
||||
import { Folder, Endpoint } from '@/types';
|
||||
import { Plus, Edit2, Trash2, Folder as FolderIcon, FolderOpen, FileCode, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import EndpointModal from '@/components/EndpointModal';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export default function Folders() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showFolderModal, setShowFolderModal] = useState(false);
|
||||
const [showEndpointModal, setShowEndpointModal] = useState(false);
|
||||
const [editingFolder, setEditingFolder] = useState<Folder | null>(null);
|
||||
const [editingEndpoint, setEditingEndpoint] = useState<Endpoint | null>(null);
|
||||
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(null);
|
||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
});
|
||||
|
||||
const { data: folders, isLoading: foldersLoading } = useQuery({
|
||||
queryKey: ['folders'],
|
||||
queryFn: () => foldersApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: endpoints, isLoading: endpointsLoading } = useQuery({
|
||||
queryKey: ['endpoints'],
|
||||
queryFn: () => endpointsApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: databases } = useQuery({
|
||||
queryKey: ['databases'],
|
||||
queryFn: () => databasesApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteFolderMutation = useMutation({
|
||||
mutationFn: (id: string) => foldersApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
|
||||
toast.success('Папка удалена');
|
||||
},
|
||||
onError: () => toast.error('Ошибка удаления папки'),
|
||||
});
|
||||
|
||||
const deleteEndpointMutation = useMutation({
|
||||
mutationFn: (id: string) => endpointsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['endpoints'] });
|
||||
toast.success('Эндпоинт удален');
|
||||
},
|
||||
onError: () => toast.error('Ошибка удаления эндпоинта'),
|
||||
});
|
||||
|
||||
const handleCreateFolder = (parentId?: string) => {
|
||||
setSelectedFolderId(parentId || null);
|
||||
setEditingFolder(null);
|
||||
setShowFolderModal(true);
|
||||
};
|
||||
|
||||
const handleEditFolder = (folder: Folder) => {
|
||||
setEditingFolder(folder);
|
||||
setShowFolderModal(true);
|
||||
};
|
||||
|
||||
const handleCreateEndpoint = (folderId?: string) => {
|
||||
setSelectedFolderId(folderId || null);
|
||||
setEditingEndpoint(null);
|
||||
setShowEndpointModal(true);
|
||||
};
|
||||
|
||||
const handleEditEndpoint = (endpoint: Endpoint) => {
|
||||
setEditingEndpoint(endpoint);
|
||||
setShowEndpointModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteFolder = (id: string) => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Удалить папку? Все вложенные папки и эндпоинты будут перемещены в корень.',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteFolderMutation.mutate(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteEndpoint = (id: string) => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Удалить этот эндпоинт?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteEndpointMutation.mutate(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggleFolder = (folderId: string) => {
|
||||
setExpandedFolders(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(folderId)) {
|
||||
newSet.delete(folderId);
|
||||
} else {
|
||||
newSet.add(folderId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Построение дерева папок
|
||||
const buildTree = () => {
|
||||
if (!folders || !endpoints) return [];
|
||||
|
||||
const folderMap = new Map(folders.map(f => [f.id, { ...f, children: [], endpoints: [] }]));
|
||||
const tree: any[] = [];
|
||||
|
||||
// Группируем папки по parent_id
|
||||
folders.forEach(folder => {
|
||||
const node: any = folderMap.get(folder.id)!;
|
||||
if (folder.parent_id && folderMap.has(folder.parent_id)) {
|
||||
(folderMap.get(folder.parent_id) as any)!.children.push(node);
|
||||
} else {
|
||||
tree.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// Добавляем эндпоинты в папки
|
||||
endpoints.forEach(endpoint => {
|
||||
if (endpoint.folder_id && folderMap.has(endpoint.folder_id)) {
|
||||
(folderMap.get(endpoint.folder_id) as any)!.endpoints.push(endpoint);
|
||||
}
|
||||
});
|
||||
|
||||
return tree;
|
||||
};
|
||||
|
||||
// Эндпоинты без папки
|
||||
const rootEndpoints = endpoints?.filter(e => !e.folder_id) || [];
|
||||
|
||||
const tree = buildTree();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Структура проекта</h1>
|
||||
<p className="text-gray-600">Древовидное представление папок и эндпоинтов</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => handleCreateEndpoint()} className="btn btn-secondary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Новый эндпоинт
|
||||
</button>
|
||||
<button onClick={() => handleCreateFolder()} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Новая папка
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{foldersLoading || endpointsLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600">Загрузка...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card p-6">
|
||||
<div className="space-y-1">
|
||||
{/* Корневые папки */}
|
||||
{tree.map(folder => (
|
||||
<TreeNode
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
level={0}
|
||||
expandedFolders={expandedFolders}
|
||||
onToggle={toggleFolder}
|
||||
onEditFolder={handleEditFolder}
|
||||
onDeleteFolder={handleDeleteFolder}
|
||||
onCreateSubfolder={handleCreateFolder}
|
||||
onCreateEndpoint={handleCreateEndpoint}
|
||||
onEditEndpoint={handleEditEndpoint}
|
||||
onDeleteEndpoint={handleDeleteEndpoint}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Корневые эндпоинты (без папки) */}
|
||||
{rootEndpoints.map(endpoint => (
|
||||
<EndpointNode
|
||||
key={endpoint.id}
|
||||
endpoint={endpoint}
|
||||
level={0}
|
||||
onEdit={handleEditEndpoint}
|
||||
onDelete={handleDeleteEndpoint}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tree.length === 0 && rootEndpoints.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<p>Нет папок и эндпоинтов.</p>
|
||||
<p className="text-sm mt-2">Создайте первую папку или эндпоинт!</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFolderModal && (
|
||||
<FolderModal
|
||||
folder={editingFolder}
|
||||
parentId={selectedFolderId}
|
||||
folders={folders || []}
|
||||
onClose={() => setShowFolderModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showEndpointModal && (
|
||||
<EndpointModal
|
||||
endpoint={editingEndpoint}
|
||||
folderId={selectedFolderId}
|
||||
databases={databases || []}
|
||||
onClose={() => setShowEndpointModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
type={dialog.type}
|
||||
onConfirm={dialog.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeNode({
|
||||
folder,
|
||||
level,
|
||||
expandedFolders,
|
||||
onToggle,
|
||||
onEditFolder,
|
||||
onDeleteFolder,
|
||||
onCreateSubfolder,
|
||||
onCreateEndpoint,
|
||||
onEditEndpoint,
|
||||
onDeleteEndpoint,
|
||||
}: any) {
|
||||
const isExpanded = expandedFolders.has(folder.id);
|
||||
const hasChildren = folder.children.length > 0 || folder.endpoints.length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 transition-colors group`}
|
||||
style={{ paddingLeft: `${level * 24 + 12}px` }}
|
||||
>
|
||||
{hasChildren && (
|
||||
<button
|
||||
onClick={() => onToggle(folder.id)}
|
||||
className="p-0.5 hover:bg-gray-200 rounded"
|
||||
>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
)}
|
||||
{!hasChildren && <div className="w-5" />}
|
||||
|
||||
{isExpanded ? (
|
||||
<FolderOpen size={18} className="text-yellow-600 flex-shrink-0" />
|
||||
) : (
|
||||
<FolderIcon size={18} className="text-yellow-600 flex-shrink-0" />
|
||||
)}
|
||||
|
||||
<span className="font-medium text-gray-900 flex-1">{folder.name}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{folder.endpoints.length} эндпоинт(ов)
|
||||
</span>
|
||||
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onCreateEndpoint(folder.id)}
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
title="Добавить эндпоинт"
|
||||
>
|
||||
<Plus size={14} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onCreateSubfolder(folder.id)}
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
title="Добавить подпапку"
|
||||
>
|
||||
<FolderIcon size={14} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEditFolder(folder)}
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit2 size={14} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteFolder(folder.id)}
|
||||
className="p-1.5 hover:bg-red-100 rounded"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={14} className="text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div>
|
||||
{/* Подпапки */}
|
||||
{folder.children.map((child: any) => (
|
||||
<TreeNode
|
||||
key={child.id}
|
||||
folder={child}
|
||||
level={level + 1}
|
||||
expandedFolders={expandedFolders}
|
||||
onToggle={onToggle}
|
||||
onEditFolder={onEditFolder}
|
||||
onDeleteFolder={onDeleteFolder}
|
||||
onCreateSubfolder={onCreateSubfolder}
|
||||
onCreateEndpoint={onCreateEndpoint}
|
||||
onEditEndpoint={onEditEndpoint}
|
||||
onDeleteEndpoint={onDeleteEndpoint}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Эндпоинты в папке */}
|
||||
{folder.endpoints.map((endpoint: Endpoint) => (
|
||||
<EndpointNode
|
||||
key={endpoint.id}
|
||||
endpoint={endpoint}
|
||||
level={level + 1}
|
||||
onEdit={onEditEndpoint}
|
||||
onDelete={onDeleteEndpoint}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EndpointNode({ endpoint, level, onEdit, onDelete }: any) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded hover:bg-gray-50 transition-colors group`}
|
||||
style={{ paddingLeft: `${level * 24 + 36}px` }}
|
||||
>
|
||||
<FileCode size={16} className="text-blue-600 flex-shrink-0" />
|
||||
<span className="text-sm text-gray-900 flex-1">{endpoint.name}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded font-medium ${
|
||||
endpoint.method === 'GET' ? 'bg-green-100 text-green-700' :
|
||||
endpoint.method === 'POST' ? 'bg-blue-100 text-blue-700' :
|
||||
endpoint.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{endpoint.method}
|
||||
</span>
|
||||
<code className="text-xs text-gray-600">{endpoint.path}</code>
|
||||
|
||||
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onEdit(endpoint)}
|
||||
className="p-1.5 hover:bg-gray-200 rounded"
|
||||
title="Редактировать"
|
||||
>
|
||||
<Edit2 size={14} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(endpoint.id)}
|
||||
className="p-1.5 hover:bg-red-100 rounded"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={14} className="text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderModal({
|
||||
folder,
|
||||
parentId,
|
||||
folders,
|
||||
onClose,
|
||||
}: {
|
||||
folder: Folder | null;
|
||||
parentId: string | null;
|
||||
folders: Folder[];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [formData, setFormData] = useState({
|
||||
name: folder?.name || '',
|
||||
parent_id: folder?.parent_id || parentId || '',
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) =>
|
||||
folder
|
||||
? foldersApi.update(folder.id, data.name, data.parent_id || undefined)
|
||||
: foldersApi.create(data.name, data.parent_id || undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['folders'] });
|
||||
toast.success(folder ? 'Папка обновлена' : 'Папка создана');
|
||||
onClose();
|
||||
},
|
||||
onError: () => toast.error('Ошибка сохранения папки'),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(formData);
|
||||
};
|
||||
|
||||
const availableFolders = folders.filter(f => f.id !== folder?.id);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-md w-full">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{folder ? 'Редактировать папку' : 'Создать папку'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Название папки
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Название папки"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Родительская папка
|
||||
</label>
|
||||
<select
|
||||
value={formData.parent_id}
|
||||
onChange={(e) => setFormData({ ...formData, parent_id: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="">Корневая папка</option>
|
||||
{availableFolders.map((f) => (
|
||||
<option key={f.id} value={f.id}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary flex-1">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary flex-1">
|
||||
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/pages/Login.tsx
Normal file
88
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { authApi } from '@/services/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { LogIn } from 'lucide-react';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const setAuth = useAuthStore((state) => state.setAuth);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const { data } = await authApi.login(formData.username, formData.password);
|
||||
setAuth(data.user, data.token);
|
||||
toast.success('Вход выполнен успешно!');
|
||||
navigate('/');
|
||||
} catch (error: any) {
|
||||
toast.error(error.response?.data?.error || 'Ошибка входа');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-primary-50 to-primary-100">
|
||||
<div className="card w-full max-w-md p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-primary-600 mb-2">KIS API Builder</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Логин
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Введите логин"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Введите пароль"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<LogIn size={18} />
|
||||
{loading ? 'Вход...' : 'Войти'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-gray-200 text-center text-sm text-gray-600">
|
||||
<p>Первый вход: admin / admin</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Смените пароль после входа</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
342
frontend/src/pages/Logs.tsx
Normal file
342
frontend/src/pages/Logs.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { logsApi, endpointsApi, apiKeysApi } from '@/services/api';
|
||||
import { Trash2, Eye, Filter, X } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
import { format } from 'date-fns';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export default function Logs() {
|
||||
const queryClient = useQueryClient();
|
||||
const [selectedLog, setSelectedLog] = useState<any | null>(null);
|
||||
const [filters, setFilters] = useState({
|
||||
endpoint_id: '',
|
||||
api_key_id: '',
|
||||
});
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
});
|
||||
|
||||
const { data: logs, isLoading } = useQuery({
|
||||
queryKey: ['logs', filters],
|
||||
queryFn: () => logsApi.getAll(filters).then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: endpoints } = useQuery({
|
||||
queryKey: ['endpoints'],
|
||||
queryFn: () => endpointsApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const { data: apiKeys } = useQuery({
|
||||
queryKey: ['apiKeys'],
|
||||
queryFn: () => apiKeysApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => logsApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
toast.success('Лог удален');
|
||||
},
|
||||
onError: () => toast.error('Ошибка удаления лога'),
|
||||
});
|
||||
|
||||
const clearMutation = useMutation({
|
||||
mutationFn: (data: any) => logsApi.clear(data),
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['logs'] });
|
||||
toast.success(`Удалено ${response.data.deleted} лог(ов)`);
|
||||
},
|
||||
onError: () => toast.error('Ошибка очистки логов'),
|
||||
});
|
||||
|
||||
const handleClearAll = () => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Вы уверены, что хотите очистить все логи?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
clearMutation.mutate({});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleClearFiltered = () => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Вы уверены, что хотите очистить логи с текущими фильтрами?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
clearMutation.mutate(filters);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Логи запросов</h1>
|
||||
<p className="text-gray-600">История запросов к API эндпоинтам</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{Object.values(filters).some(v => v) && (
|
||||
<button onClick={handleClearFiltered} className="btn btn-secondary">
|
||||
Очистить отфильтрованные
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleClearAll} className="btn btn-danger">
|
||||
Очистить все логи
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-4 mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Filter size={20} className="text-gray-400" />
|
||||
<h3 className="font-semibold">Фильтры</h3>
|
||||
{Object.values(filters).some(v => v) && (
|
||||
<button
|
||||
onClick={() => setFilters({ endpoint_id: '', api_key_id: '' })}
|
||||
className="text-sm text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||
>
|
||||
<X size={16} />
|
||||
Сбросить
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Эндпоинт</label>
|
||||
<select
|
||||
value={filters.endpoint_id}
|
||||
onChange={(e) => setFilters({ ...filters, endpoint_id: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="">Все эндпоинты</option>
|
||||
{endpoints?.map((ep) => (
|
||||
<option key={ep.id} value={ep.id}>{ep.name} ({ep.method} {ep.path})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Ключ</label>
|
||||
<select
|
||||
value={filters.api_key_id}
|
||||
onChange={(e) => setFilters({ ...filters, api_key_id: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="">Все ключи</option>
|
||||
{apiKeys?.map((key) => (
|
||||
<option key={key.id} value={key.id}>{key.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{logs?.map((log: any) => (
|
||||
<div key={log.id} className="card p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className={`px-2 py-1 text-xs font-semibold rounded ${
|
||||
log.response_status >= 200 && log.response_status < 300
|
||||
? 'bg-green-100 text-green-700'
|
||||
: log.response_status >= 400
|
||||
? 'bg-red-100 text-red-700'
|
||||
: 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{log.response_status}
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900">{log.method}</span>
|
||||
<span className="text-gray-600">{log.path}</span>
|
||||
{log.endpoint_name && (
|
||||
<span className="text-sm text-gray-500">→ {log.endpoint_name}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span>⏱ {log.execution_time}мс</span>
|
||||
<span>📅 {format(new Date(log.created_at), 'dd.MM.yyyy HH:mm:ss')}</span>
|
||||
{log.api_key_name && (
|
||||
<span>🔑 {log.api_key_name}</span>
|
||||
)}
|
||||
{log.ip_address && log.ip_address !== 'unknown' && (
|
||||
<span>📍 {log.ip_address}</span>
|
||||
)}
|
||||
</div>
|
||||
{log.error_message && (
|
||||
<div className="mt-2 p-2 bg-red-50 rounded text-sm text-red-700">
|
||||
❌ {log.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
title="Детали"
|
||||
>
|
||||
<Eye size={18} className="text-gray-600" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Удалить этот лог?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(log.id);
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="p-2 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={18} className="text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{logs?.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500">Логи не найдены</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedLog && (
|
||||
<LogDetailModal log={selectedLog} onClose={() => setSelectedLog(null)} />
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
type={dialog.type}
|
||||
onConfirm={dialog.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogDetailModal({ log, onClose }: { log: any; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-4xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-gray-900">Детали лога</h2>
|
||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Метод</label>
|
||||
<p className="text-gray-900">{log.method}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Путь</label>
|
||||
<p className="text-gray-900">{log.path}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Статус</label>
|
||||
<p className="text-gray-900">{log.response_status}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Время выполнения</label>
|
||||
<p className="text-gray-900">{log.execution_time}мс</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Эндпоинт</label>
|
||||
<p className="text-gray-900">{log.endpoint_name || 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">API Ключ</label>
|
||||
<p className="text-gray-900">{log.api_key_name || 'Без ключа'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">IP адрес</label>
|
||||
<p className="text-gray-900">{log.ip_address}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">Дата/время</label>
|
||||
<p className="text-gray-900">{format(new Date(log.created_at), 'dd.MM.yyyy HH:mm:ss')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{log.request_params && Object.keys(log.request_params).length > 0 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-2 block">Параметры запроса</label>
|
||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{JSON.stringify(log.request_params, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.request_body && Object.keys(log.request_body).length > 0 && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-2 block">Тело запроса</label>
|
||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{JSON.stringify(log.request_body, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 mb-2 block">Ответ</label>
|
||||
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto text-sm">
|
||||
{JSON.stringify(log.response_data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{log.error_message && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-red-700 mb-2 block">Ошибка</label>
|
||||
<div className="bg-red-50 border border-red-200 rounded p-4 text-red-700">
|
||||
{log.error_message}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{log.user_agent && log.user_agent !== 'unknown' && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700">User Agent</label>
|
||||
<p className="text-sm text-gray-600">{log.user_agent}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-gray-200">
|
||||
<button onClick={onClose} className="btn btn-secondary">
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
868
frontend/src/pages/Settings.tsx
Normal file
868
frontend/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,868 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { usersApi, dbManagementApi } from '@/services/api';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import toast from 'react-hot-toast';
|
||||
import { User, Lock, UserCircle, Database, Plus, Edit2, Trash2, Eye, EyeOff, Users } from 'lucide-react';
|
||||
import Dialog from '@/components/Dialog';
|
||||
|
||||
export default function Settings() {
|
||||
const { user } = useAuthStore();
|
||||
const [activeTab, setActiveTab] = useState<'profile' | 'password' | 'global'>('profile');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">Настройки</h1>
|
||||
<p className="text-gray-600">Управление профилем и настройками</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-4 px-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('profile')}
|
||||
className={`py-4 px-2 border-b-2 font-medium transition-colors ${
|
||||
activeTab === 'profile'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<User className="inline mr-2" size={18} />
|
||||
Профиль
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('password')}
|
||||
className={`py-4 px-2 border-b-2 font-medium transition-colors ${
|
||||
activeTab === 'password'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Lock className="inline mr-2" size={18} />
|
||||
Смена пароля
|
||||
</button>
|
||||
{user?.is_superadmin && (
|
||||
<button
|
||||
onClick={() => setActiveTab('global')}
|
||||
className={`py-4 px-2 border-b-2 font-medium transition-colors ${
|
||||
activeTab === 'global'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Database className="inline mr-2" size={18} />
|
||||
Глобальные настройки
|
||||
</button>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{activeTab === 'profile' && user && <ProfileTab currentUser={user} />}
|
||||
{activeTab === 'password' && user && <PasswordTab currentUser={user} />}
|
||||
{activeTab === 'global' && user?.is_superadmin && <GlobalSettingsTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileTab({ currentUser }: { currentUser: any }) {
|
||||
const { setUser } = useAuthStore();
|
||||
const [formData, setFormData] = useState({
|
||||
username: currentUser?.username || '',
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => usersApi.update(currentUser?.id, data),
|
||||
onSuccess: (response) => {
|
||||
toast.success('Профиль обновлен');
|
||||
setUser(response.data);
|
||||
},
|
||||
onError: () => toast.error('Ошибка обновления профиля'),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
updateMutation.mutate(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="bg-primary-100 p-4 rounded-full">
|
||||
<UserCircle size={48} className="text-primary-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">{currentUser?.username}</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{currentUser?.is_superadmin ? 'Супер-администратор' : currentUser?.role === 'admin' ? 'Администратор' : 'Пользователь'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<User className="inline mr-2" size={16} />
|
||||
Логин
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Логин"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{updateMutation.isPending ? 'Сохранение...' : 'Сохранить изменения'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordTab({ currentUser }: { currentUser: any }) {
|
||||
const [formData, setFormData] = useState({
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (data: any) => usersApi.update(currentUser?.id, data),
|
||||
onSuccess: () => {
|
||||
toast.success('Пароль изменен');
|
||||
setFormData({ newPassword: '', confirmPassword: '' });
|
||||
},
|
||||
onError: () => toast.error('Ошибка смены пароля'),
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (formData.newPassword !== formData.confirmPassword) {
|
||||
toast.error('Пароли не совпадают');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.newPassword.length < 4) {
|
||||
toast.error('Пароль должен быть минимум 4 символа');
|
||||
return;
|
||||
}
|
||||
|
||||
updateMutation.mutate({ password: formData.newPassword });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-2xl">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800">
|
||||
⚠️ После смены пароля вам потребуется войти заново
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Lock className="inline mr-2" size={16} />
|
||||
Новый пароль
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.newPassword}
|
||||
onChange={(e) => setFormData({ ...formData, newPassword: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Введите новый пароль"
|
||||
minLength={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
<Lock className="inline mr-2" size={16} />
|
||||
Подтверждение пароля
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => setFormData({ ...formData, confirmPassword: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Повторите новый пароль"
|
||||
minLength={4}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateMutation.isPending}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
{updateMutation.isPending ? 'Сохранение...' : 'Изменить пароль'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobalSettingsTab() {
|
||||
const [subTab, setSubTab] = useState<'databases' | 'users'>('databases');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="flex gap-4">
|
||||
<button
|
||||
onClick={() => setSubTab('databases')}
|
||||
className={`py-3 px-2 border-b-2 font-medium transition-colors ${
|
||||
subTab === 'databases'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Database className="inline mr-2" size={16} />
|
||||
Базы данных
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSubTab('users')}
|
||||
className={`py-3 px-2 border-b-2 font-medium transition-colors ${
|
||||
subTab === 'users'
|
||||
? 'border-primary-600 text-primary-600'
|
||||
: 'border-transparent text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Users className="inline mr-2" size={16} />
|
||||
Пользователи
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{subTab === 'databases' && <DatabasesSubTab />}
|
||||
{subTab === 'users' && <UsersSubTab />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DatabasesSubTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingDatabase, setEditingDatabase] = useState<any | null>(null);
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
});
|
||||
|
||||
const { data: databases, isLoading } = useQuery({
|
||||
queryKey: ['databases'],
|
||||
queryFn: () => dbManagementApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => dbManagementApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||
toast.success('База данных удалена');
|
||||
},
|
||||
onError: () => toast.error('Не удалось удалить базу данных'),
|
||||
});
|
||||
|
||||
const testConnection = async (databaseId: string) => {
|
||||
try {
|
||||
const { data } = await dbManagementApi.test(databaseId);
|
||||
if (data.success) {
|
||||
toast.success('Подключение успешно!');
|
||||
} else {
|
||||
toast.error('Ошибка подключения');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Ошибка тестирования подключения');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (database: any) => {
|
||||
setEditingDatabase(database);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingDatabase(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Вы уверены, что хотите удалить эту базу данных?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">Базы данных</h3>
|
||||
<p className="text-sm text-gray-600">Управление подключениями к базам данных</p>
|
||||
</div>
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Добавить базу данных
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{databases?.map((db) => (
|
||||
<div key={db.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Database size={20} className="text-primary-600" />
|
||||
<h4 className="font-semibold text-gray-900">{db.name}</h4>
|
||||
<span className="text-xs bg-gray-100 px-2 py-1 rounded">{db.type}</span>
|
||||
{db.is_active && (
|
||||
<span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded">Активна</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-gray-600 ml-8">
|
||||
<div>Хост: <span className="font-medium text-gray-900">{db.host}:{db.port}</span></div>
|
||||
<div>База: <span className="font-medium text-gray-900">{db.database_name}</span></div>
|
||||
<div>Пользователь: <span className="font-medium text-gray-900">{db.username}</span></div>
|
||||
<div>Пароль: <span className="font-medium text-gray-900">••••••••</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => testConnection(db.id)}
|
||||
className="btn btn-secondary text-sm"
|
||||
>
|
||||
Тест
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(db)}
|
||||
className="btn btn-secondary text-sm flex items-center gap-1"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(db.id)}
|
||||
className="p-2 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={18} className="text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{databases?.length === 0 && (
|
||||
<div className="text-center py-12 border border-gray-200 rounded-lg">
|
||||
<p className="text-gray-500">Базы данных не настроены. Добавьте первую базу данных.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<DatabaseModal
|
||||
database={editingDatabase}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
type={dialog.type}
|
||||
onConfirm={dialog.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DatabaseModal({
|
||||
database,
|
||||
onClose,
|
||||
}: {
|
||||
database: any | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
name: database?.name || '',
|
||||
type: database?.type || 'postgresql',
|
||||
host: database?.host || 'localhost',
|
||||
port: database?.port || 5432,
|
||||
database_name: database?.database_name || '',
|
||||
username: database?.username || '',
|
||||
password: '',
|
||||
ssl: database?.ssl || false,
|
||||
is_active: database?.is_active !== undefined ? database.is_active : true,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
const payload = { ...data };
|
||||
// Если редактируем и пароль пустой, удаляем его из payload
|
||||
if (database && !payload.password) {
|
||||
delete payload.password;
|
||||
}
|
||||
return database ? dbManagementApi.update(database.id, payload) : dbManagementApi.create(payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['databases'] });
|
||||
toast.success(database ? 'База данных обновлена' : 'База данных создана');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Не удалось сохранить базу данных');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{database ? 'Редактировать базу данных' : 'Добавить базу данных'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Название</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="Основная база PostgreSQL"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Тип</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="postgresql">PostgreSQL</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Порт</label>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
value={formData.port}
|
||||
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) })}
|
||||
className="input w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Хост</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.host}
|
||||
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="localhost"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Имя базы данных</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.database_name}
|
||||
onChange={(e) => setFormData({ ...formData, database_name: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="my_database"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Пользователь</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="postgres"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required={!database}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="input w-full pr-10"
|
||||
placeholder={database ? 'Оставьте пустым, чтобы не менять' : 'Введите пароль'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{database && (
|
||||
<p className="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы не менять пароль</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.ssl}
|
||||
onChange={(e) => setFormData({ ...formData, ssl: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Использовать SSL</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_active}
|
||||
onChange={(e) => setFormData({ ...formData, is_active: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm text-gray-700">Активна</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">
|
||||
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersSubTab() {
|
||||
const queryClient = useQueryClient();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<any | null>(null);
|
||||
const [dialog, setDialog] = useState<{
|
||||
isOpen: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'alert' | 'confirm';
|
||||
onConfirm?: () => void;
|
||||
}>({
|
||||
isOpen: false,
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'alert',
|
||||
});
|
||||
|
||||
const { data: users, isLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => usersApi.getAll().then(res => res.data),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => usersApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success('Пользователь удален');
|
||||
},
|
||||
onError: () => toast.error('Не удалось удалить пользователя'),
|
||||
});
|
||||
|
||||
const handleEdit = (user: any) => {
|
||||
setEditingUser(user);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingUser(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setDialog({
|
||||
isOpen: true,
|
||||
title: 'Подтверждение',
|
||||
message: 'Вы уверены, что хотите удалить этого пользователя?',
|
||||
type: 'confirm',
|
||||
onConfirm: () => {
|
||||
deleteMutation.mutate(id);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">Пользователи</h3>
|
||||
<p className="text-sm text-gray-600">Управление пользователями системы</p>
|
||||
</div>
|
||||
<button onClick={handleCreate} className="btn btn-primary flex items-center gap-2">
|
||||
<Plus size={20} />
|
||||
Добавить пользователя
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mx-auto"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{users?.map((user: any) => (
|
||||
<div key={user.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<UserCircle size={20} className="text-primary-600" />
|
||||
<h4 className="font-semibold text-gray-900">{user.username}</h4>
|
||||
{user.is_superadmin && (
|
||||
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-1 rounded">Супер-администратор</span>
|
||||
)}
|
||||
{!user.is_superadmin && user.role === 'admin' && (
|
||||
<span className="text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded">Администратор</span>
|
||||
)}
|
||||
{!user.is_superadmin && user.role !== 'admin' && (
|
||||
<span className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded">Пользователь</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600 ml-8">
|
||||
Создан: {new Date(user.created_at).toLocaleDateString('ru-RU')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(user)}
|
||||
className="btn btn-secondary text-sm flex items-center gap-1"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
Редактировать
|
||||
</button>
|
||||
{!user.is_superadmin && (
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="p-2 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="Удалить"
|
||||
>
|
||||
<Trash2 size={18} className="text-red-600" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{users?.length === 0 && (
|
||||
<div className="text-center py-12 border border-gray-200 rounded-lg">
|
||||
<p className="text-gray-500">Пользователи не найдены.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showModal && (
|
||||
<UserModal
|
||||
user={editingUser}
|
||||
onClose={() => setShowModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
isOpen={dialog.isOpen}
|
||||
onClose={() => setDialog({ ...dialog, isOpen: false })}
|
||||
title={dialog.title}
|
||||
message={dialog.message}
|
||||
type={dialog.type}
|
||||
onConfirm={dialog.onConfirm}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UserModal({
|
||||
user,
|
||||
onClose,
|
||||
}: {
|
||||
user: any | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
username: user?.username || '',
|
||||
password: '',
|
||||
role: user?.role || 'admin',
|
||||
is_superadmin: user?.is_superadmin || false,
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (data: any) => {
|
||||
const payload = { ...data };
|
||||
// Если редактируем и пароль пустой, удаляем его из payload
|
||||
if (user && !payload.password) {
|
||||
delete payload.password;
|
||||
}
|
||||
return user ? usersApi.update(user.id, payload) : usersApi.create(payload);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
toast.success(user ? 'Пользователь обновлен' : 'Пользователь создан');
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || 'Не удалось сохранить пользователя');
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
saveMutation.mutate(formData);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg max-w-lg w-full">
|
||||
<div className="p-6 border-b border-gray-200">
|
||||
<h2 className="text-2xl font-bold text-gray-900">
|
||||
{user ? 'Редактировать пользователя' : 'Добавить пользователя'}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Логин</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.username}
|
||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||
className="input w-full"
|
||||
placeholder="admin"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required={!user}
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="input w-full pr-10"
|
||||
placeholder={user ? 'Оставьте пустым, чтобы не менять' : 'Введите пароль'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
{user && (
|
||||
<p className="text-xs text-gray-500 mt-1">Оставьте пустым, чтобы не менять пароль</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Роль</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value })}
|
||||
className="input w-full"
|
||||
>
|
||||
<option value="admin">Администратор</option>
|
||||
<option value="user">Пользователь</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_superadmin"
|
||||
checked={formData.is_superadmin}
|
||||
onChange={(e) => setFormData({ ...formData, is_superadmin: e.target.checked })}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="is_superadmin" className="text-sm text-gray-700">
|
||||
Сделать суперадмином (полный доступ к системе)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||
<button type="button" onClick={onClose} className="btn btn-secondary">
|
||||
Отмена
|
||||
</button>
|
||||
<button type="submit" disabled={saveMutation.isPending} className="btn btn-primary">
|
||||
{saveMutation.isPending ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
171
frontend/src/services/api.ts
Normal file
171
frontend/src/services/api.ts
Normal 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;
|
||||
31
frontend/src/stores/authStore.ts
Normal file
31
frontend/src/stores/authStore.ts
Normal 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 });
|
||||
},
|
||||
}));
|
||||
87
frontend/src/types/index.ts
Normal file
87
frontend/src/types/index.ts
Normal 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
6
frontend/src/utils/cn.ts
Normal 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));
|
||||
}
|
||||
22
frontend/src/utils/monacoLoader.ts
Normal file
22
frontend/src/utils/monacoLoader.ts
Normal 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 };
|
||||
38
frontend/tailwind.config.js
Normal file
38
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,38 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
dark: {
|
||||
50: '#f8fafc',
|
||||
100: '#f1f5f9',
|
||||
200: '#e2e8f0',
|
||||
300: '#cbd5e1',
|
||||
400: '#94a3b8',
|
||||
500: '#64748b',
|
||||
600: '#475569',
|
||||
700: '#334155',
|
||||
800: '#1e293b',
|
||||
900: '#0f172a',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
32
frontend/vite.config.ts
Normal file
32
frontend/vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react()
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
'monaco-editor': ['monaco-editor'],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
372
package-lock.json
generated
Normal file
372
package-lock.json
generated
Normal file
@@ -0,0 +1,372 @@
|
||||
{
|
||||
"name": "kis-api-builder",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "kis-api-builder",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.28.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
|
||||
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.1.0",
|
||||
"supports-color": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk/node_modules/supports-color": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
|
||||
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"wrap-ansi": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concurrently": {
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/concurrently/-/concurrently-8.2.2.tgz",
|
||||
"integrity": "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^4.1.2",
|
||||
"date-fns": "^2.30.0",
|
||||
"lodash": "^4.17.21",
|
||||
"rxjs": "^7.8.1",
|
||||
"shell-quote": "^1.8.1",
|
||||
"spawn-command": "0.0.2",
|
||||
"supports-color": "^8.1.1",
|
||||
"tree-kill": "^1.2.2",
|
||||
"yargs": "^17.7.2"
|
||||
},
|
||||
"bin": {
|
||||
"conc": "dist/bin/concurrently.js",
|
||||
"concurrently": "dist/bin/concurrently.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.13.0 || >=16.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "2.30.0",
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz",
|
||||
"integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.21.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.11"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/date-fns"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/has-flag": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/shell-quote": {
|
||||
"version": "1.8.3",
|
||||
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
|
||||
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/spawn-command": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/spawn-command/-/spawn-command-0.0.2.tgz",
|
||||
"integrity": "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/supports-color": {
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
|
||||
"integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"has-flag": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/supports-color?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tree-kill": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||
"integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tree-kill": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
|
||||
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "17.7.2",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
|
||||
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^8.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"require-directory": "^2.1.1",
|
||||
"string-width": "^4.2.3",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^21.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "21.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
|
||||
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
package.json
Normal file
21
package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "kis-api-builder",
|
||||
"version": "1.0.0",
|
||||
"description": "API Builder - Dynamic API Construction System",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"install:all": "npm install && cd backend && npm install && cd ../frontend && npm install",
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
"dev:frontend": "cd frontend && npm run dev",
|
||||
"dev": "concurrently \"npm run dev:backend\" \"npm run dev:frontend\" --names \"backend,frontend\" --prefix-colors \"blue,green\"",
|
||||
"build:frontend": "cd frontend && npm run build",
|
||||
"build:backend": "cd backend && npm run build",
|
||||
"build": "npm run build:backend && npm run build:frontend",
|
||||
"start": "cd backend && NODE_ENV=production npm start",
|
||||
"start:prod": "npm run build && npm run start",
|
||||
"migrate": "cd backend && npm run migrate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^8.2.2"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user